webgeods webGeoDs
  • Home
  • Tools
    • All tools

    • Inspect
    • Validate
    • Check Topology
    • CRS
    • Buffer

    • Inspect
    • Calculate
    • NDVI
    • Viewshed

    • Cluster
  • Articles
    • All articles

    • Inspect
    • Validate
    • Check Topology
    • CRS
    • Buffer

    • Inspect
    • Calculate
    • NDVI
    • Viewshed

    • Cluster
  • About

Buffer & Proximity Tool

tool
vector
Upload points, lines, or polygons and generate a buffer at any distance — as a single zone or several concentric rings — see what’s within reach of each feature, optionally dissolved into one shape, colored on the map, downloadable. Runs entirely in your browser, powered by Python.
Published

September 11, 2026

Upload a point, line, or polygon file, set a distance, and see the area within that reach of every feature — a buffer. Works the same way whether you’re asking “what’s within 300m of this store” or “what falls within 50m of this road.” Set more than one ring to break that reach into distance bands instead of one zone — “within 100m”, “100–300m”, “300–500m” — each shown as its own shade on the map.

Tip

Private by design. Your file is processed entirely in your browser (Python 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; },
  kind: "vector"
})
{
  // Python-only tool (see the article for the same computation shown
  // in R too): skip writing to R's filesystem entirely.
  mutable uploadBusy = true;
  mutable uploadStatus = "⌛ Uploading...";
  try {
    const result = await WebGeoDS.Upload.load(uploadedFiles, { languages: ["python"] });
    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: "buffer-proximity" });
  return map;
}

The code itself isn’t shown here — it’s a fixed, non-editable computation using GeoPandas’ .buffer() underneath. Want to see it built up step by step, in Python and R? Read Buffer & Proximity: How Far Is Within Reach.

