@sjvair/map-sdk 0.0.0-stage → 0.1.0-beta.10

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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +713 -2
  3. package/dist/core/basemap.svelte.d.ts +38 -0
  4. package/dist/core/basemap.svelte.js +163 -0
  5. package/dist/core/context.d.ts +5 -0
  6. package/dist/core/context.js +13 -0
  7. package/dist/core/create-map.svelte.d.ts +274 -0
  8. package/dist/core/create-map.svelte.js +591 -0
  9. package/dist/core/gl.d.ts +97 -0
  10. package/dist/core/gl.js +1 -0
  11. package/dist/core/interactions.svelte.d.ts +50 -0
  12. package/dist/core/interactions.svelte.js +330 -0
  13. package/dist/core/plugin-host.svelte.d.ts +71 -0
  14. package/dist/core/plugin-host.svelte.js +445 -0
  15. package/dist/core/plugin.d.ts +190 -0
  16. package/dist/core/plugin.js +1 -0
  17. package/dist/core/scoped-context.d.ts +7 -0
  18. package/dist/core/scoped-context.js +34 -0
  19. package/dist/core/shape-view.d.ts +27 -0
  20. package/dist/core/shape-view.js +323 -0
  21. package/dist/data/format.d.ts +10 -0
  22. package/dist/data/format.js +37 -0
  23. package/dist/data/index.d.ts +9 -0
  24. package/dist/data/index.js +9 -0
  25. package/dist/data/messages/en.json +61 -0
  26. package/dist/data/messages.d.ts +10 -0
  27. package/dist/data/messages.js +17 -0
  28. package/dist/data/metadata.d.ts +22 -0
  29. package/dist/data/metadata.js +110 -0
  30. package/dist/data/refs.d.ts +18 -0
  31. package/dist/data/refs.js +61 -0
  32. package/dist/data/scale-types.d.ts +7 -0
  33. package/dist/data/scale-types.js +1 -0
  34. package/dist/data/scale.d.ts +13 -0
  35. package/dist/data/scale.js +27 -0
  36. package/dist/data/selection.d.ts +13 -0
  37. package/dist/data/selection.js +52 -0
  38. package/dist/data/static-source.d.ts +4 -0
  39. package/dist/data/static-source.js +3 -0
  40. package/dist/data/types.d.ts +74 -0
  41. package/dist/data/types.js +1 -0
  42. package/dist/index.d.ts +59 -0
  43. package/dist/index.js +42 -0
  44. package/dist/plugins/icons.d.ts +21 -0
  45. package/dist/plugins/icons.js +73 -0
  46. package/dist/plugins/points-clusters.d.ts +60 -0
  47. package/dist/plugins/points-clusters.js +93 -0
  48. package/dist/plugins/points.svelte.d.ts +105 -0
  49. package/dist/plugins/points.svelte.js +502 -0
  50. package/dist/plugins/scale-resolution.svelte.d.ts +27 -0
  51. package/dist/plugins/scale-resolution.svelte.js +111 -0
  52. package/dist/plugins/shapes.svelte.d.ts +34 -0
  53. package/dist/plugins/shapes.svelte.js +325 -0
  54. package/dist/plugins/shared.d.ts +6 -0
  55. package/dist/plugins/shared.js +18 -0
  56. package/dist/plugins/style.d.ts +57 -0
  57. package/dist/plugins/style.js +193 -0
  58. package/dist/presets/CollocationTooltip.svelte +132 -0
  59. package/dist/presets/CollocationTooltip.svelte.d.ts +16 -0
  60. package/dist/presets/FireTooltip.svelte +70 -0
  61. package/dist/presets/FireTooltip.svelte.d.ts +13 -0
  62. package/dist/presets/MonitorClusterTooltip.svelte +168 -0
  63. package/dist/presets/MonitorClusterTooltip.svelte.d.ts +16 -0
  64. package/dist/presets/MonitorReadingBox.svelte +58 -0
  65. package/dist/presets/MonitorReadingBox.svelte.d.ts +12 -0
  66. package/dist/presets/MonitorTooltip.svelte +88 -0
  67. package/dist/presets/MonitorTooltip.svelte.d.ts +20 -0
  68. package/dist/presets/calheatscore.d.ts +24 -0
  69. package/dist/presets/calheatscore.js +22 -0
  70. package/dist/presets/cluster-area.d.ts +13 -0
  71. package/dist/presets/cluster-area.js +34 -0
  72. package/dist/presets/collocation-sites.d.ts +18 -0
  73. package/dist/presets/collocation-sites.js +67 -0
  74. package/dist/presets/fire-groups.d.ts +26 -0
  75. package/dist/presets/fire-groups.js +51 -0
  76. package/dist/presets/forecast-zones.d.ts +25 -0
  77. package/dist/presets/forecast-zones.js +23 -0
  78. package/dist/presets/hms-fire.d.ts +30 -0
  79. package/dist/presets/hms-fire.js +86 -0
  80. package/dist/presets/hms-smoke.d.ts +20 -0
  81. package/dist/presets/hms-smoke.js +22 -0
  82. package/dist/presets/monitors.d.ts +48 -0
  83. package/dist/presets/monitors.js +243 -0
  84. package/dist/presets/reactive-props.svelte.d.ts +4 -0
  85. package/dist/presets/reactive-props.svelte.js +7 -0
  86. package/dist/presets/regions.d.ts +24 -0
  87. package/dist/presets/regions.js +22 -0
  88. package/dist/stores/abort.d.ts +16 -0
  89. package/dist/stores/abort.js +52 -0
  90. package/dist/stores/collocation-store.svelte.d.ts +25 -0
  91. package/dist/stores/collocation-store.svelte.js +72 -0
  92. package/dist/stores/hms-stores.svelte.d.ts +38 -0
  93. package/dist/stores/hms-stores.svelte.js +97 -0
  94. package/dist/stores/index.d.ts +4 -0
  95. package/dist/stores/index.js +4 -0
  96. package/dist/stores/load-once.svelte.d.ts +25 -0
  97. package/dist/stores/load-once.svelte.js +93 -0
  98. package/dist/stores/monitors-store.svelte.d.ts +85 -0
  99. package/dist/stores/monitors-store.svelte.js +340 -0
  100. package/dist/theme.css +34 -0
  101. package/dist/ui/FeatureList.svelte +501 -0
  102. package/dist/ui/FeatureList.svelte.d.ts +14 -0
  103. package/dist/ui/Legend.svelte +214 -0
  104. package/dist/ui/Legend.svelte.d.ts +22 -0
  105. package/dist/ui/LegendBar.svelte +161 -0
  106. package/dist/ui/LegendBar.svelte.d.ts +11 -0
  107. package/dist/ui/MapControls.svelte +135 -0
  108. package/dist/ui/MapControls.svelte.d.ts +11 -0
  109. package/dist/ui/MapTooltip.svelte +397 -0
  110. package/dist/ui/MapTooltip.svelte.d.ts +37 -0
  111. package/dist/ui/PickNotice.svelte +56 -0
  112. package/dist/ui/PickNotice.svelte.d.ts +7 -0
  113. package/dist/ui/SelectionBar.svelte +123 -0
  114. package/dist/ui/SelectionBar.svelte.d.ts +10 -0
  115. package/dist/ui/TooltipItem.svelte +66 -0
  116. package/dist/ui/TooltipItem.svelte.d.ts +11 -0
  117. package/dist/ui/ViewSummary.svelte +97 -0
  118. package/dist/ui/ViewSummary.svelte.d.ts +7 -0
  119. package/dist/ui/card.d.ts +29 -0
  120. package/dist/ui/card.js +1 -0
  121. package/dist/ui/controls.css +45 -0
  122. package/dist/ui/data-credits.svelte.d.ts +9 -0
  123. package/dist/ui/data-credits.svelte.js +52 -0
  124. package/dist/ui/feature-list.d.ts +46 -0
  125. package/dist/ui/feature-list.js +115 -0
  126. package/dist/ui/legend-data.d.ts +41 -0
  127. package/dist/ui/legend-data.js +40 -0
  128. package/dist/ui/level-chip.d.ts +11 -0
  129. package/dist/ui/level-chip.js +15 -0
  130. package/dist/ui/value-format.d.ts +8 -0
  131. package/dist/ui/value-format.js +20 -0
  132. package/dist/ui/view-summary.d.ts +11 -0
  133. package/dist/ui/view-summary.js +33 -0
  134. package/dist/view/MapView.svelte +443 -0
  135. package/dist/view/MapView.svelte.d.ts +51 -0
  136. package/dist/view/create-gl-map.d.ts +34 -0
  137. package/dist/view/create-gl-map.js +186 -0
  138. package/dist/view/list-placement.d.ts +21 -0
  139. package/dist/view/list-placement.js +13 -0
  140. package/package.json +104 -5
