@mailwoman/react 0.0.1 → 7.5.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 (143) hide show
  1. package/README.md +34 -4
  2. package/common/ClientOnly.tsx +30 -0
  3. package/common/CopyButton.tsx +44 -0
  4. package/common/KindBadge.tsx +44 -0
  5. package/common/LoadingIndicator.tsx +104 -0
  6. package/common/PresetChips.tsx +43 -0
  7. package/common/cx.ts +34 -0
  8. package/common/useClipboard.ts +73 -0
  9. package/common/useDebouncedValue.ts +23 -0
  10. package/index.ts +86 -4
  11. package/out/common/ClientOnly.d.ts +22 -0
  12. package/out/common/ClientOnly.d.ts.map +1 -0
  13. package/out/common/ClientOnly.js +20 -0
  14. package/out/common/ClientOnly.js.map +1 -0
  15. package/out/common/CopyButton.d.ts +21 -0
  16. package/out/common/CopyButton.d.ts.map +1 -0
  17. package/out/common/CopyButton.js +7 -0
  18. package/out/common/CopyButton.js.map +1 -0
  19. package/out/common/KindBadge.d.ts +24 -0
  20. package/out/common/KindBadge.d.ts.map +1 -0
  21. package/out/common/KindBadge.js +6 -0
  22. package/out/common/KindBadge.js.map +1 -0
  23. package/out/common/LoadingIndicator.d.ts +27 -0
  24. package/out/common/LoadingIndicator.d.ts.map +1 -0
  25. package/out/common/LoadingIndicator.js +41 -0
  26. package/out/common/LoadingIndicator.js.map +1 -0
  27. package/out/common/PresetChips.d.ts +22 -0
  28. package/out/common/PresetChips.d.ts.map +1 -0
  29. package/out/common/PresetChips.js +5 -0
  30. package/out/common/PresetChips.js.map +1 -0
  31. package/out/common/cx.d.ts +13 -0
  32. package/out/common/cx.d.ts.map +1 -0
  33. package/out/common/cx.js +28 -0
  34. package/out/common/cx.js.map +1 -0
  35. package/out/common/useClipboard.d.ts +16 -0
  36. package/out/common/useClipboard.d.ts.map +1 -0
  37. package/out/common/useClipboard.js +56 -0
  38. package/out/common/useClipboard.js.map +1 -0
  39. package/out/common/useDebouncedValue.d.ts +11 -0
  40. package/out/common/useDebouncedValue.d.ts.map +1 -0
  41. package/out/common/useDebouncedValue.js +19 -0
  42. package/out/common/useDebouncedValue.js.map +1 -0
  43. package/out/index.d.ts +57 -4
  44. package/out/index.d.ts.map +1 -1
  45. package/out/index.js +37 -4
  46. package/out/index.js.map +1 -1
  47. package/out/pipeline/CandidatePicker.d.ts +17 -0
  48. package/out/pipeline/CandidatePicker.d.ts.map +1 -0
  49. package/out/pipeline/CandidatePicker.js +6 -0
  50. package/out/pipeline/CandidatePicker.js.map +1 -0
  51. package/out/pipeline/ComponentTable.d.ts +14 -0
  52. package/out/pipeline/ComponentTable.d.ts.map +1 -0
  53. package/out/pipeline/ComponentTable.js +6 -0
  54. package/out/pipeline/ComponentTable.js.map +1 -0
  55. package/out/pipeline/ConfidenceCell.d.ts +13 -0
  56. package/out/pipeline/ConfidenceCell.d.ts.map +1 -0
  57. package/out/pipeline/ConfidenceCell.js +22 -0
  58. package/out/pipeline/ConfidenceCell.js.map +1 -0
  59. package/out/pipeline/PipelineExplorer.d.ts +25 -0
  60. package/out/pipeline/PipelineExplorer.d.ts.map +1 -0
  61. package/out/pipeline/PipelineExplorer.js +27 -0
  62. package/out/pipeline/PipelineExplorer.js.map +1 -0
  63. package/out/pipeline/QueryForm.d.ts +19 -0
  64. package/out/pipeline/QueryForm.d.ts.map +1 -0
  65. package/out/pipeline/QueryForm.js +9 -0
  66. package/out/pipeline/QueryForm.js.map +1 -0
  67. package/out/pipeline/ResolvedPlace.d.ts +16 -0
  68. package/out/pipeline/ResolvedPlace.d.ts.map +1 -0
  69. package/out/pipeline/ResolvedPlace.js +14 -0
  70. package/out/pipeline/ResolvedPlace.js.map +1 -0
  71. package/out/pipeline/copy.d.ts +11 -0
  72. package/out/pipeline/copy.d.ts.map +1 -0
  73. package/out/pipeline/copy.js +31 -0
  74. package/out/pipeline/copy.js.map +1 -0
  75. package/out/pipeline/presets.d.ts +12 -0
  76. package/out/pipeline/presets.d.ts.map +1 -0
  77. package/out/pipeline/presets.js +21 -0
  78. package/out/pipeline/presets.js.map +1 -0
  79. package/out/pipeline/types.d.ts +110 -0
  80. package/out/pipeline/types.d.ts.map +1 -0
  81. package/out/pipeline/types.js +12 -0
  82. package/out/pipeline/types.js.map +1 -0
  83. package/out/pipeline/useParsePipeline.d.ts +34 -0
  84. package/out/pipeline/useParsePipeline.d.ts.map +1 -0
  85. package/out/pipeline/useParsePipeline.js +55 -0
  86. package/out/pipeline/useParsePipeline.js.map +1 -0
  87. package/out/poi/AbstainPanel.d.ts +14 -0
  88. package/out/poi/AbstainPanel.d.ts.map +1 -0
  89. package/out/poi/AbstainPanel.js +5 -0
  90. package/out/poi/AbstainPanel.js.map +1 -0
  91. package/out/poi/LiveResultsBlock.d.ts +21 -0
  92. package/out/poi/LiveResultsBlock.d.ts.map +1 -0
  93. package/out/poi/LiveResultsBlock.js +7 -0
  94. package/out/poi/LiveResultsBlock.js.map +1 -0
  95. package/out/poi/OverpassBlock.d.ts +15 -0
  96. package/out/poi/OverpassBlock.d.ts.map +1 -0
  97. package/out/poi/OverpassBlock.js +11 -0
  98. package/out/poi/OverpassBlock.js.map +1 -0
  99. package/out/poi/POIExplorer.d.ts +31 -0
  100. package/out/poi/POIExplorer.d.ts.map +1 -0
  101. package/out/poi/POIExplorer.js +43 -0
  102. package/out/poi/POIExplorer.js.map +1 -0
  103. package/out/poi/QueryInput.d.ts +17 -0
  104. package/out/poi/QueryInput.d.ts.map +1 -0
  105. package/out/poi/QueryInput.js +5 -0
  106. package/out/poi/QueryInput.js.map +1 -0
  107. package/out/poi/SubjectPanel.d.ts +16 -0
  108. package/out/poi/SubjectPanel.d.ts.map +1 -0
  109. package/out/poi/SubjectPanel.js +12 -0
  110. package/out/poi/SubjectPanel.js.map +1 -0
  111. package/out/poi/runtime.d.ts +43 -0
  112. package/out/poi/runtime.d.ts.map +1 -0
  113. package/out/poi/runtime.js +67 -0
  114. package/out/poi/runtime.js.map +1 -0
  115. package/out/poi/types.d.ts +113 -0
  116. package/out/poi/types.d.ts.map +1 -0
  117. package/out/poi/types.js +12 -0
  118. package/out/poi/types.js.map +1 -0
  119. package/out/poi/usePOISearch.d.ts +42 -0
  120. package/out/poi/usePOISearch.d.ts.map +1 -0
  121. package/out/poi/usePOISearch.js +158 -0
  122. package/out/poi/usePOISearch.js.map +1 -0
  123. package/package.json +52 -8
  124. package/pipeline/CandidatePicker.tsx +45 -0
  125. package/pipeline/ComponentTable.tsx +41 -0
  126. package/pipeline/ConfidenceCell.tsx +33 -0
  127. package/pipeline/PipelineExplorer.tsx +148 -0
  128. package/pipeline/QueryForm.tsx +52 -0
  129. package/pipeline/ResolvedPlace.tsx +50 -0
  130. package/pipeline/copy.ts +37 -0
  131. package/pipeline/presets.ts +24 -0
  132. package/pipeline/types.ts +118 -0
  133. package/pipeline/useParsePipeline.ts +84 -0
  134. package/poi/AbstainPanel.tsx +22 -0
  135. package/poi/LiveResultsBlock.tsx +70 -0
  136. package/poi/OverpassBlock.tsx +37 -0
  137. package/poi/POIExplorer.tsx +133 -0
  138. package/poi/QueryInput.tsx +26 -0
  139. package/poi/SubjectPanel.tsx +79 -0
  140. package/poi/runtime.ts +78 -0
  141. package/poi/types.ts +112 -0
  142. package/poi/usePOISearch.ts +234 -0
  143. package/styles.css +648 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `useParsePipeline` — the headless core of the pipeline explorer. Owns the query text, the
