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

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 (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +506 -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 +207 -0
  8. package/dist/core/create-map.svelte.js +376 -0
  9. package/dist/core/gl.d.ts +92 -0
  10. package/dist/core/gl.js +1 -0
  11. package/dist/core/interactions.svelte.d.ts +43 -0
  12. package/dist/core/interactions.svelte.js +230 -0
  13. package/dist/core/plugin-host.svelte.d.ts +46 -0
  14. package/dist/core/plugin-host.svelte.js +366 -0
  15. package/dist/core/plugin.d.ts +155 -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/data/format.d.ts +10 -0
  20. package/dist/data/format.js +24 -0
  21. package/dist/data/index.d.ts +9 -0
  22. package/dist/data/index.js +9 -0
  23. package/dist/data/messages/en.json +52 -0
  24. package/dist/data/messages.d.ts +10 -0
  25. package/dist/data/messages.js +17 -0
  26. package/dist/data/metadata.d.ts +22 -0
  27. package/dist/data/metadata.js +110 -0
  28. package/dist/data/refs.d.ts +18 -0
  29. package/dist/data/refs.js +61 -0
  30. package/dist/data/scale-types.d.ts +7 -0
  31. package/dist/data/scale-types.js +1 -0
  32. package/dist/data/scale.d.ts +13 -0
  33. package/dist/data/scale.js +27 -0
  34. package/dist/data/selection.d.ts +7 -0
  35. package/dist/data/selection.js +31 -0
  36. package/dist/data/static-source.d.ts +4 -0
  37. package/dist/data/static-source.js +3 -0
  38. package/dist/data/types.d.ts +69 -0
  39. package/dist/data/types.js +1 -0
  40. package/dist/index.d.ts +56 -0
  41. package/dist/index.js +41 -0
  42. package/dist/plugins/icons.d.ts +21 -0
  43. package/dist/plugins/icons.js +73 -0
  44. package/dist/plugins/points-clusters.d.ts +60 -0
  45. package/dist/plugins/points-clusters.js +93 -0
  46. package/dist/plugins/points.svelte.d.ts +100 -0
  47. package/dist/plugins/points.svelte.js +501 -0
  48. package/dist/plugins/scale-resolution.svelte.d.ts +27 -0
  49. package/dist/plugins/scale-resolution.svelte.js +111 -0
  50. package/dist/plugins/shapes.svelte.d.ts +29 -0
  51. package/dist/plugins/shapes.svelte.js +307 -0
  52. package/dist/plugins/shared.d.ts +6 -0
  53. package/dist/plugins/shared.js +18 -0
  54. package/dist/plugins/style.d.ts +57 -0
  55. package/dist/plugins/style.js +193 -0
  56. package/dist/presets/CollocationTooltip.svelte +132 -0
  57. package/dist/presets/CollocationTooltip.svelte.d.ts +16 -0
  58. package/dist/presets/MonitorClusterTooltip.svelte +149 -0
  59. package/dist/presets/MonitorClusterTooltip.svelte.d.ts +16 -0
  60. package/dist/presets/MonitorReadingBox.svelte +58 -0
  61. package/dist/presets/MonitorReadingBox.svelte.d.ts +12 -0
  62. package/dist/presets/MonitorTooltip.svelte +88 -0
  63. package/dist/presets/MonitorTooltip.svelte.d.ts +20 -0
  64. package/dist/presets/calheatscore.d.ts +24 -0
  65. package/dist/presets/calheatscore.js +22 -0
  66. package/dist/presets/collocation-sites.d.ts +18 -0
  67. package/dist/presets/collocation-sites.js +67 -0
  68. package/dist/presets/fire-groups.d.ts +26 -0
  69. package/dist/presets/fire-groups.js +51 -0
  70. package/dist/presets/forecast-zones.d.ts +25 -0
  71. package/dist/presets/forecast-zones.js +23 -0
  72. package/dist/presets/hms-fire.d.ts +26 -0
  73. package/dist/presets/hms-fire.js +87 -0
  74. package/dist/presets/hms-smoke.d.ts +19 -0
  75. package/dist/presets/hms-smoke.js +20 -0
  76. package/dist/presets/monitors.d.ts +48 -0
  77. package/dist/presets/monitors.js +243 -0
  78. package/dist/presets/reactive-props.svelte.d.ts +4 -0
  79. package/dist/presets/reactive-props.svelte.js +7 -0
  80. package/dist/presets/regions.d.ts +24 -0
  81. package/dist/presets/regions.js +22 -0
  82. package/dist/stores/abort.d.ts +16 -0
  83. package/dist/stores/abort.js +52 -0
  84. package/dist/stores/collocation-store.svelte.d.ts +25 -0
  85. package/dist/stores/collocation-store.svelte.js +72 -0
  86. package/dist/stores/hms-stores.svelte.d.ts +35 -0
  87. package/dist/stores/hms-stores.svelte.js +97 -0
  88. package/dist/stores/index.d.ts +4 -0
  89. package/dist/stores/index.js +4 -0
  90. package/dist/stores/load-once.svelte.d.ts +25 -0
  91. package/dist/stores/load-once.svelte.js +93 -0
  92. package/dist/stores/monitors-store.svelte.d.ts +85 -0
  93. package/dist/stores/monitors-store.svelte.js +340 -0
  94. package/dist/theme.css +34 -0
  95. package/dist/ui/FeatureList.svelte +505 -0
  96. package/dist/ui/FeatureList.svelte.d.ts +14 -0
  97. package/dist/ui/Legend.svelte +213 -0
  98. package/dist/ui/Legend.svelte.d.ts +22 -0
  99. package/dist/ui/LegendBar.svelte +161 -0
  100. package/dist/ui/LegendBar.svelte.d.ts +11 -0
  101. package/dist/ui/MapControls.svelte +135 -0
  102. package/dist/ui/MapControls.svelte.d.ts +11 -0
  103. package/dist/ui/MapTooltip.svelte +158 -0
  104. package/dist/ui/MapTooltip.svelte.d.ts +28 -0
  105. package/dist/ui/SelectionBar.svelte +123 -0
  106. package/dist/ui/SelectionBar.svelte.d.ts +10 -0
  107. package/dist/ui/TooltipItem.svelte +72 -0
  108. package/dist/ui/TooltipItem.svelte.d.ts +11 -0
  109. package/dist/ui/ViewSummary.svelte +97 -0
  110. package/dist/ui/ViewSummary.svelte.d.ts +7 -0
  111. package/dist/ui/controls.css +45 -0
  112. package/dist/ui/data-credits.svelte.d.ts +9 -0
  113. package/dist/ui/data-credits.svelte.js +52 -0
  114. package/dist/ui/feature-list.d.ts +38 -0
  115. package/dist/ui/feature-list.js +95 -0
  116. package/dist/ui/legend-data.d.ts +41 -0
  117. package/dist/ui/legend-data.js +40 -0
  118. package/dist/ui/level-chip.d.ts +11 -0
  119. package/dist/ui/level-chip.js +15 -0
  120. package/dist/ui/value-format.d.ts +7 -0
  121. package/dist/ui/value-format.js +13 -0
  122. package/dist/ui/view-summary.d.ts +11 -0
  123. package/dist/ui/view-summary.js +33 -0
  124. package/dist/view/MapView.svelte +373 -0
  125. package/dist/view/MapView.svelte.d.ts +43 -0
  126. package/dist/view/create-gl-map.d.ts +34 -0
  127. package/dist/view/create-gl-map.js +182 -0
  128. package/dist/view/list-placement.d.ts +21 -0
  129. package/dist/view/list-placement.js +13 -0
  130. package/package.json +103 -5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Central California Asthma Collaborative (SJVAir)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,507 @@
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
+ Once published:
13
+
14
+ ```bash
15
+ npm install @sjvair/map-sdk
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. Anything else goes in
133
+ the `overlays` snippet, where `getMapContext()` returns the map. `config.basemaps` replaces the
134
+ default MapTiler Streets v4 variants (build on `defaultBasemaps()`, which `createMap` uses: today's
135
+ sjvair.com setup is `{ ...defaultBasemaps(), excludedLayers: [] }`); `config.initialView` and
136
+ `config.cooperativeGestures` set the camera and gesture handling. `config.projection` (`"mercator"`
137
+ 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.
139
+
140
+ Without `createMonitorsStore`, build a `DataSource` yourself (below).
141
+
142
+ ### Hiding the map (the WebGL release)
143
+
144
+ Browsers cap WebGL contexts. `<MapView {map} active={false} />` releases the map's context, and
145
+ `active` back to `true` rebuilds it with the same camera, basemap, selection, and the latest data.
146
+ Use it for a map on a hidden tab.
147
+
148
+ ### Plugins and draw order
149
+
150
+ Plugins draw in the order they were added, later on top. `addPlugin(plugin, { before: "id" })` draws
151
+ just below another plugin, and `map.movePlugin(id, { before? })` moves an added plugin and its
152
+ layers to the top, or just below the plugin `before` names. A plugin object belongs to one map:
153
+ create one per map (adding an object that is set up on another map throws). The presets are
154
+ `monitorsPlugin`, `regionsPlugin`, `calHeatScorePlugin`, `forecastZonesPlugin`, `hmsSmokePlugin`,
155
+ `hmsFirePlugin`, and `collocationSitesPlugin`; `pointsPlugin` and `shapesPlugin` are the general
156
+ ones they are built on. Presets don't fetch: the host supplies a `DataSource`. `monitorsPlugin` is
157
+ selectable by default, like the shape presets.
158
+
159
+ Selected points show a highlight ring and check mark by default. `selectedStyle: "enlarge"` on
160
+ `pointsPlugin` or `monitorsPlugin` instead draws the selected icon at 1.3x with no ring (icon
161
+ points only), as sjvair.com's map does today.
162
+
163
+ `outline` on `shapesPlugin` is `"theme"` (default, a 1px `--foreground` edge), `"level"` (the edge
164
+ in each level's color at the fill's opacity; choropleth only), or `"none"` (no outline layer).
165
+ `hmsSmokePlugin` uses `"level"`.
166
+
167
+ `zoomRange: { min?, max? }` on `pointsPlugin` limits every layer it draws (points, clusters,
168
+ selection) to camera zooms from `min` (inclusive) up to `max` (exclusive), switching live while
169
+ zooming. Two plugins whose ranges meet hand over with no gap, on the globe too.
170
+
171
+ `hmsFirePlugin({ id?, source, hotspots?, layerLabel })` draws fires as flame icons sized and
172
+ colored by FRP. With `hotspots`, it switches by itself while zooming, as sjvair.com's map does:
173
+ `source` (the fire store's `groups`) below zoom 10 and `hotspots` from zoom 10. It stays one
174
+ layer: one legend section, one feature list (the groups, so keyboard users reach every fire;
175
+ hotspots aren't listed), and tooltips for both.
176
+
177
+ `legendDisplay: "gradient" | "blocks"` on `pointsPlugin` sets how the legend draws a range scale
178
+ (default a gradient; `hmsFirePlugin` uses `"blocks"`, labeled with the levels' own labels). A blocks
179
+ legend (also every category scale, such as smoke) is one outlined bar of contiguous segments with
180
+ each level's label under its segment; long labels wrap. `legendTitle: "layer" | "entry-type"` sets
181
+ the legend section's title: `layerLabel` (default) or the entry type's label from the metadata
182
+ provider (`monitorsPlugin` uses the latter, so its section reads "PM2.5"; `layerLabel` still names
183
+ the layer in the list, selection bar and summary). `Legend` takes `order` (`"bottom-first"`,
184
+ default, or `"top-first"`) and `framed` (`true`, default, draws its own card; `false` draws no
185
+ border, background, padding or fixed width, so a host's own card frames it). Without a frame,
186
+ translucent swatches blend over the nearest ancestor with an opaque background (a see-through
187
+ card is skipped), else `--background`; that color is read on mount, when `framed` changes, and on
188
+ a theme change, but not when the card changes color through its own class or style.
189
+
190
+ ```svelte
191
+ <div class="w-84 rounded-md bg-white shadow">
192
+ <h3>Legend</h3>
193
+ <Legend {map} framed={false} />
194
+ </div>
195
+ ```
196
+
197
+ `detail?(props)` on `pointsPlugin` gives a feature with no level a short text (such as "Level 2
198
+ charger") shown where "No level" would be: in the feature list (plain text, no swatch), the row's
199
+ accessible name ("Fresno Station, Level 2 charger"), and the tooltip. A feature with a level
200
+ always shows its level. A plugin of your own sets the same `detail` on its `FeatureListItem`s.
201
+
202
+ `collocationSitesPlugin({ id?, source, layerLabel })` draws collocation sites as sjvair.com does:
203
+ each at its reference monitor, in that monitor's shape and level color (inactive gray, inside
204
+ outlined), wrapped in a crosshair of `COLLOCATION_CROSSHAIR_COLOR` (`#4A5FC6`, a fixed color on
205
+ every theme). Its tooltip shows the reference and collocated monitors' readings side by side, each
206
+ with its time and level. It isn't selectable and adds no legend section (the monitors' scale is
207
+ shown by `monitorsPlugin`). Add it before `monitorsPlugin` so it draws under the monitors; its
208
+ `hoverPriority` is `Number.MAX_SAFE_INTEGER`, so its tooltip still wins over a monitor drawn above
209
+ it (clicks go to the topmost feature).
210
+
211
+ The monitor marker shapes are exported for your own plugins: `circle`, `square`, and
212
+ `triangle(color, borderWidth, borderColor?)` return a 24px SVG, and `withCrosshair(svg, color)`
213
+ wraps one in the crosshair (`CROSSHAIR_ICON_SIZE`, 56px, square). Pass the size to
214
+ `pointsPlugin`'s `icon.size`.
215
+
216
+ ## Data sources
217
+
218
+ A `DataSource` is `{ status, features, scale }`: a load status (`loading`, `ready`, `error` with
219
+ `retry()`, or `unavailable`), a GeoJSON `FeatureCollection` whose features carry
220
+ `{ refKey, name, value }` properties, and a scale. The scale is either a `ScaleRef`
221
+ (`{ dataset, entryType }`, resolved by the metadata provider and used for the legend, tooltips,
222
+ and level labels) or a full `Scale`. A value the scale doesn't cover (null, negative) draws neutral
223
+ and reads "No level"; a level with an empty color draws neutral with its own label. While a
224
+ source loads, fails, or is unavailable, the legend says so (a failure offers "Try again", which
225
+ calls `status.retry()`). Values are shown with the metadata's display precision, except in the
226
+ monitor, cluster, and collocation tooltips, which match monitor-map: the reading and a cluster's
227
+ average are whole numbers, and a cluster's max and min are as reported.
228
+
229
+ `createMonitorsStore()` (from `@sjvair/map-sdk/stores`) loads the monitors list and latest readings
230
+ for an entry type, polls every two minutes, and exposes `dataSource`. `init()`, `update()`,
231
+ `entryTypeFor()`, `pauseAutoUpdate()`, `resumeAutoUpdate()`, and `dispose()` are its controls. A
232
+ dashboard that has its own date-range data builds a `DataSource` itself instead.
233
+ `meta` is the read-only monitors meta (the SDK's `MonitorsMeta`), null until `init()` resolves.
234
+
235
+ `createHmsSmokeStore()` and `createHmsFireStore()` (also from `@sjvair/map-sdk/stores`) load today's
236
+ NOAA HMS smoke and fire once, with no polling. Each has `status`, `init({ signal })`, and
237
+ `dispose()`; overlapping `init()` calls share one request, a failed load reports `error` with
238
+ `retry()` (on `status` and on every source), and a caller's abort rejects only that caller. A day
239
+ with no data is `ready` with an empty collection, so the legend still shows. The smoke store has one
240
+ `dataSource` for `hmsSmokePlugin` (scale `hms-smoke` / `smoke-density`, value the density). The fire
241
+ store has `groups` (nearby detections merged by `groupFires`, value the mean FRP) and `hotspots`
242
+ (each detection after `cleanFires`, value its FRP), for `hmsFirePlugin`'s `source` and `hotspots`
243
+ (scale `hms-fire` / `fire-frp`). Pass `{ messages }` to name the features with your own provider.
244
+
245
+ ```ts
246
+ const smoke = createHmsSmokeStore();
247
+ const fires = createHmsFireStore();
248
+ await Promise.all([smoke.init(), fires.init()]);
249
+ map.addPlugin(hmsSmokePlugin({ source: smoke.dataSource, layerLabel: "Smoke" }));
250
+ map.addPlugin(
251
+ hmsFirePlugin({ source: fires.groups, hotspots: fires.hotspots, layerLabel: "Fires" })
252
+ );
253
+ ```
254
+
255
+ `createCollocationSitesStore({ monitors })` (also from `@sjvair/map-sdk/stores`) loads the
256
+ collocation sites once and joins each to a monitors store: `dataSource` follows that store's
257
+ readings as it polls and uses its scale. A site whose reference or collocated monitor isn't in the
258
+ monitors store (inactive, filtered out, or not loaded yet) still draws at its own position, in the
259
+ neutral color with "No reading". It has `status`, `init({ signal })`, and `dispose()`, like the
260
+ HMS stores; `init()` doesn't load the monitors store.
261
+
262
+ ```ts
263
+ const monitors = createMonitorsStore();
264
+ const collocations = createCollocationSitesStore({ monitors });
265
+ await Promise.all([monitors.init("pm25"), collocations.init()]);
266
+ map.addPlugin(
267
+ collocationSitesPlugin({ source: collocations.dataSource, layerLabel: "Collocation sites" })
268
+ );
269
+ map.addPlugin(monitorsPlugin({ source: monitors.dataSource, layerLabel: "Monitors" }));
270
+ ```
271
+
272
+ ### Plain GeoJSON: `staticSource`
273
+
274
+ ```ts
275
+ import { createMap, shapesPlugin } from "@sjvair/map-sdk";
276
+ import { staticSource } from "@sjvair/map-sdk/data";
277
+ import type { FeatureCollection, Polygon } from "geojson";
278
+
279
+ declare const zones: FeatureCollection<Polygon, { refKey: string; name: string; value: number }>;
280
+
281
+ const map = createMap({ config: { maptilerKey: "…" } });
282
+ map.addPlugin(
283
+ shapesPlugin({
284
+ id: "zones",
285
+ source: staticSource(zones, { dataset: "air-quality", entryType: "pm25" }),
286
+ layerLabel: "Zones",
287
+ mode: "choropleth" // "plain" is neutral, with no legend
288
+ })
289
+ );
290
+ ```
291
+
292
+ Pass `null` as the scale for features with no values. A feature's `refKey` is `monitor:<id>`,
293
+ `region:<type>:<code>` (the region's `external_id`, never its sqid), or `feature:<id>` (shown but
294
+ not selectable). `encodeRef`/`decodeRef` convert between refs and keys.
295
+
296
+ ### Writing a plugin
297
+
298
+ A plugin is an object with an `id` and a `setup(ctx)` that adds sources, layers, and images
299
+ through its `PluginContext` and returns a teardown. The host runs `setup` once the map's style is
300
+ ready and again after a basemap or theme change; everything added through `ctx` is removed on
301
+ teardown, and ids are namespaced per map. `features()` feeds the feature list, tooltip, and
302
+ summary; `legend()` the legend. A plugin's `tooltip` content sits under the feature's name; set
303
+ `tooltipShowsName` when the content shows the name itself (the monitor and collocation tooltips
304
+ do), and the name header is left out.
305
+
306
+ ```ts
307
+ import type { FeatureListItem, MapPlugin, PluginContext } from "@sjvair/map-sdk";
308
+ import { decodeRef, NO_LEVEL } from "@sjvair/map-sdk/data";
309
+ import type { FeatureCollection, Point } from "geojson";
310
+
311
+ type Sites = FeatureCollection<Point, { refKey: string; name: string }>;
312
+
313
+ function sitesPlugin(sites: Sites): MapPlugin {
314
+ return {
315
+ id: "sites",
316
+ label: "Sites",
317
+ setup(ctx: PluginContext) {
318
+ const source = ctx.addSource("sites", { type: "geojson", data: sites });
319
+ const layer = ctx.addLayer("dots", { type: "circle", source, paint: { "circle-radius": 5 } });
320
+ ctx.interactive([layer], (props) => {
321
+ const ref = decodeRef(String(props.refKey));
322
+ const name = String(props.name);
323
+ return ref && { pluginId: "sites", ref, refKey: String(props.refKey), name };
324
+ });
325
+ return () => {};
326
+ },
327
+ features: (): FeatureListItem[] =>
328
+ sites.features.flatMap(({ properties: { refKey, name }, geometry }) => {
329
+ const ref = decodeRef(refKey);
330
+ const [lng, lat] = geometry.coordinates;
331
+ if (!ref) return [];
332
+ const lngLat: [number, number] = [lng, lat];
333
+ return [
334
+ { refKey, ref, name, value: null, level: NO_LEVEL, units: "", lngLat, selectable: false }
335
+ ];
336
+ })
337
+ };
338
+ }
339
+ ```
340
+
341
+ A plugin can also add a MapLibre custom layer, such as a wind layer from a library you add
342
+ yourself, with `ctx.addCustomLayer(local, create)`. The host calls `create` with the namespaced id
343
+ (the layer must use it) and adds the object it returns as is, in the plugin's draw-order slot. It is
344
+ removed on teardown, `create` runs again after a basemap or theme change (return a fresh layer each
345
+ time), and `movePlugin` moves it with the plugin's other layers.
346
+
347
+ The cursor is a pointer over any interactive feature or cluster bubble. Where several plugins'
348
+ features are under the pointer, the hover tooltip shows the one whose plugin has the highest
349
+ `hoverPriority` (default 0), the topmost on a tie. Over a cluster bubble, the bubble's tooltip
350
+ shows unless a feature beneath belongs to a plugin with a higher `hoverPriority` than the
351
+ bubble's (collocation sites under monitor clusters). Clicks always go to the topmost, so a click
352
+ on a bubble still zooms into it.
353
+
354
+ ```ts
355
+ import type { CustomLayerInterface, MapPlugin, PluginContext } from "@sjvair/map-sdk";
356
+
357
+ class WindLayer implements CustomLayerInterface {
358
+ readonly type = "custom";
359
+ constructor(readonly id: string) {}
360
+ onAdd() {} // set up the library's renderer
361
+ render() {} // draw one frame
362
+ onRemove() {} // release it
363
+ }
364
+
365
+ const windPlugin: MapPlugin = {
366
+ id: "wind",
367
+ label: "Wind",
368
+ setup(ctx: PluginContext) {
369
+ ctx.addCustomLayer("particles", (id) => new WindLayer(id));
370
+ return () => {};
371
+ }
372
+ };
373
+ ```
374
+
375
+ The root entry exports the types a plugin needs (`MapPlugin`, `PluginContext`, `FeatureHit`,
376
+ `FeatureListItem`, `LegendData`, `Providers`, `GlMapLike`, `CustomLayerInterface`,
377
+ `LayerSpecWithoutId`, `ThemeColors`, `ClusterLayers`, `ClusterLayerArgs`, `BlockingClick`,
378
+ `Hovered`) and the scale expressions the built-in plugins use (`colorExpression`,
379
+ `opacityExpression`, `levelKeyExpression`).
380
+
381
+ ## Selection
382
+
383
+ Each plugin has a mode: `none`, `single`, or `multiple` (set it with `addPlugin`'s
384
+ `selectionMode` or `map.setSelectionMode(id, mode)`). Click or tap picks; Shift/Cmd/Ctrl-click adds
385
+ or removes in `multiple`, and a tap does the same on touch. The selection is a `SpatialSelection`,
386
+ `{ kind: "monitors" | "regions", ids: string[] }` (region ids are `<type>:<code>`), and the host
387
+ owns it:
388
+
389
+ ```ts
390
+ map.setSelection("monitors", { kind: "monitors", ids: ["abc123"] }); // host -> map
391
+ map.addPlugin(plugin, {
392
+ selectionMode: "multiple",
393
+ selection: { kind: "monitors", ids: ["abc123"] },
394
+ onselectionchange: (selection) => save(selection) // map -> host (user picks only)
395
+ });
396
+ ```
397
+
398
+ `setSelection` does not call `onselectionchange`. Ids that match no feature are kept and counted
399
+ in the selection bar, and a feature that appears later shows as selected. `map.getSelection(id)`
400
+ and `getSelectionMode(id)` are reactive.
401
+
402
+ `map.on("featureclick", ({ pluginId, ref, lngLat }) => …)` fires for every click on a feature,
403
+ whatever the mode, and also for a pick in the feature list, so keyboard users get the same event;
404
+ `lngLat` is the click point (the item's position for a list pick). It returns an unsubscribe
405
+ function. For your own context menu, `map.featuresAt({ x, y })` returns the hits under
406
+ a canvas point, topmost first, and `map.featureForElement(element)` returns the hit for a row of
407
+ the feature list (an element with `data-mapsdk-ref`). Both return nothing while the map is
408
+ detached.
409
+
410
+ ## Camera
411
+
412
+ `map.easeTo({ center, zoom?, minZoom? })` and `map.flyTo(...)` move the camera; `minZoom` means
413
+ "zoom in to at least this" (`max(current zoom, minZoom)`), and `zoom` wins when both are given.
414
+ `map.fitBounds({ west, south, east, north }, { padding?, maxZoom? })` fits a box. All three do
415
+ nothing while the map is detached or destroyed, and use no animation under `prefers-reduced-motion`.
416
+
417
+ ```ts
418
+ map.flyTo({ center: [-119.7, 36.7], zoom: 16 });
419
+ map.easeTo({ center: monitor.lngLat, minZoom: 12 });
420
+ ```
421
+
422
+ ## Ready, and reaching the GL map
423
+
424
+ `map.ready` is reactive: true once the attached map's style has loaded and the map has gone idle
425
+ for the first time, false again on detach and while a basemap switch reloads the style. Show a load
426
+ screen until it is true. `map.gl` is the attached GL map (or `null`), reactive.
427
+
428
+ `maptilerMap(map)` returns it as a MapTiler `Map` (or `null` while detached). It is an escape hatch
429
+ for markers and popups only: never add sources or layers through it, because plugins own those.
430
+
431
+ ```ts
432
+ import { maptilerMap } from "@sjvair/map-sdk";
433
+
434
+ const gl = maptilerMap(map);
435
+ if (gl) new Marker().setLngLat(lngLat).addTo(gl);
436
+ ```
437
+
438
+ ## Providers
439
+
440
+ `createMap` takes three optional providers, so a host controls where its labels and metadata come
441
+ from:
442
+
443
+ - `metadata`: scales, entry-type labels and units, monitor type labels, and dataset attribution.
444
+ The default reads the SJVAir API through `@sjvair/sdk`. `monitorTypeInterval(type)` is an
445
+ optional method returning how often a monitor type reports (e.g. `"2 min"`); the monitor
446
+ tooltip shows "(2 min avg)" under the entry-type label when it exists, and omits the note when a
447
+ host's provider lacks it.
448
+ - `messages`: `(key, params) => string`. The default is the English catalog,
449
+ `@sjvair/map-sdk/messages/en.json`, with `{name}`-style parameters and `_one`/`_other` plurals
450
+ selected by `count`.
451
+ - `format`: numbers, date-times in America/Los_Angeles, a "PT" zone label when the device is
452
+ elsewhere, and `relativeTime(date, now?)` ("5 minutes ago").
453
+
454
+ Every UI string is a `mapsdk_*` key. To translate them in a Paraglide project, add
455
+ `node_modules/@sjvair/map-sdk/dist/data/messages/en.json` (exported as
456
+ `@sjvair/map-sdk/messages/en.json`) to the project's message sources, translate the keys, and
457
+ adapt the generated functions:
458
+
459
+ ```ts
460
+ import * as m from "$lib/paraglide/messages.js";
461
+ import type { MessageKey, MessageParams } from "@sjvair/map-sdk/data";
462
+
463
+ const messages = (key: MessageKey, params?: MessageParams) =>
464
+ (m as unknown as Record<string, (p?: MessageParams) => string>)[key]?.(params) ?? key;
465
+
466
+ const map = createMap({ config: { maptilerKey: "…" }, messages });
467
+ ```
468
+
469
+ A key that is a plural base (for example `mapsdk_selected_count`) arrives with `params.count`;
470
+ the catalog stores it as `<key>_one` and `<key>_other`, so a provider over a different catalog
471
+ maps it to its own plural form. Layer names (`layerLabel`) are passed already translated.
472
+
473
+ ## Examples are checked
474
+
475
+ `tests/unit/readme-examples.test.ts` repeats the TypeScript examples above (the map setup, plain
476
+ GeoJSON, writing a plugin, selection, and a message provider) against the real exports: `npm run
477
+ check` type-checks them and `npm test` runs them. The Svelte markup isn't mounted there. Update
478
+ it with the README.
479
+
480
+ ## Demo and the local stack
481
+
482
+ `npm run dev` serves the demo page (`demo/`): two maps on one page, one fed from date-range
483
+ sources like the dashboard and one from the live monitors store, with the presets, selection
484
+ modes, and theme toggles. It calls the API on its own origin, and Vite proxies `/api/` to the
485
+ local sjvair.com stack at `http://localhost:8000`.
486
+
487
+ 1. Copy `.env.example` to `.env` and set `VITE_MAPTILER_KEY`. Never commit `.env`.
488
+ 2. Start the sjvair.com stack on port 8000.
489
+ 3. A fresh database has no calibrations; load them once:
490
+
491
+ ```bash
492
+ podman exec sjvaircom_web_1 python manage.py loaddata default-calibrations.yaml
493
+ ```
494
+
495
+ ## Development
496
+
497
+ ```bash
498
+ npm run lint # Prettier check + ESLint
499
+ npm run check # svelte-check
500
+ npm test # unit, client, and browser (Vitest) projects
501
+ npm run test:bundle # build, then check the light entries stay off the map engine
502
+ npm run test:licenses # build, then check bundled dependencies' licenses
503
+ npm run test:e2e # Playwright on the demo (Chromium, Firefox, WebKit)
504
+ npm run build # svelte-package into dist/
505
+ ```
506
+
507
+ See `CLAUDE.md` for the architecture.
@@ -0,0 +1,38 @@
1
+ import type { GlMapLike } from "./gl.js";
2
+ export interface BasemapConfig {
3
+ styles: Array<{
4
+ id: string;
5
+ style: unknown;
6
+ }>;
7
+ defaultId: string;
8
+ excludedLayers?: string[];
9
+ }
10
+ /**
11
+ * The map's current basemap, and switching it on a GL map.
12
+ *
13
+ * The GL map is created with the style of `current` (the map's creator does that), so `attach`
14
+ * calls `setStyle` for nothing. Later `select` calls load the new style. A newer `select`
15
+ * supersedes one still loading. If the style document fails, the map goes back to the last style
16
+ * that loaded and `current` reverts; if that fails too, the map stays unstyled until the next
17
+ * `select`. Right before each `setStyle`, `onStyleChange` is called (it tears the plugins down,
18
+ * so nothing touches the old style while the new one loads). After each successful load,
19
+ * `excludedLayers` are removed and `onStyleReady` is called (it sets the plugins up again). The
20
+ * map's first style is not announced: the plugin host already waits for it.
21
+ */
22
+ export declare class BasemapState {
23
+ #private;
24
+ constructor(config: BasemapConfig | undefined);
25
+ /** The selected basemap id. Reactive. */
26
+ get current(): string;
27
+ /** Unknown ids are ignored. While no map is attached, only `current` changes. */
28
+ select(id: string): void;
29
+ /**
30
+ * For MapView only (through the map context's `attach`): uses `gl` from now on, which must
31
+ * already show (or be loading) the style of `current`. `onStyleChange` runs right before each
32
+ * `setStyle`, `onStyleReady` once that style has loaded. Reads `current` untracked, so calling
33
+ * this in an effect doesn't make it depend on it.
34
+ */
35
+ attach(gl: GlMapLike, onStyleReady: () => void, onStyleChange?: () => void): void;
36
+ /** For MapView only (through the map context's `detach`): stops using the GL map. */
37
+ detach(): void;
38
+ }