@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 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. Anything else goes in
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). Without a frame,
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`). Times in the tooltips read like sjvair.com's map, in
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, () => this.#host.restyle(), () => {
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 [{ pluginId, ref }] = e.hits;
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 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).
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 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).
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
- this.#whenStyleLoaded(true);
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
- max-width: calc(100vw - 2rem);
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));
@@ -143,7 +143,8 @@
143
143
  flex-direction: column;
144
144
  gap: 0.75rem;
145
145
  width: 21rem;
146
- max-width: calc(100vw - 2rem);
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 over the pointer.
49
- const key = $derived(map.focusedKey ?? map.hovered?.hit.refKey ?? null);
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(map.focusedKey ? null : (map.hovered?.hit.pluginId ?? null));
52
- const hitName = $derived(map.focusedKey ? null : (map.hovered?.hit.name ?? null));
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 ?? map.hovered?.lngLat ?? null;
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(() => map.hovered?.hit);
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 && !(zoomHidden && !map.focusedKey)}
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>;
@@ -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,
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.6",
4
+ "version": "0.1.0-beta.8",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",