7
+ * busy/stage flags, the parse result, and the candidate selection; delegates the actual parse+resolve
8
+ * to the injected {@link PipelineRuntime}. No model or gazetteer code lives here — it just drives the
9
+ * contract and shapes the UI state.
10
+ */
11
+
12
+ import { useCallback, useState } from "react"
13
+
14
+ import type { ParseResult, PipelineRuntime, ResolvedPlaceView } from "./types.ts"
15
+
16
+ export interface UseParsePipelineOptions {
17
+ runtime: PipelineRuntime
18
+ defaultText: string
19
+ }
20
+
21
+ export interface UseParsePipeline {
22
+ text: string
23
+ setText: (text: string) => void
24
+ busy: boolean
25
+ /** 0-based index into `runtime.parseStageLabels`; -1 when idle. */
26
+ parseStage: number
27
+ result: ParseResult | null
28
+ parseError: string | null
29
+ selectedCandidateIndex: number
30
+ selectCandidate: (index: number) => void
31
+ /** The currently-selected candidate (falls back to the first), or null. */
32
+ selectedCandidate: ResolvedPlaceView | null
33
+ /** Run a parse for the current text. Safe to bind to a form's `onSubmit`. */
34
+ submit: () => Promise<void>
35
+ /** Clear the result (used when a preset replaces the input). */
36
+ reset: () => void
37
+ }
38
+
39
+ export function useParsePipeline({ runtime, defaultText }: UseParsePipelineOptions): UseParsePipeline {
40
+ const [text, setText] = useState(defaultText)
41
+ const [busy, setBusy] = useState(false)
42
+ const [parseStage, setParseStage] = useState(-1)
43
+ const [result, setResult] = useState<ParseResult | null>(null)
44
+ const [selectedCandidateIndex, setSelectedCandidateIndex] = useState(0)
45
+ const [parseError, setParseError] = useState<string | null>(null)
46
+
47
+ const submit = useCallback(async () => {
48
+ if (!runtime.ready || busy) return
49
+
50
+ setBusy(true)
51
+ setParseStage(0)
52
+ setParseError(null)
53
+
54
+ try {
55
+ const parsed = await runtime.runParse(text, { onStage: setParseStage })
56
+ setSelectedCandidateIndex(0)
57
+ setResult(parsed)
58
+ } catch (err) {
59
+ setParseError(err instanceof Error ? err.message : String(err))
60
+ } finally {
61
+ setBusy(false)
62
+ setParseStage(-1)
63
+ }
64
+ }, [runtime, text, busy])
65
+
66
+ const reset = useCallback(() => setResult(null), [])
67
+
68
+ // Derived during render — no effect needed.
69
+ const selectedCandidate = result ? (result.candidates[selectedCandidateIndex] ?? result.candidates[0] ?? null) : null
70
+
71
+ return {
72
+ text,
73
+ setText,
74
+ busy,
75
+ parseStage,
76
+ result,
77
+ parseError,
78
+ selectedCandidateIndex,
79
+ selectCandidate: setSelectedCandidateIndex,
80
+ selectedCandidate,
81
+ submit,
82
+ reset,
83
+ }
84
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `AbstainPanel` — shown when the query carries no POI intent (it parses as an address). Names the
7
+ * classifier's kind so the abstention is legible. Presentational.
8
+ */
9
+
10
+ import type { ReactNode } from "react"
11
+
12
+ export interface AbstainPanelProps {
13
+ kind: string
14
+ }
15
+
16
+ export function AbstainPanel({ kind }: AbstainPanelProps): ReactNode {
17
+ return (
18
+ <p className="mw-muted">
19
+ No POI intent detected — parses as an address (kind <code>{kind}</code>).
20
+ </p>
21
+ )
22
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `LiveResultsBlock` — the on-demand live poi.db search affordance: a "Search live" button plus the
7
+ * idle / loading / error / results states. Presentational — all behavior comes in via props (the
8
+ * `usePOISearch` hook owns the state machine + the injected probe).
9
+ */
10
+
11
+ import type { ReactNode } from "react"
12
+
13
+ import { formatDistance } from "./runtime.ts"
14
+ import type { LiveSearchState } from "./types.ts"
15
+
16
+ export interface LiveResultsBlockProps {
17
+ /** Subject label for empty-state copy — a category ("No hospital results near …") or a brand ("No chevron results …"). */
18
+ subjectLabel: string
19
+ /** The location anchor the search runs against (empty ⇒ the button is disabled with a hint). */
20
+ anchor: string
21
+ state: LiveSearchState
22
+ onSearch: () => void
23
+ }
24
+
25
+ export function LiveResultsBlock({ subjectLabel, anchor, state, onSearch }: LiveResultsBlockProps): ReactNode {
26
+ const hasAnchor = anchor.trim().length > 0
27
+
28
+ return (
29
+ <div className="mw-live">
30
+ <div className="mw-panel__header">
31
+ <h3>Live results</h3>
32
+ <button
33
+ type="button"
34
+ className="mw-btn"
35
+ onClick={onSearch}
36
+ disabled={!hasAnchor || state.status === "loading"}
37
+ title={hasAnchor ? "Search the published poi.db layer" : 'Needs a location anchor (e.g. "near Springfield")'}
38
+ >
39
+ {state.status === "loading" ? "Searching…" : "Search live"}
40
+ </button>
41
+ </div>
42
+
43
+ {!hasAnchor ? (
44
+ <p className="mw-muted">Add a location anchor (e.g. "near Springfield") to search live.</p>
45
+ ) : state.status === "error" ? (
46
+ <p className="mw-error">{state.message}</p>
47
+ ) : state.status === "success" ? (
48
+ state.hits.length === 0 ? (
49
+ <p className="mw-muted">
50
+ No {subjectLabel.toLowerCase()} results near {state.centerName}.
51
+ </p>
52
+ ) : (
53
+ <>
54
+ <p className="mw-live__caption">Near {state.centerName}, ranked by distance:</p>
55
+ <ul className="mw-live__results">
56
+ {state.hits.map((hit, i) => (
57
+ <li key={`${hit.name}-${i}`}>
58
+ <span className="mw-live__name">{hit.name}</span>
59
+ <span className="mw-live__meta">
60
+ {formatDistance(hit.distanceM)} · {hit.country}
61
+ </span>
62
+ </li>
63
+ ))}
64
+ </ul>
65
+ </>
66
+ )
67
+ ) : null}
68
+ </div>
69
+ )
70
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `OverpassBlock` — the OverpassQL export panel: a header with a copy button and the query in a code
7
+ * block. Renders the emitter error instead when the export failed. Presentational.
8
+ */
9
+
10
+ import type { ReactNode } from "react"
11
+
12
+ import { CopyButton } from "../common/CopyButton.tsx"
13
+
14
+ export interface OverpassBlockProps {
15
+ overpassQL?: string
16
+ overpassError?: string
17
+ }
18
+
19
+ export function OverpassBlock({ overpassQL, overpassError }: OverpassBlockProps): ReactNode {
20
+ if (overpassQL) {
21
+ return (
22
+ <div className="mw-overpass">
23
+ <div className="mw-panel__header">
24
+ <h3>OverpassQL export</h3>
25
+ <CopyButton value={overpassQL} className="mw-btn" />
26
+ </div>
27
+ <pre className="mw-overpass__code">
28
+ <code>{overpassQL}</code>
29
+ </pre>
30
+ </div>
31
+ )
32
+ }
33
+
34
+ if (overpassError) return <p className="mw-error">{overpassError}</p>
35
+
36
+ return null
37
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `POIExplorer` — the composed POI-intent tester. Detects a POI subject in free text, shows the
7
+ * category + OverpassQL export, and (when a live-search probe is injected) searches the published
8
+ * poi.db layer. The intent path is self-contained — no weights, no network. All runtime concerns are
9
+ * in {@link usePOISearch}; this file is composition + a `ClientOnly` SSR boundary.
10
+ */
11
+
12
+ import { type ReactNode, useState } from "react"
13
+
14
+ import { ClientOnly } from "../common/ClientOnly.tsx"
15
+ import { KindBadge } from "../common/KindBadge.tsx"
16
+ import { PresetChips, type Preset } from "../common/PresetChips.tsx"
17
+ import { AbstainPanel } from "./AbstainPanel.tsx"
18
+ import { LiveResultsBlock } from "./LiveResultsBlock.tsx"
19
+ import { OverpassBlock } from "./OverpassBlock.tsx"
20
+ import { QueryInput } from "./QueryInput.tsx"
21
+ import { POI_DEFAULT_TEXT, POI_PRESETS } from "./runtime.ts"
22
+ import { SubjectPanel } from "./SubjectPanel.tsx"
23
+ import type { LoadPOIRuntime, POILiveSearch } from "./types.ts"
24
+ import { usePOISearch } from "./usePOISearch.ts"
25
+
26
+ export interface POIExplorerProps {
27
+ /** Query to pre-fill in the input. */
28
+ defaultText?: string
29
+ /** Example chips. @default the built-in POI presets */
30
+ presets?: ReadonlyArray<Preset>
31
+ /** Override the taxonomy-runtime loader (stories/tests inject a mock). */
32
+ loadRuntime?: LoadPOIRuntime
33
+ /** Live poi.db probe. Absent ⇒ intent-only (no live-results affordance). */
34
+ runLiveSearch?: POILiveSearch
35
+ /**
36
+ * Whether {@link runLiveSearch} can serve BRAND subjects (fetch by QID). Default false: a brand subject shows the
37
+ * intent + QID chip but no live block. The docs' httpvfs probe leaves this off (brand-wide byte-range hydration is
38
+ * pathological — measured); a server-side backend can enable it.
39
+ */
40
+ brandLiveSearch?: boolean
41
+ }
42
+
43
+ interface POIExplorerInnerProps extends POIExplorerProps {
44
+ defaultText: string
45
+ presets: ReadonlyArray<Preset>
46
+ }
47
+
48
+ function POIExplorerInner({
49
+ defaultText,
50
+ presets,
51
+ loadRuntime,
52
+ runLiveSearch,
53
+ brandLiveSearch,
54
+ }: POIExplorerInnerProps): ReactNode {
55
+ const [text, setText] = useState(defaultText)
56
+ const { result, liveSearch, searchLive } = usePOISearch({
57
+ text,
58
+ loadRuntime,
59
+ runLiveSearch,
60
+ brandLiveSearch,
61
+ })
62
+
63
+ const subject = result?.subject
64
+ // The live block appears whenever the subject COULD be live-searched with an anchor — i.e. capable minus the
65
+ // anchor-present requirement (`canSearchLive` also requires an anchor; the block itself renders the "add an anchor"
66
+ // hint, so it must show one step earlier). Category: not build-local. Brand: brand-capable probe + a QID.
67
+ const showLiveBlock = Boolean(
68
+ runLiveSearch &&
69
+ subject &&
70
+ (subject.kind === "brand" ? brandLiveSearch && subject.wikidata !== undefined : !subject.buildLocal)
71
+ )
72
+
73
+ return (
74
+ <div className="mw-poi-explorer">
75
+ <QueryInput id="mw-poi-input" label="Query" value={text} onChange={setText} placeholder={POI_DEFAULT_TEXT} />
76
+ <PresetChips presets={presets} onPick={setText} />
77
+
78
+ {result ? (
79
+ <div className="mw-result">
80
+ <KindBadge kindResult={result.kindResult} />
81
+
82
+ {subject ? (
83
+ <>
84
+ <SubjectPanel subject={subject} />
85
+ {subject.kind === "category" ? (
86
+ <OverpassBlock overpassQL={result.overpassQL} overpassError={result.overpassError} />
87
+ ) : null}
88
+
89
+ {showLiveBlock ? (
90
+ <LiveResultsBlock
91
+ subjectLabel={subject.kind === "brand" ? subject.name : subject.category.label}
92
+ anchor={subject.remainder}
93
+ state={liveSearch}
94
+ onSearch={searchLive}
95
+ />
96
+ ) : null}
97
+ </>
98
+ ) : (
99
+ <AbstainPanel kind={result.kindResult.kind} />
100
+ )}
101
+ </div>
102
+ ) : null}
103
+ </div>
104
+ )
105
+ }
106
+
107
+ export function POIExplorer({
108
+ defaultText = POI_DEFAULT_TEXT,
109
+ presets = POI_PRESETS,
110
+ loadRuntime,
111
+ runLiveSearch,
112
+ brandLiveSearch,
113
+ }: POIExplorerProps): ReactNode {
114
+ return (
115
+ <ClientOnly
116
+ fallback={
117
+ <div className="mw-poi-explorer">
118
+ <p>Loading POI tester…</p>
119
+ </div>
120
+ }
121
+ >
122
+ {() => (
123
+ <POIExplorerInner
124
+ defaultText={defaultText}
125
+ presets={presets}
126
+ loadRuntime={loadRuntime}
127
+ runLiveSearch={runLiveSearch}
128
+ brandLiveSearch={brandLiveSearch}
129
+ />
130
+ )}
131
+ </ClientOnly>
132
+ )
133
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `QueryInput` — the labelled text field for the POI explorer. Controlled; presentational only.
7
+ */
8
+
9
+ import type { ReactNode } from "react"
10
+
11
+ export interface QueryInputProps {
12
+ id: string
13
+ label: string
14
+ value: string
15
+ onChange: (value: string) => void
16
+ placeholder?: string
17
+ }
18
+
19
+ export function QueryInput({ id, label, value, onChange, placeholder }: QueryInputProps): ReactNode {
20
+ return (
21
+ <div className="mw-field">
22
+ <label htmlFor={id}>{label}</label>
23
+ <input id={id} type="text" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
24
+ </div>
25
+ )
26
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `SubjectPanel` — renders the detected POI subject. For a CATEGORY: the category chip, the optional
7
+ * build-local badge + note. For a BRAND: the brand-name chip + a Wikidata QID chip (linked). Both
8
+ * share the match-detail list (matched phrase, confidence, anchor). Presentational.
9
+ */
10
+
11
+ import type { ReactNode } from "react"
12
+
13
+ import type { POISubject } from "./types.ts"
14
+
15
+ export interface SubjectPanelProps {
16
+ subject: POISubject
17
+ }
18
+
19
+ /** The shared match-detail list (matched phrase, confidence, anchor) — identical for category + brand subjects. */
20
+ function MatchDetail({ subject }: SubjectPanelProps): ReactNode {
21
+ return (
22
+ <dl className="mw-subject__detail">
23
+ <dt>matched phrase</dt>
24
+ <dd>
25
+ <code>{subject.matchedPhrase}</code>
26
+ </dd>
27
+ <dt>confidence</dt>
28
+ <dd>{Math.round(subject.confidence * 100)}%</dd>
29
+ <dt>anchor</dt>
30
+ <dd>{subject.remainder ? subject.remainder : <em>none — global query</em>}</dd>
31
+ </dl>
32
+ )
33
+ }
34
+
35
+ export function SubjectPanel({ subject }: SubjectPanelProps): ReactNode {
36
+ if (subject.kind === "brand") {
37
+ return (
38
+ <>
39
+ <div className="mw-subject__row">
40
+ <span className="mw-subject__chip">{subject.name}</span>
41
+ <span className="mw-subject__badge mw-subject__badge--brand">brand</span>
42
+ {subject.wikidata ? (
43
+ <a
44
+ className="mw-subject__qid"
45
+ href={`https://www.wikidata.org/wiki/${subject.wikidata}`}
46
+ target="_blank"
47
+ rel="noreferrer"
48
+ >
49
+ {subject.wikidata}
50
+ </a>
51
+ ) : null}
52
+ </div>
53
+
54
+ <MatchDetail subject={subject} />
55
+
56
+ <p className="mw-subject__note">
57
+ Matched as a chain brand — resolved by Wikidata QID against the layer's brand index.
58
+ </p>
59
+ </>
60
+ )
61
+ }
62
+
63
+ return (
64
+ <>
65
+ <div className="mw-subject__row">
66
+ <span className="mw-subject__chip">{subject.category.label}</span>
67
+ {subject.buildLocal ? <span className="mw-subject__badge">build-local</span> : null}
68
+ </div>
69
+
70
+ <MatchDetail subject={subject} />
71
+
72
+ {subject.buildLocal ? (
73
+ <p className="mw-subject__note">
74
+ Requires the locally-built OSM layer (ODbL) — mailwoman ships the builder, not the data.
75
+ </p>
76
+ ) : null}
77
+ </>
78
+ )
79
+ }
package/poi/runtime.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The default POI runtime loader + presentation helpers. `loadPOIRuntime` dynamically imports the
7
+ * ~2k-record Overture taxonomy snapshot (so bundlers code-split it into its own chunk, fetched only
8
+ * when the explorer actually mounts) and wires the kind classifier over it. Pure + browser-safe: no
9
+ * network, no DOM — the whole intent path runs offline.
10
+ */
11
+
12
+ import { createKindClassifier } from "@mailwoman/kind-classifier"
13
+ import type { POIPhraseLookup } from "@mailwoman/kind-classifier"
14
+ import { createPOIBrandLookup, createPOITaxonomyLookup } from "@mailwoman/poi-taxonomy/table"
15
+
16
+ import type { POIRuntime } from "./types.ts"
17
+
18
+ /**
19
+ * Build the POI runtime, dynamically importing the taxonomy + brand JSON so the tables land in their own chunk. A
20
+ * static import would inline the whole snapshot into every consumer's bundle.
21
+ *
22
+ * The lexicon unions categories then brands, mirroring the Node runtime's `poiTaxonomyLookup` precedence: a phrase that
23
+ * matches a taxonomy CATEGORY wins (the curated set); only on a category miss does the chain-brand table fire,
24
+ * returning a `kind: "brand"` match carrying the brand's canonical name + Wikidata QID. (The Node path also chains
25
+ * `@mailwoman/variant-aliases` regional slang; the browser tester leaves that out — one fewer package + data table for
26
+ * a demo, and the QID-keyed brand table already covers the headline brands.)
27
+ */
28
+ export async function loadPOIRuntime(): Promise<POIRuntime> {
29
+ const [table, brandTable] = await Promise.all([
30
+ import("@mailwoman/poi-taxonomy/data/taxonomy.json").then((m) => m.default),
31
+ import("@mailwoman/poi-taxonomy/data/brands.json").then((m) => m.default),
32
+ ])
33
+ const lookup = createPOITaxonomyLookup(table as unknown as Parameters<typeof createPOITaxonomyLookup>[0])
34
+ const brands = createPOIBrandLookup(brandTable as unknown as Parameters<typeof createPOIBrandLookup>[0])
35
+
36
+ // Adapt the taxonomy + brand lookups to the `POIPhraseLookup` shape the classifier expects: categories first, brands
37
+ // on a category miss.
38
+ const lexicon: POIPhraseLookup = (phrase, locale) => {
39
+ const categoryHits = lookup.lookupPOICategory(phrase, locale)
40
+
41
+ if (categoryHits.length > 0) {
42
+ return categoryHits.map((match) => ({
43
+ kind: "category",
44
+ categoryID: match.category.id,
45
+ matchedPhrase: match.matchedPhrase,
46
+ confidence: match.confidence,
47
+ }))
48
+ }
49
+
50
+ return brands.lookupPOIBrand(phrase).map((match) => ({
51
+ kind: "brand",
52
+ categoryID: match.brand.name,
53
+ wikidata: match.brand.wikidata,
54
+ matchedPhrase: match.matchedPhrase,
55
+ confidence: match.confidence,
56
+ }))
57
+ }
58
+
59
+ return { lookup, lexicon, classify: createKindClassifier({ poiLexicon: lexicon }) }
60
+ }
61
+
62
+ /** Default example queries for the POI explorer — a mix of category, build-local, and chain-brand subjects. */
63
+ export const POI_PRESETS = [
64
+ { label: "Drinking fountain", value: "drinking fountain near Springfield" },
65
+ { label: "Fire hydrant", value: "fire hydrant" },
66
+ { label: "Hospital + address", value: "hospital, 350 5th Ave, New York" },
67
+ { label: "Chevron (brand)", value: "chevron near Houston" },
68
+ { label: "Applebee's (brand)", value: "applebee's near Chicago" },
69
+ ] as const
70
+
71
+ export const POI_DEFAULT_TEXT = POI_PRESETS[0].value
72
+
73
+ /** `742 m` under 1 km, `1.9 km` past it — matches the demo's distance captions. */
74
+ export function formatDistance(distanceM: number): string {
75
+ if (distanceM < 1000) return `${Math.round(distanceM)} m`
76
+
77
+ return `${(distanceM / 1000).toFixed(1)} km`
78
+ }
package/poi/types.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Types for the POI explorer. The intent path (classification → subject → OverpassQL) is fully
7
+ * self-contained over the pure `@mailwoman/*` packages; the live poi.db path is expressed only as an
8
+ * INJECTED async function ({@link POILiveSearch}) so the httpvfs/worker machinery stays out of this
9
+ * package's browser graph (it lives in the docs site, which knows where the layer is served).
10
+ */
11
+
12
+ import type { POIPhraseLookup, QueryKindResult } from "@mailwoman/kind-classifier"
13
+ import type { createKindClassifier } from "@mailwoman/kind-classifier"
14
+ import type { createPOITaxonomyLookup } from "@mailwoman/poi-taxonomy/table"
15
+
16
+ export type TaxonomyLookup = ReturnType<typeof createPOITaxonomyLookup>
17
+ export type CategoryRecord = NonNullable<ReturnType<TaxonomyLookup["getPOICategory"]>>
18
+
19
+ /** The lazily-loaded POI runtime: the taxonomy lookup, the adapted lexicon, and the kind classifier over it. */
20
+ export interface POIRuntime {
21
+ lookup: TaxonomyLookup
22
+ lexicon: POIPhraseLookup
23
+ classify: ReturnType<typeof createKindClassifier>
24
+ }
25
+
26
+ /** Fields shared by every resolved POI subject, category or brand. */
27
+ export interface POISubjectBase {
28
+ matchedPhrase: string
29
+ confidence: number
30
+ /** The non-subject remainder of the query (the location anchor, e.g. "near Springfield"). */
31
+ remainder: string
32
+ }
33
+
34
+ /** A resolved POI subject that names a taxonomy CATEGORY (`cafe`, `hospital`, `drinking fountain`). */
35
+ export interface POICategorySubject extends POISubjectBase {
36
+ kind: "category"
37
+ category: CategoryRecord
38
+ /** Whether this category needs the locally-built OSM (ODbL) layer — precomputed off the runtime. */
39
+ buildLocal: boolean
40
+ }
41
+
42
+ /**
43
+ * A resolved POI subject that names a chain BRAND (`chevron`, `applebee's`). Brands carry a Wikidata QID and are
44
+ * searched by that QID, NOT by category k-ring — see `@mailwoman/poi-taxonomy`'s brand table + the layer's
45
+ * `brand_wikidata` index.
46
+ */
47
+ export interface POIBrandSubject extends POISubjectBase {
48
+ kind: "brand"
49
+ /** The brand's canonical display name. */
50
+ name: string
51
+ /** Wikidata QID, when the lexicon carried one (`Q319642` = Chevron). Absent ⇒ matched by name alone. */
52
+ wikidata?: string
53
+ }
54
+
55
+ /** A resolved POI subject — a taxonomy category or a chain brand — plus its match metadata. */
56
+ export type POISubject = POICategorySubject | POIBrandSubject
57
+
58
+ /** The intent-only result: the kind verdict plus (when a subject was detected) its OverpassQL export (category only). */
59
+ export interface POIExplorerResult {
60
+ kindResult: QueryKindResult
61
+ subject?: POISubject
62
+ overpassQL?: string
63
+ overpassError?: string
64
+ }
65
+
66
+ /** One live poi.db hit, as the results list renders it. */
67
+ export interface POISearchHit {
68
+ name: string
69
+ lat: number
70
+ lon: number
71
+ distanceM: number
72
+ country: string
73
+ confidence: number
74
+ }
75
+
76
+ /**
77
+ * Result of an injected live search. Preserves the original tester's two failure modes — the anchor not resolving vs
78
+ * the published layer being unreachable — so the UI can word them differently.
79
+ */
80
+ export type POILiveSearchResult =
81
+ | { status: "success"; hits: POISearchHit[]; centerName: string }
82
+ | { status: "unplaced"; anchor: string }
83
+ | { status: "unavailable" }
84
+
85
+ /**
86
+ * The injected live-search function. Given the resolved category (+ its Overture leaf fan-out) and the anchor text, it
87
+ * probes the published poi.db and returns hits. Absent ⇒ the explorer runs intent-only (no "Search live" button).
88
+ *
89
+ * Brand support is ADDITIVE: when the resolved subject is a chain brand, `brandWikidata` carries its QID and the probe
90
+ * fetches by that QID instead of a category k-ring (`categoryID`/`overtureCategoryIDs` are then the brand name / empty
91
+ * and unused). The category path is byte-identical to before. A probe that can't serve brands simply leaves brand live
92
+ * search unwired at the call site (see `usePOISearch`'s `brandLiveSearch` option) — the docs' httpvfs probe does
93
+ * exactly that, brand-wide row hydration being pathological over byte-range (measured; the brand path is server-side
94
+ * only).
95
+ */
96
+ export type POILiveSearch = (params: {
97
+ categoryID: string
98
+ overtureCategoryIDs: string[]
99
+ anchor: string
100
+ /** Present when the subject is a chain brand — the probe fetches by this QID, not a category k-ring. */
101
+ brandWikidata?: string
102
+ }) => Promise<POILiveSearchResult>
103
+
104
+ /** "Search live" state machine. */
105
+ export type LiveSearchState =
106
+ | { status: "idle" }
107
+ | { status: "loading" }
108
+ | { status: "error"; message: string }
109
+ | { status: "success"; hits: POISearchHit[]; centerName: string }
110
+
111
+ /** A single-argument runtime loader — injectable so stories/tests can substitute a mock taxonomy. */
112
+ export type LoadPOIRuntime = () => Promise<POIRuntime>