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

171 lines
8.3 KiB
Markdown

# 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. An element
file is small: an explicitly-typed `const PROPS` table of property
descriptors, a `const Base = WithProps(PROPS)`, then a class with a
`createLeafletObject()` that calls one Leaflet constructor and a couple of
`declare` lines for types. Everything else — attributes, option building,
two-way sync, event forwarding, tree membership — comes from the `WithProps`
mixin.
## `elements/` and `components/`
Each component is two files sharing a basename:
- **`src/elements/leaflet-foo.ts`** exports `default class LeafletFooElement
extends Base` (where `const Base = WithProps(PROPS)`) — the class alone, no
side effects. Import it (or the `src/elements/index.ts` barrel, which
re-exports every class by name) to get the constructor **without**
registering a tag; useful for subclassing or defining it under a different
name.
- **`src/components/leaflet-foo.ts`** is three lines: import the class,
`customElements.define('leaflet-foo', LeafletFooElement)`, re-export it.
Importing this module (or `src/index.ts` / `src/index.npm.ts`, which import
all of them in a load-bearing order — see [06](./06-load-order.md)) is what
registers the tag.
`package.json` maps `leaflet-web-components/elements`,
`.../elements/leaflet-foo.js` and `.../components/leaflet-foo.js` onto the
matching `dist/` files.
### Two package entry points
`src/index.ts` is the shared entry (all classes + the load-bearing
`./components/*` side effects, **no `declare global`**) and is what `jsr.json`
publishes. `src/index.npm.ts` is the npm entry: it re-exports `./index.ts`
**and** imports `src/core/globals.ts`, which carries the two ambient
`declare global` blocks. `globals.ts` is imported only by the npm entry (and
`test/setup.ts`) and is excluded from the JSR tarball, because JSR's "no slow
types" check rejects `declare global` anywhere in its module graph. See
[07](./07-tooling-and-build.md).
## 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 typed `LeafletElementConstructor<TObj, TProps>`. An
element file does:
```ts
const PROPS: typeof latLngProps & {
title: PropDef<string, Marker>;
/* ...one line per prop... */
} = { ...latLngProps, title: str('', { set: /* ... */ }), /* ... */ };
const Base: LeafletElementConstructor<Marker, typeof PROPS> = WithProps(PROPS);
export default class LeafletMarkerElement extends Base {
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.
### Why `const PROPS` / `const Base`, both explicitly typed
`class Foo extends WithProps({ ... })` is not allowed: JSR's fast-check
rejects a call expression as a superclass, and rejects any leaked type it
can't resolve without full type inference. So each file hoists the table to
`const PROPS` **with an explicit type annotation**, then
`const Base: LeafletElementConstructor<TheLeafletClass, typeof PROPS> =
WithProps(PROPS)`, then `extends Base`. The shared fragments in
`shared-props.ts` (`pathProps`, `latLngProps`, `tileLayerProps`, `urlProp`)
carry their own explicit types so a `PROPS` annotation can reference them via
`typeof`; `Positional<T, Obj>` (in `props.ts`) is the alias for a
`positional()` prop's type. `as const` with no annotation works only when
every table value is a bare identifier reference. This is a JSR constraint,
not a TypeScript one — see [07](./07-tooling-and-build.md).
### 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. An empty table
(`const PROPS: Record<never, never> = {}`) 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/elements/leaflet-map.ts`. `LeafletMapElement` still extends a
`Base = WithProps(PROPS, { attach: 'none' })` like every other element, 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.