docs: add why/how comments to all src/core modules

Explain design rationale for every exported utility, mixin, registration
helper, and path-style handler
main
Buddy 3 months ago
parent 319b7bdb0c
commit 348e865dae

@ -1,6 +1,10 @@
import { Path } from 'leaflet'; import { Path } from 'leaflet';
import { parseAttributeValue } from './utils.js'; import { parseAttributeValue } from './utils.js';
// The set of HTML attribute names that map to Leaflet Path style options.
// These are handled specially because Leaflet exposes them through
// setStyle() rather than individual setter methods, and they are shared
// across many vector components (polyline, polygon, circle, rectangle).
export const PATH_STYLE_ATTRS = new Set([ export const PATH_STYLE_ATTRS = new Set([
'color', 'color',
'weight', 'weight',
@ -16,10 +20,18 @@ export const PATH_STYLE_ATTRS = new Set([
'fill-rule', 'fill-rule',
]); ]);
// Quick check for whether an attribute name is a path-style attribute.
// Used in attributeChangedCallback to decide whether to route through
// setLayerAttr or updatePathStyle.
export function isPathStyleAttr(name: string): boolean { export function isPathStyleAttr(name: string): boolean {
return PATH_STYLE_ATTRS.has(name); return PATH_STYLE_ATTRS.has(name);
} }
// Applies a single style change to a Leaflet Path by converting the
// kebab-case attribute name to camelCase and calling setStyle() with
// the parsed value. This is called instead of the generic setLayerAttr
// dispatcher because Path.setStyle is a bulk-update method that merges
// into the existing style object rather than replacing it.
export function updatePathStyle(obj: Path, name: string, value: string | null) { export function updatePathStyle(obj: Path, name: string, value: string | null) {
const key = name.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); const key = name.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
obj.setStyle({ [key]: parseAttributeValue(value) }); obj.setStyle({ [key]: parseAttributeValue(value) });

@ -1,6 +1,9 @@
import { Layer, LayerGroup, Popup, Tooltip } from 'leaflet'; import { Layer, LayerGroup, Popup, Tooltip } from 'leaflet';
import { registerWithParent } from './utils.js'; import { registerWithParent } from './utils.js';
// Custom event type for the bubbling registration protocol. Carries the
// Leaflet object and the originating element so the nearest parent can
// add it as a child layer, popup, or tooltip.
export type LeafletRegisterEvent = CustomEvent<{ export type LeafletRegisterEvent = CustomEvent<{
leafletObject: Layer; leafletObject: Layer;
element: HTMLElement; element: HTMLElement;
@ -12,10 +15,17 @@ declare global {
} }
} }
// Tracks whether a child registered as a plain layer, popup, or tooltip --
// used by the controls panel and by cleanup logic.
export type ChildEntry = { export type ChildEntry = {
type: 'layer' | 'popup' | 'tooltip'; type: 'layer' | 'popup' | 'tooltip';
}; };
// Builds the event handler for a given parent layer. On each
// leaflet-register event from a descendant, it checks the Leaflet type:
// Popup → bindPopup, Tooltip → bindTooltip, Layer → addLayer (if the
// parent supports it). The handler stops propagation so the event
// bubbles no further (the nearest parent claims the child).
export function createChildRegisterHandler(layer: Layer, children: Map<HTMLElement, ChildEntry>) { export function createChildRegisterHandler(layer: Layer, children: Map<HTMLElement, ChildEntry>) {
return function (e: LeafletRegisterEvent) { return function (e: LeafletRegisterEvent) {
const obj = e.detail.leafletObject; const obj = e.detail.leafletObject;
@ -36,9 +46,16 @@ export function createChildRegisterHandler(layer: Layer, children: Map<HTMLEleme
}; };
} }
// Module-scoped WeakMaps keyed by the parent element so we don't pollute
// component instances with #children / #handler fields. This keeps the
// data accessible to any caller that holds a reference to the element.
const childrenMap = new WeakMap<HTMLElement, Map<HTMLElement, ChildEntry>>(); const childrenMap = new WeakMap<HTMLElement, Map<HTMLElement, ChildEntry>>();
const handlerMap = new WeakMap<HTMLElement, (e: LeafletRegisterEvent) => void>(); const handlerMap = new WeakMap<HTMLElement, (e: LeafletRegisterEvent) => void>();
// Called during connectedCallback: creates the child tracker and event
// handler, wires up the listener, and registers this element with its
// own parent (so nested structures like polygon→line→point work).
// Returns the children map for the caller to keep a reference.
export function registerChildren(el: HTMLElement, layer: Layer): Map<HTMLElement, ChildEntry> { export function registerChildren(el: HTMLElement, layer: Layer): Map<HTMLElement, ChildEntry> {
const children = new Map<HTMLElement, ChildEntry>(); const children = new Map<HTMLElement, ChildEntry>();
const handler = createChildRegisterHandler(layer, children); const handler = createChildRegisterHandler(layer, children);
@ -49,6 +66,9 @@ export function registerChildren(el: HTMLElement, layer: Layer): Map<HTMLElement
return children; return children;
} }
// Called during disconnectedCallback: tears down the event listener and
// removes the WeakMap entries so both the handler and children map can
// be GC'd when the element is removed from the DOM.
export function unregisterChildren(el: HTMLElement): void { export function unregisterChildren(el: HTMLElement): void {
const handler = handlerMap.get(el); const handler = handlerMap.get(el);
if (handler) el.removeEventListener('leaflet-register', handler); if (handler) el.removeEventListener('leaflet-register', handler);
@ -56,6 +76,8 @@ export function unregisterChildren(el: HTMLElement): void {
handlerMap.delete(el); handlerMap.delete(el);
} }
// Public accessor for the children map. Used by the controls panel and
// by any component that needs to inspect its registered descendants.
export function getChildren(el: HTMLElement): Map<HTMLElement, ChildEntry> | undefined { export function getChildren(el: HTMLElement): Map<HTMLElement, ChildEntry> | undefined {
return childrenMap.get(el); return childrenMap.get(el);
} }

@ -1,10 +1,16 @@
import type { PropDef } from '../types/props.js'; import type { PropDef } from '../types/props.js';
import type { LatLngBoundsExpression } from 'leaflet'; import type { LatLngBoundsExpression } from 'leaflet';
// Converts kebab-case to camelCase (e.g. "fill-color" → "fillColor").
// Used when component PROPS tables map attribute names to Leaflet option keys.
export function camelCase(str: string): string { export function camelCase(str: string): string {
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
} }
// Parses an HTML attribute string into a typed JS value:
// null → null, "true"/"false" → boolean, numeric strings → number,
// valid JSON → parsed object/array, otherwise the raw string.
// This is the bridge between HTML attribute strings and Leaflet options.
export function parseAttributeValue(value: string | null): unknown { export function parseAttributeValue(value: string | null): unknown {
if (value === null) return null; if (value === null) return null;
if (value === 'true') return true; if (value === 'true') return true;
@ -18,6 +24,10 @@ export function parseAttributeValue(value: string | null): unknown {
} }
} }
// Installs reactive getter/setter pairs on a prototype for every entry in
// a PROPS table. Each getter reads from the attribute (coerced to the
// correct type), each setter writes via setAttribute/toggleAttribute.
// Used by the withProps() mixin so components have `el.lat = 51.5` sugar.
export function definePropAccessors(proto: object, props: Record<string, PropDef>) { export function definePropAccessors(proto: object, props: Record<string, PropDef>) {
for (const [name, spec] of Object.entries(props)) { for (const [name, spec] of Object.entries(props)) {
Object.defineProperty(proto, name, { Object.defineProperty(proto, name, {
@ -39,21 +49,39 @@ export function definePropAccessors(proto: object, props: Record<string, PropDef
} }
} }
// Reads a numeric attribute from an element, falling back to the default
// value declared in the PROPS table. Handles the common pattern of
// reading lat/lng/radius/opacity with a guaranteed number return.
export function numAttr(el: HTMLElement, props: Record<string, PropDef>, name: string): number { export function numAttr(el: HTMLElement, props: Record<string, PropDef>, name: string): number {
const v = el.getAttribute(name); const v = el.getAttribute(name);
if (v !== null) return Number(v); if (v !== null) return Number(v);
return (props[name] as { default: number }).default; return (props[name] as { default: number }).default;
} }
// Reads the `bounds` attribute from an element and parses it as JSON
// into a LatLngBoundsExpression. Returns an empty array when no
// attribute is set. Shared by rectangle, image-overlay, video-overlay,
// and svg-overlay, all of which accept a `bounds` attribute.
export function parseBoundsAttr(el: HTMLElement): LatLngBoundsExpression { export function parseBoundsAttr(el: HTMLElement): LatLngBoundsExpression {
const raw = el.getAttribute('bounds'); const raw = el.getAttribute('bounds');
return raw ? (JSON.parse(raw) as LatLngBoundsExpression) : []; return raw ? (JSON.parse(raw) as LatLngBoundsExpression) : [];
} }
// Builds a reverse lookup map from HTML attribute name → PROP key.
// For example, { fillColor: { attr: 'fill-color' } } becomes
// { 'fill-color' → 'fillColor' }. Used by setLayerAttr to find the
// Leaflet setter name when an attribute changes at runtime.
export function buildAttrMap(props: Record<string, PropDef>): Map<string, string> { export function buildAttrMap(props: Record<string, PropDef>): Map<string, string> {
return new Map(Object.entries(props).map(([name, spec]) => [spec.attr, name])); return new Map(Object.entries(props).map(([name, spec]) => [spec.attr, name]));
} }
// Generic dispatcher for runtime attribute changes on Leaflet objects.
// Given the attribute name, it looks up the PROP key, constructs the
// matching Leaflet setter name (e.g. "opacity" → "setOpacity"), and
// calls it with the parsed value. Returns true if a setter was found
// and called, false otherwise (caller can fall back, as WMS does with
// setParams). Handles bool-on props by passing the presence/absence of
// the attribute rather than its string value.
export function setLayerAttr( export function setLayerAttr(
obj: object, obj: object,
props: Record<string, PropDef>, props: Record<string, PropDef>,
@ -74,6 +102,12 @@ export function setLayerAttr(
return false; return false;
} }
// Builds the initial Leaflet options object from the PROPS table and the
// element's current attributes at connection time. Props listed in
// `exclude` are skipped (they're passed separately to Leaflet
// constructors, like coordinates or URLs). Null attributes use the
// default from PROPS if one exists (skipping empty-string defaults to
// avoid Leaflet rejecting them).
export function buildOptions( export function buildOptions(
el: HTMLElement, el: HTMLElement,
props: Record<string, PropDef>, props: Record<string, PropDef>,
@ -95,6 +129,12 @@ export function buildOptions(
return opts; return opts;
} }
// Dispatches a custom `leaflet-register` event upward through the DOM
// tree, carrying a Leaflet object and its host element. Parent components
// (map, circles, groups, etc.) intercept this event and add the layer,
// bind the popup, or bind the tooltip. This is the core wiring mechanism
// that replaces the parent-child relationship that Leaflet normally
// manages via imperative code.
export function registerWithParent(el: HTMLElement, obj: unknown) { export function registerWithParent(el: HTMLElement, obj: unknown) {
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('leaflet-register', { new CustomEvent('leaflet-register', {

@ -4,6 +4,15 @@ import { definePropAccessors } from './utils.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type Ctor<T = object> = new (...args: any[]) => T; type Ctor<T = object> = new (...args: any[]) => T;
// Mixin factory that turns a PROPS table into reactive property
// accessors + observedAttributes in one shot. Every component that
// follows the PROPS-table pattern uses this instead of manually
// defining a static initialization block, getters/setters, and an
// observedAttributes getter.
//
// Usage: `class LeafletFoo extends withProps(HTMLElement, PROPS)`
// Returns an intersection type so consumers see the generated
// properties from PropTypesFromTable.
export function withProps<TBase extends Ctor<HTMLElement>, TProps extends Record<string, PropDef>>( export function withProps<TBase extends Ctor<HTMLElement>, TProps extends Record<string, PropDef>>(
Base: TBase, Base: TBase,
props: TProps, props: TProps,

Loading…
Cancel
Save