@mailwoman/react 7.4.0 → 7.6.0

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 (156) hide show
  1. package/common/PresetChips.tsx +4 -1
  2. package/index.ts +31 -0
  3. package/map/BackendControl.tsx +39 -0
  4. package/map/CompareToggle.tsx +81 -0
  5. package/map/DemoControls.tsx +173 -0
  6. package/map/DemoMap.tsx +98 -0
  7. package/map/GeocoderDemo.tsx +178 -0
  8. package/map/OverlayLayers.tsx +46 -0
  9. package/map/PlaceAutocomplete.tsx +69 -0
  10. package/map/PlaceMarker.tsx +40 -0
  11. package/map/ResolvedPlaceLayers.tsx +63 -0
  12. package/map/ResultCamera.tsx +60 -0
  13. package/map/ResultOverlay.tsx +61 -0
  14. package/map/ResultPanel.tsx +84 -0
  15. package/map/VersionPicker.tsx +62 -0
  16. package/map/geometry.ts +135 -0
  17. package/map/index.ts +73 -0
  18. package/map/place-render.ts +157 -0
  19. package/map/types.ts +207 -0
  20. package/map/useCompareState.ts +45 -0
  21. package/map/useDemoGeocode.ts +67 -0
  22. package/map/useMapPlaceRender.ts +21 -0
  23. package/map/usePlaceAutocomplete.ts +195 -0
  24. package/out/common/PresetChips.d.ts +3 -1
  25. package/out/common/PresetChips.d.ts.map +1 -1
  26. package/out/common/PresetChips.js +2 -2
  27. package/out/common/PresetChips.js.map +1 -1
  28. package/out/index.d.ts +4 -1
  29. package/out/index.d.ts.map +1 -1
  30. package/out/index.js +2 -0
  31. package/out/index.js.map +1 -1
  32. package/out/map/BackendControl.d.ts +24 -0
  33. package/out/map/BackendControl.d.ts.map +1 -0
  34. package/out/map/BackendControl.js +6 -0
  35. package/out/map/BackendControl.js.map +1 -0
  36. package/out/map/CompareToggle.d.ts +35 -0
  37. package/out/map/CompareToggle.d.ts.map +1 -0
  38. package/out/map/CompareToggle.js +9 -0
  39. package/out/map/CompareToggle.js.map +1 -0
  40. package/out/map/DemoControls.d.ts +44 -0
  41. package/out/map/DemoControls.d.ts.map +1 -0
  42. package/out/map/DemoControls.js +28 -0
  43. package/out/map/DemoControls.js.map +1 -0
  44. package/out/map/DemoMap.d.ts +59 -0
  45. package/out/map/DemoMap.d.ts.map +1 -0
  46. package/out/map/DemoMap.js +12 -0
  47. package/out/map/DemoMap.js.map +1 -0
  48. package/out/map/GeocoderDemo.d.ts +47 -0
  49. package/out/map/GeocoderDemo.d.ts.map +1 -0
  50. package/out/map/GeocoderDemo.js +73 -0
  51. package/out/map/GeocoderDemo.js.map +1 -0
  52. package/out/map/OverlayLayers.d.ts +23 -0
  53. package/out/map/OverlayLayers.d.ts.map +1 -0
  54. package/out/map/OverlayLayers.js +27 -0
  55. package/out/map/OverlayLayers.js.map +1 -0
  56. package/out/map/PlaceAutocomplete.d.ts +34 -0
  57. package/out/map/PlaceAutocomplete.d.ts.map +1 -0
  58. package/out/map/PlaceAutocomplete.js +9 -0
  59. package/out/map/PlaceAutocomplete.js.map +1 -0
  60. package/out/map/PlaceMarker.d.ts +27 -0
  61. package/out/map/PlaceMarker.d.ts.map +1 -0
  62. package/out/map/PlaceMarker.js +9 -0
  63. package/out/map/PlaceMarker.js.map +1 -0
  64. package/out/map/ResolvedPlaceLayers.d.ts +34 -0
  65. package/out/map/ResolvedPlaceLayers.d.ts.map +1 -0
  66. package/out/map/ResolvedPlaceLayers.js +11 -0
  67. package/out/map/ResolvedPlaceLayers.js.map +1 -0
  68. package/out/map/ResultCamera.d.ts +34 -0
  69. package/out/map/ResultCamera.d.ts.map +1 -0
  70. package/out/map/ResultCamera.js +43 -0
  71. package/out/map/ResultCamera.js.map +1 -0
  72. package/out/map/ResultOverlay.d.ts +34 -0
  73. package/out/map/ResultOverlay.d.ts.map +1 -0
  74. package/out/map/ResultOverlay.js +18 -0
  75. package/out/map/ResultOverlay.js.map +1 -0
  76. package/out/map/ResultPanel.d.ts +34 -0
  77. package/out/map/ResultPanel.d.ts.map +1 -0
  78. package/out/map/ResultPanel.js +12 -0
  79. package/out/map/ResultPanel.js.map +1 -0
  80. package/out/map/VersionPicker.d.ts +30 -0
  81. package/out/map/VersionPicker.d.ts.map +1 -0
  82. package/out/map/VersionPicker.js +8 -0
  83. package/out/map/VersionPicker.js.map +1 -0
  84. package/out/map/geometry.d.ts +55 -0
  85. package/out/map/geometry.d.ts.map +1 -0
  86. package/out/map/geometry.js +99 -0
  87. package/out/map/geometry.js.map +1 -0
  88. package/out/map/index.d.ts +52 -0
  89. package/out/map/index.d.ts.map +1 -0
  90. package/out/map/index.js +36 -0
  91. package/out/map/index.js.map +1 -0
  92. package/out/map/place-render.d.ts +85 -0
  93. package/out/map/place-render.d.ts.map +1 -0
  94. package/out/map/place-render.js +98 -0
  95. package/out/map/place-render.js.map +1 -0
  96. package/out/map/types.d.ts +193 -0
  97. package/out/map/types.d.ts.map +1 -0
  98. package/out/map/types.js +18 -0
  99. package/out/map/types.js.map +1 -0
  100. package/out/map/useCompareState.d.ts +25 -0
  101. package/out/map/useCompareState.d.ts.map +1 -0
  102. package/out/map/useCompareState.js +27 -0
  103. package/out/map/useCompareState.js.map +1 -0
  104. package/out/map/useDemoGeocode.d.ts +36 -0
  105. package/out/map/useDemoGeocode.d.ts.map +1 -0
  106. package/out/map/useDemoGeocode.js +44 -0
  107. package/out/map/useDemoGeocode.js.map +1 -0
  108. package/out/map/useMapPlaceRender.d.ts +15 -0
  109. package/out/map/useMapPlaceRender.d.ts.map +1 -0
  110. package/out/map/useMapPlaceRender.js +18 -0
  111. package/out/map/useMapPlaceRender.js.map +1 -0
  112. package/out/map/usePlaceAutocomplete.d.ts +61 -0
  113. package/out/map/usePlaceAutocomplete.d.ts.map +1 -0
  114. package/out/map/usePlaceAutocomplete.js +122 -0
  115. package/out/map/usePlaceAutocomplete.js.map +1 -0
  116. package/out/pipeline/QueryForm.d.ts +6 -2
  117. package/out/pipeline/QueryForm.d.ts.map +1 -1
  118. package/out/pipeline/QueryForm.js +2 -2
  119. package/out/pipeline/QueryForm.js.map +1 -1
  120. package/out/poi/LiveResultsBlock.d.ts +3 -3
  121. package/out/poi/LiveResultsBlock.d.ts.map +1 -1
  122. package/out/poi/LiveResultsBlock.js +2 -2
  123. package/out/poi/LiveResultsBlock.js.map +1 -1
  124. package/out/poi/POIExplorer.d.ts +7 -1
  125. package/out/poi/POIExplorer.d.ts.map +1 -1
  126. package/out/poi/POIExplorer.js +17 -5
  127. package/out/poi/POIExplorer.js.map +1 -1
  128. package/out/poi/SubjectPanel.d.ts +3 -2
  129. package/out/poi/SubjectPanel.d.ts.map +1 -1
  130. package/out/poi/SubjectPanel.js +8 -1
  131. package/out/poi/SubjectPanel.js.map +1 -1
  132. package/out/poi/runtime.d.ts +13 -4
  133. package/out/poi/runtime.d.ts.map +1 -1
  134. package/out/poi/runtime.js +36 -12
  135. package/out/poi/runtime.js.map +1 -1
  136. package/out/poi/types.d.ts +31 -4
  137. package/out/poi/types.d.ts.map +1 -1
  138. package/out/poi/usePOISearch.d.ts +8 -1
  139. package/out/poi/usePOISearch.d.ts.map +1 -1
  140. package/out/poi/usePOISearch.js +47 -12
  141. package/out/poi/usePOISearch.js.map +1 -1
  142. package/out/runtime/useDemoRuntime.d.ts +113 -0
  143. package/out/runtime/useDemoRuntime.d.ts.map +1 -0
  144. package/out/runtime/useDemoRuntime.js +135 -0
  145. package/out/runtime/useDemoRuntime.js.map +1 -0
  146. package/package.json +31 -10
  147. package/pipeline/QueryForm.tsx +17 -2
  148. package/poi/LiveResultsBlock.tsx +4 -4
  149. package/poi/POIExplorer.tsx +39 -8
  150. package/poi/SubjectPanel.tsx +47 -12
  151. package/poi/runtime.ts +35 -10
  152. package/poi/types.ts +34 -4
  153. package/poi/usePOISearch.ts +62 -11
  154. package/runtime/useDemoRuntime.ts +245 -0
  155. package/styles.css +166 -0
  156. package/vitest.node.config.ts +20 -0
