flet-box 1.0.6 → 1.0.7

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.
Files changed (94) hide show
  1. package/README.md +28 -1
  2. package/bin/cli.js +77 -11
  3. package/bin/commands/auditFix.js +682 -0
  4. package/bin/commands/checkApi.js +7 -5
  5. package/bin/commands/create.js +40 -10
  6. package/bin/commands/createBundle.js +54 -1
  7. package/bin/commands/images.js +349 -0
  8. package/bin/commands/pruneAssets.js +170 -0
  9. package/bin/commands/release.js +34 -1
  10. package/bin/commands/runCloudflare.js +47 -9
  11. package/bin/commands/runServer.js +81 -24
  12. package/bin/commands/toPng.js +346 -0
  13. package/bin/commands/unpack.js +1173 -0
  14. package/bin/ota/loader.js +90 -0
  15. package/bin/utils/appSources.js +101 -0
  16. package/bin/utils/bundleConfig.js +166 -0
  17. package/bin/utils/dtsProps.js +147 -0
  18. package/bin/utils/imageTools.js +271 -0
  19. package/bin/utils/images/bmp.js +99 -0
  20. package/bin/utils/images/gif.js +244 -0
  21. package/bin/utils/images/index.js +163 -0
  22. package/bin/utils/images/jpeg.js +643 -0
  23. package/bin/utils/images/png.js +486 -0
  24. package/bin/utils/images/unsupported.js +29 -0
  25. package/bin/utils/neutralise.js +109 -6
  26. package/bin/utils/templates.js +307 -64
  27. package/docs/cli/README.md +82 -0
  28. package/docs/guides/online-updates.md +57 -9
  29. package/docs/widget/Accordion.md +1 -0
  30. package/docs/widget/AdMob.md +1 -0
  31. package/docs/widget/AdSense.md +1 -0
  32. package/docs/widget/AnimatedBox.md +1 -0
  33. package/docs/widget/AnimatedText.md +1 -0
  34. package/docs/widget/Audio.md +2 -1
  35. package/docs/widget/Avatar.md +12 -3
  36. package/docs/widget/Badge.md +1 -0
  37. package/docs/widget/Button.md +1 -0
  38. package/docs/widget/Card.md +1 -0
  39. package/docs/widget/Carousel.md +1 -0
  40. package/docs/widget/Chart.md +1 -0
  41. package/docs/widget/Checkbox.md +1 -0
  42. package/docs/widget/Chip.md +1 -0
  43. package/docs/widget/CircularBar.md +1 -0
  44. package/docs/widget/CircularChart.md +1 -0
  45. package/docs/widget/CodeViewer.md +1 -0
  46. package/docs/widget/Column.md +1 -0
  47. package/docs/widget/Container.md +1 -0
  48. package/docs/widget/DataTable.md +1 -0
  49. package/docs/widget/Divider.md +1 -0
  50. package/docs/widget/DraggBox.md +3 -2
  51. package/docs/widget/Dropdown.md +3 -0
  52. package/docs/widget/DroppBox.md +2 -1
  53. package/docs/widget/FloatingActionButton.md +1 -0
  54. package/docs/widget/GridView.md +1 -0
  55. package/docs/widget/Icon.md +1 -0
  56. package/docs/widget/Image.md +11 -3
  57. package/docs/widget/Input.md +1 -0
  58. package/docs/widget/InstallButton.md +1 -0
  59. package/docs/widget/ListTile.md +1 -0
  60. package/docs/widget/ListView.md +1 -0
  61. package/docs/widget/Maps.md +188 -0
  62. package/docs/widget/Markdown.md +1 -0
  63. package/docs/widget/MatrixRain.md +1 -0
  64. package/docs/widget/Pagination.md +1 -0
  65. package/docs/widget/ParallaxBox.md +1 -0
  66. package/docs/widget/ProgressBar.md +1 -0
  67. package/docs/widget/QRCode.md +1 -0
  68. package/docs/widget/README.md +12 -10
  69. package/docs/widget/Radio.md +1 -0
  70. package/docs/widget/Rating.md +1 -0
  71. package/docs/widget/Row.md +1 -0
  72. package/docs/widget/Skeleton.md +1 -0
  73. package/docs/widget/Slider.md +1 -0
  74. package/docs/widget/Stack.md +1 -0
  75. package/docs/widget/Stepper.md +1 -0
  76. package/docs/widget/Switch.md +1 -0
  77. package/docs/widget/Text.md +1 -0
  78. package/docs/widget/Tooltip.md +11 -13
  79. package/docs/widget/TreeView.md +1 -0
  80. package/docs/widget/Video.md +3 -2
  81. package/package.json +2 -2
  82. package/src/core/hmr-client.js +129 -34
  83. package/src/index.d.ts +132 -0
  84. package/src/index.js +1 -0
  85. package/src/navigations/AppBar.js +15 -4
  86. package/src/navigations/Drawer.js +9 -1
  87. package/src/widget-factory/translateProps.js +32 -1
  88. package/src/widgets/Avatar.js +228 -131
  89. package/src/widgets/Container.js +13 -0
  90. package/src/widgets/Dropdown.js +33 -1
  91. package/src/widgets/Image.js +102 -3
  92. package/src/widgets/Maps.js +1495 -0
  93. package/src/widgets/Tooltip.js +12 -2
  94. package/src/widgets/index.js +1 -0
