@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.
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,155 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+
5
+ import { useMapContext } from '../context'
6
+
7
+ export type MapScrollProtectionMode = 'off' | 'desktop' | 'mobile'
8
+
9
+ export interface UseMapScrollProtectionResult {
10
+ /** Whether the lock overlay should be shown. */
11
+ locked: boolean
12
+ /**
13
+ * Click/tap handler for the overlay. On desktop it unlocks scroll-zoom
14
+ * in place; on mobile it stays locked (the caller opens fullscreen).
15
+ */
16
+ unlock: () => void
17
+ /** True when running in mobile mode — the caller should expand to
18
+ * fullscreen on tap instead of unlocking in place. */
19
+ isMobileLock: boolean
20
+ }
21
+
22
+ /**
23
+ * Gesture guard for an inline map.
24
+ *
25
+ * - `'desktop'` — Google-Maps-style scroll isolation. Only the wheel/
26
+ * trackpad **scroll-zoom** is blocked until the user clicks the map;
27
+ * drag-pan / double-click / pinch stay live. Click inside unlocks,
28
+ * click outside re-locks. So scrolling a page past the map never
29
+ * hijacks the zoom, but the map is never fully inert.
30
+ *
31
+ * - `'mobile'` — the map is made **fully static**: scroll-zoom, drag-pan,
32
+ * touch pinch/rotate, double-click and keyboard are all disabled, so a
33
+ * finger dragging over the map scrolls the PAGE. There is no in-place
34
+ * unlock; the caller turns a tap into "open fullscreen", where a fresh
35
+ * map runs with all gestures enabled. This is the native-app pattern
36
+ * (static map preview in a feed → tap to open the full map).
37
+ *
38
+ * - `'off'` — every gesture enabled, no overlay.
39
+ */
40
+ export function useMapScrollProtection(
41
+ mode: MapScrollProtectionMode,
42
+ ): UseMapScrollProtectionResult {
43
+ const { mapRef, isLoaded } = useMapContext()
44
+ const [locked, setLocked] = useState(true)
45
+
46
+ // Latest `locked` without forcing the document-listener effect to
47
+ // re-subscribe on every toggle.
48
+ const lockedRef = useRef(locked)
49
+ lockedRef.current = locked
50
+
51
+ // On mobile the lock is permanent (tap opens fullscreen); only desktop
52
+ // unlocks in place.
53
+ const unlock = useCallback(() => {
54
+ if (mode === 'desktop') setLocked(false)
55
+ }, [mode])
56
+
57
+ // All the maplibre gesture handlers we toggle in mobile mode. Listed
58
+ // defensively (some are absent depending on options) — each is guarded.
59
+ const setAllGestures = useCallback(
60
+ (map: maplibregl.Map, enable: boolean) => {
61
+ const handlers = [
62
+ map.scrollZoom,
63
+ map.boxZoom,
64
+ map.dragRotate,
65
+ map.dragPan,
66
+ map.keyboard,
67
+ map.doubleClickZoom,
68
+ map.touchZoomRotate,
69
+ (map as unknown as { touchPitch?: { enable(): void; disable(): void } }).touchPitch,
70
+ ]
71
+ for (const h of handlers) {
72
+ if (h && typeof h.enable === 'function') {
73
+ enable ? h.enable() : h.disable()
74
+ }
75
+ }
76
+ },
77
+ [],
78
+ )
79
+
80
+ // Apply the lock to maplibre. Re-runs on load and whenever mode/lock change.
81
+ useEffect(() => {
82
+ const map = mapRef.current?.getMap()
83
+ if (!map) return
84
+
85
+ if (mode === 'off') {
86
+ // Make sure nothing we may have disabled stays off.
87
+ map.scrollZoom.enable()
88
+ return
89
+ }
90
+
91
+ if (mode === 'mobile') {
92
+ // Fully static while locked; fully live once unlocked (never happens
93
+ // on mobile, but keep the branch symmetric).
94
+ setAllGestures(map, !locked)
95
+ return
96
+ }
97
+
98
+ // desktop: only the scroll-zoom is gated.
99
+ if (locked) {
100
+ map.scrollZoom.disable()
101
+ } else {
102
+ map.scrollZoom.enable()
103
+ }
104
+ }, [mapRef, isLoaded, mode, locked, setAllGestures])
105
+
106
+ // Re-lock (desktop only) when the user clicks anywhere OUTSIDE the map.
107
+ // We do NOT re-lock on mouse-leave: once you've clicked into the map you
108
+ // keep interacting even as the pointer wanders off and back — the Google
109
+ // Maps behavior. Capture phase so we see the click before inner handlers
110
+ // can stop it. Mobile never unlocks in place, so it needs no re-lock.
111
+ useEffect(() => {
112
+ if (mode !== 'desktop') return
113
+ const container = mapRef.current?.getMap()?.getContainer()
114
+ if (!container) return
115
+
116
+ const handleDocPointerDown = (e: Event) => {
117
+ const target = e.target as Element | null
118
+ // A toolbar chip (or a menu/tooltip it opens) — never re-locks, and
119
+ // when the map is LOCKED, pressing a control unlocks it: the user is
120
+ // clearly working with the map, so the scroll guard should step aside.
121
+ const onControl = !!target?.closest?.(
122
+ '[data-map-control],[data-radix-popper-content-wrapper],[role="menu"],[role="tooltip"]',
123
+ )
124
+
125
+ if (lockedRef.current) {
126
+ // Only desktop unlocks in place; `unlock()` is a no-op on mobile.
127
+ if (onControl) unlock()
128
+ return
129
+ }
130
+
131
+ // Unlocked: clicking a control must NOT re-lock (otherwise pressing a
132
+ // toolbar button would immediately re-lock the map).
133
+ if (onControl) return
134
+ if (!container.contains(e.target as Node)) {
135
+ setLocked(true)
136
+ }
137
+ }
138
+
139
+ document.addEventListener('pointerdown', handleDocPointerDown, true)
140
+ return () => {
141
+ document.removeEventListener('pointerdown', handleDocPointerDown, true)
142
+ }
143
+ }, [mapRef, isLoaded, mode, unlock])
144
+
145
+ // Reset to locked whenever the feature is (re)enabled.
146
+ useEffect(() => {
147
+ if (mode !== 'off') setLocked(true)
148
+ }, [mode])
149
+
150
+ return {
151
+ locked: mode !== 'off' && locked,
152
+ unlock,
153
+ isMobileLock: mode === 'mobile' && locked,
154
+ }
155
+ }
@@ -0,0 +1,21 @@
1
+ 'use client'
2
+
3
+ import { useIsTouch, useMediaQuery, MEDIA_BREAKPOINTS } from '@djangocfg/ui-core/hooks'
4
+
5
+ /**
6
+ * Should the map behave as a "mobile / touch" surface?
7
+ *
8
+ * True when the primary pointer is coarse (a finger) OR the viewport is
9
+ * narrow. Both signals come from ui-core's `matchMedia`-based hooks, NOT
10
+ * the userAgent — so this reflects real touchscreens, DevTools device
11
+ * emulation, narrow windows and tablets, and updates live. (UA sniffing
12
+ * misses touch laptops, mis-detects iPadOS, and won't react to a resize.)
13
+ *
14
+ * @param breakpoint width (px) at/under which the map is treated as mobile
15
+ * even with a fine pointer. Default 768 (Tailwind `md`).
16
+ */
17
+ export function useMapTouchMode(breakpoint = MEDIA_BREAKPOINTS.md): boolean {
18
+ const isTouch = useIsTouch()
19
+ const isNarrow = useMediaQuery(`(max-width: ${breakpoint - 1}px)`)
20
+ return isTouch || isNarrow
21
+ }
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useMemo, useState } from 'react'
4
+
5
+ export interface UseMarkerCardResult {
6
+ /** The currently open marker id, or `null` when none is open. */
7
+ openId: string | null
8
+ /** Open the card for `id` (closes any other open card). */
9
+ open: (id: string) => void
10
+ /** Close the open card. */
11
+ close: () => void
12
+ /** Toggle `id` — closes it if it's already the open one. */
13
+ toggle: (id: string) => void
14
+ /** Whether `id` is the currently open card. */
15
+ isOpen: (id: string) => boolean
16
+ }
17
+
18
+ /**
19
+ * useMarkerCard — selection state for marker info-cards. Holds a single
20
+ * `openId` so only ONE card is open at a time; opening a new id closes the
21
+ * previous, and `toggle` closes the same id. Pure React glue — no UI, no map
22
+ * dependency. Drives `MapMarkers`, or use it standalone for manual control.
23
+ */
24
+ export function useMarkerCard(): UseMarkerCardResult {
25
+ const [openId, setOpenId] = useState<string | null>(null)
26
+
27
+ const open = useCallback((id: string) => setOpenId(id), [])
28
+ const close = useCallback(() => setOpenId(null), [])
29
+ const toggle = useCallback(
30
+ (id: string) => setOpenId((prev) => (prev === id ? null : id)),
31
+ []
32
+ )
33
+ const isOpen = useCallback((id: string) => openId === id, [openId])
34
+
35
+ return useMemo(
36
+ () => ({ openId, open, close, toggle, isOpen }),
37
+ [openId, open, close, toggle, isOpen]
38
+ )
39
+ }
@@ -48,9 +48,15 @@ export function useMarkers(): MarkerActions {
48
48
  }, [setMarkers])
