@djangocfg/ui-tools 2.1.431 → 2.1.432
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/package.json +28 -14
- package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
- package/src/common/CodePanelHeader/index.ts +1 -0
- package/src/common/blocks/BlockError.tsx +46 -0
- package/src/common/blocks/index.ts +34 -0
- package/src/common/blocks/schemas.ts +143 -0
- package/src/common/blocks/types.ts +76 -0
- package/src/common/chips/README.md +67 -0
- package/src/common/chips/UrlChip.tsx +105 -0
- package/src/common/chips/index.ts +20 -0
- package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
- package/src/common/link-preview/index.ts +12 -0
- package/src/common/link-preview/types.ts +44 -0
- package/src/common/tiptap/index.ts +10 -0
- package/src/common/tiptap/useNodeAttrs.ts +128 -0
- package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
- package/src/tools/chat/README.md +88 -2
- package/src/tools/chat/composer/Composer.tsx +45 -12
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
- package/src/tools/chat/constants.ts +9 -2
- package/src/tools/chat/core/extractFirstUrl.ts +45 -0
- package/src/tools/chat/core/reducer.ts +32 -8
- package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
- package/src/tools/chat/core/transport/mock.ts +4 -1
- package/src/tools/chat/hooks/useChat.ts +20 -20
- package/src/tools/chat/lazy.tsx +10 -0
- package/src/tools/chat/messages/Attachments.tsx +267 -42
- package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
- package/src/tools/chat/messages/MessageBubble.tsx +175 -31
- package/src/tools/chat/messages/MessageList.tsx +107 -21
- package/src/tools/chat/messages/ToolCalls.tsx +28 -13
- package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
- package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
- package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
- package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
- package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
- package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
- package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
- package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
- package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
- package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
- package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
- package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
- package/src/tools/chat/messages/index.ts +13 -0
- package/src/tools/chat/public.ts +5 -1
- package/src/tools/chat/shell/ChatRoot.tsx +29 -0
- package/src/tools/chat/styles/bubbleTokens.ts +18 -0
- package/src/tools/chat/styles/index.ts +1 -0
- package/src/tools/chat/types/attachment.ts +26 -2
- package/src/tools/chat/types/block.ts +56 -9
- package/src/tools/chat/types/blockSchemas.ts +197 -0
- package/src/tools/chat/types/config.ts +33 -0
- package/src/tools/chat/types/events.ts +14 -7
- package/src/tools/chat/types/index.ts +4 -2
- package/src/tools/chat/types/message.ts +7 -0
- package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
- package/src/tools/dev/Map/README.md +30 -345
- package/src/tools/dev/Map/cards/index.ts +6 -0
- package/src/tools/dev/Map/cards/types.ts +67 -0
- package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
- package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
- package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
- package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
- package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
- package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
- package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
- package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
- package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
- package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
- package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
- package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
- package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
- package/src/tools/dev/Map/components/MapControls.tsx +27 -0
- package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
- package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
- package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
- package/src/tools/dev/Map/components/index.ts +35 -1
- package/src/tools/dev/Map/geocode/index.ts +2 -0
- package/src/tools/dev/Map/geocode/photon.ts +265 -0
- package/src/tools/dev/Map/hooks/index.ts +37 -0
- package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
- package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
- package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
- package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
- package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
- package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
- package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
- package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
- package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
- package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
- package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
- package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
- package/src/tools/dev/Map/index.ts +59 -2
- package/src/tools/dev/Map/layers/index.ts +1 -0
- package/src/tools/dev/Map/layers/symbol.ts +93 -0
- package/src/tools/dev/Map/lazy.tsx +120 -0
- package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
- package/src/tools/dev/Map/styles/index.ts +62 -0
- package/src/tools/dev/Map/styles/terrain.ts +29 -0
- package/src/tools/dev/Map/types.ts +31 -3
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
- package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
- package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
- package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
- package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
- package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
- package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
- package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
- package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
- package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
- package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
- package/src/tools/forms/MarkdownEditor/README.md +84 -0
- package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
- package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
- package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
- package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
- package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
- package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
- package/src/tools/forms/MarkdownEditor/index.ts +19 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
- package/src/tools/forms/MarkdownEditor/styles.css +134 -2
- package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
- package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
- package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
- package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
- package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
- package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
- package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
- package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
- package/src/tools/forms/NotionEditor/README.md +109 -188
- package/src/tools/forms/NotionEditor/extensions.ts +15 -0
- package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
- package/src/tools/forms/NotionEditor/styles.css +25 -0
- package/src/tools/forms/NotionEditor/types.ts +9 -0
- package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
- package/src/tools/input/SpeechRecognition/types.ts +10 -0
- package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
- package/src/tools/media/ImageViewer/README.md +9 -0
- package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
- package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
- package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
- package/src/tools/media/ImageViewer/components/index.ts +2 -0
- package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
- package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
- package/src/tools/media/ImageViewer/types.ts +10 -0
- package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
- package/src/tools/media/ImageViewer/utils/index.ts +4 -0
- package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
- package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
- package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
- package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
- package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
- package/src/tools/visual/charts/SmoothLine/README.md +97 -0
- package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
- package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
- package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
- package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
- package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
- package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
- package/src/tools/visual/indicators/Fps/types.ts +1 -1
- package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
- package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
4
|
+
import type { LngLat } from 'maplibre-gl'
|
|
5
|
+
|
|
6
|
+
import type { MarkerData } from '../types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A single controlled, draggable position.
|
|
10
|
+
*
|
|
11
|
+
* This is the host-facing shape: `{ id, lng, lat }` plain numbers (plus an
|
|
12
|
+
* optional `label`). It is intentionally *not* `MarkerData` — `MarkerData`
|
|
13
|
+
* uses `longitude`/`latitude` and is the map's internal marker store shape.
|
|
14
|
+
*/
|
|
15
|
+
export interface DraggablePoint {
|
|
16
|
+
id: string
|
|
17
|
+
lng: number
|
|
18
|
+
lat: number
|
|
19
|
+
label?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseDraggableMarkersOptions {
|
|
23
|
+
/** Controlled list of positions. The host owns this array. */
|
|
24
|
+
positions: DraggablePoint[]
|
|
25
|
+
/**
|
|
26
|
+
* Fires with the full, updated array when a marker is dropped (drag-end).
|
|
27
|
+
* The host persists `next` and feeds it back via `positions`.
|
|
28
|
+
*/
|
|
29
|
+
onChange: (next: DraggablePoint[]) => void
|
|
30
|
+
/** Optional live callback fired on every drag frame (not committed). */
|
|
31
|
+
onDragMove?: (id: string, lng: number, lat: number) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Drag handlers for one rendered marker, wired to a single position. */
|
|
35
|
+
export interface DraggableMarkerHandlers {
|
|
36
|
+
onDragStart: () => void
|
|
37
|
+
onDrag: (marker: MarkerData, lngLat: LngLat) => void
|
|
38
|
+
onDragEnd: (marker: MarkerData, lngLat: LngLat) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UseDraggableMarkersResult {
|
|
42
|
+
/**
|
|
43
|
+
* The positions to render, as `MarkerData` (the map's marker shape).
|
|
44
|
+
* While a marker is being dragged its entry reflects the live cursor
|
|
45
|
+
* position so the pin follows the pointer smoothly; everything else mirrors
|
|
46
|
+
* `positions` 1:1.
|
|
47
|
+
*/
|
|
48
|
+
markers: MarkerData[]
|
|
49
|
+
/** Returns the drag handlers for the marker with the given `id`. */
|
|
50
|
+
getHandlers: (id: string) => DraggableMarkerHandlers
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Controlled convenience layer over `MapMarker`'s native `draggable` /
|
|
55
|
+
* `onDrag*` passthrough.
|
|
56
|
+
*
|
|
57
|
+
* `positions` in, `onChange` out — the host owns persistence. Internally the
|
|
58
|
+
* hook only tracks a *transient* live-drag position so the pin follows the
|
|
59
|
+
* cursor during `onDrag`; the committed source of truth stays in `positions`.
|
|
60
|
+
* On drag-end it normalizes the maplibre `LngLat` object (`.lng`/`.lat`) to
|
|
61
|
+
* plain numbers and emits the full updated array through `onChange`.
|
|
62
|
+
*/
|
|
63
|
+
export function useDraggableMarkers({
|
|
64
|
+
positions,
|
|
65
|
+
onChange,
|
|
66
|
+
onDragMove,
|
|
67
|
+
}: UseDraggableMarkersOptions): UseDraggableMarkersResult {
|
|
68
|
+
// Transient live-drag state: only the marker currently under the pointer.
|
|
69
|
+
const [dragging, setDragging] = useState<{
|
|
70
|
+
id: string
|
|
71
|
+
lng: number
|
|
72
|
+
lat: number
|
|
73
|
+
} | null>(null)
|
|
74
|
+
|
|
75
|
+
const markers = useMemo<MarkerData[]>(
|
|
76
|
+
() =>
|
|
77
|
+
positions.map((p) => {
|
|
78
|
+
const live = dragging && dragging.id === p.id ? dragging : null
|
|
79
|
+
return {
|
|
80
|
+
id: p.id,
|
|
81
|
+
longitude: live ? live.lng : p.lng,
|
|
82
|
+
latitude: live ? live.lat : p.lat,
|
|
83
|
+
data: p.label !== undefined ? { label: p.label } : undefined,
|
|
84
|
+
}
|
|
85
|
+
}),
|
|
86
|
+
[positions, dragging]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const getHandlers = useCallback(
|
|
90
|
+
(id: string): DraggableMarkerHandlers => ({
|
|
91
|
+
onDragStart: () => {
|
|
92
|
+
const p = positions.find((pos) => pos.id === id)
|
|
93
|
+
if (!p) return
|
|
94
|
+
setDragging({ id, lng: p.lng, lat: p.lat })
|
|
95
|
+
},
|
|
96
|
+
onDrag: (_marker, lngLat) => {
|
|
97
|
+
setDragging({ id, lng: lngLat.lng, lat: lngLat.lat })
|
|
98
|
+
onDragMove?.(id, lngLat.lng, lngLat.lat)
|
|
99
|
+
},
|
|
100
|
+
onDragEnd: (_marker, lngLat) => {
|
|
101
|
+
setDragging(null)
|
|
102
|
+
const next = positions.map((pos) =>
|
|
103
|
+
pos.id === id ? { ...pos, lng: lngLat.lng, lat: lngLat.lat } : pos
|
|
104
|
+
)
|
|
105
|
+
onChange(next)
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
[positions, onChange, onDragMove]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return { markers, getHandlers }
|
|
112
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useGeocoder — debounced, abortable autocomplete state for an injectable
|
|
5
|
+
* geocoding `resolve` function (default = public Photon).
|
|
6
|
+
*
|
|
7
|
+
* No UI, no map dependency. Debounces the query (~300ms), gates on a minimum
|
|
8
|
+
* length, and fires ONE `AbortController` per keystroke — the previous
|
|
9
|
+
* in-flight request is aborted before the next starts. Aborted requests are
|
|
10
|
+
* swallowed; real failures surface via `error`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
14
|
+
|
|
15
|
+
import { photonResolve, type GeocodeResult, type ResolveGeocode } from '../geocode'
|
|
16
|
+
|
|
17
|
+
export interface UseGeocoderOptions {
|
|
18
|
+
/** Injectable resolver. Defaults to public Photon (`photonResolve`). */
|
|
19
|
+
resolve?: ResolveGeocode
|
|
20
|
+
/** Geo-bias — results near this point rank higher (e.g. map center). */
|
|
21
|
+
near?: { lng: number; lat: number }
|
|
22
|
+
/** Max results per query. Default 5. */
|
|
23
|
+
limit?: number
|
|
24
|
+
/** Minimum query length before a request fires. Default 3. */
|
|
25
|
+
minLength?: number
|
|
26
|
+
/** Debounce delay in ms. Default 300. */
|
|
27
|
+
debounceMs?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UseGeocoderResult {
|
|
31
|
+
/** Current (raw, un-debounced) query text. */
|
|
32
|
+
query: string
|
|
33
|
+
/** Update the query — debounced search runs after `debounceMs`. */
|
|
34
|
+
setQuery: (next: string) => void
|
|
35
|
+
/** Latest results for the debounced query. */
|
|
36
|
+
results: GeocodeResult[]
|
|
37
|
+
/** A request is in flight. */
|
|
38
|
+
loading: boolean
|
|
39
|
+
/** Last non-abort error, or `null`. */
|
|
40
|
+
error: Error | null
|
|
41
|
+
/** Reset query, results, error and abort any in-flight request. */
|
|
42
|
+
clear: () => void
|
|
43
|
+
/**
|
|
44
|
+
* Convenience for consumers — clears the input and returns the picked
|
|
45
|
+
* result so it can be wired to e.g. `flyTo`. Purely state-level; it does
|
|
46
|
+
* not touch the map.
|
|
47
|
+
*/
|
|
48
|
+
select: (result: GeocodeResult) => GeocodeResult
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isAbortError(err: unknown): boolean {
|
|
52
|
+
return (
|
|
53
|
+
err instanceof DOMException
|
|
54
|
+
? err.name === 'AbortError'
|
|
55
|
+
: err instanceof Error && err.name === 'AbortError'
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useGeocoder(options: UseGeocoderOptions = {}): UseGeocoderResult {
|
|
60
|
+
const {
|
|
61
|
+
resolve = photonResolve,
|
|
62
|
+
near,
|
|
63
|
+
limit,
|
|
64
|
+
minLength = 3,
|
|
65
|
+
debounceMs = 300,
|
|
66
|
+
} = options
|
|
67
|
+
|
|
68
|
+
const [query, setQueryState] = useState('')
|
|
69
|
+
const [results, setResults] = useState<GeocodeResult[]>([])
|
|
70
|
+
const [loading, setLoading] = useState(false)
|
|
71
|
+
const [error, setError] = useState<Error | null>(null)
|
|
72
|
+
|
|
73
|
+
// Keep latest resolver / bias without re-triggering the debounce effect on
|
|
74
|
+
// identity churn (mirrors the link-preview resolverRef pattern).
|
|
75
|
+
const resolveRef = useRef(resolve)
|
|
76
|
+
resolveRef.current = resolve
|
|
77
|
+
const nearRef = useRef(near)
|
|
78
|
+
nearRef.current = near
|
|
79
|
+
const limitRef = useRef(limit)
|
|
80
|
+
limitRef.current = limit
|
|
81
|
+
|
|
82
|
+
// One controller for the in-flight request, aborted on each new keystroke.
|
|
83
|
+
const controllerRef = useRef<AbortController | null>(null)
|
|
84
|
+
|
|
85
|
+
const setQuery = useCallback((next: string) => {
|
|
86
|
+
setQueryState(next)
|
|
87
|
+
}, [])
|
|
88
|
+
|
|
89
|
+
const clear = useCallback(() => {
|
|
90
|
+
controllerRef.current?.abort()
|
|
91
|
+
controllerRef.current = null
|
|
92
|
+
setQueryState('')
|
|
93
|
+
setResults([])
|
|
94
|
+
setError(null)
|
|
95
|
+
setLoading(false)
|
|
96
|
+
}, [])
|
|
97
|
+
|
|
98
|
+
const select = useCallback((result: GeocodeResult) => {
|
|
99
|
+
controllerRef.current?.abort()
|
|
100
|
+
controllerRef.current = null
|
|
101
|
+
setQueryState('')
|
|
102
|
+
setResults([])
|
|
103
|
+
setError(null)
|
|
104
|
+
setLoading(false)
|
|
105
|
+
return result
|
|
106
|
+
}, [])
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const q = query.trim()
|
|
110
|
+
|
|
111
|
+
// Below the min-length gate: drop stale results, cancel in-flight.
|
|
112
|
+
if (q.length < minLength) {
|
|
113
|
+
controllerRef.current?.abort()
|
|
114
|
+
controllerRef.current = null
|
|
115
|
+
setResults([])
|
|
116
|
+
setLoading(false)
|
|
117
|
+
setError(null)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
controllerRef.current?.abort()
|
|
123
|
+
const controller = new AbortController()
|
|
124
|
+
controllerRef.current = controller
|
|
125
|
+
|
|
126
|
+
setLoading(true)
|
|
127
|
+
setError(null)
|
|
128
|
+
|
|
129
|
+
resolveRef
|
|
130
|
+
.current(q, {
|
|
131
|
+
near: nearRef.current,
|
|
132
|
+
limit: limitRef.current,
|
|
133
|
+
signal: controller.signal,
|
|
134
|
+
})
|
|
135
|
+
.then((next) => {
|
|
136
|
+
if (controller.signal.aborted) return
|
|
137
|
+
setResults(next)
|
|
138
|
+
setLoading(false)
|
|
139
|
+
})
|
|
140
|
+
.catch((err: unknown) => {
|
|
141
|
+
if (controller.signal.aborted || isAbortError(err)) return
|
|
142
|
+
setResults([])
|
|
143
|
+
setError(err instanceof Error ? err : new Error(String(err)))
|
|
144
|
+
setLoading(false)
|
|
145
|
+
})
|
|
146
|
+
}, debounceMs)
|
|
147
|
+
|
|
148
|
+
return () => clearTimeout(timer)
|
|
149
|
+
}, [query, minLength, debounceMs])
|
|
150
|
+
|
|
151
|
+
// Abort any in-flight request on unmount.
|
|
152
|
+
useEffect(() => () => controllerRef.current?.abort(), [])
|
|
153
|
+
|
|
154
|
+
return { query, setQuery, results, loading, error, clear, select }
|
|
155
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Live user position from the browser Geolocation API — independent of the
|
|
7
|
+
* map (no `useMapContext`). Use it for custom "where am I" UI or logic; for
|
|
8
|
+
* the on-map dot + accuracy circle + follow-mode, prefer `MapContainer`'s
|
|
9
|
+
* `geolocate` chip (which wraps maplibre's `GeolocateControl`).
|
|
10
|
+
*
|
|
11
|
+
* Privacy: nothing happens until you call `request()` or `watch()` — those
|
|
12
|
+
* trigger the OS permission prompt. Never auto-started.
|
|
13
|
+
*
|
|
14
|
+
* Secure context: `navigator.geolocation` requires HTTPS or localhost (it
|
|
15
|
+
* works in the cmdop Wails webview). Off a secure context the status is
|
|
16
|
+
* `'unavailable'` and the request methods no-op.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A single position fix, flattened from `GeolocationCoordinates`. */
|
|
20
|
+
export interface GeolocationFix {
|
|
21
|
+
lat: number
|
|
22
|
+
lng: number
|
|
23
|
+
/** Accuracy radius in meters (1σ). */
|
|
24
|
+
accuracy: number
|
|
25
|
+
/** Compass heading in degrees (0 = north), or null when unknown/stationary. */
|
|
26
|
+
heading: number | null
|
|
27
|
+
/** Ground speed in m/s, or null when unknown. */
|
|
28
|
+
speed: number | null
|
|
29
|
+
/** Fix timestamp (ms epoch). */
|
|
30
|
+
timestamp: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type GeolocationStatus =
|
|
34
|
+
| 'idle' // nothing requested yet
|
|
35
|
+
| 'locating' // a request/watch is in flight, no fix yet
|
|
36
|
+
| 'granted' // have a fix
|
|
37
|
+
| 'denied' // user blocked the permission (PERMISSION_DENIED)
|
|
38
|
+
| 'unavailable' // no secure context / no geolocation API / POSITION_UNAVAILABLE
|
|
39
|
+
| 'timeout' // the request timed out
|
|
40
|
+
|
|
41
|
+
export interface UseGeolocationOptions {
|
|
42
|
+
/** Higher precision (GPS) at a battery cost. @default false */
|
|
43
|
+
highAccuracy?: boolean
|
|
44
|
+
/** Accept a cached fix up to this age (ms). @default 0 (always fresh) */
|
|
45
|
+
maximumAge?: number
|
|
46
|
+
/** Give up after this long (ms). @default 10000 */
|
|
47
|
+
timeout?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UseGeolocationResult {
|
|
51
|
+
/** Latest fix, or null until one arrives. */
|
|
52
|
+
position: GeolocationFix | null
|
|
53
|
+
status: GeolocationStatus
|
|
54
|
+
/** The raw error from the last failure, if any. */
|
|
55
|
+
error: GeolocationPositionError | null
|
|
56
|
+
/** True only on HTTPS/localhost with a geolocation API present. */
|
|
57
|
+
supported: boolean
|
|
58
|
+
/** One-shot: request a single fix (prompts permission on first use). */
|
|
59
|
+
request: () => void
|
|
60
|
+
/** Continuous: start watching the position until `stop()`. */
|
|
61
|
+
watch: () => void
|
|
62
|
+
/** Stop an active watch and return to idle (keeps the last fix). */
|
|
63
|
+
stop: () => void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toFix(p: GeolocationPosition): GeolocationFix {
|
|
67
|
+
const c = p.coords
|
|
68
|
+
return {
|
|
69
|
+
lat: c.latitude,
|
|
70
|
+
lng: c.longitude,
|
|
71
|
+
accuracy: c.accuracy,
|
|
72
|
+
heading: c.heading != null && !Number.isNaN(c.heading) ? c.heading : null,
|
|
73
|
+
speed: c.speed != null && !Number.isNaN(c.speed) ? c.speed : null,
|
|
74
|
+
timestamp: p.timestamp,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function statusForError(err: GeolocationPositionError): GeolocationStatus {
|
|
79
|
+
switch (err.code) {
|
|
80
|
+
case err.PERMISSION_DENIED:
|
|
81
|
+
return 'denied'
|
|
82
|
+
case err.TIMEOUT:
|
|
83
|
+
return 'timeout'
|
|
84
|
+
default:
|
|
85
|
+
return 'unavailable' // POSITION_UNAVAILABLE
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function useGeolocation(
|
|
90
|
+
options: UseGeolocationOptions = {},
|
|
91
|
+
): UseGeolocationResult {
|
|
92
|
+
const { highAccuracy = false, maximumAge = 0, timeout = 10_000 } = options
|
|
93
|
+
|
|
94
|
+
// Secure-context + API presence. `isSecureContext` is true on
|
|
95
|
+
// localhost/HTTPS (and in the Wails webview); false on plain http.
|
|
96
|
+
const supported =
|
|
97
|
+
typeof navigator !== 'undefined' &&
|
|
98
|
+
'geolocation' in navigator &&
|
|
99
|
+
(typeof window === 'undefined' || window.isSecureContext)
|
|
100
|
+
|
|
101
|
+
const [position, setPosition] = useState<GeolocationFix | null>(null)
|
|
102
|
+
const [status, setStatus] = useState<GeolocationStatus>('idle')
|
|
103
|
+
const [error, setError] = useState<GeolocationPositionError | null>(null)
|
|
104
|
+
|
|
105
|
+
const watchIdRef = useRef<number | null>(null)
|
|
106
|
+
// Keep the latest options in a ref so request/watch identities stay stable.
|
|
107
|
+
const posOptsRef = useRef<PositionOptions>({})
|
|
108
|
+
posOptsRef.current = {
|
|
109
|
+
enableHighAccuracy: highAccuracy,
|
|
110
|
+
maximumAge,
|
|
111
|
+
timeout,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const onSuccess = useCallback((p: GeolocationPosition) => {
|
|
115
|
+
setPosition(toFix(p))
|
|
116
|
+
setError(null)
|
|
117
|
+
setStatus('granted')
|
|
118
|
+
}, [])
|
|
119
|
+
|
|
120
|
+
const onError = useCallback((err: GeolocationPositionError) => {
|
|
121
|
+
setError(err)
|
|
122
|
+
setStatus(statusForError(err))
|
|
123
|
+
}, [])
|
|
124
|
+
|
|
125
|
+
const stop = useCallback(() => {
|
|
126
|
+
if (watchIdRef.current != null) {
|
|
127
|
+
navigator.geolocation.clearWatch(watchIdRef.current)
|
|
128
|
+
watchIdRef.current = null
|
|
129
|
+
}
|
|
130
|
+
setStatus((s) => (s === 'locating' || s === 'granted' ? 'idle' : s))
|
|
131
|
+
}, [])
|
|
132
|
+
|
|
133
|
+
const request = useCallback(() => {
|
|
134
|
+
if (!supported) {
|
|
135
|
+
setStatus('unavailable')
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
setStatus('locating')
|
|
139
|
+
navigator.geolocation.getCurrentPosition(
|
|
140
|
+
onSuccess,
|
|
141
|
+
onError,
|
|
142
|
+
posOptsRef.current,
|
|
143
|
+
)
|
|
144
|
+
}, [supported, onSuccess, onError])
|
|
145
|
+
|
|
146
|
+
const watch = useCallback(() => {
|
|
147
|
+
if (!supported) {
|
|
148
|
+
setStatus('unavailable')
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
// Replace any existing watch.
|
|
152
|
+
if (watchIdRef.current != null) {
|
|
153
|
+
navigator.geolocation.clearWatch(watchIdRef.current)
|
|
154
|
+
}
|
|
155
|
+
setStatus('locating')
|
|
156
|
+
watchIdRef.current = navigator.geolocation.watchPosition(
|
|
157
|
+
onSuccess,
|
|
158
|
+
onError,
|
|
159
|
+
posOptsRef.current,
|
|
160
|
+
)
|
|
161
|
+
}, [supported, onSuccess, onError])
|
|
162
|
+
|
|
163
|
+
// Clear any active watch on unmount.
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
return () => {
|
|
166
|
+
if (watchIdRef.current != null) {
|
|
167
|
+
navigator.geolocation.clearWatch(watchIdRef.current)
|
|
168
|
+
watchIdRef.current = null
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}, [])
|
|
172
|
+
|
|
173
|
+
return { position, status, error, supported, request, watch, stop }
|
|
174
|
+
}
|
|
@@ -37,10 +37,14 @@ export function useMapControl(): MapControlActions {
|
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
const fitBounds = useCallback(
|
|
40
|
-
(
|
|
40
|
+
(
|
|
41
|
+
bounds: LngLatBoundsLike,
|
|
42
|
+
options?: { padding?: number; duration?: number; maxZoom?: number },
|
|
43
|
+
) => {
|
|
41
44
|
mapRef.current?.fitBounds(bounds, {
|
|
42
45
|
padding: options?.padding ?? 50,
|
|
43
46
|
duration: options?.duration ?? 1000,
|
|
47
|
+
...(options?.maxZoom !== undefined ? { maxZoom: options.maxZoom } : {}),
|
|
44
48
|
})
|
|
45
49
|
},
|
|
46
50
|
[mapRef]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { useMapContext } from '../context'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A single icon image to register with the map's sprite at runtime.
|
|
9
|
+
*/
|
|
10
|
+
export interface MapImage {
|
|
11
|
+
/** Image id — reference this from a symbol layer's `icon-image`. */
|
|
12
|
+
id: string
|
|
13
|
+
/** URL to load the image from (PNG/SVG/data-URL). */
|
|
14
|
+
url: string
|
|
15
|
+
/**
|
|
16
|
+
* Treat the image as an SDF (signed-distance field) so `icon-color` can
|
|
17
|
+
* recolor it per-feature. Use for single-color glyph icons.
|
|
18
|
+
*/
|
|
19
|
+
sdf?: boolean
|
|
20
|
+
/** Ratio of image pixels to screen pixels (e.g. `2` for @2x assets). */
|
|
21
|
+
pixelRatio?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseMapImagesResult {
|
|
25
|
+
/** `true` once every requested image has been registered on the map. */
|
|
26
|
+
ready: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Register icon images on the map so a `symbol` layer can reference them by
|
|
31
|
+
* id via `icon-image`. Images are loaded with `map.loadImage(url)` (MapLibre
|
|
32
|
+
* 4 returns a Promise resolving to `{ data }`) and added with
|
|
33
|
+
* `map.addImage(id, data, { sdf, pixelRatio })`.
|
|
34
|
+
*
|
|
35
|
+
* - Guards against double-add via `map.hasImage(id)`.
|
|
36
|
+
* - Re-registers on `styledata` — a style swap drops runtime images, so we
|
|
37
|
+
* re-add them whenever the style reloads.
|
|
38
|
+
* - Removes the images it added on unmount.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```tsx
|
|
42
|
+
* useMapImages([{ id: 'pin', url: '/icons/pin.png' }])
|
|
43
|
+
* // then: createSymbolLayer({ id: 'pins', sourceId: 'places', iconImage: 'pin' })
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function useMapImages(images: MapImage[]): UseMapImagesResult {
|
|
47
|
+
const { mapRef, isLoaded } = useMapContext()
|
|
48
|
+
const [ready, setReady] = useState(false)
|
|
49
|
+
|
|
50
|
+
// Stable key so the effect re-runs only when the image set actually changes.
|
|
51
|
+
const key = images
|
|
52
|
+
.map((img) => `${img.id}|${img.url}|${img.sdf ? 1 : 0}|${img.pixelRatio ?? ''}`)
|
|
53
|
+
.join('::')
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const map = mapRef.current?.getMap()
|
|
57
|
+
if (!map || !isLoaded) return
|
|
58
|
+
|
|
59
|
+
let cancelled = false
|
|
60
|
+
|
|
61
|
+
const registerAll = async () => {
|
|
62
|
+
let registered = 0
|
|
63
|
+
await Promise.all(
|
|
64
|
+
images.map(async (img) => {
|
|
65
|
+
try {
|
|
66
|
+
if (map.hasImage(img.id)) {
|
|
67
|
+
registered += 1
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const response = await map.loadImage(img.url)
|
|
71
|
+
if (cancelled || map.hasImage(img.id)) {
|
|
72
|
+
registered += 1
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
map.addImage(img.id, response.data, {
|
|
76
|
+
sdf: img.sdf,
|
|
77
|
+
pixelRatio: img.pixelRatio,
|
|
78
|
+
})
|
|
79
|
+
registered += 1
|
|
80
|
+
} catch {
|
|
81
|
+
// Image failed to load — skip it; a symbol layer referencing a
|
|
82
|
+
// missing icon simply renders no icon for that feature.
|
|
83
|
+
}
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
if (!cancelled) setReady(registered === images.length)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
void registerAll()
|
|
90
|
+
|
|
91
|
+
// A style reload (`setStyle`) drops runtime-added images — re-add them.
|
|
92
|
+
const onStyleData = () => {
|
|
93
|
+
void registerAll()
|
|
94
|
+
}
|
|
95
|
+
map.on('styledata', onStyleData)
|
|
96
|
+
|
|
97
|
+
return () => {
|
|
98
|
+
cancelled = true
|
|
99
|
+
map.off('styledata', onStyleData)
|
|
100
|
+
for (const img of images) {
|
|
101
|
+
try {
|
|
102
|
+
if (map.hasImage(img.id)) map.removeImage(img.id)
|
|
103
|
+
} catch {
|
|
104
|
+
// map may already be torn down
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
109
|
+
}, [mapRef, isLoaded, key])
|
|
110
|
+
|
|
111
|
+
return { ready }
|
|
112
|
+
}
|