Skip to content

Commit 45cb89b

Browse files
authored
feat(processing): quick analysis from the map and layer menus (#1529)
* feat(processing): quick analysis from the map and layer menus (#1523) Run the most-asked-for processing tools straight from the map's right-click menu and a layer's actions menu, with defaults already filled in. No new algorithms: every action resolves a tool from the existing vector and network registries and runs it through `runAlgorithmCapture`, so the result is identical to running the same tool from the Processing dialog — and the run lands in the Processing History panel, re-runnable and copyable as Python. - Map right-click → Quick analysis: buffer the clicked point at three distances, and drive/walk time (5, 10, 15 min) isochrones from it. - Layer actions → Quick analysis: buffer all features, centroids, convex hull, bounding box. - Buffer distances follow the scale bar's unit system, so the menu speaks in the units the map is already labelled in. The nautical setting reuses the metric ladder because a preset must stay inside the buffer tool's declared units for "Open in Processing…" to reproduce the run. - A bottom-centered banner reports a run in flight and, on failure, offers "View details" which opens the Processing History entry — a menu item that has already closed has nowhere else to report to. - Catalog keys added to en.json and all 15 non-English locales. * Address CodeRabbit review feedback - Gate the quick drive/walk-time actions on the network-routing consent notice. They send the clicked coordinate to a public Valhalla server, and `routing-consent.ts` documents that flag as checked from *every* activation path — the context menu was sending coordinates without the notice. Extracted the notice itself into `RoutingConsentDialog` so the Processing menu and the map menu show one dialog rather than two copies that can drift. Verified in a browser: no request is issued before consent, Cancel sends nothing and stores nothing, Continue runs the isochrone, and the notice does not reappear. - Guard the run-status banner with a monotonic run token. A fast buffer fired while a slow drive-time was in flight could reset the banner to idle under the slow run, or resurrect an error for a run the user had moved on from. Only the newest run may write status; superseded runs still record their outcome in the Processing History. - Extract the duplicated `Intl.NumberFormat` + unit-translation logic into `formatBufferDistance()` in `lib/quick-analysis.ts`, replacing the copies in MapContextMenu and LayerPanel. - Drop `me-2` from the layer-menu SubTrigger icon; the component already applies `gap-2`, matching every other SubTrigger icon in that file. - ru: `миль` → `ми`. The unit is appended straight after the formatted number, so the genitive plural rendered "1 миль"; the abbreviation is invariant and matches the `м`/`км` entries. - tr: `Merkezler` → `Ağırlık merkezleri` for both the action and the generated layer name — the GIS term for centroids rather than generic "centers". * Address Claude review feedback - Convert the DropdownMenu explainer to a JSX comment. Wrapping the return in a fragment for the routing consent dialog moved this `//` block from expression position into JSX children, where `//` has no meaning — it was being parsed as literal text and rendered over the map on every mount. oxfmt reflowing it as prose was the tell. Confirmed both ways in a browser: before the fix the page body contained "Keyed by the right-click", "remounts the menu", and "Radix can play"; after it, none of them, and the context menu still opens.
1 parent ca8c8ec commit 45cb89b

24 files changed

Lines changed: 1443 additions & 113 deletions

apps/geolibre-desktop/src/components/layout/DesktopShell.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import { CollaborationStatusBadge } from "./CollaborationStatusBadge";
110110
import { CollaborateDialog } from "./CollaborateDialog";
111111
import { useCollaboration } from "../../hooks/useCollaboration";
112112
import { MapModeBanner } from "./MapModeBanner";
113+
import { QuickAnalysisBanner } from "./QuickAnalysisBanner";
113114
import { PixelTimeSeriesControl } from "./PixelTimeSeriesControl";
114115
import { MapLegendPanel } from "../legend/MapLegendPanel";
115116
import { RasterSubsetPanel } from "./RasterSubsetPanel";
@@ -2029,6 +2030,7 @@ export function DesktopShell({
20292030
<CollaborationStatusBadge api={collaboration} mapControllerRef={mapControllerRef} />
20302031
</SilentErrorBoundary>
20312032
<MapModeBanner mapControllerRef={mapControllerRef} />
2033+
<QuickAnalysisBanner />
20322034
<PixelTimeSeriesControl mapControllerRef={mapControllerRef} />
20332035
<MapLegendPanel
20342036
mapControllerRef={mapControllerRef}

apps/geolibre-desktop/src/components/layout/MapContextMenu.tsx

Lines changed: 218 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,44 @@
1+
import { useAppStore } from "@geolibre/core";
12
import type { MapController } from "@geolibre/map";
23
import {
34
DropdownMenu,
45
DropdownMenuContent,
56
DropdownMenuItem,
67
DropdownMenuLabel,
78
DropdownMenuSeparator,
9+
DropdownMenuSub,
10+
DropdownMenuSubContent,
11+
DropdownMenuSubTrigger,
812
DropdownMenuTrigger,
913
} from "@geolibre/ui";
1014
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";
1328
import { useTranslation } from "react-i18next";
1429
import { googleEarthUrl, googleMapsUrl } from "../../lib/external-map-links";
1530
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";
1642

1743
interface ContextMenuState {
1844
/** Monotonic id so each right-click remounts the menu at the new anchor. */
@@ -81,7 +107,7 @@ export function MapContextMenu({
81107
/** Open a Wikipedia knowledge card for the clicked coordinate. */
82108
onExplorePlace?: (lat: number, lng: number) => void;
83109
}) {
84-
const { t } = useTranslation();
110+
const { i18n, t } = useTranslation();
85111
// `menu` keeps the last anchor/coordinate even while closing, so the exit
86112
// animation plays from the right spot; `open` drives visibility separately.
87113
const [menu, setMenu] = useState<ContextMenuState | null>(null);
@@ -164,65 +190,196 @@ export function MapContextMenu({
164190
void openExternalLink(googleEarthUrl(menu.lat, menu.lng, zoom));
165191
}, [menu, mapControllerRef]);
166192

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+
167281
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")}
202380
</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+
</>
227384
);
228385
}

0 commit comments

Comments
 (0)