flet-box 1.0.5 → 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.
- package/README.md +28 -1
- package/bin/cli.js +85 -18
- package/bin/commands/auditFix.js +682 -0
- package/bin/commands/checkApi.js +7 -5
- package/bin/commands/create.js +40 -10
- package/bin/commands/createBundle.js +54 -1
- package/bin/commands/images.js +349 -0
- package/bin/commands/pruneAssets.js +170 -0
- package/bin/commands/release.js +34 -1
- package/bin/commands/runCloudflare.js +47 -9
- package/bin/commands/runServer.js +81 -24
- package/bin/commands/toPng.js +346 -0
- package/bin/commands/unpack.js +1173 -0
- package/bin/ota/loader.js +90 -0
- package/bin/utils/appSources.js +101 -0
- package/bin/utils/bundleConfig.js +166 -0
- package/bin/utils/dtsProps.js +147 -0
- package/bin/utils/imageTools.js +271 -0
- package/bin/utils/images/bmp.js +99 -0
- package/bin/utils/images/gif.js +244 -0
- package/bin/utils/images/index.js +163 -0
- package/bin/utils/images/jpeg.js +643 -0
- package/bin/utils/images/png.js +486 -0
- package/bin/utils/images/unsupported.js +29 -0
- package/bin/utils/neutralise.js +109 -6
- package/bin/utils/templates.js +307 -64
- package/docs/cli/README.md +82 -0
- package/docs/guides/online-updates.md +57 -9
- package/docs/widget/Accordion.md +1 -0
- package/docs/widget/AdMob.md +1 -0
- package/docs/widget/AdSense.md +1 -0
- package/docs/widget/AnimatedBox.md +1 -0
- package/docs/widget/AnimatedText.md +1 -0
- package/docs/widget/Audio.md +2 -1
- package/docs/widget/Avatar.md +12 -3
- package/docs/widget/Badge.md +1 -0
- package/docs/widget/Button.md +1 -0
- package/docs/widget/Card.md +1 -0
- package/docs/widget/Carousel.md +1 -0
- package/docs/widget/Chart.md +1 -0
- package/docs/widget/Checkbox.md +1 -0
- package/docs/widget/Chip.md +1 -0
- package/docs/widget/CircularBar.md +1 -0
- package/docs/widget/CircularChart.md +1 -0
- package/docs/widget/CodeViewer.md +1 -0
- package/docs/widget/Column.md +1 -0
- package/docs/widget/Container.md +1 -0
- package/docs/widget/DataTable.md +1 -0
- package/docs/widget/Divider.md +1 -0
- package/docs/widget/DraggBox.md +3 -2
- package/docs/widget/Dropdown.md +3 -0
- package/docs/widget/DroppBox.md +2 -1
- package/docs/widget/FloatingActionButton.md +1 -0
- package/docs/widget/GridView.md +1 -0
- package/docs/widget/Icon.md +1 -0
- package/docs/widget/Image.md +11 -3
- package/docs/widget/Input.md +1 -0
- package/docs/widget/InstallButton.md +1 -0
- package/docs/widget/ListTile.md +1 -0
- package/docs/widget/ListView.md +1 -0
- package/docs/widget/Maps.md +188 -0
- package/docs/widget/Markdown.md +1 -0
- package/docs/widget/MatrixRain.md +1 -0
- package/docs/widget/Pagination.md +1 -0
- package/docs/widget/ParallaxBox.md +1 -0
- package/docs/widget/ProgressBar.md +1 -0
- package/docs/widget/QRCode.md +1 -0
- package/docs/widget/README.md +12 -10
- package/docs/widget/Radio.md +1 -0
- package/docs/widget/Rating.md +1 -0
- package/docs/widget/Row.md +1 -0
- package/docs/widget/Skeleton.md +1 -0
- package/docs/widget/Slider.md +1 -0
- package/docs/widget/Stack.md +1 -0
- package/docs/widget/Stepper.md +1 -0
- package/docs/widget/Switch.md +1 -0
- package/docs/widget/Text.md +1 -0
- package/docs/widget/Tooltip.md +11 -13
- package/docs/widget/TreeView.md +1 -0
- package/docs/widget/Video.md +3 -2
- package/package.json +2 -2
- package/src/core/hmr-client.js +129 -34
- package/src/index.d.ts +132 -0
- package/src/index.js +1 -0
- package/src/navigations/AppBar.js +15 -4
- package/src/navigations/Drawer.js +9 -1
- package/src/widget-factory/translateProps.js +32 -1
- package/src/widgets/Avatar.js +228 -131
- package/src/widgets/Container.js +13 -0
- package/src/widgets/Dropdown.js +33 -1
- package/src/widgets/Image.js +102 -3
- package/src/widgets/Maps.js +1495 -0
- package/src/widgets/Tooltip.js +12 -2
- 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;
|