@mailwoman/react 7.5.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.
- package/common/PresetChips.tsx +4 -1
- package/index.ts +28 -0
- package/map/BackendControl.tsx +39 -0
- package/map/CompareToggle.tsx +81 -0
- package/map/DemoControls.tsx +173 -0
- package/map/DemoMap.tsx +98 -0
- package/map/GeocoderDemo.tsx +178 -0
- package/map/OverlayLayers.tsx +46 -0
- package/map/PlaceAutocomplete.tsx +69 -0
- package/map/PlaceMarker.tsx +40 -0
- package/map/ResolvedPlaceLayers.tsx +63 -0
- package/map/ResultCamera.tsx +60 -0
- package/map/ResultOverlay.tsx +61 -0
- package/map/ResultPanel.tsx +84 -0
- package/map/VersionPicker.tsx +62 -0
- package/map/geometry.ts +135 -0
- package/map/index.ts +73 -0
- package/map/place-render.ts +157 -0
- package/map/types.ts +207 -0
- package/map/useCompareState.ts +45 -0
- package/map/useDemoGeocode.ts +67 -0
- package/map/useMapPlaceRender.ts +21 -0
- package/map/usePlaceAutocomplete.ts +195 -0
- package/out/common/PresetChips.d.ts +3 -1
- package/out/common/PresetChips.d.ts.map +1 -1
- package/out/common/PresetChips.js +2 -2
- package/out/common/PresetChips.js.map +1 -1
- package/out/index.d.ts +3 -0
- package/out/index.d.ts.map +1 -1
- package/out/index.js +2 -0
- package/out/index.js.map +1 -1
- package/out/map/BackendControl.d.ts +24 -0
- package/out/map/BackendControl.d.ts.map +1 -0
- package/out/map/BackendControl.js +6 -0
- package/out/map/BackendControl.js.map +1 -0
- package/out/map/CompareToggle.d.ts +35 -0
- package/out/map/CompareToggle.d.ts.map +1 -0
- package/out/map/CompareToggle.js +9 -0
- package/out/map/CompareToggle.js.map +1 -0
- package/out/map/DemoControls.d.ts +44 -0
- package/out/map/DemoControls.d.ts.map +1 -0
- package/out/map/DemoControls.js +28 -0
- package/out/map/DemoControls.js.map +1 -0
- package/out/map/DemoMap.d.ts +59 -0
- package/out/map/DemoMap.d.ts.map +1 -0
- package/out/map/DemoMap.js +12 -0
- package/out/map/DemoMap.js.map +1 -0
- package/out/map/GeocoderDemo.d.ts +47 -0
- package/out/map/GeocoderDemo.d.ts.map +1 -0
- package/out/map/GeocoderDemo.js +73 -0
- package/out/map/GeocoderDemo.js.map +1 -0
- package/out/map/OverlayLayers.d.ts +23 -0
- package/out/map/OverlayLayers.d.ts.map +1 -0
- package/out/map/OverlayLayers.js +27 -0
- package/out/map/OverlayLayers.js.map +1 -0
- package/out/map/PlaceAutocomplete.d.ts +34 -0
- package/out/map/PlaceAutocomplete.d.ts.map +1 -0
- package/out/map/PlaceAutocomplete.js +9 -0
- package/out/map/PlaceAutocomplete.js.map +1 -0
- package/out/map/PlaceMarker.d.ts +27 -0
- package/out/map/PlaceMarker.d.ts.map +1 -0
- package/out/map/PlaceMarker.js +9 -0
- package/out/map/PlaceMarker.js.map +1 -0
- package/out/map/ResolvedPlaceLayers.d.ts +34 -0
- package/out/map/ResolvedPlaceLayers.d.ts.map +1 -0
- package/out/map/ResolvedPlaceLayers.js +11 -0
- package/out/map/ResolvedPlaceLayers.js.map +1 -0
- package/out/map/ResultCamera.d.ts +34 -0
- package/out/map/ResultCamera.d.ts.map +1 -0
- package/out/map/ResultCamera.js +43 -0
- package/out/map/ResultCamera.js.map +1 -0
- package/out/map/ResultOverlay.d.ts +34 -0
- package/out/map/ResultOverlay.d.ts.map +1 -0
- package/out/map/ResultOverlay.js +18 -0
- package/out/map/ResultOverlay.js.map +1 -0
- package/out/map/ResultPanel.d.ts +34 -0
- package/out/map/ResultPanel.d.ts.map +1 -0
- package/out/map/ResultPanel.js +12 -0
- package/out/map/ResultPanel.js.map +1 -0
- package/out/map/VersionPicker.d.ts +30 -0
- package/out/map/VersionPicker.d.ts.map +1 -0
- package/out/map/VersionPicker.js +8 -0
- package/out/map/VersionPicker.js.map +1 -0
- package/out/map/geometry.d.ts +55 -0
- package/out/map/geometry.d.ts.map +1 -0
- package/out/map/geometry.js +99 -0
- package/out/map/geometry.js.map +1 -0
- package/out/map/index.d.ts +52 -0
- package/out/map/index.d.ts.map +1 -0
- package/out/map/index.js +36 -0
- package/out/map/index.js.map +1 -0
- package/out/map/place-render.d.ts +85 -0
- package/out/map/place-render.d.ts.map +1 -0
- package/out/map/place-render.js +98 -0
- package/out/map/place-render.js.map +1 -0
- package/out/map/types.d.ts +193 -0
- package/out/map/types.d.ts.map +1 -0
- package/out/map/types.js +18 -0
- package/out/map/types.js.map +1 -0
- package/out/map/useCompareState.d.ts +25 -0
- package/out/map/useCompareState.d.ts.map +1 -0
- package/out/map/useCompareState.js +27 -0
- package/out/map/useCompareState.js.map +1 -0
- package/out/map/useDemoGeocode.d.ts +36 -0
- package/out/map/useDemoGeocode.d.ts.map +1 -0
- package/out/map/useDemoGeocode.js +44 -0
- package/out/map/useDemoGeocode.js.map +1 -0
- package/out/map/useMapPlaceRender.d.ts +15 -0
- package/out/map/useMapPlaceRender.d.ts.map +1 -0
- package/out/map/useMapPlaceRender.js +18 -0
- package/out/map/useMapPlaceRender.js.map +1 -0
- package/out/map/usePlaceAutocomplete.d.ts +61 -0
- package/out/map/usePlaceAutocomplete.d.ts.map +1 -0
- package/out/map/usePlaceAutocomplete.js +122 -0
- package/out/map/usePlaceAutocomplete.js.map +1 -0
- package/out/pipeline/QueryForm.d.ts +6 -2
- package/out/pipeline/QueryForm.d.ts.map +1 -1
- package/out/pipeline/QueryForm.js +2 -2
- package/out/pipeline/QueryForm.js.map +1 -1
- package/out/runtime/useDemoRuntime.d.ts +113 -0
- package/out/runtime/useDemoRuntime.d.ts.map +1 -0
- package/out/runtime/useDemoRuntime.js +135 -0
- package/out/runtime/useDemoRuntime.js.map +1 -0
- package/package.json +27 -6
- package/pipeline/QueryForm.tsx +17 -2
- package/runtime/useDemoRuntime.ts +245 -0
- package/styles.css +145 -0
- 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
|
+
}
|
package/map/geometry.ts
ADDED
|
@@ -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"
|