diff --git a/src/components/leaflet-map.ts b/src/components/leaflet-map.ts index 9c19756..7a18920 100644 --- a/src/components/leaflet-map.ts +++ b/src/components/leaflet-map.ts @@ -1,170 +1,81 @@ import { Icon, Map as LMap, MapOptions } from 'leaflet'; import { LeafletRegisterEvent } from '../core/register.ts'; +import { defineProps, num, off, on, type NumProp, type PropDef } from '../core/props.ts'; const DEFAULT_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; const DEFAULT_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY='; const CSS_ATTRS = ['css-url', 'css-integrity', 'css-crossorigin'] as const; -// ── Prop types ───────────────────────────────────────────────────────────── - -type NumProp = { - kind: 'num'; - attr: string; - default: number; - viewState?: true; - mapGet?: (m: LMap) => number | undefined; - mapSet?: (m: LMap, v: number) => void; - event?: string; -}; - -type BoolOffProp = { - kind: 'bool-off'; - attr: string; - mapSet?: (m: LMap, enabled: boolean) => void; -}; - -type BoolOnProp = { - kind: 'bool-on'; - attr: string; -}; - -type PropDef = NumProp | BoolOffProp | BoolOnProp; - // ── Property table ───────────────────────────────────────────────────────── -const PROPS = { +const PROPS = defineProps({ // View state — excluded from #buildOptions, initialised via setView() - lat: { - kind: 'num', - attr: 'lat', - default: 0, + lat: num(0, { viewState: true, event: 'moveend', mapGet: (m: LMap) => m.getCenter()?.lat, mapSet: (m: LMap, v: number) => m.setView([v, m.getCenter()?.lng ?? 0], m.getZoom()), - }, - lng: { - kind: 'num', - attr: 'lng', - default: 0, + }), + lng: num(0, { viewState: true, event: 'moveend', mapGet: (m: LMap) => m.getCenter()?.lng, mapSet: (m: LMap, v: number) => m.setView([m.getCenter()?.lat ?? 0, v], m.getZoom()), - }, - zoom: { - kind: 'num', - attr: 'zoom', - default: 2, + }), + zoom: num(2, { viewState: true, event: 'zoomend', mapGet: (m: LMap) => m.getZoom(), mapSet: (m: LMap, v: number) => m.setZoom(v), - }, + }), // Live numeric options — have Leaflet setters - minZoom: { - kind: 'num', - attr: 'min-zoom', - default: 0, + minZoom: num(0, { mapGet: (m: LMap) => m.getMinZoom(), mapSet: (m: LMap, v: number) => m.setMinZoom(v), - }, - maxZoom: { - kind: 'num', - attr: 'max-zoom', - default: Infinity, + }), + maxZoom: num(Infinity, { mapGet: (m: LMap) => m.getMaxZoom(), mapSet: (m: LMap, v: number) => m.setMaxZoom(v), - }, + }), // Constructor-only numeric options - zoomSnap: { kind: 'num', attr: 'zoom-snap', default: 1 }, - zoomDelta: { kind: 'num', attr: 'zoom-delta', default: 1 }, - keyboardPanDelta: { kind: 'num', attr: 'keyboard-pan-delta', default: 80 }, - wheelDebounceTime: { kind: 'num', attr: 'wheel-debounce-time', default: 40 }, - wheelPxPerZoomLevel: { - kind: 'num', - attr: 'wheel-px-per-zoom-level', - default: 60, - }, - inertiaDeceleration: { - kind: 'num', - attr: 'inertia-deceleration', - default: 3000, - }, - inertiaMaxSpeed: { - kind: 'num', - attr: 'inertia-max-speed', - default: Infinity, - }, - easeLinearity: { kind: 'num', attr: 'ease-linearity', default: 0.2 }, - maxBoundsViscosity: { kind: 'num', attr: 'max-bounds-viscosity', default: 0 }, - tapTolerance: { kind: 'num', attr: 'tap-tolerance', default: 15 }, - zoomAnimationThreshold: { - kind: 'num', - attr: 'zoom-animation-threshold', - default: 4, - }, - transform3DLimit: { - kind: 'num', - attr: 'transform-3d-limit', - default: 8388608, - }, + zoomSnap: num(1), + zoomDelta: num(1), + keyboardPanDelta: num(80), + wheelDebounceTime: num(40), + wheelPxPerZoomLevel: num(60), + inertiaDeceleration: num(3000), + inertiaMaxSpeed: num(Infinity), + easeLinearity: num(0.2), + maxBoundsViscosity: num(0), + tapTolerance: num(15), + zoomAnimationThreshold: num(4), + transform3DLimit: num(8388608), // Boolean defaults-true → disable-* attribute; handler-based options have live mapSet - scrollWheelZoom: { - kind: 'bool-off', - attr: 'disable-scroll-wheel-zoom', - mapSet: (m: LMap, v: boolean) => (v ? m.scrollWheelZoom.enable() : m.scrollWheelZoom.disable()), - }, - dragging: { - kind: 'bool-off', - attr: 'disable-dragging', - mapSet: (m: LMap, v: boolean) => (v ? m.dragging.enable() : m.dragging.disable()), - }, - touchZoom: { - kind: 'bool-off', - attr: 'disable-touch-zoom', - mapSet: (m: LMap, v: boolean) => (v ? m.touchZoom.enable() : m.touchZoom.disable()), - }, - doubleClickZoom: { - kind: 'bool-off', - attr: 'disable-double-click-zoom', - mapSet: (m: LMap, v: boolean) => (v ? m.doubleClickZoom.enable() : m.doubleClickZoom.disable()), - }, - boxZoom: { - kind: 'bool-off', - attr: 'disable-box-zoom', - mapSet: (m: LMap, v: boolean) => (v ? m.boxZoom.enable() : m.boxZoom.disable()), - }, - keyboard: { - kind: 'bool-off', - attr: 'disable-keyboard', - mapSet: (m: LMap, v: boolean) => (v ? m.keyboard.enable() : m.keyboard.disable()), - }, - closePopupOnClick: { kind: 'bool-off', attr: 'disable-close-popup-on-click' }, - trackResize: { kind: 'bool-off', attr: 'disable-track-resize' }, - zoomControl: { kind: 'bool-off', attr: 'disable-zoom-control' }, - attributionControl: { kind: 'bool-off', attr: 'disable-attribution-control' }, - inertia: { kind: 'bool-off', attr: 'disable-inertia' }, - zoomAnimation: { kind: 'bool-off', attr: 'disable-zoom-animation' }, - fadeAnimation: { kind: 'bool-off', attr: 'disable-fade-animation' }, - markerZoomAnimation: { - kind: 'bool-off', - attr: 'disable-marker-zoom-animation', - }, - bounceAtZoomLimits: { - kind: 'bool-off', - attr: 'disable-bounce-at-zoom-limits', - }, - tapHold: { kind: 'bool-off', attr: 'disable-tap-hold' }, + scrollWheelZoom: off((m: LMap, v: boolean) => (v ? m.scrollWheelZoom.enable() : m.scrollWheelZoom.disable())), + dragging: off((m: LMap, v: boolean) => (v ? m.dragging.enable() : m.dragging.disable())), + touchZoom: off((m: LMap, v: boolean) => (v ? m.touchZoom.enable() : m.touchZoom.disable())), + doubleClickZoom: off((m: LMap, v: boolean) => (v ? m.doubleClickZoom.enable() : m.doubleClickZoom.disable())), + boxZoom: off((m: LMap, v: boolean) => (v ? m.boxZoom.enable() : m.boxZoom.disable())), + keyboard: off((m: LMap, v: boolean) => (v ? m.keyboard.enable() : m.keyboard.disable())), + closePopupOnClick: off(), + trackResize: off(), + zoomControl: off(), + attributionControl: off(), + inertia: off(), + zoomAnimation: off(), + fadeAnimation: off(), + markerZoomAnimation: off(), + bounceAtZoomLimits: off(), + tapHold: off(), // Boolean defaults-false → normal attribute - preferCanvas: { kind: 'bool-on', attr: 'prefer-canvas' }, - worldCopyJump: { kind: 'bool-on', attr: 'world-copy-jump' }, -} satisfies Record; + preferCanvas: on(), + worldCopyJump: on(), +}); type PropName = keyof typeof PROPS; @@ -172,17 +83,12 @@ const ATTR_TO_PROP = new globalThis.Map( (Object.entries(PROPS) as [PropName, PropDef][]).map(([name, spec]) => [spec.attr, name]), ); -// ── Element ──────────────────────────────────────────────────────────────── - // Derive property types directly from the PROPS table so adding a prop to the // table automatically makes it part of the LeafletMap instance type. type PropTypes = { [K in keyof typeof PROPS]: (typeof PROPS)[K] extends { kind: 'num' } ? number : boolean; }; -// Cast HTMLElement to a typed base whose instances include PropTypes. This is -// the mixin pattern — TypeScript sees LeafletMap as having all prop types, the -// runtime still extends HTMLElement, and no interface merge is required. const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; export default class LeafletMap extends TypedBase { diff --git a/src/core/props.ts b/src/core/props.ts index 7aef835..61c77b9 100644 --- a/src/core/props.ts +++ b/src/core/props.ts @@ -2,6 +2,10 @@ export type NumProp = { kind: 'num'; attr: string; default: number; + mapGet?: (m: any) => number | undefined; + mapSet?: (m: any, v: number) => void; + viewState?: boolean; + event?: string; }; export type StrProp = { @@ -18,6 +22,7 @@ export type BoolOnProp = { export type BoolOffProp = { kind: 'bool-off'; attr: string; + mapSet?: (m: any, enabled: boolean) => void; }; export type PropDef = NumProp | StrProp | BoolOnProp | BoolOffProp; @@ -39,20 +44,26 @@ type OptionalAttr = Omit & { attr?: string }; type NumPropInput = OptionalAttr; type StrPropInput = OptionalAttr; type BoolOnPropInput = OptionalAttr; +type BoolOffPropInput = OptionalAttr; -type PropDefInput = NumPropInput | StrPropInput | BoolOnPropInput; +type PropDefInput = NumPropInput | StrPropInput | BoolOnPropInput | BoolOffPropInput; type PropDefFromInput = T extends { kind: 'num' } ? NumProp : T extends { kind: 'str' } ? StrProp + : T extends { kind: 'bool-off' } ? BoolOffProp : BoolOnProp; function camelToKebab(s: string): string { return s.replace(/[A-Z]/g, c => '-' + c.toLowerCase()); } -export function num(def: number = 0, attr?: string): NumPropInput { - return { kind: 'num', default: def, ...(attr ? { attr } : {}) }; +export function num( + def: number = 0, + opts?: string | (Omit & { attr?: string }), +): NumPropInput { + if (typeof opts === 'string') opts = { attr: opts }; + return { kind: 'num', default: def, ...opts }; } export function str(def: string = '', attr?: string): StrPropInput { @@ -63,6 +74,10 @@ export function on(attr?: string): BoolOnPropInput { return { kind: 'bool-on', ...(attr ? { attr } : {}) }; } +export function off(mapSet?: (m: any, enabled: boolean) => void): BoolOffPropInput { + return { kind: 'bool-off', ...(mapSet ? { mapSet } : {}) }; +} + export function defineProps>( input: T, ): { [K in keyof T]: PropDefFromInput } { @@ -70,7 +85,8 @@ export function defineProps>( for (const key of Object.keys(input)) { const val = input[key] as PropDefInput; const { attr: override, ...rest } = val as any; - output[key] = { ...rest, attr: override ?? camelToKebab(key) } as PropDef; + const attr = override ?? (rest.kind === 'bool-off' ? 'disable-' + camelToKebab(key) : camelToKebab(key)); + output[key] = { ...rest, attr } as PropDef; } return output as any; }