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

115 lines
5.5 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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:
```ts
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](./02-props-and-attributes.md)). 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 (`fillOpacity` → `fill-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](./03-component-tree.md)). 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 (`opacity` → `obj.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](./04-events.md).
- **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-layer``map.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](./05-special-cases.md) for the CSS/escape-hatch details.