webgeods webGeoDs
  • Home
  • Tools
    • All tools

    • Inspect
    • Validate
    • Check Topology
    • CRS

    • Inspect
    • Calculate
    • NDVI
    • Viewshed

    • Cluster
  • Articles
    • All articles

    • Inspect
    • Validate
    • Check Topology
    • CRS

    • Inspect
    • Calculate
    • NDVI
    • Viewshed

    • Cluster
  • About

Spatial Clustering Explorer

tool
geoml
Upload a point dataset and find spatial clusters with DBSCAN — set a distance radius and a minimum group size, see clusters colored on the map, and download the result. Runs entirely in your browser, powered by Python.
Published

September 9, 2026

Upload a point file — .geojson, or a shapefile as a single .zip or as .shp/.dbf/.shx selected together — set a distance radius (ε) and a minimum group size, and find which points cluster together in space. Points that don’t belong to any dense-enough group are marked as noise, not forced into the nearest cluster.

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; }
})
{
  // Python-only tool (see the article for the same computation shown
  // in R too): skip writing to R's filesystem entirely, so selecting
  // a file doesn't also load webR in the background for no reason.
  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: "spatial-clustering-explorer" });
  return map;
}

The code itself isn’t shown here — it’s a fixed, non-editable computation using scikit-learn’s DBSCAN underneath. Want to see it built up step by step, in Python and R? Read Finding Spatial Clusters with DBSCAN.

