@sjvair/map-sdk 0.1.0-beta.2 → 0.1.0-beta.4
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 +79 -11
- package/dist/core/create-map.svelte.d.ts +5 -3
- package/dist/core/create-map.svelte.js +18 -2
- package/dist/core/interactions.svelte.d.ts +5 -3
- package/dist/core/interactions.svelte.js +20 -7
- package/dist/core/plugin-host.svelte.d.ts +25 -4
- package/dist/core/plugin-host.svelte.js +81 -13
- package/dist/core/plugin.d.ts +12 -0
- package/dist/data/format.d.ts +1 -1
- package/dist/data/format.js +16 -3
- package/dist/data/messages/en.json +8 -1
- package/dist/data/types.d.ts +5 -0
- package/dist/plugins/points.svelte.d.ts +5 -0
- package/dist/plugins/points.svelte.js +2 -1
- package/dist/plugins/shapes.svelte.d.ts +5 -0
- package/dist/plugins/shapes.svelte.js +2 -0
- package/dist/presets/FireTooltip.svelte +70 -0
- package/dist/presets/FireTooltip.svelte.d.ts +13 -0
- package/dist/presets/MonitorClusterTooltip.svelte +21 -2
- package/dist/presets/cluster-area.d.ts +13 -0
- package/dist/presets/cluster-area.js +34 -0
- package/dist/presets/hms-fire.d.ts +11 -7
- package/dist/presets/hms-fire.js +16 -17
- package/dist/presets/hms-smoke.d.ts +2 -1
- package/dist/presets/hms-smoke.js +4 -2
- package/dist/stores/hms-stores.svelte.d.ts +6 -3
- package/dist/stores/hms-stores.svelte.js +4 -4
- package/dist/ui/FeatureList.svelte +1 -1
- package/dist/ui/TooltipItem.svelte +1 -1
- package/dist/ui/feature-list.js +3 -1
- package/dist/ui/value-format.d.ts +3 -2
- package/dist/ui/value-format.js +9 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -9,10 +9,10 @@ Supported browsers: Chrome/Edge 111+, Safari 16.4+, Firefox 128+. MIT licensed.
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
map-sdk is in beta. Betas are published under the `next` tag:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
npm install @sjvair/map-sdk
|
|
15
|
+
npm install @sjvair/map-sdk@next
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Peer dependencies: `svelte` (^5.57) and `@sjvair/sdk`, which is published to
|
|
@@ -156,13 +156,33 @@ create one per map (adding an object that is set up on another map throws). The
|
|
|
156
156
|
ones they are built on. Presets don't fetch: the host supplies a `DataSource`. `monitorsPlugin` is
|
|
157
157
|
selectable by default, like the shape presets.
|
|
158
158
|
|
|
159
|
+
An added plugin is in `map.plugins` (and the legend and selection state) as soon as `addPlugin`
|
|
160
|
+
returns. On a map that is already showing, its `setup` runs just after the current code finishes,
|
|
161
|
+
so its layers aren't there yet on the next line. Code that must see them uses `await tick()`;
|
|
162
|
+
`flushSync()` does not run setup.
|
|
163
|
+
|
|
159
164
|
Selected points show a highlight ring and check mark by default. `selectedStyle: "enlarge"` on
|
|
160
165
|
`pointsPlugin` or `monitorsPlugin` instead draws the selected icon at 1.3x with no ring (icon
|
|
161
166
|
points only), as sjvair.com's map does today.
|
|
162
167
|
|
|
163
168
|
`outline` on `shapesPlugin` is `"theme"` (default, a 1px `--foreground` edge), `"level"` (the edge
|
|
164
169
|
in each level's color at the fill's opacity; choropleth only), or `"none"` (no outline layer).
|
|
165
|
-
`hmsSmokePlugin` uses `"level"`.
|
|
170
|
+
`hmsSmokePlugin` uses `"level"`. `hoverable: false` on `shapesPlugin` sets the plugin's
|
|
171
|
+
`hoverable` (see [Writing a plugin](#writing-a-plugin)): hovering a shape shows no tooltip and
|
|
172
|
+
doesn't hide what is beneath it, while focusing it from the feature list still pins its tooltip.
|
|
173
|
+
`hmsSmokePlugin` uses it, so a plume, which can cover much of the map, never pops up a tooltip.
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
map.addPlugin(
|
|
177
|
+
shapesPlugin({
|
|
178
|
+
id: "plumes",
|
|
179
|
+
source: staticSource(plumes, { dataset: "hms-smoke", entryType: "smoke-density" }),
|
|
180
|
+
layerLabel: "Plumes",
|
|
181
|
+
mode: "choropleth",
|
|
182
|
+
hoverable: false
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
```
|
|
166
186
|
|
|
167
187
|
`zoomRange: { min?, max? }` on `pointsPlugin` limits every layer it draws (points, clusters,
|
|
168
188
|
selection) to camera zooms from `min` (inclusive) up to `max` (exclusive), switching live while
|
|
@@ -172,7 +192,12 @@ zooming. Two plugins whose ranges meet hand over with no gap, on the globe too.
|
|
|
172
192
|
colored by FRP. With `hotspots`, it switches by itself while zooming, as sjvair.com's map does:
|
|
173
193
|
`source` (the fire store's `groups`) below zoom 10 and `hotspots` from zoom 10. It stays one
|
|
174
194
|
layer: one legend section, one feature list (the groups, so keyboard users reach every fire;
|
|
175
|
-
hotspots aren't listed), and tooltips for both.
|
|
195
|
+
hotspots aren't listed), and tooltips for both. The tooltip is the monitors' reading box: "Fire
|
|
196
|
+
(MW)", the power in its level's color, beside the name ("3 fire detections", "1 fire detection"
|
|
197
|
+
for a group, "Fire detection" for one detection) and the level ("50–149 MW"); a group of two or
|
|
198
|
+
more says "Average". A feature with a `count` (`FireFeatureProperties`, which the fire store's
|
|
199
|
+
groups carry) is a group. Fire values are cut off at the display precision, not rounded, in the
|
|
200
|
+
tooltip, the list and its accessible names, so 9.96 MW reads "9.9" beside "<10".
|
|
176
201
|
|
|
177
202
|
`legendDisplay: "gradient" | "blocks"` on `pointsPlugin` sets how the legend draws a range scale
|
|
178
203
|
(default a gradient; `hmsFirePlugin` uses `"blocks"`, labeled with the levels' own labels). A blocks
|
|
@@ -199,6 +224,10 @@ charger") shown where "No level" would be: in the feature list (plain text, no s
|
|
|
199
224
|
accessible name ("Fresno Station, Level 2 charger"), and the tooltip. A feature with a level
|
|
200
225
|
always shows its level. A plugin of your own sets the same `detail` on its `FeatureListItem`s.
|
|
201
226
|
|
|
227
|
+
`truncateValues: true` on `pointsPlugin` cuts values off at the display precision instead of
|
|
228
|
+
rounding them (`truncate` on each `FeatureListItem`), in the list, its accessible names, and the
|
|
229
|
+
generic tooltip, so a value never reads as the next level up. `hmsFirePlugin` uses it.
|
|
230
|
+
|
|
202
231
|
`collocationSitesPlugin({ id?, source, layerLabel })` draws collocation sites as sjvair.com does:
|
|
203
232
|
each at its reference monitor, in that monitor's shape and level color (inactive gray, inside
|
|
204
233
|
outlined), wrapped in a crosshair of `COLLOCATION_CROSSHAIR_COLOR` (`#4A5FC6`, a fixed color on
|
|
@@ -224,7 +253,12 @@ and reads "No level"; a level with an empty color draws neutral with its own lab
|
|
|
224
253
|
source loads, fails, or is unavailable, the legend says so (a failure offers "Try again", which
|
|
225
254
|
calls `status.retry()`). Values are shown with the metadata's display precision, except in the
|
|
226
255
|
monitor, cluster, and collocation tooltips, which match monitor-map: the reading and a cluster's
|
|
227
|
-
average are whole numbers, and a cluster's max and min are as reported.
|
|
256
|
+
average are whole numbers, and a cluster's max and min are as reported. Fire values are cut off
|
|
257
|
+
rather than rounded (see `truncateValues`). Times in the tooltips read like sjvair.com's map, in
|
|
258
|
+
Pacific time: "4:00 pm Friday Nov 06, 2026", with " PT" after them on a device in another zone.
|
|
259
|
+
The monitor cluster tooltip's count line gives the area the monitors are within, "3 monitors
|
|
260
|
+
within a 22.619 mi² area": the area of their convex hull, left out when it would read 0.000 (two
|
|
261
|
+
monitors, or all on one line or one spot).
|
|
228
262
|
|
|
229
263
|
`createMonitorsStore()` (from `@sjvair/map-sdk/stores`) loads the monitors list and latest readings
|
|
230
264
|
for an entry type, polls every two minutes, and exposes `dataSource`. `init()`, `update()`,
|
|
@@ -297,8 +331,10 @@ not selectable). `encodeRef`/`decodeRef` convert between refs and keys.
|
|
|
297
331
|
|
|
298
332
|
A plugin is an object with an `id` and a `setup(ctx)` that adds sources, layers, and images
|
|
299
333
|
through its `PluginContext` and returns a teardown. The host runs `setup` once the map's style is
|
|
300
|
-
ready and again after a basemap or theme change
|
|
301
|
-
|
|
334
|
+
ready and again after a basemap or theme change. For a plugin added to a ready map, `setup` runs
|
|
335
|
+
just after the current code finishes (a microtask), never inside the caller's code, so adding and
|
|
336
|
+
removing plugins from an `$effect` is safe. Everything added through `ctx` is removed on teardown,
|
|
337
|
+
and ids are namespaced per map. `features()` feeds the feature list, tooltip, and
|
|
302
338
|
summary; `legend()` the legend. A plugin's `tooltip` content sits under the feature's name; set
|
|
303
339
|
`tooltipShowsName` when the content shows the name itself (the monitor and collocation tooltips
|
|
304
340
|
do), and the name header is left out.
|
|
@@ -349,7 +385,10 @@ features are under the pointer, the hover tooltip shows the one whose plugin has
|
|
|
349
385
|
`hoverPriority` (default 0), the topmost on a tie. Over a cluster bubble, the bubble's tooltip
|
|
350
386
|
shows unless a feature beneath belongs to a plugin with a higher `hoverPriority` than the
|
|
351
387
|
bubble's (collocation sites under monitor clusters). Clicks always go to the topmost, so a click
|
|
352
|
-
on a bubble still zooms into it.
|
|
388
|
+
on a bubble still zooms into it. A plugin with `hoverable: false` never takes the hover: its
|
|
389
|
+
features show no hover tooltip or pointer cursor and are passed over as if they weren't drawn, so
|
|
390
|
+
whatever is beneath gets the hover. They are still clicked, returned by `featuresAt`, and pin
|
|
391
|
+
their tooltip when focused from the feature list.
|
|
353
392
|
|
|
354
393
|
```ts
|
|
355
394
|
import type { CustomLayerInterface, MapPlugin, PluginContext } from "@sjvair/map-sdk";
|
|
@@ -421,9 +460,10 @@ map.easeTo({ center: monitor.lngLat, minZoom: 12 });
|
|
|
421
460
|
|
|
422
461
|
## Ready, and reaching the GL map
|
|
423
462
|
|
|
424
|
-
`map.ready` is reactive: true once the attached map's style has loaded
|
|
425
|
-
for the first time,
|
|
426
|
-
|
|
463
|
+
`map.ready` is reactive: true once the attached map's style has loaded, the map has gone idle
|
|
464
|
+
for the first time, and any queued plugin setups have run; false again on detach and while a
|
|
465
|
+
basemap switch reloads the style. Adding a plugin to a ready map leaves it true. Show a load screen
|
|
466
|
+
until it is true. `map.gl` is the attached GL map (or `null`), reactive.
|
|
427
467
|
|
|
428
468
|
`maptilerMap(map)` returns it as a MapTiler `Map` (or `null` while detached). It is an escape hatch
|
|
429
469
|
for markers and popups only: never add sources or layers through it, because plugins own those.
|
|
@@ -492,6 +532,34 @@ local sjvair.com stack at `http://localhost:8000`.
|
|
|
492
532
|
podman exec sjvaircom_web_1 python manage.py loaddata default-calibrations.yaml
|
|
493
533
|
```
|
|
494
534
|
|
|
535
|
+
## Changes
|
|
536
|
+
|
|
537
|
+
### 0.1.0-beta.4
|
|
538
|
+
|
|
539
|
+
Defaults from monitor-map 5.0's parity review against sjvair.com's map (4.3.0). Every map gets
|
|
540
|
+
them; none is an option.
|
|
541
|
+
|
|
542
|
+
- **Tooltip times** read like sjvair.com's map, in Pacific time: "4:00 pm Friday Nov 06, 2026"
|
|
543
|
+
(was "Nov 6, 2026, 4:00 PM"). A device in another zone still gets " PT" after the time.
|
|
544
|
+
- **The cluster area line is back.** The monitor cluster tooltip's count line reads "3 monitors
|
|
545
|
+
within a 22.619 mi² area". This reverses the 2026-11-04 parity decision of "no area line". The
|
|
546
|
+
wording is new (sjvair.com said "covering"), and the area is the convex hull of the cluster's
|
|
547
|
+
monitors, so clusters of four or more can differ from sjvair.com's number, which followed the
|
|
548
|
+
monitors' order and could cross itself. No line when the area would read 0.000.
|
|
549
|
+
- **Fire tooltips are the reading box**: "Fire (MW)", the power in its level's color, beside the
|
|
550
|
+
name and level; "Average" for a group of two or more. Fire values are cut off, not rounded, in
|
|
551
|
+
the tooltip, the list and its accessible names. Every group names its count, a group of 1
|
|
552
|
+
included ("1 fire detection").
|
|
553
|
+
- **Smoke plumes show no hover tooltip** and never take the hover from what is beneath them;
|
|
554
|
+
focusing a plume from the feature list still pins its tooltip. This reverses part of milestone
|
|
555
|
+
2's accessibility additions ("tooltips on smoke and fire"): fire keeps a tooltip.
|
|
556
|
+
- **Breaking for hosts with their own message catalog:** `mapsdk_hms_fire_detections` is now
|
|
557
|
+
`mapsdk_hms_fire_detections_one` / `_other`. New keys: `mapsdk_hms_fire_box_header`,
|
|
558
|
+
`mapsdk_hms_fire_box_header_no_units`, `mapsdk_hms_fire_average`, `mapsdk_level_with_units`,
|
|
559
|
+
and `mapsdk_monitor_cluster_count_area_one` / `_other`.
|
|
560
|
+
- **New API:** `MapPlugin.hoverable`, `shapesPlugin`'s `hoverable`, `pointsPlugin`'s
|
|
561
|
+
`truncateValues`, `FeatureListItem.truncate`, and `FireFeatureProperties`.
|
|
562
|
+
|
|
495
563
|
## Development
|
|
496
564
|
|
|
497
565
|
```bash
|
|
@@ -82,8 +82,9 @@ export interface MapContext {
|
|
|
82
82
|
/** Reactive. */
|
|
83
83
|
readonly attached: boolean;
|
|
84
84
|
/**
|
|
85
|
-
* Reactive. True once the attached map's style has loaded
|
|
86
|
-
* first time; false again on `detach()` and while a
|
|
85
|
+
* Reactive. True once the attached map's style has loaded, the map has gone idle for the
|
|
86
|
+
* first time, and any queued plugin setups have run; false again on `detach()` and while a
|
|
87
|
+
* basemap switch reloads the style. Adding a plugin to a ready map leaves it true. A load
|
|
87
88
|
* screen can wait for it.
|
|
88
89
|
*/
|
|
89
90
|
readonly ready: boolean;
|
|
@@ -197,7 +198,8 @@ export interface MapContext {
|
|
|
197
198
|
updateView(camera: CameraState, bounds: Bounds): void;
|
|
198
199
|
/**
|
|
199
200
|
* For MapView only: the host's theme changed, so plugins set up again and re-read the theme
|
|
200
|
-
* colors they bake into paint and images. Does nothing while detached.
|
|
201
|
+
* colors they bake into paint and images. Does nothing while detached. Sets plugins up
|
|
202
|
+
* synchronously, so never call it from inside an effect (see `PluginHost`).
|
|
201
203
|
*/
|
|
202
204
|
themeChanged(): void;
|
|
203
205
|
/** Detaches, removes every plugin, and drops handlers. The context can't be used after. */
|
|
@@ -50,6 +50,8 @@ class MapContextImpl {
|
|
|
50
50
|
#bounds = $state.raw(null);
|
|
51
51
|
#attached = $state(false);
|
|
52
52
|
#ready = $state(false);
|
|
53
|
+
/** `markReady` came while plugin setups were queued; the queued run sets `ready`. */
|
|
54
|
+
#readyRequested = false;
|
|
53
55
|
#glState = $state.raw(null);
|
|
54
56
|
#focusedKey = $state(null);
|
|
55
57
|
#focusedPluginId = $state(null);
|
|
@@ -77,7 +79,15 @@ class MapContextImpl {
|
|
|
77
79
|
mapUid: this.uid,
|
|
78
80
|
providers: { metadata: this.metadata, messages: this.messages, format: this.format },
|
|
79
81
|
router: this.#router,
|
|
80
|
-
selectedKeys: (pluginId) => selectionKeys(this.getSelection(pluginId))
|
|
82
|
+
selectedKeys: (pluginId) => selectionKeys(this.getSelection(pluginId)),
|
|
83
|
+
onSettled: () => {
|
|
84
|
+
// A setup that added a plugin queued another run; that run settles it.
|
|
85
|
+
if (this.#host.pending)
|
|
86
|
+
return;
|
|
87
|
+
if (this.#readyRequested && this.#gl)
|
|
88
|
+
this.#ready = true;
|
|
89
|
+
this.#readyRequested = false;
|
|
90
|
+
}
|
|
81
91
|
});
|
|
82
92
|
this.#router.onPick((e) => this.#pick(e));
|
|
83
93
|
}
|
|
@@ -100,7 +110,11 @@ class MapContextImpl {
|
|
|
100
110
|
return this.#glState;
|
|
101
111
|
}
|
|
102
112
|
markReady() {
|
|
103
|
-
if (this.#gl)
|
|
113
|
+
if (!this.#gl)
|
|
114
|
+
return;
|
|
115
|
+
if (this.#host.pending)
|
|
116
|
+
this.#readyRequested = true;
|
|
117
|
+
else
|
|
104
118
|
this.#ready = true;
|
|
105
119
|
}
|
|
106
120
|
get focusedKey() {
|
|
@@ -269,6 +283,7 @@ class MapContextImpl {
|
|
|
269
283
|
this.#router.attach(gl);
|
|
270
284
|
this.basemap.attach(gl, () => this.#host.restyle(), () => {
|
|
271
285
|
this.#ready = false;
|
|
286
|
+
this.#readyRequested = false;
|
|
272
287
|
this.#host.suspend();
|
|
273
288
|
});
|
|
274
289
|
this.#attached = true;
|
|
@@ -283,6 +298,7 @@ class MapContextImpl {
|
|
|
283
298
|
this.#glState = null;
|
|
284
299
|
this.#attached = false;
|
|
285
300
|
this.#ready = false;
|
|
301
|
+
this.#readyRequested = false;
|
|
286
302
|
}
|
|
287
303
|
themeChanged() {
|
|
288
304
|
if (this.#gl)
|
|
@@ -11,21 +11,23 @@ export interface Hovered {
|
|
|
11
11
|
* layers, so registrations can come and go without re-binding listeners. Layers nobody registered
|
|
12
12
|
* are never hit. A blocking layer (e.g. a cluster bubble, which zooms instead) is never hit either,
|
|
13
13
|
* and when it is topmost at a point nothing beneath it is hit; only a feature whose plugin
|
|
14
|
-
* outranks it in `hoverPriority` is hovered through it.
|
|
14
|
+
* outranks it in `hoverPriority` is hovered through it. A non-hoverable plugin's features (e.g.
|
|
15
|
+
* smoke plumes) are left out of the hover and cursor as if they weren't rendered, but are still
|
|
16
|
+
* clicked and returned by lookups.
|
|
15
17
|
*/
|
|
16
18
|
export declare class InteractionRouter {
|
|
17
19
|
#private;
|
|
18
20
|
/** The top hit under the pointer and where it is. Reactive, for the tooltip. */
|
|
19
21
|
get hovered(): Hovered | null;
|
|
20
22
|
/** Records the layers whose features `toHit` turns into hits. Returns an unregister function. */
|
|
21
|
-
register(pluginId: string, layerIds: string[], toHit: (props: Record<string, unknown>) => FeatureHit | null, priority?: number): () => void;
|
|
23
|
+
register(pluginId: string, layerIds: string[], toHit: (props: Record<string, unknown>) => FeatureHit | null, priority?: number, hoverable?: boolean): () => void;
|
|
22
24
|
/**
|
|
23
25
|
* Records layers that hide what is beneath them: when one is topmost at a point, clicks pick
|
|
24
26
|
* nothing (calling `onClick` with that feature instead) and the hover is `toHover`'s hit for
|
|
25
27
|
* it (or nothing). `toHover` must have no side effects: lookups call it too. Returns an
|
|
26
28
|
* unregister function.
|
|
27
29
|
*/
|
|
28
|
-
registerBlocking(pluginId: string, layerIds: string[], toHover?: (props: Record<string, unknown>) => FeatureHit | null, onClick?: (feature: BlockingClick) => void, priority?: number): () => void;
|
|
30
|
+
registerBlocking(pluginId: string, layerIds: string[], toHover?: (props: Record<string, unknown>) => FeatureHit | null, onClick?: (feature: BlockingClick) => void, priority?: number, hoverable?: boolean): () => void;
|
|
29
31
|
/** Listens to `gl` from now on. */
|
|
30
32
|
attach(gl: GlMapLike): void;
|
|
31
33
|
/** Stops listening and clears `hovered`. */
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* layers, so registrations can come and go without re-binding listeners. Layers nobody registered
|
|
6
6
|
* are never hit. A blocking layer (e.g. a cluster bubble, which zooms instead) is never hit either,
|
|
7
7
|
* and when it is topmost at a point nothing beneath it is hit; only a feature whose plugin
|
|
8
|
-
* outranks it in `hoverPriority` is hovered through it.
|
|
8
|
+
* outranks it in `hoverPriority` is hovered through it. A non-hoverable plugin's features (e.g.
|
|
9
|
+
* smoke plumes) are left out of the hover and cursor as if they weren't rendered, but are still
|
|
10
|
+
* clicked and returned by lookups.
|
|
9
11
|
*/
|
|
10
12
|
export class InteractionRouter {
|
|
11
13
|
// Bookkeeping only; nothing reads these reactively.
|
|
@@ -28,8 +30,15 @@ export class InteractionRouter {
|
|
|
28
30
|
return this.#hovered;
|
|
29
31
|
}
|
|
30
32
|
/** Records the layers whose features `toHit` turns into hits. Returns an unregister function. */
|
|
31
|
-
register(pluginId, layerIds, toHit, priority = 0) {
|
|
32
|
-
return this.#add({
|
|
33
|
+
register(pluginId, layerIds, toHit, priority = 0, hoverable = true) {
|
|
34
|
+
return this.#add({
|
|
35
|
+
pluginId,
|
|
36
|
+
layerIds: [...layerIds],
|
|
37
|
+
toHit,
|
|
38
|
+
blocking: false,
|
|
39
|
+
priority,
|
|
40
|
+
hoverable
|
|
41
|
+
});
|
|
33
42
|
}
|
|
34
43
|
/**
|
|
35
44
|
* Records layers that hide what is beneath them: when one is topmost at a point, clicks pick
|
|
@@ -37,14 +46,15 @@ export class InteractionRouter {
|
|
|
37
46
|
* it (or nothing). `toHover` must have no side effects: lookups call it too. Returns an
|
|
38
47
|
* unregister function.
|
|
39
48
|
*/
|
|
40
|
-
registerBlocking(pluginId, layerIds, toHover, onClick, priority = 0) {
|
|
49
|
+
registerBlocking(pluginId, layerIds, toHover, onClick, priority = 0, hoverable = true) {
|
|
41
50
|
return this.#add({
|
|
42
51
|
pluginId,
|
|
43
52
|
layerIds: [...layerIds],
|
|
44
53
|
toHit: toHover ?? (() => null),
|
|
45
54
|
blocking: true,
|
|
46
55
|
onClick,
|
|
47
|
-
priority
|
|
56
|
+
priority,
|
|
57
|
+
hoverable
|
|
48
58
|
});
|
|
49
59
|
}
|
|
50
60
|
#add(registration) {
|
|
@@ -94,7 +104,8 @@ export class InteractionRouter {
|
|
|
94
104
|
const found = this.#lookup(point);
|
|
95
105
|
return found.blocked ? [] : found.hits;
|
|
96
106
|
}
|
|
97
|
-
|
|
107
|
+
/** What is under one point; with `forHover`, non-hoverable features are left out first. */
|
|
108
|
+
#lookup(point, forHover = false) {
|
|
98
109
|
const none = { hits: [], hover: null, blocked: false, blocker: null, blockedBy: null };
|
|
99
110
|
const gl = this.#gl;
|
|
100
111
|
if (!gl)
|
|
@@ -103,6 +114,8 @@ export class InteractionRouter {
|
|
|
103
114
|
/* eslint-disable svelte/prefer-svelte-reactivity */
|
|
104
115
|
const byLayer = new Map();
|
|
105
116
|
for (const registration of this.#registrations) {
|
|
117
|
+
if (forHover && !registration.hoverable)
|
|
118
|
+
continue;
|
|
106
119
|
for (const id of registration.layerIds) {
|
|
107
120
|
// MapLibre errors on querying a missing layer (e.g. mid basemap change).
|
|
108
121
|
if (!byLayer.has(id) && gl.getLayer(id))
|
|
@@ -210,7 +223,7 @@ export class InteractionRouter {
|
|
|
210
223
|
}
|
|
211
224
|
};
|
|
212
225
|
#onMove = (e) => {
|
|
213
|
-
const found = this.#lookup([e.point.x, e.point.y]);
|
|
226
|
+
const found = this.#lookup([e.point.x, e.point.y], true);
|
|
214
227
|
const hit = found.blocked ? (found.hover ?? found.blocker) : found.hover;
|
|
215
228
|
this.#hovered = hit ? { hit, lngLat: [e.lngLat.lng, e.lngLat.lat] } : null;
|
|
216
229
|
this.#setPointer(found.blocked || hit !== null);
|
|
@@ -7,12 +7,23 @@ export interface PluginHostOptions {
|
|
|
7
7
|
router: InteractionRouter;
|
|
8
8
|
/** Reactive: read inside a plugin's effects. */
|
|
9
9
|
selectedKeys(pluginId: string): ReadonlySet<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Called after each queued setup run finishes (also when it had nothing to do), never after a
|
|
12
|
+
* synchronous one.
|
|
13
|
+
*/
|
|
14
|
+
onSettled?: () => void;
|
|
10
15
|
}
|
|
11
16
|
/**
|
|
12
17
|
* Runs plugins against a GL map. Plugins are set up only when the map's style is loaded, and
|
|
13
18
|
* everything they add through their `PluginContext` is removed on teardown, whatever their own
|
|
14
19
|
* teardown does. The plugin list outlives any one GL map.
|
|
15
20
|
*
|
|
21
|
+
* Setup never runs inside the caller's code. When `add` or `attach` would set plugins up, they
|
|
22
|
+
* are queued and set up in one microtask, where no effect is active: Svelte cuts loose the
|
|
23
|
+
* `$effect.root`s created inside an effect when that effect re-runs, and their effects can stop
|
|
24
|
+
* (see docs/superpowers/specs/2026-11-07-plugin-setup-outside-effects-design.md). Setup on a
|
|
25
|
+
* style load or `restyle` comes from map events, not host code, and stays synchronous.
|
|
26
|
+
*
|
|
16
27
|
* Plugins draw in list order, later on top: each plugin's layers stay together, below the first
|
|
17
28
|
* layer of the next plugin in the list that has any.
|
|
18
29
|
*/
|
|
@@ -21,9 +32,12 @@ export declare class PluginHost {
|
|
|
21
32
|
constructor(opts: PluginHostOptions);
|
|
22
33
|
/** In draw order, bottom first. Reactive. */
|
|
23
34
|
get plugins(): readonly MapPlugin[];
|
|
35
|
+
/** True while a setup run is queued and has not started. Not reactive. */
|
|
36
|
+
get pending(): boolean;
|
|
24
37
|
/**
|
|
25
|
-
* Adds `plugin` on top, or just below the plugin `before` names.
|
|
26
|
-
*
|
|
38
|
+
* Adds `plugin` on top, or just below the plugin `before` names. It is in `plugins` at once;
|
|
39
|
+
* on a live map its setup runs in a microtask. Throws when the same plugin object is set up on
|
|
40
|
+
* another map at the moment (one plugin object per map).
|
|
27
41
|
*/
|
|
28
42
|
add(plugin: MapPlugin, before?: string): void;
|
|
29
43
|
/**
|
|
@@ -32,14 +46,21 @@ export declare class PluginHost {
|
|
|
32
46
|
*/
|
|
33
47
|
move(id: string, before?: string): void;
|
|
34
48
|
remove(id: string): void;
|
|
35
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Uses `gl` from now on, setting up every plugin once its style is loaded (in a microtask when
|
|
51
|
+
* it already is).
|
|
52
|
+
*/
|
|
36
53
|
attach(gl: GlMapLike): void;
|
|
37
54
|
/**
|
|
38
55
|
* Tears every plugin down and stops waiting for a style, before the map's style is replaced:
|
|
39
56
|
* nothing may touch a style on its way out. `restyle` sets them up again.
|
|
40
57
|
*/
|
|
41
58
|
suspend(): void;
|
|
42
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Tears down and sets up every plugin again (after a basemap change), once the style loads.
|
|
61
|
+
* On a loaded style the setup is synchronous, so only map events and theme watching call this,
|
|
62
|
+
* never code inside an effect: roots created there can stop (see the class comment).
|
|
63
|
+
*/
|
|
43
64
|
restyle(): void;
|
|
44
65
|
/** Tears down every plugin. The gl is not touched after this returns. */
|
|
45
66
|
detach(): void;
|
|
@@ -19,6 +19,12 @@ function ownershipError(plugin) {
|
|
|
19
19
|
* everything they add through their `PluginContext` is removed on teardown, whatever their own
|
|
20
20
|
* teardown does. The plugin list outlives any one GL map.
|
|
21
21
|
*
|
|
22
|
+
* Setup never runs inside the caller's code. When `add` or `attach` would set plugins up, they
|
|
23
|
+
* are queued and set up in one microtask, where no effect is active: Svelte cuts loose the
|
|
24
|
+
* `$effect.root`s created inside an effect when that effect re-runs, and their effects can stop
|
|
25
|
+
* (see docs/superpowers/specs/2026-11-07-plugin-setup-outside-effects-design.md). Setup on a
|
|
26
|
+
* style load or `restyle` comes from map events, not host code, and stays synchronous.
|
|
27
|
+
*
|
|
22
28
|
* Plugins draw in list order, later on top: each plugin's layers stay together, below the first
|
|
23
29
|
* layer of the next plugin in the list that has any.
|
|
24
30
|
*/
|
|
@@ -36,6 +42,8 @@ export class PluginHost {
|
|
|
36
42
|
* a style that already loaded while `isStyleLoaded()` is false because sources are loading.
|
|
37
43
|
*/
|
|
38
44
|
#onStyleLoad = null;
|
|
45
|
+
/** True from scheduling a queued setup run until it starts. */
|
|
46
|
+
#setupQueued = false;
|
|
39
47
|
constructor(opts) {
|
|
40
48
|
this.#opts = opts;
|
|
41
49
|
}
|
|
@@ -43,6 +51,10 @@ export class PluginHost {
|
|
|
43
51
|
get plugins() {
|
|
44
52
|
return this.#plugins;
|
|
45
53
|
}
|
|
54
|
+
/** True while a setup run is queued and has not started. Not reactive. */
|
|
55
|
+
get pending() {
|
|
56
|
+
return this.#setupQueued;
|
|
57
|
+
}
|
|
46
58
|
/**
|
|
47
59
|
* The host's own reads are untracked, so calling `add`, `attach`, etc. from inside an effect
|
|
48
60
|
* doesn't make that effect depend on the plugin list.
|
|
@@ -51,8 +63,9 @@ export class PluginHost {
|
|
|
51
63
|
return untrack(() => this.#plugins);
|
|
52
64
|
}
|
|
53
65
|
/**
|
|
54
|
-
* Adds `plugin` on top, or just below the plugin `before` names.
|
|
55
|
-
*
|
|
66
|
+
* Adds `plugin` on top, or just below the plugin `before` names. It is in `plugins` at once;
|
|
67
|
+
* on a live map its setup runs in a microtask. Throws when the same plugin object is set up on
|
|
68
|
+
* another map at the moment (one plugin object per map).
|
|
56
69
|
*/
|
|
57
70
|
add(plugin, before) {
|
|
58
71
|
if (this.#list.some((p) => p.id === plugin.id)) {
|
|
@@ -62,7 +75,7 @@ export class PluginHost {
|
|
|
62
75
|
throw ownershipError(plugin);
|
|
63
76
|
this.#plugins = this.#inserted(this.#list, plugin, before);
|
|
64
77
|
if (this.#live)
|
|
65
|
-
this.#
|
|
78
|
+
this.#queueSetup();
|
|
66
79
|
}
|
|
67
80
|
/**
|
|
68
81
|
* Moves a plugin to the top, or just below the plugin `before` names, with its layers. Does
|
|
@@ -120,16 +133,22 @@ export class PluginHost {
|
|
|
120
133
|
if (!this.#list.some((p) => p.id === id))
|
|
121
134
|
return;
|
|
122
135
|
this.#teardown(id);
|
|
136
|
+
const plugin = this.#list.find((p) => p.id === id);
|
|
137
|
+
if (plugin)
|
|
138
|
+
this.#release(plugin);
|
|
123
139
|
this.#plugins = this.#list.filter((p) => p.id !== id);
|
|
124
140
|
}
|
|
125
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* Uses `gl` from now on, setting up every plugin once its style is loaded (in a microtask when
|
|
143
|
+
* it already is).
|
|
144
|
+
*/
|
|
126
145
|
attach(gl) {
|
|
127
146
|
if (this.#gl === gl)
|
|
128
147
|
return;
|
|
129
148
|
if (this.#gl)
|
|
130
149
|
this.detach();
|
|
131
150
|
this.#gl = gl;
|
|
132
|
-
this.#whenStyleLoaded();
|
|
151
|
+
this.#whenStyleLoaded(false);
|
|
133
152
|
}
|
|
134
153
|
/**
|
|
135
154
|
* Tears every plugin down and stops waiting for a style, before the map's style is replaced:
|
|
@@ -141,12 +160,16 @@ export class PluginHost {
|
|
|
141
160
|
this.#cancelStyleWait();
|
|
142
161
|
this.#teardownAll();
|
|
143
162
|
}
|
|
144
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Tears down and sets up every plugin again (after a basemap change), once the style loads.
|
|
165
|
+
* On a loaded style the setup is synchronous, so only map events and theme watching call this,
|
|
166
|
+
* never code inside an effect: roots created there can stop (see the class comment).
|
|
167
|
+
*/
|
|
145
168
|
restyle() {
|
|
146
169
|
if (!this.#gl)
|
|
147
170
|
return;
|
|
148
171
|
this.#teardownAll();
|
|
149
|
-
this.#whenStyleLoaded();
|
|
172
|
+
this.#whenStyleLoaded(true);
|
|
150
173
|
}
|
|
151
174
|
/** Tears down every plugin. The gl is not touched after this returns. */
|
|
152
175
|
detach() {
|
|
@@ -157,11 +180,22 @@ export class PluginHost {
|
|
|
157
180
|
this.#teardownAll();
|
|
158
181
|
this.#gl = null;
|
|
159
182
|
}
|
|
160
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Sets every plugin up once the style is loaded. On a style that already is, `now` sets them up
|
|
185
|
+
* synchronously (only for callers that never run inside an effect); otherwise they are queued.
|
|
186
|
+
*/
|
|
187
|
+
#whenStyleLoaded(now) {
|
|
161
188
|
const gl = this.#gl;
|
|
162
189
|
this.#cancelStyleWait();
|
|
163
190
|
if (gl.isStyleLoaded()) {
|
|
164
|
-
|
|
191
|
+
if (now) {
|
|
192
|
+
this.#setupAll();
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// Live already, so an `add` before the microtask joins the queued run.
|
|
196
|
+
this.#live = true;
|
|
197
|
+
this.#queueSetup();
|
|
198
|
+
}
|
|
165
199
|
return;
|
|
166
200
|
}
|
|
167
201
|
const onStyleLoad = () => {
|
|
@@ -175,6 +209,37 @@ export class PluginHost {
|
|
|
175
209
|
for (const type of STYLE_READY_EVENTS)
|
|
176
210
|
gl.on(type, onStyleLoad);
|
|
177
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Sets every plugin that isn't running up in a microtask, on whichever gl is live by then.
|
|
214
|
+
* Detach, suspend, and restyle clear `#live`, and only a loaded current gl sets it again, so
|
|
215
|
+
* the run never touches a gl that left. One run covers every request made before it starts.
|
|
216
|
+
*/
|
|
217
|
+
#queueSetup() {
|
|
218
|
+
// Claim the queued plugins now, so another map can't take one before the run.
|
|
219
|
+
for (const plugin of this.#list)
|
|
220
|
+
this.#claim(plugin);
|
|
221
|
+
if (this.#setupQueued)
|
|
222
|
+
return;
|
|
223
|
+
this.#setupQueued = true;
|
|
224
|
+
queueMicrotask(() => {
|
|
225
|
+
this.#setupQueued = false;
|
|
226
|
+
if (this.#live)
|
|
227
|
+
this.#setupAll();
|
|
228
|
+
this.#opts.onSettled?.();
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** Marks `plugin` as this host's, unless another map holds it. */
|
|
232
|
+
#claim(plugin) {
|
|
233
|
+
if (!ownedElsewhere(plugin, this) && Object.isExtensible(plugin)) {
|
|
234
|
+
plugin[OWNER] = this;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Releases a plugin this host claimed but isn't running (queued, then removed or left). */
|
|
238
|
+
#release(plugin) {
|
|
239
|
+
const owned = plugin;
|
|
240
|
+
if (owned[OWNER] === this && !this.#running.has(plugin.id))
|
|
241
|
+
delete owned[OWNER];
|
|
242
|
+
}
|
|
178
243
|
#cancelStyleWait() {
|
|
179
244
|
const listener = this.#onStyleLoad;
|
|
180
245
|
if (listener && this.#gl) {
|
|
@@ -202,8 +267,10 @@ export class PluginHost {
|
|
|
202
267
|
}
|
|
203
268
|
#teardownAll() {
|
|
204
269
|
this.#live = false;
|
|
205
|
-
for (const plugin of this.#list)
|
|
270
|
+
for (const plugin of this.#list) {
|
|
206
271
|
this.#teardown(plugin.id);
|
|
272
|
+
this.#release(plugin);
|
|
273
|
+
}
|
|
207
274
|
}
|
|
208
275
|
#setup(plugin) {
|
|
209
276
|
const gl = this.#gl;
|
|
@@ -225,7 +292,8 @@ export class PluginHost {
|
|
|
225
292
|
const ctx = this.#context(plugin.id, running);
|
|
226
293
|
let teardown;
|
|
227
294
|
try {
|
|
228
|
-
// Untracked
|
|
295
|
+
// Untracked, in case a plugin's setup reads state (setup runs from a microtask or a map
|
|
296
|
+
// event, never inside the caller's effect).
|
|
229
297
|
teardown = untrack(() => plugin.setup(ctx));
|
|
230
298
|
}
|
|
231
299
|
catch (error) {
|
|
@@ -338,12 +406,12 @@ export class PluginHost {
|
|
|
338
406
|
interactive(layerIds, toHit) {
|
|
339
407
|
if (running.dead)
|
|
340
408
|
return;
|
|
341
|
-
running.unregisters.push(router.register(pluginId, layerIds, toHit, running.plugin.hoverPriority ?? 0));
|
|
409
|
+
running.unregisters.push(router.register(pluginId, layerIds, toHit, running.plugin.hoverPriority ?? 0, running.plugin.hoverable ?? true));
|
|
342
410
|
},
|
|
343
411
|
blocking(layerIds, toHover, onClick) {
|
|
344
412
|
if (running.dead)
|
|
345
413
|
return;
|
|
346
|
-
running.unregisters.push(router.registerBlocking(pluginId, layerIds, toHover, onClick, running.plugin.hoverPriority ?? 0));
|
|
414
|
+
running.unregisters.push(router.registerBlocking(pluginId, layerIds, toHover, onClick, running.plugin.hoverPriority ?? 0, running.plugin.hoverable ?? true));
|
|
347
415
|
},
|
|
348
416
|
on(type, layerId, listener) {
|
|
349
417
|
if (running.dead)
|
package/dist/core/plugin.d.ts
CHANGED
|
@@ -46,6 +46,12 @@ export interface FeatureListItem {
|
|
|
46
46
|
* metadata's display hint). Without it the value is formatted as given.
|
|
47
47
|
*/
|
|
48
48
|
precision?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Cut a numeric `value` off at `precision` instead of rounding it, so the number never reads
|
|
51
|
+
* as the next level (e.g. fire power: 9.96 MW shows "9.9", not "10.0", beside "<10"). Default
|
|
52
|
+
* false.
|
|
53
|
+
*/
|
|
54
|
+
truncate?: boolean;
|
|
49
55
|
lngLat: [number, number];
|
|
50
56
|
selectable: boolean;
|
|
51
57
|
/**
|
|
@@ -139,6 +145,12 @@ export interface MapPlugin {
|
|
|
139
145
|
* Clicks ignore this (topmost first), and a blocking layer on top still blocks them.
|
|
140
146
|
*/
|
|
141
147
|
hoverPriority?: number;
|
|
148
|
+
/**
|
|
149
|
+
* False: the plugin's features never take the hover (so no hover tooltip and no pointer
|
|
150
|
+
* cursor) and never hide a feature beneath from it, as if they weren't rendered. Clicks,
|
|
151
|
+
* `featuresAt`, and keyboard focus from the list are unchanged. Default true.
|
|
152
|
+
*/
|
|
153
|
+
hoverable?: boolean;
|
|
142
154
|
displayOptions?: Snippet;
|
|
143
155
|
/** Richer tooltip content for one of this plugin's features, below its name. */
|
|
144
156
|
tooltip?(item: FeatureListItem): Snippet | undefined;
|
package/dist/data/format.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export interface FormatProvider {
|
|
2
2
|
number(value: number, options?: Intl.NumberFormatOptions): string;
|
|
3
|
-
/** Formats in Pacific time. */
|
|
3
|
+
/** Formats in Pacific time like sjvair.com's map: "4:00 pm Friday Nov 06, 2026". */
|
|
4
4
|
dateTime(value: Date | string): string;
|
|
5
5
|
/** "PT" only when the device zone isn't Pacific. */
|
|
6
6
|
zoneLabel(): string | null;
|
package/dist/data/format.js
CHANGED
|
@@ -2,13 +2,26 @@ const PACIFIC = "America/Los_Angeles";
|
|
|
2
2
|
export function createDefaultFormat(deviceZone = Intl.DateTimeFormat().resolvedOptions().timeZone) {
|
|
3
3
|
const dateFormat = new Intl.DateTimeFormat("en-US", {
|
|
4
4
|
timeZone: PACIFIC,
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
hour: "numeric",
|
|
6
|
+
minute: "2-digit",
|
|
7
|
+
hour12: true,
|
|
8
|
+
weekday: "long",
|
|
9
|
+
month: "short",
|
|
10
|
+
day: "2-digit",
|
|
11
|
+
year: "numeric"
|
|
7
12
|
});
|
|
13
|
+
// date-fns' "h:mm aaa EEEE MMM dd, yyyy", assembled from the parts.
|
|
14
|
+
const dateTime = (value) => {
|
|
15
|
+
const date = typeof value === "string" ? new Date(value) : value;
|
|
16
|
+
const part = {};
|
|
17
|
+
for (const { type, value: text } of dateFormat.formatToParts(date))
|
|
18
|
+
part[type] = text;
|
|
19
|
+
return `${part.hour}:${part.minute} ${part.dayPeriod?.toLowerCase()} ${part.weekday} ${part.month} ${part.day}, ${part.year}`;
|
|
20
|
+
};
|
|
8
21
|
const relative = new Intl.RelativeTimeFormat("en-US", { numeric: "always" });
|
|
9
22
|
return {
|
|
10
23
|
number: (value, options) => new Intl.NumberFormat("en-US", options).format(value),
|
|
11
|
-
dateTime
|
|
24
|
+
dateTime,
|
|
12
25
|
zoneLabel: () => (deviceZone === PACIFIC ? null : "PT"),
|
|
13
26
|
relativeTime: (value, now = new Date()) => {
|
|
14
27
|
const ms = (typeof value === "string" ? new Date(value) : value).getTime() - now.getTime();
|
|
@@ -38,13 +38,20 @@
|
|
|
38
38
|
"mapsdk_monitor_cluster_title_sjvair": "SJVAir {type} Monitors",
|
|
39
39
|
"mapsdk_monitor_cluster_count_one": "{count} monitor",
|
|
40
40
|
"mapsdk_monitor_cluster_count_other": "{count} monitors",
|
|
41
|
+
"mapsdk_monitor_cluster_count_area_one": "{count} monitor within a {area} mi² area",
|
|
42
|
+
"mapsdk_monitor_cluster_count_area_other": "{count} monitors within a {area} mi² area",
|
|
41
43
|
"mapsdk_monitor_cluster_max": "Max: {value} {units}",
|
|
42
44
|
"mapsdk_monitor_cluster_min": "Min: {value} {units}",
|
|
43
45
|
"mapsdk_monitor_interval_avg": "({interval} avg)",
|
|
44
46
|
"mapsdk_monitor_updated": "Last updated {when}",
|
|
45
47
|
"mapsdk_hms_smoke_name": "Smoke",
|
|
46
48
|
"mapsdk_hms_fire_detection": "Fire detection",
|
|
47
|
-
"
|
|
49
|
+
"mapsdk_hms_fire_box_header": "Fire ({units})",
|
|
50
|
+
"mapsdk_hms_fire_box_header_no_units": "Fire",
|
|
51
|
+
"mapsdk_hms_fire_average": "Average",
|
|
52
|
+
"mapsdk_level_with_units": "{level} {units}",
|
|
53
|
+
"mapsdk_hms_fire_detections_one": "{count} fire detection",
|
|
54
|
+
"mapsdk_hms_fire_detections_other": "{count} fire detections",
|
|
48
55
|
"mapsdk_collocation_reference": "Reference monitor",
|
|
49
56
|
"mapsdk_collocation_colocated": "Collocated monitor",
|
|
50
57
|
"mapsdk_collocation_interval_avg": "({interval} avg)",
|
package/dist/data/types.d.ts
CHANGED
|
@@ -21,6 +21,11 @@ export interface MapFeatureProperties {
|
|
|
21
21
|
name: string;
|
|
22
22
|
value: number | string | null;
|
|
23
23
|
}
|
|
24
|
+
/** An HMS fire on the map: a group of nearby detections, or a single detection. */
|
|
25
|
+
export interface FireFeatureProperties extends MapFeatureProperties {
|
|
26
|
+
/** Set on a group: how many detections it averages. Absent on a single detection. */
|
|
27
|
+
count?: number;
|
|
28
|
+
}
|
|
24
29
|
/** A monitor on the map: what the monitors preset draws and its tooltips show. */
|
|
25
30
|
export interface MonitorFeatureProperties extends MapFeatureProperties {
|
|
26
31
|
/** The server's monitor type (e.g. "purpleair", "airnow"); picks the marker shape. */
|
|
@@ -82,6 +82,11 @@ export interface PointsOptions<P extends MapFeatureProperties> {
|
|
|
82
82
|
* feature with a level.
|
|
83
83
|
*/
|
|
84
84
|
detail?(props: P): string | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Cut values off at the display precision instead of rounding them, in the list, its
|
|
87
|
+
* accessible names, and the generic tooltip (`FeatureListItem.truncate`). Default false.
|
|
88
|
+
*/
|
|
89
|
+
truncateValues?: boolean;
|
|
85
90
|
/** Whether `features()` lists this plugin's features. Default true. */
|
|
86
91
|
listed?: boolean;
|
|
87
92
|
/** Tooltip content for a feature, below its name; `props` are its source properties. */
|
|
@@ -104,7 +104,8 @@ export function pointsPlugin(options) {
|
|
|
104
104
|
precision,
|
|
105
105
|
lngLat: [lng, lat],
|
|
106
106
|
selectable: selectable && selectionId(ref) !== null,
|
|
107
|
-
...(detail ? { detail } : {})
|
|
107
|
+
...(detail ? { detail } : {}),
|
|
108
|
+
...(options.truncateValues ? { truncate: true } : {})
|
|
108
109
|
});
|
|
109
110
|
}
|
|
110
111
|
return items;
|
|
@@ -19,6 +19,11 @@ export interface ShapesOptions<P extends MapFeatureProperties> {
|
|
|
19
19
|
* (so LineString geometries are not drawn either).
|
|
20
20
|
*/
|
|
21
21
|
outline?: "theme" | "level" | "none";
|
|
22
|
+
/**
|
|
23
|
+
* False: hovering a shape shows no tooltip and doesn't hide what is beneath it
|
|
24
|
+
* (`MapPlugin.hoverable`); list focus still shows it. Default true.
|
|
25
|
+
*/
|
|
26
|
+
hoverable?: boolean;
|
|
22
27
|
}
|
|
23
28
|
/** The point used to place a feature in lists: its largest ring's centroid, or a line's midpoint. */
|
|
24
29
|
export declare function labelPoint(geometry: Geometry): [number, number] | null;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { FeatureListItem } from "../core/plugin.js";
|
|
3
|
+
import type { FormatProvider } from "../data/format.js";
|
|
4
|
+
import type { MessageProvider } from "../data/messages.js";
|
|
5
|
+
import { NO_LEVEL, numericValue } from "../data/scale.js";
|
|
6
|
+
import { formatValue } from "../ui/value-format.js";
|
|
7
|
+
import MonitorReadingBox from "./MonitorReadingBox.svelte";
|
|
8
|
+
|
|
9
|
+
// A fire's power in the monitors' reading box, beside its name and level. The value is cut
|
|
10
|
+
// off, not rounded, so it never reads as the next level (9.96 MW shows 9.9 beside "<10").
|
|
11
|
+
// No detection time: a satellite pass isn't when the fire started or whether it still burns.
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
item: FeatureListItem;
|
|
15
|
+
/** How many detections a group averages; null for a single detection. */
|
|
16
|
+
count: number | null;
|
|
17
|
+
messages: MessageProvider;
|
|
18
|
+
format: FormatProvider;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let { item, count, messages, format }: Props = $props();
|
|
22
|
+
|
|
23
|
+
const level = $derived(item.level !== NO_LEVEL ? item.level : null);
|
|
24
|
+
const value = $derived.by(() => {
|
|
25
|
+
const n = numericValue(item.value);
|
|
26
|
+
return n === null ? null : formatValue(n, item.precision, format, true);
|
|
27
|
+
});
|
|
28
|
+
const header = $derived(
|
|
29
|
+
item.units
|
|
30
|
+
? messages("mapsdk_hms_fire_box_header", { units: item.units })
|
|
31
|
+
: messages("mapsdk_hms_fire_box_header_no_units")
|
|
32
|
+
);
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<div class="mapsdk-fire-tip mapsdk-tooltip-wide">
|
|
36
|
+
<MonitorReadingBox
|
|
37
|
+
color={level?.color || null}
|
|
38
|
+
{header}
|
|
39
|
+
value={value ?? messages("mapsdk_no_reading")}
|
|
40
|
+
large={value !== null}
|
|
41
|
+
subheading={count !== null && count >= 2 ? messages("mapsdk_hms_fire_average") : undefined}
|
|
42
|
+
/>
|
|
43
|
+
<div class="mapsdk-fire-detail">
|
|
44
|
+
<span class="mapsdk-fire-name text-lg">{item.name}</span>
|
|
45
|
+
<span>
|
|
46
|
+
{level
|
|
47
|
+
? item.units
|
|
48
|
+
? messages("mapsdk_level_with_units", { level: level.label, units: item.units })
|
|
49
|
+
: level.label
|
|
50
|
+
: messages("mapsdk_no_level")}
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<style>
|
|
56
|
+
.mapsdk-fire-tip {
|
|
57
|
+
display: flex;
|
|
58
|
+
gap: 0.5rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.mapsdk-fire-name {
|
|
62
|
+
font-weight: 700;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.mapsdk-fire-detail {
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-direction: column;
|
|
68
|
+
gap: 0.25rem;
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FeatureListItem } from "../core/plugin.js";
|
|
2
|
+
import type { FormatProvider } from "../data/format.js";
|
|
3
|
+
import type { MessageProvider } from "../data/messages.js";
|
|
4
|
+
interface Props {
|
|
5
|
+
item: FeatureListItem;
|
|
6
|
+
/** How many detections a group averages; null for a single detection. */
|
|
7
|
+
count: number | null;
|
|
8
|
+
messages: MessageProvider;
|
|
9
|
+
format: FormatProvider;
|
|
10
|
+
}
|
|
11
|
+
declare const FireTooltip: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type FireTooltip = ReturnType<typeof FireTooltip>;
|
|
13
|
+
export default FireTooltip;
|
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
import type { MonitorFeatureProperties } from "../data/types.js";
|
|
8
8
|
import type { ClusterInfo } from "../plugins/points-clusters.js";
|
|
9
9
|
import { formatValue } from "../ui/value-format.js";
|
|
10
|
+
import { clusterAreaSqMiles } from "./cluster-area.js";
|
|
10
11
|
import MonitorReadingBox from "./MonitorReadingBox.svelte";
|
|
11
12
|
|
|
12
13
|
// Ported from monitor-map's MonitorClusterTooltip: the cluster's average in a level-colored
|
|
13
14
|
// box beside its monitor type, count, and highest and lowest readings. The count and average
|
|
14
15
|
// come from the bubble itself, so they show at once; the rest fills in from the cluster's
|
|
15
16
|
// monitors. The average is a whole number and max and min are as reported, as monitor-map's.
|
|
17
|
+
// The count line adds the area the monitors are within (their convex hull) once they load.
|
|
16
18
|
|
|
17
19
|
interface Props {
|
|
18
20
|
cluster: ClusterInfo<MonitorFeatureProperties>;
|
|
@@ -29,6 +31,8 @@
|
|
|
29
31
|
isSjvair: boolean;
|
|
30
32
|
/** Readings only; monitors without one are left out. */
|
|
31
33
|
values: number[];
|
|
34
|
+
/** Square miles of the monitors' convex hull, every monitor included. */
|
|
35
|
+
area: number;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
let leaves = $state.raw<Leaves | null>(null);
|
|
@@ -60,7 +64,8 @@
|
|
|
60
64
|
values: found.flatMap(({ props }) => {
|
|
61
65
|
const n = numericValue(props.value);
|
|
62
66
|
return n === null ? [] : [n];
|
|
63
|
-
})
|
|
67
|
+
}),
|
|
68
|
+
area: clusterAreaSqMiles(found.map(({ lngLat }) => lngLat))
|
|
64
69
|
};
|
|
65
70
|
},
|
|
66
71
|
() => {
|
|
@@ -87,6 +92,16 @@
|
|
|
87
92
|
return found !== NO_LEVEL ? found : null;
|
|
88
93
|
});
|
|
89
94
|
const units = $derived(scale?.units ?? "");
|
|
95
|
+
/** The area to 3 decimals, or null when there is none to show (it would read 0.000). */
|
|
96
|
+
const area = $derived.by(() => {
|
|
97
|
+
// Decided on the number, so a host's own number format can't change what's hidden.
|
|
98
|
+
if (!leaves || Math.round(leaves.area * 1000) === 0) return null;
|
|
99
|
+
return format.number(leaves.area, {
|
|
100
|
+
minimumFractionDigits: 3,
|
|
101
|
+
maximumFractionDigits: 3,
|
|
102
|
+
useGrouping: false
|
|
103
|
+
});
|
|
104
|
+
});
|
|
90
105
|
const max = $derived(leaves?.values.length ? Math.max(...leaves.values) : null);
|
|
91
106
|
const min = $derived(leaves?.values.length ? Math.min(...leaves.values) : null);
|
|
92
107
|
</script>
|
|
@@ -108,7 +123,11 @@
|
|
|
108
123
|
)}
|
|
109
124
|
</span>
|
|
110
125
|
{/if}
|
|
111
|
-
<span>
|
|
126
|
+
<span>
|
|
127
|
+
{area
|
|
128
|
+
? messages("mapsdk_monitor_cluster_count_area", { count: cluster.pointCount, area })
|
|
129
|
+
: messages("mapsdk_monitor_cluster_count", { count: cluster.pointCount })}
|
|
130
|
+
</span>
|
|
112
131
|
<span>{level ? level.label : messages("mapsdk_no_level")}</span>
|
|
113
132
|
{#if max !== null && min !== null}
|
|
114
133
|
<span class="mapsdk-monitor-extremes text-base">
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type Point = [number, number];
|
|
2
|
+
/**
|
|
3
|
+
* The convex hull of lng/lat points (monotone chain): counter-clockwise, without a repeated
|
|
4
|
+
* closing point, inner and collinear points dropped. Fewer than 3 points means no area.
|
|
5
|
+
*/
|
|
6
|
+
export declare function convexHull(points: Point[]): Point[];
|
|
7
|
+
/**
|
|
8
|
+
* The area, in square miles, of the smallest shape around a cluster's monitors (their convex
|
|
9
|
+
* hull), as sjvair.com's map computed area: `@turf/area`, then `convertArea`. 0 for fewer than 3
|
|
10
|
+
* points not on one line.
|
|
11
|
+
*/
|
|
12
|
+
export declare function clusterAreaSqMiles(points: Point[]): number;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import area from "@turf/area";
|
|
2
|
+
import { convertArea, polygon } from "@turf/helpers";
|
|
3
|
+
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
4
|
+
/**
|
|
5
|
+
* The convex hull of lng/lat points (monotone chain): counter-clockwise, without a repeated
|
|
6
|
+
* closing point, inner and collinear points dropped. Fewer than 3 points means no area.
|
|
7
|
+
*/
|
|
8
|
+
export function convexHull(points) {
|
|
9
|
+
const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
10
|
+
if (sorted.length < 3)
|
|
11
|
+
return sorted;
|
|
12
|
+
const half = (from) => {
|
|
13
|
+
const chain = [];
|
|
14
|
+
for (const p of from) {
|
|
15
|
+
while (chain.length >= 2 && cross(chain[chain.length - 2], chain[chain.length - 1], p) <= 0)
|
|
16
|
+
chain.pop();
|
|
17
|
+
chain.push(p);
|
|
18
|
+
}
|
|
19
|
+
chain.pop();
|
|
20
|
+
return chain;
|
|
21
|
+
};
|
|
22
|
+
return [...half(sorted), ...half([...sorted].reverse())];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The area, in square miles, of the smallest shape around a cluster's monitors (their convex
|
|
26
|
+
* hull), as sjvair.com's map computed area: `@turf/area`, then `convertArea`. 0 for fewer than 3
|
|
27
|
+
* points not on one line.
|
|
28
|
+
*/
|
|
29
|
+
export function clusterAreaSqMiles(points) {
|
|
30
|
+
const hull = convexHull(points);
|
|
31
|
+
if (hull.length < 3)
|
|
32
|
+
return 0;
|
|
33
|
+
return convertArea(area(polygon([[...hull, hull[0]]])), "meters", "miles");
|
|
34
|
+
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { MapPlugin } from "../core/plugin.js";
|
|
2
|
-
import type { DataSource } from "../data/types.js";
|
|
2
|
+
import type { DataSource, FireFeatureProperties } from "../data/types.js";
|
|
3
3
|
export interface HmsFirePluginOptions {
|
|
4
4
|
/** Default "hms-fire". */
|
|
5
5
|
id?: string;
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The fires, or with `hotspots` the groups shown below zoom 10 (listed). A feature with a
|
|
8
|
+
* `count` is a group (its tooltip says "Average" from 2 detections); one without is a single
|
|
9
|
+
* detection.
|
|
10
|
+
*/
|
|
11
|
+
source: DataSource<FireFeatureProperties>;
|
|
8
12
|
/** Single detections shown from zoom 10 instead of `source` (not listed). Default: none. */
|
|
9
|
-
hotspots?: DataSource
|
|
13
|
+
hotspots?: DataSource<FireFeatureProperties>;
|
|
10
14
|
/** Already-translated layer name for the list and summary. */
|
|
11
15
|
layerLabel: string;
|
|
12
16
|
}
|
|
@@ -16,9 +20,9 @@ export interface HmsFirePluginOptions {
|
|
|
16
20
|
*
|
|
17
21
|
* With `hotspots`, it is still one layer (one legend section, one list) that switches by itself
|
|
18
22
|
* while zooming: `source` (groups from `groupFires`, value = `avgFrp`) below zoom 10, `hotspots`
|
|
19
|
-
* (each detection) from zoom 10. Both are hit-tested and show the same tooltip
|
|
20
|
-
* level
|
|
21
|
-
*
|
|
23
|
+
* (each detection) from zoom 10. Both are hit-tested and show the same tooltip: the power in a
|
|
24
|
+
* reading box colored by its level, beside the name and level; only the groups are listed. Values
|
|
25
|
+
* are cut off at the display precision, not rounded, in the tooltip, list, and accessible names.
|
|
22
26
|
*
|
|
23
27
|
* The sources' `scale` is expected to be the ScaleRef `{ dataset: "hms-fire", entryType:
|
|
24
28
|
* "fire-frp" }`, and each feature's `value` its FRP. This preset does not fetch.
|
package/dist/presets/hms-fire.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { scopedContext } from "../core/scoped-context.js";
|
|
2
2
|
import { fireIcon } from "../plugins/icons.js";
|
|
3
3
|
import { pointsPlugin } from "../plugins/points.svelte.js";
|
|
4
|
-
import
|
|
4
|
+
import FireTooltip from "./FireTooltip.svelte";
|
|
5
5
|
import { componentSnippet } from "./monitors.js";
|
|
6
6
|
/** The zoom where `hotspots` replace the groups (inclusive), as sjvair.com's map does. */
|
|
7
7
|
const HOTSPOT_ZOOM = 10;
|
|
@@ -22,6 +22,14 @@ function firePoints(id, source, layerLabel, zoomRange) {
|
|
|
22
22
|
selectable: false,
|
|
23
23
|
zoomRange,
|
|
24
24
|
legendDisplay: "blocks",
|
|
25
|
+
truncateValues: true,
|
|
26
|
+
tooltipShowsName: true,
|
|
27
|
+
tooltip: (item, props, env) => componentSnippet(FireTooltip, {
|
|
28
|
+
item,
|
|
29
|
+
count: typeof props.count === "number" ? props.count : null,
|
|
30
|
+
messages: env.messages,
|
|
31
|
+
format: env.format
|
|
32
|
+
}),
|
|
25
33
|
icon: {
|
|
26
34
|
category: () => "fire",
|
|
27
35
|
draw: (_category, color, _theme, levelIndex) => drawFire(color, levelIndex),
|
|
@@ -35,9 +43,9 @@ function firePoints(id, source, layerLabel, zoomRange) {
|
|
|
35
43
|
*
|
|
36
44
|
* With `hotspots`, it is still one layer (one legend section, one list) that switches by itself
|
|
37
45
|
* while zooming: `source` (groups from `groupFires`, value = `avgFrp`) below zoom 10, `hotspots`
|
|
38
|
-
* (each detection) from zoom 10. Both are hit-tested and show the same tooltip
|
|
39
|
-
* level
|
|
40
|
-
*
|
|
46
|
+
* (each detection) from zoom 10. Both are hit-tested and show the same tooltip: the power in a
|
|
47
|
+
* reading box colored by its level, beside the name and level; only the groups are listed. Values
|
|
48
|
+
* are cut off at the display precision, not rounded, in the tooltip, list, and accessible names.
|
|
41
49
|
*
|
|
42
50
|
* The sources' `scale` is expected to be the ScaleRef `{ dataset: "hms-fire", entryType:
|
|
43
51
|
* "fire-frp" }`, and each feature's `value` its FRP. This preset does not fetch.
|
|
@@ -50,8 +58,6 @@ export function hmsFirePlugin(options) {
|
|
|
50
58
|
// Both inner plugins share the outer id, so their hits are this plugin's.
|
|
51
59
|
const groups = firePoints(id, source, layerLabel, { max: HOTSPOT_ZOOM });
|
|
52
60
|
const singles = firePoints(id, hotspots, layerLabel, { min: HOTSPOT_ZOOM });
|
|
53
|
-
// The providers of the running setup, for the hotspots' tooltips.
|
|
54
|
-
let ctxNow = null;
|
|
55
61
|
return {
|
|
56
62
|
id,
|
|
57
63
|
label: layerLabel,
|
|
@@ -60,28 +66,21 @@ export function hmsFirePlugin(options) {
|
|
|
60
66
|
},
|
|
61
67
|
selectable: false,
|
|
62
68
|
setup(ctx) {
|
|
63
|
-
ctxNow = ctx;
|
|
64
69
|
const teardownGroups = groups.setup(scopedContext(ctx, "groups:"));
|
|
65
70
|
const teardownSingles = singles.setup(scopedContext(ctx, "hotspots:"));
|
|
66
71
|
return () => {
|
|
67
|
-
ctxNow = null;
|
|
68
72
|
teardownSingles();
|
|
69
73
|
teardownGroups();
|
|
70
74
|
};
|
|
71
75
|
},
|
|
72
76
|
legend: groups.legend,
|
|
73
|
-
// Only the groups are listed; a hovered hotspot shows
|
|
74
|
-
// (the generic header) with its value and level.
|
|
77
|
+
// Only the groups are listed; a hovered hotspot shows the same box as a group.
|
|
75
78
|
features: groups.features,
|
|
79
|
+
tooltip: groups.tooltip,
|
|
80
|
+
tooltipShowsName: true,
|
|
76
81
|
hoverTooltip(hit) {
|
|
77
82
|
const item = singles.features?.().find((f) => f.refKey === hit.refKey);
|
|
78
|
-
|
|
79
|
-
return undefined;
|
|
80
|
-
return componentSnippet(TooltipItem, {
|
|
81
|
-
item,
|
|
82
|
-
messages: ctxNow.messages,
|
|
83
|
-
format: ctxNow.format
|
|
84
|
-
});
|
|
83
|
+
return item && singles.tooltip?.(item);
|
|
85
84
|
}
|
|
86
85
|
};
|
|
87
86
|
}
|
|
@@ -10,7 +10,8 @@ export interface HmsSmokePluginOptions {
|
|
|
10
10
|
/**
|
|
11
11
|
* NOAA HMS smoke plumes: polygons filled and outlined (`outline: "level"`) by density, using each
|
|
12
12
|
* level's opacity. Not selectable; listed (each plume a row with its density level), so plumes
|
|
13
|
-
* can be reached without a mouse.
|
|
13
|
+
* can be reached without a mouse. Hovering a plume shows no tooltip and never hides another
|
|
14
|
+
* layer's (`hoverable: false`); focusing one from the list still pins its tooltip.
|
|
14
15
|
*
|
|
15
16
|
* The source's `scale` is expected to be the ScaleRef
|
|
16
17
|
* `{ dataset: "hms-smoke", entryType: "smoke-density" }`, and each feature's `value` its density
|
|
@@ -2,7 +2,8 @@ import { shapesPlugin } from "../plugins/shapes.svelte.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* NOAA HMS smoke plumes: polygons filled and outlined (`outline: "level"`) by density, using each
|
|
4
4
|
* level's opacity. Not selectable; listed (each plume a row with its density level), so plumes
|
|
5
|
-
* can be reached without a mouse.
|
|
5
|
+
* can be reached without a mouse. Hovering a plume shows no tooltip and never hides another
|
|
6
|
+
* layer's (`hoverable: false`); focusing one from the list still pins its tooltip.
|
|
6
7
|
*
|
|
7
8
|
* The source's `scale` is expected to be the ScaleRef
|
|
8
9
|
* `{ dataset: "hms-smoke", entryType: "smoke-density" }`, and each feature's `value` its density
|
|
@@ -15,6 +16,7 @@ export function hmsSmokePlugin(options) {
|
|
|
15
16
|
layerLabel: options.layerLabel,
|
|
16
17
|
mode: "choropleth",
|
|
17
18
|
selectable: false,
|
|
18
|
-
outline: "level"
|
|
19
|
+
outline: "level",
|
|
20
|
+
hoverable: false
|
|
19
21
|
});
|
|
20
22
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type MessageProvider } from "../data/messages.js";
|
|
2
|
-
import type { DataSource, DataStatus } from "../data/types.js";
|
|
2
|
+
import type { DataSource, DataStatus, FireFeatureProperties } from "../data/types.js";
|
|
3
3
|
import type { SignalOptions } from "./abort.js";
|
|
4
4
|
export interface HmsStoreOptions {
|
|
5
5
|
/** Names features ("Smoke", "Fire detection"). Default: the built-in English messages. */
|
|
@@ -22,8 +22,11 @@ export interface HmsSmokeStore {
|
|
|
22
22
|
export interface HmsFireStore {
|
|
23
23
|
/** `loading` until loaded, then `ready` (also when today has no fires) or `error`. */
|
|
24
24
|
readonly status: DataStatus;
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Nearby detections merged (`groupFires`), for `hmsFirePlugin`; `value` is the mean FRP and
|
|
27
|
+
* `count` how many detections it merges.
|
|
28
|
+
*/
|
|
29
|
+
readonly groups: DataSource<FireFeatureProperties>;
|
|
27
30
|
/** Each detection (`cleanFires`), for `hmsFirePlugin`; `value` is its FRP. */
|
|
28
31
|
readonly hotspots: DataSource;
|
|
29
32
|
/** As `HmsSmokeStore.init`. */
|
|
@@ -69,15 +69,15 @@ export function createHmsFireStore(options = {}) {
|
|
|
69
69
|
value: fire.frp
|
|
70
70
|
}
|
|
71
71
|
}));
|
|
72
|
+
// Every group names its count, a group of 1 included ("1 fire detection").
|
|
72
73
|
const groups = groupFires(detections).map((group) => ({
|
|
73
74
|
type: "Feature",
|
|
74
75
|
geometry: { type: "Point", coordinates: group.coordinates },
|
|
75
76
|
properties: {
|
|
76
77
|
refKey: encodeRef({ kind: "feature", id: group.id }),
|
|
77
|
-
name: group.count
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
value: group.avgFrp
|
|
78
|
+
name: messages("mapsdk_hms_fire_detections", { count: group.count }),
|
|
79
|
+
value: group.avgFrp,
|
|
80
|
+
count: group.count
|
|
81
81
|
}
|
|
82
82
|
}));
|
|
83
83
|
return {
|
|
@@ -270,7 +270,7 @@
|
|
|
270
270
|
function formatValue(row: Row): string {
|
|
271
271
|
if (row.value === null) return "";
|
|
272
272
|
return typeof row.value === "number"
|
|
273
|
-
? formatNumber(row.value, row.precision, map.format)
|
|
273
|
+
? formatNumber(row.value, row.precision, map.format, row.truncate)
|
|
274
274
|
: row.value;
|
|
275
275
|
}
|
|
276
276
|
|
package/dist/ui/feature-list.js
CHANGED
|
@@ -87,7 +87,9 @@ export function rowLabel(item, messages, format) {
|
|
|
87
87
|
}
|
|
88
88
|
const params = {
|
|
89
89
|
name: item.name,
|
|
90
|
-
value: typeof item.value === "number"
|
|
90
|
+
value: typeof item.value === "number"
|
|
91
|
+
? formatValue(item.value, item.precision, format, item.truncate)
|
|
92
|
+
: item.value,
|
|
91
93
|
units: item.units,
|
|
92
94
|
level: level.text
|
|
93
95
|
};
|
|
@@ -2,6 +2,7 @@ import type { FormatProvider } from "../data/format.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* A numeric value with exactly `precision` decimal places, or as `format.number` gives it when
|
|
4
4
|
* there is no precision. Every place that shows a value (list rows, tooltips) goes through this,
|
|
5
|
-
* so they all agree.
|
|
5
|
+
* so they all agree. With `truncate` the extra decimals are cut off rather than rounded (9.96 at
|
|
6
|
+
* precision 1 is "9.9"), so the number never reads as the next level up.
|
|
6
7
|
*/
|
|
7
|
-
export declare function formatValue(value: number, precision: number | undefined, format: FormatProvider): string;
|
|
8
|
+
export declare function formatValue(value: number, precision: number | undefined, format: FormatProvider, truncate?: boolean): string;
|
package/dist/ui/value-format.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A numeric value with exactly `precision` decimal places, or as `format.number` gives it when
|
|
3
3
|
* there is no precision. Every place that shows a value (list rows, tooltips) goes through this,
|
|
4
|
-
* so they all agree.
|
|
4
|
+
* so they all agree. With `truncate` the extra decimals are cut off rather than rounded (9.96 at
|
|
5
|
+
* precision 1 is "9.9"), so the number never reads as the next level up.
|
|
5
6
|
*/
|
|
6
|
-
export function formatValue(value, precision, format) {
|
|
7
|
+
export function formatValue(value, precision, format, truncate = false) {
|
|
7
8
|
if (precision === undefined)
|
|
8
9
|
return format.number(value);
|
|
10
|
+
if (truncate) {
|
|
11
|
+
const factor = 10 ** precision;
|
|
12
|
+
// toPrecision drops float noise first (4.35 * 100 is 434.99999999999994); 15 digits keep
|
|
13
|
+
// any real value from rounding up.
|
|
14
|
+
value = Math.trunc(Number((value * factor).toPrecision(15))) / factor;
|
|
15
|
+
}
|
|
9
16
|
return format.number(value, {
|
|
10
17
|
minimumFractionDigits: precision,
|
|
11
18
|
maximumFractionDigits: precision
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjvair/map-sdk",
|
|
3
3
|
"description": "Svelte 5 map components for SJVAir air-quality data",
|
|
4
|
-
"version": "0.1.0-beta.
|
|
4
|
+
"version": "0.1.0-beta.4",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
65
|
"@maptiler/sdk": "~4.2.0",
|
|
66
|
+
"@turf/area": "^7.4.0",
|
|
66
67
|
"@turf/clusters-dbscan": "^7.4.0",
|
|
67
68
|
"@turf/helpers": "^7.4.0",
|
|
68
69
|
"@types/geojson": "^7946.0.16",
|