@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.
Files changed (127) hide show
  1. package/lib/common/About.tsx +87 -71
  2. package/lib/common/index.ts +22 -0
  3. package/lib/index.ts +4 -99
  4. package/lib/map/BackendControl.tsx +7 -3
  5. package/lib/map/CompareToggle.tsx +3 -2
  6. package/lib/map/Geocoder.tsx +48 -28
  7. package/lib/map/GeocoderControls.tsx +258 -99
  8. package/lib/map/MapChipRow.tsx +77 -0
  9. package/lib/map/MapCompass.tsx +77 -0
  10. package/lib/map/MapControlStack.tsx +98 -0
  11. package/lib/map/MapFooter.tsx +115 -0
  12. package/lib/map/MapProgressBar.tsx +58 -0
  13. package/lib/map/MapSearchBar.tsx +68 -0
  14. package/lib/map/MapSheet.tsx +62 -0
  15. package/lib/map/index.ts +19 -0
  16. package/lib/map/types.ts +18 -4
  17. package/lib/map/useMapBearing.ts +56 -0
  18. package/lib/map/useMapLabelPick.ts +123 -0
  19. package/lib/pipeline/index.ts +36 -0
  20. package/lib/pipeline/useParsePipeline.ts +29 -18
  21. package/lib/poi/index.ts +39 -0
  22. package/lib/runtime/index.ts +16 -0
  23. package/out/common/About.d.ts +13 -4
  24. package/out/common/About.d.ts.map +1 -1
  25. package/out/common/About.js +5 -2
  26. package/out/common/About.js.map +1 -1
  27. package/out/common/index.d.ts +22 -0
  28. package/out/common/index.d.ts.map +1 -0
  29. package/out/common/index.js +15 -0
  30. package/out/common/index.js.map +1 -0
  31. package/out/index.d.ts +4 -59
  32. package/out/index.d.ts.map +1 -1
  33. package/out/index.js +4 -36
  34. package/out/index.js.map +1 -1
  35. package/out/map/BackendControl.d.ts.map +1 -1
  36. package/out/map/BackendControl.js +1 -1
  37. package/out/map/BackendControl.js.map +1 -1
  38. package/out/map/CompareToggle.d.ts.map +1 -1
  39. package/out/map/CompareToggle.js +1 -1
  40. package/out/map/CompareToggle.js.map +1 -1
  41. package/out/map/Geocoder.d.ts +11 -1
  42. package/out/map/Geocoder.d.ts.map +1 -1
  43. package/out/map/Geocoder.js +26 -26
  44. package/out/map/Geocoder.js.map +1 -1
  45. package/out/map/GeocoderControls.d.ts +29 -11
  46. package/out/map/GeocoderControls.d.ts.map +1 -1
  47. package/out/map/GeocoderControls.js +65 -16
  48. package/out/map/GeocoderControls.js.map +1 -1
  49. package/out/map/MapChipRow.d.ts +47 -0
  50. package/out/map/MapChipRow.d.ts.map +1 -0
  51. package/out/map/MapChipRow.js +12 -0
  52. package/out/map/MapChipRow.js.map +1 -0
  53. package/out/map/MapCompass.d.ts +42 -0
  54. package/out/map/MapCompass.d.ts.map +1 -0
  55. package/out/map/MapCompass.js +11 -0
  56. package/out/map/MapCompass.js.map +1 -0
  57. package/out/map/MapControlStack.d.ts +57 -0
  58. package/out/map/MapControlStack.d.ts.map +1 -0
  59. package/out/map/MapControlStack.js +15 -0
  60. package/out/map/MapControlStack.js.map +1 -0
  61. package/out/map/MapFooter.d.ts +40 -0
  62. package/out/map/MapFooter.d.ts.map +1 -0
  63. package/out/map/MapFooter.js +51 -0
  64. package/out/map/MapFooter.js.map +1 -0
  65. package/out/map/MapProgressBar.d.ts +34 -0
  66. package/out/map/MapProgressBar.d.ts.map +1 -0
  67. package/out/map/MapProgressBar.js +8 -0
  68. package/out/map/MapProgressBar.js.map +1 -0
  69. package/out/map/MapSearchBar.d.ts +49 -0
  70. package/out/map/MapSearchBar.d.ts.map +1 -0
  71. package/out/map/MapSearchBar.js +6 -0
  72. package/out/map/MapSearchBar.js.map +1 -0
  73. package/out/map/MapSheet.d.ts +32 -0
  74. package/out/map/MapSheet.d.ts.map +1 -0
  75. package/out/map/MapSheet.js +33 -0
  76. package/out/map/MapSheet.js.map +1 -0
  77. package/out/map/OverlayLayers.js +1 -1
  78. package/out/map/ResolvedPlaceLayers.js +1 -1
  79. package/out/map/ResultPanel.js +1 -1
  80. package/out/map/fake-runtime.js.map +1 -1
  81. package/out/map/index.d.ts +17 -0
  82. package/out/map/index.d.ts.map +1 -1
  83. package/out/map/index.js +10 -0
  84. package/out/map/index.js.map +1 -1
  85. package/out/map/map-debug.d.ts.map +1 -1
  86. package/out/map/map-debug.js.map +1 -1
  87. package/out/map/types.d.ts +20 -4
  88. package/out/map/types.d.ts.map +1 -1
  89. package/out/map/useMapBearing.d.ts +28 -0
  90. package/out/map/useMapBearing.d.ts.map +1 -0
  91. package/out/map/useMapBearing.js +35 -0
  92. package/out/map/useMapBearing.js.map +1 -0
  93. package/out/map/useMapLabelPick.d.ts +23 -0
  94. package/out/map/useMapLabelPick.d.ts.map +1 -0
  95. package/out/map/useMapLabelPick.js +104 -0
  96. package/out/map/useMapLabelPick.js.map +1 -0
  97. package/out/pipeline/CandidatePicker.js +1 -1
  98. package/out/pipeline/PipelineExplorer.js +1 -1
  99. package/out/pipeline/QueryForm.js +1 -1
  100. package/out/pipeline/ResolvedPlace.js +1 -1
  101. package/out/pipeline/TimingPanel.js +1 -1
  102. package/out/pipeline/index.d.ts +33 -0
  103. package/out/pipeline/index.d.ts.map +1 -0
  104. package/out/pipeline/index.js +21 -0
  105. package/out/pipeline/index.js.map +1 -0
  106. package/out/pipeline/useParsePipeline.d.ts +5 -2
  107. package/out/pipeline/useParsePipeline.d.ts.map +1 -1
  108. package/out/pipeline/useParsePipeline.js +6 -2
  109. package/out/pipeline/useParsePipeline.js.map +1 -1
  110. package/out/poi/LiveResultsBlock.js +1 -1
  111. package/out/poi/POIExplorer.js +1 -1
  112. package/out/poi/SubjectPanel.js +1 -1
  113. package/out/poi/index.d.ts +23 -0
  114. package/out/poi/index.d.ts.map +1 -0
  115. package/out/poi/index.js +15 -0
  116. package/out/poi/index.js.map +1 -0
  117. package/out/poi/runtime.d.ts.map +1 -1
  118. package/out/runtime/index.d.ts +9 -0
  119. package/out/runtime/index.d.ts.map +1 -0
  120. package/out/runtime/index.js +8 -0
  121. package/out/runtime/index.js.map +1 -0
  122. package/package.json +87 -6
  123. package/styleframe.config.ts +9 -1
  124. package/styles.css +948 -26
  125. package/tokens/index.css +2 -1
  126. package/tokens/tokens.json +7 -0
  127. 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). The opt-in display toggles live here — calibrated
267
- * confidence + dev-mode — because the host owns both the toggle state AND the {@link result} / {@link debugDrawer}
268
- * renderers those toggles drive.
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
- * Run a parse for the current text. Safe to bind to a form's `onSubmit`.
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
- const submit = useCallback(async () => {
57
- if (!runtime.ready || busy) return
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
- setBusy(true)
60
- setParseStage(0)
61
- setParseError(null)
66
+ const input = query ?? text
62
67
 
63
- try {
64
- const parsed = await runtime.runParse(text, { onStage: setParseStage })
65
- setSelectedCandidateIndex(0)
66
- setResult(parsed)
67
- } catch (error) {
68
- setParseError(error instanceof Error ? error.message : String(error))
69
- } finally {
70
- setBusy(false)
71
- setParseStage(-1)
72
- }
73
- }, [runtime, text, busy])
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