package/poi/types.ts CHANGED
@@ -23,18 +23,39 @@ export interface POIRuntime {
23
23
  classify: ReturnType<typeof createKindClassifier>
24
24
  }
25
25
 
26
- /** A resolved POI subject — the category the query names plus its match metadata. */
27
- export interface POISubject {
28
- category: CategoryRecord
26
+ /** Fields shared by every resolved POI subject, category or brand. */
27
+ export interface POISubjectBase {
29
28
  matchedPhrase: string
30
29
  confidence: number
31
30
  /** The non-subject remainder of the query (the location anchor, e.g. "near Springfield"). */
32
31
  remainder: string
32
+ }
33
+
34
+ /** A resolved POI subject that names a taxonomy CATEGORY (`cafe`, `hospital`, `drinking fountain`). */
35
+ export interface POICategorySubject extends POISubjectBase {
36
+ kind: "category"
37
+ category: CategoryRecord
33
38
  /** Whether this category needs the locally-built OSM (ODbL) layer — precomputed off the runtime. */
34
39
  buildLocal: boolean
35
40
  }
36
41
 
37
- /** The intent-only result: the kind verdict plus (when a subject was detected) its OverpassQL export. */
42
+ /**
43
+ * A resolved POI subject that names a chain BRAND (`chevron`, `applebee's`). Brands carry a Wikidata QID and are
44
+ * searched by that QID, NOT by category k-ring — see `@mailwoman/poi-taxonomy`'s brand table + the layer's
45
+ * `brand_wikidata` index.
46
+ */
47
+ export interface POIBrandSubject extends POISubjectBase {
48
+ kind: "brand"
49
+ /** The brand's canonical display name. */
50
+ name: string
51
+ /** Wikidata QID, when the lexicon carried one (`Q319642` = Chevron). Absent ⇒ matched by name alone. */
52
+ wikidata?: string
53
+ }
54
+
55
+ /** A resolved POI subject — a taxonomy category or a chain brand — plus its match metadata. */
56
+ export type POISubject = POICategorySubject | POIBrandSubject
57
+
58
+ /** The intent-only result: the kind verdict plus (when a subject was detected) its OverpassQL export (category only). */
38
59
  export interface POIExplorerResult {
39
60
  kindResult: QueryKindResult
40
61
  subject?: POISubject
@@ -64,11 +85,20 @@ export type POILiveSearchResult =
64
85
  /**
65
86
  * The injected live-search function. Given the resolved category (+ its Overture leaf fan-out) and the anchor text, it
66
87
  * probes the published poi.db and returns hits. Absent ⇒ the explorer runs intent-only (no "Search live" button).
88
+ *
89
+ * Brand support is ADDITIVE: when the resolved subject is a chain brand, `brandWikidata` carries its QID and the probe
90
+ * fetches by that QID instead of a category k-ring (`categoryID`/`overtureCategoryIDs` are then the brand name / empty
91
+ * and unused). The category path is byte-identical to before. A probe that can't serve brands simply leaves brand live
92
+ * search unwired at the call site (see `usePOISearch`'s `brandLiveSearch` option) — the docs' httpvfs probe does
93
+ * exactly that, brand-wide row hydration being pathological over byte-range (measured; the brand path is server-side
94
+ * only).
67
95
  */
68
96
  export type POILiveSearch = (params: {
69
97
  categoryID: string
70
98
  overtureCategoryIDs: string[]
71
99
  anchor: string
100
+ /** Present when the subject is a chain brand — the probe fetches by this QID, not a category k-ring. */
101
+ brandWikidata?: string
72
102
  }) => Promise<POILiveSearchResult>
73
103
 
74
104
  /** "Search live" state machine. */
@@ -26,6 +26,13 @@ export interface UsePOISearchOptions {
26
26
  loadRuntime?: LoadPOIRuntime
27
27
  /** Injected live-search probe. Absent ⇒ the live-results affordance is disabled. */
28
28
  runLiveSearch?: POILiveSearch
29
+ /**
30
+ * Whether the injected probe can serve BRAND subjects (fetch by Wikidata QID). Default false: brand subjects show the
31
+ * intent + QID chip but no live-search affordance. The docs' httpvfs probe leaves this off — brand-wide row hydration
32
+ * is pathological over byte-range (measured) — so brand live search is a server-side-backend capability. Category
33
+ * live search is unaffected either way.
34
+ */
35
+ brandLiveSearch?: boolean
29
36
  /** Debounce before (re)classifying. @default 250 */
30
37
  debounceMs?: number
31
38
  }
@@ -72,6 +79,7 @@ export function usePOISearch({
72
79
  text,
73
80
  loadRuntime = loadPOIRuntime,
74
81
  runLiveSearch,
82
+ brandLiveSearch = false,
75
83
  debounceMs = 250,
76
84
  }: UsePOISearchOptions): UsePOISearch {
77
85
  const [runtime, setRuntime] = useState<POIRuntime | null>(null)
@@ -123,9 +131,34 @@ export function usePOISearch({
123
131
  if (cancelled) return
124
132
 
125
133
  const matched = kindResult.kind === "poi_query" ? matchPOISubject(trimmed, undefined, runtime.lexicon) : null
126
- const category = matched ? runtime.lookup.getPOICategory(matched.match.categoryID) : undefined
127
134
 
128
- if (!matched || !category) {
135
+ if (!matched) {
136
+ setResult({ kindResult })
137
+
138
+ return
139
+ }
140
+
141
+ // Brand subject: the lexicon carries the brand's canonical name as `categoryID` + its Wikidata QID. No category
142
+ // record, no OverpassQL (brands are searched by QID against the layer's `brand_wikidata` index, not OSM tags).
143
+ if ((matched.match.kind ?? "category") === "brand") {
144
+ setResult({
145
+ kindResult,
146
+ subject: {
147
+ kind: "brand",
148
+ name: matched.match.categoryID,
149
+ ...(matched.match.wikidata ? { wikidata: matched.match.wikidata } : {}),
150
+ matchedPhrase: matched.match.matchedPhrase,
151
+ confidence: matched.match.confidence,
152
+ remainder: matched.remainder,
153
+ },
154
+ })
155
+
156
+ return
157
+ }
158
+
159
+ const category = runtime.lookup.getPOICategory(matched.match.categoryID)
160
+
161
+ if (!category) {
129
162
  setResult({ kindResult })
130
163
 
131
164
  return
@@ -134,6 +167,7 @@ export function usePOISearch({
134
167
  setResult({
135
168
  kindResult,
136
169
  subject: {
170
+ kind: "category",
137
171
  category,
138
172
  matchedPhrase: matched.match.matchedPhrase,
139
173
  confidence: matched.match.confidence,
@@ -150,22 +184,39 @@ export function usePOISearch({
150
184
  }, [debouncedText, runtime])
151
185
 
152
186
  const subject = result?.subject
187
+ // A subject is live-searchable when a probe is wired, it has an anchor, and: a CATEGORY that isn't build-local, or a
188
+ // BRAND with a QID AND a brand-capable probe (`brandLiveSearch`). Brands without a QID / without a brand probe show
189
+ // the intent + QID chip but no live affordance.
190
+ const subjectLiveCapable =
191
+ subject !== undefined &&
192
+ (subject.kind === "brand" ? brandLiveSearch && subject.wikidata !== undefined : !subject.buildLocal)
153
193
  const canSearchLive = Boolean(
154
- runLiveSearch && runtime && subject && !subject.buildLocal && subject.remainder.trim().length > 0
194
+ runLiveSearch && runtime && subject && subjectLiveCapable && subject.remainder.trim().length > 0
155
195
  )
156
196
 
157
197
  const searchLive = useCallback(async () => {
158
- if (!runLiveSearch || !runtime || !subject || subject.buildLocal || !subject.remainder.trim()) return
198
+ if (!runLiveSearch || !runtime || !subject || !subject.remainder.trim()) return
199
+
200
+ if (subject.kind === "category" ? subject.buildLocal : !(brandLiveSearch && subject.wikidata)) return
159
201
 
160
202
  setLiveSearch({ status: "loading" })
161
203
 
162
204
  try {
163
- const outcome = await runLiveSearch({
164
- categoryID: subject.category.id,
165
- // Fan the canonical seed id out over its Overture leaves — the same translation the Node reader uses.
166
- overtureCategoryIDs: runtime.lookup.resolveOvertureCategories(subject.category.id),
167
- anchor: subject.remainder,
168
- })
205
+ const outcome = await runLiveSearch(
206
+ subject.kind === "brand"
207
+ ? {
208
+ categoryID: subject.name,
209
+ overtureCategoryIDs: [],
210
+ anchor: subject.remainder,
211
+ brandWikidata: subject.wikidata,
212
+ }
213
+ : {
214
+ categoryID: subject.category.id,
215
+ // Fan the canonical seed id out over its Overture leaves — the same translation the Node reader uses.
216
+ overtureCategoryIDs: runtime.lookup.resolveOvertureCategories(subject.category.id),
217
+ anchor: subject.remainder,
218
+ }
219
+ )
169
220
 
170
221
  if (outcome.status === "success") {
171
222
  setLiveSearch({ status: "success", hits: outcome.hits, centerName: outcome.centerName })
@@ -177,7 +228,7 @@ export function usePOISearch({
177
228
  } catch {
178
229
  setLiveSearch({ status: "error", message: "the published POI layer isn't reachable" })
179
230
  }
180
- }, [runLiveSearch, runtime, subject])
231
+ }, [runLiveSearch, runtime, subject, brandLiveSearch])
181
232
 
182
233
  return { runtimeReady: runtime !== null, result, liveSearch, canSearchLive, searchLive }
183
234
  }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `useDemoRuntime` — the headless load-orchestration hook shared by every mailwoman demo surface (the
7
+ * inline doc-embeds via `DemoEmbed`, and the geocoder map page). It owns the version-selection state
8
+ * machine, the per-version load SEQUENCING, cancellation, and the ready / loading / error state — but
9
+ * NOTHING model- or map-specific. The actual asset fetchers (the ONNX classifier factory, the httpvfs
10
+ * WOF opener, the FST fetch, the releases.json fetch) are INJECTED by the host as async functions, so
11
+ * this module imports only React: no `onnxruntime-web`, no `sql.js-httpvfs`, no `maplibre-gl`, no
12
+ * `fetch`-specific plumbing. That keeps it node-import-safe and root-exportable from
13
+ * `@mailwoman/react` — the exact seam `PipelineRuntime` established, generalized to the loader itself.
14
+ *
15
+ * The hook is generic over `TAssets` (the opaque bundle the host's `loadAssets` returns — classifier,
16
+ * FST, WOF lookup, calibrator, …) and `TRelease` (the host's release-manifest entry). The package
17
+ * never inspects either; it just holds, reveals, and re-loads them across version/backend switches.
18
+ */
19
+
20
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
21
+
22
+ /** The minimal contract a release-manifest entry must satisfy. Hosts extend this with their own fields. */
23
+ export interface DemoReleaseBase {
24
+ /** The version tag this entry describes (matched against `selectedVersion`). */
25
+ version: string
26
+ /** Optional display label a version picker shows; falls back to `version`. */
27
+ label?: string
28
+ }
29
+
30
+ /** The releases manifest the host fetches — the default version plus the selectable release entries. */
31
+ export interface DemoManifest<TRelease extends DemoReleaseBase = DemoReleaseBase> {
32
+ /** The version selected on first load (before the user picks another). */
33
+ defaultVersion: string
34
+ /** Every selectable release. */
35
+ releases: TRelease[]
36
+ }
37
+
38
+ /**
39
+ * The progress channel handed to the host's `loadAssets`. The host reports load progress + the resolved backend + the
40
+ * staged step labels/index THROUGH these setters (all no-op once the load is superseded/aborted), while the hook owns
41
+ * the terminal state (revealing the assets + clearing progress on success, surfacing the error on failure).
42
+ */
43
+ export interface DemoAssetsLoadContext {
44
+ /** Aborts when this load is superseded (version/backend switch) or the provider unmounts. */
45
+ signal: AbortSignal
46
+ /** Whether the host should force the CPU/WASM backend (opt out of WebGPU) for this load. */
47
+ forceWASM: boolean
48
+ /** Set the human-readable progress line (e.g. `Loading v7 model (~28 MB)…`). */
49
+ setProgress: (progress: string) => void
50
+ /** Set the staged-loader step labels (e.g. `["Loading classifier", "Loading FST gazetteer"]`). */
51
+ setStepLabels: (labels: string[]) => void
52
+ /** Advance the staged-loader step index (0-based). */
53
+ setStepIndex: (index: number) => void
54
+ /** Report the backend the neural runtime resolved to (e.g. `webgpu (27 MB int8)`). */
55
+ setBackend: (backend: string) => void
56
+ }
57
+
58
+ /** The injected loaders the hook orchestrates. Nothing here is model- or map-aware — the host owns all that. */
59
+ export interface DemoRuntimeConfig<TAssets, TRelease extends DemoReleaseBase = DemoReleaseBase> {
60
+ /**
61
+ * Fetch + normalize the releases manifest. Returns `null` when no manifest is available (the demo then shows nothing
62
+ * selectable). Rejecting surfaces `errorMessage`. Runs once on mount.
63
+ */
64
+ loadManifest: (signal: AbortSignal) => Promise<DemoManifest<TRelease> | null>
65
+ /**
66
+ * Load the full asset bundle for one release — the classifier, FST, WOF lookup, calibrator, whatever the host needs.
67
+ * Runs on every version or `forceWASM` change. Report progress via `ctx`; return the bundle. Rejecting surfaces
68
+ * `errorMessage`. Bail early when `ctx.signal.aborted` — the hook discards a superseded result regardless.
69
+ */
70
+ loadAssets: (release: TRelease, ctx: DemoAssetsLoadContext) => Promise<TAssets>
71
+ /** The progress line shown before the manifest arrives. @default "Loading releases…" */
72
+ initialProgress?: string
73
+ }
74
+
75
+ /**
76
+ * The state `useDemoRuntime` produces — the load-orchestration state a demo surface renders + re-projects.
77
+ *
78
+ * NAMING (phase 4): this is the LOADER STATE, deliberately distinct from {@link DemoRuntime} (the injected runtime
79
+ * contract `<GeocoderDemo>` consumes). The two were same-stem before (`UseDemoRuntime` vs `DemoRuntime`); phase 4
80
+ * disambiguates by renaming the loader-state type to `DemoLoaderState`. A host builds a {@link DemoRuntime} by pairing
81
+ * this loader state (assets + backend + version) with the map surface (style, overlays, bias, parse) — see the map
82
+ * subpath's `DemoRuntime`.
83
+ */
84
+ export interface DemoLoaderState<TAssets, TRelease extends DemoReleaseBase = DemoReleaseBase> {
85
+ /** The releases manifest, once fetched. */
86
+ manifest: DemoManifest<TRelease> | null
87
+ /** The currently-selected version, or `null` before the manifest resolves. */
88
+ selectedVersion: string | null
89
+ /** The release entry matching `selectedVersion` (convenience over `manifest.releases.find`). */
90
+ selectedRelease: TRelease | null
91
+ /** The loaded asset bundle for the selected version, or `null` while (re)loading. */
92
+ assets: TAssets | null
93
+ /** Whether the asset bundle is loaded and ready to use. */
94
+ ready: boolean
95
+ /** Human-readable load progress (`""` when idle/ready). */
96
+ loadingProgress: string
97
+ /** Staged-loader step index (0-based; `-1` before the first step). */
98
+ loadingStepIndex: number
99
+ /** Staged-loader step labels. */
100
+ loadingStepLabels: string[]
101
+ /** A load error (manifest or asset), distinct from any per-parse error a consumer tracks separately. */
102
+ errorMessage: string | null
103
+ /** The backend the neural runtime resolved to (e.g. `webgpu (27 MB int8)`), or `""` before it's known. */
104
+ activeBackend: string
105
+ /** Whether the CPU/WASM backend is currently forced. */
106
+ forceWASM: boolean
107
+ /** Switch to a different version (clears any error, then reloads the asset bundle). */
108
+ selectVersion: (version: string) => void
109
+ /** Force (or unforce) the CPU/WASM backend — reloads the asset bundle. */
110
+ setForceWASM: (forceWASM: boolean) => void
111
+ }
112
+
113
+ /**
114
+ * Drive the shared version → asset-bundle load state machine over a host-injected loader.
115
+ *
116
+ * Sequence: on mount `loadManifest` runs and its `defaultVersion` becomes the selection; each version (or `forceWASM`)
117
+ * change reloads the bundle via `loadAssets`, the previous load aborted first. The assets are revealed ATOMICALLY when
118
+ * `loadAssets` resolves (so `ready` flips exactly once per load), and consumers gate on `ready`.
119
+ */
120
+ export function useDemoRuntime<TAssets, TRelease extends DemoReleaseBase = DemoReleaseBase>(
121
+ config: DemoRuntimeConfig<TAssets, TRelease>
122
+ ): DemoLoaderState<TAssets, TRelease> {
123
+ const { initialProgress = "Loading releases…" } = config
124
+
125
+ const [manifest, setManifest] = useState<DemoManifest<TRelease> | null>(null)
126
+ const [selectedVersion, setSelectedVersion] = useState<string | null>(null)
127
+ const [assets, setAssets] = useState<TAssets | null>(null)
128
+ const [loadingProgress, setLoadingProgress] = useState<string>(initialProgress)
129
+ const [loadingStepIndex, setLoadingStepIndex] = useState(-1)
130
+ const [loadingStepLabels, setLoadingStepLabels] = useState<string[]>([])
131
+ const [errorMessage, setErrorMessage] = useState<string | null>(null)
132
+ const [activeBackend, setActiveBackend] = useState<string>("")
133
+ const [forceWASM, setForceWASMState] = useState(false)
134
+
135
+ // Latest-ref the injected loaders: a host that re-creates them each render (an inline arrow) must NOT retrigger the
136
+ // load effects, which key ONLY on version/backend. The effects read `.current` at run time.
137
+ const loadManifestRef = useRef(config.loadManifest)
138
+ loadManifestRef.current = config.loadManifest
139
+ const loadAssetsRef = useRef(config.loadAssets)
140
+ loadAssetsRef.current = config.loadAssets
141
+
142
+ // Latest manifest for the version-load effect, so it can resolve the release WITHOUT depending on `manifest`
143
+ // identity — which would double-fire the load the instant the manifest first arrives (the selection transition
144
+ // null → defaultVersion already fires it once).
145
+ const manifestRef = useRef<DemoManifest<TRelease> | null>(null)
146
+ manifestRef.current = manifest
147
+
148
+ // Mount: fetch the manifest, then select the default version.
149
+ useEffect(() => {
150
+ const controller = new AbortController()
151
+
152
+ void (async () => {
153
+ try {
154
+ const data = await loadManifestRef.current(controller.signal)
155
+
156
+ if (controller.signal.aborted) return
157
+
158
+ if (data) {
159
+ setManifest(data)
160
+ setSelectedVersion(data.defaultVersion)
161
+ }
162
+ } catch (error) {
163
+ if (controller.signal.aborted) return
164
+ setErrorMessage(error instanceof Error ? error.message : String(error))
165
+ }
166
+ })()
167
+
168
+ return () => controller.abort()
169
+ }, [])
170
+
171
+ // Load the per-version asset bundle when the version (or the backend force) changes.
172
+ useEffect(() => {
173
+ if (!selectedVersion) return
174
+
175
+ const release = manifestRef.current?.releases.find((r) => r.version === selectedVersion) ?? null
176
+
177
+ if (!release) return
178
+
179
+ const controller = new AbortController()
180
+ const { signal } = controller
181
+ const guard = (fn: () => void) => {
182
+ if (!signal.aborted) {
183
+ fn()
184
+ }
185
+ }
186
+
187
+ void (async () => {
188
+ try {
189
+ setAssets(null)
190
+ setLoadingStepIndex(-1)
191
+ setLoadingStepLabels([])
192
+ setActiveBackend("")
193
+
194
+ const ctx: DemoAssetsLoadContext = {
195
+ signal,
196
+ forceWASM,
197
+ setProgress: (progress) => guard(() => setLoadingProgress(progress)),
198
+ setStepLabels: (labels) => guard(() => setLoadingStepLabels(labels)),
199
+ setStepIndex: (index) => guard(() => setLoadingStepIndex(index)),
200
+ setBackend: (backend) => guard(() => setActiveBackend(backend)),
201
+ }
202
+
203
+ const loaded = await loadAssetsRef.current(release, ctx)
204
+
205
+ if (signal.aborted) return
206
+ setAssets(loaded)
207
+ setLoadingProgress("")
208
+ } catch (error) {
209
+ if (signal.aborted) return
210
+ setErrorMessage(error instanceof Error ? error.message : String(error))
211
+ setLoadingProgress("")
212
+ }
213
+ })()
214
+
215
+ return () => controller.abort()
216
+ }, [selectedVersion, forceWASM])
217
+
218
+ const selectVersion = useCallback((version: string) => {
219
+ setSelectedVersion(version)
220
+ setErrorMessage(null)
221
+ }, [])
222
+
223
+ const setForceWASM = useCallback((next: boolean) => setForceWASMState(next), [])
224
+
225
+ const selectedRelease = useMemo(
226
+ () => manifest?.releases.find((r) => r.version === selectedVersion) ?? null,
227
+ [manifest, selectedVersion]
228
+ )
229
+
230
+ return {
231
+ manifest,
232
+ selectedVersion,
233
+ selectedRelease,
234
+ assets,
235
+ ready: assets !== null,
236
+ loadingProgress,
237
+ loadingStepIndex,
238
+ loadingStepLabels,
239
+ errorMessage,
240
+ activeBackend,
241
+ forceWASM,
242
+ selectVersion,
243
+ setForceWASM,
244
+ }
245
+ }
package/styles.css CHANGED
@@ -214,6 +214,27 @@
214
214
  letter-spacing: 0.02em;
215
215
  }
216
216
 
217
+ .mw-subject__badge--brand {
218
+ background: var(--ifm-color-info-contrast-background, rgba(84, 199, 236, 0.15));
219
+ color: var(--ifm-color-info-darkest, #14657d);
220
+ }
221
+
222
+ .mw-subject__qid {
223
+ display: inline-block;
224
+ padding: 0.2rem 0.5rem;
225
+ border-radius: 999px;
226
+ background: var(--ifm-color-emphasis-200, #ebedf0);
227
+ color: var(--ifm-color-emphasis-800, #303846);
228
+ font-family: var(--ifm-font-family-monospace, monospace);
229
+ font-size: 0.75rem;
230
+ font-weight: 600;
231
+ text-decoration: none;
232
+ }
233
+
234
+ .mw-subject__qid:hover {
235
+ text-decoration: underline;
236
+ }
237
+
217
238
  .mw-subject__note {
218
239
  margin: 0 0 0.75rem;
219
240
  font-size: 0.85rem;
@@ -625,3 +646,148 @@
625
646
  border-width: 0 2px 2px 0;
626
647
  transform: translateY(-60%) rotate(45deg);
627
648
  }
649
+
650
+ /* ── Demo map (react-map-gl/maplibre shell) ──────────────────────────────── */
651
+
652
+ /*
653
+ * The <DemoMap> wrapper. Sizing is the host's call (pass `style`/`className`), but a min-height keeps
654
+ * the map from collapsing to zero when a consumer forgets. The inner <Map> fills 100% of this box.
655
+ * The host must ALSO import `maplibre-gl/dist/maplibre-gl.css` for the map's own control/canvas styles.
656
+ */
657
+ .mw-demo-map {
658
+ position: relative;
659
+ width: 100%;
660
+ min-height: 320px;
661
+ overflow: hidden;
662
+ }
663
+
664
+ /* ── Geocoder demo (composed: full-viewport map + floating control panel) ──── */
665
+
666
+ /*
667
+ * The demo fills its positioning context; the host sizes it (a full-viewport wrapper in docs). The map is the
668
+ * background layer, the control panel floats over the top-left (bottom on narrow screens). A host that wants a
669
+ * different layout can restyle these classes without touching the components.
670
+ */
671
+ .mw-geocoder-demo {
672
+ position: relative;
673
+ width: 100%;
674
+ height: 100%;
675
+ min-height: 480px;
676
+ }
677
+
678
+ .mw-geocoder-demo__map {
679
+ position: absolute;
680
+ inset: 0;
681
+ }
682
+
683
+ .mw-demo-controls {
684
+ position: absolute;
685
+ top: 1rem;
686
+ left: 1rem;
687
+ z-index: 2;
688
+ width: min(360px, calc(100% - 2rem));
689
+ max-height: calc(100% - 2rem);
690
+ overflow-y: auto;
691
+ padding: 1rem;
692
+ border-radius: 10px;
693
+ background: color-mix(in srgb, var(--ifm-background-surface-color, #fff) 92%, transparent);
694
+ box-shadow: 0 2px 16px rgba(0, 0, 0, 0.18);
695
+ font-size: 0.95rem;
696
+ }
697
+
698
+ @media (max-width: 600px) {
699
+ .mw-demo-controls {
700
+ top: auto;
701
+ bottom: 1rem;
702
+ left: 1rem;
703
+ right: 1rem;
704
+ width: auto;
705
+ }
706
+ }
707
+
708
+ /* ── Demo control field (version / compare-version selects) ──────────────── */
709
+
710
+ .mw-demo-control {
711
+ margin-bottom: 0.75rem;
712
+ }
713
+
714
+ .mw-demo-control__label {
715
+ display: block;
716
+ margin-bottom: 0.25rem;
717
+ font-weight: 600;
718
+ font-size: 0.9rem;
719
+ }
720
+
721
+ .mw-demo-control__select {
722
+ width: 100%;
723
+ padding: 0.4rem;
724
+ font: inherit;
725
+ border: 1px solid var(--ifm-color-emphasis-300, #d1d5db);
726
+ border-radius: 6px;
727
+ background: var(--ifm-background-surface-color, #fff);
728
+ color: var(--ifm-font-color-base, inherit);
729
+ }
730
+
731
+ .mw-demo-control__select:disabled {
732
+ opacity: 0.5;
733
+ }
734
+
735
+ /* ── Backend indicator + WASM toggle / compare toggle ────────────────────── */
736
+
737
+ .mw-demo-backend,
738
+ .mw-demo-compare {
739
+ display: flex;
740
+ align-items: center;
741
+ gap: 0.75rem;
742
+ flex-wrap: wrap;
743
+ margin-bottom: 0.75rem;
744
+ font-size: 0.85rem;
745
+ }
746
+
747
+ .mw-demo-compare {
748
+ display: block;
749
+ }
750
+
751
+ .mw-demo-backend__active {
752
+ opacity: 0.7;
753
+ }
754
+
755
+ .mw-demo-backend__toggle,
756
+ .mw-demo-compare__toggle {
757
+ display: inline-flex;
758
+ align-items: center;
759
+ gap: 0.25rem;
760
+ cursor: pointer;
761
+ opacity: 0.8;
762
+ }
763
+
764
+ .mw-demo-compare__toggle {
765
+ margin-bottom: 0.5rem;
766
+ }
767
+
768
+ .mw-demo-compare__status {
769
+ margin: 0.25rem 0 0;
770
+ font-size: 0.8rem;
771
+ color: var(--ifm-color-emphasis-600, #6e6e6e);
772
+ }
773
+
774
+ /* ── Place autocomplete listbox (reuses .mw-chip) ────────────────────────── */
775
+
776
+ .mw-demo-suggest {
777
+ margin-top: 0.75rem;
778
+ display: flex;
779
+ flex-wrap: wrap;
780
+ gap: 0.4rem;
781
+ align-items: baseline;
782
+ }
783
+
784
+ .mw-demo-suggest__label {
785
+ font-size: 0.85rem;
786
+ color: var(--ifm-color-emphasis-600, #6e6e6e);
787
+ margin-right: 0.25rem;
788
+ }
789
+
790
+ .mw-chip--active {
791
+ outline: 2px solid var(--ifm-color-primary, #e0367c);
792
+ outline-offset: 1px;
793
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Node-mode vitest for `@mailwoman/react` — runs the PURE `*.node.test.ts` suites under bare node (no
7
+ * browser, no WebGL, no map). Their existence is the proof that the geometry (`map/geometry.ts`) and
8
+ * render-spec (`map/place-render.ts`) modules are side-effect-free and node-safe: if either grew a
9
+ * `react-map-gl` / `maplibre-gl` / DOM import, these tests would fail to even load here. The browser
10
+ * suite (`vitest.config.ts`) excludes these files, so this is the only entry that runs them.
11
+ */
12
+
13
+ import { defineConfig } from "vitest/config"
14
+
15
+ export default defineConfig({
16
+ test: {
17
+ environment: "node",
18
+ include: ["**/*.node.test.ts"],
19
+ },
20
+ })