@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.
- package/README.md +34 -4
- package/common/ClientOnly.tsx +30 -0
- package/common/CopyButton.tsx +44 -0
- package/common/KindBadge.tsx +44 -0
- package/common/LoadingIndicator.tsx +104 -0
- package/common/PresetChips.tsx +43 -0
- package/common/cx.ts +34 -0
- package/common/useClipboard.ts +73 -0
- package/common/useDebouncedValue.ts +23 -0
- package/index.ts +86 -4
- package/out/common/ClientOnly.d.ts +22 -0
- package/out/common/ClientOnly.d.ts.map +1 -0
- package/out/common/ClientOnly.js +20 -0
- package/out/common/ClientOnly.js.map +1 -0
- package/out/common/CopyButton.d.ts +21 -0
- package/out/common/CopyButton.d.ts.map +1 -0
- package/out/common/CopyButton.js +7 -0
- package/out/common/CopyButton.js.map +1 -0
- package/out/common/KindBadge.d.ts +24 -0
- package/out/common/KindBadge.d.ts.map +1 -0
- package/out/common/KindBadge.js +6 -0
- package/out/common/KindBadge.js.map +1 -0
- package/out/common/LoadingIndicator.d.ts +27 -0
- package/out/common/LoadingIndicator.d.ts.map +1 -0
- package/out/common/LoadingIndicator.js +41 -0
- package/out/common/LoadingIndicator.js.map +1 -0
- package/out/common/PresetChips.d.ts +22 -0
- package/out/common/PresetChips.d.ts.map +1 -0
- package/out/common/PresetChips.js +5 -0
- package/out/common/PresetChips.js.map +1 -0
- package/out/common/cx.d.ts +13 -0
- package/out/common/cx.d.ts.map +1 -0
- package/out/common/cx.js +28 -0
- package/out/common/cx.js.map +1 -0
- package/out/common/useClipboard.d.ts +16 -0
- package/out/common/useClipboard.d.ts.map +1 -0
- package/out/common/useClipboard.js +56 -0
- package/out/common/useClipboard.js.map +1 -0
- package/out/common/useDebouncedValue.d.ts +11 -0
- package/out/common/useDebouncedValue.d.ts.map +1 -0
- package/out/common/useDebouncedValue.js +19 -0
- package/out/common/useDebouncedValue.js.map +1 -0
- package/out/index.d.ts +57 -4
- package/out/index.d.ts.map +1 -1
- package/out/index.js +37 -4
- package/out/index.js.map +1 -1
- package/out/pipeline/CandidatePicker.d.ts +17 -0
- package/out/pipeline/CandidatePicker.d.ts.map +1 -0
- package/out/pipeline/CandidatePicker.js +6 -0
- package/out/pipeline/CandidatePicker.js.map +1 -0
- package/out/pipeline/ComponentTable.d.ts +14 -0
- package/out/pipeline/ComponentTable.d.ts.map +1 -0
- package/out/pipeline/ComponentTable.js +6 -0
- package/out/pipeline/ComponentTable.js.map +1 -0
- package/out/pipeline/ConfidenceCell.d.ts +13 -0
- package/out/pipeline/ConfidenceCell.d.ts.map +1 -0
- package/out/pipeline/ConfidenceCell.js +22 -0
- package/out/pipeline/ConfidenceCell.js.map +1 -0
- package/out/pipeline/PipelineExplorer.d.ts +25 -0
- package/out/pipeline/PipelineExplorer.d.ts.map +1 -0
- package/out/pipeline/PipelineExplorer.js +27 -0
- package/out/pipeline/PipelineExplorer.js.map +1 -0
- package/out/pipeline/QueryForm.d.ts +19 -0
- package/out/pipeline/QueryForm.d.ts.map +1 -0
- package/out/pipeline/QueryForm.js +9 -0
- package/out/pipeline/QueryForm.js.map +1 -0
- package/out/pipeline/ResolvedPlace.d.ts +16 -0
- package/out/pipeline/ResolvedPlace.d.ts.map +1 -0
- package/out/pipeline/ResolvedPlace.js +14 -0
- package/out/pipeline/ResolvedPlace.js.map +1 -0
- package/out/pipeline/copy.d.ts +11 -0
- package/out/pipeline/copy.d.ts.map +1 -0
- package/out/pipeline/copy.js +31 -0
- package/out/pipeline/copy.js.map +1 -0
- package/out/pipeline/presets.d.ts +12 -0
- package/out/pipeline/presets.d.ts.map +1 -0
- package/out/pipeline/presets.js +21 -0
- package/out/pipeline/presets.js.map +1 -0
- package/out/pipeline/types.d.ts +110 -0
- package/out/pipeline/types.d.ts.map +1 -0
- package/out/pipeline/types.js +12 -0
- package/out/pipeline/types.js.map +1 -0
- package/out/pipeline/useParsePipeline.d.ts +34 -0
- package/out/pipeline/useParsePipeline.d.ts.map +1 -0
- package/out/pipeline/useParsePipeline.js +55 -0
- package/out/pipeline/useParsePipeline.js.map +1 -0
- package/out/poi/AbstainPanel.d.ts +14 -0
- package/out/poi/AbstainPanel.d.ts.map +1 -0
- package/out/poi/AbstainPanel.js +5 -0
- package/out/poi/AbstainPanel.js.map +1 -0
- package/out/poi/LiveResultsBlock.d.ts +21 -0
- package/out/poi/LiveResultsBlock.d.ts.map +1 -0
- package/out/poi/LiveResultsBlock.js +7 -0
- package/out/poi/LiveResultsBlock.js.map +1 -0
- package/out/poi/OverpassBlock.d.ts +15 -0
- package/out/poi/OverpassBlock.d.ts.map +1 -0
- package/out/poi/OverpassBlock.js +11 -0
- package/out/poi/OverpassBlock.js.map +1 -0
- package/out/poi/POIExplorer.d.ts +31 -0
- package/out/poi/POIExplorer.d.ts.map +1 -0
- package/out/poi/POIExplorer.js +43 -0
- package/out/poi/POIExplorer.js.map +1 -0
- package/out/poi/QueryInput.d.ts +17 -0
- package/out/poi/QueryInput.d.ts.map +1 -0
- package/out/poi/QueryInput.js +5 -0
- package/out/poi/QueryInput.js.map +1 -0
- package/out/poi/SubjectPanel.d.ts +16 -0
- package/out/poi/SubjectPanel.d.ts.map +1 -0
- package/out/poi/SubjectPanel.js +12 -0
- package/out/poi/SubjectPanel.js.map +1 -0
- package/out/poi/runtime.d.ts +43 -0
- package/out/poi/runtime.d.ts.map +1 -0
- package/out/poi/runtime.js +67 -0
- package/out/poi/runtime.js.map +1 -0
- package/out/poi/types.d.ts +113 -0
- package/out/poi/types.d.ts.map +1 -0
- package/out/poi/types.js +12 -0
- package/out/poi/types.js.map +1 -0
- package/out/poi/usePOISearch.d.ts +42 -0
- package/out/poi/usePOISearch.d.ts.map +1 -0
- package/out/poi/usePOISearch.js +158 -0
- package/out/poi/usePOISearch.js.map +1 -0
- package/package.json +52 -8
- package/pipeline/CandidatePicker.tsx +45 -0
- package/pipeline/ComponentTable.tsx +41 -0
- package/pipeline/ConfidenceCell.tsx +33 -0
- package/pipeline/PipelineExplorer.tsx +148 -0
- package/pipeline/QueryForm.tsx +52 -0
- package/pipeline/ResolvedPlace.tsx +50 -0
- package/pipeline/copy.ts +37 -0
- package/pipeline/presets.ts +24 -0
- package/pipeline/types.ts +118 -0
- package/pipeline/useParsePipeline.ts +84 -0
- package/poi/AbstainPanel.tsx +22 -0
- package/poi/LiveResultsBlock.tsx +70 -0
- package/poi/OverpassBlock.tsx +37 -0
- package/poi/POIExplorer.tsx +133 -0
- package/poi/QueryInput.tsx +26 -0
- package/poi/SubjectPanel.tsx +79 -0
- package/poi/runtime.ts +78 -0
- package/poi/types.ts +112 -0
- package/poi/usePOISearch.ts +234 -0
- package/styles.css +648 -0
|
@@ -0,0 +1,234 @@
|
|
|
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
|
+
/**
|
|
30
|
+
* Whether the injected probe can serve BRAND subjects (fetch by Wikidata QID). Default false: brand subjects show the
|
|
31
|
+
* intent + QID chip but no live-search affordance. The docs' httpvfs probe leaves this off — brand-wide row hydration
|
|
32
|
+
* is pathological over byte-range (measured) — so brand live search is a server-side-backend capability. Category
|
|
33
|
+
* live search is unaffected either way.
|
|
34
|
+
*/
|
|
35
|
+
brandLiveSearch?: boolean
|
|
36
|
+
/** Debounce before (re)classifying. @default 250 */
|
|
37
|
+
debounceMs?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UsePOISearch {
|
|
41
|
+
/** True once the taxonomy runtime has loaded. */
|
|
42
|
+
runtimeReady: boolean
|
|
43
|
+
/** The intent result for the current (debounced) query, or null for empty input. */
|
|
44
|
+
result: POIExplorerResult | null
|
|
45
|
+
/** State of the on-demand live poi.db search. */
|
|
46
|
+
liveSearch: LiveSearchState
|
|
47
|
+
/** Whether a live search can run right now (a probe is wired + there's a resolved subject with an anchor). */
|
|
48
|
+
canSearchLive: boolean
|
|
49
|
+
/** Kick off a live search for the current subject. No-op when {@link canSearchLive} is false. */
|
|
50
|
+
searchLive: () => Promise<void>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Compute the OverpassQL export for a matched subject, capturing any emitter error rather than throwing. */
|
|
54
|
+
function buildOverpass(
|
|
55
|
+
runtime: POIRuntime,
|
|
56
|
+
categoryID: string,
|
|
57
|
+
matchedPhrase: string,
|
|
58
|
+
remainder: string
|
|
59
|
+
): { overpassQL?: string; overpassError?: string } {
|
|
60
|
+
const category = runtime.lookup.getPOICategory(categoryID)
|
|
61
|
+
|
|
62
|
+
if (!category) {
|
|
63
|
+
return {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const intent: OverpassIntentLike = {
|
|
67
|
+
subject: { kind: "category", categoryID, matched: matchedPhrase },
|
|
68
|
+
...(remainder ? { anchor: { text: remainder } } : {}),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return { overpassQL: emitOverpassQL(intent, category.osmTag ? { osmTag: category.osmTag } : {}) }
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { overpassError: err instanceof Error ? err.message : String(err) }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function usePOISearch({
|
|
79
|
+
text,
|
|
80
|
+
loadRuntime = loadPOIRuntime,
|
|
81
|
+
runLiveSearch,
|
|
82
|
+
brandLiveSearch = false,
|
|
83
|
+
debounceMs = 250,
|
|
84
|
+
}: UsePOISearchOptions): UsePOISearch {
|
|
85
|
+
const [runtime, setRuntime] = useState<POIRuntime | null>(null)
|
|
86
|
+
const [result, setResult] = useState<POIExplorerResult | null>(null)
|
|
87
|
+
const [liveSearch, setLiveSearch] = useState<LiveSearchState>({ status: "idle" })
|
|
88
|
+
|
|
89
|
+
const debouncedText = useDebouncedValue(text, debounceMs)
|
|
90
|
+
|
|
91
|
+
// Capture the loader in a ref so the load fires exactly ONCE on mount, regardless of whether the
|
|
92
|
+
// caller passes a fresh `loadRuntime` closure each render (an inline `async () => …` would otherwise
|
|
93
|
+
// retrigger the effect → reload → re-render loop). The runtime is a load-once resource.
|
|
94
|
+
const loadRuntimeRef = useRef(loadRuntime)
|
|
95
|
+
loadRuntimeRef.current = loadRuntime
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
let cancelled = false
|
|
99
|
+
|
|
100
|
+
loadRuntimeRef.current().then((loaded) => {
|
|
101
|
+
if (!cancelled) {
|
|
102
|
+
setRuntime(loaded)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
cancelled = true
|
|
108
|
+
}
|
|
109
|
+
}, [])
|
|
110
|
+
|
|
111
|
+
// Classify the debounced query and derive the subject + OverpassQL (async, so it lives in an effect).
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!runtime) return
|
|
114
|
+
|
|
115
|
+
let cancelled = false
|
|
116
|
+
const trimmed = debouncedText.trim()
|
|
117
|
+
|
|
118
|
+
// A new query invalidates live results from the previous one.
|
|
119
|
+
setLiveSearch({ status: "idle" })
|
|
120
|
+
|
|
121
|
+
if (!trimmed) {
|
|
122
|
+
setResult(null)
|
|
123
|
+
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const input = { raw: trimmed, normalized: trimmed }
|
|
128
|
+
const shape = computeQueryShape(trimmed)
|
|
129
|
+
|
|
130
|
+
runtime.classify(input, shape).then((kindResult) => {
|
|
131
|
+
if (cancelled) return
|
|
132
|
+
|
|
133
|
+
const matched = kindResult.kind === "poi_query" ? matchPOISubject(trimmed, undefined, runtime.lexicon) : null
|
|
134
|
+
|
|
135
|
+
if (!matched) {
|
|
136
|
+
setResult({ kindResult })
|
|
137
|
+
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Brand subject: the lexicon carries the brand's canonical name as `categoryID` + its Wikidata QID. No category
|
|
142
|
+
// record, no OverpassQL (brands are searched by QID against the layer's `brand_wikidata` index, not OSM tags).
|
|
143
|
+
if ((matched.match.kind ?? "category") === "brand") {
|
|
144
|
+
setResult({
|
|
145
|
+
kindResult,
|
|
146
|
+
subject: {
|
|
147
|
+
kind: "brand",
|
|
148
|
+
name: matched.match.categoryID,
|
|
149
|
+
...(matched.match.wikidata ? { wikidata: matched.match.wikidata } : {}),
|
|
150
|
+
matchedPhrase: matched.match.matchedPhrase,
|
|
151
|
+
confidence: matched.match.confidence,
|
|
152
|
+
remainder: matched.remainder,
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const category = runtime.lookup.getPOICategory(matched.match.categoryID)
|
|
160
|
+
|
|
161
|
+
if (!category) {
|
|
162
|
+
setResult({ kindResult })
|
|
163
|
+
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
setResult({
|
|
168
|
+
kindResult,
|
|
169
|
+
subject: {
|
|
170
|
+
kind: "category",
|
|
171
|
+
category,
|
|
172
|
+
matchedPhrase: matched.match.matchedPhrase,
|
|
173
|
+
confidence: matched.match.confidence,
|
|
174
|
+
remainder: matched.remainder,
|
|
175
|
+
buildLocal: runtime.lookup.requiresBuildLocalLayer(category),
|
|
176
|
+
},
|
|
177
|
+
...buildOverpass(runtime, matched.match.categoryID, matched.match.matchedPhrase, matched.remainder),
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
return () => {
|
|
182
|
+
cancelled = true
|
|
183
|
+
}
|
|
184
|
+
}, [debouncedText, runtime])
|
|
185
|
+
|
|
186
|
+
const subject = result?.subject
|
|
187
|
+
// A subject is live-searchable when a probe is wired, it has an anchor, and: a CATEGORY that isn't build-local, or a
|
|
188
|
+
// BRAND with a QID AND a brand-capable probe (`brandLiveSearch`). Brands without a QID / without a brand probe show
|
|
189
|
+
// the intent + QID chip but no live affordance.
|
|
190
|
+
const subjectLiveCapable =
|
|
191
|
+
subject !== undefined &&
|
|
192
|
+
(subject.kind === "brand" ? brandLiveSearch && subject.wikidata !== undefined : !subject.buildLocal)
|
|
193
|
+
const canSearchLive = Boolean(
|
|
194
|
+
runLiveSearch && runtime && subject && subjectLiveCapable && subject.remainder.trim().length > 0
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
const searchLive = useCallback(async () => {
|
|
198
|
+
if (!runLiveSearch || !runtime || !subject || !subject.remainder.trim()) return
|
|
199
|
+
|
|
200
|
+
if (subject.kind === "category" ? subject.buildLocal : !(brandLiveSearch && subject.wikidata)) return
|
|
201
|
+
|
|
202
|
+
setLiveSearch({ status: "loading" })
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const outcome = await runLiveSearch(
|
|
206
|
+
subject.kind === "brand"
|
|
207
|
+
? {
|
|
208
|
+
categoryID: subject.name,
|
|
209
|
+
overtureCategoryIDs: [],
|
|
210
|
+
anchor: subject.remainder,
|
|
211
|
+
brandWikidata: subject.wikidata,
|
|
212
|
+
}
|
|
213
|
+
: {
|
|
214
|
+
categoryID: subject.category.id,
|
|
215
|
+
// Fan the canonical seed id out over its Overture leaves — the same translation the Node reader uses.
|
|
216
|
+
overtureCategoryIDs: runtime.lookup.resolveOvertureCategories(subject.category.id),
|
|
217
|
+
anchor: subject.remainder,
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if (outcome.status === "success") {
|
|
222
|
+
setLiveSearch({ status: "success", hits: outcome.hits, centerName: outcome.centerName })
|
|
223
|
+
} else if (outcome.status === "unplaced") {
|
|
224
|
+
setLiveSearch({ status: "error", message: `couldn't place "${outcome.anchor}"` })
|
|
225
|
+
} else {
|
|
226
|
+
setLiveSearch({ status: "error", message: "the published POI layer isn't reachable" })
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
setLiveSearch({ status: "error", message: "the published POI layer isn't reachable" })
|
|
230
|
+
}
|
|
231
|
+
}, [runLiveSearch, runtime, subject, brandLiveSearch])
|
|
232
|
+
|
|
233
|
+
return { runtimeReady: runtime !== null, result, liveSearch, canSearchLive, searchLive }
|
|
234
|
+
}
|