@@ -0,0 +1,1495 @@
1
+ /**
2
+ * @file Maps.js
3
+ * @description
4
+ * Interactive slippy map — Web Mercator projection, raster tile grid, pan, zoom,
5
+ * markers and polylines, all implemented in this file.
6
+ *
7
+ * There is no map SDK on purpose. A Google/Mapbox dependency would need an API
8
+ * key, a CDN and network access at import time, and `flet-box check-api` could
9
+ * not verify any of it. Here a tile is a plain `<img>`, so any server that
10
+ * answers the `{z}/{x}/{y}` scheme works; the default is OpenStreetMap, and its
11
+ * attribution renders by default because their usage policy requires it. Pass
12
+ * `tileUrl` + `attribution` for another provider.
13
+ *
14
+ * Every layer is appended with `appendChild`, never through `child`/`children`:
15
+ * the factory re-creates those on any update that re-passes them, which would
16
+ * throw away the tile pool on every pan-driven `update()`.
17
+ */
18
+
19
+ import { WidgetFactory } from "../widget-factory/index.js";
20
+ import { colors } from "../utils/themes.js";
21
+ import { composeUpdate } from "../utils/composeUpdate.js";
22
+ import { isReactive, reactiveKeyOf, resolveReactive } from "../widget-factory/reactiveValue.js";
23
+ import { registerWidget } from "../tools/useState.js";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Projection constants and pure helpers (no DOM, no side effects)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** Edge of one raster tile in pixels — fixed by the XYZ tiling scheme. */
30
+ const TILE_SIZE = 256;
31
+
32
+ /** Web Mercator is undefined at the poles; this is the usual clamp (~85.0511°). */
33
+ const MAX_LATITUDE = 85.0511287798;
34
+
35
+ const DEFAULT_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
36
+ const DEFAULT_ATTRIBUTION = "© OpenStreetMap contributors";
37
+ const OSM_COPYRIGHT_URL = "https://www.openstreetmap.org/copyright";
38
+
39
+ /**
40
+ * How far the pointer has to leave the point it went down on before the press
41
+ * becomes a pan. Measuring distance from the origin — not the length of the
42
+ * path walked — is what keeps a shaky click a click: a finger or a trackpad
43
+ * trembles a pixel or two per event, which adds up past any total a press can
44
+ * honestly make, and a gesture promoted to a pan takes the `click` with it.
45
+ */
46
+ const PAN_START_SLOP = 4;
47
+
48
+ /**
49
+ * The teardrop is a 20 px square with `border-radius: 50% 50% 50% 0` turned
50
+ * -45°: its one sharp corner — the point that has to sit on the coordinate —
51
+ * ends up half a side right and `side/2 · (1 + √2)` down from the box. Every
52
+ * offset that positions a pin is derived from these two numbers, so the drop
53
+ * cannot drift off its own location again.
54
+ */
55
+ const PIN_WIDTH = 20;
56
+ const PIN_TIP_X = PIN_WIDTH / 2;
57
+ const PIN_TIP_Y = PIN_WIDTH / 2 + (PIN_WIDTH / 2) * Math.SQRT2;
58
+
59
+ /** Tiles kept around the viewport so a small pan does not flash empty grid. */
60
+ const TILE_BUFFER = 1;
61
+
62
+ const KEY_PAN_STEP = 64;
63
+
64
+ /** Wheel deltas smaller than this are noise (a trackpad tick, a nudge). */
65
+ const WHEEL_THRESHOLD = 40;
66
+
67
+ /** One wheel zoom per this many ms, so a flick does not jump ten levels. */
68
+ const WHEEL_COOLDOWN = 120;
69
+
70
+ /** `onMoveEnd` waits for the view to stop changing for this long. */
71
+ const SETTLE_DELAY = 140;
72
+
73
+ const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
74
+
75
+ /** Width of the whole world in pixels at zoom `z`. */
76
+ const worldSizePx = (z) => TILE_SIZE * 2 ** z;
77
+
78
+ /** Longitude → world X (px) at zoom `z`. */
79
+ const lngToWorldX = (lng, z) => ((lng + 180) / 360) * worldSizePx(z);
80
+
81
+ /** Latitude → world Y (px) at zoom `z` (Mercator, clamped to ±85.0511°). */
82
+ const latToWorldY = (lat, z) => {
83
+ const sin = Math.sin(clamp(lat, -MAX_LATITUDE, MAX_LATITUDE) * (Math.PI / 180));
84
+ return (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * worldSizePx(z);
85
+ };
86
+
87
+ const worldXToLng = (x, z) => (x / worldSizePx(z)) * 360 - 180;
88
+
89
+ const worldYToLat = (y, z) => {
90
+ const n = Math.PI * (1 - (2 * y) / worldSizePx(z));
91
+ return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
92
+ };
93
+
94
+ /**
95
+ * Keep a longitude inside [-180, 180) so `getCenter()` never reports 380°.
96
+ *
97
+ * The in-range case returns the value untouched: the modulo round trip is not
98
+ * exact in binary floating point, so `setCenter([48.8566, 2.3522])` would
99
+ * otherwise report `2.352200000000039` back — same point, broken equality.
100
+ */
101
+ const wrapLng = (lng) =>
102
+ lng >= -180 && lng < 180 ? lng : ((((lng + 180) % 360) + 360) % 360) - 180;
103
+
104
+ /**
105
+ * Accepts every position shape the API documents and returns `{lat, lng}`.
106
+ * `[lat, lng]`, `{lat, lng}` and `{latitude, longitude}` mean the same point.
107
+ *
108
+ * @param {*} value
109
+ * @returns {{lat: number, lng: number}|null} Null when nothing usable was given.
110
+ */
111
+ const normalizePosition = (value) => {
112
+ if (!value) return null;
113
+ if (Array.isArray(value)) {
114
+ const [lat, lng] = value;
115
+ if (typeof lat !== "number" || typeof lng !== "number") return null;
116
+ return { lat: clamp(lat, -MAX_LATITUDE, MAX_LATITUDE), lng };
117
+ }
118
+ if (typeof value !== "object") return null;
119
+ const lat = value.lat ?? value.latitude;
120
+ const lng = value.lng ?? value.longitude;
121
+ if (typeof lat !== "number" || typeof lng !== "number") return null;
122
+ return { lat: clamp(lat, -MAX_LATITUDE, MAX_LATITUDE), lng };
123
+ };
124
+
125
+ /** A bare position (array or `{lat, lng}`) instead of a `{position, …}` record. */
126
+ const isBarePosition = (value) => {
127
+ if (Array.isArray(value)) return true;
128
+ if (!value || typeof value !== "object") return false;
129
+ return value.lat !== undefined || value.latitude !== undefined;
130
+ };
131
+
132
+ /**
133
+ * A marker is either a bare position or
134
+ * `{position, label, color, id, rotation, data}`.
135
+ *
136
+ * @param {*} raw
137
+ * @param {string} fallbackColor Colour for markers that do not set their own.
138
+ * @param {function(): string} nextId Supplies ids to unlabelled markers.
139
+ * @returns {Object|null}
140
+ */
141
+ const normalizeMarker = (raw, fallbackColor, nextId) => {
142
+ const bare = isBarePosition(raw);
143
+ const position = normalizePosition(bare ? raw : raw && raw.position);
144
+ if (!position) return null;
145
+ const base = bare ? {} : raw;
146
+ const ownColor = base.color || base.markerColor;
147
+ return {
148
+ id: base.id !== undefined && base.id !== null ? String(base.id) : nextId(),
149
+ position,
150
+ label: typeof base.label === "string" ? base.label : "",
151
+ color: ownColor || fallbackColor,
152
+ // Without this flag a later `markerColor` update would recolour pins that
153
+ // asked for a colour of their own.
154
+ hasOwnColor: Boolean(ownColor),
155
+ // Degrees clockwise from north, the compass convention a GPS fix reports.
156
+ // Kept as given: normalising 390° to 30° here would make `getMarkers()`
157
+ // disagree with the app on every frame of a turning vehicle.
158
+ rotation: typeof base.rotation === "number" && Number.isFinite(base.rotation) ? base.rotation : 0,
159
+ // A tracked vehicle is drawn exactly where a finger taps to choose a pick-up
160
+ // point: a pin that does not answer to taps must not swallow them either.
161
+ clickable: base.clickable !== false,
162
+ data: base.data,
163
+ };
164
+ };
165
+
166
+ /** Stroke width for a line that does not ask for one. */
167
+ const DEFAULT_LINE_WIDTH = 4;
168
+
169
+ /**
170
+ * A line is either a bare list of positions or
171
+ * `{points, color, width, opacity, dashed, id}`.
172
+ *
173
+ * Two usable points is the minimum. A one-point line is dropped rather than
174
+ * painted as a dot: a route that silently turns into a speck is harder to
175
+ * debug than a call that returns `null`.
176
+ *
177
+ * @param {*} raw
178
+ * @param {string} fallbackColor Stroke for lines that do not set their own.
179
+ * @param {function(): string} nextId Supplies ids to unlabelled lines.
180
+ * @returns {Object|null}
181
+ */
182
+ const normalizeLine = (raw, fallbackColor, nextId) => {
183
+ const bare = Array.isArray(raw);
184
+ const base = bare ? {} : raw;
185
+ const source = bare ? raw : base && (base.points ?? base.positions);
186
+ const points = (Array.isArray(source) ? source : [])
187
+ .map((point) => normalizePosition(point))
188
+ .filter(Boolean);
189
+ if (points.length < 2) return null;
190
+ const ownColor = base.color || base.strokeColor;
191
+ return {
192
+ id: base.id !== undefined && base.id !== null ? String(base.id) : nextId(),
193
+ points,
194
+ color: ownColor || fallbackColor,
195
+ // Without this flag a later `polylineColor` update would recolour lines
196
+ // that asked for a stroke of their own.
197
+ hasOwnColor: Boolean(ownColor),
198
+ width: typeof base.width === "number" && base.width > 0 ? base.width : DEFAULT_LINE_WIDTH,
199
+ opacity: typeof base.opacity === "number" ? clamp(base.opacity, 0, 1) : 1,
200
+ dashed: Boolean(base.dashed),
201
+ };
202
+ };
203
+
204
+ /**
205
+ * Bounds accept `[[south, west], [north, east]]`, `{south, west, north, east}`
206
+ * or a list of positions.
207
+ *
208
+ * @param {*} value
209
+ * @returns {{south:number, west:number, north:number, east:number}|null}
210
+ */
211
+ const normalizeBounds = (value) => {
212
+ if (!value || typeof value !== "object") return null;
213
+ if (!Array.isArray(value)) {
214
+ const south = value.south ?? value.minLat;
215
+ const north = value.north ?? value.maxLat;
216
+ const west = value.west ?? value.minLng;
217
+ const east = value.east ?? value.maxLng;
218
+ if ([south, north, west, east].some((n) => typeof n !== "number")) return null;
219
+ return { south, west, north, east };
220
+ }
221
+ const points = value.map((entry) => normalizePosition(entry)).filter(Boolean);
222
+ if (points.length === 0) return null;
223
+ const lats = points.map((point) => point.lat);
224
+ const lngs = points.map((point) => point.lng);
225
+ return {
226
+ south: Math.min(...lats),
227
+ north: Math.max(...lats),
228
+ west: Math.min(...lngs),
229
+ east: Math.max(...lngs),
230
+ };
231
+ };
232
+
233
+ /** Some providers shard their tiles over `{s}`; fill it only when asked. */
234
+ const SUBDOMAINS = "abc";
235
+
236
+ /**
237
+ * Expand an XYZ tile template.
238
+ * @param {string} template
239
+ * @param {number} z
240
+ * @param {number} x already wrapped into [0, 2^z)
241
+ * @param {number} y
242
+ * @returns {string}
243
+ */
244
+ const buildTileUrl = (template, z, x, y) =>
245
+ template
246
+ .replace("{z}", String(z))
247
+ .replace("{x}", String(x))
248
+ .replace("{y}", String(y))
249
+ .replace("{s}", SUBDOMAINS[(x + y) % SUBDOMAINS.length]);
250
+
251
+ /** Write a batch of declarations onto a raw element, bypassing the factory. */
252
+ const paint = (el, text) => {
253
+ el.style.cssText = text;
254
+ return el;
255
+ };
256
+
257
+ /**
258
+ * Props owned by the Maps closure. They are routed to `applyWidgetProps` and must
259
+ * never reach the factory: `center`/`markers` would be written as invalid CSS and
260
+ * `onMapClick` would be sliced into a `mapclick` listener that never fires.
261
+ */
262
+ const MAPS_PROPS = [
263
+ "center", "zoom", "minZoom", "maxZoom", "markers", "markerColor",
264
+ "polylines", "polylineColor",
265
+ "tileUrl", "attribution", "showAttribution",
266
+ "interactive", "scrollZoom", "doubleClickZoom", "showZoomControl",
267
+ "showUserLocation", "followUser",
268
+ "onMapClick", "onMarkerClick", "onMoveEnd", "onUserUpdate",
269
+ ];
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Widget
273
+ // ---------------------------------------------------------------------------
274
+
275
+ /**
276
+ * Creates a Maps widget: a pannable, zoomable raster map with markers.
277
+ *
278
+ * @param {Object} [props]
279
+ * @param {Array|Object} [props.center] - View centre, `[lat, lng]` or `{lat, lng}`.
280
+ * @param {number} [props.zoom=2] - Initial integer zoom level.
281
+ * @param {number} [props.minZoom=0] - Lowest zoom the user can reach.
282
+ * @param {number} [props.maxZoom=19] - Highest zoom the user can reach.
283
+ * @param {Array} [props.markers=[]] - Positions, or `{position, label, color, id, data}`.
284
+ * @param {string} [props.markerColor] - Colour of markers without their own one.
285
+ * @param {Array} [props.polylines=[]] - Position lists, or `{points, color, width, opacity, dashed, id}`.
286
+ * @param {string} [props.polylineColor] - Stroke for lines without their own one.
287
+ * @param {string} [props.tileUrl] - `{z}/{x}/{y}` tile template.
288
+ * @param {string} [props.attribution] - Credit line; most providers require it.
289
+ * @param {boolean} [props.showAttribution=true] - Render the credit line.
290
+ * @param {boolean} [props.showZoomControl=true] - Render the ± buttons.
291
+ * @param {boolean} [props.interactive=true] - Allow pan, zoom and keyboard.
292
+ * @param {boolean} [props.scrollZoom=true] - Allow wheel zoom.
293
+ * @param {boolean} [props.doubleClickZoom=true] - Allow double-click zoom.
294
+ * @param {boolean} [props.showUserLocation=false] - Draw the browser position.
295
+ * @param {boolean} [props.followUser=false] - Keep the view centred on it.
296
+ * @param {function} [props.onMapClick] - `(point, event)` on a stationary click.
297
+ * @param {function} [props.onMarkerClick] - `(marker, event)` on a pin.
298
+ * @param {function} [props.onMoveEnd] - `(state)` once the view settles.
299
+ * @param {function} [props.onUserUpdate] - `(fix)` on each watched position.
300
+ * @returns {HTMLElement} The map element, extended with the imperative map API.
301
+ */
302
+ export const Maps = (inputProps = {}) => {
303
+ // The factory never receives the owned keys, so it can neither resolve a
304
+ // `useState` value for them (a live reactive is a function, and
305
+ // `normalizePosition` would reject it) nor subscribe the widget to the key.
306
+ // Both happen here instead: read the values once, keep the keys, bind later.
307
+ const boundKeys = new Map();
308
+ const props = { ...inputProps };
309
+ for (const key of MAPS_PROPS) {
310
+ const value = inputProps[key];
311
+ if (!isReactive(value)) continue;
312
+ props[key] = resolveReactive(value);
313
+ const stateKey = reactiveKeyOf(value);
314
+ if (stateKey) boundKeys.set(key, stateKey);
315
+ }
316
+
317
+ const {
318
+ center: centerProp = [20, 0],
319
+ zoom: zoomProp = 2,
320
+ minZoom = 0,
321
+ maxZoom = 19,
322
+ markers: markersProp = [],
323
+ markerColor = colors.primary,
324
+ polylines: polylinesProp = [],
325
+ polylineColor = colors.primary,
326
+ tileUrl = DEFAULT_TILE_URL,
327
+ attribution = DEFAULT_ATTRIBUTION,
328
+ showAttribution = true,
329
+ showZoomControl = true,
330
+ interactive = true,
331
+ scrollZoom = true,
332
+ doubleClickZoom = true,
333
+ showUserLocation = false,
334
+ followUser = false,
335
+ onMapClick,
336
+ onMarkerClick,
337
+ onMoveEnd,
338
+ onUserUpdate,
339
+ ...rest
340
+ } = props;
341
+
342
+ // -------------------------------------------------------------------------
343
+ // 1. Closure state (private, mutable)
344
+ // -------------------------------------------------------------------------
345
+
346
+ let center = normalizePosition(centerProp) || { lat: 0, lng: 0 };
347
+ let lowerZoom = Math.max(0, Math.floor(minZoom));
348
+ let upperZoom = Math.max(lowerZoom, Math.floor(maxZoom));
349
+ let zoom = clamp(Math.round(zoomProp), lowerZoom, upperZoom);
350
+ let markers = [];
351
+ let lines = [];
352
+ let tileTemplate = tileUrl;
353
+ let pinColor = markerColor;
354
+ let lineColor = polylineColor;
355
+ let credit = attribution;
356
+ let showsCredit = showAttribution;
357
+ let letsPan = interactive;
358
+ let wheelZooms = scrollZoom;
359
+ let dblClickZooms = doubleClickZoom;
360
+ let showsUser = showUserLocation;
361
+ let followsUser = followUser;
362
+ let clickHandler = onMapClick;
363
+ let pinClickHandler = onMarkerClick;
364
+ let moveEndHandler = onMoveEnd;
365
+ let userUpdateHandler = onUserUpdate;
366
+
367
+ let idSeq = 0;
368
+ const nextId = () => `m${++idSeq}`;
369
+ // A separate counter: a polyline and a pin must never share an id, since the
370
+ // two layers are removed from independently.
371
+ let lineIdSeq = 0;
372
+ const nextLineId = () => `l${++lineIdSeq}`;
373
+
374
+ /** @type {Map<string, {el: HTMLImageElement, settled: boolean, failed: boolean}>} */
375
+ const tiles = new Map();
376
+ /** @type {Map<string, HTMLElement>} */
377
+ const pinEls = new Map();
378
+
379
+ let viewport = { width: 0, height: 0 };
380
+ let frame = 0;
381
+ let settleTimer = 0;
382
+ let observer = null;
383
+ let watcherId = null;
384
+ let userPosition = null;
385
+ // "watch" while the background watcher owns the dot, "app" once something
386
+ // called setUserPosition()/locate() explicitly.
387
+ let userPositionOwner = null;
388
+ let running = false;
389
+ let disposed = false;
390
+ let drag = null;
391
+ let wheelAccum = 0;
392
+ let wheelLockUntil = 0;
393
+
394
+ // -------------------------------------------------------------------------
395
+ // 2. One factory root + the raw layers it never manages
396
+ // -------------------------------------------------------------------------
397
+
398
+ const container = WidgetFactory({
399
+ widgetName: "Maps",
400
+ position: "relative",
401
+ overflow: "hidden",
402
+ width: "100%",
403
+ height: 400,
404
+ backgroundColor: colors.surface,
405
+ cursor: letsPan ? "grab" : "default",
406
+ ...rest,
407
+ });
408
+
409
+ container.tabIndex = 0;
410
+ container.setAttribute("role", "application");
411
+ container.setAttribute(
412
+ "aria-label",
413
+ "Interactive map. Arrow keys pan, plus and minus zoom.",
414
+ );
415
+
416
+ // `pointer-events: none` on the tile layer: a tile that swallowed the pointer
417
+ // would break panning the moment the cursor crossed one.
418
+ const tileLayer = paint(
419
+ document.createElement("div"),
420
+ "position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:1;",
421
+ );
422
+ const lineCanvas = document.createElement("canvas");
423
+ paint(
424
+ lineCanvas,
425
+ "position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:1;",
426
+ );
427
+ const pinLayer = paint(
428
+ document.createElement("div"),
429
+ "position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:2;",
430
+ );
431
+ const userDot = paint(
432
+ document.createElement("div"),
433
+ "position:absolute;left:0;top:0;width:14px;height:14px;border-radius:50%;display:none;" +
434
+ "background:" + colors.primary + ";border:2px solid #fff;" +
435
+ "box-shadow:0 0 0 4px rgba(99,102,241,0.28);z-index:3;pointer-events:none;",
436
+ );
437
+ const notice = paint(
438
+ document.createElement("div"),
439
+ "position:absolute;left:8px;top:8px;max-width:calc(100% - 16px);padding:8px 12px;" +
440
+ "border-radius:8px;background:rgba(15,23,42,0.86);color:#fff;font-size:12px;" +
441
+ "line-height:1.4;display:none;z-index:5;pointer-events:none;",
442
+ );
443
+ notice.textContent =
444
+ "Map tiles could not be loaded. Check the connection, the tile server, or pass another tileUrl.";
445
+
446
+ const zoomControl = paint(
447
+ document.createElement("div"),
448
+ "position:absolute;right:8px;top:8px;display:" +
449
+ (showZoomControl ? "flex" : "none") +
450
+ ";flex-direction:column;gap:8px;z-index:4;",
451
+ );
452
+ const makeZoomButton = (glyph, label, action) => {
453
+ const button = document.createElement("button");
454
+ button.type = "button";
455
+ button.textContent = glyph;
456
+ button.setAttribute("aria-label", label);
457
+ paint(
458
+ button,
459
+ "width:32px;height:32px;padding:0;border-radius:8px;border:1px solid " +
460
+ colors.border + ";background:" + colors.card + ";color:" + colors.text +
461
+ ";font-size:18px;line-height:1;cursor:pointer;display:flex;" +
462
+ "align-items:center;justify-content:center;",
463
+ );
464
+ button.addEventListener("click", (event) => {
465
+ event.stopPropagation();
466
+ if (!disposed) action();
467
+ });
468
+ return button;
469
+ };
470
+ zoomControl.appendChild(makeZoomButton("+", "Zoom in", () => zoomAt(zoom + 1)));
471
+ zoomControl.appendChild(makeZoomButton("−", "Zoom out", () => zoomAt(zoom - 1)));
472
+
473
+ // Sits over the tiles, not over the app surface, so its ink is fixed rather
474
+ // than `colors.text`: in the dark theme that token is near-white and the
475
+ // credit line vanished into its own light chip.
476
+ const creditBox = paint(
477
+ document.createElement("div"),
478
+ "position:absolute;right:0;bottom:0;padding:4px 8px;border-radius:8px 0 0 0;" +
479
+ "background:rgba(15,23,42,0.72);color:#fff;" +
480
+ ";font-size:12px;z-index:4;",
481
+ );
482
+
483
+ container.appendChild(tileLayer);
484
+ // Same z-index as the tiles, so DOM order decides: this paints over the
485
+ // basemap and under the pins. Adding a layer never renumbers the existing ones.
486
+ container.appendChild(lineCanvas);
487
+ container.appendChild(pinLayer);
488
+ container.appendChild(userDot);
489
+ container.appendChild(notice);
490
+ container.appendChild(zoomControl);
491
+ container.appendChild(creditBox);
492
+
493
+ const rebuildCredit = () => {
494
+ creditBox.textContent = "";
495
+ creditBox.style.display = showsCredit && credit ? "block" : "none";
496
+ if (!showsCredit || !credit) return;
497
+ creditBox.appendChild(document.createTextNode(String(credit)));
498
+ // OSM's policy asks for a link to the copyright page, not just a name.
499
+ if (tileTemplate.includes("openstreetmap.org")) {
500
+ creditBox.appendChild(document.createTextNode(" · "));
501
+ const link = document.createElement("a");
502
+ link.href = OSM_COPYRIGHT_URL;
503
+ link.target = "_blank";
504
+ link.rel = "noreferrer noopener";
505
+ link.textContent = "copyright";
506
+ link.style.color = colors.primary;
507
+ creditBox.appendChild(link);
508
+ }
509
+ };
510
+
511
+ // -------------------------------------------------------------------------
512
+ // 3. Geometry — world pixels, container-relative screen pixels
513
+ // -------------------------------------------------------------------------
514
+
515
+ const measure = () => {
516
+ const rect = container.getBoundingClientRect();
517
+ viewport = {
518
+ width: Math.max(1, Math.round(rect.width)),
519
+ height: Math.max(1, Math.round(rect.height)),
520
+ };
521
+ return rect;
522
+ };
523
+
524
+ /** World pixel of the viewport's top-left corner. */
525
+ const originX = () => lngToWorldX(center.lng, zoom) - viewport.width / 2;
526
+ const originY = () => latToWorldY(center.lat, zoom) - viewport.height / 2;
527
+
528
+ /** Container-relative screen point → geographic point at the current zoom. */
529
+ const screenToGeo = (sx, sy) => ({
530
+ lat: worldYToLat(originY() + sy, zoom),
531
+ lng: worldXToLng(originX() + sx, zoom),
532
+ });
533
+
534
+ /** Geographic point → container-relative screen point. */
535
+ const geoToScreen = (lat, lng) => ({
536
+ x: lngToWorldX(lng, zoom) - originX(),
537
+ y: latToWorldY(lat, zoom) - originY(),
538
+ });
539
+
540
+ /** Client (page) coordinates → container-relative ones. */
541
+ const localFromClient = (clientX, clientY) => {
542
+ const rect = container.getBoundingClientRect();
543
+ return { x: clientX - rect.left, y: clientY - rect.top };
544
+ };
545
+
546
+ const viewState = () => ({
547
+ center: { lat: center.lat, lng: wrapLng(center.lng) },
548
+ zoom,
549
+ bounds: {
550
+ north: screenToGeo(0, 0).lat,
551
+ south: screenToGeo(0, viewport.height).lat,
552
+ west: screenToGeo(0, 0).lng,
553
+ east: screenToGeo(viewport.width, 0).lng,
554
+ },
555
+ });
556
+
557
+ // -------------------------------------------------------------------------
558
+ // 4. Tiles
559
+ // -------------------------------------------------------------------------
560
+
561
+ const dropTile = (key) => {
562
+ const record = tiles.get(key);
563
+ if (!record) return;
564
+ record.el.remove();
565
+ tiles.delete(key);
566
+ };
567
+
568
+ const createTile = (key, x, y) => {
569
+ const image = document.createElement("img");
570
+ image.alt = "";
571
+ image.decoding = "async";
572
+ paint(
573
+ image,
574
+ `position:absolute;left:0;top:0;width:${TILE_SIZE}px;height:${TILE_SIZE}px;` +
575
+ "max-width:none;opacity:0;transition:opacity 150ms linear;will-change:transform;" +
576
+ "user-select:none;-webkit-user-drag:none;",
577
+ );
578
+ const record = { el: image, settled: false, failed: false };
579
+ image.addEventListener("load", () => {
580
+ record.settled = true;
581
+ image.style.opacity = "1";
582
+ // A tile landing late can complete the layer, which is what releases the
583
+ // tiles held from the previous zoom.
584
+ scheduleRender();
585
+ });
586
+ image.addEventListener("error", () => {
587
+ record.settled = true;
588
+ record.failed = true;
589
+ scheduleRender();
590
+ });
591
+ image.src = buildTileUrl(tileTemplate, zoom, x, y);
592
+ tileLayer.appendChild(image);
593
+ tiles.set(key, record);
594
+ return record;
595
+ };
596
+
597
+ const clearTiles = () => {
598
+ for (const key of Array.from(tiles.keys())) dropTile(key);
599
+ notice.style.display = "none";
600
+ };
601
+
602
+ const renderTiles = () => {
603
+ if (viewport.width < 2 || viewport.height < 2) return;
604
+ const ox = originX();
605
+ const oy = originY();
606
+ const columns = 2 ** zoom;
607
+ const first = Math.floor(ox / TILE_SIZE) - TILE_BUFFER;
608
+ const last = Math.floor((ox + viewport.width) / TILE_SIZE) + TILE_BUFFER;
609
+ // Rows never wrap: outside 0..2^z-1 there is no world, only the surface colour.
610
+ const firstRow = clamp(Math.floor(oy / TILE_SIZE) - TILE_BUFFER, 0, columns - 1);
611
+ const lastRow = clamp(Math.floor((oy + viewport.height) / TILE_SIZE) + TILE_BUFFER, 0, columns - 1);
612
+
613
+ const wanted = new Set();
614
+ let needed = 0;
615
+ let settledCount = 0;
616
+ let failedCount = 0;
617
+
618
+ for (let y = firstRow; y <= lastRow; y++) {
619
+ for (let x = first; x <= last; x++) {
620
+ // `x` may fall outside the world when the map is zoomed out and panned
621
+ // sideways: the URL wraps, the placement does not, so the copies line up
622
+ // side by side instead of stacking on top of each other.
623
+ const key = `${zoom}/${x}/${y}`;
624
+ wanted.add(key);
625
+ let record = tiles.get(key);
626
+ if (!record) record = createTile(key, ((x % columns) + columns) % columns, y);
627
+ record.el.style.transform = `translate(${x * TILE_SIZE - ox}px, ${y * TILE_SIZE - oy}px)`;
628
+ needed++;
629
+ if (record.settled) settledCount++;
630
+ if (record.failed) failedCount++;
631
+ }
632
+ }
633
+
634
+ const layerIsUp = needed > 0 && settledCount === needed;
635
+ for (const key of Array.from(tiles.keys())) {
636
+ if (wanted.has(key)) continue;
637
+ // Hold the previous zoom until this one is on screen, then drop it.
638
+ if (Number(key.slice(0, key.indexOf("/"))) === zoom || layerIsUp) dropTile(key);
639
+ }
640
+
641
+ notice.style.display = needed > 0 && failedCount === needed ? "block" : "none";
642
+ };
643
+
644
+ // -------------------------------------------------------------------------
645
+ // 5. Markers
646
+ // -------------------------------------------------------------------------
647
+
648
+ const buildCaption = (label) => {
649
+ const el = paint(
650
+ document.createElement("span"),
651
+ `position:absolute;left:${PIN_TIP_X}px;top:${Math.ceil(PIN_TIP_Y) + 2}px;` +
652
+ "transform:translateX(-50%);" +
653
+ "padding:2px 6px;border-radius:4px;background:rgba(15,23,42,0.82);" +
654
+ "color:#fff;font-size:12px;line-height:1.3;white-space:nowrap;",
655
+ );
656
+ el.textContent = label;
657
+ return el;
658
+ };
659
+
660
+ /**
661
+ * A pin that does not answer to taps must not block them either: `none` lets
662
+ * the pointer fall through to the map surface, and `tabIndex` keeps it out of
663
+ * the keyboard order, where a click handler guard would not.
664
+ */
665
+ const applyClickable = (button, clickable) => {
666
+ button.style.pointerEvents = clickable ? "auto" : "none";
667
+ button.style.cursor = clickable ? "pointer" : "default";
668
+ button.tabIndex = clickable ? 0 : -1;
669
+ };
670
+
671
+ const buildPin = (marker) => {
672
+ const button = document.createElement("button");
673
+ button.type = "button";
674
+ button.setAttribute("aria-label", marker.label || "Map marker");
675
+ if (marker.label) button.title = marker.label;
676
+ // The box is exactly the drop, never the text under it: a button that sizes
677
+ // itself to its label centres the teardrop on the *label*, which slides it
678
+ // tens of pixels away from the point it marks — and steals every map click
679
+ // that lands on the caption.
680
+ paint(
681
+ button,
682
+ `position:absolute;left:0;top:0;width:${PIN_WIDTH}px;height:${Math.ceil(PIN_TIP_Y)}px;` +
683
+ "padding:0;border:none;background:transparent;" +
684
+ "will-change:transform;",
685
+ );
686
+ // After `paint`, whose cssText assignment wipes anything written before it.
687
+ button._clickable = marker.clickable;
688
+ applyClickable(button, marker.clickable);
689
+
690
+ // Teardrop: `border-radius` leaves the bottom-left corner sharp, and
691
+ // rotate(-45deg) swings that corner to the bottom centre — the anchor point.
692
+ // The head carries the marker's own bearing, and its origin is the tip: any
693
+ // other origin would swing the drop off the point it marks as it turns.
694
+ const head = paint(
695
+ document.createElement("span"),
696
+ `position:relative;display:block;width:${PIN_WIDTH}px;height:${Math.ceil(PIN_TIP_Y)}px;` +
697
+ `transform-origin:${PIN_TIP_X}px ${PIN_TIP_Y}px;`,
698
+ );
699
+ const pin = paint(
700
+ document.createElement("span"),
701
+ `display:block;width:${PIN_WIDTH}px;height:${PIN_WIDTH}px;box-sizing:border-box;` +
702
+ "border-radius:50% 50% 50% 0;" +
703
+ `background:${marker.color};border:2px solid #fff;` +
704
+ "box-shadow:0 1px 4px rgba(0,0,0,0.4);transform:rotate(-45deg);",
705
+ );
706
+ const dot = paint(
707
+ document.createElement("span"),
708
+ `position:absolute;left:${PIN_TIP_X - 2}px;top:${PIN_TIP_X - 2}px;width:4px;height:4px;` +
709
+ "border-radius:50%;background:rgba(255,255,255,0.92);",
710
+ );
711
+ head.appendChild(pin);
712
+ head.appendChild(dot);
713
+ button.appendChild(head);
714
+ button._pin = pin;
715
+ button._head = head;
716
+
717
+ if (marker.label) {
718
+ // Absolute and centred on the tip, so the caption can be any length without
719
+ // moving the drop or widening the hit box.
720
+ button._caption = buildCaption(marker.label);
721
+ button.appendChild(button._caption);
722
+ }
723
+
724
+ button.addEventListener("click", (event) => {
725
+ event.stopPropagation();
726
+ if (disposed || !button._clickable || typeof pinClickHandler !== "function") return;
727
+ // The pooled node outlives every update of its marker, so report the one
728
+ // it is showing now rather than the one it was built from.
729
+ pinClickHandler(button._marker, event);
730
+ });
731
+ return button;
732
+ };
733
+
734
+ const renderPins = () => {
735
+ const live = new Set();
736
+ for (const marker of markers) {
737
+ const { x, y } = geoToScreen(marker.position.lat, marker.position.lng);
738
+ const id = marker.id;
739
+ const el = pinEls.get(id);
740
+ const offscreen =
741
+ x < -80 || y < -120 || x > viewport.width + 80 || y > viewport.height + 120;
742
+ if (offscreen) {
743
+ if (el) {
744
+ el.remove();
745
+ pinEls.delete(id);
746
+ }
747
+ continue;
748
+ }
749
+ live.add(id);
750
+ let node = el;
751
+ if (!node) {
752
+ node = buildPin(marker);
753
+ pinEls.set(id, node);
754
+ pinLayer.appendChild(node);
755
+ }
756
+ // A pooled node is reused across frames, so only touch what changed.
757
+ node._marker = marker;
758
+ if (node._color !== marker.color) {
759
+ node._pin.style.background = marker.color;
760
+ node._color = marker.color;
761
+ }
762
+ if (node._rotation !== marker.rotation) {
763
+ node._head.style.transform = marker.rotation ? `rotate(${marker.rotation}deg)` : "";
764
+ node._rotation = marker.rotation;
765
+ }
766
+ if (node._clickable !== marker.clickable) {
767
+ node._clickable = marker.clickable;
768
+ applyClickable(node, marker.clickable);
769
+ }
770
+ // A moving pin usually changes its caption too — "Coche · 90°" is not the
771
+ // label it was built with. The node is pooled, so the text has to follow.
772
+ if (node._label !== marker.label) {
773
+ node._label = marker.label;
774
+ node.setAttribute("aria-label", marker.label || "Map marker");
775
+ node.title = marker.label || "";
776
+ if (!marker.label) {
777
+ if (node._caption) {
778
+ node._caption.remove();
779
+ node._caption = null;
780
+ }
781
+ } else if (node._caption) {
782
+ node._caption.textContent = marker.label;
783
+ } else {
784
+ node._caption = buildCaption(marker.label);
785
+ node.appendChild(node._caption);
786
+ }
787
+ }
788
+ // Subtracting the tip's offset — not half the box — puts the point of the
789
+ // teardrop on the coordinate, whatever the label next to it measures.
790
+ node.style.transform = `translate(${x - PIN_TIP_X}px, ${y - PIN_TIP_Y}px)`;
791
+ }
792
+ for (const [id, el] of Array.from(pinEls)) {
793
+ if (live.has(id)) continue;
794
+ el.remove();
795
+ pinEls.delete(id);
796
+ }
797
+ };
798
+
799
+ const renderLines = () => {
800
+ const ctx = lineCanvas.getContext("2d");
801
+ if (!ctx) return;
802
+ // A canvas is drawn in device pixels, so a CSS-sized element left at its
803
+ // default backing store strokes every line at half the intended width on a
804
+ // Retina screen. Reset the transform each frame: the ratio changes when a
805
+ // window moves between displays.
806
+ const dpr = Math.max(1, Math.round((window.devicePixelRatio || 1) * 100) / 100);
807
+ const pw = Math.round(viewport.width * dpr);
808
+ const ph = Math.round(viewport.height * dpr);
809
+ if (lineCanvas.width !== pw) lineCanvas.width = pw;
810
+ if (lineCanvas.height !== ph) lineCanvas.height = ph;
811
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
812
+ ctx.clearRect(0, 0, viewport.width, viewport.height);
813
+ if (viewport.width < 2 || viewport.height < 2 || lines.length === 0) return;
814
+ ctx.lineJoin = "round";
815
+ ctx.lineCap = "round";
816
+
817
+ const visible = viewState().bounds;
818
+ for (const line of lines) {
819
+ // Cull on the geographic box: a walk route is thousands of coordinates,
820
+ // and the ones two cities away cost the same frame time as the visible ones.
821
+ let minLat = line.points[0].lat, maxLat = minLat;
822
+ let minLng = line.points[0].lng, maxLng = minLng;
823
+ for (const point of line.points) {
824
+ if (point.lat < minLat) minLat = point.lat;
825
+ else if (point.lat > maxLat) maxLat = point.lat;
826
+ if (point.lng < minLng) minLng = point.lng;
827
+ else if (point.lng > maxLng) maxLng = point.lng;
828
+ }
829
+ if (
830
+ maxLat < visible.south || minLat > visible.north ||
831
+ maxLng < visible.west || minLng > visible.east
832
+ ) continue;
833
+
834
+ ctx.beginPath();
835
+ for (let i = 0; i < line.points.length; i++) {
836
+ const { x, y } = geoToScreen(line.points[i].lat, line.points[i].lng);
837
+ if (i === 0) ctx.moveTo(x, y);
838
+ else ctx.lineTo(x, y);
839
+ }
840
+ ctx.globalAlpha = line.opacity;
841
+ ctx.strokeStyle = line.color;
842
+ ctx.lineWidth = line.width;
843
+ ctx.setLineDash(line.dashed ? [line.width * 3, line.width * 2] : []);
844
+ ctx.stroke();
845
+ }
846
+ ctx.globalAlpha = 1;
847
+ };
848
+
849
+ const renderUserDot = () => {
850
+ // Either flag needs the position on screen: `showUserLocation` draws the dot,
851
+ // `followUser` is pointless without it.
852
+ if (!(showsUser || followsUser) || !userPosition) {
853
+ userDot.style.display = "none";
854
+ return;
855
+ }
856
+ const { x, y } = geoToScreen(userPosition.lat, userPosition.lng);
857
+ userDot.style.display = "block";
858
+ userDot.style.transform = `translate(${x - 7}px, ${y - 7}px)`;
859
+ };
860
+
861
+ // -------------------------------------------------------------------------
862
+ // 6. Render loop
863
+ // -------------------------------------------------------------------------
864
+
865
+ const paintFrame = () => {
866
+ frame = 0;
867
+ if (disposed || !running) return;
868
+ measure();
869
+ renderTiles();
870
+ renderLines();
871
+ renderPins();
872
+ renderUserDot();
873
+ };
874
+
875
+ const scheduleRender = () => {
876
+ if (disposed || frame) return;
877
+ frame = requestAnimationFrame(paintFrame);
878
+ };
879
+
880
+ /** Every view change funnels through here: repaint now, report once it settles. */
881
+ const invalidate = () => {
882
+ scheduleRender();
883
+ if (typeof moveEndHandler !== "function") return;
884
+ clearTimeout(settleTimer);
885
+ settleTimer = setTimeout(() => {
886
+ if (!disposed) moveEndHandler(viewState());
887
+ }, SETTLE_DELAY);
888
+ };
889
+
890
+ // -------------------------------------------------------------------------
891
+ // 7. View mutations
892
+ // -------------------------------------------------------------------------
893
+
894
+ /**
895
+ * Change the zoom, keeping the geographic point under `(sx, sy)` — when given —
896
+ * fixed on screen. That is what makes wheel zoom follow the cursor.
897
+ * @param {number} next
898
+ * @param {{x:number, y:number}} [anchor] container-relative pixels
899
+ * @returns {boolean} whether the zoom actually changed
900
+ */
901
+ const applyZoom = (next, anchor) => {
902
+ if (typeof next !== "number" || !Number.isFinite(next)) return false;
903
+ const target = clamp(Math.round(next), lowerZoom, upperZoom);
904
+ if (target === zoom) return false;
905
+ const geo = anchor ? screenToGeo(anchor.x, anchor.y) : null;
906
+ zoom = target;
907
+ if (geo) {
908
+ center = {
909
+ lat: clamp(
910
+ worldYToLat(latToWorldY(geo.lat, target) - (anchor.y - viewport.height / 2), target),
911
+ -MAX_LATITUDE,
912
+ MAX_LATITUDE,
913
+ ),
914
+ lng: wrapLng(
915
+ worldXToLng(lngToWorldX(geo.lng, target) - (anchor.x - viewport.width / 2), target),
916
+ ),
917
+ };
918
+ }
919
+ return true;
920
+ };
921
+
922
+ const zoomAt = (next, clientPoint) => {
923
+ let anchor;
924
+ if (clientPoint) {
925
+ measure();
926
+ const local = localFromClient(clientPoint.x, clientPoint.y);
927
+ anchor = { x: local.x, y: local.y };
928
+ }
929
+ if (applyZoom(next, anchor)) invalidate();
930
+ return container;
931
+ };
932
+
933
+ const panByPixels = (dx, dy) => {
934
+ const next = {
935
+ lng: worldXToLng(lngToWorldX(center.lng, zoom) - dx, zoom),
936
+ lat: worldYToLat(latToWorldY(center.lat, zoom) - dy, zoom),
937
+ };
938
+ center = { lat: clamp(next.lat, -MAX_LATITUDE, MAX_LATITUDE), lng: wrapLng(next.lng) };
939
+ invalidate();
940
+ };
941
+
942
+ const goTo = (position, nextZoom) => {
943
+ const point = normalizePosition(position);
944
+ if (point) center = point;
945
+ if (nextZoom !== undefined) applyZoom(nextZoom);
946
+ invalidate();
947
+ return container;
948
+ };
949
+
950
+ // -------------------------------------------------------------------------
951
+ // 8. Interaction
952
+ // -------------------------------------------------------------------------
953
+
954
+ /**
955
+ * Did this pointer land on the map itself?
956
+ *
957
+ * Every layer that only *shows* something — tiles, the line canvas, the user
958
+ * dot, the notice — is `pointer-events: none`, so the container is the target
959
+ * of any press on the map surface. Anything else is the widget's own chrome
960
+ * (the ± buttons, the credit link, a pin) and has its own behaviour: the
961
+ * bottom-right of the view is not the place the user meant when they pressed
962
+ * `+`, and reporting it anyway moves the app's pin there on every zoom step.
963
+ * @param {Event} event
964
+ * @returns {boolean}
965
+ */
966
+ const isMapSurface = (event) => event.target === container;
967
+
968
+ container.addEventListener("pointerdown", (event) => {
969
+ if (!letsPan || disposed || event.button !== 0) return;
970
+ // `x`/`y` stay at the pointerdown point until the slop breaks, so the pan
971
+ // measures distance from the press and not the length of the path walked.
972
+ drag = {
973
+ id: event.pointerId,
974
+ x: event.clientX,
975
+ y: event.clientY,
976
+ panning: false,
977
+ captured: false,
978
+ };
979
+ });
980
+
981
+ container.addEventListener("pointermove", (event) => {
982
+ if (!drag || event.pointerId !== drag.id) return;
983
+ // Until the press is a pan, the gesture is a click in progress: the view
984
+ // stays put and the anchor keeps the pointerdown point, so the first real
985
+ // drag step moves the map by the whole displacement and stays glued to it.
986
+ if (!drag.panning) {
987
+ const offX = event.clientX - drag.x;
988
+ const offY = event.clientY - drag.y;
989
+ if (Math.abs(offX) <= PAN_START_SLOP && Math.abs(offY) <= PAN_START_SLOP) return;
990
+ drag.panning = true;
991
+ drag.captured = true;
992
+ container.style.cursor = "grabbing";
993
+ // Capturing re-targets every later pointer *and* mouse event to the map,
994
+ // so a pin under this press stops reporting clicks — which is exactly what
995
+ // a pan wants, and the reason the capture waits for the slop to break.
996
+ try {
997
+ container.setPointerCapture(event.pointerId);
998
+ } catch (_) {}
999
+ }
1000
+ const dx = event.clientX - drag.x;
1001
+ const dy = event.clientY - drag.y;
1002
+ drag.x = event.clientX;
1003
+ drag.y = event.clientY;
1004
+ if (dx || dy) panByPixels(dx, dy);
1005
+ });
1006
+
1007
+ const endDrag = (event) => {
1008
+ if (!drag || event.pointerId !== drag.id) return;
1009
+ const gesture = drag;
1010
+ drag = null;
1011
+ container.style.cursor = letsPan ? "grab" : "default";
1012
+ if (gesture.captured) {
1013
+ try {
1014
+ container.releasePointerCapture(event.pointerId);
1015
+ } catch (_) {}
1016
+ }
1017
+ if (gesture.panning) return;
1018
+ if (!clickHandler || !isMapSurface(event)) return;
1019
+ measure();
1020
+ const local = localFromClient(event.clientX, event.clientY);
1021
+ const geo = screenToGeo(local.x, local.y);
1022
+ clickHandler({ lat: geo.lat, lng: wrapLng(geo.lng), zoom }, event);
1023
+ };
1024
+
1025
+ container.addEventListener("pointerup", endDrag);
1026
+ container.addEventListener("pointercancel", (event) => {
1027
+ if (drag && event.pointerId === drag.id) {
1028
+ drag = null;
1029
+ container.style.cursor = letsPan ? "grab" : "default";
1030
+ }
1031
+ });
1032
+
1033
+ container.addEventListener(
1034
+ "wheel",
1035
+ (event) => {
1036
+ if (!letsPan || !wheelZooms) return;
1037
+ // Not scrolling the page behind the map is the whole point of wheel zoom.
1038
+ event.preventDefault();
1039
+ const now = Date.now();
1040
+ if (now < wheelLockUntil) return;
1041
+ wheelAccum += event.deltaY;
1042
+ if (Math.abs(wheelAccum) < WHEEL_THRESHOLD) return;
1043
+ const direction = wheelAccum > 0 ? -1 : 1;
1044
+ wheelAccum = 0;
1045
+ wheelLockUntil = now + WHEEL_COOLDOWN;
1046
+ measure();
1047
+ const local = localFromClient(event.clientX, event.clientY);
1048
+ if (applyZoom(zoom + direction, local)) invalidate();
1049
+ },
1050
+ { passive: false },
1051
+ );
1052
+
1053
+ container.addEventListener("dblclick", (event) => {
1054
+ if (!letsPan || !dblClickZooms || !isMapSurface(event)) return;
1055
+ event.preventDefault();
1056
+ zoomAt(zoom + (event.shiftKey ? -1 : 1), { x: event.clientX, y: event.clientY });
1057
+ });
1058
+
1059
+ container.addEventListener("keydown", (event) => {
1060
+ if (!letsPan) return;
1061
+ const moves = {
1062
+ // `panByPixels(dx, dy)` moves the CONTENT like a dragged pointer would, so
1063
+ // pointing the view east means pushing it west: the signs are inverted.
1064
+ ArrowUp: () => panByPixels(0, KEY_PAN_STEP),
1065
+ ArrowDown: () => panByPixels(0, -KEY_PAN_STEP),
1066
+ ArrowLeft: () => panByPixels(KEY_PAN_STEP, 0),
1067
+ ArrowRight: () => panByPixels(-KEY_PAN_STEP, 0),
1068
+ "+": () => zoomAt(zoom + 1),
1069
+ "=": () => zoomAt(zoom + 1),
1070
+ "-": () => zoomAt(zoom - 1),
1071
+ _: () => zoomAt(zoom - 1),
1072
+ };
1073
+ const move = moves[event.key];
1074
+ if (!move) return;
1075
+ // The map owns the arrows while it has focus, otherwise the page would
1076
+ // scroll underneath and the widget would report a pan that never happened.
1077
+ event.preventDefault();
1078
+ move();
1079
+ });
1080
+
1081
+ // -------------------------------------------------------------------------
1082
+ // 9. Geolocation
1083
+ // -------------------------------------------------------------------------
1084
+
1085
+ const startLocation = () => {
1086
+ if (watcherId !== null || typeof navigator === "undefined" || !navigator.geolocation) return;
1087
+ watcherId = navigator.geolocation.watchPosition(
1088
+ (position) => {
1089
+ userPosition = { lat: position.coords.latitude, lng: position.coords.longitude };
1090
+ userPositionOwner = "watch";
1091
+ // Following means the view chases every fix, so the frame must be painted
1092
+ // through the view-change path, not just a repaint.
1093
+ if (followsUser) goTo(userPosition);
1094
+ else scheduleRender();
1095
+ if (typeof userUpdateHandler === "function") {
1096
+ userUpdateHandler({
1097
+ lat: userPosition.lat,
1098
+ lng: userPosition.lng,
1099
+ accuracy: typeof position.coords.accuracy === "number" ? position.coords.accuracy : null,
1100
+ // A phone gives the direction of travel for free and a fleet view is
1101
+ // meaningless without it. Devices that do not report it — and a
1102
+ // standing still car — answer null rather than a bearing to guess.
1103
+ heading:
1104
+ typeof position.coords.heading === "number" && Number.isFinite(position.coords.heading)
1105
+ ? position.coords.heading
1106
+ : null,
1107
+ });
1108
+ }
1109
+ },
1110
+ () => {
1111
+ // Denied or unavailable: the dot stays hidden, the map keeps working.
1112
+ // A position the app set on purpose outlives the watcher, which keeps
1113
+ // reporting its old failure on every retry.
1114
+ if (userPositionOwner !== "watch") return;
1115
+ userPosition = null;
1116
+ userDot.style.display = "none";
1117
+ },
1118
+ {
1119
+ // While following, a cached fix is the difference between a smooth chase
1120
+ // and a dot that lags two minutes behind; the trade is battery.
1121
+ enableHighAccuracy: followsUser,
1122
+ maximumAge: followsUser ? 0 : 60000,
1123
+ timeout: 15000,
1124
+ },
1125
+ );
1126
+ };
1127
+
1128
+ const stopLocation = () => {
1129
+ if (watcherId === null || typeof navigator === "undefined" || !navigator.geolocation) return;
1130
+ navigator.geolocation.clearWatch(watcherId);
1131
+ watcherId = null;
1132
+ };
1133
+
1134
+ /** Either flag needs the position stream; only when both are off, release it. */
1135
+ const syncLocation = () => {
1136
+ if (showsUser || followsUser) startLocation();
1137
+ else stopLocation();
1138
+ };
1139
+
1140
+ /**
1141
+ * Chase the live position (`true`) or leave the view alone (`false`). Turning
1142
+ * it on re-opens the watcher with fresh, high-accuracy fixes.
1143
+ * @param {boolean} next
1144
+ * @returns {Object} The widget, for chaining.
1145
+ */
1146
+ const setFollowUser = (next) => {
1147
+ const on = Boolean(next);
1148
+ if (on === followsUser) return container;
1149
+ followsUser = on;
1150
+ if (watcherId !== null) {
1151
+ stopLocation();
1152
+ startLocation();
1153
+ } else syncLocation();
1154
+ if (on && userPosition) goTo(userPosition);
1155
+ else scheduleRender();
1156
+ return container;
1157
+ };
1158
+
1159
+ // -------------------------------------------------------------------------
1160
+ // 10. Lifecycle: the map only paints while it is in the DOM
1161
+ // -------------------------------------------------------------------------
1162
+
1163
+ const start = () => {
1164
+ if (disposed || running) return;
1165
+ running = true;
1166
+ scheduleRender();
1167
+ if (typeof ResizeObserver !== "undefined") {
1168
+ observer = new ResizeObserver(() => scheduleRender());
1169
+ observer.observe(container);
1170
+ } else if (typeof window !== "undefined") {
1171
+ window.addEventListener("resize", scheduleRender);
1172
+ }
1173
+ syncLocation();
1174
+ };
1175
+
1176
+ const dispose = () => {
1177
+ if (disposed) return;
1178
+ disposed = true;
1179
+ running = false;
1180
+ if (frame) cancelAnimationFrame(frame);
1181
+ frame = 0;
1182
+ clearTimeout(settleTimer);
1183
+ if (observer) observer.disconnect();
1184
+ observer = null;
1185
+ if (typeof window !== "undefined") window.removeEventListener("resize", scheduleRender);
1186
+ stopLocation();
1187
+ };
1188
+
1189
+ container.onMount(start);
1190
+ container.onUnmount(dispose);
1191
+
1192
+ // -------------------------------------------------------------------------
1193
+ // 11. Public surface
1194
+ // -------------------------------------------------------------------------
1195
+
1196
+ markers = (Array.isArray(markersProp) ? markersProp : [])
1197
+ .map((raw) => normalizeMarker(raw, pinColor, nextId))
1198
+ .filter(Boolean);
1199
+
1200
+ lines = (Array.isArray(polylinesProp) ? polylinesProp : [])
1201
+ .map((raw) => normalizeLine(raw, lineColor, nextLineId))
1202
+ .filter(Boolean);
1203
+
1204
+ rebuildCredit();
1205
+
1206
+ /**
1207
+ * Applies the widget-owned keys of an `update()`. Everything else went to the
1208
+ * factory, so `map.update({ opacity: .5 })` keeps working.
1209
+ * @param {Object} next
1210
+ * @returns {void}
1211
+ */
1212
+ const applyProps = (next) => {
1213
+ let viewChanged = false;
1214
+ let needsCredit = false;
1215
+
1216
+ if ("center" in next) {
1217
+ const point = normalizePosition(next.center);
1218
+ // Only a different centre is a view change. An app that feeds `onMoveEnd`
1219
+ // back into the key it binds — the usual controlled-map pattern — would
1220
+ // otherwise re-invalidate on every settle tick and never stop reporting.
1221
+ const moved = point && (
1222
+ Math.abs(point.lat - center.lat) > 1e-9 ||
1223
+ Math.abs(wrapLng(point.lng - center.lng)) > 1e-9
1224
+ );
1225
+ if (moved) {
1226
+ center = point;
1227
+ viewChanged = true;
1228
+ }
1229
+ }
1230
+ if ("zoom" in next) viewChanged = applyZoom(next.zoom) || viewChanged;
1231
+ if ("minZoom" in next && typeof next.minZoom === "number") {
1232
+ lowerZoom = Math.max(0, Math.floor(next.minZoom));
1233
+ if (upperZoom < lowerZoom) upperZoom = lowerZoom;
1234
+ const fitted = clamp(zoom, lowerZoom, upperZoom);
1235
+ if (fitted !== zoom) {
1236
+ zoom = fitted;
1237
+ viewChanged = true;
1238
+ }
1239
+ }
1240
+ if ("maxZoom" in next && typeof next.maxZoom === "number") {
1241
+ upperZoom = Math.max(lowerZoom, Math.floor(next.maxZoom));
1242
+ const fitted = clamp(zoom, lowerZoom, upperZoom);
1243
+ if (fitted !== zoom) {
1244
+ zoom = fitted;
1245
+ viewChanged = true;
1246
+ }
1247
+ }
1248
+ if ("markers" in next) {
1249
+ markers = (Array.isArray(next.markers) ? next.markers : [])
1250
+ .map((raw) => normalizeMarker(raw, pinColor, nextId))
1251
+ .filter(Boolean);
1252
+ }
1253
+ if ("markerColor" in next && next.markerColor) {
1254
+ pinColor = String(next.markerColor);
1255
+ // Only the pins that never asked for a colour follow the default.
1256
+ markers = markers.map((marker) =>
1257
+ marker.hasOwnColor ? marker : Object.assign({}, marker, { color: pinColor }),
1258
+ );
1259
+ }
1260
+ if ("polylines" in next) {
1261
+ lines = (Array.isArray(next.polylines) ? next.polylines : [])
1262
+ .map((raw) => normalizeLine(raw, lineColor, nextLineId))
1263
+ .filter(Boolean);
1264
+ }
1265
+ if ("polylineColor" in next && next.polylineColor) {
1266
+ lineColor = String(next.polylineColor);
1267
+ lines = lines.map((line) =>
1268
+ line.hasOwnColor ? line : Object.assign({}, line, { color: lineColor }),
1269
+ );
1270
+ }
1271
+ if ("tileUrl" in next && next.tileUrl) {
1272
+ tileTemplate = String(next.tileUrl);
1273
+ clearTiles();
1274
+ viewChanged = true;
1275
+ needsCredit = true;
1276
+ }
1277
+ if ("attribution" in next) credit = next.attribution;
1278
+ if ("showAttribution" in next) showsCredit = Boolean(next.showAttribution);
1279
+ if ("interactive" in next) {
1280
+ letsPan = Boolean(next.interactive);
1281
+ container.style.cursor = letsPan ? "grab" : "default";
1282
+ }
1283
+ if ("scrollZoom" in next) wheelZooms = Boolean(next.scrollZoom);
1284
+ if ("doubleClickZoom" in next) dblClickZooms = Boolean(next.doubleClickZoom);
1285
+ if ("showZoomControl" in next) {
1286
+ zoomControl.style.display = next.showZoomControl ? "flex" : "none";
1287
+ }
1288
+ if ("showUserLocation" in next) {
1289
+ showsUser = Boolean(next.showUserLocation);
1290
+ syncLocation();
1291
+ }
1292
+ if ("followUser" in next) setFollowUser(next.followUser);
1293
+ if ("onMapClick" in next) clickHandler = next.onMapClick;
1294
+ if ("onMarkerClick" in next) pinClickHandler = next.onMarkerClick;
1295
+ if ("onMoveEnd" in next) moveEndHandler = next.onMoveEnd;
1296
+ if ("onUserUpdate" in next) userUpdateHandler = next.onUserUpdate;
1297
+
1298
+ if (needsCredit || "attribution" in next || "showAttribution" in next) rebuildCredit();
1299
+
1300
+ if (viewChanged) invalidate();
1301
+ else scheduleRender();
1302
+ };
1303
+
1304
+ composeUpdate(container, MAPS_PROPS, applyProps);
1305
+
1306
+ // Bind after composing: `registerWidget` pushes the current store value through
1307
+ // `update`, which must be the composed one that routes owned keys here. It also
1308
+ // releases the subscription itself on unmount, so `dispose()` stays local.
1309
+ for (const [propName, stateKey] of boundKeys) registerWidget(stateKey, container, propName);
1310
+
1311
+ /** Point the map at `position` (`[lat, lng]` or `{lat, lng}`), at an optional zoom. */
1312
+ container.setCenter = (position, nextZoom) => goTo(position, nextZoom);
1313
+ container.getCenter = () => ({ lat: center.lat, lng: wrapLng(center.lng) });
1314
+ container.getZoom = () => zoom;
1315
+ container.zoomTo = (nextZoom) => zoomAt(nextZoom);
1316
+ container.panBy = (dx, dy) => {
1317
+ measure();
1318
+ panByPixels(dx, dy);
1319
+ return container;
1320
+ };
1321
+ container.getState = () => {
1322
+ measure();
1323
+ return viewState();
1324
+ };
1325
+
1326
+ /**
1327
+ * Zoom and centre to fit `bounds` — `[[south, west], [north, east]]`,
1328
+ * `{south, west, north, east}` or a list of positions. Antimeridian boxes are
1329
+ * not supported: the west/east span is read as-is.
1330
+ */
1331
+ container.fitBounds = (bounds, padding = 16) => {
1332
+ const box = normalizeBounds(bounds);
1333
+ if (!box) return container;
1334
+ const rect = measure();
1335
+ const usableWidth = Math.max(1, rect.width - padding * 2);
1336
+ const usableHeight = Math.max(1, rect.height - padding * 2);
1337
+ let target = upperZoom;
1338
+ while (target > lowerZoom) {
1339
+ const w = Math.abs(lngToWorldX(box.east, target) - lngToWorldX(box.west, target));
1340
+ const h = Math.abs(latToWorldY(box.south, target) - latToWorldY(box.north, target));
1341
+ if (w <= usableWidth && h <= usableHeight) break;
1342
+ target--;
1343
+ }
1344
+ zoom = target;
1345
+ center = {
1346
+ lat: clamp((box.south + box.north) / 2, -MAX_LATITUDE, MAX_LATITUDE),
1347
+ lng: wrapLng((box.west + box.east) / 2),
1348
+ };
1349
+ invalidate();
1350
+ return container;
1351
+ };
1352
+
1353
+ /** Append one marker (position or `{position, label, color}`) and repaint. */
1354
+ container.addMarker = (raw) => {
1355
+ const marker = normalizeMarker(raw, pinColor, nextId);
1356
+ if (!marker) return null;
1357
+ markers = markers.concat([marker]);
1358
+ scheduleRender();
1359
+ return marker;
1360
+ };
1361
+
1362
+ /**
1363
+ * Replace the marker with the same id, or append it when that id is new.
1364
+ *
1365
+ * A vehicle reports its position and bearing many times a second, and
1366
+ * `addMarker` would stack a pin per fix: the pooled node is keyed by id, so
1367
+ * the last one wins on screen while the list grows without bound.
1368
+ * @returns {Object|null} The stored marker, or null when it had no usable position.
1369
+ */
1370
+ container.setMarker = (raw) => {
1371
+ const marker = normalizeMarker(raw, pinColor, nextId);
1372
+ if (!marker) return null;
1373
+ const at = markers.findIndex((current) => current.id === marker.id);
1374
+ if (at < 0) markers = markers.concat([marker]);
1375
+ else {
1376
+ markers = markers.slice();
1377
+ markers[at] = marker;
1378
+ }
1379
+ scheduleRender();
1380
+ return marker;
1381
+ };
1382
+
1383
+ /** Remove a marker by id or by the object `addMarker` returned. */
1384
+ container.removeMarker = (idOrMarker) => {
1385
+ const id =
1386
+ idOrMarker && typeof idOrMarker === "object" ? idOrMarker.id : String(idOrMarker);
1387
+ const before = markers.length;
1388
+ markers = markers.filter((marker) => marker.id !== id);
1389
+ if (markers.length === before) return container;
1390
+ const node = pinEls.get(id);
1391
+ if (node) {
1392
+ node.remove();
1393
+ pinEls.delete(id);
1394
+ }
1395
+ scheduleRender();
1396
+ return container;
1397
+ };
1398
+
1399
+ container.clearMarkers = () => {
1400
+ markers = [];
1401
+ for (const node of Array.from(pinEls.values())) node.remove();
1402
+ pinEls.clear();
1403
+ scheduleRender();
1404
+ return container;
1405
+ };
1406
+
1407
+ container.getMarkers = () => markers.slice();
1408
+
1409
+ /**
1410
+ * Draw a line — a list of positions or `{points, color, width, opacity, dashed, id}`.
1411
+ * @returns {Object|null} The stored line, or null when it had no usable points.
1412
+ */
1413
+ container.addPolyline = (raw) => {
1414
+ const line = normalizeLine(raw, lineColor, nextLineId);
1415
+ if (!line) return null;
1416
+ lines = lines.concat([line]);
1417
+ scheduleRender();
1418
+ return line;
1419
+ };
1420
+
1421
+ /** Remove a line by id or by the object `addPolyline` returned. */
1422
+ container.removePolyline = (idOrLine) => {
1423
+ const id =
1424
+ idOrLine && typeof idOrLine === "object" ? idOrLine.id : String(idOrLine);
1425
+ const before = lines.length;
1426
+ lines = lines.filter((line) => line.id !== id);
1427
+ if (lines.length === before) return container;
1428
+ scheduleRender();
1429
+ return container;
1430
+ };
1431
+
1432
+ container.clearPolylines = () => {
1433
+ lines = [];
1434
+ scheduleRender();
1435
+ return container;
1436
+ };
1437
+
1438
+ container.getPolylines = () => lines.slice();
1439
+
1440
+ /** @see setFollowUser */
1441
+ container.setFollowUser = (next) => setFollowUser(next);
1442
+
1443
+ /**
1444
+ * Draw a position the app already holds, without asking the browser for
1445
+ * permission: a native Capacitor fix, an IP lookup or a test fixture.
1446
+ * Pass `null` to hide the dot, and `{ setCenter: false }` to only move the dot.
1447
+ * @param {{lat:number, lng:number}|number[]|null} position
1448
+ * @param {{setCenter?: boolean, zoom?: number}} [settings]
1449
+ * @returns {Object} The widget, for chaining.
1450
+ */
1451
+ container.setUserPosition = (position, settings = {}) => {
1452
+ const point = normalizePosition(position);
1453
+ userPosition = point;
1454
+ userPositionOwner = point ? "app" : null;
1455
+ if (!point || settings.setCenter === false) scheduleRender();
1456
+ else goTo(point, settings.zoom);
1457
+ return container;
1458
+ };
1459
+
1460
+ /**
1461
+ * Ask the browser for the current position once and centre on it.
1462
+ * Pass `{ setCenter: false }` to only draw the dot, and `{ timeout }` /
1463
+ * `{ maximumAge }` / `{ highAccuracy: false }` to tune the underlying
1464
+ * `getCurrentPosition` call (defaults 10 s, 0 ms, high accuracy on).
1465
+ * @returns {Promise<{lat:number, lng:number}>|null} Null when geolocation is absent.
1466
+ */
1467
+ container.locate = (settings = {}) => {
1468
+ if (typeof navigator === "undefined" || !navigator.geolocation) return null;
1469
+ return new Promise((resolve, reject) => {
1470
+ navigator.geolocation.getCurrentPosition(
1471
+ (position) => {
1472
+ const point = { lat: position.coords.latitude, lng: position.coords.longitude };
1473
+ container.setUserPosition(point, settings);
1474
+ resolve(point);
1475
+ },
1476
+ reject,
1477
+ {
1478
+ enableHighAccuracy: settings.highAccuracy !== false,
1479
+ timeout: typeof settings.timeout === "number" ? settings.timeout : 10000,
1480
+ maximumAge: typeof settings.maximumAge === "number" ? settings.maximumAge : 0,
1481
+ },
1482
+ );
1483
+ });
1484
+ };
1485
+
1486
+ /** Request a repaint, e.g. after the container was resized from outside. */
1487
+ container.render = () => {
1488
+ scheduleRender();
1489
+ return container;
1490
+ };
1491
+
1492
+ return container;
1493
+ };
1494
+
1495
+ export default Maps;