@djangocfg/ui-tools 2.1.431 → 2.1.433

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 (171) hide show
  1. package/package.json +28 -14
  2. package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
  3. package/src/common/CodePanelHeader/index.ts +1 -0
  4. package/src/common/blocks/BlockError.tsx +46 -0
  5. package/src/common/blocks/index.ts +34 -0
  6. package/src/common/blocks/schemas.ts +143 -0
  7. package/src/common/blocks/types.ts +76 -0
  8. package/src/common/chips/README.md +67 -0
  9. package/src/common/chips/UrlChip.tsx +105 -0
  10. package/src/common/chips/index.ts +20 -0
  11. package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
  12. package/src/common/link-preview/index.ts +12 -0
  13. package/src/common/link-preview/types.ts +44 -0
  14. package/src/common/tiptap/index.ts +10 -0
  15. package/src/common/tiptap/useNodeAttrs.ts +128 -0
  16. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
  17. package/src/tools/chat/README.md +88 -2
  18. package/src/tools/chat/composer/Composer.tsx +45 -12
  19. package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
  20. package/src/tools/chat/constants.ts +9 -2
  21. package/src/tools/chat/core/extractFirstUrl.ts +45 -0
  22. package/src/tools/chat/core/reducer.ts +32 -8
  23. package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
  24. package/src/tools/chat/core/transport/mock.ts +4 -1
  25. package/src/tools/chat/hooks/useChat.ts +20 -20
  26. package/src/tools/chat/lazy.tsx +10 -0
  27. package/src/tools/chat/messages/Attachments.tsx +267 -42
  28. package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
  29. package/src/tools/chat/messages/MessageBubble.tsx +175 -31
  30. package/src/tools/chat/messages/MessageList.tsx +107 -21
  31. package/src/tools/chat/messages/ToolCalls.tsx +28 -13
  32. package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
  33. package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
  34. package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
  35. package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
  36. package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
  37. package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
  38. package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
  39. package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
  40. package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
  41. package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
  42. package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
  43. package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
  44. package/src/tools/chat/messages/index.ts +13 -0
  45. package/src/tools/chat/public.ts +5 -1
  46. package/src/tools/chat/shell/ChatRoot.tsx +29 -0
  47. package/src/tools/chat/styles/bubbleTokens.ts +18 -0
  48. package/src/tools/chat/styles/index.ts +1 -0
  49. package/src/tools/chat/types/attachment.ts +26 -2
  50. package/src/tools/chat/types/block.ts +56 -9
  51. package/src/tools/chat/types/blockSchemas.ts +197 -0
  52. package/src/tools/chat/types/config.ts +33 -0
  53. package/src/tools/chat/types/events.ts +14 -7
  54. package/src/tools/chat/types/index.ts +4 -2
  55. package/src/tools/chat/types/message.ts +7 -0
  56. package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
  57. package/src/tools/dev/Map/README.md +30 -345
  58. package/src/tools/dev/Map/cards/index.ts +6 -0
  59. package/src/tools/dev/Map/cards/types.ts +67 -0
  60. package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
  61. package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
  62. package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
  63. package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
  64. package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
  65. package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
  66. package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
  67. package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
  68. package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
  69. package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
  70. package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
  71. package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
  72. package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
  73. package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
  74. package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
  75. package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
  76. package/src/tools/dev/Map/components/MapControls.tsx +27 -0
  77. package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
  78. package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
  79. package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
  80. package/src/tools/dev/Map/components/index.ts +35 -1
  81. package/src/tools/dev/Map/geocode/index.ts +2 -0
  82. package/src/tools/dev/Map/geocode/photon.ts +265 -0
  83. package/src/tools/dev/Map/hooks/index.ts +37 -0
  84. package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
  85. package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
  86. package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
  87. package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
  88. package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
  89. package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
  90. package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
  91. package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
  92. package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
  93. package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
  94. package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
  95. package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
  96. package/src/tools/dev/Map/index.ts +59 -2
  97. package/src/tools/dev/Map/layers/index.ts +1 -0
  98. package/src/tools/dev/Map/layers/symbol.ts +93 -0
  99. package/src/tools/dev/Map/lazy.tsx +120 -0
  100. package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
  101. package/src/tools/dev/Map/styles/index.ts +62 -0
  102. package/src/tools/dev/Map/styles/terrain.ts +29 -0
  103. package/src/tools/dev/Map/types.ts +31 -3
  104. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
  105. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
  106. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
  107. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
  108. package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
  109. package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
  110. package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
  111. package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
  112. package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
  113. package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
  114. package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
  115. package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
  116. package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
  117. package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
  118. package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
  119. package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
  120. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
  121. package/src/tools/forms/MarkdownEditor/README.md +84 -0
  122. package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
  123. package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
  124. package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
  125. package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
  126. package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
  127. package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
  128. package/src/tools/forms/MarkdownEditor/index.ts +19 -0
  129. package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
  130. package/src/tools/forms/MarkdownEditor/styles.css +134 -2
  131. package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
  132. package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
  133. package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
  134. package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
  135. package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
  136. package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
  137. package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
  138. package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
  139. package/src/tools/forms/NotionEditor/README.md +109 -188
  140. package/src/tools/forms/NotionEditor/extensions.ts +15 -0
  141. package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
  142. package/src/tools/forms/NotionEditor/styles.css +25 -0
  143. package/src/tools/forms/NotionEditor/types.ts +9 -0
  144. package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
  145. package/src/tools/input/SpeechRecognition/types.ts +10 -0
  146. package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
  147. package/src/tools/media/ImageViewer/README.md +9 -0
  148. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
  149. package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
  150. package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
  151. package/src/tools/media/ImageViewer/components/index.ts +2 -0
  152. package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
  153. package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
  154. package/src/tools/media/ImageViewer/types.ts +10 -0
  155. package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
  156. package/src/tools/media/ImageViewer/utils/index.ts +4 -0
  157. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
  158. package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
  159. package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
  160. package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
  161. package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
  162. package/src/tools/visual/charts/SmoothLine/README.md +97 -0
  163. package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
  164. package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
  165. package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
  166. package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
  167. package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
  168. package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
  169. package/src/tools/visual/indicators/Fps/types.ts +1 -1
  170. package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
  171. 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
- (bounds: LngLatBoundsLike, options?: { padding?: number; duration?: number }) => {
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
+ }