Skip to content

Commit 99a57d1

Browse files
authored
Add an interactive viewshed from a clicked map point (#1815) (#1826)
* Add an interactive viewshed from a clicked map point (#1815) Right-click the map -> Viewshed from here, at 2/5/15 km. No DEM to find, download or load first: the terrain the map is already rendering is the input. Not routed through the Whitebox viewshed, despite the issue suggesting it. That tool consumes a DEM raster file *and* a station-point vector file and emits a raster file, so from a clicked point it is three format round-trips before any analysis runs -- and it still requires the user to have a DEM in hand, which is the data-prep step the issue is about removing. The line-of-sight is ~60 lines on a grid we already hold in memory, and computing it directly gives exact control over observer height and radius, both of which the issue calls for. The Whitebox tool is untouched and remains the rigorous DEM-in-hand option. Two halves, deliberately separable so the geometry is testable without a network: assembleTerrainDem fetches and decodes the Terrarium RGB tiles the terrain control already uses into one elevation grid, and computeViewshed walks a ray to every cell tracking the maximum slope seen so far. Tiles that fail to load leave their cells at 0 rather than aborting: a viewshed with one missing tile is degraded but still useful, while a hard failure makes a transient network error look like a broken feature. The zoom is chosen from the radius to land near 512 cells across, so both the fetch and the O(n^2*sqrt(n)) walk stay interactive, and the radius is clamped to 50 km so one click cannot pull a continent of tiles. The result is an "image" layer -- a translucent wash pinned by corner coordinates -- reusing the overlay path the Raster Georeferencer established, so it gets opacity, ordering, zoom-to and removal for free with no new layer type. A PNG data URL rather than a blob URL, since the layer is saved with the project and a blob URL would be dead on reopen. Earth curvature and refraction are not modelled; documented on the function, since at the 50 km cap the curvature drop reaches ~180 m and that matters for a radio study even though it does not for "what can I see from this overlook". * Address review feedback on the interactive viewshed - decodeTile now reports the decoded tile's dimensions, and a tile served at a size other than the mosaic assumes is skipped rather than copied at the wrong stride, which would have sheared the elevations. - Tile fetches carry a 15s timeout. A hung request would otherwise stall the whole assembly behind Promise.all with no recovery, since fetch has none by default. Composed with the caller's signal via AbortSignal.any where present. - Reject out-of-range positions up front instead of leaving them to fail as 404s partway through: beyond ~85 degrees the tile grid is undefined, and a square straddling the antimeridian produces a negative tile x. - Remove the dead visibleCells === 0 branch. computeViewshed always marks the observer's own cell, so the count is at least 1 by construction. - The radius labels went through hardcoded "km"/"m"; they now format for the active locale and take the unit from the catalog, matching how formatBufferDistance labels the buffer presets in the same menu. - Export AssembleTerrainDemOptions alongside the function it parameterizes. * Clamp the viewshed radius on both bounds run-viewshed clamped only the ceiling, but assembleTerrainDem floors to MIN_VIEWSHED_RADIUS_METERS internally. A radius below the floor would therefore build the DEM at the floor while computeViewshed culled against the smaller requested value, so the analysed square and the visibility limit disagreed. Not reachable from the menu presets, but the runner is exported. * Reuse the existing unit catalog for the viewshed radius labels quickAnalysis.unit.kilometers/meters already existed and are what formatBufferDistance uses a few lines below, so the new unitKilometers/ unitMeters keys were 36 redundant strings across 18 catalogs. Removed them and switched to the existing lookup. Also restored alphabetical order in the icon import. * Address fourth review round on the viewshed - Set metadata.bounds on the result layer. fitLayer resolves an extent from geojson, then source/metadata bounds, then the live source's bounds -- an ImageSource exposes only coordinates, so Zoom to layer silently did nothing, contradicting this PR's claim that the image-layer path gets it for free. - Report through the Quick Analysis banner instead of swallowing failures. The terrain fetch takes seconds and can fail; a click with no feedback either way reads as a broken menu item. setQuickAnalysisStatus is exported for actions that are not registry tools. - Correct the complexity claim: the walk is O(n^3) in the grid's side length, not O(n^2*sqrt(n)). - Icon import: Eye after Earth, which is where alphabetical order puts it. * Move the viewshed off the main thread, and guard its banner writes - computeViewshedAsync runs the line-of-sight walk on a one-shot Web Worker, falling back to this thread where Workers do not exist (SSR, node:test, a locked-down webview). The walk is O(n^3) in the grid's side length with no yield points, so running it inline froze the tab for its whole duration -- several seconds at the 15km preset on modest hardware, which reads worse than a network wait because nothing repaints. The DEM and the visibility grid are plain typed arrays, so the round trip is one clone in and one transfer out. Mirrors runToolOnWorker in wasm-convert.ts: one job per worker, terminated on the terminal message, no timeout. - The viewshed's banner writes now go through the same latestRun guard runQuickAnalysis uses. setQuickAnalysisStatus let a slow viewshed resolving after a faster action clobber that action's status -- exactly the "resurrect a banner for a run the user has moved on from" case the counter exists to prevent. Replaced with beginQuickAnalysisRun, which claims the run and returns a setter bound to it. * Fix the viewshed always reporting "no visible area" decodeTile read bitmap.width/height *after* bitmap.close(), which zeroes them. Every tile therefore decoded to a 0x0 grid, which the assembly loop then discarded as the wrong size -- so `loaded` stayed 0, assembleTerrainDem returned null, and every click reported "No visible area could be computed here." Self-inflicted in 58be6da: that commit added the tile-dimension check a reviewer asked for, and read the real dimensions from the bitmap to do it, without noticing the read now happened after the close. The unit tests never caught it because they stub the image and never exercise decodeTile. Rather than just reordering the two lines, the RGBA-to-elevation step moves into decodeTerrariumRgba, which takes width and height as arguments. A caller can no longer size the output from an image it has already closed, so the ordering hazard cannot come back. Verified end to end in a browser against real Terrarium tiles: a 2km request over Zion now assembles a 263x263 DEM with an observer ground elevation of 1240m, matching the visitor center. * Address review feedback - Frame the result after adding it, as every sibling quick action does. At the larger radii the square can fall outside the viewport even though the clicked point was on screen, so the banner flashed and cleared with nothing visible -- which reads as "nothing happened", worse than an error. - Build the layer through the store's addImageOverlayLayer instead of hand-rolling one. That action owns id generation, the default style, source.type and the metadata merge, and is what the KML ground-overlay and Georeferencer paths already use. - Derive the worker's success payload from ViewshedResult rather than restating its fields, so a field added there cannot be dropped by the rest-spread in computeViewshedAsync without a type error. - Interpolate the crop's rows in Web Mercator Y. Tile rows are uniform in mercator space, not latitude, so interpolating in latitude skewed the crop north-south by an amount that grows with latitude. The residual approximation -- a single cell height for the whole grid -- is now documented alongside the curvature note. - Label the radii in the scale bar's unit system, so an imperial-preference user no longer sees miles for buffers and kilometres for viewsheds in one submenu. Memoized with explicit deps like the sibling formatDistance. - Note on beginQuickAnalysisRun that it records no Processing History entry, so the module docstring's claim about every quick action is not read as covering a caller that is not a registry tool.
1 parent 55a1a18 commit 99a57d1

25 files changed

Lines changed: 1218 additions & 37 deletions

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

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useAppStore } from "@geolibre/core";
1+
import { useAppStore, FEET_PER_METER, METERS_PER_MILE } from "@geolibre/core";
22
import type { MapController } from "@geolibre/map";
33
import {
44
DropdownMenu,
@@ -18,6 +18,7 @@ import {
1818
Circle,
1919
Crosshair,
2020
Earth,
21+
Eye,
2122
MapIcon,
2223
MapPin,
2324
Route,
@@ -35,9 +36,11 @@ import {
3536
QUICK_TRAVEL_CONTOURS,
3637
QUICK_TRAVEL_CONTOURS_LABEL,
3738
runQuickAnalysis,
39+
beginQuickAnalysisRun,
3840
type QuickBufferPreset,
3941
} from "../../lib/quick-analysis";
4042
import { hasRoutingConsent, recordRoutingConsent } from "../../lib/routing-consent";
43+
import { runViewshed } from "../../lib/run-viewshed";
4144
import { RoutingConsentDialog } from "./RoutingConsentDialog";
4245

4346
interface ContextMenuState {
@@ -197,11 +200,86 @@ export function MapContextMenu({
197200
const bufferPresets = useMemo(() => bufferPresetsFor(scaleUnit), [scaleUnit]);
198201
const setVectorToolOpen = useAppStore((s) => s.setVectorToolOpen);
199202

203+
/**
204+
* Radius label for the viewshed entries.
205+
*
206+
* Follows the scale bar's unit system like the buffer ladder above, so an
207+
* imperial-preference user does not get miles for buffers and kilometres for
208+
* viewsheds in the same submenu. The radii themselves stay metric constants —
209+
* they size the analysis, not the label — so an imperial reading is a
210+
* conversion of the same distance rather than a different one.
211+
*/
212+
const formatViewshedRadius = useCallback(
213+
(meters: number): string => {
214+
const imperial = scaleUnit === "imperial";
215+
const perUnit = imperial ? METERS_PER_MILE : 1000;
216+
const large = meters >= perUnit;
217+
const value = large ? meters / perUnit : imperial ? meters * FEET_PER_METER : meters;
218+
const formatted = new Intl.NumberFormat(i18n.language, {
219+
maximumFractionDigits: large ? 1 : 0,
220+
}).format(value);
221+
const unit = large
222+
? imperial
223+
? t("quickAnalysis.unit.miles")
224+
: t("quickAnalysis.unit.kilometers")
225+
: imperial
226+
? t("quickAnalysis.unit.feet")
227+
: t("quickAnalysis.unit.meters");
228+
return `${formatted} ${unit}`;
229+
},
230+
[scaleUnit, i18n.language, t],
231+
);
232+
200233
const formatDistance = useCallback(
201234
(preset: QuickBufferPreset) => formatBufferDistance(preset, i18n.language, t),
202235
[i18n.language, t],
203236
);
204237

238+
// Viewshed radii. Small enough that the tile fetch and the line-of-sight walk
239+
// stay interactive; the 50km cap in the processing module is the hard limit.
240+
const VIEWSHED_RADII_METERS = [2000, 5000, 15000];
241+
242+
const [viewshedBusy, setViewshedBusy] = useState(false);
243+
const viewshedHere = useCallback(
244+
(radiusMeters: number) => {
245+
if (!menu || viewshedBusy) return;
246+
const { lng, lat } = menu;
247+
setViewshedBusy(true);
248+
const toolName = t("quickAnalysis.viewshedToolName");
249+
// Reported through the Quick Analysis banner like every other action in
250+
// this menu rather than failing silently: the terrain fetch takes seconds
251+
// and can fail, and a click with no feedback either way reads as a broken
252+
// menu item. The returned setter is bound to this run, so a slow viewshed
253+
// cannot overwrite the status of a faster action started after it.
254+
const reportStatus = beginQuickAnalysisRun(toolName);
255+
void runViewshed({
256+
lng,
257+
lat,
258+
radiusMeters,
259+
mapControllerRef,
260+
layerName: t("quickAnalysis.viewshedLayerName", {
261+
radius: formatViewshedRadius(radiusMeters),
262+
}),
263+
})
264+
.then((result) => {
265+
reportStatus(
266+
result
267+
? { phase: "idle" }
268+
: { phase: "error", toolName, message: t("quickAnalysis.viewshedNoResult") },
269+
);
270+
})
271+
.catch((error: unknown) => {
272+
reportStatus({
273+
phase: "error",
274+
toolName,
275+
message: error instanceof Error ? error.message : t("quickAnalysis.viewshedNoResult"),
276+
});
277+
})
278+
.finally(() => setViewshedBusy(false));
279+
},
280+
[menu, viewshedBusy, t, formatViewshedRadius, mapControllerRef],
281+
);
282+
205283
const bufferHere = useCallback(
206284
(preset: QuickBufferPreset) => {
207285
if (!menu) return;
@@ -361,6 +439,20 @@ export function MapContextMenu({
361439
{t("quickAnalysis.walkTimeHere", { contours: QUICK_TRAVEL_CONTOURS_LABEL })}
362440
</DropdownMenuItem>
363441
<DropdownMenuSeparator />
442+
{VIEWSHED_RADII_METERS.map((radiusMeters) => (
443+
<DropdownMenuItem
444+
key={`viewshed-${radiusMeters}`}
445+
onSelect={() => viewshedHere(radiusMeters)}
446+
disabled={viewshedBusy}
447+
className="gap-2"
448+
>
449+
<Eye className="h-4 w-4 shrink-0 text-muted-foreground" />
450+
{t("quickAnalysis.viewshedHere", {
451+
radius: formatViewshedRadius(radiusMeters),
452+
})}
453+
</DropdownMenuItem>
454+
))}
455+
<DropdownMenuSeparator />
364456
{/* Escape hatch when the presets aren't what was wanted: the full
365457
dialog, preselected on the same tool. */}
366458
<DropdownMenuItem onSelect={() => setVectorToolOpen("buffer")} className="gap-2">

apps/geolibre-desktop/src/i18n/locales/ar.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1900,8 +1900,13 @@
19001900
"unit": {
19011901
"meters": "م",
19021902
"kilometers": "كم",
1903-
"miles": "ميل"
1904-
}
1903+
"miles": "ميل",
1904+
"feet": "قدم"
1905+
},
1906+
"viewshedHere": "مجال الرؤية من هنا ({{radius}})",
1907+
"viewshedLayerName": "مجال الرؤية ({{radius}})",
1908+
"viewshedToolName": "مجال الرؤية",
1909+
"viewshedNoResult": "تعذّر حساب منطقة مرئية هنا."
19051910
},
19061911
"knowledgeCard": {
19071912
"title": "معلومات المكان",

apps/geolibre-desktop/src/i18n/locales/de.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,8 +1729,13 @@
17291729
"unit": {
17301730
"meters": "m",
17311731
"kilometers": "km",
1732-
"miles": "mi"
1733-
}
1732+
"miles": "mi",
1733+
"feet": "ft"
1734+
},
1735+
"viewshedHere": "Sichtfeld von hier ({{radius}})",
1736+
"viewshedLayerName": "Sichtfeld ({{radius}})",
1737+
"viewshedToolName": "Sichtfeld",
1738+
"viewshedNoResult": "Hier konnte kein Sichtbereich berechnet werden."
17341739
},
17351740
"knowledgeCard": {
17361741
"title": "Ortsinformationen",

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,8 +1729,13 @@
17291729
"unit": {
17301730
"meters": "m",
17311731
"kilometers": "km",
1732-
"miles": "mi"
1733-
}
1732+
"miles": "mi",
1733+
"feet": "ft"
1734+
},
1735+
"viewshedHere": "Viewshed from here ({{radius}})",
1736+
"viewshedLayerName": "Viewshed ({{radius}})",
1737+
"viewshedToolName": "Viewshed",
1738+
"viewshedNoResult": "No visible area could be computed here."
17341739
},
17351740
"knowledgeCard": {
17361741
"title": "Place info",

apps/geolibre-desktop/src/i18n/locales/es.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,8 +1729,13 @@
17291729
"unit": {
17301730
"meters": "m",
17311731
"kilometers": "km",
1732-
"miles": "mi"
1733-
}
1732+
"miles": "mi",
1733+
"feet": "ft"
1734+
},
1735+
"viewshedHere": "Cuenca visual desde aquí ({{radius}})",
1736+
"viewshedLayerName": "Cuenca visual ({{radius}})",
1737+
"viewshedToolName": "Cuenca visual",
1738+
"viewshedNoResult": "No se pudo calcular un área visible aquí."
17341739
},
17351740
"knowledgeCard": {
17361741
"title": "Información del lugar",

apps/geolibre-desktop/src/i18n/locales/fa.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,8 +1729,13 @@
17291729
"unit": {
17301730
"meters": "متر",
17311731
"kilometers": "کیلومتر",
1732-
"miles": "مایل"
1733-
}
1732+
"miles": "مایل",
1733+
"feet": "فوت"
1734+
},
1735+
"viewshedHere": "حوزه دید از اینجا ({{radius}})",
1736+
"viewshedLayerName": "حوزه دید ({{radius}})",
1737+
"viewshedToolName": "حوزه دید",
1738+
"viewshedNoResult": "محاسبه ناحیه قابل مشاهده در اینجا ممکن نشد."
17341739
},
17351740
"knowledgeCard": {
17361741
"title": "اطلاعات مکان",

apps/geolibre-desktop/src/i18n/locales/fr.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,8 +1729,13 @@
17291729
"unit": {
17301730
"meters": "m",
17311731
"kilometers": "km",
1732-
"miles": "mi"
1733-
}
1732+
"miles": "mi",
1733+
"feet": "pi"
1734+
},
1735+
"viewshedHere": "Bassin de visibilité d'ici ({{radius}})",
1736+
"viewshedLayerName": "Bassin de visibilité ({{radius}})",
1737+
"viewshedToolName": "Bassin de visibilité",
1738+
"viewshedNoResult": "Aucune zone visible n'a pu être calculée ici."
17341739
},
17351740
"knowledgeCard": {
17361741
"title": "Infos sur le lieu",

apps/geolibre-desktop/src/i18n/locales/hi.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,8 +1729,13 @@
17291729
"unit": {
17301730
"meters": "मी",
17311731
"kilometers": "किमी",
1732-
"miles": "मील"
1733-
}
1732+
"miles": "मील",
1733+
"feet": "फ़ुट"
1734+
},
1735+
"viewshedHere": "यहाँ से दृश्यक्षेत्र ({{radius}})",
1736+
"viewshedLayerName": "दृश्यक्षेत्र ({{radius}})",
1737+
"viewshedToolName": "दृश्यक्षेत्र",
1738+
"viewshedNoResult": "यहाँ कोई दृश्य क्षेत्र नहीं निकाला जा सका।"
17341739
},
17351740
"knowledgeCard": {
17361741
"title": "स्थान जानकारी",

apps/geolibre-desktop/src/i18n/locales/id.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,8 +1686,13 @@
16861686
"unit": {
16871687
"meters": "m",
16881688
"kilometers": "km",
1689-
"miles": "mi"
1690-
}
1689+
"miles": "mi",
1690+
"feet": "kaki"
1691+
},
1692+
"viewshedHere": "Area pandang dari sini ({{radius}})",
1693+
"viewshedLayerName": "Area pandang ({{radius}})",
1694+
"viewshedToolName": "Area pandang",
1695+
"viewshedNoResult": "Tidak ada area terlihat yang dapat dihitung di sini."
16911696
},
16921697
"knowledgeCard": {
16931698
"title": "Info tempat",

apps/geolibre-desktop/src/i18n/locales/it.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,8 +1729,13 @@
17291729
"unit": {
17301730
"meters": "m",
17311731
"kilometers": "km",
1732-
"miles": "mi"
1733-
}
1732+
"miles": "mi",
1733+
"feet": "ft"
1734+
},
1735+
"viewshedHere": "Bacino visivo da qui ({{radius}})",
1736+
"viewshedLayerName": "Bacino visivo ({{radius}})",
1737+
"viewshedToolName": "Bacino visivo",
1738+
"viewshedNoResult": "Non è stato possibile calcolare un'area visibile qui."
17341739
},
17351740
"knowledgeCard": {
17361741
"title": "Informazioni sul luogo",

0 commit comments

Comments
 (0)