package/README.md CHANGED
@@ -1,3 +1,714 @@
1
- # Temporary Holding Version
1
+ # @sjvair/map-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Svelte 5 map components for SJVAir air-quality data, built on the MapTiler SDK. A map is a
4
+ `createMap()` context, shown with `<MapView>`, with data drawn by plugins (monitors, region fills,
5
+ CalHeatScore, forecast zones, HMS smoke and fire, or your own GeoJSON). It ships a legend, a
6
+ feature list, a selection bar, tooltips, and zoom controls, all themed from the host's tokens.
7
+
8
+ Supported browsers: Chrome/Edge 111+, Safari 16.4+, Firefox 128+. MIT licensed.
9
+
10
+ ## Install
11
+
12
+ map-sdk is in beta. Betas are published under the `next` tag:
13
+
14
+ ```bash
15
+ npm install @sjvair/map-sdk@next
16
+ ```
17
+
18
+ Peer dependencies: `svelte` (^5.57) and `@sjvair/sdk`, which is published to
19
+ JSR and installed through npm's JSR registry as an alias:
20
+
21
+ ```json
22
+ {
23
+ "dependencies": {
24
+ "@sjvair/sdk": "npm:@jsr/sjvair__sdk@^5.0.0-beta.2"
25
+ }
26
+ }
27
+ ```
28
+
29
+ and in the host's `.npmrc` (npm 12 refuses the JSR registry's tarball URLs unless
30
+ `allow-remote` lets the root project fetch them):
31
+
32
+ ```ini
33
+ @jsr:registry=https://npm.jsr.io
34
+ allow-remote=root
35
+ ```
36
+
37
+ Tailwind CSS v4 is required in the host. Its stylesheet must scan this package for classes and
38
+ import the theme (the path in `@source` is relative to the stylesheet; this one is in `src/`):
39
+
40
+ ```css
41
+ @import "tailwindcss";
42
+ @source "../node_modules/@sjvair/map-sdk/dist";
43
+ @import "@sjvair/map-sdk/theme.css";
44
+ ```
45
+
46
+ Without the `@source` line the components are unstyled: their type sizes and spacing are Tailwind
47
+ classes in the markup.
48
+
49
+ MapTiler needs a key: pass it as `config.maptilerKey`. The key is added to each map's own requests,
50
+ so two maps can use different keys. The MapTiler SDK also logs an "API key is not set" warning to
51
+ the console when the host has not set its global key (`config.apiKey` from `@maptiler/sdk`). That
52
+ is cosmetic and can be ignored, or silenced by setting the global key too.
53
+
54
+ ## Theme tokens
55
+
56
+ Components use only the host's tokens and never assume a `.dark` class; dark mode follows
57
+ `prefers-color-scheme` and `[data-theme]`/`.dark` changes on `<html>` (the map re-reads its colors
58
+ when they change). A token the host does not define falls back as below. Define the shadcn names
59
+ at least; the family tokens are optional.
60
+
61
+ Standard (no fallback, must exist): `--background`, `--foreground`, `--card`, `--card-foreground`,
62
+ `--primary`, `--primary-foreground`, `--muted`, `--muted-foreground`, `--border`, `--ring`,
63
+ `--radius`. Read with a fallback: `--popover` (`--background`), `--popover-foreground`
64
+ (`--foreground`), `--accent` (`--muted`).
65
+
66
+ | Family token | Fallback | Used for |
67
+ | ------------------------ | ---------------------------- | ------------------------------------------------------------ |
68
+ | `--highlight` | `--primary` | Selection ring, active fills (never text) |
69
+ | `--highlight-subtle` | `--muted` | Selected-item fill |
70
+ | `--highlight-foreground` | `--primary-foreground` | Text on the selected fill |
71
+ | `--card-border` | `--border` | Panel and control borders |
72
+ | `--shadow-card` | `none` | Panels and controls |
73
+ | `--shadow-lifted` | `--shadow-card`, then `none` | Raised panels |
74
+ | `--radius-control` | `--radius` | Buttons and inputs |
75
+ | `--overlay` | `--background` | Credits and map overlays |
76
+ | `--link` | `--primary` | Links (credits) |
77
+ | `--ring-edge` | `--background` | Edge of the focus ring (2px `--ring` outline plus this edge) |
78
+ | `--swatch-outline-width` | `1px` | Legend swatch outline |
79
+ | `--swatch-outline-color` | a mix of `--foreground` | Legend swatch outline |
80
+ | `--motion-duration-fast` | `120ms` | Control transitions (0 under reduced motion) |
81
+ | `--motion-ease-standard` | `ease` | Control transitions |
82
+
83
+ `theme.css` exposes the family tokens as Tailwind utilities (`bg-highlight`, `border-card-border`,
84
+ `text-link`, `ring-ring-edge`, `shadow-card`, `rounded-control`, `shadow-lifted`).
85
+
86
+ ## A map
87
+
88
+ ```svelte
89
+ <script lang="ts">
90
+ import { createMap, Legend, MapView, monitorsPlugin } from "@sjvair/map-sdk";
91
+ import { createMonitorsStore } from "@sjvair/map-sdk/stores";
92
+ import { onDestroy, onMount } from "svelte";
93
+
94
+ const store = createMonitorsStore();
95
+ const map = createMap({ config: { maptilerKey: import.meta.env.VITE_MAPTILER_KEY } });
96
+ map.addPlugin(
97
+ monitorsPlugin({ source: store.dataSource, layerLabel: "Monitors", selectable: true }),
98
+ { selectionMode: "multiple" }
99
+ );
100
+
101
+ onMount(() => void store.init("pm25"));
102
+ onDestroy(() => {
103
+ store.dispose();
104
+ map.destroy();
105
+ });
106
+ </script>
107
+
108
+ <MapView {map} class="h-[600px]">
109
+ {#snippet overlays()}
110
+ <Legend {map} />
111
+ {/snippet}
112
+ </MapView>
113
+ ```
114
+
115
+ `createMap(options)` returns the map's state and services (`MapContext`) with no GL map yet;
116
+ `MapView` creates the GL map when it mounts. `MapView` draws the tooltip, selection bar, view
117
+ summary, zoom buttons, and the List button with its feature list, and the engine draws the
118
+ credits. `featureList={false}` leaves out the List button and its panel, and `selectionBar={false}`
119
+ the selection bar. `featureList` also takes options (type `FeatureListOptions`): `position` puts the
120
+ button in any corner (`"top-left"`, the default, `"top-right"`, `"bottom-left"`, `"bottom-right"`),
121
+ stacked below the controls in that corner (above them in a bottom corner), and the open panel
122
+ stops short of the controls and credits at the far end of its side. `reveal: "focus"` shows the
123
+ button only while it or its open panel has keyboard focus, and the whole time the panel is open;
124
+ otherwise it's visually hidden but stays in the Tab order and the accessibility tree, like a skip
125
+ link. monitor-map uses `featureList={{ position: "top-right", reveal: "focus" }}` with
126
+ `controls="engine"`. The List button and its open panel are `z-index: 2` inside the map, so a
127
+ host's own menus over the map can sit above them. `controls` picks the zoom controls: `"mapsdk"` (default) draws map-sdk's zoom
128
+ buttons, `"engine"` the MapTiler SDK's default zoom/compass and locate-me controls (top right, as
129
+ monitor-map uses today), and `"none"` none; the credits show in every case. Choose `controls` up
130
+ front: changing it after mount recreates the map. `tooltip` is `"pinned"` (default: anchored to the
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. `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.
139
+ `card={{ actions }}` also puts your buttons in a row at the card's foot: `actions` is a snippet
140
+ given a `CardContext` (`pluginId`, `ref`, `refKey`, the listed `item` or `null`, reactive
141
+ `selectable` and `selected`, and `toggle()`, which is `map.togglePick` for that feature). Show
142
+ Select/Remove only when `selectable` is true: a fire hotspot, a smoke plume, or a layer in mode
143
+ `"none"` can't be selected, and `toggle()` then does nothing. Anything else goes in
144
+ the `overlays` snippet, where `getMapContext()` returns the map. `config.basemaps` replaces the
145
+ default MapTiler Streets v4 variants (build on `defaultBasemaps()`, which `createMap` uses: today's
146
+ sjvair.com setup is `{ ...defaultBasemaps(), excludedLayers: [] }`); `config.initialView` and
147
+ `config.cooperativeGestures` set the camera and gesture handling. `config.projection` (`"mercator"`
148
+ by default, or `"globe"`) and `config.space` (`false` by default, or `{ preset: "milkyway" }` for a
149
+ space background behind the globe) are passed to MapTiler only when set. `config.telemetry: false`
150
+ turns off MapTiler's telemetry (a usage report of the SDK version, API key, and session id, sent
151
+ about 2 s after each map is created) before the map is created. It is a page-wide setting in
152
+ MapTiler's SDK: once one map turns it off, it stays off for every map on the page. MapTiler's
153
+ session id (`mtsid`) stays on either way, since MapTiler bills SDK maps by session (without one,
154
+ every tile request counts on its own) and its tile cache needs it. By default (`true`) MapTiler's
155
+ own default applies.
156
+
157
+ Without `createMonitorsStore`, build a `DataSource` yourself (below).
158
+
159
+ ### Hiding the map (the WebGL release)
160
+
161
+ Browsers cap WebGL contexts. `<MapView {map} active={false} />` releases the map's context, and
162
+ `active` back to `true` rebuilds it with the same camera, basemap, selection, and the latest data.
163
+ Use it for a map on a hidden tab.
164
+
165
+ ### Plugins and draw order
166
+
167
+ Plugins draw in the order they were added, later on top. `addPlugin(plugin, { before: "id" })` draws
168
+ just below another plugin, and `map.movePlugin(id, { before? })` moves an added plugin and its
169
+ layers to the top, or just below the plugin `before` names. A plugin object belongs to one map:
170
+ create one per map (adding an object that is set up on another map throws). The presets are
171
+ `monitorsPlugin`, `regionsPlugin`, `calHeatScorePlugin`, `forecastZonesPlugin`, `hmsSmokePlugin`,
172
+ `hmsFirePlugin`, and `collocationSitesPlugin`; `pointsPlugin` and `shapesPlugin` are the general
173
+ ones they are built on. Presets don't fetch: the host supplies a `DataSource`. `monitorsPlugin` is
174
+ selectable by default, like the shape presets.
175
+
176
+ An added plugin is in `map.plugins` (and the legend and selection state) as soon as `addPlugin`
177
+ returns. On a map that is already showing, its `setup` runs just after the current code finishes,
178
+ so its layers aren't there yet on the next line. Code that must see them uses `await tick()`;
179
+ `flushSync()` does not run setup.
180
+
181
+ Selected points show a highlight ring and check mark by default. `selectedStyle: "enlarge"` on
182
+ `pointsPlugin` or `monitorsPlugin` instead draws the selected icon at 1.3x with no ring (icon
183
+ points only), as sjvair.com's map does today.
184
+
185
+ `outline` on `shapesPlugin` is `"theme"` (default, a 1px `--foreground` edge), `"level"` (the edge
186
+ in each level's color at the fill's opacity; choropleth only), or `"none"` (no outline layer).
187
+ `hmsSmokePlugin` uses `"level"`. `hoverable: false` on `shapesPlugin` sets the plugin's
188
+ `hoverable` (see [Writing a plugin](#writing-a-plugin)): hovering a shape shows no tooltip and
189
+ doesn't hide what is beneath it, while focusing it from the feature list still pins its tooltip.
190
+ `hmsSmokePlugin` uses it, so a plume, which can cover much of the map, never pops up a tooltip.
191
+
192
+ ```ts
193
+ map.addPlugin(
194
+ shapesPlugin({
195
+ id: "plumes",
196
+ source: staticSource(plumes, { dataset: "hms-smoke", entryType: "smoke-density" }),
197
+ layerLabel: "Plumes",
198
+ mode: "choropleth",
199
+ hoverable: false
200
+ })
201
+ );
202
+ ```
203
+
204
+ `zoomRange: { min?, max? }` on `pointsPlugin` limits every layer it draws (points, clusters,
205
+ selection) to camera zooms from `min` (inclusive) up to `max` (exclusive), switching live while
206
+ zooming. Two plugins whose ranges meet hand over with no gap, on the globe too.
207
+
208
+ `hmsFirePlugin({ id?, source, hotspots?, layerLabel })` draws fires as flame icons sized and
209
+ colored by FRP. With `hotspots`, it switches by itself while zooming, as sjvair.com's map does:
210
+ `source` (the fire store's `groups`) below zoom 10 and `hotspots` from zoom 10. It stays one
211
+ layer: one legend section, one feature list (the groups, so keyboard users reach every fire;
212
+ hotspots aren't listed), and tooltips for both. The tooltip is the monitors' reading box: "Fire
213
+ (MW)", the power in its level's color, beside the name ("3 fire detections", "1 fire detection"
214
+ for a group, "Fire detection" for one detection) and the level ("50–149 MW"); a group of two or
215
+ more says "Average". A feature with a `count` (`FireFeatureProperties`, which the fire store's
216
+ groups carry) is a group. Fire values are cut off at the display precision, not rounded, in the
217
+ tooltip, the list and its accessible names, so 9.96 MW reads "9.9" beside "<10".
218
+
219
+ `legendDisplay: "gradient" | "blocks"` on `pointsPlugin` sets how the legend draws a range scale
220
+ (default a gradient; `hmsFirePlugin` uses `"blocks"`, labeled with the levels' own labels). A blocks
221
+ legend (also every category scale, such as smoke) is one outlined bar of contiguous segments with
222
+ each level's label under its segment; long labels wrap. `legendTitle: "layer" | "entry-type"` sets
223
+ the legend section's title: `layerLabel` (default) or the entry type's label from the metadata
224
+ provider (`monitorsPlugin` uses the latter, so its section reads "PM2.5"; `layerLabel` still names
225
+ the layer in the list, selection bar and summary). `Legend` takes `order` (`"bottom-first"`,
226
+ default, or `"top-first"`) and `framed` (`true`, default, draws its own card; `false` draws no
227
+ border, background, padding or fixed width, so a host's own card frames it). The framed legend
228
+ and the feature list panel are 21rem wide at most, and never wider than the map less 2rem (the
229
+ window less 2rem outside a `MapView`), so they fit a small map on a wide screen. Without a frame,
230
+ translucent swatches blend over the nearest ancestor with an opaque background (a see-through
231
+ card is skipped), else `--background`; that color is read on mount, when `framed` changes, and on
232
+ a theme change, but not when the card changes color through its own class or style.
233
+
234
+ ```svelte
235
+ <div class="w-84 rounded-md bg-white shadow">
236
+ <h3>Legend</h3>
237
+ <Legend {map} framed={false} />
238
+ </div>
239
+ ```
240
+
241
+ `detail?(props)` on `pointsPlugin` gives a feature with no level a short text (such as "Level 2
242
+ charger") shown where "No level" would be: in the feature list (plain text, no swatch), the row's
243
+ accessible name ("Fresno Station, Level 2 charger"), and the tooltip. A feature with a level
244
+ always shows its level. A plugin of your own sets the same `detail` on its `FeatureListItem`s.
245
+
246
+ `truncateValues: true` on `pointsPlugin` cuts values off at the display precision instead of
247
+ rounding them (`truncate` on each `FeatureListItem`), in the list, its accessible names, and the
248
+ generic tooltip, so a value never reads as the next level up. `hmsFirePlugin` uses it.
249
+
250
+ `collocationSitesPlugin({ id?, source, layerLabel })` draws collocation sites as sjvair.com does:
251
+ each at its reference monitor, in that monitor's shape and level color (inactive gray, inside
252
+ outlined), wrapped in a crosshair of `COLLOCATION_CROSSHAIR_COLOR` (`#4A5FC6`, a fixed color on
253
+ every theme). Its tooltip shows the reference and collocated monitors' readings side by side, each
254
+ with its time and level. It isn't selectable and adds no legend section (the monitors' scale is
255
+ shown by `monitorsPlugin`). Add it before `monitorsPlugin` so it draws under the monitors; its
256
+ `hoverPriority` is `Number.MAX_SAFE_INTEGER`, so its tooltip still wins over a monitor drawn above
257
+ it (clicks go to the topmost feature).
258
+
259
+ The monitor marker shapes are exported for your own plugins: `circle`, `square`, and
260
+ `triangle(color, borderWidth, borderColor?)` return a 24px SVG, and `withCrosshair(svg, color)`
261
+ wraps one in the crosshair (`CROSSHAIR_ICON_SIZE`, 56px, square). Pass the size to
262
+ `pointsPlugin`'s `icon.size`.
263
+
264
+ ## Data sources
265
+
266
+ A `DataSource` is `{ status, features, scale }`: a load status (`loading`, `ready`, `error` with
267
+ `retry()`, or `unavailable`), a GeoJSON `FeatureCollection` whose features carry
268
+ `{ refKey, name, value }` properties, and a scale. The scale is either a `ScaleRef`
269
+ (`{ dataset, entryType }`, resolved by the metadata provider and used for the legend, tooltips,
270
+ and level labels) or a full `Scale`. A value the scale doesn't cover (null, negative) draws neutral
271
+ and reads "No level"; a level with an empty color draws neutral with its own label. While a
272
+ source loads, fails, or is unavailable, the legend says so (a failure offers "Try again", which
273
+ calls `status.retry()`). Values are shown with the metadata's display precision, except in the
274
+ monitor, cluster, and collocation tooltips, which match monitor-map: the reading and a cluster's
275
+ average are whole numbers, and a cluster's max and min are as reported. Fire values are cut off
276
+ rather than rounded (see `truncateValues`). On a category scale, a value that is its level's code
277
+ (smoke's `"light"`, the heat score's `2`) isn't shown: the list row, the tooltip, and the row's
278
+ accessible name give only the level label. Times in the tooltips read like sjvair.com's map, in
279
+ Pacific time: "4:00 pm Friday Nov 06, 2026", with " PT" after them on a device in another zone.
280
+ The monitor cluster tooltip's count line gives the area the monitors are within, "3 monitors
281
+ within a 22.619 mi² area": the area of their convex hull, left out when it would read 0.000 (two
282
+ monitors, or all on one line or one spot).
283
+
284
+ `createMonitorsStore()` (from `@sjvair/map-sdk/stores`) loads the monitors list and latest readings
285
+ for an entry type, polls every two minutes, and exposes `dataSource`. `init()`, `update()`,
286
+ `entryTypeFor()`, `pauseAutoUpdate()`, `resumeAutoUpdate()`, and `dispose()` are its controls. A
287
+ dashboard that has its own date-range data builds a `DataSource` itself instead.
288
+ `meta` is the read-only monitors meta (the SDK's `MonitorsMeta`), null until `init()` resolves.
289
+
290
+ `createHmsSmokeStore()` and `createHmsFireStore()` (also from `@sjvair/map-sdk/stores`) load today's
291
+ NOAA HMS smoke and fire once, with no polling. Each has `status`, `init({ signal })`, and
292
+ `dispose()`; overlapping `init()` calls share one request, a failed load reports `error` with
293
+ `retry()` (on `status` and on every source), and a caller's abort rejects only that caller. A day
294
+ with no data is `ready` with an empty collection, so the legend still shows. The smoke store has one
295
+ `dataSource` for `hmsSmokePlugin` (scale `hms-smoke` / `smoke-density`, value the density). The fire
296
+ store has `groups` (nearby detections merged by `groupFires`, value the mean FRP) and `hotspots`
297
+ (each detection after `cleanFires`, value its FRP), for `hmsFirePlugin`'s `source` and `hotspots`
298
+ (scale `hms-fire` / `fire-frp`). Pass `{ messages }` to name the features with your own provider.
299
+
300
+ ```ts
301
+ const smoke = createHmsSmokeStore();
302
+ const fires = createHmsFireStore();
303
+ await Promise.all([smoke.init(), fires.init()]);
304
+ map.addPlugin(hmsSmokePlugin({ source: smoke.dataSource, layerLabel: "Smoke" }));
305
+ map.addPlugin(
306
+ hmsFirePlugin({ source: fires.groups, hotspots: fires.hotspots, layerLabel: "Fires" })
307
+ );
308
+ ```
309
+
310
+ `createCollocationSitesStore({ monitors })` (also from `@sjvair/map-sdk/stores`) loads the
311
+ collocation sites once and joins each to a monitors store: `dataSource` follows that store's
312
+ readings as it polls and uses its scale. A site whose reference or collocated monitor isn't in the
313
+ monitors store (inactive, filtered out, or not loaded yet) still draws at its own position, in the
314
+ neutral color with "No reading". It has `status`, `init({ signal })`, and `dispose()`, like the
315
+ HMS stores; `init()` doesn't load the monitors store.
316
+
317
+ ```ts
318
+ const monitors = createMonitorsStore();
319
+ const collocations = createCollocationSitesStore({ monitors });
320
+ await Promise.all([monitors.init("pm25"), collocations.init()]);
321
+ map.addPlugin(
322
+ collocationSitesPlugin({ source: collocations.dataSource, layerLabel: "Collocation sites" })
323
+ );
324
+ map.addPlugin(monitorsPlugin({ source: monitors.dataSource, layerLabel: "Monitors" }));
325
+ ```
326
+
327
+ ### Plain GeoJSON: `staticSource`
328
+
329
+ ```ts
330
+ import { createMap, shapesPlugin } from "@sjvair/map-sdk";
331
+ import { staticSource } from "@sjvair/map-sdk/data";
332
+ import type { FeatureCollection, Polygon } from "geojson";
333
+
334
+ declare const zones: FeatureCollection<Polygon, { refKey: string; name: string; value: number }>;
335
+
336
+ const map = createMap({ config: { maptilerKey: "…" } });
337
+ map.addPlugin(
338
+ shapesPlugin({
339
+ id: "zones",
340
+ source: staticSource(zones, { dataset: "air-quality", entryType: "pm25" }),
341
+ layerLabel: "Zones",
342
+ mode: "choropleth" // "plain" is neutral, with no legend
343
+ })
344
+ );
345
+ ```
346
+
347
+ Pass `null` as the scale for features with no values. A feature's `refKey` is `monitor:<id>`,
348
+ `region:<type>:<code>` (the region's `external_id`, never its sqid), or `feature:<id>` (shown but
349
+ not selectable). `encodeRef`/`decodeRef` convert between refs and keys.
350
+
351
+ ### Writing a plugin
352
+
353
+ A plugin is an object with an `id` and a `setup(ctx)` that adds sources, layers, and images
354
+ through its `PluginContext` and returns a teardown. The host runs `setup` once the map's style is
355
+ ready and again after a basemap or theme change. For a plugin added to a ready map, `setup` runs
356
+ just after the current code finishes (a microtask), never inside the caller's code, so adding and
357
+ removing plugins from an `$effect` is safe. Everything added through `ctx` is removed on teardown,
358
+ and ids are namespaced per map. `features()` feeds the feature list, tooltip, and
359
+ summary; `legend()` the legend. A plugin's `tooltip` content sits under the feature's name; set
360
+ `tooltipShowsName` when the content shows the name itself (the monitor and collocation tooltips
361
+ do), and the name header is left out.
362
+
363
+ ```ts
364
+ import type { FeatureListItem, MapPlugin, PluginContext } from "@sjvair/map-sdk";
365
+ import { decodeRef, NO_LEVEL } from "@sjvair/map-sdk/data";
366
+ import type { FeatureCollection, Point } from "geojson";
367
+
368
+ type Sites = FeatureCollection<Point, { refKey: string; name: string }>;
369
+
370
+ function sitesPlugin(sites: Sites): MapPlugin {
371
+ return {
372
+ id: "sites",
373
+ label: "Sites",
374
+ setup(ctx: PluginContext) {
375
+ const source = ctx.addSource("sites", { type: "geojson", data: sites });
376
+ const layer = ctx.addLayer("dots", { type: "circle", source, paint: { "circle-radius": 5 } });
377
+ ctx.interactive([layer], (props) => {
378
+ const ref = decodeRef(String(props.refKey));
379
+ const name = String(props.name);
380
+ return ref && { pluginId: "sites", ref, refKey: String(props.refKey), name };
381
+ });
382
+ return () => {};
383
+ },
384
+ features: (): FeatureListItem[] =>
385
+ sites.features.flatMap(({ properties: { refKey, name }, geometry }) => {
386
+ const ref = decodeRef(refKey);
387
+ const [lng, lat] = geometry.coordinates;
388
+ if (!ref) return [];
389
+ const lngLat: [number, number] = [lng, lat];
390
+ return [
391
+ { refKey, ref, name, value: null, level: NO_LEVEL, units: "", lngLat, selectable: false }
392
+ ];
393
+ })
394
+ };
395
+ }
396
+ ```
397
+
398
+ An item for a shape (a polygon or line) can carry its `geometry` and, optionally, its `bbox`
399
+ (`[west, south, east, north]`, from `geometryBbox(geometry)`). The list and the summary then count
400
+ it as in view when the shape overlaps the view, not only when its `lngLat` is inside it: a smoke
401
+ plume that fills the screen is listed even though its center is off-screen. Measure `bbox` once per
402
+ data load (both are tested on every move); without it the box is computed each time. Focusing such
403
+ a shape from the list while its `lngLat` is off-screen leaves the camera where it is, and its
404
+ tooltip pins at a point of the shape in view: the view's center (beside the list panel) when the
405
+ shape covers it, else a point inside its visible part. `map.focusedAnchor` is where the focused
406
+ tooltip pins, and `map.anchorFor(item)` gives that point for any item without moving the camera
407
+ (the hover tooltip uses it). `intersectsBounds(item, bounds)` is the in-view test. `shapesPlugin`
408
+ and the presets built on it set `geometry` and `bbox` already. Bounds across the antimeridian are
409
+ not handled.
410
+
411
+ A plugin can also add a MapLibre custom layer, such as a wind layer from a library you add
412
+ yourself, with `ctx.addCustomLayer(local, create)`. The host calls `create` with the namespaced id
413
+ (the layer must use it) and adds the object it returns as is, in the plugin's draw-order slot. It is
414
+ removed on teardown, `create` runs again after a basemap or theme change (return a fresh layer each
415
+ time), and `movePlugin` moves it with the plugin's other layers.
416
+
417
+ The cursor is a pointer over any interactive feature or cluster bubble. Where several plugins'
418
+ features are under the pointer, the hover tooltip shows the one whose plugin has the highest
419
+ `hoverPriority` (default 0), the topmost on a tie. Over a cluster bubble, the bubble's tooltip
420
+ shows unless a feature beneath belongs to a plugin with a higher `hoverPriority` than the
421
+ bubble's (collocation sites under monitor clusters). Clicks always go to the topmost, so a click
422
+ on a bubble still zooms into it. A plugin with `hoverable: false` never takes the hover: its
423
+ features show no hover tooltip or pointer cursor and are passed over as if they weren't drawn, so
424
+ whatever is beneath gets the hover. They are still clicked, returned by `featuresAt`, and pin
425
+ their tooltip when focused from the feature list.
426
+
427
+ ```ts
428
+ import type { CustomLayerInterface, MapPlugin, PluginContext } from "@sjvair/map-sdk";
429
+
430
+ class WindLayer implements CustomLayerInterface {
431
+ readonly type = "custom";
432
+ constructor(readonly id: string) {}
433
+ onAdd() {} // set up the library's renderer
434
+ render() {} // draw one frame
435
+ onRemove() {} // release it
436
+ }
437
+
438
+ const windPlugin: MapPlugin = {
439
+ id: "wind",
440
+ label: "Wind",
441
+ setup(ctx: PluginContext) {
442
+ ctx.addCustomLayer("particles", (id) => new WindLayer(id));
443
+ return () => {};
444
+ }
445
+ };
446
+ ```
447
+
448
+ The root entry exports the types a plugin needs (`MapPlugin`, `PluginContext`, `FeatureHit`,
449
+ `FeatureListItem`, `LegendData`, `Providers`, `GlMapLike`, `CustomLayerInterface`,
450
+ `LayerSpecWithoutId`, `ThemeColors`, `ClusterLayers`, `ClusterLayerArgs`, `BlockingClick`,
451
+ `Hovered`) and the scale expressions the built-in plugins use (`colorExpression`,
452
+ `opacityExpression`, `levelKeyExpression`).
453
+
454
+ ## Selection
455
+
456
+ Each plugin has a mode: `none`, `single`, or `multiple` (set it with `addPlugin`'s
457
+ `selectionMode` or `map.setSelectionMode(id, mode)`). Click or tap picks; Shift/Cmd/Ctrl-click adds
458
+ or removes in `multiple`, and a tap does the same on touch. The selection is a `SpatialSelection`,
459
+ `{ kind: "monitors" | "regions", ids: string[] }` (region ids are `<type>:<code>`), and the host
460
+ owns it:
461
+
462
+ ```ts
463
+ map.setSelection("monitors", { kind: "monitors", ids: ["abc123"] }); // host -> map
464
+ map.addPlugin(plugin, {
465
+ selectionMode: "multiple",
466
+ selection: { kind: "monitors", ids: ["abc123"] },
467
+ onselectionchange: (selection) => save(selection) // map -> host (user picks only)
468
+ });
469
+ ```
470
+
471
+ `setSelection` does not call `onselectionchange`. Ids that match no feature are kept and counted
472
+ in the selection bar, and a feature that appears later shows as selected. `map.getSelection(id)`
473
+ and `getSelectionMode(id)` are reactive.
474
+
475
+ `pickWith: "modifier"` (an `addPlugin` option; default `"click"`) is for maps where a click should
476
+ only show the reading: a plain click or tap pins the card (use it with `MapView`'s `card`), and
477
+ Shift/Cmd/Ctrl-click, or the list's Space or Shift+Enter, toggles the feature in the selection (in
478
+ `single` mode it selects, or clears it when it's already selected). A tap never selects; the card's
479
+ own buttons do (below). Shift-click goes through MapLibre's box zoom, which takes every Shift press:
480
+ map-sdk turns a Shift release in place into the pick for `"modifier"` plugins only, and Shift-drag
481
+ still zooms to a box. With `pickWith: "click"`, a Shift-click still does nothing on the map.
482
+
483
+ `canSelect(next, ref)` (also an `addPlugin` option) refuses a user's pick before it shows: it gets
484
+ the selection the pick would produce and returns `true` to let it through or a message to refuse
485
+ it. A refused pick changes nothing and calls no `onselectionchange`; the map shows the message for
486
+ 4 s and announces it to screen readers (`map.notice` holds it). It isn't asked for `setSelection`
487
+ or the selection bar's Clear.
488
+
489
+ ```ts
490
+ map.addPlugin(plugin, {
491
+ selectionMode: "multiple",
492
+ pickWith: "modifier",
493
+ canSelect: (next) => (next?.ids.length ?? 0) <= 3 || "Up to 3 monitors"
494
+ });
495
+ ```
496
+
497
+ `map.togglePick(pluginId, ref)` is a user's toggle for your own controls (the card's Select or
498
+ Remove): the same rule, `canSelect`, and `onselectionchange` as a modifier-click.
499
+
500
+ `map.on("featureclick", ({ pluginId, ref, lngLat }) => …)` fires for every click on a feature,
501
+ whatever the mode, and also for a pick in the feature list, so keyboard users get the same event;
502
+ `lngLat` is the click point. For a list pick it's where the item's tooltip pins: the item's
503
+ position, or for a shape whose label point is off-screen, a point of the shape in view. It returns an unsubscribe
504
+ function. For your own context menu, `map.featuresAt({ x, y })` returns the hits under
505
+ a canvas point, topmost first, and `map.featureForElement(element)` returns the hit for a row of
506
+ the feature list (an element with `data-mapsdk-ref`). Both return nothing while the map is
507
+ detached.
508
+
509
+ ## Camera
510
+
511
+ `map.easeTo({ center, zoom?, minZoom? })` and `map.flyTo(...)` move the camera; `minZoom` means
512
+ "zoom in to at least this" (`max(current zoom, minZoom)`), and `zoom` wins when both are given.
513
+ `map.fitBounds({ west, south, east, north }, { padding?, maxZoom? })` fits a box. All three do
514
+ nothing while the map is detached or destroyed, and use no animation under `prefers-reduced-motion`.
515
+
516
+ ```ts
517
+ map.flyTo({ center: [-119.7, 36.7], zoom: 16 });
518
+ map.easeTo({ center: monitor.lngLat, minZoom: 12 });
519
+ ```
520
+
521
+ ## Ready, and reaching the GL map
522
+
523
+ `map.ready` is reactive: true once the attached map's style has loaded, the map has gone idle
524
+ for the first time, and any queued plugin setups have run; false again on detach and while a
525
+ basemap switch reloads the style. Adding a plugin to a ready map leaves it true. Show a load screen
526
+ until it is true. `map.gl` is the attached GL map (or `null`), reactive.
527
+
528
+ `maptilerMap(map)` returns it as a MapTiler `Map` (or `null` while detached). It is an escape hatch
529
+ for markers and popups only: never add sources or layers through it, because plugins own those.
530
+
531
+ ```ts
532
+ import { maptilerMap } from "@sjvair/map-sdk";
533
+
534
+ const gl = maptilerMap(map);
535
+ if (gl) new Marker().setLngLat(lngLat).addTo(gl);
536
+ ```
537
+
538
+ ## Providers
539
+
540
+ `createMap` takes three optional providers, so a host controls where its labels and metadata come
541
+ from:
542
+
543
+ - `metadata`: scales, entry-type labels and units, monitor type labels, and dataset attribution.
544
+ The default reads the SJVAir API through `@sjvair/sdk`. `monitorTypeInterval(type)` is an
545
+ optional method returning how often a monitor type reports (e.g. `"2 min"`); the monitor
546
+ tooltip shows "(2 min avg)" under the entry-type label when it exists, and omits the note when a
547
+ host's provider lacks it.
548
+ - `messages`: `(key, params) => string`. The default is the English catalog,
549
+ `@sjvair/map-sdk/messages/en.json`, with `{name}`-style parameters and `_one`/`_other` plurals
550
+ selected by `count`.
551
+ - `format`: numbers, date-times in America/Los_Angeles, a "PT" zone label when the device is
552
+ elsewhere, and `relativeTime(date, now?)` ("5 minutes ago").
553
+
554
+ Every UI string is a `mapsdk_*` key. To translate them in a Paraglide project, add
555
+ `node_modules/@sjvair/map-sdk/dist/data/messages/en.json` (exported as
556
+ `@sjvair/map-sdk/messages/en.json`) to the project's message sources, translate the keys, and
557
+ adapt the generated functions:
558
+
559
+ ```ts
560
+ import * as m from "$lib/paraglide/messages.js";
561
+ import type { MessageKey, MessageParams } from "@sjvair/map-sdk/data";
562
+
563
+ const messages = (key: MessageKey, params?: MessageParams) =>
564
+ (m as unknown as Record<string, (p?: MessageParams) => string>)[key]?.(params) ?? key;
565
+
566
+ const map = createMap({ config: { maptilerKey: "…" }, messages });
567
+ ```
568
+
569
+ A key that is a plural base (for example `mapsdk_selected_count`) arrives with `params.count`;
570
+ the catalog stores it as `<key>_one` and `<key>_other`, so a provider over a different catalog
571
+ maps it to its own plural form. Layer names (`layerLabel`) are passed already translated.
572
+
573
+ ## Examples are checked
574
+
575
+ `tests/unit/readme-examples.test.ts` repeats the TypeScript examples above (the map setup, plain
576
+ GeoJSON, writing a plugin, selection, and a message provider) against the real exports: `npm run
577
+ check` type-checks them and `npm test` runs them. The Svelte markup isn't mounted there. Update
578
+ it with the README.
579
+
580
+ ## Demo and the local stack
581
+
582
+ `npm run dev` serves the demo page (`demo/`): two maps on one page, one fed from date-range
583
+ sources like the dashboard and one from the live monitors store, with the presets, selection
584
+ modes, and theme toggles. It calls the API on its own origin, and Vite proxies `/api/` to the
585
+ local sjvair.com stack at `http://localhost:8000`.
586
+
587
+ 1. Copy `.env.example` to `.env` and set `VITE_MAPTILER_KEY`. Never commit `.env`.
588
+ 2. Start the sjvair.com stack on port 8000.
589
+ 3. A fresh database has no calibrations; load them once:
590
+
591
+ ```bash
592
+ podman exec sjvaircom_web_1 python manage.py loaddata default-calibrations.yaml
593
+ ```
594
+
595
+ ## Changes
596
+
597
+ ### 0.1.0-beta.10
598
+
599
+ - **Buttons on the pinned card.** `card={{ actions }}`: a snippet of the host's own buttons, given
600
+ the card's feature, whether it's selected, and `toggle()` (the dashboard's Select and Remove).
601
+ - **Pick with a modifier.** `pickWith: "modifier"`: a plain click or tap only pins the card, and
602
+ Shift/Cmd/Ctrl-click (or the list's Space or Shift+Enter) toggles the selection. Shift-click now
603
+ reaches the map through MapLibre's box zoom for these plugins; Shift-drag still zooms to a box.
604
+ - **Refuse a pick before it shows.** `canSelect(next, ref)` returns `true` or a message: a refused
605
+ pick changes nothing and calls no `onselectionchange`, and the map shows and announces the
606
+ message for 4 s (`map.notice`). An empty message refuses quietly.
607
+ - **The feature list's Shift+Enter now picks additively**, as Shift-click on a row does (Enter
608
+ alone replaces, Space adds or removes, as before). This applies with any `pickWith`.
609
+ - **New API:** `pickWith`, `canSelect`, `MapContext.togglePick`, `getPickWith`, `notice`,
610
+ `CardOptions`, `CardContext`, and `GlMapLike.unproject` (for hosts that wrap the GL map).
611
+ - **Every `MapView` now has a `role="status"` element** (empty unless a pick is refused), so a host
612
+ test that finds "the" status by role inside a map may need to narrow it.
613
+
614
+ ### 0.1.0-beta.9
615
+
616
+ - **A map with `tooltip="pointer"` comes back after releasing WebGL.** Setting `MapView`'s
617
+ `active` to `false` threw "Cannot read properties of null (reading 'off')" from the tooltip's
618
+ zoom listener, and the map never drew again until a reload. It now releases and rebuilds cleanly.
619
+ In 0.1.0-beta.7 and 0.1.0-beta.8; maps with the default `tooltip="pinned"` weren't affected.
620
+
621
+ ### 0.1.0-beta.8
622
+
623
+ - **The pinned reading card.** `MapView`'s new `card` prop: a click or tap on a feature pins its
624
+ card, which takes the pointer, has a Close button, and closes with Esc, Close, or a click
625
+ elsewhere on the map. Focusing a feature-list row shows the same card, and Tab reaches it; closing
626
+ it returns focus where it came from. With `card`, a tap always shows the reading: without
627
+ it, a tap's emulated `mouseout` (Chromium, WebKit) still hides the hover card right after the
628
+ tap, as before. Nothing changes without `card`, and with it, selection and `featureclick` behave
629
+ as before.
630
+ - **New API:** `MapView`'s `card`, `MapContext.pinned` and `unpin()`. New message key for hosts
631
+ with their own catalog: `mapsdk_card_close` ("Close").
632
+
633
+ ### 0.1.0-beta.7
634
+
635
+ - **Plugins come back at once after a basemap switch.** After picking another basemap, monitors,
636
+ fire, smoke and the other layers were missing until every tile of the new basemap had loaded
637
+ and drawn (4–10 s on a slow connection). They are now added as soon as the new style loads, as
638
+ sjvair.com's map does. A theme change while tiles are loading no longer waits either.
639
+ - **`config.telemetry: false`** turns off MapTiler's telemetry (its usage report) for the page,
640
+ before the map is created. Sessions stay on, so billing by session and the tile cache are
641
+ unchanged. By default nothing changes.
642
+ - **The feature list panel and the framed legend fit the map.** They were capped at the window's
643
+ width less 2rem; inside a `MapView` they are now capped at the map's width less 2rem, so a small
644
+ map on a wide screen doesn't overflow. MapView sets `--mapsdk-map-width` for this.
645
+
646
+ ### 0.1.0-beta.6
647
+
648
+ - **A category reading is shown once.** When a feature's value is its category level's code, the
649
+ list row, the tooltip, and the row's accessible name show only the level label. Smoke rows read
650
+ "Smoke, Light" (was "light Light"), burn status "Burning Discouraged", and heat score
651
+ "Moderate" (the 0–4 number is no longer shown). Number scales such as PM2.5 and fire power are
652
+ unchanged, and a category value that matches no level is still shown.
653
+ - **New message key for hosts with their own catalog:** `mapsdk_list_row_level`
654
+ (`"{name}, {level}"`).
655
+
656
+ ### 0.1.0-beta.5
657
+
658
+ - **Shapes that cover the view are in view.** The feature list and the view summary count a shape
659
+ when it overlaps the view, not only when its center (label point) is inside it. A smoke plume or
660
+ a big region that fills the screen, with its center off-screen, now has a list row and is
661
+ counted. Points are unchanged.
662
+ - **Focusing such a shape doesn't move the map.** Before, focusing a row panned to center the
663
+ shape's label point (a plume's could be off Baja). Now the camera stays, and the tooltip pins at
664
+ a point of the shape in view, with no move event. A shape whose label point is on screen still
665
+ pans to it.
666
+ - **Hover tooltips** in `"pinned"` mode follow the same rule: a hovered shape whose label point is
667
+ off-screen pins its tooltip inside the view instead of at the map's edge. If the map moves while
668
+ the pointer rests on the shape, the pin is found again when the map settles.
669
+ - **`featureclick` from the list** (Enter on a row) reports that same in-view point for such a
670
+ shape, instead of its off-screen label point. A handler that pans to `lngLat` no longer sends
671
+ the map off to the shape's far-away center. Points and on-screen label points report their
672
+ position, as before.
673
+ - **New API (all optional):** `FeatureListItem.geometry` and `.bbox`, `MapContext.focusedAnchor`
674
+ and `.anchorFor(item)`, `geometryBbox`, `intersectsBounds`, and the `Bbox` type.
675
+
676
+ ### 0.1.0-beta.4
677
+
678
+ Defaults from monitor-map 5.0's parity review against sjvair.com's map (4.3.0). Every map gets
679
+ them; none is an option.
680
+
681
+ - **Tooltip times** read like sjvair.com's map, in Pacific time: "4:00 pm Friday Nov 06, 2026"
682
+ (was "Nov 6, 2026, 4:00 PM"). A device in another zone still gets " PT" after the time.
683
+ - **The cluster area line is back.** The monitor cluster tooltip's count line reads "3 monitors
684
+ within a 22.619 mi² area". This reverses the 2026-11-04 parity decision of "no area line". The
685
+ wording is new (sjvair.com said "covering"), and the area is the convex hull of the cluster's
686
+ monitors, so clusters of four or more can differ from sjvair.com's number, which followed the
687
+ monitors' order and could cross itself. No line when the area would read 0.000.
688
+ - **Fire tooltips are the reading box**: "Fire (MW)", the power in its level's color, beside the
689
+ name and level; "Average" for a group of two or more. Fire values are cut off, not rounded, in
690
+ the tooltip, the list and its accessible names. Every group names its count, a group of 1
691
+ included ("1 fire detection").
692
+ - **Smoke plumes show no hover tooltip** and never take the hover from what is beneath them;
693
+ focusing a plume from the feature list still pins its tooltip. This reverses part of milestone
694
+ 2's accessibility additions ("tooltips on smoke and fire"): fire keeps a tooltip.
695
+ - **Breaking for hosts with their own message catalog:** `mapsdk_hms_fire_detections` is now
696
+ `mapsdk_hms_fire_detections_one` / `_other`. New keys: `mapsdk_hms_fire_box_header`,
697
+ `mapsdk_hms_fire_box_header_no_units`, `mapsdk_hms_fire_average`, `mapsdk_level_with_units`,
698
+ and `mapsdk_monitor_cluster_count_area_one` / `_other`.
699
+ - **New API:** `MapPlugin.hoverable`, `shapesPlugin`'s `hoverable`, `pointsPlugin`'s
700
+ `truncateValues`, `FeatureListItem.truncate`, and `FireFeatureProperties`.
701
+
702
+ ## Development
703
+
704
+ ```bash
705
+ npm run lint # Prettier check + ESLint
706
+ npm run check # svelte-check
707
+ npm test # unit, client, and browser (Vitest) projects
708
+ npm run test:bundle # build, then check the light entries stay off the map engine
709
+ npm run test:licenses # build, then check bundled dependencies' licenses
710
+ npm run test:e2e # Playwright on the demo (Chromium, Firefox, WebKit)
711
+ npm run build # svelte-package into dist/
712
+ ```
713
+
714
+ See `CLAUDE.md` for the architecture.