@mailwoman/react 0.0.1 → 7.4.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 +83 -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 +25 -0
  100. package/out/poi/POIExplorer.d.ts.map +1 -0
  101. package/out/poi/POIExplorer.js +31 -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 +15 -0
  108. package/out/poi/SubjectPanel.d.ts.map +1 -0
  109. package/out/poi/SubjectPanel.js +5 -0
  110. package/out/poi/SubjectPanel.js.map +1 -0
  111. package/out/poi/runtime.d.ts +34 -0
  112. package/out/poi/runtime.d.ts.map +1 -0
  113. package/out/poi/runtime.js +43 -0
  114. package/out/poi/runtime.js.map +1 -0
  115. package/out/poi/types.d.ts +86 -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 +35 -0
  120. package/out/poi/usePOISearch.d.ts.map +1 -0
  121. package/out/poi/usePOISearch.js +123 -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 +102 -0
  138. package/poi/QueryInput.tsx +26 -0
  139. package/poi/SubjectPanel.tsx +44 -0
  140. package/poi/runtime.ts +53 -0
  141. package/poi/types.ts +82 -0
  142. package/poi/usePOISearch.ts +183 -0
  143. package/styles.css +627 -0
@@ -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,102 @@
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
+
37
+ interface POIExplorerInnerProps extends POIExplorerProps {
38
+ defaultText: string
39
+ presets: ReadonlyArray<Preset>
40
+ }
41
+
42
+ function POIExplorerInner({ defaultText, presets, loadRuntime, runLiveSearch }: POIExplorerInnerProps): ReactNode {
43
+ const [text, setText] = useState(defaultText)
44
+ const { result, liveSearch, searchLive } = usePOISearch({ text, loadRuntime, runLiveSearch })
45
+
46
+ return (
47
+ <div className="mw-poi-explorer">
48
+ <QueryInput id="mw-poi-input" label="Query" value={text} onChange={setText} placeholder={POI_DEFAULT_TEXT} />
49
+ <PresetChips presets={presets} onPick={setText} />
50
+
51
+ {result ? (
52
+ <div className="mw-result">
53
+ <KindBadge kindResult={result.kindResult} />
54
+
55
+ {result.subject ? (
56
+ <>
57
+ <SubjectPanel subject={result.subject} />
58
+ <OverpassBlock overpassQL={result.overpassQL} overpassError={result.overpassError} />
59
+
60
+ {runLiveSearch && !result.subject.buildLocal ? (
61
+ <LiveResultsBlock
62
+ categoryLabel={result.subject.category.label}
63
+ anchor={result.subject.remainder}
64
+ state={liveSearch}
65
+ onSearch={searchLive}
66
+ />
67
+ ) : null}
68
+ </>
69
+ ) : (
70
+ <AbstainPanel kind={result.kindResult.kind} />
71
+ )}
72
+ </div>
73
+ ) : null}
74
+ </div>
75
+ )
76
+ }
77
+
78
+ export function POIExplorer({
79
+ defaultText = POI_DEFAULT_TEXT,
80
+ presets = POI_PRESETS,
81
+ loadRuntime,
82
+ runLiveSearch,
83
+ }: POIExplorerProps): ReactNode {
84
+ return (
85
+ <ClientOnly
86
+ fallback={
87
+ <div className="mw-poi-explorer">
88
+ <p>Loading POI tester…</p>
89
+ </div>
90
+ }
91
+ >
92
+ {() => (
93
+ <POIExplorerInner
94
+ defaultText={defaultText}
95
+ presets={presets}
96
+ loadRuntime={loadRuntime}
97
+ runLiveSearch={runLiveSearch}
98
+ />
99
+ )}
100
+ </ClientOnly>
101
+ )
102
+ }
@@ -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,44 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `SubjectPanel` — renders the detected POI subject: the category chip, the optional build-local
7
+ * badge + note, and the match-detail list (matched phrase, confidence, anchor). Presentational.
8
+ */
9
+
10
+ import type { ReactNode } from "react"
11
+
12
+ import type { POISubject } from "./types.ts"
13
+
14
+ export interface SubjectPanelProps {
15
+ subject: POISubject
16
+ }
17
+
18
+ export function SubjectPanel({ subject }: SubjectPanelProps): ReactNode {
19
+ return (
20
+ <>
21
+ <div className="mw-subject__row">
22
+ <span className="mw-subject__chip">{subject.category.label}</span>
23
+ {subject.buildLocal ? <span className="mw-subject__badge">build-local</span> : null}
24
+ </div>
25
+
26
+ <dl className="mw-subject__detail">
27
+ <dt>matched phrase</dt>
28
+ <dd>
29
+ <code>{subject.matchedPhrase}</code>
30
+ </dd>
31
+ <dt>confidence</dt>
32
+ <dd>{Math.round(subject.confidence * 100)}%</dd>
33
+ <dt>anchor</dt>
34
+ <dd>{subject.remainder ? subject.remainder : <em>none — global query</em>}</dd>
35
+ </dl>
36
+
37
+ {subject.buildLocal ? (
38
+ <p className="mw-subject__note">
39
+ Requires the locally-built OSM layer (ODbL) — mailwoman ships the builder, not the data.
40
+ </p>
41
+ ) : null}
42
+ </>
43
+ )
44
+ }
package/poi/runtime.ts ADDED
@@ -0,0 +1,53 @@
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 { 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 JSON so the ~2k-record table lands in its own chunk. A
20
+ * static import would inline the whole snapshot into every consumer's bundle.
21
+ */
22
+ export async function loadPOIRuntime(): Promise<POIRuntime> {
23
+ const table = (await import("@mailwoman/poi-taxonomy/data/taxonomy.json")).default
24
+ const lookup = createPOITaxonomyLookup(table as unknown as Parameters<typeof createPOITaxonomyLookup>[0])
25
+
26
+ // Adapt `POITaxonomyLookup.lookupPOICategory` to the `POIPhraseLookup` shape the classifier expects.
27
+ const lexicon: POIPhraseLookup = (phrase, locale) =>
28
+ lookup.lookupPOICategory(phrase, locale).map((match) => ({
29
+ kind: "category",
30
+ categoryID: match.category.id,
31
+ matchedPhrase: match.matchedPhrase,
32
+ confidence: match.confidence,
33
+ }))
34
+
35
+ return { lookup, lexicon, classify: createKindClassifier({ poiLexicon: lexicon }) }
36
+ }
37
+
38
+ /** Default example queries for the POI explorer. */
39
+ export const POI_PRESETS = [
40
+ { label: "Drinking fountain", value: "drinking fountain near Springfield" },
41
+ { label: "Fire hydrant", value: "fire hydrant" },
42
+ { label: "Hospital + address", value: "hospital, 350 5th Ave, New York" },
43
+ { label: "Biking trails", value: "biking trails near Portland" },
44
+ ] as const
45
+
46
+ export const POI_DEFAULT_TEXT = POI_PRESETS[0].value
47
+
48
+ /** `742 m` under 1 km, `1.9 km` past it — matches the demo's distance captions. */
49
+ export function formatDistance(distanceM: number): string {
50
+ if (distanceM < 1000) return `${Math.round(distanceM)} m`
51
+
52
+ return `${(distanceM / 1000).toFixed(1)} km`
53
+ }
package/poi/types.ts ADDED
@@ -0,0 +1,82 @@
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
+ /** A resolved POI subject — the category the query names plus its match metadata. */
27
+ export interface POISubject {
28
+ category: CategoryRecord
29
+ matchedPhrase: string
30
+ confidence: number
31
+ /** The non-subject remainder of the query (the location anchor, e.g. "near Springfield"). */
32
+ remainder: string
33
+ /** Whether this category needs the locally-built OSM (ODbL) layer — precomputed off the runtime. */
34
+ buildLocal: boolean
35
+ }
36
+
37
+ /** The intent-only result: the kind verdict plus (when a subject was detected) its OverpassQL export. */
38
+ export interface POIExplorerResult {
39
+ kindResult: QueryKindResult
40
+ subject?: POISubject
41
+ overpassQL?: string
42
+ overpassError?: string
43
+ }
44
+
45
+ /** One live poi.db hit, as the results list renders it. */
46
+ export interface POISearchHit {
47
+ name: string
48
+ lat: number
49
+ lon: number
50
+ distanceM: number
51
+ country: string
52
+ confidence: number
53
+ }
54
+
55
+ /**
56
+ * Result of an injected live search. Preserves the original tester's two failure modes — the anchor not resolving vs
57
+ * the published layer being unreachable — so the UI can word them differently.
58
+ */
59
+ export type POILiveSearchResult =
60
+ | { status: "success"; hits: POISearchHit[]; centerName: string }
61
+ | { status: "unplaced"; anchor: string }
62
+ | { status: "unavailable" }
63
+
64
+ /**
65
+ * The injected live-search function. Given the resolved category (+ its Overture leaf fan-out) and the anchor text, it
66
+ * probes the published poi.db and returns hits. Absent ⇒ the explorer runs intent-only (no "Search live" button).
67
+ */
68
+ export type POILiveSearch = (params: {
69
+ categoryID: string
70
+ overtureCategoryIDs: string[]
71
+ anchor: string
72
+ }) => Promise<POILiveSearchResult>
73
+
74
+ /** "Search live" state machine. */
75
+ export type LiveSearchState =
76
+ | { status: "idle" }
77
+ | { status: "loading" }
78
+ | { status: "error"; message: string }
79
+ | { status: "success"; hits: POISearchHit[]; centerName: string }
80
+
81
+ /** A single-argument runtime loader — injectable so stories/tests can substitute a mock taxonomy. */
82
+ export type LoadPOIRuntime = () => Promise<POIRuntime>
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `usePOISearch` — the headless core of the POI explorer. Owns the taxonomy-runtime load, the
7
+ * debounced classify → subject → OverpassQL derivation, and the "Search live" state machine. The
8
+ * runtime loader and the live-search probe are both INJECTABLE, so stories/tests drive it with mocks
9
+ * and no network or db. Presentation is entirely the caller's concern.
10
+ */
11
+
12
+ import { matchPOISubject } from "@mailwoman/kind-classifier"
13
+ import { emitOverpassQL } from "@mailwoman/poi-taxonomy/overpass"
14
+ import type { OverpassIntentLike } from "@mailwoman/poi-taxonomy/overpass"
15
+ import { computeQueryShape } from "@mailwoman/query-shape"
16
+ import { useCallback, useEffect, useRef, useState } from "react"
17
+
18
+ import { useDebouncedValue } from "../common/useDebouncedValue.ts"
19
+ import { loadPOIRuntime } from "./runtime.ts"
20
+ import type { LiveSearchState, LoadPOIRuntime, POIExplorerResult, POILiveSearch, POIRuntime } from "./types.ts"
21
+
22
+ export interface UsePOISearchOptions {
23
+ /** The current query text (controlled by the caller). */
24
+ text: string
25
+ /** Runtime loader. @default loadPOIRuntime */
26
+ loadRuntime?: LoadPOIRuntime
27
+ /** Injected live-search probe. Absent ⇒ the live-results affordance is disabled. */
28
+ runLiveSearch?: POILiveSearch
29
+ /** Debounce before (re)classifying. @default 250 */
30
+ debounceMs?: number
31
+ }
32
+
33
+ export interface UsePOISearch {
34
+ /** True once the taxonomy runtime has loaded. */
35
+ runtimeReady: boolean
36
+ /** The intent result for the current (debounced) query, or null for empty input. */
37
+ result: POIExplorerResult | null
38
+ /** State of the on-demand live poi.db search. */
39
+ liveSearch: LiveSearchState
40
+ /** Whether a live search can run right now (a probe is wired + there's a resolved subject with an anchor). */
41
+ canSearchLive: boolean
42
+ /** Kick off a live search for the current subject. No-op when {@link canSearchLive} is false. */
43
+ searchLive: () => Promise<void>
44
+ }
45
+
46
+ /** Compute the OverpassQL export for a matched subject, capturing any emitter error rather than throwing. */
47
+ function buildOverpass(
48
+ runtime: POIRuntime,
49
+ categoryID: string,
50
+ matchedPhrase: string,
51
+ remainder: string
52
+ ): { overpassQL?: string; overpassError?: string } {
53
+ const category = runtime.lookup.getPOICategory(categoryID)
54
+
55
+ if (!category) {
56
+ return {}
57
+ }
58
+
59
+ const intent: OverpassIntentLike = {
60
+ subject: { kind: "category", categoryID, matched: matchedPhrase },
61
+ ...(remainder ? { anchor: { text: remainder } } : {}),
62
+ }
63
+
64
+ try {
65
+ return { overpassQL: emitOverpassQL(intent, category.osmTag ? { osmTag: category.osmTag } : {}) }
66
+ } catch (err) {
67
+ return { overpassError: err instanceof Error ? err.message : String(err) }
68
+ }
69
+ }
70
+
71
+ export function usePOISearch({
72
+ text,
73
+ loadRuntime = loadPOIRuntime,
74
+ runLiveSearch,
75
+ debounceMs = 250,
76
+ }: UsePOISearchOptions): UsePOISearch {
77
+ const [runtime, setRuntime] = useState<POIRuntime | null>(null)
78
+ const [result, setResult] = useState<POIExplorerResult | null>(null)
79
+ const [liveSearch, setLiveSearch] = useState<LiveSearchState>({ status: "idle" })
80
+
81
+ const debouncedText = useDebouncedValue(text, debounceMs)
82
+
83
+ // Capture the loader in a ref so the load fires exactly ONCE on mount, regardless of whether the
84
+ // caller passes a fresh `loadRuntime` closure each render (an inline `async () => …` would otherwise
85
+ // retrigger the effect → reload → re-render loop). The runtime is a load-once resource.
86
+ const loadRuntimeRef = useRef(loadRuntime)
87
+ loadRuntimeRef.current = loadRuntime
88
+
89
+ useEffect(() => {
90
+ let cancelled = false
91
+
92
+ loadRuntimeRef.current().then((loaded) => {
93
+ if (!cancelled) {
94
+ setRuntime(loaded)
95
+ }
96
+ })
97
+
98
+ return () => {
99
+ cancelled = true
100
+ }
101
+ }, [])
102
+
103
+ // Classify the debounced query and derive the subject + OverpassQL (async, so it lives in an effect).
104
+ useEffect(() => {
105
+ if (!runtime) return
106
+
107
+ let cancelled = false
108
+ const trimmed = debouncedText.trim()
109
+
110
+ // A new query invalidates live results from the previous one.
111
+ setLiveSearch({ status: "idle" })
112
+
113
+ if (!trimmed) {
114
+ setResult(null)
115
+
116
+ return
117
+ }
118
+
119
+ const input = { raw: trimmed, normalized: trimmed }
120
+ const shape = computeQueryShape(trimmed)
121
+
122
+ runtime.classify(input, shape).then((kindResult) => {
123
+ if (cancelled) return
124
+
125
+ const matched = kindResult.kind === "poi_query" ? matchPOISubject(trimmed, undefined, runtime.lexicon) : null
126
+ const category = matched ? runtime.lookup.getPOICategory(matched.match.categoryID) : undefined
127
+
128
+ if (!matched || !category) {
129
+ setResult({ kindResult })
130
+
131
+ return
132
+ }
133
+
134
+ setResult({
135
+ kindResult,
136
+ subject: {
137
+ category,
138
+ matchedPhrase: matched.match.matchedPhrase,
139
+ confidence: matched.match.confidence,
140
+ remainder: matched.remainder,
141
+ buildLocal: runtime.lookup.requiresBuildLocalLayer(category),
142
+ },
143
+ ...buildOverpass(runtime, matched.match.categoryID, matched.match.matchedPhrase, matched.remainder),
144
+ })
145
+ })
146
+
147
+ return () => {
148
+ cancelled = true
149
+ }
150
+ }, [debouncedText, runtime])
151
+
152
+ const subject = result?.subject
153
+ const canSearchLive = Boolean(
154
+ runLiveSearch && runtime && subject && !subject.buildLocal && subject.remainder.trim().length > 0
155
+ )
156
+
157
+ const searchLive = useCallback(async () => {
158
+ if (!runLiveSearch || !runtime || !subject || subject.buildLocal || !subject.remainder.trim()) return
159
+
160
+ setLiveSearch({ status: "loading" })
161
+
162
+ try {
163
+ const outcome = await runLiveSearch({
164
+ categoryID: subject.category.id,
165
+ // Fan the canonical seed id out over its Overture leaves — the same translation the Node reader uses.
166
+ overtureCategoryIDs: runtime.lookup.resolveOvertureCategories(subject.category.id),
167
+ anchor: subject.remainder,
168
+ })
169
+
170
+ if (outcome.status === "success") {
171
+ setLiveSearch({ status: "success", hits: outcome.hits, centerName: outcome.centerName })
172
+ } else if (outcome.status === "unplaced") {
173
+ setLiveSearch({ status: "error", message: `couldn't place "${outcome.anchor}"` })
174
+ } else {
175
+ setLiveSearch({ status: "error", message: "the published POI layer isn't reachable" })
176
+ }
177
+ } catch {
178
+ setLiveSearch({ status: "error", message: "the published POI layer isn't reachable" })
179
+ }
180
+ }, [runLiveSearch, runtime, subject])
181
+
182
+ return { runtimeReady: runtime !== null, result, liveSearch, canSearchLive, searchLive }
183
+ }