@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
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `<PlaceAutocomplete>` — the "Did you mean" suggestion listbox from the demo (`_app.tsx:1394-1418`), as
7
+ * a dumb presentational unit. It renders the current suggestions with the keyboard-highlighted active
8
+ * descendant; the state + keyboard nav live in {@link usePlaceAutocomplete}. Renders `null` when there
9
+ * is nothing to suggest, so the row only appears when useful. Wire the ids from the hook so the input's
10
+ * `aria-controls` / `aria-activedescendant` match this listbox.
11
+ *
12
+ * NODE-SAFE: pure React, no maplibre.
13
+ */
14
+
15
+ import type { ReactNode } from "react"
16
+
17
+ import { cx } from "../common/cx.ts"
18
+ import type { Suggestion } from "./types.ts"
19
+
20
+ export interface PlaceAutocompleteProps {
21
+ /** The suggestions to render (from {@link usePlaceAutocomplete}). */
22
+ suggestions: Suggestion[]
23
+ /** The keyboard-highlighted index; `-1` for none. */
24
+ activeIndex: number
25
+ /** Fired (with the suggestion `value`) when one is clicked. */
26
+ onPick: (value: string) => void
27
+ /** Fired with the index a pointer entered, so hover matches keyboard highlight. */
28
+ onHover?: (index: number) => void
29
+ /** The listbox element id — pass `listboxId` from the hook (matches the input's `aria-controls`). */
30
+ listboxId: string
31
+ /** Build the option element id — pass `optionId` from the hook. */
32
+ optionId: (index: number) => string
33
+ /** Leading label. @default "Did you mean:" */
34
+ caption?: string
35
+ }
36
+
37
+ /** The suggestion listbox. */
38
+ export function PlaceAutocomplete({
39
+ suggestions,
40
+ activeIndex,
41
+ onPick,
42
+ onHover,
43
+ listboxId,
44
+ optionId,
45
+ caption = "Did you mean:",
46
+ }: PlaceAutocompleteProps): ReactNode {
47
+ if (suggestions.length === 0) return null
48
+
49
+ return (
50
+ <div className="mw-demo-suggest" id={listboxId} role="listbox" aria-label="Place suggestions">
51
+ <span className="mw-demo-suggest__label">{caption}</span>
52
+ {suggestions.map((s, i) => (
53
+ <button
54
+ key={`${s.value}-${i}`}
55
+ id={optionId(i)}
56
+ type="button"
57
+ role="option"
58
+ aria-selected={i === activeIndex}
59
+ className={cx("mw-chip", { "mw-chip--active": i === activeIndex })}
60
+ onMouseEnter={() => onHover?.(i)}
61
+ onClick={() => onPick(s.value)}
62
+ title={s.placetype}
63
+ >
64
+ {s.label ?? s.value}
65
+ </button>
66
+ ))}
67
+ </div>
68
+ )
69
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `<PlaceMarker>` — the resolved-place pin, as a declarative `react-map-gl/maplibre` `<Marker>`. This
7
+ * replaces the old imperative `new maplibre.Marker(...).setLngLat(...).addTo(map)` (+ the manual
8
+ * `markerRef.current.remove()` teardown) from the demo's redraw effect: mounting/unmounting the marker
9
+ * is now React's job, keyed on the resolved candidate. Renders as a child of `<DemoMap>`.
10
+ *
11
+ * NODE-IMPORT SAFETY: imports `react-map-gl/maplibre` at module scope, so it is reachable ONLY through
12
+ * the `@mailwoman/react/map` subpath, never the package root.
13
+ */
14
+
15
+ import type { ReactNode } from "react"
16
+ import { Marker } from "react-map-gl/maplibre"
17
+
18
+ /** The house pink the demo has always drawn the resolved-place marker in. */
19
+ const MARKER_COLOR = "#e0367c"
20
+
21
+ export interface PlaceMarkerProps {
22
+ /** Marker longitude. */
23
+ longitude: number
24
+ /** Marker latitude. */
25
+ latitude: number
26
+ /** Pin color. @default the house pink. */
27
+ color?: string
28
+ /** Which part of the marker sits on the coordinate. @default "center" (a symmetric dot). */
29
+ anchor?: "center" | "top" | "bottom" | "left" | "right"
30
+ }
31
+
32
+ /** A single resolved-place marker. Wraps `react-map-gl`'s `<Marker>` with the demo's default color. */
33
+ export function PlaceMarker({
34
+ longitude,
35
+ latitude,
36
+ color = MARKER_COLOR,
37
+ anchor = "center",
38
+ }: PlaceMarkerProps): ReactNode {
39
+ return <Marker longitude={longitude} latitude={latitude} color={color} anchor={anchor} />
40
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `<ResolvedPlaceLayers>` — the drop-in that renders a whole {@link MapPlaceRenderSpec} as `<DemoMap>`
7
+ * children: the marker(s), the outline, and (optionally) the camera move. This is the declarative
8
+ * replacement for the demo's imperative marker/bbox/camera redraw effect — a consumer computes the spec
9
+ * with `useMapPlaceRender(place)` and drops `<ResolvedPlaceLayers spec={spec} />` inside `<DemoMap>`.
10
+ *
11
+ * `spec = null` (no result / no candidate) renders nothing, which also unmounts the previous marker +
12
+ * outline — the teardown the old effect did by hand (`markerRef.remove()`, `clearBbox`) is now just
13
+ * React unmounting. NODE-IMPORT SAFETY: pulls the map components — `@mailwoman/react/map` only.
14
+ */
15
+
16
+ import type { ReactNode } from "react"
17
+
18
+ import type { MapPlaceRenderSpec } from "./place-render.ts"
19
+ import { PlaceMarker } from "./PlaceMarker.tsx"
20
+ import { ResultCamera } from "./ResultCamera.tsx"
21
+ import { ResultOverlay } from "./ResultOverlay.tsx"
22
+
23
+ export interface ResolvedPlaceLayersProps {
24
+ /** The render spec (from {@link useMapPlaceRender}); `null` renders nothing. */
25
+ spec: MapPlaceRenderSpec | null
26
+ /**
27
+ * Apply the computed camera target via {@link ResultCamera}. @default true. Set false when the consumer drives the
28
+ * camera itself (e.g. a controlled `<DemoMap viewState>` fed by {@link cameraToViewState}).
29
+ */
30
+ applyCamera?: boolean
31
+ /** Animate the camera move. Forwarded to {@link ResultCamera}. @default true */
32
+ animateCamera?: boolean
33
+ /** Source/layer id prefix for the outline. @default "mw-result" */
34
+ outlineId?: string
35
+ /** Marker color. @default the house pink. */
36
+ markerColor?: string
37
+ }
38
+
39
+ /** Render the marker(s), outline, and (optional) camera move for one resolved place. */
40
+ export function ResolvedPlaceLayers({
41
+ spec,
42
+ applyCamera = true,
43
+ animateCamera = true,
44
+ outlineId,
45
+ markerColor,
46
+ }: ResolvedPlaceLayersProps): ReactNode {
47
+ if (!spec) return null
48
+
49
+ return (
50
+ <>
51
+ {spec.markers.map(([longitude, latitude], index) => (
52
+ <PlaceMarker
53
+ key={`${longitude},${latitude},${index}`}
54
+ longitude={longitude}
55
+ latitude={latitude}
56
+ color={markerColor}
57
+ />
58
+ ))}
59
+ <ResultOverlay outline={spec.outline} id={outlineId} color={markerColor} />
60
+ {applyCamera ? <ResultCamera target={spec.camera} animate={animateCamera} /> : null}
61
+ </>
62
+ )
63
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `<ResultCamera>` — applies the {@link MapCameraTarget} a {@link MapPlaceRenderSpec} computes, by
7
+ * animating the live map to it. This is the ONE deliberately-imperative touch in the phase-2 overlays,
8
+ * and it is justified: maplibre exposes animated camera moves (`flyTo`) and viewport-fitting
9
+ * (`fitBounds`, which needs the map's PIXEL dimensions + padding) ONLY imperatively — react-map-gl has
10
+ * no declarative prop for "animate to these bounds". It is applied the v8-idiomatic way, through
11
+ * `useMap()` (exactly as `DashboardMap`/`GeoJSONClipboardLayer` reach the map), never a threaded handle.
12
+ *
13
+ * A consumer that prefers a hard, declarative jump can instead feed the target through
14
+ * {@link cameraToViewState} into a controlled `<DemoMap viewState>` and skip this component — the
15
+ * `center` case has that declarative path; only `bounds` strictly requires this. Renders nothing.
16
+ *
17
+ * `target` is expected to be the STABLE, memoized `camera` off a `useMapPlaceRender` spec, so listing it
18
+ * as the effect dependency re-runs the camera move exactly when the resolved place changes — no
19
+ * value-key dance, no dependency-lint suppression.
20
+ */
21
+
22
+ import { type ReactNode, useEffect } from "react"
23
+ import { useMap } from "react-map-gl/maplibre"
24
+
25
+ import type { MapCameraTarget } from "./place-render.ts"
26
+
27
+ export interface ResultCameraProps {
28
+ /** The camera target to animate to. `null` leaves the camera untouched (no result yet). */
29
+ target: MapCameraTarget | null
30
+ /**
31
+ * Animate (`flyTo`/`fitBounds`) vs jump. @default true. When false, a `center` target jumps with `jumpTo`; a `bounds`
32
+ * target still uses `fitBounds` (no instantaneous fit exists) but with `duration: 0`.
33
+ */
34
+ animate?: boolean
35
+ }
36
+
37
+ /** Drive the live map to `target`. No DOM of its own — it is a behavior mounted as a `<Map>` child. */
38
+ export function ResultCamera({ target, animate = true }: ResultCameraProps): ReactNode {
39
+ const map = useMap()
40
+
41
+ useEffect(() => {
42
+ const instance = map.current?.getMap()
43
+
44
+ if (!instance || !target) return
45
+
46
+ if (target.kind === "center") {
47
+ if (animate) {
48
+ instance.flyTo({ center: target.center, zoom: target.zoom })
49
+ } else {
50
+ instance.jumpTo({ center: target.center, zoom: target.zoom })
51
+ }
52
+
53
+ return
54
+ }
55
+
56
+ instance.fitBounds(target.bounds, { padding: target.padding, duration: animate ? undefined : 0 })
57
+ }, [map, target, animate])
58
+
59
+ return null
60
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `<ResultOverlay>` — the resolved-place outline (crisp admin polygon, or an approximate/exact-radius
7
+ * circle) as a declarative `<Source>` + fill/line `<Layer>`s. This replaces the demo's imperative
8
+ * `setPlaceOutline` / `drawApproxCircle` / `drawPlaceGeometry` / `clearBbox` helpers and their
9
+ * `whenStyleReady` gate: react-map-gl owns the "add source/layer once the style is loaded, remove on
10
+ * unmount" lifecycle, so the hand-rolled `isStyleLoaded()` / `styledata` races disappear.
11
+ *
12
+ * Pass the `outline` straight from a {@link MapPlaceRenderSpec}; a `null` outline renders nothing (the
13
+ * bare-point case). NODE-IMPORT SAFETY: imports `react-map-gl/maplibre` — `@mailwoman/react/map` only.
14
+ */
15
+
16
+ import type { ReactNode } from "react"
17
+ import { Layer, Source } from "react-map-gl/maplibre"
18
+
19
+ import type { PlaceGeometry } from "./geometry.ts"
20
+
21
+ /** The house pink, matched to {@link PlaceMarker}. */
22
+ const OUTLINE_COLOR = "#e0367c"
23
+
24
+ export interface ResultOverlayProps {
25
+ /** The outline geometry to draw, or `null` to draw nothing (the bare-point result). */
26
+ outline: PlaceGeometry | null
27
+ /** Source/layer id prefix — override to render more than one outline on a map. @default "mw-result" */
28
+ id?: string
29
+ /** Fill color. @default the house pink. */
30
+ color?: string
31
+ /** Fill opacity. @default 0.12 */
32
+ fillOpacity?: number
33
+ /** Outline stroke width (px). @default 2 */
34
+ lineWidth?: number
35
+ }
36
+
37
+ /**
38
+ * Render the resolved-place outline. One geojson `<Source>` feeds a translucent fill `<Layer>` and a solid line
39
+ * `<Layer>` — the same two layers the imperative `setPlaceOutline` created, now declarative and self-cleaning.
40
+ */
41
+ export function ResultOverlay({
42
+ outline,
43
+ id = "mw-result",
44
+ color = OUTLINE_COLOR,
45
+ fillOpacity = 0.12,
46
+ lineWidth = 2,
47
+ }: ResultOverlayProps): ReactNode {
48
+ if (!outline) return null
49
+
50
+ const data = {
51
+ type: "FeatureCollection" as const,
52
+ features: [{ type: "Feature" as const, geometry: outline, properties: {} }],
53
+ }
54
+
55
+ return (
56
+ <Source id={id} type="geojson" data={data}>
57
+ <Layer id={`${id}-fill`} type="fill" paint={{ "fill-color": color, "fill-opacity": fillOpacity }} />
58
+ <Layer id={`${id}-line`} type="line" paint={{ "line-color": color, "line-width": lineWidth }} />
59
+ </Source>
60
+ )
61
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `<ResultPanel>` — the demo's parse+resolve result block, assembled from the SHARED pipeline
7
+ * presentational units (it does not re-implement any of them): `KindBadge`, `ComponentTable`,
8
+ * `ResolvedPlace`, `CandidatePicker`, plus the `CopyButton` + `buildParsePayload` copy affordance. This
9
+ * is the standalone, story-testable unit the demo's floating panel renders (the map analogue of the
10
+ * result block PipelineExplorer inlines), so `GeocoderDemo` composes ONE panel instead of duplicating
11
+ * the marker/table/candidate glue. Heavy host visualizers (span highlight, tree, timing) slot in via
12
+ * `extras`; the no-resolve diagnostic via `failure`.
13
+ *
14
+ * NODE-SAFE: pure React + the shared units, no maplibre.
15
+ */
16
+
17
+ import type { ReactNode } from "react"
18
+
19
+ import { CopyButton } from "../common/CopyButton.tsx"
20
+ import { KindBadge } from "../common/KindBadge.tsx"
21
+ import { CandidatePicker } from "../pipeline/CandidatePicker.tsx"
22
+ import { ComponentTable } from "../pipeline/ComponentTable.tsx"
23
+ import { buildParsePayload } from "../pipeline/copy.ts"
24
+ import { ResolvedPlace } from "../pipeline/ResolvedPlace.tsx"
25
+ import type { ParseResult, ResolvedPlaceView } from "../pipeline/types.ts"
26
+
27
+ export interface ResultPanelProps {
28
+ /** The parse+resolve result to render. */
29
+ result: ParseResult
30
+ /** The selected candidate (falls back to the first), used for the resolved-place detail + copy payload. */
31
+ selectedCandidate: ResolvedPlaceView | null
32
+ /** The selected candidate index, for the picker's active state. */
33
+ selectedCandidateIndex: number
34
+ /** Fired when a candidate in the picker is chosen. */
35
+ onSelectCandidate: (index: number) => void
36
+ /** Host-injected heavy visualizers (span highlight, tree, timing, …), rendered from the result. */
37
+ extras?: (result: ParseResult) => ReactNode
38
+ /** Host-injected no-resolve diagnostic, rendered when nothing resolved. */
39
+ failure?: (result: ParseResult) => ReactNode
40
+ }
41
+
42
+ /** The composed result block for the geocoder demo. */
43
+ export function ResultPanel({
44
+ result,
45
+ selectedCandidate,
46
+ selectedCandidateIndex,
47
+ onSelectCandidate,
48
+ extras,
49
+ failure,
50
+ }: ResultPanelProps): ReactNode {
51
+ return (
52
+ <div className="mw-result">
53
+ <div className="mw-result__header">
54
+ <h2>Parsed components</h2>
55
+ <CopyButton
56
+ value={() => buildParsePayload(result, selectedCandidate)}
57
+ label="Copy JSON"
58
+ copiedLabel="✓ Copied"
59
+ />
60
+ </div>
61
+
62
+ {result.kindResult ? <KindBadge kindResult={result.kindResult} /> : null}
63
+
64
+ {extras ? extras(result) : null}
65
+
66
+ <ComponentTable nodes={result.nodes} />
67
+
68
+ {selectedCandidate ? (
69
+ <>
70
+ <ResolvedPlace place={selectedCandidate} dualRoles={result.dualRoles} />
71
+ {result.candidates.length > 1 ? (
72
+ <CandidatePicker
73
+ candidates={result.candidates}
74
+ selectedIndex={selectedCandidateIndex}
75
+ onSelect={onSelectCandidate}
76
+ />
77
+ ) : null}
78
+ </>
79
+ ) : failure ? (
80
+ failure(result)
81
+ ) : null}
82
+ </div>
83
+ )
84
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `<VersionPicker>` — the model-version `<select>` from the demo's control panel (`_app.tsx:1230-1254`),
7
+ * as a dumb presentational unit: the available bundles + current selection come in as props, the choice
8
+ * goes out through `onSelect`. No load state, no manifest fetch — the host (via `useDemoRuntime`) owns
9
+ * that. Renders nothing when there is fewer than two versions to choose between.
10
+ *
11
+ * NODE-SAFE: pure React + a `<select>`, no maplibre. It rides the `@mailwoman/react/map` subpath only
12
+ * because it is a demo-specific control, not because it needs WebGL.
13
+ */
14
+
15
+ import type { ReactNode } from "react"
16
+
17
+ import type { DemoVersionOption } from "./types.ts"
18
+
19
+ export interface VersionPickerProps {
20
+ /** The selectable model bundles. */
21
+ versions: ReadonlyArray<DemoVersionOption>
22
+ /** The currently-selected version tag. */
23
+ selected: string | null
24
+ /** Fired with the chosen version tag. */
25
+ onSelect: (version: string) => void
26
+ /** Disable the control (e.g. while a parse is running). */
27
+ disabled?: boolean
28
+ /** Field label. @default "Model version" */
29
+ label?: string
30
+ }
31
+
32
+ /** The model-version selector. Renders `null` when there is nothing meaningful to pick. */
33
+ export function VersionPicker({
34
+ versions,
35
+ selected,
36
+ onSelect,
37
+ disabled,
38
+ label = "Model version",
39
+ }: VersionPickerProps): ReactNode {
40
+ if (versions.length < 2) return null
41
+
42
+ return (
43
+ <div className="mw-demo-control">
44
+ <label className="mw-demo-control__label" htmlFor="mw-demo-version">
45
+ {label}
46
+ </label>
47
+ <select
48
+ id="mw-demo-version"
49
+ className="mw-demo-control__select"
50
+ value={selected ?? ""}
51
+ onChange={(e) => onSelect(e.target.value)}
52
+ disabled={disabled}
53
+ >
54
+ {versions.map((v) => (
55
+ <option key={v.version} value={v.version}>
56
+ {v.label ?? v.version}
57
+ </option>
58
+ ))}
59
+ </select>
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Pure map geometry for the geocoder demo — the resolved-place outline math, lifted verbatim from the
7
+ * docs demo's `_map-helpers.ts`. Zero imports: no React, no `react-map-gl`, no `maplibre-gl`, no DOM.
8
+ * Every function takes plain numbers and returns plain GeoJSON, so the whole module runs (and is
9
+ * tested) under bare node — see `geometry.node.test.ts`.
10
+ *
11
+ * WHY NOT `@mailwoman/spatial`: the only truly-spatial primitive here is the bbox half-diagonal (a
12
+ * great-circle-ish distance). `@mailwoman/spatial` exposes `haversineKm`, but ONLY via its root barrel,
13
+ * which pulls `@mailwoman/core` + `h3-js` + `wkx` — a heavy, partly node-only graph — into what must
14
+ * stay a lightweight, browser-only map bundle (the `@mailwoman/react/map` subpath). The original
15
+ * `_map-helpers.ts` made the same call: a planar `kmPerDeg` approximation, local, no dependency. We
16
+ * preserve it so the ported behavior is byte-identical and the browser graph stays clean.
17
+ */
18
+
19
+ /** Mean km per degree of latitude (WGS84 average). */
20
+ const KM_PER_DEG_LAT = 111.32
21
+
22
+ /** A GeoJSON Polygon / MultiPolygon — what the polygon DB stores and the map draws as the place outline. */
23
+ export type PlaceGeometry =
24
+ | { type: "Polygon"; coordinates: number[][][] }
25
+ | { type: "MultiPolygon"; coordinates: number[][][][] }
26
+
27
+ /** A place bounding box in the demo's object form (the WOF points DB carries only these four numbers). */
28
+ export interface PlaceBBox {
29
+ minLat: number
30
+ maxLat: number
31
+ minLon: number
32
+ maxLon: number
33
+ }
34
+
35
+ /** A `[west, south, east, north]` → `[[minLon, minLat], [maxLon, maxLat]]` pair, the shape `fitBounds` wants. */
36
+ export type BoundsTuple = [[number, number], [number, number]]
37
+
38
+ /** Km per degree of longitude at a given latitude — the meridians converge toward the poles. */
39
+ function kmPerDegLon(lat: number): number {
40
+ return KM_PER_DEG_LAT * Math.cos((lat * Math.PI) / 180)
41
+ }
42
+
43
+ /** A closed 64-segment (65-point) GeoJSON ring of `radiusKm` around `[lon, lat]`, with latitude correction. */
44
+ function circleRing(lat: number, lon: number, radiusKm: number): number[][] {
45
+ const perLon = kmPerDegLon(lat)
46
+ const ring: number[][] = []
47
+
48
+ for (let i = 0; i <= 64; i++) {
49
+ const theta = (2 * Math.PI * i) / 64
50
+
51
+ ring.push([lon + (radiusKm * Math.cos(theta)) / perLon, lat + (radiusKm * Math.sin(theta)) / KM_PER_DEG_LAT])
52
+ }
53
+
54
+ return ring
55
+ }
56
+
57
+ /**
58
+ * Approximate-extent circle for places without a crisp polygon: centered on the place point, radius from the bbox
59
+ * half-diagonal (clamped 0.5–50 km). 64-point ring with latitude correction — visually a circle anywhere outside the
60
+ * poles. With no bbox (anchor-centroid postcodes carry no extent) it defaults to a ~ZIP-sized 3 km radius.
61
+ */
62
+ export function approxCircleGeometry(lat: number, lon: number, bbox?: PlaceBBox): PlaceGeometry {
63
+ const halfDiagKm = bbox
64
+ ? Math.hypot((bbox.maxLat - bbox.minLat) * KM_PER_DEG_LAT, (bbox.maxLon - bbox.minLon) * kmPerDegLon(lat)) / 2
65
+ : 3
66
+ const radiusKm = Math.min(50, Math.max(0.5, halfDiagKm))
67
+
68
+ return { type: "Polygon", coordinates: [circleRing(lat, lon, radiusKm)] }
69
+ }
70
+
71
+ /**
72
+ * A circle of an EXACT radius in meters — for the street-level uncertainty (#377): a 10 m situs floor or a calibrated
73
+ * interp radius. Unlike {@link approxCircleGeometry} (clamped to a ~ZIP-sized 0.5 km floor for admin fallbacks), this
74
+ * honors small radii so an exact building reads as a tight dot (an ~8 m floor keeps a 10 m situs circle visible).
75
+ */
76
+ export function radiusCircleGeometry(lat: number, lon: number, radiusM: number): PlaceGeometry {
77
+ const radiusKm = Math.max(0.008, radiusM / 1000)
78
+
79
+ return { type: "Polygon", coordinates: [circleRing(lat, lon, radiusKm)] }
80
+ }
81
+
82
+ /**
83
+ * Bounding box of a Polygon / MultiPolygon, for `fitBounds`. Walks the nested coordinate arrays, so it handles both a
84
+ * single-ring polygon and a multi-part polygon uniformly. Antimeridian-crossing geometry is NOT normalized (the naive
85
+ * min/max is returned) — matching the ported behavior; callers that need a wrapped bbox must handle it upstream.
86
+ */
87
+ export function geomBounds(geometry: PlaceGeometry): PlaceBBox {
88
+ let minLon = Infinity
89
+ let minLat = Infinity
90
+ let maxLon = -Infinity
91
+ let maxLat = -Infinity
92
+
93
+ const visit = (node: unknown): void => {
94
+ if (Array.isArray(node) && typeof node[0] === "number" && typeof node[1] === "number") {
95
+ const lon: number = node[0]
96
+ const lat: number = node[1]
97
+
98
+ if (lon < minLon) {
99
+ minLon = lon
100
+ }
101
+
102
+ if (lon > maxLon) {
103
+ maxLon = lon
104
+ }
105
+
106
+ if (lat < minLat) {
107
+ minLat = lat
108
+ }
109
+
110
+ if (lat > maxLat) {
111
+ maxLat = lat
112
+ }
113
+
114
+ return
115
+ }
116
+
117
+ if (Array.isArray(node)) {
118
+ for (const child of node) {
119
+ visit(child)
120
+ }
121
+ }
122
+ }
123
+
124
+ visit(geometry.coordinates)
125
+
126
+ return { minLon, minLat, maxLon, maxLat }
127
+ }
128
+
129
+ /** Reshape a {@link PlaceBBox} into the `[[minLon, minLat], [maxLon, maxLat]]` pair `fitBounds` expects. */
130
+ export function bboxToBounds(bbox: PlaceBBox): BoundsTuple {
131
+ return [
132
+ [bbox.minLon, bbox.minLat],
133
+ [bbox.maxLon, bbox.maxLat],
134
+ ]
135
+ }
package/map/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `@mailwoman/react/map` — the geocoder-demo map surface, kept behind its OWN subpath so `maplibre-gl`
7
+ * / `react-map-gl` (WebGL + DOM at import) never enter the package-root graph. Importing this subpath
8
+ * pulls the map deps; importing `@mailwoman/react` (root) does not. Consumers who only want the
9
+ * parse/POI explorers never pay for maplibre.
10
+ *
11
+ * The host must supply `maplibre-gl` + `react-map-gl` (peer deps) and import
12
+ * `maplibre-gl/dist/maplibre-gl.css` + `@mailwoman/react/styles.css` itself.
13
+ */
14
+
15
+ export { DemoMap } from "./DemoMap.tsx"
16
+ export type { DemoMapExtraProps, DemoMapProps, DemoMapStyle } from "./DemoMap.tsx"
17
+ export type {
18
+ DemoBackend,
19
+ DemoCompareContext,
20
+ DemoPanels,
21
+ DemoResultContext,
22
+ DemoRuntime,
23
+ DemoVersionOption,
24
+ LngLatTuple,
25
+ MapBias,
26
+ OverlaySpec,
27
+ Suggestion,
28
+ } from "./types.ts"
29
+
30
+ // ── Pure geometry + render spec (node-safe; no react-map-gl at runtime) ──────
31
+ export { approxCircleGeometry, bboxToBounds, geomBounds, radiusCircleGeometry } from "./geometry.ts"
32
+ export type { BoundsTuple, PlaceBBox, PlaceGeometry } from "./geometry.ts"
33
+ export { cameraToViewState, computeMapPlaceRenderSpec } from "./place-render.ts"
34
+ export type { LngLat, MapCameraTarget, MapPlaceRenderSpec, PlaceTier, ResolvedMapPlace } from "./place-render.ts"
35
+ export { useMapPlaceRender } from "./useMapPlaceRender.ts"
36
+
37
+ // ── Declarative overlays (react-map-gl `<Marker>`/`<Source>`/`<Layer>`) ──────
38
+ export { OverlayLayers } from "./OverlayLayers.tsx"
39
+ export type { OverlayLayersProps } from "./OverlayLayers.tsx"
40
+ export { PlaceMarker } from "./PlaceMarker.tsx"
41
+ export type { PlaceMarkerProps } from "./PlaceMarker.tsx"
42
+ export { ResolvedPlaceLayers } from "./ResolvedPlaceLayers.tsx"
43
+ export type { ResolvedPlaceLayersProps } from "./ResolvedPlaceLayers.tsx"
44
+ export { ResultCamera } from "./ResultCamera.tsx"
45
+ export type { ResultCameraProps } from "./ResultCamera.tsx"
46
+ export { ResultOverlay } from "./ResultOverlay.tsx"
47
+ export type { ResultOverlayProps } from "./ResultOverlay.tsx"
48
+
49
+ // ── Demo controls + composed demo (phase 4) ─────────────────────────────────
50
+ export { BackendControl } from "./BackendControl.tsx"
51
+ export type { BackendControlProps } from "./BackendControl.tsx"
52
+ export { CompareToggle } from "./CompareToggle.tsx"
53
+ export type { CompareToggleProps } from "./CompareToggle.tsx"
54
+ export { DemoControls } from "./DemoControls.tsx"
55
+ export type { DemoControlsProps } from "./DemoControls.tsx"
56
+ export { GeocoderDemo } from "./GeocoderDemo.tsx"
57
+ export type { GeocoderDemoProps } from "./GeocoderDemo.tsx"
58
+ export { PlaceAutocomplete } from "./PlaceAutocomplete.tsx"
59
+ export type { PlaceAutocompleteProps } from "./PlaceAutocomplete.tsx"
60
+ export { ResultPanel } from "./ResultPanel.tsx"
61
+ export type { ResultPanelProps } from "./ResultPanel.tsx"
62
+ export { useCompareState } from "./useCompareState.ts"
63
+ export type { UseCompareState } from "./useCompareState.ts"
64
+ export { useDemoGeocode } from "./useDemoGeocode.ts"
65
+ export type { UseDemoGeocode, UseDemoGeocodeOptions } from "./useDemoGeocode.ts"
66
+ export { usePlaceAutocomplete } from "./usePlaceAutocomplete.ts"
67
+ export type {
68
+ AutocompleteInputProps,
69
+ UsePlaceAutocomplete,
70
+ UsePlaceAutocompleteOptions,
71
+ } from "./usePlaceAutocomplete.ts"
72
+ export { VersionPicker } from "./VersionPicker.tsx"
73
+ export type { VersionPickerProps } from "./VersionPicker.tsx"