@sjvair/map-sdk 0.1.0-beta.3 → 0.1.0-beta.5
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 +99 -5
- package/dist/core/create-map.svelte.d.ts +17 -2
- package/dist/core/create-map.svelte.js +108 -5
- package/dist/core/interactions.svelte.d.ts +5 -3
- package/dist/core/interactions.svelte.js +20 -7
- package/dist/core/plugin-host.svelte.js +2 -2
- package/dist/core/plugin.d.ts +25 -0
- package/dist/core/shape-view.d.ts +27 -0
- package/dist/core/shape-view.js +323 -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/index.d.ts +2 -0
- package/dist/index.js +1 -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 +20 -2
- 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/MapTooltip.svelte +17 -4
- package/dist/ui/TooltipItem.svelte +1 -1
- package/dist/ui/feature-list.d.ts +4 -1
- package/dist/ui/feature-list.js +9 -5
- 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
|
@@ -167,7 +167,22 @@ points only), as sjvair.com's map does today.
|
|
|
167
167
|
|
|
168
168
|
`outline` on `shapesPlugin` is `"theme"` (default, a 1px `--foreground` edge), `"level"` (the edge
|
|
169
169
|
in each level's color at the fill's opacity; choropleth only), or `"none"` (no outline layer).
|
|
170
|
-
`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
|
+
```
|
|
171
186
|
|
|
172
187
|
`zoomRange: { min?, max? }` on `pointsPlugin` limits every layer it draws (points, clusters,
|
|
173
188
|
selection) to camera zooms from `min` (inclusive) up to `max` (exclusive), switching live while
|
|
@@ -177,7 +192,12 @@ zooming. Two plugins whose ranges meet hand over with no gap, on the globe too.
|
|
|
177
192
|
colored by FRP. With `hotspots`, it switches by itself while zooming, as sjvair.com's map does:
|
|
178
193
|
`source` (the fire store's `groups`) below zoom 10 and `hotspots` from zoom 10. It stays one
|
|
179
194
|
layer: one legend section, one feature list (the groups, so keyboard users reach every fire;
|
|
180
|
-
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".
|
|
181
201
|
|
|
182
202
|
`legendDisplay: "gradient" | "blocks"` on `pointsPlugin` sets how the legend draws a range scale
|
|
183
203
|
(default a gradient; `hmsFirePlugin` uses `"blocks"`, labeled with the levels' own labels). A blocks
|
|
@@ -204,6 +224,10 @@ charger") shown where "No level" would be: in the feature list (plain text, no s
|
|
|
204
224
|
accessible name ("Fresno Station, Level 2 charger"), and the tooltip. A feature with a level
|
|
205
225
|
always shows its level. A plugin of your own sets the same `detail` on its `FeatureListItem`s.
|
|
206
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
|
+
|
|
207
231
|
`collocationSitesPlugin({ id?, source, layerLabel })` draws collocation sites as sjvair.com does:
|
|
208
232
|
each at its reference monitor, in that monitor's shape and level color (inactive gray, inside
|
|
209
233
|
outlined), wrapped in a crosshair of `COLLOCATION_CROSSHAIR_COLOR` (`#4A5FC6`, a fixed color on
|
|
@@ -229,7 +253,12 @@ and reads "No level"; a level with an empty color draws neutral with its own lab
|
|
|
229
253
|
source loads, fails, or is unavailable, the legend says so (a failure offers "Try again", which
|
|
230
254
|
calls `status.retry()`). Values are shown with the metadata's display precision, except in the
|
|
231
255
|
monitor, cluster, and collocation tooltips, which match monitor-map: the reading and a cluster's
|
|
232
|
-
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).
|
|
233
262
|
|
|
234
263
|
`createMonitorsStore()` (from `@sjvair/map-sdk/stores`) loads the monitors list and latest readings
|
|
235
264
|
for an entry type, polls every two minutes, and exposes `dataSource`. `init()`, `update()`,
|
|
@@ -345,6 +374,19 @@ function sitesPlugin(sites: Sites): MapPlugin {
|
|
|
345
374
|
}
|
|
346
375
|
```
|
|
347
376
|
|
|
377
|
+
An item for a shape (a polygon or line) can carry its `geometry` and, optionally, its `bbox`
|
|
378
|
+
(`[west, south, east, north]`, from `geometryBbox(geometry)`). The list and the summary then count
|
|
379
|
+
it as in view when the shape overlaps the view, not only when its `lngLat` is inside it: a smoke
|
|
380
|
+
plume that fills the screen is listed even though its center is off-screen. Measure `bbox` once per
|
|
381
|
+
data load (both are tested on every move); without it the box is computed each time. Focusing such
|
|
382
|
+
a shape from the list while its `lngLat` is off-screen leaves the camera where it is, and its
|
|
383
|
+
tooltip pins at a point of the shape in view: the view's center (beside the list panel) when the
|
|
384
|
+
shape covers it, else a point inside its visible part. `map.focusedAnchor` is where the focused
|
|
385
|
+
tooltip pins, and `map.anchorFor(item)` gives that point for any item without moving the camera
|
|
386
|
+
(the hover tooltip uses it). `intersectsBounds(item, bounds)` is the in-view test. `shapesPlugin`
|
|
387
|
+
and the presets built on it set `geometry` and `bbox` already. Bounds across the antimeridian are
|
|
388
|
+
not handled.
|
|
389
|
+
|
|
348
390
|
A plugin can also add a MapLibre custom layer, such as a wind layer from a library you add
|
|
349
391
|
yourself, with `ctx.addCustomLayer(local, create)`. The host calls `create` with the namespaced id
|
|
350
392
|
(the layer must use it) and adds the object it returns as is, in the plugin's draw-order slot. It is
|
|
@@ -356,7 +398,10 @@ features are under the pointer, the hover tooltip shows the one whose plugin has
|
|
|
356
398
|
`hoverPriority` (default 0), the topmost on a tie. Over a cluster bubble, the bubble's tooltip
|
|
357
399
|
shows unless a feature beneath belongs to a plugin with a higher `hoverPriority` than the
|
|
358
400
|
bubble's (collocation sites under monitor clusters). Clicks always go to the topmost, so a click
|
|
359
|
-
on a bubble still zooms into it.
|
|
401
|
+
on a bubble still zooms into it. A plugin with `hoverable: false` never takes the hover: its
|
|
402
|
+
features show no hover tooltip or pointer cursor and are passed over as if they weren't drawn, so
|
|
403
|
+
whatever is beneath gets the hover. They are still clicked, returned by `featuresAt`, and pin
|
|
404
|
+
their tooltip when focused from the feature list.
|
|
360
405
|
|
|
361
406
|
```ts
|
|
362
407
|
import type { CustomLayerInterface, MapPlugin, PluginContext } from "@sjvair/map-sdk";
|
|
@@ -408,7 +453,8 @@ and `getSelectionMode(id)` are reactive.
|
|
|
408
453
|
|
|
409
454
|
`map.on("featureclick", ({ pluginId, ref, lngLat }) => …)` fires for every click on a feature,
|
|
410
455
|
whatever the mode, and also for a pick in the feature list, so keyboard users get the same event;
|
|
411
|
-
`lngLat` is the click point
|
|
456
|
+
`lngLat` is the click point. For a list pick it's where the item's tooltip pins: the item's
|
|
457
|
+
position, or for a shape whose label point is off-screen, a point of the shape in view. It returns an unsubscribe
|
|
412
458
|
function. For your own context menu, `map.featuresAt({ x, y })` returns the hits under
|
|
413
459
|
a canvas point, topmost first, and `map.featureForElement(element)` returns the hit for a row of
|
|
414
460
|
the feature list (an element with `data-mapsdk-ref`). Both return nothing while the map is
|
|
@@ -500,6 +546,54 @@ local sjvair.com stack at `http://localhost:8000`.
|
|
|
500
546
|
podman exec sjvaircom_web_1 python manage.py loaddata default-calibrations.yaml
|
|
501
547
|
```
|
|
502
548
|
|
|
549
|
+
## Changes
|
|
550
|
+
|
|
551
|
+
### 0.1.0-beta.5
|
|
552
|
+
|
|
553
|
+
- **Shapes that cover the view are in view.** The feature list and the view summary count a shape
|
|
554
|
+
when it overlaps the view, not only when its center (label point) is inside it. A smoke plume or
|
|
555
|
+
a big region that fills the screen, with its center off-screen, now has a list row and is
|
|
556
|
+
counted. Points are unchanged.
|
|
557
|
+
- **Focusing such a shape doesn't move the map.** Before, focusing a row panned to center the
|
|
558
|
+
shape's label point (a plume's could be off Baja). Now the camera stays, and the tooltip pins at
|
|
559
|
+
a point of the shape in view, with no move event. A shape whose label point is on screen still
|
|
560
|
+
pans to it.
|
|
561
|
+
- **Hover tooltips** in `"pinned"` mode follow the same rule: a hovered shape whose label point is
|
|
562
|
+
off-screen pins its tooltip inside the view instead of at the map's edge. If the map moves while
|
|
563
|
+
the pointer rests on the shape, the pin is found again when the map settles.
|
|
564
|
+
- **`featureclick` from the list** (Enter on a row) reports that same in-view point for such a
|
|
565
|
+
shape, instead of its off-screen label point. A handler that pans to `lngLat` no longer sends
|
|
566
|
+
the map off to the shape's far-away center. Points and on-screen label points report their
|
|
567
|
+
position, as before.
|
|
568
|
+
- **New API (all optional):** `FeatureListItem.geometry` and `.bbox`, `MapContext.focusedAnchor`
|
|
569
|
+
and `.anchorFor(item)`, `geometryBbox`, `intersectsBounds`, and the `Bbox` type.
|
|
570
|
+
|
|
571
|
+
### 0.1.0-beta.4
|
|
572
|
+
|
|
573
|
+
Defaults from monitor-map 5.0's parity review against sjvair.com's map (4.3.0). Every map gets
|
|
574
|
+
them; none is an option.
|
|
575
|
+
|
|
576
|
+
- **Tooltip times** read like sjvair.com's map, in Pacific time: "4:00 pm Friday Nov 06, 2026"
|
|
577
|
+
(was "Nov 6, 2026, 4:00 PM"). A device in another zone still gets " PT" after the time.
|
|
578
|
+
- **The cluster area line is back.** The monitor cluster tooltip's count line reads "3 monitors
|
|
579
|
+
within a 22.619 mi² area". This reverses the 2026-11-04 parity decision of "no area line". The
|
|
580
|
+
wording is new (sjvair.com said "covering"), and the area is the convex hull of the cluster's
|
|
581
|
+
monitors, so clusters of four or more can differ from sjvair.com's number, which followed the
|
|
582
|
+
monitors' order and could cross itself. No line when the area would read 0.000.
|
|
583
|
+
- **Fire tooltips are the reading box**: "Fire (MW)", the power in its level's color, beside the
|
|
584
|
+
name and level; "Average" for a group of two or more. Fire values are cut off, not rounded, in
|
|
585
|
+
the tooltip, the list and its accessible names. Every group names its count, a group of 1
|
|
586
|
+
included ("1 fire detection").
|
|
587
|
+
- **Smoke plumes show no hover tooltip** and never take the hover from what is beneath them;
|
|
588
|
+
focusing a plume from the feature list still pins its tooltip. This reverses part of milestone
|
|
589
|
+
2's accessibility additions ("tooltips on smoke and fire"): fire keeps a tooltip.
|
|
590
|
+
- **Breaking for hosts with their own message catalog:** `mapsdk_hms_fire_detections` is now
|
|
591
|
+
`mapsdk_hms_fire_detections_one` / `_other`. New keys: `mapsdk_hms_fire_box_header`,
|
|
592
|
+
`mapsdk_hms_fire_box_header_no_units`, `mapsdk_hms_fire_average`, `mapsdk_level_with_units`,
|
|
593
|
+
and `mapsdk_monitor_cluster_count_area_one` / `_other`.
|
|
594
|
+
- **New API:** `MapPlugin.hoverable`, `shapesPlugin`'s `hoverable`, `pointsPlugin`'s
|
|
595
|
+
`truncateValues`, `FeatureListItem.truncate`, and `FireFeatureProperties`.
|
|
596
|
+
|
|
503
597
|
## Development
|
|
504
598
|
|
|
505
599
|
```bash
|
|
@@ -5,7 +5,7 @@ import type { FeatureRef, SelectionMode, SpatialSelection } from "../data/types.
|
|
|
5
5
|
import { BasemapState, type BasemapConfig } from "./basemap.svelte.js";
|
|
6
6
|
import type { GlMapLike } from "./gl.js";
|
|
7
7
|
import { type Hovered } from "./interactions.svelte.js";
|
|
8
|
-
import type { FeatureHit, MapPlugin } from "./plugin.js";
|
|
8
|
+
import type { FeatureHit, FeatureListItem, MapPlugin } from "./plugin.js";
|
|
9
9
|
export interface CameraState {
|
|
10
10
|
center: [number, number];
|
|
11
11
|
zoom: number;
|
|
@@ -52,7 +52,10 @@ export interface PluginOptions {
|
|
|
52
52
|
export type FeatureClickHandler = (e: {
|
|
53
53
|
pluginId: string;
|
|
54
54
|
ref: FeatureRef;
|
|
55
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* The click point. For a pick in the feature list, where the item's tooltip pins: its position,
|
|
57
|
+
* or for a shape whose label point is off-screen, a point of the shape in view.
|
|
58
|
+
*/
|
|
56
59
|
lngLat: [number, number];
|
|
57
60
|
}) => void;
|
|
58
61
|
/** Where a camera move ends up. `zoom` wins over `minZoom`. */
|
|
@@ -185,6 +188,18 @@ export interface MapContext {
|
|
|
185
188
|
readonly focusedKey: string | null;
|
|
186
189
|
/** Reactive. The plugin `focusFeature` was given with `focusedKey`, or `null`. */
|
|
187
190
|
readonly focusedPluginId: string | null;
|
|
191
|
+
/**
|
|
192
|
+
* Reactive. Where the focused feature's tooltip pins, set by `focusFeature` (`null` without
|
|
193
|
+
* focus): its `lngLat`, or for a shape that overlaps the view with its `lngLat` off-screen, a
|
|
194
|
+
* point of the shape in the part of the view the list panel leaves clear. Fixed at focus
|
|
195
|
+
* time, so it stays on the feature if the map then moves.
|
|
196
|
+
*/
|
|
197
|
+
readonly focusedAnchor: [number, number] | null;
|
|
198
|
+
/**
|
|
199
|
+
* Where `item`'s tooltip pins in the current view, without moving the camera: its `lngLat`,
|
|
200
|
+
* or for a shape (`geometry`) with its `lngLat` off-screen, a point of the shape in view.
|
|
201
|
+
*/
|
|
202
|
+
anchorFor(item: FeatureListItem): [number, number];
|
|
188
203
|
/** For MapView only: uses `gl` from now on. */
|
|
189
204
|
attach(gl: GlMapLike): void;
|
|
190
205
|
/** For MapView only: stops using the current GL map. */
|
|
@@ -7,6 +7,7 @@ import { applyPick, selectionKeys } from "../data/selection.js";
|
|
|
7
7
|
import { BasemapState } from "./basemap.svelte.js";
|
|
8
8
|
import { InteractionRouter } from "./interactions.svelte.js";
|
|
9
9
|
import { PluginHost } from "./plugin-host.svelte.js";
|
|
10
|
+
import { intersectsBounds, shapeAnchor } from "./shape-view.js";
|
|
10
11
|
/** Monitor-map's start view. */
|
|
11
12
|
const DEFAULT_VIEW = {
|
|
12
13
|
center: [-119.7987626619462, 36.76272050981146],
|
|
@@ -36,6 +37,7 @@ function sameSelection(a, b) {
|
|
|
36
37
|
return a === b;
|
|
37
38
|
return (a.kind === b.kind && a.ids.length === b.ids.length && a.ids.every((id, i) => id === b.ids[i]));
|
|
38
39
|
}
|
|
40
|
+
const inRect = (p, b) => p[0] >= b.west && p[0] <= b.east && p[1] >= b.south && p[1] <= b.north;
|
|
39
41
|
function prefersReducedMotion() {
|
|
40
42
|
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
|
41
43
|
}
|
|
@@ -55,6 +57,7 @@ class MapContextImpl {
|
|
|
55
57
|
#glState = $state.raw(null);
|
|
56
58
|
#focusedKey = $state(null);
|
|
57
59
|
#focusedPluginId = $state(null);
|
|
60
|
+
#focusedAnchor = $state.raw(null);
|
|
58
61
|
#selections = new SvelteMap();
|
|
59
62
|
#featureClickHandlers = new Set();
|
|
60
63
|
#router = new InteractionRouter();
|
|
@@ -63,6 +66,11 @@ class MapContextImpl {
|
|
|
63
66
|
#destroyed = false;
|
|
64
67
|
#listInset = 0;
|
|
65
68
|
#listInsetSide = "left";
|
|
69
|
+
/** An animated ease that `focusFeature` started is still running (cleared on `moveend`). */
|
|
70
|
+
#focusEasing = false;
|
|
71
|
+
#onMoveEnd = () => {
|
|
72
|
+
this.#focusEasing = false;
|
|
73
|
+
};
|
|
66
74
|
constructor(options) {
|
|
67
75
|
const initialView = copyView(options.config.initialView ?? DEFAULT_VIEW);
|
|
68
76
|
this.config = {
|
|
@@ -123,6 +131,9 @@ class MapContextImpl {
|
|
|
123
131
|
get focusedPluginId() {
|
|
124
132
|
return this.#focusedPluginId;
|
|
125
133
|
}
|
|
134
|
+
get focusedAnchor() {
|
|
135
|
+
return this.#focusedAnchor;
|
|
136
|
+
}
|
|
126
137
|
get hovered() {
|
|
127
138
|
return this.#router.hovered;
|
|
128
139
|
}
|
|
@@ -178,9 +189,15 @@ class MapContextImpl {
|
|
|
178
189
|
const found = untrack(() => this.#findItem(refKey, pluginId));
|
|
179
190
|
if (!found)
|
|
180
191
|
return;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
192
|
+
const { item } = found;
|
|
193
|
+
// Where the row's tooltip pins: the focused anchor for the focused row, else a point of a
|
|
194
|
+
// shape in view (a point's own position for points and on-screen label points).
|
|
195
|
+
const focused = untrack(() => this.#focusedKey) === refKey &&
|
|
196
|
+
(untrack(() => this.#focusedPluginId) ?? pluginId) === pluginId;
|
|
197
|
+
const anchor = (focused && untrack(() => this.#focusedAnchor)) || this.anchorFor(item);
|
|
198
|
+
this.#emitClick(pluginId, item.ref, anchor);
|
|
199
|
+
if (item.selectable)
|
|
200
|
+
this.#applyPick(pluginId, item.ref, additive);
|
|
184
201
|
}
|
|
185
202
|
clearSelection(pluginId) {
|
|
186
203
|
if (this.#destroyed)
|
|
@@ -225,19 +242,101 @@ class MapContextImpl {
|
|
|
225
242
|
return;
|
|
226
243
|
this.#focusedKey = refKey;
|
|
227
244
|
this.#focusedPluginId = refKey === null ? null : (pluginId ?? null);
|
|
245
|
+
this.#focusedAnchor = null;
|
|
228
246
|
const gl = this.#gl;
|
|
229
247
|
if (!gl || refKey === null)
|
|
230
248
|
return;
|
|
231
249
|
const found = untrack(() => this.#findItem(refKey, pluginId));
|
|
232
250
|
if (!found)
|
|
233
251
|
return;
|
|
252
|
+
const { item } = found;
|
|
253
|
+
const place = this.#placeFor(item, gl);
|
|
254
|
+
this.#focusedAnchor = place.anchor;
|
|
255
|
+
if (!place.pan) {
|
|
256
|
+
// Stays where it is now: an earlier focus's ease that is still running stops here.
|
|
257
|
+
// With nothing running, no move at all.
|
|
258
|
+
if (this.#focusEasing) {
|
|
259
|
+
const { lng, lat } = gl.getCenter();
|
|
260
|
+
gl.easeTo({ center: [lng, lat], duration: 0 });
|
|
261
|
+
this.#focusEasing = false;
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const reduce = prefersReducedMotion();
|
|
234
266
|
gl.easeTo({
|
|
235
|
-
center:
|
|
267
|
+
center: place.anchor,
|
|
236
268
|
// The middle of the part the panel leaves clear. An offset, not padding: MapLibre keeps
|
|
237
269
|
// padding for later moves (zooms, the reported center), an offset applies to this one.
|
|
238
270
|
...(this.#listInset > 0 && { offset: [this.#listOffsetX(), 0] }),
|
|
239
|
-
...(
|
|
271
|
+
...(reduce && { duration: 0 })
|
|
240
272
|
});
|
|
273
|
+
// Set after the call: stopping an earlier move fires its `moveend` inside `easeTo`.
|
|
274
|
+
this.#focusEasing = !reduce;
|
|
275
|
+
}
|
|
276
|
+
anchorFor(item) {
|
|
277
|
+
const gl = this.#gl;
|
|
278
|
+
if (!gl || !item.geometry)
|
|
279
|
+
return item.lngLat;
|
|
280
|
+
const { live, clear, liveCenter, clearCenter } = this.#views(gl);
|
|
281
|
+
if (inRect(item.lngLat, live) || !intersectsBounds(item, live))
|
|
282
|
+
return item.lngLat;
|
|
283
|
+
return (shapeAnchor(item.geometry, clear, clearCenter) ??
|
|
284
|
+
shapeAnchor(item.geometry, live, liveCenter) ??
|
|
285
|
+
item.lngLat);
|
|
286
|
+
}
|
|
287
|
+
/** The live view, and the part of it the list panel leaves clear, with their centers. */
|
|
288
|
+
#views(gl) {
|
|
289
|
+
const b = gl.getBounds();
|
|
290
|
+
const live = {
|
|
291
|
+
west: b.getWest(),
|
|
292
|
+
south: b.getSouth(),
|
|
293
|
+
east: b.getEast(),
|
|
294
|
+
north: b.getNorth()
|
|
295
|
+
};
|
|
296
|
+
const center = gl.getCenter();
|
|
297
|
+
const width = untrack(() => gl.getCanvas().clientWidth) || 0;
|
|
298
|
+
const share = width > 0 ? Math.min(Math.max(this.#listInset / width, 0), 0.99) : 0;
|
|
299
|
+
const cut = share * (live.east - live.west);
|
|
300
|
+
const clear = this.#listInsetSide === "left"
|
|
301
|
+
? { ...live, west: live.west + cut }
|
|
302
|
+
: { ...live, east: live.east - cut };
|
|
303
|
+
return {
|
|
304
|
+
live,
|
|
305
|
+
clear,
|
|
306
|
+
liveCenter: [center.lng, center.lat],
|
|
307
|
+
clearCenter: [(clear.west + clear.east) / 2, center.lat]
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Where focus puts `item`: `pan: false` keeps the camera (a shape in view whose `lngLat` is
|
|
312
|
+
* off-screen, with a point in the clear part); otherwise ease to center `anchor`.
|
|
313
|
+
*/
|
|
314
|
+
#placeFor(item, gl) {
|
|
315
|
+
const { geometry, lngLat } = item;
|
|
316
|
+
if (!geometry)
|
|
317
|
+
return { anchor: lngLat, pan: true };
|
|
318
|
+
const { live, clear, liveCenter, clearCenter } = this.#views(gl);
|
|
319
|
+
if (inRect(lngLat, live))
|
|
320
|
+
return { anchor: lngLat, pan: true };
|
|
321
|
+
if (intersectsBounds(item, live)) {
|
|
322
|
+
const inClear = shapeAnchor(geometry, clear, clearCenter);
|
|
323
|
+
if (inClear)
|
|
324
|
+
return { anchor: inClear, pan: false };
|
|
325
|
+
const inLive = shapeAnchor(geometry, live, liveCenter);
|
|
326
|
+
if (inLive)
|
|
327
|
+
return { anchor: inLive, pan: true };
|
|
328
|
+
}
|
|
329
|
+
const settled = untrack(() => this.#bounds);
|
|
330
|
+
if (settled && intersectsBounds(item, settled)) {
|
|
331
|
+
const middle = [
|
|
332
|
+
(settled.west + settled.east) / 2,
|
|
333
|
+
(settled.south + settled.north) / 2
|
|
334
|
+
];
|
|
335
|
+
const inSettled = shapeAnchor(geometry, settled, middle);
|
|
336
|
+
if (inSettled)
|
|
337
|
+
return { anchor: inSettled, pan: true };
|
|
338
|
+
}
|
|
339
|
+
return { anchor: lngLat, pan: true };
|
|
241
340
|
}
|
|
242
341
|
#cameraOptions(target, gl) {
|
|
243
342
|
const zoom = target.zoom ??
|
|
@@ -281,6 +380,7 @@ class MapContextImpl {
|
|
|
281
380
|
this.#glState = gl;
|
|
282
381
|
this.#host.attach(gl);
|
|
283
382
|
this.#router.attach(gl);
|
|
383
|
+
gl.on("moveend", this.#onMoveEnd);
|
|
284
384
|
this.basemap.attach(gl, () => this.#host.restyle(), () => {
|
|
285
385
|
this.#ready = false;
|
|
286
386
|
this.#readyRequested = false;
|
|
@@ -291,6 +391,8 @@ class MapContextImpl {
|
|
|
291
391
|
detach() {
|
|
292
392
|
if (!this.#gl)
|
|
293
393
|
return;
|
|
394
|
+
this.#gl.off("moveend", this.#onMoveEnd);
|
|
395
|
+
this.#focusEasing = false;
|
|
294
396
|
this.#router.detach();
|
|
295
397
|
this.basemap.detach();
|
|
296
398
|
this.#host.detach();
|
|
@@ -326,6 +428,7 @@ class MapContextImpl {
|
|
|
326
428
|
this.#featureClickHandlers.clear();
|
|
327
429
|
this.#focusedKey = null;
|
|
328
430
|
this.#focusedPluginId = null;
|
|
431
|
+
this.#focusedAnchor = null;
|
|
329
432
|
this.#destroyed = true;
|
|
330
433
|
}
|
|
331
434
|
/** A click on a feature: report it, then apply it to the top hit's plugin's selection. */
|
|
@@ -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);
|
|
@@ -406,12 +406,12 @@ export class PluginHost {
|
|
|
406
406
|
interactive(layerIds, toHit) {
|
|
407
407
|
if (running.dead)
|
|
408
408
|
return;
|
|
409
|
-
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));
|
|
410
410
|
},
|
|
411
411
|
blocking(layerIds, toHover, onClick) {
|
|
412
412
|
if (running.dead)
|
|
413
413
|
return;
|
|
414
|
-
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));
|
|
415
415
|
},
|
|
416
416
|
on(type, layerId, listener) {
|
|
417
417
|
if (running.dead)
|
package/dist/core/plugin.d.ts
CHANGED
|
@@ -46,7 +46,26 @@ 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];
|
|
56
|
+
/**
|
|
57
|
+
* The feature's shape. When given, the item is in view (the feature list, the view summary)
|
|
58
|
+
* when the shape overlaps the view, not only when `lngLat` is inside it, and focusing or
|
|
59
|
+
* hovering it while `lngLat` is off-screen pins its tooltip at a point of the shape in view
|
|
60
|
+
* (focus then leaves the camera where it is).
|
|
61
|
+
*/
|
|
62
|
+
geometry?: Geometry;
|
|
63
|
+
/**
|
|
64
|
+
* `geometry`'s bounding box, `[west, south, east, north]` (`geometryBbox`). Optional; computed
|
|
65
|
+
* from `geometry` when missing. A plugin listing many shapes should measure it once per data
|
|
66
|
+
* load, since the list and summary test every item on every move.
|
|
67
|
+
*/
|
|
68
|
+
bbox?: [number, number, number, number];
|
|
50
69
|
selectable: boolean;
|
|
51
70
|
/**
|
|
52
71
|
* A short text about a feature with no level (e.g. "Level 2 charger"), shown where its level
|
|
@@ -139,6 +158,12 @@ export interface MapPlugin {
|
|
|
139
158
|
* Clicks ignore this (topmost first), and a blocking layer on top still blocks them.
|
|
140
159
|
*/
|
|
141
160
|
hoverPriority?: number;
|
|
161
|
+
/**
|
|
162
|
+
* False: the plugin's features never take the hover (so no hover tooltip and no pointer
|
|
163
|
+
* cursor) and never hide a feature beneath from it, as if they weren't rendered. Clicks,
|
|
164
|
+
* `featuresAt`, and keyboard focus from the list are unchanged. Default true.
|
|
165
|
+
*/
|
|
166
|
+
hoverable?: boolean;
|
|
142
167
|
displayOptions?: Snippet;
|
|
143
168
|
/** Richer tooltip content for one of this plugin's features, below its name. */
|
|
144
169
|
tooltip?(item: FeatureListItem): Snippet | undefined;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Geometry } from "geojson";
|
|
2
|
+
import type { Bounds } from "./create-map.svelte.js";
|
|
3
|
+
/**
|
|
4
|
+
* Shapes against the view: a geometry's bounding box, whether it overlaps the view, and a point of
|
|
5
|
+
* it inside the view. Coordinates are planar lng/lat (as `labelPoint`); no antimeridian handling.
|
|
6
|
+
*/
|
|
7
|
+
/** `[west, south, east, north]`. */
|
|
8
|
+
export type Bbox = [west: number, south: number, east: number, north: number];
|
|
9
|
+
/** The geometry's bounding box, or `null` when it has no coordinates. */
|
|
10
|
+
export declare function geometryBbox(geometry: Geometry): Bbox | null;
|
|
11
|
+
/** Inside an outer ring and outside its holes, for any polygon of the geometry. */
|
|
12
|
+
export declare function containsPoint(geometry: Geometry, p: [number, number]): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Whether the item is in view: a shape (`geometry`) when it intersects `bounds` (bbox pre-check,
|
|
15
|
+
* then the exact test; holes count), anything else when its `lngLat` is inside. Edges included.
|
|
16
|
+
*/
|
|
17
|
+
export declare function intersectsBounds(item: {
|
|
18
|
+
lngLat: [number, number];
|
|
19
|
+
geometry?: Geometry;
|
|
20
|
+
bbox?: Bbox;
|
|
21
|
+
}, bounds: Bounds): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* A point of the shape inside `view`: `center` when the shape contains it; otherwise a point well
|
|
24
|
+
* inside the shape's visible part (polygons: the largest clipped one, never in a hole; lines: the
|
|
25
|
+
* middle of the longest visible piece); `null` when nothing of it is visible.
|
|
26
|
+
*/
|
|
27
|
+
export declare function shapeAnchor(geometry: Geometry, view: Bounds, center: [number, number]): [number, number] | null;
|