autoInspect = async () => {
  await window.WebGeoDS.CodeCell.find("buffer-inspect-py").run();
}
// Deterministic synthetic dataset -- five store locations near Rome,
// fixed coordinates rather than generated at runtime, same reasoning
// as every other tool's own example (reviewable, reproducible, no
// randomness to explain).
EXAMPLE_POINTS = [
  [12.4964, 41.9028, "Store — Centro"],
  [12.5157, 41.8986, "Store — Colosseo"],
  [12.4733, 41.8896, "Store — Trastevere"],
  [12.4614, 41.9109, "Store — Prati"],
  [12.5389, 41.9095, "Store — Pigneto"]
]
EXAMPLE_GEOJSON = JSON.stringify({
  type: "FeatureCollection",
  features: EXAMPLE_POINTS.map(([lon, lat, name]) => ({
    type: "Feature",
    properties: { name },
    geometry: { type: "Point", coordinates: [lon, lat] }
  }))
})
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 {
      const bytes = new TextEncoder().encode(EXAMPLE_GEOJSON);
      await window.WebGeoDS.Python.writeFile("/uploaded.geojson", bytes);
      mutable uploadStatus = "⌛ Inspecting...";
      await autoInspect();
      mutable uploadStatus = "✓ example data loaded — set a distance and Compute below.";
    } finally {
      mutable uploadBusy = false;
    }
  };
  return button;
}
mutable inspectSummary = null
mutable resultSummary = null
mutable exportFeatures = null
mutable crsWarning = null
pyInspect = WebGeoDS.getCellValue("buffer-inspect-py", Generators)
{
  if (pyInspect) {
    // No paint override -- default styling is exactly right for
    // "uploaded, not yet buffered". The buffer's own color only
    // appears once Compute has actually run.
    await sharedMap.setGeoJSON("buffer-original-py", pyInspect.features);
    await sharedMap.fitToData(pyInspect.features);
    mutable inspectSummary = pyInspect.summary;
    mutable crsWarning = pyInspect.crsWarning;
    // A fresh upload/example invalidates any previously computed
    // buffer -- same reasoning as clicking Reset.
    mutable resultSummary = null;
    mutable exportFeatures = null;
    await sharedMap.removeGeoJSON("buffer-result-py");
  }
}
// A function, not a static constant: the number of rings is chosen
// per run. Opacity fades from 0.6 (closest ring) down to ~0.15
// (farthest) via a match expression on each feature's own "ring"
// property -- same "match" mechanism as Spatial Clustering's cluster
// colors, applied to opacity instead of hue since these are ordered
// distance bands, not unrelated categories. ringCount=1 resolves to
// the single 0.35 opacity this tool always used before rings existed.
ringPaint = (ringCount) => {
  const opacityExpr = ["match", ["get", "ring"]];
  for (let i = 1; i <= ringCount; i++) {
    const opacity = ringCount === 1 ? 0.35 : 0.6 - (i - 1) * (0.45 / (ringCount - 1));
    opacityExpr.push(i, Number(opacity.toFixed(2)));
  }
  opacityExpr.push(0.35);
  return {
    "fill-color": "#42583c",
    "fill-opacity": opacityExpr,
    "fill-outline-color": "#2a2117"
  };
}
// The distance slider + dissolve checkbox + Compute button, rebuilt
// whenever inspectSummary changes -- same "disabled with an
// explanatory message until there's data" pattern as Raster
// Calculator's own band-math row.
bufferControlsRow = {
  const s = inspectSummary;
  const row = document.createElement("div");
  row.className = "webgeods-panel-row";

  if (!s) {
    const status = document.createElement("span");
    status.className = "webgeods-panel-status";
    status.textContent = "Upload a file or load the example to set a distance.";
    row.append(status);
    return row;
  }

  const slider = WebGeoDS.createSlider([10, 2000], {
    value: 100,
    step: 10,
    label: "Buffer distance (m)",
    id: "buffer-distance-control"
  });

  const ringSlider = WebGeoDS.createSlider([1, 5], {
    value: 1,
    step: 1,
    label: "Rings",
    id: "ring-count-control"
  });

  const dissolveLabel = document.createElement("label");
  dissolveLabel.className = "webgeods-panel-status";
  const dissolveCheckbox = document.createElement("input");
  dissolveCheckbox.type = "checkbox";
  dissolveCheckbox.id = "dissolve-buffers-control";
  dissolveLabel.append(dissolveCheckbox, " Dissolve overlapping buffers");

  const computeButton = document.createElement("button");
  computeButton.className = "webgeods-panel-btn";
  computeButton.textContent = "▶ Compute";
  computeButton.onclick = async () => {
    computeButton.disabled = true;
    const originalText = computeButton.textContent;
    computeButton.textContent = "⌛ Computing...";
    try {
      const bufferDistance = Number(document.getElementById("buffer-distance-control").value);
      const ringCount = Number(document.getElementById("ring-count-control").value);
      const dissolveBuffers = document.getElementById("dissolve-buffers-control").checked;
      window.bufferDistance = bufferDistance;
      window.ringCount = ringCount;
      window.dissolveBuffers = dissolveBuffers;
      await window.WebGeoDS.CodeCell.find("buffer-compute-py").run();
      const result = document.getElementById("buffer-compute-py").value;
      await sharedMap.setGeoJSON("buffer-result-py", result.features, { paint: ringPaint(ringCount) });
      await sharedMap.fitToData(result.features);
      mutable exportFeatures = result.originalCrsFeatures;
      mutable resultSummary = { ...result.summary, bufferDistance, ringCount, dissolveBuffers };
      window.WebGeoDS.track?.("validation_completed", { tool: "buffer-proximity" });
    } catch (err) {
      console.error("buffer-proximity: Compute failed", err);
      mutable uploadStatus = `⚠️ Compute failed: ${err.message}`;
    } finally {
      computeButton.disabled = false;
      computeButton.textContent = originalText;
    }
  };

  row.append(slider, ringSlider, dissolveLabel, computeButton);
  return row;
}
resetBuffer = async () => {
  if (sharedMap.getGeoJSON("buffer-original-py")) {
    await sharedMap.setGeoJSON("buffer-original-py", { type: "FeatureCollection", features: [] });
  }
  await sharedMap.removeGeoJSON("buffer-result-py").catch(() => {});
  mutable inspectSummary = null;
  mutable resultSummary = null;
  mutable exportFeatures = null;
  mutable crsWarning = null;
}
resetMapButton = {
  const button = document.createElement("button");
  button.className = "webgeods-panel-btn";
  button.dataset.variant = "outline";
  button.textContent = "🔄 Reset";
  button.onclick = () => resetBuffer();
  return button;
}
// Gated on resultSummary (a completed Compute), not just an upload --
// downloading an unbuffered file wouldn't be this tool's own output.
downloadButton = {
  const button = document.createElement("button");
  button.className = "webgeods-panel-btn";
  button.dataset.variant = "outline";
  button.textContent = "⬇ Download";
  button.disabled = !resultSummary;
  button.onclick = async () => {
    if (!exportFeatures) return;
    button.disabled = true;
    const originalText = button.textContent;
    button.textContent = "⌛ Preparing...";
    try {
      const base = WebGeoDS.Upload.baseName(uploadedFiles);
      WebGeoDS.downloadBlob(
        JSON.stringify(exportFeatures, null, 2),
        base ? `${base}-buffered.geojson` : "buffered.geojson",
        "application/geo+json",
        { tool: "buffer-proximity" }
      );
    } finally {
      button.disabled = false;
      button.textContent = originalText;
    }
  };
  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 Python engine — timing varies by device, watch the counter below the code once it starts.

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("Features", "—");
    row("—", "Upload a point/line/polygon file or load the example above");
    return box;
  }

  row("Features", s.count.toLocaleString());
  row("Geometry", s.geometryTypes.join(", "));
  row("Original CRS", s.crs === "None" || !s.crs ? "Unknown" : s.crs);

  if (r) {
    row("Calculation CRS", r.calculationCrs);
    row("Buffer distance", `${r.bufferDistance} m`);
    row("Rings", r.ringCount === 1 ? "1 (no ring split)" : `${r.ringCount} (at ${r.ringDistances.join(", ")} m)`);
    row("Dissolved", r.dissolveBuffers ? "Yes" : "No");
    row("Buffer features", r.bufferCount);
  }

  return box;
}

