@mailwoman/react 9.3.0 → 10.0.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/fonts.css +59 -0
- package/lib/common/About.tsx +105 -0
- package/lib/common/KindBadge.tsx +2 -7
- package/lib/common/confidence-tiers.ts +50 -0
- package/lib/common/index.ts +22 -0
- package/lib/common/text-tokens.ts +88 -0
- package/lib/index.ts +10 -103
- package/lib/map/BackendControl.tsx +7 -3
- package/lib/map/CompareToggle.tsx +5 -4
- package/lib/map/{GeocoderDemo.tsx → Geocoder.tsx} +81 -61
- package/lib/map/GeocoderControls.tsx +352 -0
- package/lib/map/{DemoMap.tsx → MapCanvas.tsx} +15 -15
- package/lib/map/MapChipRow.tsx +77 -0
- package/lib/map/MapCompass.tsx +77 -0
- package/lib/map/MapControlStack.tsx +98 -0
- package/lib/map/MapFooter.tsx +115 -0
- package/lib/map/MapProgressBar.tsx +58 -0
- package/lib/map/MapSearchBar.tsx +68 -0
- package/lib/map/MapSheet.tsx +62 -0
- package/lib/map/PlaceMarker.tsx +1 -1
- package/lib/map/ResolvedPlaceLayers.tsx +3 -3
- package/lib/map/ResultCamera.tsx +1 -1
- package/lib/map/ResultPanel.tsx +2 -2
- package/lib/map/VersionPicker.tsx +3 -3
- package/lib/map/fake-runtime.ts +122 -0
- package/lib/map/index.ts +35 -16
- package/lib/map/map-debug.ts +51 -0
- package/lib/map/place-render.ts +3 -2
- package/lib/map/types.ts +58 -42
- package/lib/map/{useDemoGeocode.ts → useGeocode.ts} +8 -8
- package/lib/map/useMapBearing.ts +56 -0
- package/lib/map/useMapLabelPick.ts +123 -0
- package/lib/pipeline/CandidatePicker.tsx +1 -1
- package/lib/pipeline/ComponentTable.tsx +1 -2
- package/lib/pipeline/ConfidenceCell.tsx +7 -19
- package/lib/pipeline/FailureDiagnostic.tsx +56 -0
- package/lib/pipeline/ResolvedPlace.tsx +17 -4
- package/lib/pipeline/SpanHighlight.tsx +97 -0
- package/lib/pipeline/TimingPanel.tsx +65 -0
- package/lib/pipeline/TreeView.tsx +55 -0
- package/lib/pipeline/copy.ts +1 -1
- package/lib/pipeline/index.ts +36 -0
- package/lib/pipeline/types.ts +2 -75
- package/lib/pipeline/useParsePipeline.ts +31 -19
- package/lib/poi/index.ts +39 -0
- package/lib/poi/types.ts +2 -1
- package/lib/runtime/index.ts +16 -0
- package/lib/runtime/{useDemoRuntime.ts → useReleaseRuntime.ts} +22 -24
- package/out/common/About.d.ts +21 -0
- package/out/common/About.d.ts.map +1 -0
- package/out/common/About.js +8 -0
- package/out/common/About.js.map +1 -0
- package/out/common/KindBadge.d.ts +2 -9
- package/out/common/KindBadge.d.ts.map +1 -1
- package/out/common/KindBadge.js.map +1 -1
- package/out/common/confidence-tiers.d.ts +36 -0
- package/out/common/confidence-tiers.d.ts.map +1 -0
- package/out/common/confidence-tiers.js +42 -0
- package/out/common/confidence-tiers.js.map +1 -0
- package/out/common/index.d.ts +22 -0
- package/out/common/index.d.ts.map +1 -0
- package/out/common/index.js +15 -0
- package/out/common/index.js.map +1 -0
- package/out/common/text-tokens.d.ts +46 -0
- package/out/common/text-tokens.d.ts.map +1 -0
- package/out/common/text-tokens.js +52 -0
- package/out/common/text-tokens.js.map +1 -0
- package/out/index.d.ts +5 -51
- package/out/index.d.ts.map +1 -1
- package/out/index.js +4 -30
- package/out/index.js.map +1 -1
- package/out/map/BackendControl.d.ts.map +1 -1
- package/out/map/BackendControl.js +1 -1
- package/out/map/BackendControl.js.map +1 -1
- package/out/map/CompareToggle.d.ts +2 -2
- package/out/map/CompareToggle.d.ts.map +1 -1
- package/out/map/CompareToggle.js +1 -1
- package/out/map/CompareToggle.js.map +1 -1
- package/out/map/Geocoder.d.ts +67 -0
- package/out/map/Geocoder.d.ts.map +1 -0
- package/out/map/Geocoder.js +98 -0
- package/out/map/Geocoder.js.map +1 -0
- package/out/map/GeocoderControls.d.ts +81 -0
- package/out/map/GeocoderControls.d.ts.map +1 -0
- package/out/map/GeocoderControls.js +79 -0
- package/out/map/GeocoderControls.js.map +1 -0
- package/out/map/{DemoMap.d.ts → MapCanvas.d.ts} +15 -15
- package/out/map/MapCanvas.d.ts.map +1 -0
- package/out/map/{DemoMap.js → MapCanvas.js} +2 -2
- package/out/map/MapCanvas.js.map +1 -0
- package/out/map/MapChipRow.d.ts +47 -0
- package/out/map/MapChipRow.d.ts.map +1 -0
- package/out/map/MapChipRow.js +12 -0
- package/out/map/MapChipRow.js.map +1 -0
- package/out/map/MapCompass.d.ts +42 -0
- package/out/map/MapCompass.d.ts.map +1 -0
- package/out/map/MapCompass.js +11 -0
- package/out/map/MapCompass.js.map +1 -0
- package/out/map/MapControlStack.d.ts +57 -0
- package/out/map/MapControlStack.d.ts.map +1 -0
- package/out/map/MapControlStack.js +15 -0
- package/out/map/MapControlStack.js.map +1 -0
- package/out/map/MapFooter.d.ts +40 -0
- package/out/map/MapFooter.d.ts.map +1 -0
- package/out/map/MapFooter.js +51 -0
- package/out/map/MapFooter.js.map +1 -0
- package/out/map/MapProgressBar.d.ts +34 -0
- package/out/map/MapProgressBar.d.ts.map +1 -0
- package/out/map/MapProgressBar.js +8 -0
- package/out/map/MapProgressBar.js.map +1 -0
- package/out/map/MapSearchBar.d.ts +49 -0
- package/out/map/MapSearchBar.d.ts.map +1 -0
- package/out/map/MapSearchBar.js +6 -0
- package/out/map/MapSearchBar.js.map +1 -0
- package/out/map/MapSheet.d.ts +32 -0
- package/out/map/MapSheet.d.ts.map +1 -0
- package/out/map/MapSheet.js +33 -0
- package/out/map/MapSheet.js.map +1 -0
- package/out/map/OverlayLayers.js +1 -1
- package/out/map/PlaceMarker.d.ts +1 -1
- package/out/map/ResolvedPlaceLayers.d.ts +3 -3
- package/out/map/ResolvedPlaceLayers.js +1 -1
- package/out/map/ResultCamera.d.ts +1 -1
- package/out/map/ResultCamera.js +1 -1
- package/out/map/ResultPanel.d.ts +2 -2
- package/out/map/ResultPanel.d.ts.map +1 -1
- package/out/map/ResultPanel.js +1 -1
- package/out/map/ResultPanel.js.map +1 -1
- package/out/map/VersionPicker.d.ts +3 -3
- package/out/map/VersionPicker.d.ts.map +1 -1
- package/out/map/fake-runtime.d.ts +38 -0
- package/out/map/fake-runtime.d.ts.map +1 -0
- package/out/map/fake-runtime.js +107 -0
- package/out/map/fake-runtime.js.map +1 -0
- package/out/map/index.d.ts +27 -10
- package/out/map/index.d.ts.map +1 -1
- package/out/map/index.js +16 -6
- package/out/map/index.js.map +1 -1
- package/out/map/map-debug.d.ts +31 -0
- package/out/map/map-debug.d.ts.map +1 -0
- package/out/map/map-debug.js +31 -0
- package/out/map/map-debug.js.map +1 -0
- package/out/map/place-render.d.ts +2 -2
- package/out/map/place-render.d.ts.map +1 -1
- package/out/map/place-render.js +1 -1
- package/out/map/place-render.js.map +1 -1
- package/out/map/types.d.ts +60 -42
- package/out/map/types.d.ts.map +1 -1
- package/out/map/types.js +5 -5
- package/out/map/{useDemoGeocode.d.ts → useGeocode.d.ts} +9 -9
- package/out/map/useGeocode.d.ts.map +1 -0
- package/out/map/{useDemoGeocode.js → useGeocode.js} +4 -4
- package/out/map/useGeocode.js.map +1 -0
- package/out/map/useMapBearing.d.ts +28 -0
- package/out/map/useMapBearing.d.ts.map +1 -0
- package/out/map/useMapBearing.js +35 -0
- package/out/map/useMapBearing.js.map +1 -0
- package/out/map/useMapLabelPick.d.ts +23 -0
- package/out/map/useMapLabelPick.d.ts.map +1 -0
- package/out/map/useMapLabelPick.js +104 -0
- package/out/map/useMapLabelPick.js.map +1 -0
- package/out/pipeline/CandidatePicker.d.ts +1 -1
- package/out/pipeline/CandidatePicker.d.ts.map +1 -1
- package/out/pipeline/CandidatePicker.js +1 -1
- package/out/pipeline/CandidatePicker.js.map +1 -1
- package/out/pipeline/ComponentTable.d.ts +1 -1
- package/out/pipeline/ComponentTable.d.ts.map +1 -1
- package/out/pipeline/ComponentTable.js.map +1 -1
- package/out/pipeline/ConfidenceCell.d.ts +2 -2
- package/out/pipeline/ConfidenceCell.d.ts.map +1 -1
- package/out/pipeline/ConfidenceCell.js +4 -16
- package/out/pipeline/ConfidenceCell.js.map +1 -1
- package/out/pipeline/FailureDiagnostic.d.ts +15 -0
- package/out/pipeline/FailureDiagnostic.d.ts.map +1 -0
- package/out/pipeline/FailureDiagnostic.js +21 -0
- package/out/pipeline/FailureDiagnostic.js.map +1 -0
- package/out/pipeline/PipelineExplorer.js +1 -1
- package/out/pipeline/QueryForm.js +1 -1
- package/out/pipeline/ResolvedPlace.d.ts +1 -1
- package/out/pipeline/ResolvedPlace.d.ts.map +1 -1
- package/out/pipeline/ResolvedPlace.js +4 -10
- package/out/pipeline/ResolvedPlace.js.map +1 -1
- package/out/pipeline/SpanHighlight.d.ts +23 -0
- package/out/pipeline/SpanHighlight.d.ts.map +1 -0
- package/out/pipeline/SpanHighlight.js +31 -0
- package/out/pipeline/SpanHighlight.js.map +1 -0
- package/out/pipeline/TimingPanel.d.ts +16 -0
- package/out/pipeline/TimingPanel.d.ts.map +1 -0
- package/out/pipeline/TimingPanel.js +20 -0
- package/out/pipeline/TimingPanel.js.map +1 -0
- package/out/pipeline/TreeView.d.ts +17 -0
- package/out/pipeline/TreeView.d.ts.map +1 -0
- package/out/pipeline/TreeView.js +15 -0
- package/out/pipeline/TreeView.js.map +1 -0
- package/out/pipeline/copy.d.ts +1 -1
- package/out/pipeline/copy.d.ts.map +1 -1
- package/out/pipeline/index.d.ts +33 -0
- package/out/pipeline/index.d.ts.map +1 -0
- package/out/pipeline/index.js +21 -0
- package/out/pipeline/index.js.map +1 -0
- package/out/pipeline/types.d.ts +2 -68
- package/out/pipeline/types.d.ts.map +1 -1
- package/out/pipeline/types.js +1 -1
- package/out/pipeline/useParsePipeline.d.ts +7 -3
- package/out/pipeline/useParsePipeline.d.ts.map +1 -1
- package/out/pipeline/useParsePipeline.js +6 -2
- package/out/pipeline/useParsePipeline.js.map +1 -1
- package/out/poi/LiveResultsBlock.js +1 -1
- package/out/poi/POIExplorer.js +1 -1
- package/out/poi/SubjectPanel.js +1 -1
- package/out/poi/index.d.ts +23 -0
- package/out/poi/index.d.ts.map +1 -0
- package/out/poi/index.js +15 -0
- package/out/poi/index.js.map +1 -0
- package/out/poi/runtime.d.ts.map +1 -1
- package/out/poi/types.d.ts +2 -1
- package/out/poi/types.d.ts.map +1 -1
- package/out/runtime/index.d.ts +9 -0
- package/out/runtime/index.d.ts.map +1 -0
- package/out/runtime/index.js +8 -0
- package/out/runtime/index.js.map +1 -0
- package/out/runtime/{useDemoRuntime.d.ts → useReleaseRuntime.d.ts} +18 -20
- package/out/runtime/useReleaseRuntime.d.ts.map +1 -0
- package/out/runtime/{useDemoRuntime.js → useReleaseRuntime.js} +4 -4
- package/out/runtime/useReleaseRuntime.js.map +1 -0
- package/package.json +222 -31
- package/styleframe.config.ts +329 -0
- package/styles.css +1995 -663
- package/tokens/index.css +116 -0
- package/tokens/tokens.json +528 -0
- package/tokens/tokens.resolver.json +185 -0
- package/lib/map/DemoControls.tsx +0 -194
- package/out/map/DemoControls.d.ts +0 -64
- package/out/map/DemoControls.d.ts.map +0 -1
- package/out/map/DemoControls.js +0 -30
- package/out/map/DemoControls.js.map +0 -1
- package/out/map/DemoMap.d.ts.map +0 -1
- package/out/map/DemoMap.js.map +0 -1
- package/out/map/GeocoderDemo.d.ts +0 -57
- package/out/map/GeocoderDemo.d.ts.map +0 -1
- package/out/map/GeocoderDemo.js +0 -98
- package/out/map/GeocoderDemo.js.map +0 -1
- package/out/map/useDemoGeocode.d.ts.map +0 -1
- package/out/map/useDemoGeocode.js.map +0 -1
- package/out/runtime/useDemoRuntime.d.ts.map +0 -1
- package/out/runtime/useDemoRuntime.js.map +0 -1
|
@@ -3,47 +3,47 @@
|
|
|
3
3
|
* @license AGPL-3.0
|
|
4
4
|
* @author Teffen Ellis, et al.
|
|
5
5
|
*
|
|
6
|
-
* `<
|
|
7
|
-
* and takes the SAME DI shape: an injected {@link
|
|
8
|
-
* the composed map style) plus a {@link
|
|
6
|
+
* `<Geocoder>` — the WHOLE geocoder, composed. It is the map analogue of `PipelineExplorer`
|
|
7
|
+
* and takes the SAME DI shape: an injected {@link GeocoderRuntime} (the host owns ONNX / httpvfs / R2 /
|
|
8
|
+
* the composed map style) plus a {@link GeocoderPanels} bag (the host's ModelVisualizer / VersionCompare /
|
|
9
9
|
* About / Permalink). Everything here is composition + a `ClientOnly` boundary:
|
|
10
10
|
*
|
|
11
|
-
* - the floating {@link
|
|
12
|
-
* - the declarative {@link
|
|
11
|
+
* - the floating {@link GeocoderControls} panel (version / compare / backend / query+autocomplete / result),
|
|
12
|
+
* - the declarative {@link MapCanvas} with the phase-2 overlays ({@link OverlayLayers}) and the
|
|
13
13
|
* resolved-place marker/outline/camera ({@link ResolvedPlaceLayers}) driven by the parse state,
|
|
14
|
-
* - the hooks that wire them: {@link
|
|
14
|
+
* - the hooks that wire them: {@link useGeocode} (parse + viewport bias + map place),
|
|
15
15
|
* {@link usePlaceAutocomplete}, {@link useCompareState}.
|
|
16
16
|
*
|
|
17
|
-
* Because it pulls {@link
|
|
17
|
+
* Because it pulls {@link MapCanvas} (→ `react-map-gl` → `maplibre-gl`, WebGL + DOM at import), it lives on
|
|
18
18
|
* the `@mailwoman/react/map` subpath ONLY — never the package root. The whole thing renders in Storybook
|
|
19
19
|
* over a fake runtime (offline stub style + canned geocode) with no network, no ONNX, no gazetteer.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { type ReactNode, useCallback, useEffect, useRef } from "react"
|
|
22
|
+
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"
|
|
23
23
|
import type { MapRef } from "react-map-gl/maplibre"
|
|
24
24
|
|
|
25
|
-
import type {
|
|
25
|
+
import type { GeocoderPanels, GeocoderRuntime, MapBias } from "#map/types"
|
|
26
26
|
import { useCompareState } from "#map/useCompareState"
|
|
27
|
-
import {
|
|
27
|
+
import { useGeocode } from "#map/useGeocode"
|
|
28
28
|
import { useMapPlaceRender } from "#map/useMapPlaceRender"
|
|
29
29
|
import { usePlaceAutocomplete } from "#map/usePlaceAutocomplete"
|
|
30
30
|
|
|
31
31
|
import { ClientOnly } from "../common/ClientOnly.tsx"
|
|
32
32
|
import type { Preset } from "../common/PresetChips.tsx"
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
33
|
+
import { GeocoderControls } from "./GeocoderControls.tsx"
|
|
34
|
+
import { MapCanvas } from "./MapCanvas.tsx"
|
|
35
35
|
import { OverlayLayers } from "./OverlayLayers.tsx"
|
|
36
36
|
import { ResolvedPlaceLayers } from "./ResolvedPlaceLayers.tsx"
|
|
37
37
|
|
|
38
|
-
export interface
|
|
38
|
+
export interface GeocoderProps {
|
|
39
39
|
/**
|
|
40
|
-
* The injected
|
|
40
|
+
* The injected geocoder runtime (map style + overlays + parse + version/backend).
|
|
41
41
|
*/
|
|
42
|
-
runtime:
|
|
42
|
+
runtime: GeocoderRuntime
|
|
43
43
|
/**
|
|
44
44
|
* Host-injected panels (about, release blurb, compare, permalink, debug drawer, map controls, …).
|
|
45
45
|
*/
|
|
46
|
-
panels?:
|
|
46
|
+
panels?: GeocoderPanels
|
|
47
47
|
/**
|
|
48
48
|
* Address to pre-fill.
|
|
49
49
|
*/
|
|
@@ -52,74 +52,87 @@ export interface GeocoderDemoProps {
|
|
|
52
52
|
* Example chips. @default the empty set (host supplies its own).
|
|
53
53
|
*/
|
|
54
54
|
presets?: ReadonlyArray<Preset>
|
|
55
|
+
/**
|
|
56
|
+
* Open the developer disclosure on mount — the model version, the backend readout and compare. Collapsed by default
|
|
57
|
+
* so the address field is the first thing a visitor meets. @default false
|
|
58
|
+
*/
|
|
59
|
+
developer?: boolean
|
|
60
|
+
/**
|
|
61
|
+
* Fired with the query each time one is submitted. The host writes it into its own URL — this package never touches
|
|
62
|
+
* `location`, because which parameter carries a query is the app's decision.
|
|
63
|
+
*/
|
|
64
|
+
onSubmitQuery?: (query: string) => void
|
|
55
65
|
/**
|
|
56
66
|
* Only hint the viewport bias once the visitor has zoomed past the global view — a whole-globe center is noise.
|
|
57
|
-
* Matches the
|
|
67
|
+
* Matches the `map.getZoom() >= 4` threshold. @default 4
|
|
58
68
|
*/
|
|
59
69
|
minBiasZoom?: number
|
|
60
70
|
/**
|
|
61
71
|
* Fly/fit the map to the resolved place on each result (via {@link ResolvedPlaceLayers}). @default true. Set false
|
|
62
|
-
* for a host that drives the camera itself (a controlled `<
|
|
72
|
+
* for a host that drives the camera itself (a controlled `<MapCanvas viewState>`), or to keep a headless test
|
|
63
73
|
* deterministic — the marker + outline still render, only the animated camera move is skipped.
|
|
64
74
|
*/
|
|
65
75
|
applyResultCamera?: boolean
|
|
66
76
|
}
|
|
67
77
|
|
|
68
|
-
interface
|
|
69
|
-
Pick<
|
|
78
|
+
interface GeocoderInnerProps extends Required<
|
|
79
|
+
Pick<GeocoderProps, "runtime" | "defaultAddress" | "minBiasZoom" | "applyResultCamera" | "developer">
|
|
70
80
|
> {
|
|
71
|
-
panels:
|
|
81
|
+
panels: GeocoderPanels
|
|
72
82
|
presets: ReadonlyArray<Preset>
|
|
83
|
+
onSubmitQuery?: (query: string) => void
|
|
73
84
|
}
|
|
74
85
|
|
|
75
|
-
function
|
|
86
|
+
function GeocoderInner({
|
|
76
87
|
runtime,
|
|
77
88
|
panels,
|
|
78
89
|
defaultAddress,
|
|
79
90
|
presets,
|
|
80
91
|
minBiasZoom,
|
|
81
92
|
applyResultCamera,
|
|
82
|
-
|
|
93
|
+
developer,
|
|
94
|
+
onSubmitQuery,
|
|
95
|
+
}: GeocoderInnerProps): ReactNode {
|
|
83
96
|
const mapRef = useRef<MapRef>(null)
|
|
84
|
-
|
|
97
|
+
// The chrome sits OUTSIDE `<MapCanvas>`, so it cannot take the handle from `useMap()`. A ref alone does not
|
|
98
|
+
// re-render the compass or the layer control when the map arrives, so the same poll that publishes the test
|
|
99
|
+
// handle also puts it in state — one poll, two consumers.
|
|
100
|
+
const [map, setMap] = useState<ReturnType<MapRef["getMap"]> | null>(null)
|
|
101
|
+
|
|
102
|
+
// The map announces itself; nothing polls for it. `onLoad` carries the instance, so the compass and the layer
|
|
103
|
+
// control render on the frame the map is ready rather than up to an interval later.
|
|
104
|
+
//
|
|
85
105
|
// TEST INJECTION POINT: the e2e viewport-bias suite drives the REAL map (pan + zoom past the bias threshold)
|
|
86
|
-
// before submitting, and a browser test cannot reach a React ref — so the
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const host = globalThis as { __mailwomanDemoMap?: ReturnType<MapRef["getMap"]> }
|
|
92
|
-
|
|
93
|
-
const timer = setInterval(() => {
|
|
94
|
-
const map = mapRef.current?.getMap()
|
|
95
|
-
|
|
96
|
-
if (map) {
|
|
97
|
-
host.__mailwomanDemoMap = map
|
|
98
|
-
clearInterval(timer)
|
|
99
|
-
}
|
|
100
|
-
}, 250)
|
|
101
|
-
|
|
102
|
-
return () => {
|
|
103
|
-
clearInterval(timer)
|
|
104
|
-
delete host.__mailwomanDemoMap
|
|
105
|
-
}
|
|
106
|
+
// before submitting, and a browser test cannot reach a React ref — so the same handle is republished on
|
|
107
|
+
// `globalThis.__mailwomanMapCanvas`, and cleared on unmount so a torn-down geocoder leaves no stale handle.
|
|
108
|
+
const onMapLoad = useCallback((event: { target: ReturnType<MapRef["getMap"]> }) => {
|
|
109
|
+
;(globalThis as { __mailwomanMapCanvas?: ReturnType<MapRef["getMap"]> }).__mailwomanMapCanvas = event.target
|
|
110
|
+
setMap(event.target)
|
|
106
111
|
}, [])
|
|
107
112
|
|
|
113
|
+
useEffect(
|
|
114
|
+
() => () => {
|
|
115
|
+
setMap(null)
|
|
116
|
+
delete (globalThis as { __mailwomanMapCanvas?: ReturnType<MapRef["getMap"]> }).__mailwomanMapCanvas
|
|
117
|
+
},
|
|
118
|
+
[]
|
|
119
|
+
)
|
|
120
|
+
|
|
108
121
|
// Read the viewport bias at submit time — through the map handle, never a threaded state value, so granting/zooming
|
|
109
122
|
// mid-session doesn't re-create the parse callback. Below the min-bias zoom, a whole-globe center is noise → null.
|
|
110
123
|
const getBias = useCallback((): MapBias | null => {
|
|
111
|
-
const
|
|
124
|
+
const live = mapRef.current?.getMap()
|
|
112
125
|
|
|
113
|
-
if (!
|
|
114
|
-
const zoom =
|
|
126
|
+
if (!live) return null
|
|
127
|
+
const zoom = live.getZoom()
|
|
115
128
|
|
|
116
129
|
if (zoom < minBiasZoom) return null
|
|
117
|
-
const center =
|
|
130
|
+
const center = live.getCenter()
|
|
118
131
|
|
|
119
132
|
return { center: [center.lng, center.lat], zoom }
|
|
120
133
|
}, [minBiasZoom])
|
|
121
134
|
|
|
122
|
-
const geocode =
|
|
135
|
+
const geocode = useGeocode({ runtime, defaultText: defaultAddress, getBias })
|
|
123
136
|
const compare = useCompareState()
|
|
124
137
|
|
|
125
138
|
const autocomplete = usePlaceAutocomplete({
|
|
@@ -143,7 +156,7 @@ function GeocoderDemoInner({
|
|
|
143
156
|
return (
|
|
144
157
|
<div className="mw-geocoder-demo">
|
|
145
158
|
<div className="mw-geocoder-demo__map">
|
|
146
|
-
<
|
|
159
|
+
<MapCanvas
|
|
147
160
|
mapStyle={runtime.mapStyle}
|
|
148
161
|
mapRef={mapRef}
|
|
149
162
|
initialViewState={{
|
|
@@ -152,17 +165,17 @@ function GeocoderDemoInner({
|
|
|
152
165
|
zoom: runtime.initialZoom ?? 3,
|
|
153
166
|
}}
|
|
154
167
|
style={{ width: "100%", height: "100%" }}
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
mapProps={{ attributionControl: { compact: true }, maplibreLogo: false }}
|
|
168
|
+
// One compact attribution pill (the map's own default is a wide, always-open "MapLibre | © …" bar), no
|
|
169
|
+
// maplibre wordmark logo.
|
|
170
|
+
mapProps={{ attributionControl: { compact: true }, maplibreLogo: false, onLoad: onMapLoad }}
|
|
158
171
|
>
|
|
159
172
|
<OverlayLayers overlays={runtime.overlays} />
|
|
160
173
|
<ResolvedPlaceLayers spec={spec} applyCamera={applyResultCamera} />
|
|
161
174
|
{panels.mapControls}
|
|
162
|
-
</
|
|
175
|
+
</MapCanvas>
|
|
163
176
|
</div>
|
|
164
177
|
|
|
165
|
-
<
|
|
178
|
+
<GeocoderControls
|
|
166
179
|
runtime={runtime}
|
|
167
180
|
geocode={geocode}
|
|
168
181
|
autocomplete={autocomplete}
|
|
@@ -170,8 +183,11 @@ function GeocoderDemoInner({
|
|
|
170
183
|
panels={panels}
|
|
171
184
|
presets={presets}
|
|
172
185
|
placeholder={defaultAddress}
|
|
186
|
+
map={map}
|
|
187
|
+
onSubmitQuery={onSubmitQuery}
|
|
173
188
|
onSelectVersion={onSelectVersion}
|
|
174
189
|
onForceWASMChange={onForceWASMChange}
|
|
190
|
+
developer={developer}
|
|
175
191
|
/>
|
|
176
192
|
|
|
177
193
|
{panels.debugDrawer ? panels.debugDrawer({ result: geocode.result }) : null}
|
|
@@ -182,36 +198,40 @@ function GeocoderDemoInner({
|
|
|
182
198
|
/**
|
|
183
199
|
* Stable empty defaults — a fresh `{}`/`[]` per render would churn every downstream memo dep.
|
|
184
200
|
*/
|
|
185
|
-
const NO_PANELS:
|
|
201
|
+
const NO_PANELS: GeocoderPanels = {}
|
|
186
202
|
const NO_PRESETS: ReadonlyArray<Preset> = []
|
|
187
203
|
|
|
188
204
|
/**
|
|
189
|
-
* The composed geocoder
|
|
205
|
+
* The composed geocoder, behind a `ClientOnly` SSR boundary (the map is intrinsically a client component).
|
|
190
206
|
*/
|
|
191
|
-
export function
|
|
207
|
+
export function Geocoder({
|
|
192
208
|
runtime,
|
|
193
209
|
panels = NO_PANELS,
|
|
194
210
|
defaultAddress = "",
|
|
195
211
|
presets = NO_PRESETS,
|
|
196
212
|
minBiasZoom = 4,
|
|
197
213
|
applyResultCamera = true,
|
|
198
|
-
|
|
214
|
+
developer = false,
|
|
215
|
+
onSubmitQuery,
|
|
216
|
+
}: GeocoderProps): ReactNode {
|
|
199
217
|
return (
|
|
200
218
|
<ClientOnly
|
|
201
219
|
fallback={
|
|
202
220
|
<div className="mw-geocoder-demo">
|
|
203
|
-
<p>Loading
|
|
221
|
+
<p>Loading the geocoder…</p>
|
|
204
222
|
</div>
|
|
205
223
|
}
|
|
206
224
|
>
|
|
207
225
|
{() => (
|
|
208
|
-
<
|
|
226
|
+
<GeocoderInner
|
|
209
227
|
runtime={runtime}
|
|
210
228
|
panels={panels}
|
|
211
229
|
defaultAddress={defaultAddress}
|
|
212
230
|
presets={presets}
|
|
213
231
|
minBiasZoom={minBiasZoom}
|
|
214
232
|
applyResultCamera={applyResultCamera}
|
|
233
|
+
developer={developer}
|
|
234
|
+
onSubmitQuery={onSubmitQuery}
|
|
215
235
|
/>
|
|
216
236
|
)}
|
|
217
237
|
</ClientOnly>
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The geocoder's chrome over the map: a search pill on top, example chips on one line beneath it, a control column
|
|
7
|
+
* down the right edge, and a result sheet that rises from the bottom when there is something to show.
|
|
8
|
+
*
|
|
9
|
+
* It replaces a 400px full-height panel that carried every control at once. The layout is the one the reference
|
|
10
|
+
* map apps use, and the reason is the map: chrome that floats over it leaves the map the page, where a slab makes
|
|
11
|
+
* the map the leftover third.
|
|
12
|
+
*
|
|
13
|
+
* NODE-SAFE: pure React + the shared units, no maplibre.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type ReactNode, useCallback, useState } from "react"
|
|
17
|
+
import type { MapInstance } from "react-map-gl/maplibre"
|
|
18
|
+
|
|
19
|
+
import type { GeocoderPanels, GeocoderRuntime } from "#map/types"
|
|
20
|
+
import type { UseCompareState } from "#map/useCompareState"
|
|
21
|
+
import type { UseGeocode } from "#map/useGeocode"
|
|
22
|
+
import { useMapBearing } from "#map/useMapBearing"
|
|
23
|
+
import { useMapLabelPick } from "#map/useMapLabelPick"
|
|
24
|
+
import type { UsePlaceAutocomplete } from "#map/usePlaceAutocomplete"
|
|
25
|
+
|
|
26
|
+
import { LoadingIndicator } from "../common/LoadingIndicator.tsx"
|
|
27
|
+
import type { Preset } from "../common/PresetChips.tsx"
|
|
28
|
+
import { BackendControl } from "./BackendControl.tsx"
|
|
29
|
+
import { CompareToggle } from "./CompareToggle.tsx"
|
|
30
|
+
import { MapChipRow } from "./MapChipRow.tsx"
|
|
31
|
+
import { MapCompass } from "./MapCompass.tsx"
|
|
32
|
+
import { MapControlButton, MapControlGroup, MapControlStack } from "./MapControlStack.tsx"
|
|
33
|
+
import { MapProgressBar } from "./MapProgressBar.tsx"
|
|
34
|
+
import { MapSearchBar } from "./MapSearchBar.tsx"
|
|
35
|
+
import { MapSheet } from "./MapSheet.tsx"
|
|
36
|
+
import { PlaceAutocomplete } from "./PlaceAutocomplete.tsx"
|
|
37
|
+
import { ResultPanel } from "./ResultPanel.tsx"
|
|
38
|
+
import { VersionPicker } from "./VersionPicker.tsx"
|
|
39
|
+
|
|
40
|
+
export interface GeocoderControlsProps {
|
|
41
|
+
/**
|
|
42
|
+
* The injected geocoder runtime.
|
|
43
|
+
*/
|
|
44
|
+
runtime: GeocoderRuntime
|
|
45
|
+
/**
|
|
46
|
+
* The parse+resolve state machine.
|
|
47
|
+
*/
|
|
48
|
+
geocode: UseGeocode
|
|
49
|
+
/**
|
|
50
|
+
* The place-autocomplete combobox state.
|
|
51
|
+
*/
|
|
52
|
+
autocomplete: UsePlaceAutocomplete
|
|
53
|
+
/**
|
|
54
|
+
* The compare-mode state.
|
|
55
|
+
*/
|
|
56
|
+
compare: UseCompareState
|
|
57
|
+
/**
|
|
58
|
+
* Host-injected panels (about, release blurb, compare, permalink, extras, failure).
|
|
59
|
+
*/
|
|
60
|
+
panels: GeocoderPanels
|
|
61
|
+
/**
|
|
62
|
+
* Example chips.
|
|
63
|
+
*/
|
|
64
|
+
presets: ReadonlyArray<Preset>
|
|
65
|
+
/**
|
|
66
|
+
* Input placeholder.
|
|
67
|
+
*/
|
|
68
|
+
placeholder: string
|
|
69
|
+
/**
|
|
70
|
+
* The live map, for the controls that read it: the compass takes its bearing, the host's layer control reads its
|
|
71
|
+
* style. `null` until react-map-gl instantiates the map.
|
|
72
|
+
*/
|
|
73
|
+
map?: MapInstance | null
|
|
74
|
+
/**
|
|
75
|
+
* Fired with the query whenever one is submitted, before the parse starts. The host writes it into the URL; this
|
|
76
|
+
* package never touches `location`, because the address bar is the app's state, not a control's.
|
|
77
|
+
*/
|
|
78
|
+
onSubmitQuery?: (query: string) => void
|
|
79
|
+
/**
|
|
80
|
+
* Select a model version (the composed geocoder also clears a now-colliding compare selection).
|
|
81
|
+
*/
|
|
82
|
+
onSelectVersion: (version: string) => void
|
|
83
|
+
/**
|
|
84
|
+
* Toggle the forced WASM backend.
|
|
85
|
+
*/
|
|
86
|
+
onForceWASMChange: (forceWASM: boolean) => void
|
|
87
|
+
/**
|
|
88
|
+
* Open the developer panel on mount. The model version, the backend readout and compare live inside it: they read on
|
|
89
|
+
* the model rather than on an address, and above the query field they were the first thing every visitor met.
|
|
90
|
+
*
|
|
91
|
+
* @default false
|
|
92
|
+
*/
|
|
93
|
+
developer?: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Which side sheet is open. At most one, because they share an edge and a phone gives each the whole panel.
|
|
98
|
+
*/
|
|
99
|
+
type SheetName = "about" | "layers" | "developer" | null
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The chrome. Everything positioned here floats over the map; nothing occupies a column of the page.
|
|
103
|
+
*/
|
|
104
|
+
export function GeocoderControls({
|
|
105
|
+
runtime,
|
|
106
|
+
geocode,
|
|
107
|
+
autocomplete,
|
|
108
|
+
compare,
|
|
109
|
+
panels,
|
|
110
|
+
presets,
|
|
111
|
+
placeholder,
|
|
112
|
+
map = null,
|
|
113
|
+
onSubmitQuery,
|
|
114
|
+
onSelectVersion,
|
|
115
|
+
onForceWASMChange,
|
|
116
|
+
developer = false,
|
|
117
|
+
}: GeocoderControlsProps): ReactNode {
|
|
118
|
+
const versions = runtime.availableVersions ?? []
|
|
119
|
+
const { busy, result, selectedCandidate } = geocode
|
|
120
|
+
const loading = runtime.loading
|
|
121
|
+
const errorMessage = geocode.parseError ?? runtime.errorMessage ?? null
|
|
122
|
+
|
|
123
|
+
// One sheet at a time. Two open at once stack on the same edge, and on a phone each is the full panel.
|
|
124
|
+
const [openSheet, setOpenSheet] = useState<SheetName>(developer ? "developer" : null)
|
|
125
|
+
const { bearing, resetNorth } = useMapBearing(map)
|
|
126
|
+
|
|
127
|
+
const toggleSheet = (name: Exclude<SheetName, null>) => setOpenSheet((current) => (current === name ? null : name))
|
|
128
|
+
const closeSheet = useCallback(() => setOpenSheet(null), [])
|
|
129
|
+
|
|
130
|
+
// Every path that starts a query goes through here, so the URL is written in exactly one place — the event that
|
|
131
|
+
// caused it, rather than an effect watching the result after the fact.
|
|
132
|
+
const runQuery = useCallback(
|
|
133
|
+
(query: string) => {
|
|
134
|
+
onSubmitQuery?.(query)
|
|
135
|
+
void geocode.submit(query)
|
|
136
|
+
},
|
|
137
|
+
[onSubmitQuery, geocode]
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// A label on the map is a search a visitor already typed by pointing at it.
|
|
141
|
+
const pickLabel = useCallback(
|
|
142
|
+
(name: string) => {
|
|
143
|
+
geocode.setText(name)
|
|
144
|
+
geocode.reset()
|
|
145
|
+
runQuery(name)
|
|
146
|
+
},
|
|
147
|
+
[geocode, runQuery]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
useMapLabelPick(map, pickLabel)
|
|
151
|
+
|
|
152
|
+
const chips = presets.map((preset) => ({ label: preset.label, value: preset.value }))
|
|
153
|
+
// The bundle load no longer opens the result sheet: it reports on the bar at the top of the viewport and in the
|
|
154
|
+
// footer, so an empty sheet does not sit over the map for the length of a 38 MB download.
|
|
155
|
+
const showSheet = Boolean(busy || result || errorMessage)
|
|
156
|
+
|
|
157
|
+
const bundleLoading = Boolean(loading && !runtime.ready)
|
|
158
|
+
const steps = loading?.stepLabels.length ?? 0
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<>
|
|
162
|
+
<MapProgressBar
|
|
163
|
+
active={bundleLoading}
|
|
164
|
+
fraction={steps ? ((loading?.stepIndex ?? 0) + 1) / steps : null}
|
|
165
|
+
label="Loading the geocoder"
|
|
166
|
+
/>
|
|
167
|
+
|
|
168
|
+
<div className="mw-map-chrome mw-map-chrome--top">
|
|
169
|
+
<form
|
|
170
|
+
className="mw-map-chrome__search"
|
|
171
|
+
onSubmit={(event) => {
|
|
172
|
+
event.preventDefault()
|
|
173
|
+
runQuery(geocode.text)
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
{/*
|
|
177
|
+
* `type="search"` brings its own clear button, so the pill carries no trailing slot: a second cross
|
|
178
|
+
* beside the native one is two controls for one job, and the spinner that used to live there changed
|
|
179
|
+
* the field's height on every submit.
|
|
180
|
+
*/}
|
|
181
|
+
<MapSearchBar label="Search addresses" leading={<span aria-hidden="true">⌕</span>} busy={busy}>
|
|
182
|
+
<input
|
|
183
|
+
id="mw-pipeline-input"
|
|
184
|
+
type="search"
|
|
185
|
+
aria-label="Address"
|
|
186
|
+
value={geocode.text}
|
|
187
|
+
onChange={(event) => geocode.setText(event.target.value)}
|
|
188
|
+
disabled={!runtime.ready}
|
|
189
|
+
placeholder={placeholder}
|
|
190
|
+
onKeyDown={autocomplete.onInputKeyDown}
|
|
191
|
+
{...autocomplete.inputProps}
|
|
192
|
+
/>
|
|
193
|
+
</MapSearchBar>
|
|
194
|
+
</form>
|
|
195
|
+
|
|
196
|
+
<PlaceAutocomplete
|
|
197
|
+
suggestions={autocomplete.suggestions}
|
|
198
|
+
activeIndex={autocomplete.activeIndex}
|
|
199
|
+
onPick={autocomplete.pick}
|
|
200
|
+
onHover={autocomplete.setActiveIndex}
|
|
201
|
+
listboxID={autocomplete.listboxID}
|
|
202
|
+
optionID={autocomplete.optionID}
|
|
203
|
+
/>
|
|
204
|
+
|
|
205
|
+
<MapChipRow
|
|
206
|
+
chips={chips}
|
|
207
|
+
label="Example addresses"
|
|
208
|
+
activeValue={geocode.text}
|
|
209
|
+
disabled={!runtime.ready || busy}
|
|
210
|
+
onPick={(value) => {
|
|
211
|
+
geocode.setText(value)
|
|
212
|
+
geocode.reset()
|
|
213
|
+
runQuery(value)
|
|
214
|
+
}}
|
|
215
|
+
/>
|
|
216
|
+
|
|
217
|
+
{panels.bias}
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{/*
|
|
221
|
+
* Every floating control lives in this one column, so nothing can land on top of anything else: the layer
|
|
222
|
+
* control joins the capsule rather than sitting in the left column, and the compass takes its own capsule
|
|
223
|
+
* below because it comes and goes and would otherwise resize the one above it.
|
|
224
|
+
*/}
|
|
225
|
+
<MapControlStack label="Map controls">
|
|
226
|
+
<MapControlGroup>
|
|
227
|
+
<MapControlButton
|
|
228
|
+
label="About this geocoder"
|
|
229
|
+
active={openSheet === "about"}
|
|
230
|
+
onPress={() => toggleSheet("about")}
|
|
231
|
+
>
|
|
232
|
+
<span aria-hidden="true">i</span>
|
|
233
|
+
</MapControlButton>
|
|
234
|
+
|
|
235
|
+
{panels.layers ? (
|
|
236
|
+
<MapControlButton label="Map layers" active={openSheet === "layers"} onPress={() => toggleSheet("layers")}>
|
|
237
|
+
<span aria-hidden="true">≡</span>
|
|
238
|
+
</MapControlButton>
|
|
239
|
+
) : null}
|
|
240
|
+
|
|
241
|
+
<MapControlButton
|
|
242
|
+
label="Developer controls"
|
|
243
|
+
active={openSheet === "developer"}
|
|
244
|
+
onPress={() => toggleSheet("developer")}
|
|
245
|
+
>
|
|
246
|
+
<span aria-hidden="true">⚙</span>
|
|
247
|
+
</MapControlButton>
|
|
248
|
+
</MapControlGroup>
|
|
249
|
+
|
|
250
|
+
<MapControlGroup className="mw-map-control-group--compass">
|
|
251
|
+
<MapCompass bearing={bearing} onResetNorth={resetNorth} />
|
|
252
|
+
</MapControlGroup>
|
|
253
|
+
</MapControlStack>
|
|
254
|
+
|
|
255
|
+
{openSheet === "about" ? (
|
|
256
|
+
<MapSheet title="About" onClose={closeSheet}>
|
|
257
|
+
{panels.header}
|
|
258
|
+
</MapSheet>
|
|
259
|
+
) : null}
|
|
260
|
+
|
|
261
|
+
{openSheet === "layers" ? (
|
|
262
|
+
<MapSheet title="Layers" onClose={closeSheet}>
|
|
263
|
+
{panels.layers?.({ map })}
|
|
264
|
+
</MapSheet>
|
|
265
|
+
) : null}
|
|
266
|
+
|
|
267
|
+
{openSheet === "developer" ? (
|
|
268
|
+
<MapSheet title="Developer" onClose={closeSheet}>
|
|
269
|
+
{panels.releaseInfo ? (
|
|
270
|
+
<div className="mw-map-sheet__row">
|
|
271
|
+
<span className="mw-map-sheet__label">This release</span>
|
|
272
|
+
{panels.releaseInfo}
|
|
273
|
+
</div>
|
|
274
|
+
) : null}
|
|
275
|
+
|
|
276
|
+
<div className="mw-map-sheet__row">
|
|
277
|
+
<VersionPicker
|
|
278
|
+
versions={versions}
|
|
279
|
+
selected={runtime.selectedVersion ?? null}
|
|
280
|
+
onSelect={onSelectVersion}
|
|
281
|
+
disabled={busy}
|
|
282
|
+
/>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<div className="mw-map-sheet__row">
|
|
286
|
+
<BackendControl
|
|
287
|
+
activeBackend={runtime.activeBackend}
|
|
288
|
+
forceWASM={runtime.forceWASM ?? false}
|
|
289
|
+
onForceWASMChange={onForceWASMChange}
|
|
290
|
+
/>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<div className="mw-map-sheet__row">
|
|
294
|
+
<CompareToggle
|
|
295
|
+
versions={versions}
|
|
296
|
+
primaryVersion={runtime.selectedVersion ?? null}
|
|
297
|
+
compareMode={compare.compareMode}
|
|
298
|
+
onCompareModeChange={compare.setCompareMode}
|
|
299
|
+
compareVersion={compare.compareVersion}
|
|
300
|
+
onCompareVersionChange={compare.setCompareVersion}
|
|
301
|
+
disabled={busy}
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
{panels.developerExtras}
|
|
306
|
+
</MapSheet>
|
|
307
|
+
) : null}
|
|
308
|
+
|
|
309
|
+
{showSheet ? (
|
|
310
|
+
<section className="mw-map-sheet mw-map-sheet--bottom" aria-label="Result">
|
|
311
|
+
<div className="mw-map-sheet__handle" aria-hidden="true" />
|
|
312
|
+
|
|
313
|
+
{errorMessage ? <p className="mw-error">{errorMessage}</p> : null}
|
|
314
|
+
|
|
315
|
+
{panels.aboveResult ? panels.aboveResult({ result }) : null}
|
|
316
|
+
|
|
317
|
+
{busy ? (
|
|
318
|
+
<div className="mw-result">
|
|
319
|
+
<LoadingIndicator mode="staged" steps={runtime.parseStageLabels} activeStep={geocode.parseStage} />
|
|
320
|
+
</div>
|
|
321
|
+
) : result ? (
|
|
322
|
+
panels.result ? (
|
|
323
|
+
panels.result({
|
|
324
|
+
result,
|
|
325
|
+
selectedCandidate,
|
|
326
|
+
selectedCandidateIndex: geocode.selectedCandidateIndex,
|
|
327
|
+
onSelectCandidate: geocode.selectCandidate,
|
|
328
|
+
})
|
|
329
|
+
) : (
|
|
330
|
+
<ResultPanel
|
|
331
|
+
result={result}
|
|
332
|
+
selectedCandidate={selectedCandidate}
|
|
333
|
+
selectedCandidateIndex={geocode.selectedCandidateIndex}
|
|
334
|
+
onSelectCandidate={geocode.selectCandidate}
|
|
335
|
+
extras={panels.extras}
|
|
336
|
+
failure={panels.failure}
|
|
337
|
+
/>
|
|
338
|
+
)
|
|
339
|
+
) : null}
|
|
340
|
+
|
|
341
|
+
{panels.compare
|
|
342
|
+
? panels.compare({ result, compareMode: compare.compareMode, compareVersion: compare.compareVersion })
|
|
343
|
+
: null}
|
|
344
|
+
|
|
345
|
+
{panels.permalink ? <div className="mw-map-sheet__actions">{panels.permalink(geocode.text)}</div> : null}
|
|
346
|
+
</section>
|
|
347
|
+
) : null}
|
|
348
|
+
|
|
349
|
+
{panels.footer}
|
|
350
|
+
</>
|
|
351
|
+
)
|
|
352
|
+
}
|