webgeods webGeoDs
  • Home
  • Tools
    • All tools

    • Inspect
    • Validate
    • Check Topology
    • CRS

    • Inspect
    • Calculate
    • NDVI
    • Viewshed
  • Articles
    • All articles

    • Inspect
    • Validate
    • Check Topology
    • CRS

    • Inspect
    • Calculate
    • NDVI
    • Viewshed
  • About

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.

mutable uploadStatus = WebGeoDS.Upload.defaultStatus
mutable uploadBusy = false
mutable uploadedFiles = null
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;
}
autoInspect = async () => {
  await window.WebGeoDS.CodeCell.find("viewshed-inspect-r").run();
}
FOOTPRINT_PAINT = ({
  "fill-color": "#42583c",
  "fill-opacity": 0.35,
  "fill-outline-color": "#2a2117"
})
mutable inspectSummary = null
mutable resultSummary = null
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;
}

sharedMapEl = sharedMap.element

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).