diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 231bb66..1dd1f84 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -12,6 +12,11 @@ module.exports = { plugins: ['@typescript-eslint'], root: true, + rules: { + '@typescript-eslint/no-inferrable-types': 'off', + 'prefer-const': ['error', {destructuring: 'all'}] + }, + overrides: [ { files: ['**/*.js', '**/*.mjs', '**/*.cjs'], diff --git a/README.md b/README.md index aa67235..19a7949 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,16 @@ contains information about the map (all camera parameters and current map bounds), the marker (interaction state, map visibility, ...), other attributes and user-specified data. +### Adding new Attributes + +To add a new attribute + +1. add the attribute name to `attributeKeys` +2. add the name and type to the StaticAttributes type definition +3. add declarations for the attribute to the Marker class and the + ComputedMarkerAttributes class +4. implement the attribute logic within the `update()` function + ## API ### constructor diff --git a/src/editor/init-editor.ts b/src/editor/init-editor.ts index 51d39fb..99414b6 100644 --- a/src/editor/init-editor.ts +++ b/src/editor/init-editor.ts @@ -1,9 +1,10 @@ -import {editor, languages, KeyCode, KeyMod, Uri} from 'monaco-editor'; +import {editor, KeyCode, KeyMod, languages, Uri} from 'monaco-editor'; import {decode, encode} from './snippet-encoder'; import './worker-config'; import googleMapsDTSSource from '../../node_modules/@types/google.maps/index.d.ts?raw'; import markerExampleSource from '../../examples/00.default.ts?raw'; +import {assertNotNull} from '../lib/util'; const libModules = import.meta.glob('../../examples/lib/*.d.ts', {as: 'raw'}); const modules: Record = { @@ -33,7 +34,7 @@ export async function initEditor( }) ); - for (let [path, source] of Object.entries(modules)) { + for (const [path, source] of Object.entries(modules)) { typescriptDefaults.addExtraLib(source, `file:///${path}`); } @@ -73,7 +74,7 @@ export async function initEditor( id: 'compile-and-run', label: 'Compile and run', keybindings: [KeyMod.CtrlCmd | KeyCode.Enter], - async run(editor) { + async run() { const worker = await typescript.getTypeScriptWorker(); const proxy = await worker(model.uri); @@ -84,13 +85,18 @@ export async function initEditor( } }); - const runButton = document.querySelector('#btn-compile-and-run')!; + const runButton = document.querySelector('#btn-compile-and-run'); + assertNotNull(runButton, 'run button not fond'); + runButton.addEventListener('click', () => { editorInstance .getAction('compile-and-run') .run() .then(() => { - console.log('compie and run completed.'); + console.log('compile-and-run completed.'); + }) + .catch(err => { + console.error('compile-and-run failed', err); }); }); @@ -103,8 +109,7 @@ export async function initEditor( if (!tsCode) return; - const encoded = encode({code: tsCode, version: API_VERSION}); - location.hash = encoded; + location.hash = encode({code: tsCode, version: API_VERSION}); } }); diff --git a/src/editor/run-playground-js.ts b/src/editor/run-playground-js.ts index fbc4a9e..8ab1ac4 100644 --- a/src/editor/run-playground-js.ts +++ b/src/editor/run-playground-js.ts @@ -3,12 +3,12 @@ import * as marker from '../lib/marker'; import * as icons from '../lib/icons'; import * as color from '../lib/color'; -let markers: Set = new Set(); -let cleanupFn: (() => void) | null = null; +const markers: Set = new Set(); +let cleanupFn: (() => void) | void = void 0; export function runPlaygroundJs(js: string, map: google.maps.Map) { // remove all markers - for (let m of markers) m.map = null; + for (const m of markers) m.map = null; markers.clear(); // if the last setup left a cleanup-function behind, @@ -16,21 +16,22 @@ export function runPlaygroundJs(js: string, map: google.maps.Map) { if (cleanupFn) cleanupFn(); // wrap code in a function with exports and require + // eslint-disable-next-line @typescript-eslint/no-implied-eval const tmpFn = new Function('exports', 'require', js); - const exports: any = {}; + const exports: {default?: (map: google.maps.Map) => (() => void) | void} = {}; // we need a proxy for the Marker class to keep track of markers // added to the map, so they don't have to be removed manually class MarkerProxy extends Marker { - constructor(...args: any[]) { + constructor(...args: never[]) { super(...args); markers.add(this); } } - const modules: Record = { + const modules: Record = { './lib/marker': marker, './lib/color': color, './lib/icons': icons diff --git a/src/editor/snippet-encoder.ts b/src/editor/snippet-encoder.ts index 4cffbb9..f64f503 100644 --- a/src/editor/snippet-encoder.ts +++ b/src/editor/snippet-encoder.ts @@ -5,7 +5,7 @@ export function encode(data: SavedCodeSnippetData): string { const p = new URLSearchParams(); p.set('v', version); - p.set('c', btoa(code)); + p.set('c', window.btoa(code)); return p.toString(); } @@ -14,11 +14,11 @@ export function decode(encoded: string): SavedCodeSnippetData { const p = new URLSearchParams(encoded); const base64 = p.get('c'); - let version = p.get('v') || '0.0.0'; + const version = p.get('v') || '0.0.0'; if (!base64) { return {version, code: ''}; } - return {code: atob(base64), version}; + return {code: window.atob(base64), version}; } diff --git a/src/editor/worker-config.ts b/src/editor/worker-config.ts index ef4a0c3..94e1957 100644 --- a/src/editor/worker-config.ts +++ b/src/editor/worker-config.ts @@ -4,9 +4,8 @@ import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'; import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'; import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; -// @ts-ignore self.MonacoEnvironment = { - getWorker(_: any, label: string) { + getWorker(_: unknown, label: string) { if (label === 'json') { return new jsonWorker(); } diff --git a/src/lib/color.ts b/src/lib/color.ts index 680acb4..5ee07be 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -73,15 +73,14 @@ function rgbToHsl([r, g, b]: RGBColor): HSLColor { const min = Math.min(r, g, b); let h; - let s; - let l = (max + min) / 2; + const l = (max + min) / 2; if (max == min) { return [0, 0, l]; // achromatic } const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); if (max === r) { h = (g - b) / d + (g < b ? 6 : 0); diff --git a/src/lib/icons.ts b/src/lib/icons.ts index f1e032f..b485de3 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -82,13 +82,13 @@ function createSpan(className: string, content: string): HTMLElement { * @param family */ function isFontLoaded(family: string): boolean { - const fontStylesheets = Array.from( + const fontStylesheets = Array.from( document.querySelectorAll( 'link[rel="stylesheet"][href*="fonts.googleapis.com"]' ) - ) as HTMLLinkElement[]; + ); - for (let stylesheet of fontStylesheets) { + for (const stylesheet of fontStylesheets) { const url = new URL(stylesheet.href); let families = url.pathname.endsWith('css2') diff --git a/src/lib/marker-collection.ts b/src/lib/marker-collection.ts index 174a968..c9e5878 100644 --- a/src/lib/marker-collection.ts +++ b/src/lib/marker-collection.ts @@ -1,30 +1,32 @@ -import type {Attributes} from './marker'; - -export class MarkerCollection> { - constructor(data: TUserData[], attributes: Attributes) {} - - static fromArray>( - data: TUserData[], - attributes: Attributes - ): MarkerCollection { - return new MarkerCollection(data, attributes); - } - - // a collection provides bindings between an array of records and - // the corresponding markers. - - // - attributes: attributes are shared with all markers, which is where - // dynamic attributes can really shine - // - // - data-updates: data in the collection can be updated after creation. - // This will assume that complete sets of records are passed on every - // update. If incremental updates are needed, those have to be applied - // to the data before updating the marker collection. - // When transitions are implemented (also for performance reasons), it - // will become important to recognize identical records, so those can be - // updated instead of re-created with every update. - // - - - // .map property: forwards to all markers - // marker.visible attribute -} +export {}; +// import type {Attributes} from './marker'; +// +// +// export class MarkerCollection> { +// constructor(data: TUserData[], attributes: Attributes) {} +// +// static fromArray>( +// data: TUserData[], +// attributes: Attributes +// ): MarkerCollection { +// return new MarkerCollection(data, attributes); +// } +// +// // a collection provides bindings between an array of records and +// // the corresponding markers. +// +// // - attributes: attributes are shared with all markers, which is where +// // dynamic attributes can really shine +// // +// // - data-updates: data in the collection can be updated after creation. +// // This will assume that complete sets of records are passed on every +// // update. If incremental updates are needed, those have to be applied +// // to the data before updating the marker collection. +// // When transitions are implemented (also for performance reasons), it +// // will become important to recognize identical records, so those can be +// // updated instead of re-created with every update. +// // - +// +// // .map property: forwards to all markers +// // marker.visible attribute +// } diff --git a/src/lib/marker.ts b/src/lib/marker.ts index 28f4cf7..5f82cc5 100644 --- a/src/lib/marker.ts +++ b/src/lib/marker.ts @@ -4,17 +4,38 @@ import { parseCssColorValue, rgbaToString } from './color'; -import {warnOnce} from './util'; +import {assertNotNull, warnOnce} from './util'; import type {IconProvider} from './icons'; +type TUserDataDefault = Record; + +// These keys are used to create the dynamic properties (mostly to save us +// from having to type them all out and to make adding attributes a bit easier). +const attributeKeys: readonly AttributeKey[] = [ + 'position', + 'draggable', + 'collisionBehavior', + 'title', + 'zIndex', + + 'color', + 'backgroundColor', + 'borderColor', + 'glyphColor', + + 'icon', + 'glyph', + 'scale' +] as const; + /** * The Marker class. * The optional type-parameter TUserData can be used to specify a type to * be used for the data specified in setData and available in the dynamic * attribute callbacks. */ -export class Marker> { +export class Marker { private static iconProviders: Map = new Map(); static registerIconProvider( provider: IconProvider, @@ -47,8 +68,10 @@ export class Marker> { // attributes set by the user are stored in attributes_ and // dynamicAttributes_. - private attributes_: Partial = {}; - private dynamicAttributes_: Partial> = {}; + readonly attributes_: Partial = {}; + readonly dynamicAttributes_: Partial> = {}; + readonly computedAttributes_ = new ComputedMarkerAttributes(this); + private mapEventListener_: google.maps.MapsEventListener | null = null; // AdvancedMarkerView and PinView instances used to render the marker @@ -165,6 +188,9 @@ export class Marker> { TKey extends AttributeKey, TValue extends Attributes[TKey] >(name: TKey, value: TValue) { + // update the marker when we're done + this.scheduleUpdate(); + if (typeof value === 'function') { this.dynamicAttributes_[name] = value as DynamicAttributes[TKey]; @@ -172,14 +198,12 @@ export class Marker> { this.attributes_[name] = value as StaticAttributes[TKey]; delete this.dynamicAttributes_[name]; } - - this.scheduleUpdate(); } /** * Internal method to get the attribute value as it was specified by * the user (e.g. will return the dynamic attribute function instead of the - * effective value. + * effective value). * @param name * @internal */ @@ -193,7 +217,8 @@ export class Marker> { } /** - * Schedules an update via microtask. + * Schedules an update via microtask. This makes sure that we won't + * run multiple updates when multiple attributes are changed sequentially. * @internal */ private scheduleUpdate() { @@ -206,42 +231,14 @@ export class Marker> { }); } - private getComputedAttributes(): Partial { - const attributes: Partial = { - ...this.attributes_ - }; - - let recursionDepth = 0; - for (const [key, callback] of Object.entries(this.dynamicAttributes_)) { - Object.defineProperty(attributes, key, { - get: () => { - recursionDepth++; - - if (recursionDepth > 10) { - throw new Error( - 'maximum recurcion depth reached. ' + - 'This is probably caused by a cyclic dependency in dynamic attributes.' - ); - } - - const res = callback({ - data: this.data_, - map: this.mapState_!, - marker: this.markerState_, - attr: attributes - }); - recursionDepth--; - - return res; - } - }); - } - - return attributes; - } - /** - * Performs an update of the pinView and markerView. + * Updates the rendered objects for this marker, typically an + * AdvancedMarkerView and PinView. + * This method is called very often, so it is critical to keep it as + * performant as possible: + * - avoid object allocations if possible + * - avoid expensive computations. These can likely be moved into + * setAttribute_ or the ComputedMarkerAttributes class * @internal */ update() { @@ -250,7 +247,7 @@ export class Marker> { return; } - const attrs = this.getComputedAttributes(); + const attrs = this.computedAttributes_; this.updatePinViewColors(attrs); @@ -333,9 +330,21 @@ export class Marker> { * @param map */ private onMapBoundsChange = (map: google.maps.Map) => { + const center = map.getCenter(); + const bounds = map.getBounds(); + + if (!center || !bounds) { + console.error( + 'Marker.onMapBoundsChange(): map center and/or bounds undefined.' + + ' Not updating map state.' + ); + + return; + } + this.mapState_ = { - center: map.getCenter()!, - bounds: map.getBounds()!, + center, + bounds, zoom: map.getZoom() || 0, heading: map.getHeading() || 0, tilt: map.getTilt() || 0 @@ -343,38 +352,109 @@ export class Marker> { this.scheduleUpdate(); }; -} -// keys used for creating the object-properties -export const attributeKeys: readonly AttributeKey[] = [ - 'position', - 'draggable', - 'collisionBehavior', - 'title', - 'zIndex', + /** + * Retrieve the parameters to be passed to dynamic attribute callbacks. + * @internal + */ + getDynamicAttributeState(): { + data: TUserData | null; + map: MapState; + marker: MarkerState; + } { + assertNotNull(this.mapState_, 'this.mapState_ is not defined'); - 'color', - 'backgroundColor', - 'borderColor', - 'glyphColor', + return { + data: this.data_, + map: this.mapState_, + marker: this.markerState_ + }; + } - 'icon', - 'glyph', - 'scale' -] as const; + static { + // set up all attributes for the prototypes of Marker and + // ComputedMarkerAttributes. For performance reasons, these are defined on + // the prototype instead of the object itself. + for (const key of attributeKeys) { + // internal Marker-properties for all attributes, note that `this` is + // bound to the marker-instance in the get/set callbacks. + Object.defineProperty(Marker.prototype, key, { + get(this: Marker) { + return this.getAttribute_(key); + }, + set(this: Marker, value) { + this.setAttribute_(key, value); + } + }); + } + } +} -// set up the internal properties for all attributes, note that `this` is -// bound to the marker-instance in the get/set callbacks. For perfromance -// reasons, these are defined on the prototype instead of the object itself. -for (const key of attributeKeys) { - Object.defineProperty(Marker.prototype, key, { - get(this: Marker) { - return this.getAttribute_(key); - }, - set(this: Marker, value) { - this.setAttribute_(key, value); +/** @internal */ +class ComputedMarkerAttributes + implements Partial +{ + private marker_: Marker; + private callbackDepth_: number = 0; + + // attributes are only declared here, they are dynamically added to the + // prototype below the class-declaration + declare position?: StaticAttributes['position']; + declare draggable?: StaticAttributes['draggable']; + declare collisionBehavior?: StaticAttributes['collisionBehavior']; + declare title?: StaticAttributes['title']; + declare zIndex?: StaticAttributes['zIndex']; + + declare glyph?: StaticAttributes['glyph']; + declare scale?: StaticAttributes['scale']; + declare color?: StaticAttributes['color']; + declare backgroundColor?: StaticAttributes['backgroundColor']; + declare borderColor?: StaticAttributes['borderColor']; + declare glyphColor?: StaticAttributes['glyphColor']; + declare icon?: StaticAttributes['icon']; + + constructor(marker: Marker) { + this.marker_ = marker; + } + + static { + for (const key of attributeKeys) { + // set up internal properties of the ComputedMarkerAttributes class, + // resolve all dynamic to static values. + Object.defineProperty(ComputedMarkerAttributes.prototype, key, { + get(this: ComputedMarkerAttributes) { + const {map, data, marker} = this.marker_.getDynamicAttributeState(); + const callback = this.marker_.dynamicAttributes_[key]; + + if (!callback) { + return this.marker_.attributes_[key]; + } else { + this.callbackDepth_++; + + if (this.callbackDepth_ > 10) { + throw new Error( + 'maximum recurcion depth reached. ' + + 'This is probably caused by a cyclic dependency in dynamic attributes.' + ); + } + + const res = callback({ + data, + map, + marker, + // forced cast to StaticAttributes; this object will behave + // exactly like the plain attributes object as far as the callbacks + // are concerned + attr: this as never as StaticAttributes + }); + this.callbackDepth_--; + + return res; + } + } + }); } - }); + } } // copied from Google Maps typings since we can't use the maps-api @@ -400,7 +480,7 @@ export enum CollisionBehavior { REQUIRED_AND_HIDES_OPTIONAL = 'REQUIRED_AND_HIDES_OPTIONAL' } -export type StaticAttributes = { +export interface StaticAttributes { position: google.maps.LatLngLiteral; draggable: boolean; collisionBehavior: CollisionBehavior; @@ -414,7 +494,7 @@ export type StaticAttributes = { icon: string; glyph: string | Element | URL; scale: number; -}; +} // just the keys for all attributes export type AttributeKey = keyof StaticAttributes; diff --git a/src/lib/util.ts b/src/lib/util.ts index 4bee2d2..2f9a396 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -1,6 +1,6 @@ const warnings = new Set(); -export function warnOnce(message: string, ...params: any[]) { +export function warnOnce(message: string, ...params: unknown[]) { if (warnings.has(message)) return; warnings.add(message); @@ -8,3 +8,12 @@ export function warnOnce(message: string, ...params: any[]) { console.warn(message, ...params); } } + +export function assertNotNull( + value: TValue, + message: string +): asserts value is NonNullable { + if (value === null || value === undefined) { + throw Error(message); + } +} diff --git a/src/load-maps-api.ts b/src/load-maps-api.ts index a1005e6..aaef4ec 100644 --- a/src/load-maps-api.ts +++ b/src/load-maps-api.ts @@ -1,14 +1,8 @@ -interface MapsApiOptions { +export type MapsApiOptions = { key: string; libraries?: string; v?: string; -} - -declare global { - interface Window { - __maps_callback__?: () => void; - } -} +}; let mapsApiLoaded: Promise | null = null; @@ -40,3 +34,9 @@ export async function loadMapsApi(apiOptions: MapsApiOptions): Promise { return mapsApiLoaded; } + +declare global { + interface Window { + __maps_callback__?: () => void; + } +} diff --git a/src/main.ts b/src/main.ts index 8da7cb9..69077a1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,12 +3,22 @@ import {Marker} from './lib/marker'; import {runPlaygroundJs} from './editor/run-playground-js'; +/* eslint "@typescript-eslint/ban-ts-comment": "off" + ---- + diabling this since in this file we're setting some global variables + for debugging purposes. +*/ async function main() { const map = await initMap(); - import('./editor/init-editor').then(({initEditor}) => - initEditor(jsCode => runPlaygroundJs(jsCode, map)) - ); + import('./editor/init-editor') + .then(async ({initEditor}) => { + const editor = await initEditor(jsCode => runPlaygroundJs(jsCode, map)); + + // @ts-ignore + window.editor = editor; + }) + .catch(err => console.error('editor init failed', err)); // @ts-ignore window.map = map; @@ -24,6 +34,7 @@ async function initMap() { libraries: 'marker' }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return new google.maps.Map(document.querySelector('#map')!, { mapId: 'bf51a910020fa25a', center: {lat: 53.55, lng: 10},