49
49
 
50
50
  const fitToMarkers = useCallback(
51
- (padding = 50) => {
51
+ // Accepts a plain `padding` number (back-compat) or an options object.
52
+ // `maxZoom` (default 16) stops a single / tightly-clustered set from
53
+ // zooming all the way in and losing surrounding context.
54
+ (paddingOrOpts: number | { padding?: number; maxZoom?: number } = 50) => {
52
55
  if (markers.length === 0) return
53
56
 
57
+ const { padding = 50, maxZoom = 16 } =
58
+ typeof paddingOrOpts === 'number' ? { padding: paddingOrOpts } : paddingOrOpts
59
+
54
60
  if (markers.length === 1) {
55
61
  const marker = markers[0]
56
62
  fitBounds(
@@ -58,7 +64,7 @@ export function useMarkers(): MarkerActions {
58
64
  [marker.longitude - 0.01, marker.latitude - 0.01],
59
65
  [marker.longitude + 0.01, marker.latitude + 0.01],
60
66
  ],
61
- { padding }
67
+ { padding, maxZoom }
62
68
  )
63
69
  return
64
70
  }
@@ -71,7 +77,7 @@ export function useMarkers(): MarkerActions {
71
77
  [Math.max(...lngs), Math.max(...lats)],
72
78
  ]
73
79
 
74
- fitBounds(bounds, { padding })
80
+ fitBounds(bounds, { padding, maxZoom })
75
81
  },
76
82
  [markers, fitBounds]
77
83
  )
@@ -0,0 +1,169 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { length as turfLength, area as turfArea } from '@turf/turf'
5
+
6
+ import { useMapContext } from '../context'
7
+ import { createLineString, createPolygon, createFeatureCollection } from '../utils'
8
+
9
+ /** Measure interaction mode. */
10
+ export type MeasureMode = 'distance' | 'area'
11
+
12
+ export interface UseMeasureResult {
13
+ /** Current measure mode (`'distance'` = path length, `'area'` = polygon area). */
14
+ mode: MeasureMode
15
+ /** Switch the measure mode. Clears the in-progress vertices. */
16
+ setMode: (mode: MeasureMode) => void
17
+ /** Vertices collected so far, as `[lng, lat]` pairs in click order. */
18
+ points: [number, number][]
19
+ /**
20
+ * The collected geometry as a FeatureCollection ready for `MapSource`.
21
+ * A LineString in `'distance'` mode, a Polygon in `'area'` mode (empty
22
+ * until enough vertices exist to form the geometry).
23
+ */
24
+ geojson: GeoJSON.FeatureCollection
25
+ /**
26
+ * The running total — kilometres in `'distance'` mode, square metres in
27
+ * `'area'` mode. `0` until at least two (distance) / three (area) points.
28
+ */
29
+ total: number
30
+ /** Human-readable total, e.g. `"1.24 km"`, `"3,420 m²"`, `"0.34 km²"`. */
31
+ totalLabel: string
32
+ /** Remove all collected vertices. */
33
+ clear: () => void
34
+ /** Whether the measure interaction is listening for map clicks. */
35
+ active: boolean
36
+ /** Start / stop listening for map clicks. Stopping clears the vertices. */
37
+ setActive: (active: boolean) => void
38
+ }
39
+
40
+ const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 })
41
+
42
+ /** Format a distance in kilometres into a compact `m`/`km` label. */
43
+ function formatDistance(km: number): string {
44
+ if (km <= 0) return '0 m'
45
+ if (km < 1) return `${numberFormat.format(km * 1000)} m`
46
+ return `${km.toFixed(2)} km`
47
+ }
48
+
49
+ /** Format an area in square metres into a compact `m²`/`km²` label. */
50
+ function formatArea(m2: number): string {
51
+ if (m2 <= 0) return '0 m²'
52
+ if (m2 < 1_000_000) return `${numberFormat.format(m2)} m²`
53
+ return `${(m2 / 1_000_000).toFixed(2)} km²`
54
+ }
55
+
56
+ /**
57
+ * Click-to-measure interaction for the Map tool.
58
+ *
59
+ * Subscribes to map clicks while `active` and appends each `[lng, lat]` to
60
+ * the vertex list, recomputing a running total with turf:
61
+ * - `'distance'` → a LineString, total via `turf.length` (km).
62
+ * - `'area'` → a Polygon, total via `turf.area` (m²).
63
+ *
64
+ * Geometry is built with the tool's own `createLineString` / `createPolygon`
65
+ * helpers so it matches house style and renders through the existing
66
+ * `createLineLayer` / `createPolygonLayer` factories.
67
+ *
68
+ * Must be used inside a `MapProvider` (it reads `useMapContext`).
69
+ */
70
+ export function useMeasure(initialMode: MeasureMode = 'distance'): UseMeasureResult {
71
+ const { mapRef, isLoaded } = useMapContext()
72
+
73
+ const [mode, setModeState] = useState<MeasureMode>(initialMode)
74
+ const [active, setActiveState] = useState(false)
75
+ const [points, setPoints] = useState<[number, number][]>([])
76
+
77
+ // Latest mode/active read inside the (re-subscribed only on isLoaded) click
78
+ // handler, so changing mode/active never re-binds the underlying listener.
79
+ const modeRef = useRef(mode)
80
+ const activeRef = useRef(active)
81
+ useEffect(() => {
82
+ modeRef.current = mode
83
+ }, [mode])
84
+ useEffect(() => {
85
+ activeRef.current = active
86
+ }, [active])
87
+
88
+ const clear = useCallback(() => {
89
+ setPoints([])
90
+ }, [])
91
+
92
+ const setMode = useCallback((next: MeasureMode) => {
93
+ setModeState(next)
94
+ setPoints([])
95
+ }, [])
96
+
97
+ const setActive = useCallback((next: boolean) => {
98
+ setActiveState(next)
99
+ // Stopping the interaction discards the in-progress measurement.
100
+ if (!next) setPoints([])
101
+ }, [])
102
+
103
+ // Subscribe to map clicks. Listener is attached once per `isLoaded`; the
104
+ // handler reads the latest mode/active via refs (same pattern as
105
+ // `useMapEvents`).
106
+ useEffect(() => {
107
+ const map = mapRef.current?.getMap()
108
+ if (!map || !isLoaded) return
109
+
110
+ const handleClick = (event: maplibregl.MapMouseEvent) => {
111
+ if (!activeRef.current) return
112
+ const { lng, lat } = event.lngLat
113
+ setPoints((prev) => [...prev, [lng, lat]])
114
+ }
115
+
116
+ map.on('click', handleClick)
117
+ return () => {
118
+ map.off('click', handleClick)
119
+ }
120
+ }, [mapRef, isLoaded])
121
+
122
+ // Reflect the active state as a crosshair cursor on the map canvas.
123
+ useEffect(() => {
124
+ const map = mapRef.current?.getMap()
125
+ if (!map || !isLoaded) return
126
+ const canvas = map.getCanvas()
127
+ if (!canvas) return
128
+ const previous = canvas.style.cursor
129
+ if (active) canvas.style.cursor = 'crosshair'
130
+ return () => {
131
+ canvas.style.cursor = previous
132
+ }
133
+ }, [mapRef, isLoaded, active])
134
+
135
+ const geojson = useMemo<GeoJSON.FeatureCollection>(() => {
136
+ if (mode === 'distance') {
137
+ if (points.length < 2) return createFeatureCollection()
138
+ return createFeatureCollection([createLineString(points)])
139
+ }
140
+ if (points.length < 3) return createFeatureCollection()
141
+ return createFeatureCollection([createPolygon(points)])
142
+ }, [mode, points])
143
+
144
+ const total = useMemo<number>(() => {
145
+ if (mode === 'distance') {
146
+ if (points.length < 2) return 0
147
+ return turfLength(createLineString(points), { units: 'kilometers' })
148
+ }
149
+ if (points.length < 3) return 0
150
+ return turfArea(createPolygon(points))
151
+ }, [mode, points])
152
+
153
+ const totalLabel = useMemo<string>(
154
+ () => (mode === 'distance' ? formatDistance(total) : formatArea(total)),
155
+ [mode, total]
156
+ )
157
+
158
+ return {
159
+ mode,
160
+ setMode,
161
+ points,
162
+ geojson,
163
+ total,
164
+ totalLabel,
165
+ clear,
166
+ active,
167
+ setActive,
168
+ }
169
+ }
@@ -0,0 +1,123 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ import { useMapContext } from '../context'
6
+ import { TERRAIN_DEM } from '../styles'
7
+
8
+ import type { RasterDEMSourceSpecification } from 'maplibre-gl'
9
+
10
+ /** Stable source / layer ids the hook owns. */
11
+ const DEM_SOURCE_ID = 'terrain-dem'
12
+ const HILLSHADE_LAYER_ID = 'terrain-hillshade'
13
+
14
+ /** Default vertical exaggeration — subtle, reads as 3D without caricature. */
15
+ const DEFAULT_EXAGGERATION = 1.2
16
+ /** Pitch eased in when terrain is enabled so it actually reads as 3D. */
17
+ const TERRAIN_PITCH = 60
18
+
19
+ export interface UseTerrainOptions {
20
+ /** Turn 3D terrain on/off. */
21
+ enabled?: boolean
22
+ /**
23
+ * `raster-dem` source spec to use. Defaults to the free AWS Terrarium DEM
24
+ * (`TERRAIN_DEM`) — no API key required.
25
+ */
26
+ source?: RasterDEMSourceSpecification
27
+ /** Vertical exaggeration passed to `setTerrain`. @default 1.2 */
28
+ exaggeration?: number
29
+ /** Also add a `hillshade` layer from the same DEM. @default true */
30
+ hillshade?: boolean
31
+ /**
32
+ * Ease the camera pitch in (to ~60°) when enabling and back to 0 when
33
+ * disabling, so a flat map visibly becomes 3D. Opt out for hosts that
34
+ * drive pitch themselves. @default true
35
+ */
36
+ autoPitch?: boolean
37
+ }
38
+
39
+ /**
40
+ * 3D terrain + hillshade from a free `raster-dem` source.
41
+ *
42
+ * When `enabled`, adds the DEM source (if absent), calls `setTerrain`, and
43
+ * optionally a `hillshade` layer. On disable/unmount it clears terrain and
44
+ * removes the source + layer cleanly. Re-applies on `styledata` because a
45
+ * style reload drops terrain. Terrain only reads as 3D with `pitch > 0`, so
46
+ * `autoPitch` eases the camera in/out (default on).
47
+ *
48
+ * Mirrors the map-access + add/remove + cleanup pattern of `useMapLayers`.
49
+ */
50
+ export function useTerrain({
51
+ enabled = false,
52
+ source = TERRAIN_DEM,
53
+ exaggeration = DEFAULT_EXAGGERATION,
54
+ hillshade = true,
55
+ autoPitch = true,
56
+ }: UseTerrainOptions) {
57
+ const { mapRef, isLoaded } = useMapContext()
58
+
59
+ // Keep latest opts in refs so the styledata listener always reads current
60
+ // values without re-subscribing.
61
+ const optsRef = useRef({ source, exaggeration, hillshade })
62
+ optsRef.current = { source, exaggeration, hillshade }
63
+
64
+ useEffect(() => {
65
+ const map = mapRef.current?.getMap()
66
+ if (!map || !isLoaded) return
67
+
68
+ const apply = () => {
69
+ const m = mapRef.current?.getMap()
70
+ if (!m || !m.isStyleLoaded()) return
71
+ const { source: src, exaggeration: ex, hillshade: shade } = optsRef.current
72
+
73
+ if (!m.getSource(DEM_SOURCE_ID)) {
74
+ m.addSource(DEM_SOURCE_ID, src)
75
+ }
76
+ m.setTerrain({ source: DEM_SOURCE_ID, exaggeration: ex })
77
+
78
+ if (shade) {
79
+ if (!m.getLayer(HILLSHADE_LAYER_ID)) {
80
+ m.addLayer({
81
+ id: HILLSHADE_LAYER_ID,
82
+ type: 'hillshade',
83
+ source: DEM_SOURCE_ID,
84
+ })
85
+ }
86
+ } else if (m.getLayer(HILLSHADE_LAYER_ID)) {
87
+ m.removeLayer(HILLSHADE_LAYER_ID)
88
+ }
89
+ }
90
+
91
+ const teardown = () => {
92
+ const m = mapRef.current?.getMap()
93
+ if (!m) return
94
+ try {
95
+ if (m.getTerrain()) m.setTerrain(null)
96
+ if (m.getLayer(HILLSHADE_LAYER_ID)) m.removeLayer(HILLSHADE_LAYER_ID)
97
+ if (m.getSource(DEM_SOURCE_ID)) m.removeSource(DEM_SOURCE_ID)
98
+ } catch {
99
+ // style may have been swapped out mid-teardown — ignore.
100
+ }
101
+ }
102
+
103
+ if (!enabled) {
104
+ teardown()
105
+ if (autoPitch && map.getPitch() > 0) {
106
+ map.easeTo({ pitch: 0, duration: 600 })
107
+ }
108
+ return
109
+ }
110
+
111
+ apply()
112
+ if (autoPitch && map.getPitch() === 0) {
113
+ map.easeTo({ pitch: TERRAIN_PITCH, duration: 800 })
114
+ }
115
+
116
+ // A style reload drops terrain/sources — re-apply when the style settles.
117
+ map.on('styledata', apply)
118
+ return () => {
119
+ map.off('styledata', apply)
120
+ teardown()
121
+ }
122
+ }, [mapRef, isLoaded, enabled, autoPitch])
123
+ }
@@ -28,9 +28,9 @@ export type {
28
28
  PolygonLayerOptions,
29
29
  LineLayerOptions,
30
30
  RouteLayerOptions,
31
+ SymbolLayerOptions,
31
32
  // Control props
32
33
  DrawControlProps,
33
- GeocoderControlProps,
34
34
  CustomOverlayProps,
35
35
  // Legend
36
36
  LegendItem,
@@ -52,7 +52,32 @@ export {
52
52
  useMapEvents,
53
53
  useMapViewport,
54
54
  useMapLayers,
55
+ useMapImages,
56
+ useMeasure,
57
+ useTerrain,
58
+ useCompass,
59
+ useGeolocation,
55
60
  useControl,
61
+ useDraggableMarkers,
62
+ useGeocoder,
63
+ useMarkerCard,
64
+ type UseMarkerCardResult,
65
+ type UseCompassResult,
66
+ type UseGeocoderOptions,
67
+ type UseGeocoderResult,
68
+ type MapImage,
69
+ type UseMapImagesResult,
70
+ type UseMeasureResult,
71
+ type MeasureMode,
72
+ type UseTerrainOptions,
73
+ type GeolocationFix,
74
+ type GeolocationStatus,
75
+ type UseGeolocationOptions,
76
+ type UseGeolocationResult,
77
+ type DraggablePoint,
78
+ type UseDraggableMarkersOptions,
79
+ type UseDraggableMarkersResult,
80
+ type DraggableMarkerHandlers,
56
81
  } from './hooks'
57
82
 
58
83
  // Components
@@ -68,17 +93,45 @@ export {
68
93
  CustomOverlay,
69
94
  MapLegend,
70
95
  LayerSwitcher,
96
+ MeasureControl,
97
+ DraggableMarkers,
98
+ GeocoderInput,
99
+ GeocoderControl,
100
+ CompassChip,
101
+ MarkerCard,
102
+ MapMarkers,
71
103
  } from './components'
72
104
  export type {
73
105
  MapContainerProps,
106
+ GeolocateOptions,
107
+ GeolocatePosition,
74
108
  MapControlsProps,
75
109
  MapMarkerProps,
76
110
  MapPopupProps,
77
111
  MapClusterProps,
78
112
  MapSourceProps,
79
113
  MapLayerProps,
114
+ MeasureControlProps,
115
+ DraggableMarkersProps,
116
+ GeocoderInputProps,
117
+ GeocoderControlProps,
118
+ CompassChipProps,
119
+ MarkerCardProps,
120
+ MapMarkersProps,
121
+ MarkerWithCard,
122
+ MarkerCardAction,
123
+ MarkerCardData,
124
+ MarkerCardActionData,
80
125
  } from './components'
81
126
 
127
+ // The card-data shape. `MarkerCard` (value) is the component above; the data
128
+ // type ships as `MarkerCardInfo` to avoid colliding with that value export.
129
+ export type { MarkerCard as MarkerCardInfo } from './cards'
130
+
131
+ // Geocoder resolver — pure, key-free (light; no maplibre runtime).
132
+ export { photonResolve, cameraForResult } from './geocode'
133
+ export type { GeocodeResult, GeocodeKind, ResolveGeocode } from './geocode'
134
+
82
135
  // Layers
83
136
  export {
84
137
  createClusterLayers,
@@ -91,10 +144,14 @@ export {
91
144
  createRouteLayers,
92
145
  createDashedLineLayer,
93
146
  createAnimatedLineLayer,
147
+ createSymbolLayer,
94
148
  } from './layers'
95
149
 
96
150
  // Styles
97
- export { MAP_STYLES, getMapStyle } from './styles'
151
+ export { MAP_STYLES, getMapStyle, TERRAIN_DEM, TERRAIN_DEM_URL } from './styles'
152
+
153
+ // PMTiles protocol — opt-in self-hosted / offline basemaps (no tile server)
154
+ export { addPMTilesProtocol, pmtilesSourceUrl } from './protocols/pmtiles'
98
155
 
99
156
  // Utils
100
157
  export {
@@ -11,5 +11,6 @@ export {
11
11
  createDashedLineLayer,
12
12
  createAnimatedLineLayer,
13
13
  } from './line'
14
+ export { createSymbolLayer } from './symbol'
14
15
 
15
16
  // Types are exported from ../types.ts