@mailwoman/react 9.4.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/lib/common/About.tsx +87 -71
- package/lib/common/index.ts +22 -0
- package/lib/index.ts +4 -99
- package/lib/map/BackendControl.tsx +7 -3
- package/lib/map/CompareToggle.tsx +3 -2
- package/lib/map/Geocoder.tsx +48 -28
- package/lib/map/GeocoderControls.tsx +258 -99
- 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/index.ts +19 -0
- package/lib/map/types.ts +18 -4
- package/lib/map/useMapBearing.ts +56 -0
- package/lib/map/useMapLabelPick.ts +123 -0
- package/lib/pipeline/index.ts +36 -0
- package/lib/pipeline/useParsePipeline.ts +29 -18
- package/lib/poi/index.ts +39 -0
- package/lib/runtime/index.ts +16 -0
- package/out/common/About.d.ts +13 -4
- package/out/common/About.d.ts.map +1 -1
- package/out/common/About.js +5 -2
- package/out/common/About.js.map +1 -1
- 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/index.d.ts +4 -59
- package/out/index.d.ts.map +1 -1
- package/out/index.js +4 -36
- 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.map +1 -1
- package/out/map/CompareToggle.js +1 -1
- package/out/map/CompareToggle.js.map +1 -1
- package/out/map/Geocoder.d.ts +11 -1
- package/out/map/Geocoder.d.ts.map +1 -1
- package/out/map/Geocoder.js +26 -26
- package/out/map/Geocoder.js.map +1 -1
- package/out/map/GeocoderControls.d.ts +29 -11
- package/out/map/GeocoderControls.d.ts.map +1 -1
- package/out/map/GeocoderControls.js +65 -16
- package/out/map/GeocoderControls.js.map +1 -1
- 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/ResolvedPlaceLayers.js +1 -1
- package/out/map/ResultPanel.js +1 -1
- package/out/map/fake-runtime.js.map +1 -1
- package/out/map/index.d.ts +17 -0
- package/out/map/index.d.ts.map +1 -1
- package/out/map/index.js +10 -0
- package/out/map/index.js.map +1 -1
- package/out/map/map-debug.d.ts.map +1 -1
- package/out/map/map-debug.js.map +1 -1
- package/out/map/types.d.ts +20 -4
- package/out/map/types.d.ts.map +1 -1
- 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.js +1 -1
- package/out/pipeline/PipelineExplorer.js +1 -1
- package/out/pipeline/QueryForm.js +1 -1
- package/out/pipeline/ResolvedPlace.js +1 -1
- package/out/pipeline/TimingPanel.js +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/useParsePipeline.d.ts +5 -2
- 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/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/package.json +87 -6
- package/styleframe.config.ts +9 -1
- package/styles.css +948 -26
- package/tokens/index.css +2 -1
- package/tokens/tokens.json +7 -0
- package/tokens/tokens.resolver.json +1 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `<MapFooter>` — the strip along the bottom of a map: identity and load status on the left, an attribution button
|
|
7
|
+
* on the right.
|
|
8
|
+
*
|
|
9
|
+
* Attribution is a licence obligation for every source these apps draw, so it is a first-class slot rather than
|
|
10
|
+
* something an app remembers to add. It sits behind a button because the strip has to stay ONE line: spelled out, the
|
|
11
|
+
* credits ran three lines deep on a phone and covered the sheet above them. The button names the obligation, and
|
|
12
|
+
* pressing it shows every credit — which is the treatment the reference map apps use.
|
|
13
|
+
*
|
|
14
|
+
* The status slot is where the loader says WHAT it is fetching while the bar at the top of the viewport says how far
|
|
15
|
+
* along it is.
|
|
16
|
+
*
|
|
17
|
+
* NODE-SAFE: pure React, no maplibre.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { type ReactNode, useEffect, useId, useRef, useState } from "react"
|
|
21
|
+
|
|
22
|
+
import { cx } from "#common/cx"
|
|
23
|
+
|
|
24
|
+
export interface MapFooterProps {
|
|
25
|
+
/**
|
|
26
|
+
* The left side: a wordmark, a body name, a line saying what the page does.
|
|
27
|
+
*/
|
|
28
|
+
identity?: ReactNode
|
|
29
|
+
/**
|
|
30
|
+
* Beside the identity: what is loading right now. Absent when nothing is.
|
|
31
|
+
*/
|
|
32
|
+
status?: ReactNode
|
|
33
|
+
/**
|
|
34
|
+
* The credits, one entry per source. Shown in the popover the attribution button opens.
|
|
35
|
+
*/
|
|
36
|
+
attribution?: ReactNode[]
|
|
37
|
+
/**
|
|
38
|
+
* What the attribution button reads. @default "Sources"
|
|
39
|
+
*/
|
|
40
|
+
attributionLabel?: string
|
|
41
|
+
className?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function MapFooter({
|
|
45
|
+
identity,
|
|
46
|
+
status,
|
|
47
|
+
attribution,
|
|
48
|
+
attributionLabel = "Sources",
|
|
49
|
+
className,
|
|
50
|
+
}: MapFooterProps): ReactNode {
|
|
51
|
+
const [open, setOpen] = useState(false)
|
|
52
|
+
const popoverID = useId()
|
|
53
|
+
const root = useRef<HTMLElement>(null)
|
|
54
|
+
|
|
55
|
+
// A popover closes when the conversation moves elsewhere: a press outside it, or Escape.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!open) return
|
|
58
|
+
|
|
59
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
60
|
+
if (!root.current?.contains(event.target as Node)) {
|
|
61
|
+
setOpen(false)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
66
|
+
if (event.key === "Escape") {
|
|
67
|
+
setOpen(false)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
document.addEventListener("pointerdown", onPointerDown)
|
|
72
|
+
document.addEventListener("keydown", onKeyDown)
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
document.removeEventListener("pointerdown", onPointerDown)
|
|
76
|
+
document.removeEventListener("keydown", onKeyDown)
|
|
77
|
+
}
|
|
78
|
+
}, [open])
|
|
79
|
+
|
|
80
|
+
if (!identity && !status && !attribution?.length) return null
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<footer className={cx("mw-map-footer", className)} ref={root}>
|
|
84
|
+
<div className="mw-map-footer__identity">
|
|
85
|
+
{identity}
|
|
86
|
+
{status ? <span className="mw-map-footer__status">{status}</span> : null}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{attribution?.length ? (
|
|
90
|
+
<div className="mw-map-footer__sources">
|
|
91
|
+
{open ? (
|
|
92
|
+
<div className="mw-map-footer__popover" id={popoverID} role="group" aria-label="Map data sources">
|
|
93
|
+
{attribution.map((entry, index) => (
|
|
94
|
+
// The entries are a fixed, ordered credit list that never reorders, so position is the identity.
|
|
95
|
+
<span key={index} className="mw-map-footer__credit">
|
|
96
|
+
{entry}
|
|
97
|
+
</span>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
) : null}
|
|
101
|
+
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className="mw-map-footer__attribution-button"
|
|
105
|
+
aria-expanded={open}
|
|
106
|
+
aria-controls={open ? popoverID : undefined}
|
|
107
|
+
onClick={() => setOpen((value) => !value)}
|
|
108
|
+
>
|
|
109
|
+
{attributionLabel}
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
) : null}
|
|
113
|
+
</footer>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `<MapProgressBar>` — the loading bar pinned across the top of the viewport.
|
|
7
|
+
*
|
|
8
|
+
* Loading a 38 MB model is a page-level event, not a control-level one, so it reports at the page's edge rather than
|
|
9
|
+
* inside the search field: a spinner in the field resizes the one control a visitor is trying to type into, and a
|
|
10
|
+
* staged list in the result sheet claims the space an answer will need.
|
|
11
|
+
*
|
|
12
|
+
* It reports a FRACTION when the loader names its steps and runs indeterminate when it does not, because a bar that
|
|
13
|
+
* invents a percentage is worse than one that admits it is only saying "still working".
|
|
14
|
+
*
|
|
15
|
+
* NODE-SAFE: pure React, no maplibre.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ReactNode } from "react"
|
|
19
|
+
|
|
20
|
+
import { cx } from "#common/cx"
|
|
21
|
+
|
|
22
|
+
export interface MapProgressBarProps {
|
|
23
|
+
/**
|
|
24
|
+
* Whether the bar is showing at all. It stays mounted through its fade rather than unmounting on the transition.
|
|
25
|
+
*/
|
|
26
|
+
active: boolean
|
|
27
|
+
/**
|
|
28
|
+
* Completed steps over total steps, in [0, 1]. Omit for work whose length is unknown.
|
|
29
|
+
*/
|
|
30
|
+
fraction?: number | null
|
|
31
|
+
/**
|
|
32
|
+
* The bar's accessible name — what is loading, in a few words.
|
|
33
|
+
*/
|
|
34
|
+
label: string
|
|
35
|
+
className?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function MapProgressBar({ active, fraction, label, className }: MapProgressBarProps): ReactNode {
|
|
39
|
+
const determinate = typeof fraction === "number" && Number.isFinite(fraction)
|
|
40
|
+
const clamped = determinate ? Math.min(1, Math.max(0, fraction)) : 0
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
className={cx("mw-map-progress", !active && "mw-map-progress--idle", className)}
|
|
45
|
+
role="progressbar"
|
|
46
|
+
aria-label={label}
|
|
47
|
+
aria-hidden={!active}
|
|
48
|
+
aria-valuemin={determinate ? 0 : undefined}
|
|
49
|
+
aria-valuemax={determinate ? 100 : undefined}
|
|
50
|
+
aria-valuenow={determinate ? Math.round(clamped * 100) : undefined}
|
|
51
|
+
>
|
|
52
|
+
<div
|
|
53
|
+
className={cx("mw-map-progress__fill", !determinate && "mw-map-progress__fill--indeterminate")}
|
|
54
|
+
style={determinate ? { transform: `scaleX(${clamped})` } : undefined}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `<MapSearchBar>` — the floating search pill that sits over a map. Presentational only: it supplies the glass
|
|
7
|
+
* material, the pill shape and the two icon slots, and the host supplies the input and whatever backend answers it.
|
|
8
|
+
* Earth searches an address through the geocoder runtime and the planetary apps search a nomenclature artifact, so
|
|
9
|
+
* nothing about the query belongs here.
|
|
10
|
+
*
|
|
11
|
+
* The slots are elements rather than icon names because a leading mark and a trailing control differ per app — a
|
|
12
|
+
* magnifier on one, a body glyph on another.
|
|
13
|
+
*
|
|
14
|
+
* BUSY IS A LINE, NOT A SLOT. A spinner placed in the trailing slot changed the pill's height on every submit,
|
|
15
|
+
* because a slot is laid out and an indicator is not part of the query. `busy` draws a hairline across the pill's
|
|
16
|
+
* lower edge instead, which costs no layout. A host that wants `type="search"` also gets the browser's own clear
|
|
17
|
+
* button in that corner, and two crosses side by side is one control too many.
|
|
18
|
+
*
|
|
19
|
+
* NODE-SAFE: pure React, no maplibre.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ReactNode } from "react"
|
|
23
|
+
|
|
24
|
+
import { cx } from "#common/cx"
|
|
25
|
+
|
|
26
|
+
export interface MapSearchBarProps {
|
|
27
|
+
/**
|
|
28
|
+
* The input (or combobox) the host owns. Rendered between the two slots.
|
|
29
|
+
*/
|
|
30
|
+
children: ReactNode
|
|
31
|
+
/**
|
|
32
|
+
* Rendered before the input — a magnifier, a body mark, a back arrow.
|
|
33
|
+
*/
|
|
34
|
+
leading?: ReactNode
|
|
35
|
+
/**
|
|
36
|
+
* Rendered after the input — a microphone, a menu. Not a spinner: pass {@link busy} instead.
|
|
37
|
+
*/
|
|
38
|
+
trailing?: ReactNode
|
|
39
|
+
/**
|
|
40
|
+
* A query is running. Draws a progress hairline along the pill's lower edge, which takes no layout.
|
|
41
|
+
*/
|
|
42
|
+
busy?: boolean
|
|
43
|
+
/**
|
|
44
|
+
* Extra class on the pill.
|
|
45
|
+
*/
|
|
46
|
+
className?: string
|
|
47
|
+
/**
|
|
48
|
+
* Accessible name for the surrounding region, when the bar is the app's primary search.
|
|
49
|
+
*/
|
|
50
|
+
label?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function MapSearchBar({ children, leading, trailing, busy, className, label }: MapSearchBarProps): ReactNode {
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
className={cx("mw-map-searchbar", busy && "mw-map-searchbar--busy", className)}
|
|
57
|
+
role={label ? "search" : undefined}
|
|
58
|
+
aria-label={label}
|
|
59
|
+
aria-busy={busy}
|
|
60
|
+
>
|
|
61
|
+
{leading ? <span className="mw-map-searchbar__slot mw-map-searchbar__slot--leading">{leading}</span> : null}
|
|
62
|
+
|
|
63
|
+
<span className="mw-map-searchbar__field">{children}</span>
|
|
64
|
+
|
|
65
|
+
{trailing ? <span className="mw-map-searchbar__slot mw-map-searchbar__slot--trailing">{trailing}</span> : null}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `<MapSheet>` — a side panel over the map: a title, a close button, and content.
|
|
7
|
+
*
|
|
8
|
+
* The close button is not optional. On a wide screen the control that opened the sheet stays visible beside it and
|
|
9
|
+
* could close it again, but on a phone the sheet is the whole panel and covers that control — so a sheet without its
|
|
10
|
+
* own close is a sheet a phone cannot dismiss. One component carries it so the four sheets in these apps cannot
|
|
11
|
+
* disagree about that.
|
|
12
|
+
*
|
|
13
|
+
* Escape closes it too, because a panel over the whole screen is a modal in every way that matters to someone
|
|
14
|
+
* holding a keyboard.
|
|
15
|
+
*
|
|
16
|
+
* NODE-SAFE: pure React, no maplibre.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { type ReactNode, useEffect } from "react"
|
|
20
|
+
|
|
21
|
+
import { cx } from "#common/cx"
|
|
22
|
+
|
|
23
|
+
export interface MapSheetProps {
|
|
24
|
+
/**
|
|
25
|
+
* The sheet's heading, and its accessible name.
|
|
26
|
+
*/
|
|
27
|
+
title: string
|
|
28
|
+
/**
|
|
29
|
+
* Dismiss the sheet.
|
|
30
|
+
*/
|
|
31
|
+
onClose: () => void
|
|
32
|
+
children: ReactNode
|
|
33
|
+
className?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function MapSheet({ title, onClose, children, className }: MapSheetProps): ReactNode {
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
39
|
+
if (event.key === "Escape") {
|
|
40
|
+
onClose()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
document.addEventListener("keydown", onKeyDown)
|
|
45
|
+
|
|
46
|
+
return () => document.removeEventListener("keydown", onKeyDown)
|
|
47
|
+
}, [onClose])
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<aside className={cx("mw-map-sheet mw-map-sheet--side", className)} aria-label={title}>
|
|
51
|
+
<div className="mw-map-sheet__header">
|
|
52
|
+
<h2 className="mw-map-sheet__title">{title}</h2>
|
|
53
|
+
|
|
54
|
+
<button type="button" className="mw-map-sheet__close" aria-label={`Close ${title}`} onClick={onClose}>
|
|
55
|
+
<span aria-hidden="true">×</span>
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div className="mw-map-sheet__body">{children}</div>
|
|
60
|
+
</aside>
|
|
61
|
+
)
|
|
62
|
+
}
|
package/lib/map/index.ts
CHANGED
|
@@ -15,6 +15,25 @@
|
|
|
15
15
|
export { MapCanvas } from "./MapCanvas.tsx"
|
|
16
16
|
export type { MapCanvasExtraProps, MapCanvasProps, MapCanvasStyle } from "./MapCanvas.tsx"
|
|
17
17
|
|
|
18
|
+
// ── Map chrome (node-safe presentation; the host supplies the input and reads its own map) ──
|
|
19
|
+
export { MapChipRow } from "./MapChipRow.tsx"
|
|
20
|
+
export type { MapChip, MapChipRowProps } from "./MapChipRow.tsx"
|
|
21
|
+
export { MapCompass } from "./MapCompass.tsx"
|
|
22
|
+
export type { MapCompassProps } from "./MapCompass.tsx"
|
|
23
|
+
export { useMapBearing } from "./useMapBearing.ts"
|
|
24
|
+
export type { UseMapBearing } from "./useMapBearing.ts"
|
|
25
|
+
export { useMapLabelPick } from "./useMapLabelPick.ts"
|
|
26
|
+
export { MapControlButton, MapControlGroup, MapControlStack } from "./MapControlStack.tsx"
|
|
27
|
+
export type { MapControlButtonProps, MapControlGroupProps, MapControlStackProps } from "./MapControlStack.tsx"
|
|
28
|
+
export { MapFooter } from "./MapFooter.tsx"
|
|
29
|
+
export type { MapFooterProps } from "./MapFooter.tsx"
|
|
30
|
+
export { MapProgressBar } from "./MapProgressBar.tsx"
|
|
31
|
+
export type { MapProgressBarProps } from "./MapProgressBar.tsx"
|
|
32
|
+
export { MapSheet } from "./MapSheet.tsx"
|
|
33
|
+
export type { MapSheetProps } from "./MapSheet.tsx"
|
|
34
|
+
export { MapSearchBar } from "./MapSearchBar.tsx"
|
|
35
|
+
export type { MapSearchBarProps } from "./MapSearchBar.tsx"
|
|
36
|
+
|
|
18
37
|
export type {
|
|
19
38
|
CompareContext,
|
|
20
39
|
GeocoderPanels,
|
package/lib/map/types.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import type { ParseResult, ResolvedPlaceView } from "@mailwoman/core/pipeline/client-result"
|
|
19
19
|
import type { ReactNode } from "react"
|
|
20
|
-
import type { LayerSpecification, SourceSpecification } from "react-map-gl/maplibre"
|
|
20
|
+
import type { LayerSpecification, MapInstance, SourceSpecification } from "react-map-gl/maplibre"
|
|
21
21
|
|
|
22
22
|
import type { LngLat, ResolvedMapPlace } from "#map/place-render"
|
|
23
23
|
import type { PipelineRuntime } from "#pipeline/types"
|
|
@@ -263,11 +263,17 @@ export interface GeocoderPanels {
|
|
|
263
263
|
*/
|
|
264
264
|
extras?: (result: ParseResult) => ReactNode
|
|
265
265
|
/**
|
|
266
|
-
* Rendered just above the result block (present or empty)
|
|
267
|
-
*
|
|
268
|
-
*
|
|
266
|
+
* Rendered just above the result block (present or empty), for content that reads on THIS answer.
|
|
267
|
+
*
|
|
268
|
+
* A control that reads on the model rather than on an address belongs in {@link developerExtras}: above the result it
|
|
269
|
+
* is the first thing a visitor meets, and on a phone it pushes the answer below the fold.
|
|
269
270
|
*/
|
|
270
271
|
aboveResult?: (context: { result: ParseResult | null }) => ReactNode
|
|
272
|
+
/**
|
|
273
|
+
* Host controls appended to the Developer sheet — the opt-in display toggles, whose state and the renderers they
|
|
274
|
+
* drive both belong to the host.
|
|
275
|
+
*/
|
|
276
|
+
developerExtras?: ReactNode
|
|
271
277
|
/**
|
|
272
278
|
* Replace the package's default {@link ResultPanel} entirely. When provided, the host renders its own result block
|
|
273
279
|
* (span highlight, timing, hierarchy, precision detail, calibrated confidences) from the {@link ResultContext}. Absent
|
|
@@ -293,6 +299,14 @@ export interface GeocoderPanels {
|
|
|
293
299
|
* Extra map controls mounted as `<MapCanvas>` children (host's DebugControl / LayerToggle via `useControl`).
|
|
294
300
|
*/
|
|
295
301
|
mapControls?: ReactNode
|
|
302
|
+
/**
|
|
303
|
+
* A layer control, rendered in the chrome's top column under the example chips rather than in a MapLibre corner.
|
|
304
|
+
*
|
|
305
|
+
* A render-prop taking the live map handle, because a layer control reads `getStyle().layers` and writes
|
|
306
|
+
* `setLayoutProperty` — it cannot be a static node. The handle is `null` until the map instantiates, so the host
|
|
307
|
+
* returns `null` for that first frame rather than rendering an empty control.
|
|
308
|
+
*/
|
|
309
|
+
layers?: (context: { map: MapInstance | null }) => ReactNode
|
|
296
310
|
/**
|
|
297
311
|
* A permalink control for the current address (host's PermalinkButton).
|
|
298
312
|
*/
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `useMapBearing` — the map's rotation as React state, for a compass that appears off north and fades back.
|
|
7
|
+
*
|
|
8
|
+
* The map is an external store, so it is read through `useSyncExternalStore` rather than mirrored into state by an
|
|
9
|
+
* effect: the bearing is a number, which makes a stable snapshot, and React tears nothing during a concurrent
|
|
10
|
+
* render.
|
|
11
|
+
*
|
|
12
|
+
* It listens on `rotate` rather than `rotateend`, because the needle has to track the gesture rather than snap once
|
|
13
|
+
* the gesture is over. `move` is subscribed too: a `flyTo` or an `easeTo` carrying a bearing rotates the map without
|
|
14
|
+
* firing a rotate event, and a compass that misses those stays pointing north over a turned map.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useCallback, useSyncExternalStore } from "react"
|
|
18
|
+
import type { MapInstance } from "react-map-gl/maplibre"
|
|
19
|
+
|
|
20
|
+
export interface UseMapBearing {
|
|
21
|
+
/**
|
|
22
|
+
* Degrees off north, as MapLibre reports it. Zero while there is no map.
|
|
23
|
+
*/
|
|
24
|
+
bearing: number
|
|
25
|
+
/**
|
|
26
|
+
* Rotate the map back to north. A no-op while there is no map.
|
|
27
|
+
*/
|
|
28
|
+
resetNorth: () => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useMapBearing(map: MapInstance | null): UseMapBearing {
|
|
32
|
+
const subscribe = useCallback(
|
|
33
|
+
(onChange: () => void) => {
|
|
34
|
+
if (!map) return () => undefined
|
|
35
|
+
|
|
36
|
+
map.on("rotate", onChange)
|
|
37
|
+
map.on("move", onChange)
|
|
38
|
+
|
|
39
|
+
return () => {
|
|
40
|
+
map.off("rotate", onChange)
|
|
41
|
+
map.off("move", onChange)
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
[map]
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
// The server snapshot is the same reading: there is no map during a server render, and north is what a compass
|
|
48
|
+
// shows when it has nothing to report.
|
|
49
|
+
const readBearing = useCallback(() => map?.getBearing() ?? 0, [map])
|
|
50
|
+
|
|
51
|
+
const bearing = useSyncExternalStore(subscribe, readBearing, () => 0)
|
|
52
|
+
|
|
53
|
+
const resetNorth = useCallback(() => map?.easeTo({ bearing: 0, pitch: 0 }), [map])
|
|
54
|
+
|
|
55
|
+
return { bearing, resetNorth }
|
|
56
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `useMapLabelPick` — clicking a place label on the map searches for it, the way the reference map apps behave.
|
|
7
|
+
*
|
|
8
|
+
* A label on a map looks like a link, so a visitor clicks it. Without this the click reaches the map's pan handler
|
|
9
|
+
* and nothing happens, which reads as the label being decoration.
|
|
10
|
+
*
|
|
11
|
+
* It reads the label's OWN name from the rendered feature rather than reverse-geocoding the click point: the name
|
|
12
|
+
* is what the visitor pointed at, and a lookup by coordinate answers whatever is nearest instead, which on a dense
|
|
13
|
+
* basemap is regularly not the thing under the cursor.
|
|
14
|
+
*
|
|
15
|
+
* THE QUERY IS SCOPED, AND THE HOVER QUERY IS THROTTLED. `queryRenderedFeatures` with no `layers` walks the whole
|
|
16
|
+
* style: measured at 64.3 ms per call returning 4,819 features over the 79-layer basemap at zoom 14 in Manhattan,
|
|
17
|
+
* against 4.9 ms and 44 features scoped to that style's 11 label layers. At one call per pointer move the unscoped
|
|
18
|
+
* form is the map's whole frame budget, so the layer list is resolved once per style and the hover query runs at
|
|
19
|
+
* most once per animation frame. The click query is not throttled — there is one of those per click.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useEffect, useEffectEvent } from "react"
|
|
23
|
+
import type { MapInstance, MapLayerMouseEvent } from "react-map-gl/maplibre"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The layers whose features carry a place name. Protomaps names its label layers `<theme>_label` and its settlement
|
|
27
|
+
* layers `places_*`; anything else in the style is geometry, not a label a visitor can read and point at.
|
|
28
|
+
*/
|
|
29
|
+
const LABEL_LAYER = /_label|^places_/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Properties a label carries its text under, in the order they are trusted. `name` is protomaps' own; the localized
|
|
33
|
+
* variants appear on styles built for a specific script.
|
|
34
|
+
*/
|
|
35
|
+
const NAME_KEYS = ["name", "name:en", "name_en"] as const
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The style's label layers, by id.
|
|
39
|
+
*
|
|
40
|
+
* A style with none answers an EMPTY ARRAY, and the caller must treat that as "no labels to pick" rather than passing
|
|
41
|
+
* it to `queryRenderedFeatures` — an empty `layers` option is not the same as an absent one there, and the difference
|
|
42
|
+
* between "this style has no labels" and "query everything" is the 64 ms this hook exists to avoid.
|
|
43
|
+
*/
|
|
44
|
+
function labelLayerIDs(map: MapInstance): string[] {
|
|
45
|
+
const layers = map.getStyle()?.layers ?? []
|
|
46
|
+
|
|
47
|
+
return layers.map((layer) => layer.id).filter((id) => LABEL_LAYER.test(id))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function labelNameAt(map: MapInstance, point: MapLayerMouseEvent["point"], layers: string[]): string | null {
|
|
51
|
+
if (!layers.length) return null
|
|
52
|
+
|
|
53
|
+
// `queryRenderedFeatures` answers in paint order with the topmost first, which is the label drawn over the others
|
|
54
|
+
// and therefore the one a click landed on.
|
|
55
|
+
for (const feature of map.queryRenderedFeatures(point, { layers })) {
|
|
56
|
+
for (const key of NAME_KEYS) {
|
|
57
|
+
const value = feature.properties?.[key]
|
|
58
|
+
|
|
59
|
+
if (typeof value === "string" && value.trim()) return value
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useMapLabelPick(map: MapInstance | null, onPick: (name: string) => void): void {
|
|
67
|
+
// The subscription depends on the MAP alone. `useGeocode` returns a fresh object every render, so a callback built
|
|
68
|
+
// from it is new every render too — with `onPick` in the dependency list these map listeners were torn down and
|
|
69
|
+
// re-added on every keystroke in the search field. `useEffectEvent` is the shape for exactly this: an event
|
|
70
|
+
// handler that always sees the latest props without being a reactive dependency.
|
|
71
|
+
const pick = useEffectEvent((name: string) => onPick(name))
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!map) return
|
|
75
|
+
|
|
76
|
+
// Recomputed when the style swaps (a theme change, a version switch) and not once per pointer move.
|
|
77
|
+
let layers = labelLayerIDs(map)
|
|
78
|
+
|
|
79
|
+
const readLayers = () => {
|
|
80
|
+
layers = labelLayerIDs(map)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const onClick = (event: MapLayerMouseEvent) => {
|
|
84
|
+
const name = labelNameAt(map, event.point, layers)
|
|
85
|
+
|
|
86
|
+
if (name) {
|
|
87
|
+
pick(name)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let frame = 0
|
|
92
|
+
|
|
93
|
+
const onMove = (event: MapLayerMouseEvent) => {
|
|
94
|
+
if (frame) return
|
|
95
|
+
|
|
96
|
+
frame = requestAnimationFrame(() => {
|
|
97
|
+
frame = 0
|
|
98
|
+
|
|
99
|
+
const canvas = map.getCanvas()
|
|
100
|
+
|
|
101
|
+
// The drag cursor belongs to the pan gesture; overriding it mid-drag would fight the map for the pointer.
|
|
102
|
+
if (canvas.style.cursor === "grabbing") return
|
|
103
|
+
|
|
104
|
+
canvas.style.cursor = labelNameAt(map, event.point, layers) ? "pointer" : ""
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
map.on("styledata", readLayers)
|
|
109
|
+
map.on("click", onClick)
|
|
110
|
+
map.on("mousemove", onMove)
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
if (frame) {
|
|
114
|
+
cancelAnimationFrame(frame)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
map.off("styledata", readLayers)
|
|
118
|
+
map.off("click", onClick)
|
|
119
|
+
map.off("mousemove", onMove)
|
|
120
|
+
map.getCanvas().style.cursor = ""
|
|
121
|
+
}
|
|
122
|
+
}, [map])
|
|
123
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
* @file Pipeline explorer components, visualizers, hooks, and contracts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { About } from "../common/About.tsx"
|
|
9
|
+
export { FailureDiagnostic } from "./FailureDiagnostic.tsx"
|
|
10
|
+
export type { FailureDiagnosticProps } from "./FailureDiagnostic.tsx"
|
|
11
|
+
export { SpanHighlight } from "./SpanHighlight.tsx"
|
|
12
|
+
export type { SpanHighlightProps } from "./SpanHighlight.tsx"
|
|
13
|
+
export { TimingPanel } from "./TimingPanel.tsx"
|
|
14
|
+
export type { TimingPanelProps } from "./TimingPanel.tsx"
|
|
15
|
+
export { TreeView } from "./TreeView.tsx"
|
|
16
|
+
export type { TreeViewProps } from "./TreeView.tsx"
|
|
17
|
+
|
|
18
|
+
export { CandidatePicker } from "./CandidatePicker.tsx"
|
|
19
|
+
export type { CandidatePickerProps } from "./CandidatePicker.tsx"
|
|
20
|
+
export { ComponentTable } from "./ComponentTable.tsx"
|
|
21
|
+
export type { ComponentTableProps } from "./ComponentTable.tsx"
|
|
22
|
+
export { ConfidenceCell } from "./ConfidenceCell.tsx"
|
|
23
|
+
export type { ConfidenceCellProps } from "./ConfidenceCell.tsx"
|
|
24
|
+
export { buildParsePayload } from "#pipeline/copy"
|
|
25
|
+
export { PIPELINE_DEFAULT_ADDRESS, PIPELINE_PRESETS } from "#pipeline/presets"
|
|
26
|
+
export { PipelineExplorer } from "./PipelineExplorer.tsx"
|
|
27
|
+
export type { PipelineExplorerProps } from "./PipelineExplorer.tsx"
|
|
28
|
+
export { QueryForm } from "./QueryForm.tsx"
|
|
29
|
+
export type { QueryFormProps } from "./QueryForm.tsx"
|
|
30
|
+
export { ResolvedPlace } from "./ResolvedPlace.tsx"
|
|
31
|
+
export type { ResolvedPlaceProps } from "./ResolvedPlace.tsx"
|
|
32
|
+
|
|
33
|
+
export type { PipelineLoadingState, PipelinePanels, PipelineRuntime } from "#pipeline/types"
|
|
34
|
+
|
|
35
|
+
export { useParsePipeline } from "#pipeline/useParsePipeline"
|
|
36
|
+
export type { UseParsePipeline, UseParsePipelineOptions } from "#pipeline/useParsePipeline"
|
|
@@ -36,9 +36,12 @@ export interface UseParsePipeline {
|
|
|
36
36
|
*/
|
|
37
37
|
selectedCandidate: ResolvedPlaceView | null
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
39
|
+
* Parse and resolve the current text. Safe to bind to a form's `onSubmit`.
|
|
40
|
+
*
|
|
41
|
+
* Pass `query` to submit a value the field has not re-rendered with yet — a preset press sets the text and submits in
|
|
42
|
+
* one handler, and that state update is not visible to this call.
|
|
40
43
|
*/
|
|
41
|
-
submit: () => Promise<void>
|
|
44
|
+
submit: (query?: string) => Promise<void>
|
|
42
45
|
/**
|
|
43
46
|
* Clear the result (used when a preset replaces the input).
|
|
44
47
|
*/
|
|
@@ -53,24 +56,32 @@ export function useParsePipeline({ runtime, defaultText }: UseParsePipelineOptio
|
|
|
53
56
|
const [selectedCandidateIndex, setSelectedCandidateIndex] = useState(0)
|
|
54
57
|
const [parseError, setParseError] = useState<string | null>(null)
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
// `query` exists because `setText` does not reach this closure before the call after it runs. A preset that called
|
|
60
|
+
// `setText(value)` then `submit()` parsed the PREVIOUS text — the field showed the preset and the map answered the
|
|
61
|
+
// address before it. A caller that already knows the query passes it; the field's own submit passes nothing.
|
|
62
|
+
const submit = useCallback(
|
|
63
|
+
async (query?: string) => {
|
|
64
|
+
if (!runtime.ready || busy) return
|
|
58
65
|
|
|
59
|
-
|
|
60
|
-
setParseStage(0)
|
|
61
|
-
setParseError(null)
|
|
66
|
+
const input = query ?? text
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
setBusy(true)
|
|
69
|
+
setParseStage(0)
|
|
70
|
+
setParseError(null)
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const parsed = await runtime.runParse(input, { onStage: setParseStage })
|
|
74
|
+
setSelectedCandidateIndex(0)
|
|
75
|
+
setResult(parsed)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
setParseError(error instanceof Error ? error.message : String(error))
|
|
78
|
+
} finally {
|
|
79
|
+
setBusy(false)
|
|
80
|
+
setParseStage(-1)
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
[runtime, text, busy]
|
|
84
|
+
)
|
|
74
85
|
|
|
75
86
|
const reset = useCallback(() => setResult(null), [])
|
|
76
87
|
|