You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
leaflet-components/docs/01-architecture.md

5.5 KiB

01 — Architecture

Element per Leaflet object

Every leaflet-* custom element maps 1:1 to a single Leaflet object — a Map, a Marker, a TileLayer, a Popup, a Control, an Icon. The element owns that object for its connected lifetime, holds the only reference to it, and exposes it as element.leafletObject.

Nothing about the wrapping is per-object hand-written plumbing. A component file is small (often 3060 lines): a table of property descriptors, a createLeafletObject() that calls one Leaflet constructor, a couple of declare lines for types, and customElements.define(). Everything else — attributes, option building, two-way sync, event forwarding, tree membership — comes from the WithProps mixin.

The WithProps mixin

src/core/with-props.ts. WithProps(PROPS, options?) is a mixin factory: it always extends HTMLElement internally (there is no base-class parameter) and returns a constructor. A component does:

export class LeafletMarker extends WithProps(PROPS) {
  declare readonly leafletObject?: Marker;
  createLeafletObject(options: MarkerOptions): Marker {
    return new Marker([this.lat, this.lng], options);
  }
}

PROPS is a const record mapping property names to PropDef descriptors (see 02). From that table alone the mixin derives everything below.

What the generated class does

  • static observedAttributes — the kebab-cased attribute name of every prop in the table (fillOpacityfill-opacity; a PropDef can override the derived name, e.g. disabled() produces disable-*).

  • Property accessors — one Object.defineProperty per prop on the prototype. The getter reads the live Leaflet value when the PropDef defines a get (falling back to the attribute, then the declared default); the setter encodes the value onto the attribute and lets attributeChangedCallback propagate it.

  • On connect (connectedCallback): #buildOptions() assembles a Leaflet options object from the currently-present attributes plus nothing else (absent attribute ⇒ absent key ⇒ Leaflet's own default applies), skipping any prop marked positional(). Then createLeafletObject(options) runs. Then, unless attach: 'none', the element registers with its parent (see 03). Then leafletObjectCreated() — a no-op hook components can override.

  • On attribute change (attributeChangedCallback): dispatch to the prop's own set(obj, value, el) if it has one; otherwise call the matching Leaflet setter by naming convention (opacityobj.setOpacity) if the object has one; otherwise silently do nothing — many Leaflet options are constructor-only and this is expected. If the mixin was created with options.recreate (icons), an attribute change instead throws the object away and rebuilds it.

  • Two-way sync: #watchObject() subscribes one Leaflet listener per distinct event: named in the table; when it fires, every prop naming that event has its live value read via get and written back to the attribute. Dragging a marker fires move, which writes both lat and lng. A #syncing flag set during that write makes the resulting attributeChangedCallback a no-op — this is the only place an update cycle could form, and it's the only guard needed.

  • Event forwarding: #forwardEvents() wraps the object's own fire() method so every Leaflet event becomes a non-bubbling leaflet:<type> CustomEvent on the element. Generic and automatic — no per-component or per-event registration. See 04.

  • On disconnect: unbind children, remove the object from its parent, call obj.off() for the sync listeners and obj.remove().

Why a factory and not a base class

The prop table drives code generation (observedAttributes, the accessor descriptors) that has to exist on the class before any instance. A factory that closes over the resolved table and defines accessors on Class.prototype is the natural shape for that. WithProps({}) — an empty table — is still a useful base: leaflet-layer-group and leaflet-feature-group use it to get lifecycle, child registration and event forwarding with no options of their own.

Duck typing, deliberately

Layer, Control and Icon share no common Leaflet interface, and which setters exist varies by class. The mixin's method(obj, name) helper looks a method up by string and binds it, or returns undefined. This is why the attribute-change path can "try the setter, else no-op" without knowing anything about the concrete class.

leaflet-map is special

src/components/leaflet-map.ts. It still extends WithProps(PROPS) with attach: 'none', but additionally:

  • builds its own Shadow DOM in connectedCallback (a <div> container for Leaflet, a <style> for :host, and a <link> to Leaflet's CSS),
  • is the root of the component tree: it listens for the bubbling leaflet-register event and terminates it with layer.addTo(this.map) (plus leaflet-add-layer / leaflet-remove-layermap.addLayer / removeLayer, which only leaflet-control-layers dispatches),
  • runs a ResizeObserver on the host to call map.invalidateSize(),
  • treats its css-* attributes as describing the shadow-root stylesheet, not the map — changing one just re-links the <link> and re-derives Icon.Default.imagePath from the same URL.

See 05 for the CSS/escape-hatch details.