Viewshed Calculator
tool
raster
Upload a DEM, place an observer, and see which cells are visible from that point — a colored map overlay and a downloadable GeoTIFF. Runs entirely in your browser, powered by R.
Published
September 7, 2026
Upload a .tif/.tiff elevation model (DEM), click the map (or type coordinates) to place an observer, and see which cells are visible from that point — accounting for terrain occlusion.
Tip
Private by design. Your file is processed entirely in your browser (R via WebAssembly) — it’s never uploaded to any server, including ours. Nothing to configure, nothing to trust: the code never gets the chance to see your data.
uploadControl = WebGeoDS.Upload.createControl({
label: "📁 Upload",
onChange: (files) => { mutable uploadedFiles = files; }
}){
// R-only tool — the first one in this project. rasterio/GDAL have
// no viewshed function usable in Pyodide (verified directly against
// this project: gdal isn't in Pyodide's own curated package index),
// while terra::viewshed() works. Skip writing to Python's
// filesystem entirely, same reasoning every Python-only tool here
// already uses in reverse.
mutable uploadBusy = true;
mutable uploadStatus = "⌛ Uploading...";
try {
const result = await WebGeoDS.Upload.load(uploadedFiles, { languages: ["r"] });
if (result.ok) {
mutable uploadStatus = "⌛ Inspecting...";
await autoInspect();
mutable uploadStatus = result.message;
} else {
mutable uploadStatus = result.message;
}
} finally {
mutable uploadBusy = false;
}
}uploadStatusEl = {
const span = document.createElement("span");
span.className = "webgeods-panel-status" + (uploadBusy ? " webgeods-btn-loading" : "");
span.textContent = uploadStatus;
return span;
}sharedMap = {
const map = new window.WebGeoDS.Map({ center: [12.45, 41.9], zoom: 4, height: "480px" });
await map.ready();
window.WebGeoDS.track?.("tool_loaded", { tool: "viewshed-calculator" });
return map;
}The code itself isn’t shown here — it’s a fixed, non-editable computation using terra::viewshed() underneath. Want to see it built up step by step (and read why this tool runs on R, not Python)? Read Viewshed: What’s Visible from Here.
// "Load example" writes a small synthetic DEM directly — a Gaussian
// hill, same shape reused across the whole Raster family, but here
// deliberately in a GEOGRAPHIC CRS (EPSG:4326), the case real-world
// DEMs (SRTM and similar) commonly ship in and the one
// terra::viewshed() itself refuses to compute on directly — so the
// example exercises the tool's own automatic reprojection path, not
// just the easy case.
generateExample = async () => {
await window.WebGeoDS.CodeCell.find("viewshed-generate-example-r").run();
}loadExampleButton = {
const button = document.createElement("button");
button.className = "webgeods-panel-btn";
button.dataset.variant = "outline";
button.textContent = "📋 Load example";
button.onclick = async () => {
mutable uploadBusy = true;
mutable uploadStatus = "⌛ Loading example...";
try {
await generateExample();
mutable uploadStatus = "⌛ Inspecting...";
await autoInspect();
mutable uploadStatus = "✓ example DEM loaded — click the map or enter coordinates, then Compute.";
} finally {
mutable uploadBusy = false;
}
};
return button;
}FOOTPRINT_PAINT = ({
"fill-color": "#42583c",
"fill-opacity": 0.35,
"fill-outline-color": "#2a2117"
})rInspect = WebGeoDS.getCellValue("viewshed-inspect-r", Generators)
{
if (rInspect && rInspect.summary) {
await sharedMap.setGeoJSON("viewshed-footprint", rInspect.footprint, { paint: FOOTPRINT_PAINT });
await sharedMap.fitToData(rInspect.footprint);
mutable inspectSummary = rInspect.summary;
// A fresh upload/example invalidates any previously computed
// result — same reasoning as clicking Reset.
await sharedMap.removeRasterImage("viewshed-result");
mutable resultSummary = null;
}
}// base64 -> raw bytes, same pattern as the rest of the site (binary
// data can only cross the webR->JS bridge as a cell value via a text
// encoding).
base64ToBytes = (base64Str) => {
const binary = atob(base64Str);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}// Colored overlay for a boolean result -- not a gradient like Band
// Math/NDVI. Both ramp stops are the same warm color: any finite
// value (only ever 1, "visible") paints solid, NaN ("not visible")
// stays transparent via _renderRasterCanvas's existing NaN handling.
VIEWSHED_RAMP = [
[0.00, 255, 213, 79],
[1.00, 255, 213, 79]
]// Static: unlike the band pickers on Calculator/NDVI, nothing here
// depends on the uploaded file's shape, so this cell (and the DOM
// nodes it creates) never needs to be rebuilt — map-click wiring
// below can bind to it once.
observerRow = {
const row = document.createElement("div");
row.className = "webgeods-panel-row";
const makeNumberInput = (id, defaultValue, step, width) => {
const input = document.createElement("input");
input.type = "number";
input.id = id;
input.step = String(step);
input.value = String(defaultValue);
input.className = "webgeods-panel-status";
input.style.width = width;
return input;
};
const label = (text) => {
const span = document.createElement("span");
span.className = "webgeods-panel-status";
span.textContent = text;
return span;
};
const lonInput = makeNumberInput("observer-lon-input", 12.45, 0.0001, "95px");
const latInput = makeNumberInput("observer-lat-input", 41.90, 0.0001, "95px");
const heightInput = makeNumberInput("observer-height-input", 1.8, 0.1, "60px");
const computeButton = document.createElement("button");
computeButton.className = "webgeods-panel-btn";
computeButton.textContent = "▶ Compute";
computeButton.onclick = async () => {
if (!inspectSummary) {
mutable uploadStatus = "⚠️ Upload a DEM or load the example first.";
return;
}
computeButton.disabled = true;
const originalText = computeButton.textContent;
computeButton.textContent = "⌛ Computing...";
try {
const obsLon = Number(lonInput.value);
const obsLat = Number(latInput.value);
const obsHeight = Number(heightInput.value);
window.obsLon = obsLon;
window.obsLat = obsLat;
window.obsHeight = obsHeight;
await window.WebGeoDS.CodeCell.find("viewshed-r").run();
// The cell's own return value is { summary, footprint } (same
// shape as the inspect cell, unlike Calculator/NDVI's flat
// compute-cell output) -- summary holds the fields used here.
const result = document.getElementById("viewshed-r").value.summary;
await sharedMap.setRasterImage("viewshed-result", {
bounds: result.wgs84Bounds,
width: result.width,
height: result.height,
values: new Float32Array(base64ToBytes(result.data).buffer),
min: 0,
max: 1,
colorRamp: VIEWSHED_RAMP
});
mutable resultSummary = { ...result, obsLon, obsLat };
window.WebGeoDS.track?.("validation_completed", { tool: "viewshed-calculator" });
} catch (err) {
console.error("viewshed-calculator: Compute failed", err);
mutable uploadStatus = `⚠️ Compute failed: ${err.message}`;
} finally {
computeButton.disabled = false;
computeButton.textContent = originalText;
}
};
row.append(
label("Observer lon:"), lonInput,
label("lat:"), latInput,
label("height (m):"), heightInput,
label("— or click the map"),
computeButton
);
return row;
}// Wires the map click -> observer inputs once. sharedMap.map is
// MapLibre's own instance (already used directly elsewhere in this
// session for source lookups) -- no new shared/map.js method needed,
// same "JS decides the interaction, the engine only computes"
// architecture as everywhere else on this site.
mapClickWiring = {
sharedMap.map.on("click", (e) => {
const lonInput = document.getElementById("observer-lon-input");
const latInput = document.getElementById("observer-lat-input");
if (lonInput) lonInput.value = e.lngLat.lng.toFixed(6);
if (latInput) latInput.value = e.lngLat.lat.toFixed(6);
});
return true;
}downloadButton = {
const button = document.createElement("button");
button.className = "webgeods-panel-btn";
button.dataset.variant = "outline";
button.textContent = "⬇ Download (calculation CRS)";
button.disabled = !resultSummary;
button.onclick = async () => {
if (!resultSummary) return;
button.disabled = true;
const originalText = button.textContent;
button.textContent = "⌛ Preparing...";
try {
window.obsLon = resultSummary.obsLon;
window.obsLat = resultSummary.obsLat;
window.obsHeight = resultSummary.observerHeight;
await window.WebGeoDS.CodeCell.find("viewshed-export-r").run();
const b64 = document.getElementById("viewshed-export-r").value;
const bytes = base64ToBytes(b64);
const base = WebGeoDS.Upload.baseName(uploadedFiles);
WebGeoDS.downloadBlob(
bytes,
base ? `${base}-viewshed.tif` : "viewshed.tif",
"image/tiff",
{ tool: "viewshed-calculator" }
);
} finally {
button.disabled = false;
button.textContent = originalText;
}
};
return button;
}resetCalculator = async () => {
await sharedMap.removeRasterImage("viewshed-result");
if (sharedMap.getGeoJSON("viewshed-footprint")) {
await sharedMap.setGeoJSON("viewshed-footprint", { type: "FeatureCollection", features: [] });
}
mutable inspectSummary = null;
mutable resultSummary = null;
}resetMapButton = {
const button = document.createElement("button");
button.className = "webgeods-panel-btn";
button.dataset.variant = "outline";
button.textContent = "🔄 Reset";
button.onclick = () => resetCalculator();
return button;
}controlPanelRow = {
const row = document.createElement("div");
row.className = "webgeods-panel-row";
row.append(uploadControl, loadExampleButton, downloadButton, resetMapButton);
return row;
}First run loads the R engine — expect 1-3 minutes (R’s WebAssembly build is noticeably slower to start than this site’s Python tools). Instant after that, and again each time you come back to this page.
statCard = {
const s = inspectSummary;
const r = resultSummary;
const box = document.createElement("div");
box.className = "webgeods-stat-grid";
const row = (label, value) => {
const dt = document.createElement("div");
dt.className = "webgeods-stat-label";
dt.textContent = label;
const dd = document.createElement("div");
dd.className = "webgeods-stat-value";
dd.textContent = value;
box.append(dt, dd);
};
if (!s) {
row("Dimensions", "—");
row("—", "Upload a .tif/.tiff DEM or load the example above");
return box;
}
row("Dimensions", `${s.width} × ${s.height} px`);
row("Original CRS", s.crs === null || s.crs === undefined ? "Unknown ⚠️ viewshed needs a real-world location" : s.crs);
row("Bounds", s.bounds.join(", "));
if (r) {
row("Calculation CRS", r.calculationCrs);
row("Observer height", `${r.observerHeight} m`);
row("Visible pixels", r.visiblePixels.toLocaleString());
row("Total pixels", r.totalPixels.toLocaleString());
row("Visible %", `${r.visiblePercent}%`);
}
return box;
}
legendEl = {
const r = resultSummary;
const wrap = document.createElement("div");
wrap.className = "webgeods-legend";
if (!r) {
wrap.hidden = true;
return wrap;
}
const swatch = document.createElement("div");
swatch.className = "webgeods-legend-bar";
swatch.style.flex = "0 0 32px";
swatch.style.background = "rgb(255,213,79)";
const label = document.createElement("span");
label.className = "webgeods-legend-label";
label.textContent = "Visible from observer";
wrap.append(swatch, label);
return wrap;
}
Fourth and last tool in the Raster family. Read the companion article for the same computation built up one cell at a time — including why this is the one tool on this site that runs on R, with Python shown only as a reference (not runnable here).