legendEl = {
  const r = resultSummary;
  const wrap = document.createElement("div");
  wrap.className = "webgeods-legend";
  if (!r) {
    wrap.hidden = true;
    return wrap;
  }
  wrap.style.flexWrap = "wrap";
  wrap.style.rowGap = "8px";

  const addSwatch = (opacity, text) => {
    const swatch = document.createElement("div");
    swatch.className = "webgeods-legend-bar";
    swatch.style.flex = "0 0 32px";
    swatch.style.background = `rgba(66,88,60,${opacity})`;
    const label = document.createElement("span");
    label.className = "webgeods-legend-label";
    label.textContent = text;
    wrap.append(swatch, label);
  };

  if (r.ringCount === 1) {
    addSwatch(0.6, `Buffer (${r.bufferDistance} m)`);
    return wrap;
  }

  let previousDistance = 0;
  r.ringDistances.forEach((distance, i) => {
    const opacity = 0.6 - i * (0.45 / (r.ringCount - 1));
    addSwatch(opacity.toFixed(2), `${previousDistance}–${distance} m`);
    previousDistance = distance;
  });
  return wrap;
}

sharedMapEl = sharedMap.element
bufferTable = sharedMap.tableCell(["buffer-result-py"], null, Generators, {
  emptyMessage: "No results yet"
})

First tool in the Vector family’s newest addition. Want to see the code, or compare against R’s independent sf::st_buffer() implementation? Read the full article.