@mailwoman/react 9.1.0 → 9.2.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 +1 -1
- package/common/ClientOnly.tsx +18 -6
- package/map/GeocoderDemo.tsx +24 -1
- package/map/geometry.ts +7 -7
- package/map/usePlaceAutocomplete.ts +37 -25
- package/out/common/ClientOnly.d.ts +5 -2
- package/out/common/ClientOnly.d.ts.map +1 -1
- package/out/common/ClientOnly.js +13 -5
- package/out/common/ClientOnly.js.map +1 -1
- package/out/map/GeocoderDemo.d.ts.map +1 -1
- package/out/map/GeocoderDemo.js +20 -1
- package/out/map/GeocoderDemo.js.map +1 -1
- package/out/map/geometry.js +7 -7
- package/out/map/usePlaceAutocomplete.d.ts.map +1 -1
- package/out/map/usePlaceAutocomplete.js +33 -22
- package/out/map/usePlaceAutocomplete.js.map +1 -1
- package/out/poi/usePOISearch.d.ts.map +1 -1
- package/out/poi/usePOISearch.js +71 -44
- package/out/poi/usePOISearch.js.map +1 -1
- package/out/runtime/useDemoRuntime.d.ts.map +1 -1
- package/out/runtime/useDemoRuntime.js +5 -3
- package/out/runtime/useDemoRuntime.js.map +1 -1
- package/package.json +249 -6
- package/poi/usePOISearch.ts +72 -46
- package/runtime/useDemoRuntime.ts +6 -3
- package/styles.css +49 -19
package/poi/usePOISearch.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { matchPOISubject } from "@mailwoman/kind-classifier"
|
|
|
13
13
|
import { emitOverpassQL } from "@mailwoman/poi-taxonomy/overpass"
|
|
14
14
|
import type { OverpassIntentLike } from "@mailwoman/poi-taxonomy/overpass"
|
|
15
15
|
import { computeQueryShape } from "@mailwoman/query-shape"
|
|
16
|
-
import { useCallback, useEffect,
|
|
16
|
+
import { useCallback, useEffect, useEffectEvent, useState } from "react"
|
|
17
17
|
|
|
18
18
|
import { useDebouncedValue } from "../common/useDebouncedValue.ts"
|
|
19
19
|
import { loadPOIRuntime } from "./runtime.ts"
|
|
@@ -103,21 +103,31 @@ export function usePOISearch({
|
|
|
103
103
|
debounceMs = 250,
|
|
104
104
|
}: UsePOISearchOptions): UsePOISearch {
|
|
105
105
|
const [runtime, setRuntime] = useState<POIRuntime | null>(null)
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
/**
|
|
107
|
+
* The classify result KEYED BY the query that produced it. The visible result is derived during render
|
|
108
|
+
* (`storedResult.query === trimmedText ? … : null`), so a new query invalidates the old answer by derivation — the
|
|
109
|
+
* effect never writes state synchronously to "reset", which is the react(set-state-in-effect) shape the lint bump
|
|
110
|
+
* rightly flags.
|
|
111
|
+
*/
|
|
112
|
+
const [storedResult, setStoredResult] = useState<{ query: string; value: POIExplorerResult } | null>(null)
|
|
113
|
+
/**
|
|
114
|
+
* Live-search state, keyed the same way: launched FOR a query, visible only while that query stands.
|
|
115
|
+
*/
|
|
116
|
+
const [storedLive, setStoredLive] = useState<{ query: string; state: LiveSearchState } | null>(null)
|
|
108
117
|
|
|
109
118
|
const debouncedText = useDebouncedValue(text, debounceMs)
|
|
119
|
+
const trimmedText = debouncedText.trim()
|
|
110
120
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
// The load fires exactly ONCE on mount regardless of whether the caller passes a fresh `loadRuntime`
|
|
122
|
+
// closure each render (an inline `async () => …` would otherwise retrigger the effect → reload →
|
|
123
|
+
// re-render loop). `useEffectEvent` reads the LATEST closure without joining the dependency list —
|
|
124
|
+
// the runtime is a load-once resource.
|
|
125
|
+
const loadRuntimeEvent = useEffectEvent(() => loadRuntime())
|
|
116
126
|
|
|
117
127
|
useEffect(() => {
|
|
118
128
|
let cancelled = false
|
|
119
129
|
|
|
120
|
-
|
|
130
|
+
void loadRuntimeEvent().then((loaded) => {
|
|
121
131
|
if (!cancelled) {
|
|
122
132
|
setRuntime(loaded)
|
|
123
133
|
}
|
|
@@ -129,20 +139,16 @@ export function usePOISearch({
|
|
|
129
139
|
}, [])
|
|
130
140
|
|
|
131
141
|
// Classify the debounced query and derive the subject + OverpassQL (async, so it lives in an effect).
|
|
142
|
+
// Every state write below happens after an await — invalidation on query change is handled by the
|
|
143
|
+
// key derivation above, not by a synchronous reset here.
|
|
132
144
|
useEffect(() => {
|
|
133
145
|
if (!runtime) return
|
|
134
146
|
|
|
135
|
-
|
|
136
|
-
const trimmed = debouncedText.trim()
|
|
137
|
-
|
|
138
|
-
// A new query invalidates live results from the previous one.
|
|
139
|
-
setLiveSearch({ status: "idle" })
|
|
147
|
+
const trimmed = trimmedText
|
|
140
148
|
|
|
141
|
-
if (!trimmed)
|
|
142
|
-
setResult(null)
|
|
149
|
+
if (!trimmed) return
|
|
143
150
|
|
|
144
|
-
|
|
145
|
-
}
|
|
151
|
+
let cancelled = false
|
|
146
152
|
|
|
147
153
|
const input = { raw: trimmed, normalized: trimmed }
|
|
148
154
|
const shape = computeQueryShape(trimmed)
|
|
@@ -153,7 +159,7 @@ export function usePOISearch({
|
|
|
153
159
|
const matched = kindResult.kind === "poi_query" ? matchPOISubject(trimmed, undefined, runtime.lexicon) : null
|
|
154
160
|
|
|
155
161
|
if (!matched) {
|
|
156
|
-
|
|
162
|
+
setStoredResult({ query: trimmed, value: { kindResult } })
|
|
157
163
|
|
|
158
164
|
return
|
|
159
165
|
}
|
|
@@ -161,15 +167,18 @@ export function usePOISearch({
|
|
|
161
167
|
// Brand subject: the lexicon carries the brand's canonical name as `categoryID` + its Wikidata QID. No category
|
|
162
168
|
// record, no OverpassQL (brands are searched by QID against the layer's `brand_wikidata` index, not OSM tags).
|
|
163
169
|
if ((matched.match.kind ?? "category") === "brand") {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
setStoredResult({
|
|
171
|
+
query: trimmed,
|
|
172
|
+
value: {
|
|
173
|
+
kindResult,
|
|
174
|
+
subject: {
|
|
175
|
+
kind: "brand",
|
|
176
|
+
name: matched.match.categoryID,
|
|
177
|
+
...(matched.match.wikidata ? { wikidata: matched.match.wikidata } : {}),
|
|
178
|
+
matchedPhrase: matched.match.matchedPhrase,
|
|
179
|
+
confidence: matched.match.confidence,
|
|
180
|
+
remainder: matched.remainder,
|
|
181
|
+
},
|
|
173
182
|
},
|
|
174
183
|
})
|
|
175
184
|
|
|
@@ -179,29 +188,37 @@ export function usePOISearch({
|
|
|
179
188
|
const category = runtime.lookup.getPOICategory(matched.match.categoryID)
|
|
180
189
|
|
|
181
190
|
if (!category) {
|
|
182
|
-
|
|
191
|
+
setStoredResult({ query: trimmed, value: { kindResult } })
|
|
183
192
|
|
|
184
193
|
return
|
|
185
194
|
}
|
|
186
195
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
+
setStoredResult({
|
|
197
|
+
query: trimmed,
|
|
198
|
+
value: {
|
|
199
|
+
kindResult,
|
|
200
|
+
subject: {
|
|
201
|
+
kind: "category",
|
|
202
|
+
category,
|
|
203
|
+
matchedPhrase: matched.match.matchedPhrase,
|
|
204
|
+
confidence: matched.match.confidence,
|
|
205
|
+
remainder: matched.remainder,
|
|
206
|
+
buildLocal: runtime.lookup.requiresBuildLocalLayer(category),
|
|
207
|
+
},
|
|
208
|
+
...buildOverpass(runtime, matched.match.categoryID, matched.match.matchedPhrase, matched.remainder),
|
|
196
209
|
},
|
|
197
|
-
...buildOverpass(runtime, matched.match.categoryID, matched.match.matchedPhrase, matched.remainder),
|
|
198
210
|
})
|
|
199
211
|
})
|
|
200
212
|
|
|
201
213
|
return () => {
|
|
202
214
|
cancelled = true
|
|
203
215
|
}
|
|
204
|
-
}, [
|
|
216
|
+
}, [trimmedText, runtime])
|
|
217
|
+
|
|
218
|
+
// Derived visibility: an answer stands only while its query does. A new query reads as null/idle
|
|
219
|
+
// with no reset write anywhere.
|
|
220
|
+
const result = storedResult?.query === trimmedText ? storedResult.value : null
|
|
221
|
+
const liveSearch: LiveSearchState = storedLive?.query === trimmedText ? storedLive.state : { status: "idle" }
|
|
205
222
|
|
|
206
223
|
const subject = result?.subject
|
|
207
224
|
|
|
@@ -221,7 +238,7 @@ export function usePOISearch({
|
|
|
221
238
|
|
|
222
239
|
if (subject.kind === "category" ? subject.buildLocal : !(brandLiveSearch && subject.wikidata)) return
|
|
223
240
|
|
|
224
|
-
|
|
241
|
+
setStoredLive({ query: trimmedText, state: { status: "loading" } })
|
|
225
242
|
|
|
226
243
|
try {
|
|
227
244
|
const outcome = await runLiveSearch(
|
|
@@ -241,16 +258,25 @@ export function usePOISearch({
|
|
|
241
258
|
)
|
|
242
259
|
|
|
243
260
|
if (outcome.status === "success") {
|
|
244
|
-
|
|
261
|
+
setStoredLive({
|
|
262
|
+
query: trimmedText,
|
|
263
|
+
state: { status: "success", hits: outcome.hits, centerName: outcome.centerName },
|
|
264
|
+
})
|
|
245
265
|
} else if (outcome.status === "unplaced") {
|
|
246
|
-
|
|
266
|
+
setStoredLive({ query: trimmedText, state: { status: "error", message: `couldn't place "${outcome.anchor}"` } })
|
|
247
267
|
} else {
|
|
248
|
-
|
|
268
|
+
setStoredLive({
|
|
269
|
+
query: trimmedText,
|
|
270
|
+
state: { status: "error", message: "the published POI layer isn't reachable" },
|
|
271
|
+
})
|
|
249
272
|
}
|
|
250
273
|
} catch {
|
|
251
|
-
|
|
274
|
+
setStoredLive({
|
|
275
|
+
query: trimmedText,
|
|
276
|
+
state: { status: "error", message: "the published POI layer isn't reachable" },
|
|
277
|
+
})
|
|
252
278
|
}
|
|
253
|
-
}, [runLiveSearch, runtime, subject, brandLiveSearch])
|
|
279
|
+
}, [runLiveSearch, runtime, subject, brandLiveSearch, trimmedText])
|
|
254
280
|
|
|
255
281
|
return { runtimeReady: runtime !== null, result, liveSearch, canSearchLive, searchLive }
|
|
256
282
|
}
|
|
@@ -189,15 +189,18 @@ export function useDemoRuntime<TAssets, TRelease extends DemoReleaseBase = DemoR
|
|
|
189
189
|
// Latest-ref the injected loaders: a host that re-creates them each render (an inline arrow) must NOT retrigger the
|
|
190
190
|
// load effects, which key ONLY on version/backend. The effects read `.current` at run time.
|
|
191
191
|
const loadManifestRef = useRef(config.loadManifest)
|
|
192
|
-
loadManifestRef.current = config.loadManifest
|
|
193
192
|
const loadAssetsRef = useRef(config.loadAssets)
|
|
194
|
-
loadAssetsRef.current = config.loadAssets
|
|
195
193
|
|
|
196
194
|
// Latest manifest for the version-load effect, so it can resolve the release WITHOUT depending on `manifest`
|
|
197
195
|
// identity — which would double-fire the load the instant the manifest first arrives (the selection transition
|
|
198
196
|
// null → defaultVersion already fires it once).
|
|
199
197
|
const manifestRef = useRef<DemoManifest<TRelease> | null>(null)
|
|
200
|
-
|
|
198
|
+
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
loadManifestRef.current = config.loadManifest
|
|
201
|
+
loadAssetsRef.current = config.loadAssets
|
|
202
|
+
manifestRef.current = manifest
|
|
203
|
+
}, [config.loadAssets, config.loadManifest, manifest])
|
|
201
204
|
|
|
202
205
|
// Mount: fetch the manifest, then select the default version.
|
|
203
206
|
useEffect(() => {
|
package/styles.css
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
font-size: 0.95rem;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/*
|
|
18
|
+
/* #region Field (label + input + button) */
|
|
19
19
|
|
|
20
20
|
.mw-field {
|
|
21
21
|
display: flex;
|
|
@@ -60,7 +60,9 @@
|
|
|
60
60
|
cursor: not-allowed;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/*
|
|
63
|
+
/* #endregion */
|
|
64
|
+
|
|
65
|
+
/* #region Preset chips */
|
|
64
66
|
|
|
65
67
|
.mw-presets {
|
|
66
68
|
margin-top: 0.75rem;
|
|
@@ -98,7 +100,9 @@
|
|
|
98
100
|
cursor: not-allowed;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
/*
|
|
103
|
+
/* #endregion */
|
|
104
|
+
|
|
105
|
+
/* #region Generic button (copy / search) */
|
|
102
106
|
|
|
103
107
|
.mw-btn {
|
|
104
108
|
font-size: 0.8rem;
|
|
@@ -120,7 +124,9 @@
|
|
|
120
124
|
cursor: not-allowed;
|
|
121
125
|
}
|
|
122
126
|
|
|
123
|
-
/*
|
|
127
|
+
/* #endregion */
|
|
128
|
+
|
|
129
|
+
/* #region Result panel + errors */
|
|
124
130
|
|
|
125
131
|
.mw-result {
|
|
126
132
|
margin-top: 1.5rem;
|
|
@@ -161,7 +167,9 @@
|
|
|
161
167
|
white-space: pre-wrap;
|
|
162
168
|
}
|
|
163
169
|
|
|
164
|
-
/*
|
|
170
|
+
/* #endregion */
|
|
171
|
+
|
|
172
|
+
/* #region KindBadge */
|
|
165
173
|
|
|
166
174
|
.mw-kind {
|
|
167
175
|
margin-bottom: 0.5rem;
|
|
@@ -183,7 +191,9 @@
|
|
|
183
191
|
list-style: disc;
|
|
184
192
|
}
|
|
185
193
|
|
|
186
|
-
/*
|
|
194
|
+
/* #endregion */
|
|
195
|
+
|
|
196
|
+
/* #region POI subject panel */
|
|
187
197
|
|
|
188
198
|
.mw-subject__row {
|
|
189
199
|
display: flex;
|
|
@@ -258,7 +268,9 @@
|
|
|
258
268
|
margin: 0;
|
|
259
269
|
}
|
|
260
270
|
|
|
261
|
-
/*
|
|
271
|
+
/* #endregion */
|
|
272
|
+
|
|
273
|
+
/* #region OverpassQL block */
|
|
262
274
|
|
|
263
275
|
.mw-overpass {
|
|
264
276
|
margin-top: 0.75rem;
|
|
@@ -275,7 +287,9 @@
|
|
|
275
287
|
overflow-x: auto;
|
|
276
288
|
}
|
|
277
289
|
|
|
278
|
-
/*
|
|
290
|
+
/* #endregion */
|
|
291
|
+
|
|
292
|
+
/* #region Live results */
|
|
279
293
|
|
|
280
294
|
.mw-live {
|
|
281
295
|
margin-top: 1rem;
|
|
@@ -317,7 +331,9 @@
|
|
|
317
331
|
white-space: nowrap;
|
|
318
332
|
}
|
|
319
333
|
|
|
320
|
-
/*
|
|
334
|
+
/* #endregion */
|
|
335
|
+
|
|
336
|
+
/* #region Component table */
|
|
321
337
|
|
|
322
338
|
.mw-components {
|
|
323
339
|
width: 100%;
|
|
@@ -340,7 +356,9 @@
|
|
|
340
356
|
text-transform: uppercase;
|
|
341
357
|
}
|
|
342
358
|
|
|
343
|
-
/*
|
|
359
|
+
/* #endregion */
|
|
360
|
+
|
|
361
|
+
/* #region Confidence cell */
|
|
344
362
|
|
|
345
363
|
.mw-conf__dash {
|
|
346
364
|
color: var(--ifm-color-emphasis-500, #8c8c8c);
|
|
@@ -377,7 +395,9 @@
|
|
|
377
395
|
font-size: 0.8rem;
|
|
378
396
|
}
|
|
379
397
|
|
|
380
|
-
/*
|
|
398
|
+
/* #endregion */
|
|
399
|
+
|
|
400
|
+
/* #region Resolved place */
|
|
381
401
|
|
|
382
402
|
.mw-resolved {
|
|
383
403
|
margin-top: 1rem;
|
|
@@ -416,7 +436,9 @@
|
|
|
416
436
|
font-size: 0.9rem;
|
|
417
437
|
}
|
|
418
438
|
|
|
419
|
-
/*
|
|
439
|
+
/* #endregion */
|
|
440
|
+
|
|
441
|
+
/* #region Candidate picker */
|
|
420
442
|
|
|
421
443
|
.mw-candidates {
|
|
422
444
|
margin-top: 1rem;
|
|
@@ -471,7 +493,9 @@
|
|
|
471
493
|
color: var(--ifm-color-emphasis-600, #6e6e6e);
|
|
472
494
|
}
|
|
473
495
|
|
|
474
|
-
/*
|
|
496
|
+
/* #endregion */
|
|
497
|
+
|
|
498
|
+
/* #region LoadingIndicator */
|
|
475
499
|
|
|
476
500
|
.mw-loading {
|
|
477
501
|
display: flex;
|
|
@@ -647,7 +671,9 @@
|
|
|
647
671
|
transform: translateY(-60%) rotate(45deg);
|
|
648
672
|
}
|
|
649
673
|
|
|
650
|
-
/*
|
|
674
|
+
/* #endregion */
|
|
675
|
+
|
|
676
|
+
/* #region Demo map (react-map-gl/maplibre shell) */
|
|
651
677
|
|
|
652
678
|
/*
|
|
653
679
|
* The <DemoMap> wrapper. Sizing is the host's call (pass `style`/`className`), but a min-height keeps
|
|
@@ -661,8 +687,6 @@
|
|
|
661
687
|
overflow: hidden;
|
|
662
688
|
}
|
|
663
689
|
|
|
664
|
-
/* ── Geocoder demo (composed: full-viewport map + floating control panel) ──── */
|
|
665
|
-
|
|
666
690
|
/*
|
|
667
691
|
* The demo fills its positioning context; the host sizes it (a full-viewport wrapper in docs). The map is the
|
|
668
692
|
* background layer, the control panel floats over the top-left (bottom on narrow screens). A host that wants a
|
|
@@ -705,7 +729,9 @@
|
|
|
705
729
|
}
|
|
706
730
|
}
|
|
707
731
|
|
|
708
|
-
/*
|
|
732
|
+
/* #endregion */
|
|
733
|
+
|
|
734
|
+
/* #region Demo control field (version / compare-version selects) */
|
|
709
735
|
|
|
710
736
|
.mw-demo-control {
|
|
711
737
|
margin-bottom: 0.75rem;
|
|
@@ -732,7 +758,9 @@
|
|
|
732
758
|
opacity: 0.5;
|
|
733
759
|
}
|
|
734
760
|
|
|
735
|
-
/*
|
|
761
|
+
/* #endregion */
|
|
762
|
+
|
|
763
|
+
/* #region Backend indicator + WASM toggle / compare toggle */
|
|
736
764
|
|
|
737
765
|
.mw-demo-backend,
|
|
738
766
|
.mw-demo-compare {
|
|
@@ -771,7 +799,9 @@
|
|
|
771
799
|
color: var(--ifm-color-emphasis-600, #6e6e6e);
|
|
772
800
|
}
|
|
773
801
|
|
|
774
|
-
/*
|
|
802
|
+
/* #endregion */
|
|
803
|
+
|
|
804
|
+
/* #region Place autocomplete listbox (reuses .mw-chip) */
|
|
775
805
|
|
|
776
806
|
.mw-demo-suggest {
|
|
777
807
|
margin-top: 0.75rem;
|