autoInspect = async () => {
  await window.WebGeoDS.CodeCell.find("spatial-clustering-inspect-py").run();
}
// Deterministic synthetic dataset -- three tight groups (~40-90m
// across) a couple kilometers apart near Rome, plus six scattered
// points kept well outside any group's reach, so the default ε/min
// points below cleanly recover 3 clusters + 6 noise points on first
// try. Fixed coordinates, not generated at runtime -- same reasoning
// as the Validator's own EXAMPLE_GEOJSON: reviewable, reproducible,
// no randomness to explain.
EXAMPLE_POINTS = [
  [12.450066, 41.900016], [12.450332, 41.900159], [12.450202, 41.900152],
  [12.450017, 41.899721], [12.449639, 41.899781], [12.44983, 41.900398],
  [12.450168, 41.900137], [12.44989, 41.899805], [12.449684, 41.90032],
  [12.45028, 41.900272], [12.449961, 41.899825], [12.450273, 41.899636],
  [12.472213, 41.905371], [12.472244, 41.904692], [12.47187, 41.90512],
  [12.471645, 41.904815], [12.472004, 41.905215], [12.471817, 41.905013],
  [12.471602, 41.905062], [12.472044, 41.905121], [12.472162, 41.905283],
  [12.47225, 41.904784], [12.472269, 41.904942], [12.472334, 41.904712],
  [12.455277, 41.884832], [12.454614, 41.884991], [12.454621, 41.884723],
  [12.45535, 41.884793], [12.45486, 41.885091], [12.455187, 41.885068],
  [12.455246, 41.885086], [12.455096, 41.884671], [12.454816, 41.884664],
  [12.45532, 41.885381],
  [12.43, 41.895], [12.49, 41.91], [12.46, 41.87],
  [12.44, 41.92], [12.48, 41.88], [12.42, 41.905]
]
EXAMPLE_GEOJSON = JSON.stringify({
  type: "FeatureCollection",
  features: EXAMPLE_POINTS.map((coord, i) => ({
    type: "Feature",
    properties: { name: `point ${i + 1}` },
    geometry: { type: "Point", coordinates: coord }
  }))
})
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 ε/min points and Compute below.";
    } finally {
      mutable uploadBusy = false;
    }
  };
  return button;
}
mutable inspectSummary = null
mutable resultSummary = null
mutable exportFeatures = null
mutable crsWarning = null
pyInspect = WebGeoDS.getCellValue("spatial-clustering-inspect-py", Generators)
{
  if (pyInspect) {
    // No paint override here -- the default circle style (shared/map.js's
    // _defaultPaint) is exactly right for "uploaded, not yet clustered".
    // Cluster colors only appear once Compute has actually run.
    await sharedMap.setGeoJSON("spatial-clustering-py", pyInspect.features);
    await sharedMap.fitToData(pyInspect.features);
    mutable inspectSummary = pyInspect.summary;
    mutable crsWarning = pyInspect.crsWarning;
    // A fresh upload/example invalidates any previously computed
    // clustering result -- same reasoning as clicking Reset.
    mutable resultSummary = null;
    mutable exportFeatures = null;
  }
}
// Warm-palette hues from shared/_brand.yml (terracotta, muschio, info,
// attenzione, errore) -- the ones this palette actually has besides
// its neutral ink/paper tones. Cycles if there are more than 5
// clusters; noise always gets the same muted neutral (etichetta),
// never one of the cluster hues.
CLUSTER_PALETTE = ["#ab502b", "#42583c", "#3d5a73", "#c48a2e", "#8b2f24"]
NOISE_COLOR = "#766851"
// A function, not a static constant: the number of branches depends
// on how many clusters DBSCAN actually found in this run -- same
// pattern as topology-checker.qmd's errorPaint.
clusterPaint = (clusterIds) => {
  const expr = ["match", ["get", "cluster"]];
  clusterIds.forEach((id, i) => {
    expr.push(id, CLUSTER_PALETTE[i % CLUSTER_PALETTE.length]);
  });
  expr.push(NOISE_COLOR);
  return {
    "circle-color": expr,
    "circle-radius": 6,
    "circle-stroke-width": 1,
    "circle-stroke-color": "#2a2117"
  };
}
// The ε/min-points inputs + 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.
clusterControlsRow = {
  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 point file or load the example to set parameters.";
    row.append(status);
    return row;
  }

  const label = (text) => {
    const span = document.createElement("span");
    span.className = "webgeods-panel-status";
    span.textContent = text;
    return span;
  };

  const makeNumberInput = (id, defaultValue, step, min, width) => {
    const input = document.createElement("input");
    input.type = "number";
    input.id = id;
    input.step = String(step);
    input.min = String(min);
    input.value = String(defaultValue);
    input.className = "webgeods-panel-status";
    input.style.width = width;
    return input;
  };

  const epsInput = makeNumberInput("eps-input", 100, 10, 1, "70px");
  const minPtsInput = makeNumberInput("min-pts-input", 5, 1, 1, "50px");

  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 = "⌛ Clustering...";
    try {
      const epsMeters = Number(epsInput.value);
      const minPts = Number(minPtsInput.value);
      window.epsMeters = epsMeters;
      window.minPts = minPts;
      await window.WebGeoDS.CodeCell.find("spatial-clustering-compute-py").run();
      const result = document.getElementById("spatial-clustering-compute-py").value;
      await sharedMap.setGeoJSON("spatial-clustering-py", result.features, {
        paint: clusterPaint(result.summary.clusterIds)
      });
      mutable exportFeatures = result.originalCrsFeatures;
      mutable resultSummary = { ...result.summary, epsMeters, minPts };
      window.WebGeoDS.track?.("validation_completed", { tool: "spatial-clustering-explorer" });
    } catch (err) {
      console.error("spatial-clustering-explorer: Compute failed", err);
      mutable uploadStatus = `⚠️ Compute failed: ${err.message}`;
    } finally {
      computeButton.disabled = false;
      computeButton.textContent = originalText;
    }
  };

  row.append(
    label("ε (eps, meters):"), epsInput,
    label("min points:"), minPtsInput,
    computeButton
  );
  return row;
}
resetExplorer = async () => {
  if (sharedMap.getGeoJSON("spatial-clustering-py")) {
    await sharedMap.setGeoJSON("spatial-clustering-py", { type: "FeatureCollection", features: [] });
  }
  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 = () => resetExplorer();
  return button;
}
// Gated on resultSummary (a completed Compute), not just an upload --
// downloading an unclustered 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}-clustered.geojson` : "clustered.geojson",
        "application/geo+json",
        { tool: "spatial-clustering-explorer" }
      );
    } 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 — expect 30-40 seconds. 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("Points", "—");
    row("—", "Upload a point GeoJSON/Shapefile or load the example above");
    return box;
  }

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

  if (r) {
    row("Calculation CRS", r.calculationCrs);
    row("ε (eps)", `${r.epsMeters} m`);
    row("Min points", r.minPts);
    row("Clusters found", r.clusterCount);
    row("Noise points", r.noiseCount);
  }

  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 = (color, text) => {
    const swatch = document.createElement("div");
    swatch.className = "webgeods-legend-bar";
    swatch.style.flex = "0 0 24px";
    swatch.style.background = color;
    const label = document.createElement("span");
    label.className = "webgeods-legend-label";
    label.textContent = text;
    wrap.append(swatch, label);
  };

  r.clusterIds.forEach((id, i) => {
    addSwatch(CLUSTER_PALETTE[i % CLUSTER_PALETTE.length], `Cluster ${id}`);
  });
  if (r.noiseCount > 0) addSwatch(NOISE_COLOR, "Noise");

  return wrap;
}

sharedMapEl = sharedMap.element
clusterTable = sharedMap.tableCell(["spatial-clustering-py"], null, Generators, {
  emptyMessage: "No results yet"
})

First tool in the GeoML family. Want to understand why noise points aren’t forced into a cluster, see the actual code, or compare against R’s independent dbscan implementation (and the label-numbering difference between the two)? Read the full article.