@sjvair/map-sdk 0.1.0-beta.6 → 0.1.0-beta.8
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 +45 -4
- package/dist/core/create-map.svelte.d.ts +19 -0
- package/dist/core/create-map.svelte.js +38 -3
- package/dist/core/interactions.svelte.d.ts +5 -0
- package/dist/core/interactions.svelte.js +25 -1
- package/dist/core/plugin-host.svelte.d.ts +8 -4
- package/dist/core/plugin-host.svelte.js +16 -5
- package/dist/data/messages/en.json +2 -1
- package/dist/ui/FeatureList.svelte +2 -1
- package/dist/ui/Legend.svelte +2 -1
- package/dist/ui/MapTooltip.svelte +180 -9
- package/dist/ui/MapTooltip.svelte.d.ts +5 -0
- package/dist/view/MapView.svelte +55 -2
- package/dist/view/MapView.svelte.d.ts +6 -0
- package/dist/view/create-gl-map.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -129,13 +129,25 @@ buttons, `"engine"` the MapTiler SDK's default zoom/compass and locate-me contro
|
|
|
129
129
|
monitor-map uses today), and `"none"` none; the credits show in every case. Choose `controls` up
|
|
130
130
|
front: changing it after mount recreates the map. `tooltip` is `"pinned"` (default: anchored to the
|
|
131
131
|
feature) or `"pointer"` (shown at the pointer, as sjvair.com's map does, and hidden on zoom until
|
|
132
|
-
the pointer moves again); a feature focused from the list is always pinned.
|
|
132
|
+
the pointer moves again); a feature focused from the list is always pinned. `card` (default
|
|
133
|
+
`false`) pins the reading card on a click or tap: the card takes the pointer, has a Close button,
|
|
134
|
+
and closes with Esc, Close, or a click elsewhere on the map; focusing a feature-list row shows the
|
|
135
|
+
same card, and Tab from the row reaches it. Esc closes the card of the map that has focus; with
|
|
136
|
+
focus nowhere, it closes the page's most recently pinned card. One card shows at a time, and no hover card shows while
|
|
137
|
+
one is pinned. Selection and `featureclick` are unchanged: a click still selects as before and
|
|
138
|
+
also pins. `map.pinned` is the pinned feature (reactive), and `map.unpin()` closes it. Anything else goes in
|
|
133
139
|
the `overlays` snippet, where `getMapContext()` returns the map. `config.basemaps` replaces the
|
|
134
140
|
default MapTiler Streets v4 variants (build on `defaultBasemaps()`, which `createMap` uses: today's
|
|
135
141
|
sjvair.com setup is `{ ...defaultBasemaps(), excludedLayers: [] }`); `config.initialView` and
|
|
136
142
|
`config.cooperativeGestures` set the camera and gesture handling. `config.projection` (`"mercator"`
|
|
137
143
|
by default, or `"globe"`) and `config.space` (`false` by default, or `{ preset: "milkyway" }` for a
|
|
138
|
-
space background behind the globe) are passed to MapTiler only when set.
|
|
144
|
+
space background behind the globe) are passed to MapTiler only when set. `config.telemetry: false`
|
|
145
|
+
turns off MapTiler's telemetry (a usage report of the SDK version, API key, and session id, sent
|
|
146
|
+
about 2 s after each map is created) before the map is created. It is a page-wide setting in
|
|
147
|
+
MapTiler's SDK: once one map turns it off, it stays off for every map on the page. MapTiler's
|
|
148
|
+
session id (`mtsid`) stays on either way, since MapTiler bills SDK maps by session (without one,
|
|
149
|
+
every tile request counts on its own) and its tile cache needs it. By default (`true`) MapTiler's
|
|
150
|
+
own default applies.
|
|
139
151
|
|
|
140
152
|
Without `createMonitorsStore`, build a `DataSource` yourself (below).
|
|
141
153
|
|
|
@@ -207,7 +219,9 @@ the legend section's title: `layerLabel` (default) or the entry type's label fro
|
|
|
207
219
|
provider (`monitorsPlugin` uses the latter, so its section reads "PM2.5"; `layerLabel` still names
|
|
208
220
|
the layer in the list, selection bar and summary). `Legend` takes `order` (`"bottom-first"`,
|
|
209
221
|
default, or `"top-first"`) and `framed` (`true`, default, draws its own card; `false` draws no
|
|
210
|
-
border, background, padding or fixed width, so a host's own card frames it).
|
|
222
|
+
border, background, padding or fixed width, so a host's own card frames it). The framed legend
|
|
223
|
+
and the feature list panel are 21rem wide at most, and never wider than the map less 2rem (the
|
|
224
|
+
window less 2rem outside a `MapView`), so they fit a small map on a wide screen. Without a frame,
|
|
211
225
|
translucent swatches blend over the nearest ancestor with an opaque background (a see-through
|
|
212
226
|
card is skipped), else `--background`; that color is read on mount, when `framed` changes, and on
|
|
213
227
|
a theme change, but not when the card changes color through its own class or style.
|
|
@@ -254,7 +268,9 @@ source loads, fails, or is unavailable, the legend says so (a failure offers "Tr
|
|
|
254
268
|
calls `status.retry()`). Values are shown with the metadata's display precision, except in the
|
|
255
269
|
monitor, cluster, and collocation tooltips, which match monitor-map: the reading and a cluster's
|
|
256
270
|
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`).
|
|
271
|
+
rather than rounded (see `truncateValues`). On a category scale, a value that is its level's code
|
|
272
|
+
(smoke's `"light"`, the heat score's `2`) isn't shown: the list row, the tooltip, and the row's
|
|
273
|
+
accessible name give only the level label. Times in the tooltips read like sjvair.com's map, in
|
|
258
274
|
Pacific time: "4:00 pm Friday Nov 06, 2026", with " PT" after them on a device in another zone.
|
|
259
275
|
The monitor cluster tooltip's count line gives the area the monitors are within, "3 monitors
|
|
260
276
|
within a 22.619 mi² area": the area of their convex hull, left out when it would read 0.000 (two
|
|
@@ -548,6 +564,31 @@ local sjvair.com stack at `http://localhost:8000`.
|
|
|
548
564
|
|
|
549
565
|
## Changes
|
|
550
566
|
|
|
567
|
+
### 0.1.0-beta.8
|
|
568
|
+
|
|
569
|
+
- **The pinned reading card.** `MapView`'s new `card` prop: a click or tap on a feature pins its
|
|
570
|
+
card, which takes the pointer, has a Close button, and closes with Esc, Close, or a click
|
|
571
|
+
elsewhere on the map. Focusing a feature-list row shows the same card, and Tab reaches it; closing
|
|
572
|
+
it returns focus where it came from. With `card`, a tap always shows the reading: without
|
|
573
|
+
it, a tap's emulated `mouseout` (Chromium, WebKit) still hides the hover card right after the
|
|
574
|
+
tap, as before. Nothing changes without `card`, and with it, selection and `featureclick` behave
|
|
575
|
+
as before.
|
|
576
|
+
- **New API:** `MapView`'s `card`, `MapContext.pinned` and `unpin()`. New message key for hosts
|
|
577
|
+
with their own catalog: `mapsdk_card_close` ("Close").
|
|
578
|
+
|
|
579
|
+
### 0.1.0-beta.7
|
|
580
|
+
|
|
581
|
+
- **Plugins come back at once after a basemap switch.** After picking another basemap, monitors,
|
|
582
|
+
fire, smoke and the other layers were missing until every tile of the new basemap had loaded
|
|
583
|
+
and drawn (4–10 s on a slow connection). They are now added as soon as the new style loads, as
|
|
584
|
+
sjvair.com's map does. A theme change while tiles are loading no longer waits either.
|
|
585
|
+
- **`config.telemetry: false`** turns off MapTiler's telemetry (its usage report) for the page,
|
|
586
|
+
before the map is created. Sessions stay on, so billing by session and the tile cache are
|
|
587
|
+
unchanged. By default nothing changes.
|
|
588
|
+
- **The feature list panel and the framed legend fit the map.** They were capped at the window's
|
|
589
|
+
width less 2rem; inside a `MapView` they are now capped at the map's width less 2rem, so a small
|
|
590
|
+
map on a wide screen doesn't overflow. MapView sets `--mapsdk-map-width` for this.
|
|
591
|
+
|
|
551
592
|
### 0.1.0-beta.6
|
|
552
593
|
|
|
553
594
|
- **A category reading is shown once.** When a feature's value is its category level's code, the
|
|
@@ -32,6 +32,14 @@ export interface MapConfig {
|
|
|
32
32
|
space?: false | {
|
|
33
33
|
preset: string;
|
|
34
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* Default `true`: MapTiler's own default. `false` turns off MapTiler's telemetry (a usage
|
|
37
|
+
* report of the SDK version, the API key, and the session id, sent about 2 s after each map is
|
|
38
|
+
* created) before the GL map is created. It is a page-wide setting in MapTiler's SDK, so once
|
|
39
|
+
* one map turns it off it stays off for every map on the page. MapTiler's session id (`mtsid`)
|
|
40
|
+
* stays on: MapTiler bills SDK maps by session, and its tile cache needs sessions.
|
|
41
|
+
*/
|
|
42
|
+
telemetry?: boolean;
|
|
35
43
|
}
|
|
36
44
|
export interface CreateMapOptions {
|
|
37
45
|
config: MapConfig;
|
|
@@ -162,6 +170,17 @@ export interface MapContext {
|
|
|
162
170
|
* lists, with the bubble's `properties`, never a feature beneath it.
|
|
163
171
|
*/
|
|
164
172
|
readonly hovered: Hovered | null;
|
|
173
|
+
/**
|
|
174
|
+
* Reactive. The feature whose reading card is pinned, and where, while `MapView`'s `card` is
|
|
175
|
+
* on; `null` otherwise. A click on a feature pins it (selection and `featureclick` are
|
|
176
|
+
* unchanged), focusing a feature-list row pins that row's feature, and a click on nothing (or
|
|
177
|
+
* on a cluster bubble or a feature without a hover card) unpins.
|
|
178
|
+
*/
|
|
179
|
+
readonly pinned: Hovered | null;
|
|
180
|
+
/** Closes the pinned card. Does nothing when nothing is pinned. */
|
|
181
|
+
unpin(): void;
|
|
182
|
+
/** @internal For `MapView` only: turns pinning on or off. Off also unpins. */
|
|
183
|
+
setCardEnabled(on: boolean): void;
|
|
165
184
|
/**
|
|
166
185
|
* Marks the feature focused and pans to it (no animation under reduced motion): the item in
|
|
167
186
|
* `pluginId`'s features when given, else the first plugin listing `refKey`. `null` clears the
|
|
@@ -58,6 +58,8 @@ class MapContextImpl {
|
|
|
58
58
|
#focusedKey = $state(null);
|
|
59
59
|
#focusedPluginId = $state(null);
|
|
60
60
|
#focusedAnchor = $state.raw(null);
|
|
61
|
+
#pinned = $state.raw(null);
|
|
62
|
+
#cardEnabled = false;
|
|
61
63
|
#selections = new SvelteMap();
|
|
62
64
|
#featureClickHandlers = new Set();
|
|
63
65
|
#router = new InteractionRouter();
|
|
@@ -98,6 +100,7 @@ class MapContextImpl {
|
|
|
98
100
|
}
|
|
99
101
|
});
|
|
100
102
|
this.#router.onPick((e) => this.#pick(e));
|
|
103
|
+
this.#router.onMiss(() => this.unpin());
|
|
101
104
|
}
|
|
102
105
|
get camera() {
|
|
103
106
|
return this.#camera;
|
|
@@ -137,6 +140,18 @@ class MapContextImpl {
|
|
|
137
140
|
get hovered() {
|
|
138
141
|
return this.#router.hovered;
|
|
139
142
|
}
|
|
143
|
+
get pinned() {
|
|
144
|
+
return this.#pinned;
|
|
145
|
+
}
|
|
146
|
+
unpin() {
|
|
147
|
+
if (untrack(() => this.#pinned) !== null)
|
|
148
|
+
this.#pinned = null;
|
|
149
|
+
}
|
|
150
|
+
setCardEnabled(on) {
|
|
151
|
+
this.#cardEnabled = on;
|
|
152
|
+
if (!on)
|
|
153
|
+
this.unpin();
|
|
154
|
+
}
|
|
140
155
|
addPlugin(plugin, options = {}) {
|
|
141
156
|
this.#assertAlive();
|
|
142
157
|
const previous = untrack(() => this.#selections.get(plugin.id));
|
|
@@ -171,6 +186,8 @@ class MapContextImpl {
|
|
|
171
186
|
removePlugin(id) {
|
|
172
187
|
this.#host.remove(id);
|
|
173
188
|
this.#selections.delete(id);
|
|
189
|
+
if (untrack(() => this.#pinned)?.hit.pluginId === id)
|
|
190
|
+
this.unpin();
|
|
174
191
|
}
|
|
175
192
|
setSelection(pluginId, selection) {
|
|
176
193
|
if (this.#destroyed)
|
|
@@ -249,9 +266,15 @@ class MapContextImpl {
|
|
|
249
266
|
const found = untrack(() => this.#findItem(refKey, pluginId));
|
|
250
267
|
if (!found)
|
|
251
268
|
return;
|
|
252
|
-
const { item } = found;
|
|
269
|
+
const { plugin, item } = found;
|
|
253
270
|
const place = this.#placeFor(item, gl);
|
|
254
271
|
this.#focusedAnchor = place.anchor;
|
|
272
|
+
if (this.#cardEnabled) {
|
|
273
|
+
this.#pinned = {
|
|
274
|
+
hit: { pluginId: plugin.id, ref: item.ref, refKey: item.refKey, name: item.name },
|
|
275
|
+
lngLat: place.anchor
|
|
276
|
+
};
|
|
277
|
+
}
|
|
255
278
|
if (!place.pan) {
|
|
256
279
|
// Stays where it is now: an earlier focus's ease that is still running stops here.
|
|
257
280
|
// With nothing running, no move at all.
|
|
@@ -381,7 +404,9 @@ class MapContextImpl {
|
|
|
381
404
|
this.#host.attach(gl);
|
|
382
405
|
this.#router.attach(gl);
|
|
383
406
|
gl.on("moveend", this.#onMoveEnd);
|
|
384
|
-
this.basemap.attach(gl,
|
|
407
|
+
this.basemap.attach(gl,
|
|
408
|
+
// Called from style.load: the style is loaded, though its tiles may not be.
|
|
409
|
+
() => this.#host.restyle(true), () => {
|
|
385
410
|
this.#ready = false;
|
|
386
411
|
this.#readyRequested = false;
|
|
387
412
|
this.#host.suspend();
|
|
@@ -393,6 +418,7 @@ class MapContextImpl {
|
|
|
393
418
|
return;
|
|
394
419
|
this.#gl.off("moveend", this.#onMoveEnd);
|
|
395
420
|
this.#focusEasing = false;
|
|
421
|
+
this.unpin();
|
|
396
422
|
this.#router.detach();
|
|
397
423
|
this.basemap.detach();
|
|
398
424
|
this.#host.detach();
|
|
@@ -435,9 +461,18 @@ class MapContextImpl {
|
|
|
435
461
|
#pick(e) {
|
|
436
462
|
if (this.#destroyed)
|
|
437
463
|
return;
|
|
438
|
-
const [
|
|
464
|
+
const [hit] = e.hits;
|
|
465
|
+
const { pluginId, ref } = hit;
|
|
439
466
|
this.#emitClick(pluginId, ref, e.lngLat);
|
|
440
467
|
this.#applyPick(pluginId, ref, e.additive);
|
|
468
|
+
if (!this.#cardEnabled || this.#destroyed)
|
|
469
|
+
return;
|
|
470
|
+
// A feature with no hover card (e.g. a smoke plume) gets no pinned card either.
|
|
471
|
+
const plugin = untrack(() => this.plugins).find((p) => p.id === pluginId);
|
|
472
|
+
if (!plugin || plugin.hoverable === false)
|
|
473
|
+
this.unpin();
|
|
474
|
+
else
|
|
475
|
+
this.#pinned = { hit, lngLat: e.lngLat };
|
|
441
476
|
}
|
|
442
477
|
#emitClick(pluginId, ref, lngLat) {
|
|
443
478
|
for (const handler of [...this.#featureClickHandlers]) {
|
|
@@ -42,4 +42,9 @@ export declare class InteractionRouter {
|
|
|
42
42
|
* click, or a touch tap; selection rules decide what it means for the plugin's mode.
|
|
43
43
|
*/
|
|
44
44
|
onPick(handler: (e: ClickEvent) => void): () => void;
|
|
45
|
+
/**
|
|
46
|
+
* Called for a click that picks nothing: one with no hits, or one on a blocking layer (after
|
|
47
|
+
* that layer's own click handler). Returns an unsubscribe function.
|
|
48
|
+
*/
|
|
49
|
+
onMiss(handler: () => void): () => void;
|
|
45
50
|
}
|
|
@@ -14,6 +14,7 @@ export class InteractionRouter {
|
|
|
14
14
|
/* eslint-disable svelte/prefer-svelte-reactivity */
|
|
15
15
|
#registrations = new Set();
|
|
16
16
|
#pickHandlers = new Set();
|
|
17
|
+
#missHandlers = new Set();
|
|
17
18
|
/* eslint-enable svelte/prefer-svelte-reactivity */
|
|
18
19
|
#gl = null;
|
|
19
20
|
#canvas = null;
|
|
@@ -186,6 +187,26 @@ export class InteractionRouter {
|
|
|
186
187
|
this.#pickHandlers.delete(handler);
|
|
187
188
|
};
|
|
188
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Called for a click that picks nothing: one with no hits, or one on a blocking layer (after
|
|
192
|
+
* that layer's own click handler). Returns an unsubscribe function.
|
|
193
|
+
*/
|
|
194
|
+
onMiss(handler) {
|
|
195
|
+
this.#missHandlers.add(handler);
|
|
196
|
+
return () => {
|
|
197
|
+
this.#missHandlers.delete(handler);
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
#emitMiss() {
|
|
201
|
+
for (const handler of [...this.#missHandlers]) {
|
|
202
|
+
try {
|
|
203
|
+
handler();
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
console.error("map-sdk: a miss handler failed", error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
189
210
|
#onPointerDown = (e) => {
|
|
190
211
|
this.#lastPointerType = e.pointerType || null;
|
|
191
212
|
};
|
|
@@ -201,11 +222,14 @@ export class InteractionRouter {
|
|
|
201
222
|
catch (error) {
|
|
202
223
|
console.error("map-sdk: a blocking layer's click handler failed", error);
|
|
203
224
|
}
|
|
225
|
+
this.#emitMiss();
|
|
204
226
|
return;
|
|
205
227
|
}
|
|
206
228
|
const { hits } = found;
|
|
207
|
-
if (hits.length === 0)
|
|
229
|
+
if (hits.length === 0) {
|
|
230
|
+
this.#emitMiss();
|
|
208
231
|
return;
|
|
232
|
+
}
|
|
209
233
|
const o = e.originalEvent;
|
|
210
234
|
const pointerType = o?.pointerType || recorded;
|
|
211
235
|
const event = {
|
|
@@ -57,11 +57,15 @@ export declare class PluginHost {
|
|
|
57
57
|
*/
|
|
58
58
|
suspend(): void;
|
|
59
59
|
/**
|
|
60
|
-
* Tears down and sets up every plugin again (after a basemap change), once the style
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* Tears down and sets up every plugin again (after a basemap or theme change), once the style
|
|
61
|
+
* loads. Pass `styleLoaded` from a `style.load` listener: `isStyleLoaded()` stays false there
|
|
62
|
+
* until every tile has loaded, and `style.load` has already fired, so waiting would last until
|
|
63
|
+
* `idle`. A live host's style is loaded too (`suspend` comes before every `setStyle`), so a theme
|
|
64
|
+
* change sets up at once as well. On a loaded style the setup is synchronous, so only map events
|
|
65
|
+
* and theme watching call this, never code inside an effect: roots created there can stop (see
|
|
66
|
+
* the class comment).
|
|
63
67
|
*/
|
|
64
|
-
restyle(): void;
|
|
68
|
+
restyle(styleLoaded?: boolean): void;
|
|
65
69
|
/** Tears down every plugin. The gl is not touched after this returns. */
|
|
66
70
|
detach(): void;
|
|
67
71
|
}
|
|
@@ -161,15 +161,26 @@ export class PluginHost {
|
|
|
161
161
|
this.#teardownAll();
|
|
162
162
|
}
|
|
163
163
|
/**
|
|
164
|
-
* Tears down and sets up every plugin again (after a basemap change), once the style
|
|
165
|
-
*
|
|
166
|
-
*
|
|
164
|
+
* Tears down and sets up every plugin again (after a basemap or theme change), once the style
|
|
165
|
+
* loads. Pass `styleLoaded` from a `style.load` listener: `isStyleLoaded()` stays false there
|
|
166
|
+
* until every tile has loaded, and `style.load` has already fired, so waiting would last until
|
|
167
|
+
* `idle`. A live host's style is loaded too (`suspend` comes before every `setStyle`), so a theme
|
|
168
|
+
* change sets up at once as well. On a loaded style the setup is synchronous, so only map events
|
|
169
|
+
* and theme watching call this, never code inside an effect: roots created there can stop (see
|
|
170
|
+
* the class comment).
|
|
167
171
|
*/
|
|
168
|
-
restyle() {
|
|
172
|
+
restyle(styleLoaded = false) {
|
|
169
173
|
if (!this.#gl)
|
|
170
174
|
return;
|
|
175
|
+
const loaded = styleLoaded || this.#live;
|
|
171
176
|
this.#teardownAll();
|
|
172
|
-
|
|
177
|
+
if (loaded) {
|
|
178
|
+
this.#cancelStyleWait();
|
|
179
|
+
this.#setupAll();
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
this.#whenStyleLoaded(true);
|
|
183
|
+
}
|
|
173
184
|
}
|
|
174
185
|
/** Tears down every plugin. The gl is not touched after this returns. */
|
|
175
186
|
detach() {
|
|
@@ -56,5 +56,6 @@
|
|
|
56
56
|
"mapsdk_collocation_reference": "Reference monitor",
|
|
57
57
|
"mapsdk_collocation_colocated": "Collocated monitor",
|
|
58
58
|
"mapsdk_collocation_interval_avg": "({interval} avg)",
|
|
59
|
-
"mapsdk_collocation_updated": "Last updated {when}"
|
|
59
|
+
"mapsdk_collocation_updated": "Last updated {when}",
|
|
60
|
+
"mapsdk_card_close": "Close"
|
|
60
61
|
}
|
|
@@ -381,7 +381,8 @@
|
|
|
381
381
|
flex-direction: column;
|
|
382
382
|
position: relative;
|
|
383
383
|
width: 21rem;
|
|
384
|
-
|
|
384
|
+
/* The map's width inside a MapView (set there), else the window's. */
|
|
385
|
+
max-width: calc(var(--mapsdk-map-width, 100vw) - 2rem);
|
|
385
386
|
max-height: 100%;
|
|
386
387
|
box-sizing: border-box;
|
|
387
388
|
border: 1px solid var(--card-border, var(--border));
|
package/dist/ui/Legend.svelte
CHANGED
|
@@ -143,7 +143,8 @@
|
|
|
143
143
|
flex-direction: column;
|
|
144
144
|
gap: 0.75rem;
|
|
145
145
|
width: 21rem;
|
|
146
|
-
|
|
146
|
+
/* The map's width inside a MapView (set there), else the window's. */
|
|
147
|
+
max-width: calc(var(--mapsdk-map-width, 100vw) - 2rem);
|
|
147
148
|
box-sizing: border-box;
|
|
148
149
|
padding: 0.75rem 1rem;
|
|
149
150
|
border: 1px solid var(--card-border, var(--border));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { untrack } from "svelte";
|
|
3
|
+
import type { Attachment } from "svelte/attachments";
|
|
3
4
|
import type { MapContext } from "../core/create-map.svelte.js";
|
|
4
5
|
import type { GlMapLike } from "../core/gl.js";
|
|
5
6
|
import type { FeatureListItem, MapPlugin } from "../core/plugin.js";
|
|
@@ -24,9 +25,17 @@
|
|
|
24
25
|
mode?: "pinned" | "pointer";
|
|
25
26
|
/** The GL map, for `zoomstart` in `"pointer"` mode. */
|
|
26
27
|
gl?: Pick<GlMapLike, "on" | "off"> | null;
|
|
28
|
+
/**
|
|
29
|
+
* The pinned reading card: `map.pinned` and a focused list item show as an interactive card
|
|
30
|
+
* (pointer events, a Close button, Esc), and the hover doesn't show while one is pinned.
|
|
31
|
+
*/
|
|
32
|
+
card?: boolean;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
let { map, project, width, height, mode = "pinned", gl = null }: Props = $props();
|
|
35
|
+
let { map, project, width, height, mode = "pinned", gl = null, card = false }: Props = $props();
|
|
36
|
+
|
|
37
|
+
// Ignored without the card, so a host's map never pins.
|
|
38
|
+
const pinned = $derived(card ? map.pinned : null);
|
|
30
39
|
|
|
31
40
|
// Pointer mode only: set by a zoom, cleared by the next pointer move over a hit.
|
|
32
41
|
let zoomHidden = $state(false);
|
|
@@ -45,11 +54,15 @@
|
|
|
45
54
|
let tipHeight = $state(0);
|
|
46
55
|
const GAP = 12;
|
|
47
56
|
|
|
48
|
-
// The keyboard-focused list item wins
|
|
49
|
-
|
|
57
|
+
// The keyboard-focused list item wins, then the pinned card, then the pointer (which shows
|
|
58
|
+
// nothing while a card is pinned).
|
|
59
|
+
const shownHit = $derived(map.focusedKey ? null : (pinned ?? map.hovered));
|
|
60
|
+
const key = $derived(map.focusedKey ?? shownHit?.hit.refKey ?? null);
|
|
50
61
|
// Primitives, so moving the pointer within one feature doesn't recompute the content.
|
|
51
|
-
const hitPluginId = $derived(
|
|
52
|
-
const hitName = $derived(
|
|
62
|
+
const hitPluginId = $derived(shownHit?.hit.pluginId ?? null);
|
|
63
|
+
const hitName = $derived(shownHit?.hit.name ?? null);
|
|
64
|
+
// The card shows (interactive) for a focused item or a pinned feature.
|
|
65
|
+
const interactive = $derived(card && (map.focusedKey !== null || pinned !== null));
|
|
53
66
|
|
|
54
67
|
// Reads only the shown feature's plugin (or all plugins for a focused key), and only while
|
|
55
68
|
// something is shown, so an idle map doesn't subscribe to any plugin's data.
|
|
@@ -83,12 +96,20 @@
|
|
|
83
96
|
return untrack(() => (item?.geometry ? map.anchorFor(item) : null));
|
|
84
97
|
});
|
|
85
98
|
const anchor = $derived.by((): [number, number] | null => {
|
|
86
|
-
if (mode === "pointer" && !map.focusedKey) return map.hovered?.lngLat ?? null;
|
|
99
|
+
if (mode === "pointer" && !map.focusedKey && !pinned) return map.hovered?.lngLat ?? null;
|
|
87
100
|
// A focused feature that left the data has no item, so no tooltip.
|
|
88
101
|
if (map.focusedKey) return item ? (map.focusedAnchor ?? item.lngLat) : null;
|
|
89
|
-
return hoverAnchor ?? item?.lngLat ??
|
|
102
|
+
return hoverAnchor ?? item?.lngLat ?? shownHit?.lngLat ?? null;
|
|
90
103
|
});
|
|
91
104
|
const projected = $derived(anchor ? project(anchor) : null);
|
|
105
|
+
// A card whose anchor is out of the map's box hides until it's back.
|
|
106
|
+
const offScreen = $derived(
|
|
107
|
+
interactive &&
|
|
108
|
+
projected !== null &&
|
|
109
|
+
width !== undefined &&
|
|
110
|
+
height !== undefined &&
|
|
111
|
+
(projected.x < 0 || projected.y < 0 || projected.x > width || projected.y > height)
|
|
112
|
+
);
|
|
92
113
|
// Centered above the point; flipped below it near the top edge; inside the container.
|
|
93
114
|
const position = $derived.by(() => {
|
|
94
115
|
if (!projected) return null;
|
|
@@ -105,7 +126,7 @@
|
|
|
105
126
|
// A hovered hit no plugin lists (e.g. a cluster bubble): its plugin's hover content.
|
|
106
127
|
if (key === null || hitPluginId === null) return undefined;
|
|
107
128
|
const plugin = map.plugins.find((p) => p.id === hitPluginId);
|
|
108
|
-
const hit = untrack(() =>
|
|
129
|
+
const hit = untrack(() => shownHit?.hit);
|
|
109
130
|
return hit && plugin?.hoverTooltip?.(hit);
|
|
110
131
|
});
|
|
111
132
|
|
|
@@ -116,10 +137,128 @@
|
|
|
116
137
|
const plugin = found?.plugin ?? map.plugins.find((p) => p.id === hitPluginId);
|
|
117
138
|
return !plugin?.tooltipShowsName;
|
|
118
139
|
});
|
|
140
|
+
|
|
141
|
+
let cardEl: HTMLDivElement | undefined = $state();
|
|
142
|
+
/** Where focus came from when it moved into the card, to return it on close. */
|
|
143
|
+
let origin: HTMLElement | null = null;
|
|
144
|
+
|
|
145
|
+
function onfocusin(e: FocusEvent) {
|
|
146
|
+
const from = e.relatedTarget;
|
|
147
|
+
focusInside = true;
|
|
148
|
+
region = cardEl?.closest<HTMLElement>("[data-mapsdk-map]") ?? region;
|
|
149
|
+
// Focus from outside sets the origin afresh, even from nowhere, so an earlier card's
|
|
150
|
+
// origin is never reused.
|
|
151
|
+
if (!(from instanceof Node && cardEl?.contains(from))) {
|
|
152
|
+
origin = from instanceof HTMLElement ? from : null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Focus is inside the card (kept by focusin/focusout, since a removed card no longer says). */
|
|
157
|
+
let focusInside = false;
|
|
158
|
+
/** The map region the card is in, found while it's in the document. */
|
|
159
|
+
let region: HTMLElement | null = null;
|
|
160
|
+
|
|
161
|
+
function onfocusout(e: FocusEvent) {
|
|
162
|
+
const to = e.relatedTarget;
|
|
163
|
+
if (to instanceof Node && cardEl?.contains(to)) return;
|
|
164
|
+
// Later, not now: removing the focused card fires focusout too, and its teardown (which
|
|
165
|
+
// runs first) still has to see that focus was inside.
|
|
166
|
+
queueMicrotask(() => {
|
|
167
|
+
if (!cardEl?.contains(document.activeElement)) focusInside = false;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Focus inside the card goes back where it came from, else to the map region. */
|
|
172
|
+
function handBack() {
|
|
173
|
+
if (!focusInside) return;
|
|
174
|
+
focusInside = false;
|
|
175
|
+
const back = origin;
|
|
176
|
+
origin = null;
|
|
177
|
+
if (back?.isConnected) back.focus();
|
|
178
|
+
else if (region?.isConnected) {
|
|
179
|
+
if (!region.hasAttribute("tabindex")) region.setAttribute("tabindex", "-1");
|
|
180
|
+
region.focus();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// However the card goes (Close, a host's unpin, its plugin removed, its anchor off-screen),
|
|
185
|
+
// focus inside it isn't left on a removed node.
|
|
186
|
+
const handBackOnRemove: Attachment<HTMLElement> = () => () => handBack();
|
|
187
|
+
|
|
188
|
+
/** Unpins, handing focus back first. */
|
|
189
|
+
function close() {
|
|
190
|
+
handBack();
|
|
191
|
+
map.unpin();
|
|
192
|
+
// Focus back on a list row focuses (and so shows) that row again; the card stays closed
|
|
193
|
+
// until the next row move, as with Esc in the list.
|
|
194
|
+
if (map.focusedKey !== null) map.focusFeature(null);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function onkeydown(e: KeyboardEvent) {
|
|
198
|
+
if (e.key !== "Escape") return;
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
e.stopPropagation();
|
|
201
|
+
close();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A pinned hit with nothing to show (no name, listed item, or hover content) would be an
|
|
205
|
+
// invisible card that still hides the hover and takes Esc: unpin it.
|
|
206
|
+
$effect(() => {
|
|
207
|
+
if (pinned && !map.focusedKey && !(name || item || custom)) untrack(() => map.unpin());
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// A pinned feature that left its plugin's data (a poll dropped it) closes its card. Only one
|
|
211
|
+
// its plugin listed while pinned: some hits are never listed (HMS fire hotspots), and keep it.
|
|
212
|
+
let listedKey: string | null = null;
|
|
213
|
+
$effect(() => {
|
|
214
|
+
if (!pinned || map.focusedKey) return;
|
|
215
|
+
const { pluginId, refKey } = pinned.hit;
|
|
216
|
+
const plugin = map.plugins.find((p) => p.id === pluginId);
|
|
217
|
+
if (!plugin?.features) return;
|
|
218
|
+
const id = `${pluginId}\n${refKey}`;
|
|
219
|
+
if (plugin.features().some((f) => f.refKey === refKey)) listedKey = id;
|
|
220
|
+
else if (listedKey === id) {
|
|
221
|
+
listedKey = null;
|
|
222
|
+
untrack(close);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
119
225
|
</script>
|
|
120
226
|
|
|
121
227
|
<!-- Only with something to show: a hit with no name, item, or content gets no empty box. -->
|
|
122
|
-
{#if (name || item || custom) && position && !
|
|
228
|
+
{#if interactive && (name || item || custom) && position && !offScreen}
|
|
229
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
230
|
+
<div
|
|
231
|
+
bind:this={cardEl}
|
|
232
|
+
{@attach handBackOnRemove}
|
|
233
|
+
class="mapsdk-tooltip mapsdk-card text-sm"
|
|
234
|
+
role="group"
|
|
235
|
+
aria-label={name ?? undefined}
|
|
236
|
+
bind:offsetWidth={tipWidth}
|
|
237
|
+
bind:offsetHeight={tipHeight}
|
|
238
|
+
style:left="{position.left}px"
|
|
239
|
+
style:top="{position.top}px"
|
|
240
|
+
{onfocusin}
|
|
241
|
+
{onfocusout}
|
|
242
|
+
{onkeydown}
|
|
243
|
+
>
|
|
244
|
+
{#if showName}
|
|
245
|
+
<div class="mapsdk-tooltip-name">{name}</div>
|
|
246
|
+
{/if}
|
|
247
|
+
{#if custom}
|
|
248
|
+
{@render custom()}
|
|
249
|
+
{:else if item}
|
|
250
|
+
<TooltipItem {item} messages={map.messages} format={map.format} />
|
|
251
|
+
{/if}
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
class="mapsdk-card-close mapsdk-focus"
|
|
255
|
+
aria-label={map.messages("mapsdk_card_close")}
|
|
256
|
+
onclick={close}
|
|
257
|
+
>
|
|
258
|
+
<span aria-hidden="true">×</span>
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
{:else if !interactive && (name || item || custom) && position && !(zoomHidden && !map.focusedKey)}
|
|
123
262
|
<div
|
|
124
263
|
class="mapsdk-tooltip text-sm"
|
|
125
264
|
role="tooltip"
|
|
@@ -168,4 +307,36 @@
|
|
|
168
307
|
.mapsdk-tooltip-name {
|
|
169
308
|
font-weight: 600;
|
|
170
309
|
}
|
|
310
|
+
|
|
311
|
+
/* The pinned card: takes the pointer, and sits above the feature list panel. Room on the
|
|
312
|
+
right for the Close button. */
|
|
313
|
+
.mapsdk-card {
|
|
314
|
+
z-index: 3;
|
|
315
|
+
padding-right: 2rem;
|
|
316
|
+
pointer-events: auto;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.mapsdk-card-close {
|
|
320
|
+
position: absolute;
|
|
321
|
+
top: 0.25rem;
|
|
322
|
+
right: 0.25rem;
|
|
323
|
+
display: flex;
|
|
324
|
+
align-items: center;
|
|
325
|
+
justify-content: center;
|
|
326
|
+
width: 1.5rem;
|
|
327
|
+
height: 1.5rem;
|
|
328
|
+
padding: 0;
|
|
329
|
+
border: 0;
|
|
330
|
+
border-radius: var(--radius-control, var(--radius));
|
|
331
|
+
background: transparent;
|
|
332
|
+
color: inherit;
|
|
333
|
+
font: inherit;
|
|
334
|
+
font-size: 1.125rem;
|
|
335
|
+
line-height: 1;
|
|
336
|
+
cursor: pointer;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.mapsdk-card-close:hover {
|
|
340
|
+
background: var(--accent, var(--muted));
|
|
341
|
+
}
|
|
171
342
|
</style>
|
|
@@ -22,6 +22,11 @@ interface Props {
|
|
|
22
22
|
mode?: "pinned" | "pointer";
|
|
23
23
|
/** The GL map, for `zoomstart` in `"pointer"` mode. */
|
|
24
24
|
gl?: Pick<GlMapLike, "on" | "off"> | null;
|
|
25
|
+
/**
|
|
26
|
+
* The pinned reading card: `map.pinned` and a focused list item show as an interactive card
|
|
27
|
+
* (pointer events, a Close button, Esc), and the hover doesn't show while one is pinned.
|
|
28
|
+
*/
|
|
29
|
+
card?: boolean;
|
|
25
30
|
}
|
|
26
31
|
declare const MapTooltip: import("svelte").Component<Props, {}, "">;
|
|
27
32
|
type MapTooltip = ReturnType<typeof MapTooltip>;
|
package/dist/view/MapView.svelte
CHANGED
|
@@ -56,6 +56,12 @@
|
|
|
56
56
|
* Keyboard focus from the feature list always shows it pinned.
|
|
57
57
|
*/
|
|
58
58
|
tooltip?: "pinned" | "pointer";
|
|
59
|
+
/**
|
|
60
|
+
* `true`: a click or tap on a feature pins its reading card, which takes the pointer and
|
|
61
|
+
* closes with Esc, its Close button, or a click elsewhere on the map; focusing a
|
|
62
|
+
* feature-list row shows the same card. Selection and `featureclick` are unchanged.
|
|
63
|
+
*/
|
|
64
|
+
card?: boolean;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
let {
|
|
@@ -67,9 +73,46 @@
|
|
|
67
73
|
featureList = true,
|
|
68
74
|
selectionBar = true,
|
|
69
75
|
controls = "mapsdk",
|
|
70
|
-
tooltip = "pinned"
|
|
76
|
+
tooltip = "pinned",
|
|
77
|
+
card = false
|
|
71
78
|
}: Props = $props();
|
|
72
79
|
|
|
80
|
+
// Pinning only, no plugin setup, so it's safe in an effect.
|
|
81
|
+
$effect(() => {
|
|
82
|
+
map.setCardEnabled(card);
|
|
83
|
+
return () => map.setCardEnabled(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Esc outside the card closes it when focus is in this map or nowhere (the body), so a key
|
|
88
|
+
* pressed in another map, or elsewhere on the page, leaves this card alone.
|
|
89
|
+
*/
|
|
90
|
+
function onWindowKeydown(e: KeyboardEvent) {
|
|
91
|
+
if (e.key !== "Escape" || e.defaultPrevented || !map.pinned) return;
|
|
92
|
+
const active = document.activeElement;
|
|
93
|
+
if (active && active !== document.body && !root.contains(active)) return;
|
|
94
|
+
// Focus nowhere: only the most recently pinned card on the page closes.
|
|
95
|
+
if (!active || active === document.body) {
|
|
96
|
+
let latest: HTMLElement | null = null;
|
|
97
|
+
for (const el of document.querySelectorAll<HTMLElement>("[data-mapsdk-pinned-at]")) {
|
|
98
|
+
if (!latest || Number(el.dataset.mapsdkPinnedAt) > Number(latest.dataset.mapsdkPinnedAt)) {
|
|
99
|
+
latest = el;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (latest && latest !== root) return;
|
|
103
|
+
}
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
map.unpin();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// When this map's card was pinned, so Esc with focus nowhere closes the latest card only.
|
|
109
|
+
$effect(() => {
|
|
110
|
+
const pinned = map.pinned;
|
|
111
|
+
if (!root) return;
|
|
112
|
+
if (pinned) root.dataset.mapsdkPinnedAt = String(performance.now());
|
|
113
|
+
else delete root.dataset.mapsdkPinnedAt;
|
|
114
|
+
});
|
|
115
|
+
|
|
73
116
|
const list = $derived(resolveFeatureList(featureList));
|
|
74
117
|
const sides = $derived(list ? cornerSides(list.position) : null);
|
|
75
118
|
|
|
@@ -148,6 +191,12 @@
|
|
|
148
191
|
function onListKeydown(e: KeyboardEvent) {
|
|
149
192
|
if (e.key !== "Escape") return;
|
|
150
193
|
e.preventDefault();
|
|
194
|
+
// The card closes first (and the focused row's card with it); the next Esc closes the panel.
|
|
195
|
+
if (card && (map.pinned || map.focusedKey)) {
|
|
196
|
+
map.unpin();
|
|
197
|
+
map.focusFeature(null);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
151
200
|
listOpen = false;
|
|
152
201
|
listButton?.focus();
|
|
153
202
|
}
|
|
@@ -242,6 +291,8 @@
|
|
|
242
291
|
});
|
|
243
292
|
</script>
|
|
244
293
|
|
|
294
|
+
<svelte:window onkeydown={onWindowKeydown} />
|
|
295
|
+
|
|
245
296
|
<div
|
|
246
297
|
bind:this={root}
|
|
247
298
|
bind:clientWidth={width}
|
|
@@ -250,9 +301,9 @@
|
|
|
250
301
|
role="region"
|
|
251
302
|
aria-label={label ?? map.messages("mapsdk_map_label")}
|
|
252
303
|
data-mapsdk-map={map.uid}
|
|
304
|
+
style:--mapsdk-map-width={width > 0 ? `${width}px` : undefined}
|
|
253
305
|
>
|
|
254
306
|
<div class="mapsdk-gl" bind:this={glContainer}></div>
|
|
255
|
-
<MapTooltip {map} {project} {width} {height} mode={tooltip} gl={projector} />
|
|
256
307
|
{#if list && sides}
|
|
257
308
|
<div
|
|
258
309
|
class="mapsdk-list-panel"
|
|
@@ -282,6 +333,8 @@
|
|
|
282
333
|
{/if}
|
|
283
334
|
</div>
|
|
284
335
|
{/if}
|
|
336
|
+
<!-- After the list, so Tab from a list row reaches the pinned card's Close button. -->
|
|
337
|
+
<MapTooltip {map} {project} {width} {height} mode={tooltip} gl={projector} {card} />
|
|
285
338
|
<!-- After the list, so Tab reaches the List button first. -->
|
|
286
339
|
{#if selectionBar}
|
|
287
340
|
<SelectionBar {map} clearRight={list?.position === "top-right" && controls === "none"} />
|
|
@@ -37,6 +37,12 @@ interface Props {
|
|
|
37
37
|
* Keyboard focus from the feature list always shows it pinned.
|
|
38
38
|
*/
|
|
39
39
|
tooltip?: "pinned" | "pointer";
|
|
40
|
+
/**
|
|
41
|
+
* `true`: a click or tap on a feature pins its reading card, which takes the pointer and
|
|
42
|
+
* closes with Esc, its Close button, or a click elsewhere on the map; focusing a
|
|
43
|
+
* feature-list row shows the same card. Selection and `featureclick` are unchanged.
|
|
44
|
+
*/
|
|
45
|
+
card?: boolean;
|
|
40
46
|
}
|
|
41
47
|
declare const MapView: import("svelte").Component<Props, {}, "">;
|
|
42
48
|
type MapView = ReturnType<typeof MapView>;
|
|
@@ -128,6 +128,10 @@ function recordView(gl, ctx) {
|
|
|
128
128
|
*/
|
|
129
129
|
export function createGlMap(container, ctx, engineControls = false) {
|
|
130
130
|
const { camera, style } = untrack(() => ({ camera: ctx.camera, style: styleOf(ctx) }));
|
|
131
|
+
// Before the map exists, so its telemetry timer finds it off. Sessions stay on: MapTiler
|
|
132
|
+
// bills SDK maps by session, and without one every tile request counts on its own.
|
|
133
|
+
if (ctx.config.telemetry === false)
|
|
134
|
+
maptilerConfig.telemetry = false;
|
|
131
135
|
const gl = new GlMap({
|
|
132
136
|
container,
|
|
133
137
|
style,
|