|
| 1 | +import { useAppStore } from "@geolibre/core"; |
1 | 2 | import type { MapController } from "@geolibre/map"; |
2 | 3 | import { |
3 | 4 | DropdownMenu, |
4 | 5 | DropdownMenuContent, |
5 | 6 | DropdownMenuItem, |
6 | 7 | DropdownMenuLabel, |
7 | 8 | DropdownMenuSeparator, |
| 9 | + DropdownMenuSub, |
| 10 | + DropdownMenuSubContent, |
| 11 | + DropdownMenuSubTrigger, |
8 | 12 | DropdownMenuTrigger, |
9 | 13 | } from "@geolibre/ui"; |
10 | 14 | import type maplibregl from "maplibre-gl"; |
11 | | -import { BookOpen, Braces, Crosshair, Earth, MapIcon, MapPin, ZoomIn } from "lucide-react"; |
12 | | -import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; |
| 15 | +import { |
| 16 | + BookOpen, |
| 17 | + Braces, |
| 18 | + Circle, |
| 19 | + Crosshair, |
| 20 | + Earth, |
| 21 | + MapIcon, |
| 22 | + MapPin, |
| 23 | + Route, |
| 24 | + Sparkles, |
| 25 | + ZoomIn, |
| 26 | +} from "lucide-react"; |
| 27 | +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; |
13 | 28 | import { useTranslation } from "react-i18next"; |
14 | 29 | import { googleEarthUrl, googleMapsUrl } from "../../lib/external-map-links"; |
15 | 30 | import { openExternalLink } from "../../lib/open-external"; |
| 31 | +import { |
| 32 | + bufferPresetsFor, |
| 33 | + clickedPointLayer, |
| 34 | + formatBufferDistance, |
| 35 | + QUICK_TRAVEL_CONTOURS, |
| 36 | + QUICK_TRAVEL_CONTOURS_LABEL, |
| 37 | + runQuickAnalysis, |
| 38 | + type QuickBufferPreset, |
| 39 | +} from "../../lib/quick-analysis"; |
| 40 | +import { hasRoutingConsent, recordRoutingConsent } from "../../lib/routing-consent"; |
| 41 | +import { RoutingConsentDialog } from "./RoutingConsentDialog"; |
16 | 42 |
|
17 | 43 | interface ContextMenuState { |
18 | 44 | /** Monotonic id so each right-click remounts the menu at the new anchor. */ |
@@ -81,7 +107,7 @@ export function MapContextMenu({ |
81 | 107 | /** Open a Wikipedia knowledge card for the clicked coordinate. */ |
82 | 108 | onExplorePlace?: (lat: number, lng: number) => void; |
83 | 109 | }) { |
84 | | - const { t } = useTranslation(); |
| 110 | + const { i18n, t } = useTranslation(); |
85 | 111 | // `menu` keeps the last anchor/coordinate even while closing, so the exit |
86 | 112 | // animation plays from the right spot; `open` drives visibility separately. |
87 | 113 | const [menu, setMenu] = useState<ContextMenuState | null>(null); |
@@ -164,65 +190,196 @@ export function MapContextMenu({ |
164 | 190 | void openExternalLink(googleEarthUrl(menu.lat, menu.lng, zoom)); |
165 | 191 | }, [menu, mapControllerRef]); |
166 | 192 |
|
| 193 | + // Quick analysis (#1523): run an existing Processing tool against the clicked |
| 194 | + // point with defaults filled in. The buffer ladder follows the scale bar's |
| 195 | + // unit system so the menu speaks in the units the map is already labelled in. |
| 196 | + const scaleUnit = useAppStore((s) => s.preferences.map.scaleUnit); |
| 197 | + const bufferPresets = useMemo(() => bufferPresetsFor(scaleUnit), [scaleUnit]); |
| 198 | + const setVectorToolOpen = useAppStore((s) => s.setVectorToolOpen); |
| 199 | + |
| 200 | + const formatDistance = useCallback( |
| 201 | + (preset: QuickBufferPreset) => formatBufferDistance(preset, i18n.language, t), |
| 202 | + [i18n.language, t], |
| 203 | + ); |
| 204 | + |
| 205 | + const bufferHere = useCallback( |
| 206 | + (preset: QuickBufferPreset) => { |
| 207 | + if (!menu) return; |
| 208 | + const point = clickedPointLayer(menu.lng, menu.lat); |
| 209 | + void runQuickAnalysis({ |
| 210 | + toolId: "buffer", |
| 211 | + parameters: { layer: point.id, distance: preset.distance, units: preset.units }, |
| 212 | + extraLayers: [point], |
| 213 | + resultName: t("quickAnalysis.bufferLayerName", { distance: formatDistance(preset) }), |
| 214 | + mapControllerRef, |
| 215 | + }); |
| 216 | + }, |
| 217 | + [menu, t, formatDistance, mapControllerRef], |
| 218 | + ); |
| 219 | + |
| 220 | + // Travel time sends the clicked coordinate to a public Valhalla server, so it |
| 221 | + // is gated on the same one-time privacy notice as the Network tools — that |
| 222 | + // consent flag is documented as covering every activation path, and a menu |
| 223 | + // item that skipped it would send coordinates without the notice. |
| 224 | + const [pendingTravelMode, setPendingTravelMode] = useState<"auto" | "pedestrian" | null>(null); |
| 225 | + // The coordinate is captured when the notice opens: `menu` is cleared by the |
| 226 | + // next right-click, and the run must use the point the user actually chose. |
| 227 | + const pendingTravelPoint = useRef<{ lng: number; lat: number } | null>(null); |
| 228 | + |
| 229 | + const runTravelTime = useCallback( |
| 230 | + (mode: "auto" | "pedestrian", lng: number, lat: number) => { |
| 231 | + const point = clickedPointLayer(lng, lat); |
| 232 | + void runQuickAnalysis({ |
| 233 | + toolId: "isochrone", |
| 234 | + parameters: { |
| 235 | + layer: point.id, |
| 236 | + mode, |
| 237 | + metric: "time", |
| 238 | + contours: QUICK_TRAVEL_CONTOURS, |
| 239 | + // Left empty so the tool resolves the configured routing server |
| 240 | + // rather than baking one in here. |
| 241 | + endpoint: "", |
| 242 | + }, |
| 243 | + extraLayers: [point], |
| 244 | + resultName: |
| 245 | + mode === "auto" |
| 246 | + ? t("quickAnalysis.driveTimeLayerName") |
| 247 | + : t("quickAnalysis.walkTimeLayerName"), |
| 248 | + mapControllerRef, |
| 249 | + }); |
| 250 | + }, |
| 251 | + [t, mapControllerRef], |
| 252 | + ); |
| 253 | + |
| 254 | + const travelTimeHere = useCallback( |
| 255 | + (mode: "auto" | "pedestrian") => { |
| 256 | + if (!menu) return; |
| 257 | + if (hasRoutingConsent()) { |
| 258 | + runTravelTime(mode, menu.lng, menu.lat); |
| 259 | + return; |
| 260 | + } |
| 261 | + pendingTravelPoint.current = { lng: menu.lng, lat: menu.lat }; |
| 262 | + setPendingTravelMode(mode); |
| 263 | + }, |
| 264 | + [menu, runTravelTime], |
| 265 | + ); |
| 266 | + |
| 267 | + const confirmTravelTime = useCallback(() => { |
| 268 | + const point = pendingTravelPoint.current; |
| 269 | + const mode = pendingTravelMode; |
| 270 | + recordRoutingConsent(); |
| 271 | + setPendingTravelMode(null); |
| 272 | + pendingTravelPoint.current = null; |
| 273 | + if (mode && point) runTravelTime(mode, point.lng, point.lat); |
| 274 | + }, [pendingTravelMode, runTravelTime]); |
| 275 | + |
| 276 | + const cancelTravelTime = useCallback(() => { |
| 277 | + setPendingTravelMode(null); |
| 278 | + pendingTravelPoint.current = null; |
| 279 | + }, []); |
| 280 | + |
167 | 281 | return ( |
168 | | - // Keyed by the right-click id so each new right-click remounts the menu with |
169 | | - // a fresh anchor at the cursor. The key is tied to `menu` (not `open`), so a |
170 | | - // normal close leaves the key stable and Radix can play its exit animation; |
171 | | - // only the next right-click forces the remount that repositions the popup. |
172 | | - <DropdownMenu key={menu?.id ?? "init"} open={open} onOpenChange={setOpen}> |
173 | | - <DropdownMenuTrigger asChild> |
174 | | - <span |
175 | | - aria-hidden |
176 | | - style={{ |
177 | | - position: "fixed", |
178 | | - left: menu?.x ?? 0, |
179 | | - top: menu?.y ?? 0, |
180 | | - width: 0, |
181 | | - height: 0, |
182 | | - }} |
183 | | - /> |
184 | | - </DropdownMenuTrigger> |
185 | | - <DropdownMenuContent align="start" side="bottom" className="w-64"> |
186 | | - <DropdownMenuItem |
187 | | - onSelect={copyCoords} |
188 | | - className="gap-2 font-mono text-xs" |
189 | | - title={t("mapContextMenu.copyCoordinatesHint")} |
190 | | - > |
191 | | - <MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> |
192 | | - <span className="truncate">{menu ? formatCoords(menu.lat, menu.lng) : ""}</span> |
193 | | - </DropdownMenuItem> |
194 | | - <DropdownMenuSeparator /> |
195 | | - <DropdownMenuLabel className="text-xs text-muted-foreground"> |
196 | | - {t("mapContextMenu.quickActions")} |
197 | | - </DropdownMenuLabel> |
198 | | - {onExplorePlace ? ( |
199 | | - <DropdownMenuItem onSelect={explorePlace} className="gap-2"> |
200 | | - <BookOpen className="h-4 w-4 shrink-0 text-muted-foreground" /> |
201 | | - {t("mapContextMenu.whatsHere")} |
| 282 | + <> |
| 283 | + <RoutingConsentDialog |
| 284 | + open={pendingTravelMode !== null} |
| 285 | + onCancel={cancelTravelTime} |
| 286 | + onConfirm={confirmTravelTime} |
| 287 | + /> |
| 288 | + {/* Keyed by the right-click id so each new right-click remounts the menu |
| 289 | + with a fresh anchor at the cursor. The key is tied to `menu` (not |
| 290 | + `open`), so a normal close leaves the key stable and Radix can play |
| 291 | + its exit animation; only the next right-click forces the remount that |
| 292 | + repositions the popup. */} |
| 293 | + <DropdownMenu key={menu?.id ?? "init"} open={open} onOpenChange={setOpen}> |
| 294 | + <DropdownMenuTrigger asChild> |
| 295 | + <span |
| 296 | + aria-hidden |
| 297 | + style={{ |
| 298 | + position: "fixed", |
| 299 | + left: menu?.x ?? 0, |
| 300 | + top: menu?.y ?? 0, |
| 301 | + width: 0, |
| 302 | + height: 0, |
| 303 | + }} |
| 304 | + /> |
| 305 | + </DropdownMenuTrigger> |
| 306 | + <DropdownMenuContent align="start" side="bottom" className="w-64"> |
| 307 | + <DropdownMenuItem |
| 308 | + onSelect={copyCoords} |
| 309 | + className="gap-2 font-mono text-xs" |
| 310 | + title={t("mapContextMenu.copyCoordinatesHint")} |
| 311 | + > |
| 312 | + <MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> |
| 313 | + <span className="truncate">{menu ? formatCoords(menu.lat, menu.lng) : ""}</span> |
| 314 | + </DropdownMenuItem> |
| 315 | + <DropdownMenuSeparator /> |
| 316 | + <DropdownMenuLabel className="text-xs text-muted-foreground"> |
| 317 | + {t("mapContextMenu.quickActions")} |
| 318 | + </DropdownMenuLabel> |
| 319 | + {onExplorePlace ? ( |
| 320 | + <DropdownMenuItem onSelect={explorePlace} className="gap-2"> |
| 321 | + <BookOpen className="h-4 w-4 shrink-0 text-muted-foreground" /> |
| 322 | + {t("mapContextMenu.whatsHere")} |
| 323 | + </DropdownMenuItem> |
| 324 | + ) : null} |
| 325 | + <DropdownMenuItem onSelect={copyGeoJson} className="gap-2"> |
| 326 | + <Braces className="h-4 w-4 shrink-0 text-muted-foreground" /> |
| 327 | + {t("mapContextMenu.copyGeoJson")} |
| 328 | + </DropdownMenuItem> |
| 329 | + <DropdownMenuItem onSelect={centerHere} className="gap-2"> |
| 330 | + <Crosshair className="h-4 w-4 shrink-0 text-muted-foreground" /> |
| 331 | + {t("mapContextMenu.centerHere")} |
| 332 | + </DropdownMenuItem> |
| 333 | + <DropdownMenuItem onSelect={zoomInHere} className="gap-2"> |
| 334 | + <ZoomIn className="h-4 w-4 shrink-0 text-muted-foreground" /> |
| 335 | + {t("mapContextMenu.zoomInHere")} |
| 336 | + </DropdownMenuItem> |
| 337 | + <DropdownMenuSeparator /> |
| 338 | + <DropdownMenuSub> |
| 339 | + <DropdownMenuSubTrigger className="gap-2"> |
| 340 | + <Sparkles className="h-4 w-4 shrink-0 text-muted-foreground" /> |
| 341 | + {t("quickAnalysis.menu")} |
| 342 | + </DropdownMenuSubTrigger> |
| 343 | + <DropdownMenuSubContent className="w-60"> |
| 344 | + {bufferPresets.map((preset) => ( |
| 345 | + <DropdownMenuItem |
| 346 | + key={`${preset.distance}-${preset.units}`} |
| 347 | + onSelect={() => bufferHere(preset)} |
| 348 | + className="gap-2" |
| 349 | + > |
| 350 | + <Circle className="h-4 w-4 shrink-0 text-muted-foreground" /> |
| 351 | + {t("quickAnalysis.bufferHere", { distance: formatDistance(preset) })} |
| 352 | + </DropdownMenuItem> |
| 353 | + ))} |
| 354 | + <DropdownMenuSeparator /> |
| 355 | + <DropdownMenuItem onSelect={() => travelTimeHere("auto")} className="gap-2"> |
| 356 | + <Route className="h-4 w-4 shrink-0 text-muted-foreground" /> |
| 357 | + {t("quickAnalysis.driveTimeHere", { contours: QUICK_TRAVEL_CONTOURS_LABEL })} |
| 358 | + </DropdownMenuItem> |
| 359 | + <DropdownMenuItem onSelect={() => travelTimeHere("pedestrian")} className="gap-2"> |
| 360 | + <Route className="h-4 w-4 shrink-0 text-muted-foreground" /> |
| 361 | + {t("quickAnalysis.walkTimeHere", { contours: QUICK_TRAVEL_CONTOURS_LABEL })} |
| 362 | + </DropdownMenuItem> |
| 363 | + <DropdownMenuSeparator /> |
| 364 | + {/* Escape hatch when the presets aren't what was wanted: the full |
| 365 | + dialog, preselected on the same tool. */} |
| 366 | + <DropdownMenuItem onSelect={() => setVectorToolOpen("buffer")} className="gap-2"> |
| 367 | + <Sparkles className="h-4 w-4 shrink-0 text-muted-foreground" /> |
| 368 | + {t("quickAnalysis.openInProcessing")} |
| 369 | + </DropdownMenuItem> |
| 370 | + </DropdownMenuSubContent> |
| 371 | + </DropdownMenuSub> |
| 372 | + <DropdownMenuSeparator /> |
| 373 | + <DropdownMenuItem onSelect={viewInGoogleMaps} className="gap-2"> |
| 374 | + <MapIcon className="h-4 w-4 shrink-0 text-muted-foreground" /> |
| 375 | + {t("mapContextMenu.viewInGoogleMaps")} |
| 376 | + </DropdownMenuItem> |
| 377 | + <DropdownMenuItem onSelect={viewInGoogleEarth} className="gap-2"> |
| 378 | + <Earth className="h-4 w-4 shrink-0 text-muted-foreground" /> |
| 379 | + {t("mapContextMenu.viewInGoogleEarth")} |
202 | 380 | </DropdownMenuItem> |
203 | | - ) : null} |
204 | | - <DropdownMenuItem onSelect={copyGeoJson} className="gap-2"> |
205 | | - <Braces className="h-4 w-4 shrink-0 text-muted-foreground" /> |
206 | | - {t("mapContextMenu.copyGeoJson")} |
207 | | - </DropdownMenuItem> |
208 | | - <DropdownMenuItem onSelect={centerHere} className="gap-2"> |
209 | | - <Crosshair className="h-4 w-4 shrink-0 text-muted-foreground" /> |
210 | | - {t("mapContextMenu.centerHere")} |
211 | | - </DropdownMenuItem> |
212 | | - <DropdownMenuItem onSelect={zoomInHere} className="gap-2"> |
213 | | - <ZoomIn className="h-4 w-4 shrink-0 text-muted-foreground" /> |
214 | | - {t("mapContextMenu.zoomInHere")} |
215 | | - </DropdownMenuItem> |
216 | | - <DropdownMenuSeparator /> |
217 | | - <DropdownMenuItem onSelect={viewInGoogleMaps} className="gap-2"> |
218 | | - <MapIcon className="h-4 w-4 shrink-0 text-muted-foreground" /> |
219 | | - {t("mapContextMenu.viewInGoogleMaps")} |
220 | | - </DropdownMenuItem> |
221 | | - <DropdownMenuItem onSelect={viewInGoogleEarth} className="gap-2"> |
222 | | - <Earth className="h-4 w-4 shrink-0 text-muted-foreground" /> |
223 | | - {t("mapContextMenu.viewInGoogleEarth")} |
224 | | - </DropdownMenuItem> |
225 | | - </DropdownMenuContent> |
226 | | - </DropdownMenu> |
| 381 | + </DropdownMenuContent> |
| 382 | + </DropdownMenu> |
| 383 | + </> |
227 | 384 | ); |
228 | 385 | } |
0 commit comments