@invana/graph-layer-maplibre 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
1
+ import { EventMap, Layer, LayerOptions, CanvasContext } from '@invana/canvas';
2
+ import maplibregl from 'maplibre-gl';
3
+
4
+ /**
5
+ * `@invana/graph-layer-maplibre` — public types.
6
+ *
7
+ * The MapLayer owns a MapLibre GL JS map mounted as a sibling DOM element
8
+ * underneath the Pixi canvas, and keeps the canvas camera mirrored to the
9
+ * map's transform every time the user pans / zooms the map. Domain layers
10
+ * (typically `GraphLayer` from `@invana/graph`) pin their content to
11
+ * geographic coordinates via {@link MapLayer.project}.
12
+ */
13
+
14
+ /** Geographic coordinate as `[longitude, latitude]` in degrees. */
15
+ type LngLat = readonly [number, number];
16
+ /** A world-coordinate point (mercator pixels at the layer's reference zoom). */
17
+ interface WorldPoint {
18
+ x: number;
19
+ y: number;
20
+ }
21
+ /**
22
+ * Construction-time options for {@link MapLayer}.
23
+ *
24
+ * The layer is intentionally thin: it hosts a MapLibre map, projects
25
+ * `[lng, lat]` to stable world coords (web-mercator pixels at zoom 0), and
26
+ * syncs the canvas camera so anything drawn at those world coords lines up
27
+ * pixel-accurately with the basemap as the user pans / zooms.
28
+ */
29
+ interface MapLayerOptions {
30
+ /**
31
+ * MapLibre style URL. Defaults to the OpenFreeMap "liberty" style
32
+ * (https://openfreemap.org) — free, no-key, OSM-based vector tiles. Pass a
33
+ * different URL or a full StyleSpecification object to swap basemaps.
34
+ */
35
+ styleUrl?: string | object;
36
+ /** Initial map centre as `[lng, lat]`. Default `[0, 20]`. */
37
+ center?: LngLat;
38
+ /** Initial MapLibre zoom level (0..22). Default `1.5`. */
39
+ zoom?: number;
40
+ /**
41
+ * Minimum / maximum allowed MapLibre zoom. Defaults `0` / `22`. The canvas
42
+ * camera mirrors `2^zoom` as its scale, so these implicitly clamp how far
43
+ * the user can zoom the engine view too.
44
+ */
45
+ minZoom?: number;
46
+ maxZoom?: number;
47
+ /**
48
+ * Optional DOM element the map is mounted into. If omitted, the layer
49
+ * inserts a new `<div>` as the first child of the Pixi canvas's parent
50
+ * element (so the basemap renders *behind* the Pixi canvas).
51
+ */
52
+ mountTarget?: HTMLElement;
53
+ /**
54
+ * Make the Pixi canvas pointer-event-transparent so MapLibre receives all
55
+ * mouse / touch input (pan, zoom, click). Default `true`. Set `false` if
56
+ * you want Pixi-layer behaviours (hover, click-select) to take input
57
+ * priority — you'll then have to drive map pan/zoom by other means.
58
+ */
59
+ passInputToMap?: boolean;
60
+ }
61
+ /** {@link MapLayer} state — exposed via `layer.state.getState()`. */
62
+ interface MapLayerState {
63
+ /** True after the MapLibre `load` event has fired (style + first tiles in). */
64
+ ready: boolean;
65
+ }
66
+ /** Event payloads emitted by {@link MapLayer}. */
67
+ interface MapLayerEvents extends EventMap {
68
+ /** Fired once after MapLibre's `load` event — style + initial tiles ready. */
69
+ 'map:ready': {
70
+ center: [number, number];
71
+ zoom: number;
72
+ };
73
+ /** Fired each time the map transform changes (move / zoom / resize). */
74
+ 'map:move': {
75
+ center: [number, number];
76
+ zoom: number;
77
+ };
78
+ }
79
+
80
+ /**
81
+ * `MapLayer` — hosts a MapLibre GL JS basemap underneath the Pixi canvas
82
+ * and mirrors its camera transform into `canvas.camera` every frame the map
83
+ * moves. Domain layers (graph, contours, anything drawing in world coords)
84
+ * pin their content to geographic positions via {@link MapLayer.project}.
85
+ *
86
+ * ## Why a Layer at all
87
+ *
88
+ * MapLibre owns its own canvas, camera, and input handling — none of which
89
+ * compose with PixiJS directly. We model the integration as a two-stack
90
+ * overlay:
91
+ *
92
+ * ```
93
+ * ┌─ host element (the user's container) ─────────────────────────────┐
94
+ * │ ┌─ MapLibre <div> ────────────────────────────────────────────┐ │
95
+ * │ │ basemap tiles (MapLibre's own webgl canvas) │ │
96
+ * │ └─────────────────────────────────────────────────────────────┘ │
97
+ * │ ┌─ Pixi <canvas> (this engine, transparent) ──────────────────┐ │
98
+ * │ │ graph nodes / edges / overlays │ │
99
+ * │ └─────────────────────────────────────────────────────────────┘ │
100
+ * └────────────────────────────────────────────────────────────────────┘
101
+ * ```
102
+ *
103
+ * MapLibre drives all pan / zoom — the Pixi canvas is pointer-event-
104
+ * transparent by default so the map receives clicks and drags natively.
105
+ * The MapLayer subscribes to `map.on('move', ...)` and rewrites the
106
+ * `pixi-viewport` transform so the two canvases stay pixel-aligned. Result:
107
+ * a node at world `(x, y)` (= the mercator-pixel projection of some
108
+ * `[lng, lat]`) always lands on the same screen pixel as MapLibre's own
109
+ * `map.project([lng, lat])`.
110
+ *
111
+ * ## Coordinate model
112
+ *
113
+ * The MapLayer projects `[lng, lat]` to **web-mercator pixel coordinates at
114
+ * zoom 0**, where the entire world is a 512×512 px square (MapLibre's tile
115
+ * convention). Two consequences worth understanding:
116
+ *
117
+ * 1. **World positions are stable.** A node's world `(x, y)` doesn't change
118
+ * when the user zooms the map — only the camera transform does. That
119
+ * means downstream layers (graph, contours, layouts) don't need to be
120
+ * re-fed on zoom; they just keep their cached positions.
121
+ * 2. **Canvas scale = `2^zoom`.** The mirrored transform is
122
+ * `viewport.scale = 2^map.getZoom()`, and `viewport.position` is solved
123
+ * so the reference point `(lng=0, lat=0)` lands where MapLibre projects
124
+ * it. Bearing and pitch are locked to 0 because the canvas camera is
125
+ * affine + uniform-scale; map rotation/tilt would desync the two stacks.
126
+ *
127
+ * ## Constraints
128
+ *
129
+ * - Requires `canvas.init(...)` — the headless `initWithStage` path has no
130
+ * DOM, so there's nowhere to mount the map. The layer throws on mount
131
+ * in that case.
132
+ * - Don't register `DragPanBehaviour` / `WheelZoomBehaviour` /
133
+ * `PinchZoomBehaviour` alongside this layer. They'd fight the map for
134
+ * the camera, and on a pointer-events-none canvas they'd never see input
135
+ * anyway.
136
+ * - Cross-layer dependencies (e.g. a graph layer needing the projection)
137
+ * declare their dep with an explicit `mapLayerId` option and resolve it
138
+ * via `ctx.layers.get<MapLayer>(...)`. Don't reach for the layer by
139
+ * guessing — see `architecture-proposal.md` §2.4.
140
+ */
141
+
142
+ declare class MapLayer extends Layer<MapLayerOptions, MapLayerState, MapLayerEvents> {
143
+ private map;
144
+ private mapContainer;
145
+ private ownsMapContainer;
146
+ private originalCanvasPointerEvents;
147
+ private originalCanvasPosition;
148
+ private originalCanvasZIndex;
149
+ /**
150
+ * Last camera scale we pushed to the bus on a `camera:zoom` event.
151
+ * `syncCameraFromMap` writes the viewport directly (it has to, to mirror
152
+ * MapLibre's transform exactly), so the bus only learns about zoom
153
+ * changes when we emit them. Skip the emit when scale is unchanged
154
+ * (pan-only frames) so listeners like `ScreenSizeBehaviour` don't pay
155
+ * the O(N) reflow cost on every pan tick.
156
+ */
157
+ private lastEmittedScale;
158
+ private readonly handleMapMove;
159
+ constructor(opts: LayerOptions<MapLayerOptions>);
160
+ /** The underlying MapLibre Map. `null` before mount / after unmount. */
161
+ get maplibre(): maplibregl.Map | null;
162
+ protected createState(): MapLayerState;
163
+ /**
164
+ * Project a geographic coordinate to canvas world coordinates.
165
+ *
166
+ * Returns mercator pixels at zoom 0 (a 512×512 square for the whole
167
+ * earth). Stable across map zoom — pin nodes once at setup and let the
168
+ * camera handle the rest.
169
+ *
170
+ * @example
171
+ * const { x, y } = mapLayer.project([airport.lng, airport.lat]);
172
+ * graphLayer.setData({ nodes: [{ id, position: { x, y }, ... }], ... });
173
+ */
174
+ project(lngLat: LngLat): WorldPoint;
175
+ /**
176
+ * Inverse of {@link project} — world coords back to `[lng, lat]`. Useful
177
+ * for hit-testing or reporting the geographic location under a cursor.
178
+ */
179
+ unproject(world: WorldPoint): [number, number];
180
+ /** Pan/zoom the basemap to a new view. Camera follows automatically via `move`. */
181
+ flyTo(opts: {
182
+ center?: LngLat;
183
+ zoom?: number;
184
+ duration?: number;
185
+ }): void;
186
+ protected onMount(ctx: CanvasContext): void;
187
+ protected onUnmount(ctx: CanvasContext): void;
188
+ /**
189
+ * Solve for the pixi-viewport transform that lines our world axes up with
190
+ * MapLibre's screen pixels.
191
+ *
192
+ * Pixi viewport projects `world -> screen` as `s = w * scale + position`.
193
+ * We pick a fixed reference point (`lng=0, lat=0` — the mercator equator
194
+ * meridian intersection, world coord `(256, 256)` at our reference zoom)
195
+ * and solve:
196
+ *
197
+ * screen_of_lat0lng0 = worldRef * (2 ** map.zoom) + viewport.position
198
+ *
199
+ * `screen_of_lat0lng0` comes straight from `map.project([0, 0])` —
200
+ * MapLibre handles all the map's internal padding / world-wrap / pixel
201
+ * ratio for us. We then rewrite `viewport.position` to match.
202
+ */
203
+ private syncCameraFromMap;
204
+ }
205
+
206
+ /**
207
+ * Spherical interpolation between two `[lng, lat]` points along the
208
+ * great-circle (shortest path on the sphere). Returns `n` evenly-spaced
209
+ * samples *including* both endpoints.
210
+ *
211
+ * Used by stories drawing flight routes / airline arcs: the projected
212
+ * polyline of these samples reads as a smooth curve on a mercator basemap.
213
+ * Pure function, no engine dependency — exposed from
214
+ * `@invana/graph-layer-maplibre` because it pairs with {@link MapLayer.project},
215
+ * but works fine without the layer too.
216
+ *
217
+ * Algorithm: classic Slerp on unit-sphere 3-vectors derived from
218
+ * `(lng, lat)`. Falls back to linear interpolation when the two points are
219
+ * effectively coincident (angle ≈ 0), which keeps tiny self-loops from
220
+ * dividing by zero in `sin(0)`.
221
+ */
222
+ type LngLatTuple = readonly [number, number];
223
+ /**
224
+ * Sample `n` points (`n >= 2`) along the great circle from `from` to `to`.
225
+ *
226
+ * - `n = 2` returns just the endpoints.
227
+ * - `n = 32` (the typical default for flight arcs) gives a visually-smooth
228
+ * curve at most map zooms; bump to 64+ for long transoceanic routes.
229
+ */
230
+ declare function greatCircleSamples(from: LngLatTuple, to: LngLatTuple, n: number): [number, number][];
231
+
232
+ export { type LngLat, type LngLatTuple, MapLayer, type MapLayerEvents, type MapLayerOptions, type MapLayerState, type WorldPoint, greatCircleSamples };
package/dist/index.js ADDED
@@ -0,0 +1,270 @@
1
+ import { Layer } from '@invana/canvas';
2
+ import maplibregl from 'maplibre-gl';
3
+
4
+ // src/MapLayer.ts
5
+ var WORLD_SIZE = 512;
6
+ var DEFAULT_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty";
7
+ var MapLayer = class extends Layer {
8
+ map = null;
9
+ mapContainer = null;
10
+ ownsMapContainer = false;
11
+ originalCanvasPointerEvents = null;
12
+ originalCanvasPosition = null;
13
+ originalCanvasZIndex = null;
14
+ /**
15
+ * Last camera scale we pushed to the bus on a `camera:zoom` event.
16
+ * `syncCameraFromMap` writes the viewport directly (it has to, to mirror
17
+ * MapLibre's transform exactly), so the bus only learns about zoom
18
+ * changes when we emit them. Skip the emit when scale is unchanged
19
+ * (pan-only frames) so listeners like `ScreenSizeBehaviour` don't pay
20
+ * the O(N) reflow cost on every pan tick.
21
+ */
22
+ lastEmittedScale = null;
23
+ handleMapMove = () => this.syncCameraFromMap();
24
+ constructor(opts) {
25
+ super({
26
+ ...opts,
27
+ // The map is a basemap — clicks on empty map area should fall through
28
+ // to the (non-existent) layer below, not be claimed here. Domain
29
+ // layers above still hit-test normally.
30
+ hittable: opts.hittable ?? false,
31
+ // Always render — the map fills the whole viewport regardless of
32
+ // where graph content sits.
33
+ cullable: opts.cullable ?? false
34
+ });
35
+ }
36
+ /** The underlying MapLibre Map. `null` before mount / after unmount. */
37
+ get maplibre() {
38
+ return this.map;
39
+ }
40
+ createState() {
41
+ return { ready: false };
42
+ }
43
+ /**
44
+ * Project a geographic coordinate to canvas world coordinates.
45
+ *
46
+ * Returns mercator pixels at zoom 0 (a 512×512 square for the whole
47
+ * earth). Stable across map zoom — pin nodes once at setup and let the
48
+ * camera handle the rest.
49
+ *
50
+ * @example
51
+ * const { x, y } = mapLayer.project([airport.lng, airport.lat]);
52
+ * graphLayer.setData({ nodes: [{ id, position: { x, y }, ... }], ... });
53
+ */
54
+ project(lngLat) {
55
+ const [lng, lat] = lngLat;
56
+ const clampedLat = Math.max(-85.05112878, Math.min(85.05112878, lat));
57
+ const sin = Math.sin(clampedLat * Math.PI / 180);
58
+ const x = (lng + 180) / 360 * WORLD_SIZE;
59
+ const y = (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * WORLD_SIZE;
60
+ return { x, y };
61
+ }
62
+ /**
63
+ * Inverse of {@link project} — world coords back to `[lng, lat]`. Useful
64
+ * for hit-testing or reporting the geographic location under a cursor.
65
+ */
66
+ unproject(world) {
67
+ const lng = world.x / WORLD_SIZE * 360 - 180;
68
+ const n = Math.PI - 2 * Math.PI * (world.y / WORLD_SIZE);
69
+ const lat = 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
70
+ return [lng, lat];
71
+ }
72
+ /** Pan/zoom the basemap to a new view. Camera follows automatically via `move`. */
73
+ flyTo(opts) {
74
+ if (!this.map) return;
75
+ this.map.flyTo({
76
+ center: opts.center ? [opts.center[0], opts.center[1]] : void 0,
77
+ zoom: opts.zoom,
78
+ duration: opts.duration ?? 1e3
79
+ });
80
+ }
81
+ onMount(ctx) {
82
+ const canvasEl = ctx.canvasElement;
83
+ if (!canvasEl) {
84
+ throw new Error(
85
+ `MapLayer "${this.id}" requires a DOM-mounted Canvas (canvas.init), not the headless initWithStage path \u2014 there's no element to mount the basemap into.`
86
+ );
87
+ }
88
+ const explicitTarget = this.options.mountTarget;
89
+ if (explicitTarget) {
90
+ this.mapContainer = explicitTarget;
91
+ } else {
92
+ const host = canvasEl.parentElement;
93
+ if (!host) {
94
+ throw new Error(
95
+ `MapLayer "${this.id}": Pixi canvas has no parent element to mount the basemap into.`
96
+ );
97
+ }
98
+ if (getComputedStyle(host).position === "static") {
99
+ host.style.position = "relative";
100
+ }
101
+ const div = document.createElement("div");
102
+ div.dataset.invanaMaplayerId = this.id;
103
+ div.style.cssText = "position:absolute; inset:0; width:100%; height:100%; z-index:0; pointer-events:auto;";
104
+ host.insertBefore(div, host.firstChild);
105
+ this.mapContainer = div;
106
+ this.ownsMapContainer = true;
107
+ }
108
+ this.originalCanvasPosition = canvasEl.style.position;
109
+ this.originalCanvasZIndex = canvasEl.style.zIndex;
110
+ if (getComputedStyle(canvasEl).position === "static") {
111
+ canvasEl.style.position = "relative";
112
+ }
113
+ canvasEl.style.zIndex = "1";
114
+ if (this.options.passInputToMap ?? true) {
115
+ this.originalCanvasPointerEvents = canvasEl.style.pointerEvents;
116
+ canvasEl.style.pointerEvents = "none";
117
+ }
118
+ const styleUrl = this.options.styleUrl ?? DEFAULT_STYLE_URL;
119
+ const center = this.options.center ?? [0, 20];
120
+ const zoom = this.options.zoom ?? 1.5;
121
+ this.map = new maplibregl.Map({
122
+ container: this.mapContainer,
123
+ // MapLibre's typings accept `string | StyleSpecification`; we widen to
124
+ // `object` in our public types and cast here.
125
+ style: styleUrl,
126
+ center: [center[0], center[1]],
127
+ zoom,
128
+ minZoom: this.options.minZoom ?? 0,
129
+ maxZoom: this.options.maxZoom ?? 22,
130
+ // Lock orientation: our pixi camera is uniform-scale, no rotation/tilt.
131
+ bearing: 0,
132
+ pitch: 0,
133
+ dragRotate: false,
134
+ pitchWithRotate: false,
135
+ touchPitch: false,
136
+ attributionControl: { compact: true }
137
+ });
138
+ this.map.on("move", this.handleMapMove);
139
+ this.map.on("load", () => {
140
+ this.state.setState((s) => {
141
+ s.ready = true;
142
+ });
143
+ this.events.emit("map:ready", {
144
+ center: [this.map.getCenter().lng, this.map.getCenter().lat],
145
+ zoom: this.map.getZoom()
146
+ });
147
+ this.syncCameraFromMap();
148
+ });
149
+ this.syncCameraFromMap();
150
+ }
151
+ onUnmount(ctx) {
152
+ if (this.map) {
153
+ this.map.off("move", this.handleMapMove);
154
+ this.map.remove();
155
+ this.map = null;
156
+ }
157
+ if (this.ownsMapContainer && this.mapContainer?.parentElement) {
158
+ this.mapContainer.parentElement.removeChild(this.mapContainer);
159
+ }
160
+ this.mapContainer = null;
161
+ this.ownsMapContainer = false;
162
+ if (ctx.canvasElement) {
163
+ const el = ctx.canvasElement;
164
+ if (this.originalCanvasPointerEvents !== null) {
165
+ el.style.pointerEvents = this.originalCanvasPointerEvents;
166
+ }
167
+ if (this.originalCanvasPosition !== null) {
168
+ el.style.position = this.originalCanvasPosition;
169
+ }
170
+ if (this.originalCanvasZIndex !== null) {
171
+ el.style.zIndex = this.originalCanvasZIndex;
172
+ }
173
+ }
174
+ this.originalCanvasPointerEvents = null;
175
+ this.originalCanvasPosition = null;
176
+ this.originalCanvasZIndex = null;
177
+ this.lastEmittedScale = null;
178
+ }
179
+ /**
180
+ * Solve for the pixi-viewport transform that lines our world axes up with
181
+ * MapLibre's screen pixels.
182
+ *
183
+ * Pixi viewport projects `world -> screen` as `s = w * scale + position`.
184
+ * We pick a fixed reference point (`lng=0, lat=0` — the mercator equator
185
+ * meridian intersection, world coord `(256, 256)` at our reference zoom)
186
+ * and solve:
187
+ *
188
+ * screen_of_lat0lng0 = worldRef * (2 ** map.zoom) + viewport.position
189
+ *
190
+ * `screen_of_lat0lng0` comes straight from `map.project([0, 0])` —
191
+ * MapLibre handles all the map's internal padding / world-wrap / pixel
192
+ * ratio for us. We then rewrite `viewport.position` to match.
193
+ */
194
+ syncCameraFromMap() {
195
+ const map = this.map;
196
+ const ctx = this.ctx;
197
+ if (!map || !ctx) return;
198
+ const zoom = map.getZoom();
199
+ const scale = Math.pow(2, zoom);
200
+ const ref = [0, 0];
201
+ const screenRef = map.project([ref[0], ref[1]]);
202
+ const worldRef = this.project(ref);
203
+ const tx = screenRef.x - worldRef.x * scale;
204
+ const ty = screenRef.y - worldRef.y * scale;
205
+ ctx.camera.viewport.scale.set(scale);
206
+ ctx.camera.viewport.position.set(tx, ty);
207
+ if (this.lastEmittedScale === null || this.lastEmittedScale !== scale) {
208
+ ctx.events.emit("camera:zoom", {
209
+ scale,
210
+ centerX: ctx.camera.screenWidth / 2,
211
+ centerY: ctx.camera.screenHeight / 2
212
+ });
213
+ this.lastEmittedScale = scale;
214
+ }
215
+ ctx.events.emit("camera:pan", { x: tx, y: ty });
216
+ this.events.emit("map:move", {
217
+ center: [map.getCenter().lng, map.getCenter().lat],
218
+ zoom
219
+ });
220
+ }
221
+ };
222
+
223
+ // src/greatCircle.ts
224
+ var DEG = Math.PI / 180;
225
+ var RAD = 180 / Math.PI;
226
+ function toCartesian(lng, lat) {
227
+ const clng = Math.cos(lng * DEG);
228
+ const slng = Math.sin(lng * DEG);
229
+ const clat = Math.cos(lat * DEG);
230
+ const slat = Math.sin(lat * DEG);
231
+ return [clat * clng, clat * slng, slat];
232
+ }
233
+ function toLngLat(v) {
234
+ const [x, y, z] = v;
235
+ const lng = Math.atan2(y, x) * RAD;
236
+ const lat = Math.atan2(z, Math.sqrt(x * x + y * y)) * RAD;
237
+ return [lng, lat];
238
+ }
239
+ function greatCircleSamples(from, to, n) {
240
+ if (n < 2) throw new Error(`greatCircleSamples: n must be >= 2 (got ${n})`);
241
+ const a = toCartesian(from[0], from[1]);
242
+ const b = toCartesian(to[0], to[1]);
243
+ const dot = Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2]));
244
+ const omega = Math.acos(dot);
245
+ const sinOmega = Math.sin(omega);
246
+ const out = new Array(n);
247
+ if (sinOmega < 1e-9) {
248
+ for (let i = 0; i < n; i++) {
249
+ const t = i / (n - 1);
250
+ out[i] = [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t];
251
+ }
252
+ return out;
253
+ }
254
+ for (let i = 0; i < n; i++) {
255
+ const t = i / (n - 1);
256
+ const s1 = Math.sin((1 - t) * omega) / sinOmega;
257
+ const s2 = Math.sin(t * omega) / sinOmega;
258
+ const v = [
259
+ s1 * a[0] + s2 * b[0],
260
+ s1 * a[1] + s2 * b[1],
261
+ s1 * a[2] + s2 * b[2]
262
+ ];
263
+ out[i] = toLngLat(v);
264
+ }
265
+ return out;
266
+ }
267
+
268
+ export { MapLayer, greatCircleSamples };
269
+ //# sourceMappingURL=index.js.map
270
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/MapLayer.ts","../src/greatCircle.ts"],"names":[],"mappings":";;;;AA0EA,IAAM,UAAA,GAAa,GAAA;AAEnB,IAAM,iBAAA,GAAoB,8CAAA;AAEnB,IAAM,QAAA,GAAN,cAAuB,KAAA,CAAsD;AAAA,EAC1E,GAAA,GAA6B,IAAA;AAAA,EAC7B,YAAA,GAAsC,IAAA;AAAA,EACtC,gBAAA,GAAmB,KAAA;AAAA,EACnB,2BAAA,GAA6C,IAAA;AAAA,EAC7C,sBAAA,GAAwC,IAAA;AAAA,EACxC,oBAAA,GAAsC,IAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAStC,gBAAA,GAAkC,IAAA;AAAA,EACzB,aAAA,GAAgB,MAAY,IAAA,CAAK,iBAAA,EAAkB;AAAA,EAEpE,YAAY,IAAA,EAAqC;AAC/C,IAAA,KAAA,CAAM;AAAA,MACJ,GAAG,IAAA;AAAA;AAAA;AAAA;AAAA,MAIH,QAAA,EAAU,KAAK,QAAA,IAAY,KAAA;AAAA;AAAA;AAAA,MAG3B,QAAA,EAAU,KAAK,QAAA,IAAY;AAAA,KAC5B,CAAA;AAAA,EACH;AAAA;AAAA,EAGA,IAAI,QAAA,GAAkC;AACpC,IAAA,OAAO,IAAA,CAAK,GAAA;AAAA,EACd;AAAA,EAEU,WAAA,GAA6B;AACrC,IAAA,OAAO,EAAE,OAAO,KAAA,EAAM;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaA,QAAQ,MAAA,EAA4B;AAClC,IAAA,MAAM,CAAC,GAAA,EAAK,GAAG,CAAA,GAAI,MAAA;AACnB,IAAA,MAAM,UAAA,GAAa,KAAK,GAAA,CAAI,YAAA,EAAc,KAAK,GAAA,CAAI,WAAA,EAAa,GAAG,CAAC,CAAA;AACpE,IAAA,MAAM,MAAM,IAAA,CAAK,GAAA,CAAK,UAAA,GAAa,IAAA,CAAK,KAAM,GAAG,CAAA;AACjD,IAAA,MAAM,CAAA,GAAA,CAAM,GAAA,GAAM,GAAA,IAAO,GAAA,GAAO,UAAA;AAChC,IAAA,MAAM,CAAA,GAAA,CAAK,GAAA,GAAM,IAAA,CAAK,GAAA,CAAA,CAAK,CAAA,GAAI,GAAA,KAAQ,CAAA,GAAI,GAAA,CAAI,CAAA,IAAK,CAAA,GAAI,IAAA,CAAK,EAAA,CAAA,IAAO,UAAA;AACpE,IAAA,OAAO,EAAE,GAAG,CAAA,EAAE;AAAA,EAChB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,UAAU,KAAA,EAAqC;AAC7C,IAAA,MAAM,GAAA,GAAO,KAAA,CAAM,CAAA,GAAI,UAAA,GAAc,GAAA,GAAM,GAAA;AAC3C,IAAA,MAAM,IAAI,IAAA,CAAK,EAAA,GAAK,IAAI,IAAA,CAAK,EAAA,IAAM,MAAM,CAAA,GAAI,UAAA,CAAA;AAC7C,IAAA,MAAM,GAAA,GAAO,GAAA,GAAM,IAAA,CAAK,EAAA,GAAM,KAAK,IAAA,CAAK,GAAA,IAAO,IAAA,CAAK,GAAA,CAAI,CAAC,CAAA,GAAI,IAAA,CAAK,GAAA,CAAI,CAAC,CAAC,CAAA,CAAE,CAAA;AAC1E,IAAA,OAAO,CAAC,KAAK,GAAG,CAAA;AAAA,EAClB;AAAA;AAAA,EAGA,MAAM,IAAA,EAAmE;AACvE,IAAA,IAAI,CAAC,KAAK,GAAA,EAAK;AACf,IAAA,IAAA,CAAK,IAAI,KAAA,CAAM;AAAA,MACb,MAAA,EAAQ,IAAA,CAAK,MAAA,GAAS,CAAC,IAAA,CAAK,MAAA,CAAO,CAAC,CAAA,EAAG,IAAA,CAAK,MAAA,CAAO,CAAC,CAAC,CAAA,GAAI,MAAA;AAAA,MACzD,MAAM,IAAA,CAAK,IAAA;AAAA,MACX,QAAA,EAAU,KAAK,QAAA,IAAY;AAAA,KAC5B,CAAA;AAAA,EACH;AAAA,EAEmB,QAAQ,GAAA,EAA0B;AACnD,IAAA,MAAM,WAAW,GAAA,CAAI,aAAA;AACrB,IAAA,IAAI,CAAC,QAAA,EAAU;AACb,MAAA,MAAM,IAAI,KAAA;AAAA,QACR,CAAA,UAAA,EAAa,KAAK,EAAE,CAAA,uIAAA;AAAA,OAEtB;AAAA,IACF;AAEA,IAAA,MAAM,cAAA,GAAiB,KAAK,OAAA,CAAQ,WAAA;AACpC,IAAA,IAAI,cAAA,EAAgB;AAClB,MAAA,IAAA,CAAK,YAAA,GAAe,cAAA;AAAA,IACtB,CAAA,MAAO;AACL,MAAA,MAAM,OAAO,QAAA,CAAS,aAAA;AACtB,MAAA,IAAI,CAAC,IAAA,EAAM;AACT,QAAA,MAAM,IAAI,KAAA;AAAA,UACR,CAAA,UAAA,EAAa,KAAK,EAAE,CAAA,+DAAA;AAAA,SACtB;AAAA,MACF;AAEA,MAAA,IAAI,gBAAA,CAAiB,IAAI,CAAA,CAAE,QAAA,KAAa,QAAA,EAAU;AAChD,QAAA,IAAA,CAAK,MAAM,QAAA,GAAW,UAAA;AAAA,MACxB;AACA,MAAA,MAAM,GAAA,GAAM,QAAA,CAAS,aAAA,CAAc,KAAK,CAAA;AACxC,MAAA,GAAA,CAAI,OAAA,CAAQ,mBAAmB,IAAA,CAAK,EAAA;AAMpC,MAAA,GAAA,CAAI,MAAM,OAAA,GACR,sFAAA;AACF,MAAA,IAAA,CAAK,YAAA,CAAa,GAAA,EAAK,IAAA,CAAK,UAAU,CAAA;AACtC,MAAA,IAAA,CAAK,YAAA,GAAe,GAAA;AACpB,MAAA,IAAA,CAAK,gBAAA,GAAmB,IAAA;AAAA,IAC1B;AAKA,IAAA,IAAA,CAAK,sBAAA,GAAyB,SAAS,KAAA,CAAM,QAAA;AAC7C,IAAA,IAAA,CAAK,oBAAA,GAAuB,SAAS,KAAA,CAAM,MAAA;AAC3C,IAAA,IAAI,gBAAA,CAAiB,QAAQ,CAAA,CAAE,QAAA,KAAa,QAAA,EAAU;AACpD,MAAA,QAAA,CAAS,MAAM,QAAA,GAAW,UAAA;AAAA,IAC5B;AACA,IAAA,QAAA,CAAS,MAAM,MAAA,GAAS,GAAA;AAGxB,IAAA,IAAI,IAAA,CAAK,OAAA,CAAQ,cAAA,IAAkB,IAAA,EAAM;AACvC,MAAA,IAAA,CAAK,2BAAA,GAA8B,SAAS,KAAA,CAAM,aAAA;AAClD,MAAA,QAAA,CAAS,MAAM,aAAA,GAAgB,MAAA;AAAA,IACjC;AAEA,IAAA,MAAM,QAAA,GAAW,IAAA,CAAK,OAAA,CAAQ,QAAA,IAAY,iBAAA;AAC1C,IAAA,MAAM,SAAS,IAAA,CAAK,OAAA,CAAQ,MAAA,IAAU,CAAC,GAAG,EAAE,CAAA;AAC5C,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,OAAA,CAAQ,IAAA,IAAQ,GAAA;AAElC,IAAA,IAAA,CAAK,GAAA,GAAM,IAAI,UAAA,CAAW,GAAA,CAAI;AAAA,MAC5B,WAAW,IAAA,CAAK,YAAA;AAAA;AAAA;AAAA,MAGhB,KAAA,EAAO,QAAA;AAAA,MACP,QAAQ,CAAC,MAAA,CAAO,CAAC,CAAA,EAAG,MAAA,CAAO,CAAC,CAAC,CAAA;AAAA,MAC7B,IAAA;AAAA,MACA,OAAA,EAAS,IAAA,CAAK,OAAA,CAAQ,OAAA,IAAW,CAAA;AAAA,MACjC,OAAA,EAAS,IAAA,CAAK,OAAA,CAAQ,OAAA,IAAW,EAAA;AAAA;AAAA,MAEjC,OAAA,EAAS,CAAA;AAAA,MACT,KAAA,EAAO,CAAA;AAAA,MACP,UAAA,EAAY,KAAA;AAAA,MACZ,eAAA,EAAiB,KAAA;AAAA,MACjB,UAAA,EAAY,KAAA;AAAA,MACZ,kBAAA,EAAoB,EAAE,OAAA,EAAS,IAAA;AAAK,KACrC,CAAA;AAED,IAAA,IAAA,CAAK,GAAA,CAAI,EAAA,CAAG,MAAA,EAAQ,IAAA,CAAK,aAAa,CAAA;AACtC,IAAA,IAAA,CAAK,GAAA,CAAI,EAAA,CAAG,MAAA,EAAQ,MAAM;AACxB,MAAA,IAAA,CAAK,KAAA,CAAM,QAAA,CAAS,CAAC,CAAA,KAAM;AACzB,QAAA,CAAA,CAAE,KAAA,GAAQ,IAAA;AAAA,MACZ,CAAC,CAAA;AACD,MAAA,IAAA,CAAK,MAAA,CAAO,KAAK,WAAA,EAAa;AAAA,QAC5B,MAAA,EAAQ,CAAC,IAAA,CAAK,GAAA,CAAK,SAAA,EAAU,CAAE,GAAA,EAAK,IAAA,CAAK,GAAA,CAAK,SAAA,EAAU,CAAE,GAAG,CAAA;AAAA,QAC7D,IAAA,EAAM,IAAA,CAAK,GAAA,CAAK,OAAA;AAAQ,OACzB,CAAA;AACD,MAAA,IAAA,CAAK,iBAAA,EAAkB;AAAA,IACzB,CAAC,CAAA;AAID,IAAA,IAAA,CAAK,iBAAA,EAAkB;AAAA,EACzB;AAAA,EAEmB,UAAU,GAAA,EAA0B;AACrD,IAAA,IAAI,KAAK,GAAA,EAAK;AACZ,MAAA,IAAA,CAAK,GAAA,CAAI,GAAA,CAAI,MAAA,EAAQ,IAAA,CAAK,aAAa,CAAA;AACvC,MAAA,IAAA,CAAK,IAAI,MAAA,EAAO;AAChB,MAAA,IAAA,CAAK,GAAA,GAAM,IAAA;AAAA,IACb;AACA,IAAA,IAAI,IAAA,CAAK,gBAAA,IAAoB,IAAA,CAAK,YAAA,EAAc,aAAA,EAAe;AAC7D,MAAA,IAAA,CAAK,YAAA,CAAa,aAAA,CAAc,WAAA,CAAY,IAAA,CAAK,YAAY,CAAA;AAAA,IAC/D;AACA,IAAA,IAAA,CAAK,YAAA,GAAe,IAAA;AACpB,IAAA,IAAA,CAAK,gBAAA,GAAmB,KAAA;AAExB,IAAA,IAAI,IAAI,aAAA,EAAe;AACrB,MAAA,MAAM,KAAK,GAAA,CAAI,aAAA;AACf,MAAA,IAAI,IAAA,CAAK,gCAAgC,IAAA,EAAM;AAC7C,QAAA,EAAA,CAAG,KAAA,CAAM,gBAAgB,IAAA,CAAK,2BAAA;AAAA,MAChC;AACA,MAAA,IAAI,IAAA,CAAK,2BAA2B,IAAA,EAAM;AACxC,QAAA,EAAA,CAAG,KAAA,CAAM,WAAW,IAAA,CAAK,sBAAA;AAAA,MAC3B;AACA,MAAA,IAAI,IAAA,CAAK,yBAAyB,IAAA,EAAM;AACtC,QAAA,EAAA,CAAG,KAAA,CAAM,SAAS,IAAA,CAAK,oBAAA;AAAA,MACzB;AAAA,IACF;AACA,IAAA,IAAA,CAAK,2BAAA,GAA8B,IAAA;AACnC,IAAA,IAAA,CAAK,sBAAA,GAAyB,IAAA;AAC9B,IAAA,IAAA,CAAK,oBAAA,GAAuB,IAAA;AAC5B,IAAA,IAAA,CAAK,gBAAA,GAAmB,IAAA;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBQ,iBAAA,GAA0B;AAChC,IAAA,MAAM,MAAM,IAAA,CAAK,GAAA;AACjB,IAAA,MAAM,MAAM,IAAA,CAAK,GAAA;AACjB,IAAA,IAAI,CAAC,GAAA,IAAO,CAAC,GAAA,EAAK;AAElB,IAAA,MAAM,IAAA,GAAO,IAAI,OAAA,EAAQ;AACzB,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAI,CAAA;AAE9B,IAAA,MAAM,GAAA,GAAc,CAAC,CAAA,EAAG,CAAC,CAAA;AACzB,IAAA,MAAM,SAAA,GAAY,GAAA,CAAI,OAAA,CAAQ,CAAC,GAAA,CAAI,CAAC,CAAA,EAAG,GAAA,CAAI,CAAC,CAAC,CAAC,CAAA;AAC9C,IAAA,MAAM,QAAA,GAAW,IAAA,CAAK,OAAA,CAAQ,GAAG,CAAA;AAEjC,IAAA,MAAM,EAAA,GAAK,SAAA,CAAU,CAAA,GAAI,QAAA,CAAS,CAAA,GAAI,KAAA;AACtC,IAAA,MAAM,EAAA,GAAK,SAAA,CAAU,CAAA,GAAI,QAAA,CAAS,CAAA,GAAI,KAAA;AAUtC,IAAA,GAAA,CAAI,MAAA,CAAO,QAAA,CAAS,KAAA,CAAM,GAAA,CAAI,KAAK,CAAA;AACnC,IAAA,GAAA,CAAI,MAAA,CAAO,QAAA,CAAS,QAAA,CAAS,GAAA,CAAI,IAAI,EAAE,CAAA;AAMvC,IAAA,IAAI,IAAA,CAAK,gBAAA,KAAqB,IAAA,IAAQ,IAAA,CAAK,qBAAqB,KAAA,EAAO;AACrE,MAAA,GAAA,CAAI,MAAA,CAAO,KAAK,aAAA,EAAe;AAAA,QAC7B,KAAA;AAAA,QACA,OAAA,EAAS,GAAA,CAAI,MAAA,CAAO,WAAA,GAAc,CAAA;AAAA,QAClC,OAAA,EAAS,GAAA,CAAI,MAAA,CAAO,YAAA,GAAe;AAAA,OACpC,CAAA;AACD,MAAA,IAAA,CAAK,gBAAA,GAAmB,KAAA;AAAA,IAC1B;AACA,IAAA,GAAA,CAAI,MAAA,CAAO,KAAK,YAAA,EAAc,EAAE,GAAG,EAAA,EAAI,CAAA,EAAG,IAAI,CAAA;AAE9C,IAAA,IAAA,CAAK,MAAA,CAAO,KAAK,UAAA,EAAY;AAAA,MAC3B,MAAA,EAAQ,CAAC,GAAA,CAAI,SAAA,GAAY,GAAA,EAAK,GAAA,CAAI,SAAA,EAAU,CAAE,GAAG,CAAA;AAAA,MACjD;AAAA,KACD,CAAA;AAAA,EACH;AACF;;;AClUA,IAAM,GAAA,GAAM,KAAK,EAAA,GAAK,GAAA;AACtB,IAAM,GAAA,GAAM,MAAM,IAAA,CAAK,EAAA;AAEvB,SAAS,WAAA,CAAY,KAAa,GAAA,EAAuC;AACvE,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,GAAA,GAAM,GAAG,CAAA;AAC/B,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,GAAA,GAAM,GAAG,CAAA;AAC/B,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,GAAA,GAAM,GAAG,CAAA;AAC/B,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,GAAA,GAAM,GAAG,CAAA;AAC/B,EAAA,OAAO,CAAC,IAAA,GAAO,IAAA,EAAM,IAAA,GAAO,MAAM,IAAI,CAAA;AACxC;AAEA,SAAS,SAAS,CAAA,EAA+C;AAC/D,EAAA,MAAM,CAAC,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA,GAAI,CAAA;AAClB,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,CAAC,CAAA,GAAI,GAAA;AAC/B,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,IAAA,CAAK,IAAA,CAAK,CAAA,GAAI,CAAA,GAAI,CAAA,GAAI,CAAC,CAAC,CAAA,GAAI,GAAA;AACtD,EAAA,OAAO,CAAC,KAAK,GAAG,CAAA;AAClB;AASO,SAAS,kBAAA,CACd,IAAA,EACA,EAAA,EACA,CAAA,EACoB;AACpB,EAAA,IAAI,IAAI,CAAA,EAAG,MAAM,IAAI,KAAA,CAAM,CAAA,wCAAA,EAA2C,CAAC,CAAA,CAAA,CAAG,CAAA;AAC1E,EAAA,MAAM,IAAI,WAAA,CAAY,IAAA,CAAK,CAAC,CAAA,EAAG,IAAA,CAAK,CAAC,CAAC,CAAA;AACtC,EAAA,MAAM,IAAI,WAAA,CAAY,EAAA,CAAG,CAAC,CAAA,EAAG,EAAA,CAAG,CAAC,CAAC,CAAA;AAGlC,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,GAAA,CAAI,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,EAAG,CAAA,CAAE,CAAC,CAAA,GAAI,CAAA,CAAE,CAAC,IAAI,CAAA,CAAE,CAAC,CAAA,GAAI,CAAA,CAAE,CAAC,CAAA,GAAI,CAAA,CAAE,CAAC,CAAA,GAAI,CAAA,CAAE,CAAC,CAAC,CAAC,CAAA;AAC7E,EAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,IAAA,CAAK,GAAG,CAAA;AAC3B,EAAA,MAAM,QAAA,GAAW,IAAA,CAAK,GAAA,CAAI,KAAK,CAAA;AAE/B,EAAA,MAAM,GAAA,GAA0B,IAAI,KAAA,CAAM,CAAC,CAAA;AAI3C,EAAA,IAAI,WAAW,IAAA,EAAM;AACnB,IAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,CAAA,EAAG,CAAA,EAAA,EAAK;AAC1B,MAAA,MAAM,CAAA,GAAI,KAAK,CAAA,GAAI,CAAA,CAAA;AACnB,MAAA,GAAA,CAAI,CAAC,IAAI,CAAC,IAAA,CAAK,CAAC,CAAA,GAAA,CAAK,EAAA,CAAG,CAAC,CAAA,GAAI,IAAA,CAAK,CAAC,KAAK,CAAA,EAAG,IAAA,CAAK,CAAC,CAAA,GAAA,CAAK,EAAA,CAAG,CAAC,CAAA,GAAI,IAAA,CAAK,CAAC,CAAA,IAAK,CAAC,CAAA;AAAA,IAC5E;AACA,IAAA,OAAO,GAAA;AAAA,EACT;AAEA,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,CAAA,EAAG,CAAA,EAAA,EAAK;AAC1B,IAAA,MAAM,CAAA,GAAI,KAAK,CAAA,GAAI,CAAA,CAAA;AACnB,IAAA,MAAM,KAAK,IAAA,CAAK,GAAA,CAAA,CAAK,CAAA,GAAI,CAAA,IAAK,KAAK,CAAA,GAAI,QAAA;AACvC,IAAA,MAAM,EAAA,GAAK,IAAA,CAAK,GAAA,CAAI,CAAA,GAAI,KAAK,CAAA,GAAI,QAAA;AACjC,IAAA,MAAM,CAAA,GAA8B;AAAA,MAClC,KAAK,CAAA,CAAE,CAAC,CAAA,GAAI,EAAA,GAAK,EAAE,CAAC,CAAA;AAAA,MACpB,KAAK,CAAA,CAAE,CAAC,CAAA,GAAI,EAAA,GAAK,EAAE,CAAC,CAAA;AAAA,MACpB,KAAK,CAAA,CAAE,CAAC,CAAA,GAAI,EAAA,GAAK,EAAE,CAAC;AAAA,KACtB;AACA,IAAA,GAAA,CAAI,CAAC,CAAA,GAAI,QAAA,CAAS,CAAC,CAAA;AAAA,EACrB;AACA,EAAA,OAAO,GAAA;AACT","file":"index.js","sourcesContent":["/**\n * `MapLayer` — hosts a MapLibre GL JS basemap underneath the Pixi canvas\n * and mirrors its camera transform into `canvas.camera` every frame the map\n * moves. Domain layers (graph, contours, anything drawing in world coords)\n * pin their content to geographic positions via {@link MapLayer.project}.\n *\n * ## Why a Layer at all\n *\n * MapLibre owns its own canvas, camera, and input handling — none of which\n * compose with PixiJS directly. We model the integration as a two-stack\n * overlay:\n *\n * ```\n * ┌─ host element (the user's container) ─────────────────────────────┐\n * │ ┌─ MapLibre <div> ────────────────────────────────────────────┐ │\n * │ │ basemap tiles (MapLibre's own webgl canvas) │ │\n * │ └─────────────────────────────────────────────────────────────┘ │\n * │ ┌─ Pixi <canvas> (this engine, transparent) ──────────────────┐ │\n * │ │ graph nodes / edges / overlays │ │\n * │ └─────────────────────────────────────────────────────────────┘ │\n * └────────────────────────────────────────────────────────────────────┘\n * ```\n *\n * MapLibre drives all pan / zoom — the Pixi canvas is pointer-event-\n * transparent by default so the map receives clicks and drags natively.\n * The MapLayer subscribes to `map.on('move', ...)` and rewrites the\n * `pixi-viewport` transform so the two canvases stay pixel-aligned. Result:\n * a node at world `(x, y)` (= the mercator-pixel projection of some\n * `[lng, lat]`) always lands on the same screen pixel as MapLibre's own\n * `map.project([lng, lat])`.\n *\n * ## Coordinate model\n *\n * The MapLayer projects `[lng, lat]` to **web-mercator pixel coordinates at\n * zoom 0**, where the entire world is a 512×512 px square (MapLibre's tile\n * convention). Two consequences worth understanding:\n *\n * 1. **World positions are stable.** A node's world `(x, y)` doesn't change\n * when the user zooms the map — only the camera transform does. That\n * means downstream layers (graph, contours, layouts) don't need to be\n * re-fed on zoom; they just keep their cached positions.\n * 2. **Canvas scale = `2^zoom`.** The mirrored transform is\n * `viewport.scale = 2^map.getZoom()`, and `viewport.position` is solved\n * so the reference point `(lng=0, lat=0)` lands where MapLibre projects\n * it. Bearing and pitch are locked to 0 because the canvas camera is\n * affine + uniform-scale; map rotation/tilt would desync the two stacks.\n *\n * ## Constraints\n *\n * - Requires `canvas.init(...)` — the headless `initWithStage` path has no\n * DOM, so there's nowhere to mount the map. The layer throws on mount\n * in that case.\n * - Don't register `DragPanBehaviour` / `WheelZoomBehaviour` /\n * `PinchZoomBehaviour` alongside this layer. They'd fight the map for\n * the camera, and on a pointer-events-none canvas they'd never see input\n * anyway.\n * - Cross-layer dependencies (e.g. a graph layer needing the projection)\n * declare their dep with an explicit `mapLayerId` option and resolve it\n * via `ctx.layers.get<MapLayer>(...)`. Don't reach for the layer by\n * guessing — see `architecture-proposal.md` §2.4.\n */\n\nimport { Layer, type CanvasContext, type LayerOptions } from '@invana/canvas';\nimport maplibregl from 'maplibre-gl';\n\nimport type {\n LngLat,\n MapLayerEvents,\n MapLayerOptions,\n MapLayerState,\n WorldPoint,\n} from './types';\n\n/** Width/height in world units of the whole earth at our reference zoom (= MapLibre tile size at zoom 0). */\nconst WORLD_SIZE = 512;\n\nconst DEFAULT_STYLE_URL = 'https://tiles.openfreemap.org/styles/liberty';\n\nexport class MapLayer extends Layer<MapLayerOptions, MapLayerState, MapLayerEvents> {\n private map: maplibregl.Map | null = null;\n private mapContainer: HTMLDivElement | null = null;\n private ownsMapContainer = false;\n private originalCanvasPointerEvents: string | null = null;\n private originalCanvasPosition: string | null = null;\n private originalCanvasZIndex: string | null = null;\n /**\n * Last camera scale we pushed to the bus on a `camera:zoom` event.\n * `syncCameraFromMap` writes the viewport directly (it has to, to mirror\n * MapLibre's transform exactly), so the bus only learns about zoom\n * changes when we emit them. Skip the emit when scale is unchanged\n * (pan-only frames) so listeners like `ScreenSizeBehaviour` don't pay\n * the O(N) reflow cost on every pan tick.\n */\n private lastEmittedScale: number | null = null;\n private readonly handleMapMove = (): void => this.syncCameraFromMap();\n\n constructor(opts: LayerOptions<MapLayerOptions>) {\n super({\n ...opts,\n // The map is a basemap — clicks on empty map area should fall through\n // to the (non-existent) layer below, not be claimed here. Domain\n // layers above still hit-test normally.\n hittable: opts.hittable ?? false,\n // Always render — the map fills the whole viewport regardless of\n // where graph content sits.\n cullable: opts.cullable ?? false,\n });\n }\n\n /** The underlying MapLibre Map. `null` before mount / after unmount. */\n get maplibre(): maplibregl.Map | null {\n return this.map;\n }\n\n protected createState(): MapLayerState {\n return { ready: false };\n }\n\n /**\n * Project a geographic coordinate to canvas world coordinates.\n *\n * Returns mercator pixels at zoom 0 (a 512×512 square for the whole\n * earth). Stable across map zoom — pin nodes once at setup and let the\n * camera handle the rest.\n *\n * @example\n * const { x, y } = mapLayer.project([airport.lng, airport.lat]);\n * graphLayer.setData({ nodes: [{ id, position: { x, y }, ... }], ... });\n */\n project(lngLat: LngLat): WorldPoint {\n const [lng, lat] = lngLat;\n const clampedLat = Math.max(-85.05112878, Math.min(85.05112878, lat));\n const sin = Math.sin((clampedLat * Math.PI) / 180);\n const x = ((lng + 180) / 360) * WORLD_SIZE;\n const y = (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * WORLD_SIZE;\n return { x, y };\n }\n\n /**\n * Inverse of {@link project} — world coords back to `[lng, lat]`. Useful\n * for hit-testing or reporting the geographic location under a cursor.\n */\n unproject(world: WorldPoint): [number, number] {\n const lng = (world.x / WORLD_SIZE) * 360 - 180;\n const n = Math.PI - 2 * Math.PI * (world.y / WORLD_SIZE);\n const lat = (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));\n return [lng, lat];\n }\n\n /** Pan/zoom the basemap to a new view. Camera follows automatically via `move`. */\n flyTo(opts: { center?: LngLat; zoom?: number; duration?: number }): void {\n if (!this.map) return;\n this.map.flyTo({\n center: opts.center ? [opts.center[0], opts.center[1]] : undefined,\n zoom: opts.zoom,\n duration: opts.duration ?? 1000,\n });\n }\n\n protected override onMount(ctx: CanvasContext): void {\n const canvasEl = ctx.canvasElement;\n if (!canvasEl) {\n throw new Error(\n `MapLayer \"${this.id}\" requires a DOM-mounted Canvas (canvas.init), ` +\n `not the headless initWithStage path — there's no element to mount the basemap into.`,\n );\n }\n\n const explicitTarget = this.options.mountTarget;\n if (explicitTarget) {\n this.mapContainer = explicitTarget as HTMLDivElement;\n } else {\n const host = canvasEl.parentElement;\n if (!host) {\n throw new Error(\n `MapLayer \"${this.id}\": Pixi canvas has no parent element to mount the basemap into.`,\n );\n }\n // Ensure absolute children stack correctly against the host.\n if (getComputedStyle(host).position === 'static') {\n host.style.position = 'relative';\n }\n const div = document.createElement('div');\n div.dataset.invanaMaplayerId = this.id;\n // `z-index: 0` keeps the map below the canvas (which we hoist to\n // `z-index: 1` below). DOM-order alone isn't enough: an absolutely-\n // positioned sibling stacks above a statically-positioned one\n // regardless of source order, which would put the basemap on top of\n // the Pixi canvas and hide everything drawn there.\n div.style.cssText =\n 'position:absolute; inset:0; width:100%; height:100%; z-index:0; pointer-events:auto;';\n host.insertBefore(div, host.firstChild);\n this.mapContainer = div;\n this.ownsMapContainer = true;\n }\n\n // Hoist the Pixi canvas above the basemap. `z-index` is a no-op on a\n // statically-positioned element, so we also force `position: relative`\n // (preserves layout flow). Originals are stashed and restored on unmount.\n this.originalCanvasPosition = canvasEl.style.position;\n this.originalCanvasZIndex = canvasEl.style.zIndex;\n if (getComputedStyle(canvasEl).position === 'static') {\n canvasEl.style.position = 'relative';\n }\n canvasEl.style.zIndex = '1';\n\n // Pixi canvas → input-transparent so MapLibre's gestures reach the map.\n if (this.options.passInputToMap ?? true) {\n this.originalCanvasPointerEvents = canvasEl.style.pointerEvents;\n canvasEl.style.pointerEvents = 'none';\n }\n\n const styleUrl = this.options.styleUrl ?? DEFAULT_STYLE_URL;\n const center = this.options.center ?? [0, 20];\n const zoom = this.options.zoom ?? 1.5;\n\n this.map = new maplibregl.Map({\n container: this.mapContainer,\n // MapLibre's typings accept `string | StyleSpecification`; we widen to\n // `object` in our public types and cast here.\n style: styleUrl as string,\n center: [center[0], center[1]],\n zoom,\n minZoom: this.options.minZoom ?? 0,\n maxZoom: this.options.maxZoom ?? 22,\n // Lock orientation: our pixi camera is uniform-scale, no rotation/tilt.\n bearing: 0,\n pitch: 0,\n dragRotate: false,\n pitchWithRotate: false,\n touchPitch: false,\n attributionControl: { compact: true },\n });\n\n this.map.on('move', this.handleMapMove);\n this.map.on('load', () => {\n this.state.setState((s) => {\n s.ready = true;\n });\n this.events.emit('map:ready', {\n center: [this.map!.getCenter().lng, this.map!.getCenter().lat],\n zoom: this.map!.getZoom(),\n });\n this.syncCameraFromMap();\n });\n\n // Sync once up front so the canvas transform is in the right place\n // before tiles finish loading.\n this.syncCameraFromMap();\n }\n\n protected override onUnmount(ctx: CanvasContext): void {\n if (this.map) {\n this.map.off('move', this.handleMapMove);\n this.map.remove();\n this.map = null;\n }\n if (this.ownsMapContainer && this.mapContainer?.parentElement) {\n this.mapContainer.parentElement.removeChild(this.mapContainer);\n }\n this.mapContainer = null;\n this.ownsMapContainer = false;\n\n if (ctx.canvasElement) {\n const el = ctx.canvasElement;\n if (this.originalCanvasPointerEvents !== null) {\n el.style.pointerEvents = this.originalCanvasPointerEvents;\n }\n if (this.originalCanvasPosition !== null) {\n el.style.position = this.originalCanvasPosition;\n }\n if (this.originalCanvasZIndex !== null) {\n el.style.zIndex = this.originalCanvasZIndex;\n }\n }\n this.originalCanvasPointerEvents = null;\n this.originalCanvasPosition = null;\n this.originalCanvasZIndex = null;\n this.lastEmittedScale = null;\n }\n\n /**\n * Solve for the pixi-viewport transform that lines our world axes up with\n * MapLibre's screen pixels.\n *\n * Pixi viewport projects `world -> screen` as `s = w * scale + position`.\n * We pick a fixed reference point (`lng=0, lat=0` — the mercator equator\n * meridian intersection, world coord `(256, 256)` at our reference zoom)\n * and solve:\n *\n * screen_of_lat0lng0 = worldRef * (2 ** map.zoom) + viewport.position\n *\n * `screen_of_lat0lng0` comes straight from `map.project([0, 0])` —\n * MapLibre handles all the map's internal padding / world-wrap / pixel\n * ratio for us. We then rewrite `viewport.position` to match.\n */\n private syncCameraFromMap(): void {\n const map = this.map;\n const ctx = this.ctx;\n if (!map || !ctx) return;\n\n const zoom = map.getZoom();\n const scale = Math.pow(2, zoom);\n\n const ref: LngLat = [0, 0];\n const screenRef = map.project([ref[0], ref[1]]);\n const worldRef = this.project(ref);\n\n const tx = screenRef.x - worldRef.x * scale;\n const ty = screenRef.y - worldRef.y * scale;\n\n // Direct viewport writes — `camera.setZoom` / `setPosition` would\n // re-anchor at the viewport centre (their own math), which doesn't\n // match MapLibre's exact transform. We mirror the transform raw, then\n // bridge the resulting camera change onto the canvas event bus below\n // so behaviours subscribed to `camera:zoom` / `camera:pan` (e.g.\n // `ScreenSizeBehaviour`, `LabelResolutionLODBehaviour`) react to\n // MapLibre-driven pan/zoom too. Without this bridge those listeners\n // are silently never called when the map drives the camera.\n ctx.camera.viewport.scale.set(scale);\n ctx.camera.viewport.position.set(tx, ty);\n\n // `camera:zoom` only when scale actually changes — most map gestures\n // are pan-only and the reflow listeners are O(N) over their tracked\n // entities, so we don't want to fire on every move tick during a\n // long pan.\n if (this.lastEmittedScale === null || this.lastEmittedScale !== scale) {\n ctx.events.emit('camera:zoom', {\n scale,\n centerX: ctx.camera.screenWidth / 2,\n centerY: ctx.camera.screenHeight / 2,\n });\n this.lastEmittedScale = scale;\n }\n ctx.events.emit('camera:pan', { x: tx, y: ty });\n\n this.events.emit('map:move', {\n center: [map.getCenter().lng, map.getCenter().lat],\n zoom,\n });\n }\n}\n","/**\n * Spherical interpolation between two `[lng, lat]` points along the\n * great-circle (shortest path on the sphere). Returns `n` evenly-spaced\n * samples *including* both endpoints.\n *\n * Used by stories drawing flight routes / airline arcs: the projected\n * polyline of these samples reads as a smooth curve on a mercator basemap.\n * Pure function, no engine dependency — exposed from\n * `@invana/graph-layer-maplibre` because it pairs with {@link MapLayer.project},\n * but works fine without the layer too.\n *\n * Algorithm: classic Slerp on unit-sphere 3-vectors derived from\n * `(lng, lat)`. Falls back to linear interpolation when the two points are\n * effectively coincident (angle ≈ 0), which keeps tiny self-loops from\n * dividing by zero in `sin(0)`.\n */\n\nexport type LngLatTuple = readonly [number, number];\n\nconst DEG = Math.PI / 180;\nconst RAD = 180 / Math.PI;\n\nfunction toCartesian(lng: number, lat: number): [number, number, number] {\n const clng = Math.cos(lng * DEG);\n const slng = Math.sin(lng * DEG);\n const clat = Math.cos(lat * DEG);\n const slat = Math.sin(lat * DEG);\n return [clat * clng, clat * slng, slat];\n}\n\nfunction toLngLat(v: [number, number, number]): [number, number] {\n const [x, y, z] = v;\n const lng = Math.atan2(y, x) * RAD;\n const lat = Math.atan2(z, Math.sqrt(x * x + y * y)) * RAD;\n return [lng, lat];\n}\n\n/**\n * Sample `n` points (`n >= 2`) along the great circle from `from` to `to`.\n *\n * - `n = 2` returns just the endpoints.\n * - `n = 32` (the typical default for flight arcs) gives a visually-smooth\n * curve at most map zooms; bump to 64+ for long transoceanic routes.\n */\nexport function greatCircleSamples(\n from: LngLatTuple,\n to: LngLatTuple,\n n: number,\n): [number, number][] {\n if (n < 2) throw new Error(`greatCircleSamples: n must be >= 2 (got ${n})`);\n const a = toCartesian(from[0], from[1]);\n const b = toCartesian(to[0], to[1]);\n\n // Angle between the two unit vectors.\n const dot = Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2]));\n const omega = Math.acos(dot);\n const sinOmega = Math.sin(omega);\n\n const out: [number, number][] = new Array(n);\n\n // Coincident / near-coincident: slerp degenerates; linear-interpolate in\n // lng/lat directly — small enough that mercator distortion is invisible.\n if (sinOmega < 1e-9) {\n for (let i = 0; i < n; i++) {\n const t = i / (n - 1);\n out[i] = [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t];\n }\n return out;\n }\n\n for (let i = 0; i < n; i++) {\n const t = i / (n - 1);\n const s1 = Math.sin((1 - t) * omega) / sinOmega;\n const s2 = Math.sin(t * omega) / sinOmega;\n const v: [number, number, number] = [\n s1 * a[0] + s2 * b[0],\n s1 * a[1] + s2 * b[1],\n s1 * a[2] + s2 * b[2],\n ];\n out[i] = toLngLat(v);\n }\n return out;\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@invana/graph-layer-maplibre",
3
+ "version": "0.0.1",
4
+ "description": "MapLibre GL JS basemap Layer for @invana/canvas — projects graph nodes onto a real interactive world map.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "typedocOptions": {
9
+ "entryPoints": [
10
+ "src/index.ts"
11
+ ]
12
+ },
13
+ "dependencies": {
14
+ "maplibre-gl": "^4.7.1"
15
+ },
16
+ "peerDependencies": {
17
+ "pixi.js": "^8.18.1",
18
+ "@invana/canvas": "0.0.1"
19
+ },
20
+ "devDependencies": {
21
+ "pixi.js": "^8.18.1",
22
+ "tsup": "^8.3.5",
23
+ "typescript": "5.9.2",
24
+ "@invana/canvas": "0.0.1",
25
+ "@repo/typescript-config": "0.0.0"
26
+ },
27
+ "keywords": [
28
+ "canvas",
29
+ "graph",
30
+ "layer",
31
+ "maplibre",
32
+ "map",
33
+ "geo",
34
+ "basemap"
35
+ ],
36
+ "license": "Apache-2.0",
37
+ "module": "./dist/index.js",
38
+ "exports": {
39
+ ".": {
40
+ "types": "./dist/index.d.ts",
41
+ "import": "./dist/index.js",
42
+ "default": "./dist/index.js"
43
+ }
44
+ },
45
+ "files": [
46
+ "dist"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "build": "tsup",
53
+ "dev": "tsup --watch",
54
+ "lint": "eslint src/",
55
+ "check-types": "tsc --noEmit",
56
+ "clean": "rm -rf dist"
57
+ }
58
+ }