webgeods
  • Home
  • Tools
    • All tools

    • Inspect
    • Validate
    • Check Topology
  • Articles
    • All articles

    • Inspect
    • Validate
    • Check Topology
  • About

GeoSpatial File Inspector

tool
geometry
Upload a GeoJSON or a Shapefile and get an instant summary — feature count, geometry type, CRS, bounds, attributes, invalid/empty/duplicate geometries. Free, runs entirely in your browser: the file never leaves your computer.
Published

September 5, 2026

Upload a file — .geojson, or a shapefile as a single .zip or as .shp/.dbf/.shx selected together — and get an instant summary of what’s actually in it, before you dig into any one check.

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
// Pulses the status text while true — set around both the actual
// file write AND the automatic inspection that follows it, same
// pattern as every other tool on this site.
mutable uploadBusy = false
// The current file selection — a plain `mutable`, written by
// uploadControl's onChange below. See WebGeoDS.Upload.createControl's
// doc comment in shared/upload.js for why this is a native
// <input type="file"> instead of a `viewof`-bound Observable Inputs
// widget.
mutable uploadedFiles = null
// The upload control itself — a native <input type="file"> wrapped
// in a <label> styled as a .webgeods-panel-btn. output:false + only
// ever consumed via controlPanelRow's row.append() below (same
// reasoning as loadExampleButton/downloadButton there): folding it
// into that one JS-built row, instead of giving it its own
// `${uploadControl}` markdown interpolation, sidesteps a real bug
// found on geojson-shapefile-validator.qmd — two `${...}`
// interpolations on separate paragraphs each get wrapped in their own
// <p> by Pandoc, and each <p> becomes an independent flex item,
// breaking vertical alignment between them.
uploadControl = WebGeoDS.Upload.createControl({
  label: "📁 Upload",
  onChange: (files) => { mutable uploadedFiles = files; }
})
{
  // Python-only tool: skip writing to R's filesystem entirely, so
  // selecting a file doesn't also load webR in the background for no
  // reason (see shared/upload.js's `languages` option doc).
  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;
  }
}
// Only ever shown via ${uploadStatusEl} inside the panel — see
// loadExampleButton's comment for why this needs output:false too.
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.5, 41.9], zoom: 4, height: "480px" });
  await map.ready();
  window.WebGeoDS.track?.("tool_loaded", { tool: "geospatial-file-inspector" });
  return map;
}

The code itself isn’t shown here — it’s a fixed, non-editable summary using GeoPandas/Shapely underneath; see the full article for the actual code, editable and explained one stat at a time.

// No fallback on page load anymore (an earlier version of this
// pattern — see geojson-shapefile-validator.qmd's own history —
// diagnosed hardcoded data automatically) — the page starts genuinely
// empty until the user either uploads a file or clicks "Load
// example" below. A small, deliberately imperfect dataset: one exact
// duplicate (b2), one invalid geometry (bowtie), so every stat in the
// summary below has something real to show.
EXAMPLE_GEOJSON = JSON.stringify({
  type: "FeatureCollection",
  features: [
    { type: "Feature", properties: { name: "square-1", population: 1200 }, geometry: { type: "Polygon", coordinates: [[[12.40, 41.80], [12.50, 41.80], [12.50, 41.90], [12.40, 41.90], [12.40, 41.80]]] } },
    { type: "Feature", properties: { name: "square-1-copy", population: 1200 }, geometry: { type: "Polygon", coordinates: [[[12.40, 41.80], [12.50, 41.80], [12.50, 41.90], [12.40, 41.90], [12.40, 41.80]]] } },
    { type: "Feature", properties: { name: "triangle", population: 340 }, geometry: { type: "Polygon", coordinates: [[[12.60, 41.80], [12.70, 41.80], [12.65, 41.90], [12.60, 41.80]]] } },
    { type: "Feature", properties: { name: "bowtie (invalid)", population: null }, geometry: { type: "Polygon", coordinates: [[[12.75, 41.80], [12.85, 41.90], [12.75, 41.90], [12.85, 41.80], [12.75, 41.80]]] } }
  ]
})
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 — ready for all the cells below.";
    } finally {
      mutable uploadBusy = false;
    }
  };
  return button;
}
autoInspect = async () => {
  await window.WebGeoDS.CodeCell.find("inspect-py").run();
}
// Same coloring convention as geojson-shapefile-validator.qmd's
// VALIDITY_PAINT — green valid, red invalid — so a reader who has
// already seen that tool recognizes the color language immediately.
INSPECT_PAINT = ({
  "fill-color": [
    "case",
    ["==", ["get", "valid"], true],
    "#2ea44f",
    "#e05252"
  ],
  "fill-opacity": 0.55
})
// The summary card's own state, separate from reading pyInspect
// directly: pyInspect is the CodeCell's cached value, which Reset
// below has no way to clear (there's nothing to re-run) — without
// this, clicking Reset would empty the map but leave the summary card
// showing stale numbers. Set alongside the map update below, cleared
// alongside the map reset further down, so the two always agree.
mutable inspectSummary = null
pyInspect = WebGeoDS.getCellValue("inspect-py", Generators)
{
  if (pyInspect) {
    // mapFeatures, not features: the map needs WGS84, the summary
    // card (and the download button below) intentionally keep the
    // file's own native CRS — see inspect-py's own comment.
    await sharedMap.setGeoJSON("inspect-py", pyInspect.mapFeatures, { paint: INSPECT_PAINT });
    // Zooms to whatever was just inspected — an upload or "Load
    // example" — same as every other tool. A no-op on an empty
    // FeatureCollection.
    await sharedMap.fitToData(pyInspect.mapFeatures);
    mutable inspectSummary = pyInspect.summary;
  }
}
resetInspector = async () => {
  if (sharedMap.getGeoJSON("inspect-py")) {
    await sharedMap.setGeoJSON("inspect-py", { type: "FeatureCollection", features: [] });
  }
  mutable inspectSummary = null;
}
resetMapButton = {
  const button = document.createElement("button");
  button.className = "webgeods-panel-btn";
  button.dataset.variant = "outline";
  button.textContent = "🔄 Reset";
  button.onclick = () => resetInspector();
  return button;
}
// WebGeoDS.downloadBlob (shared/download.js) — the same helper every
// other tool uses. Downloads the enriched GeoJSON (original columns +
// valid/empty/duplicate) in the file's own native CRS — pyInspect.
// features, not the map source: the map source holds the WGS84 copy
// reprojected just for display (see inspect-py/pyInspect's comments),
// and a downloaded file should give back the same CRS it came in.
downloadButton = {
  const button = document.createElement("button");
  button.className = "webgeods-panel-btn";
  button.dataset.variant = "outline";
  button.textContent = "⬇ Download";
  button.onclick = () => {
    const data = pyInspect?.features;
    if (!data) return;
    const base = WebGeoDS.Upload.baseName(uploadedFiles);
    WebGeoDS.downloadBlob(
      JSON.stringify(data, null, 2),
      base ? `${base}-inspected.geojson` : "inspected.geojson",
      "application/geo+json",
      { tool: "geospatial-file-inspector" }
    );
  };
  return button;
}
// output:false: this cell's own definition sits well before the
// panel in the document — without it, the fully-grouped row would
// flash into view there FIRST (Quarto's own auto-display), then
// disappear and reappear down in the panel once ${controlPanelRow}
// mounts the same node — same reasoning as every button cell above.
controlPanelRow = {
  const row = document.createElement("div");
  row.className = "webgeods-panel-row";
  row.append(uploadControl, loadExampleButton, downloadButton, resetMapButton);
  const status = document.createElement("span");
  status.className = "webgeods-panel-status";
  status.textContent = "";
  row.appendChild(status);
  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.

// Reactive directly on inspectSummary (a mutable set alongside the
// map, cleared alongside it too — see resetInspector), not on the
// map's "sourcedata" event like other tools' stats lines: reading
// straight from pyInspect instead would have left this showing stale
// numbers after Reset, since there's no cell to re-run that would
// naturally clear it. Physically positioned here, between the panel
// and the map, matching the roadmap's own sketch of this tool:
// controls, then a summary card, then the map to see it on.
statCard = {
  const s = inspectSummary;
  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 || s.total === 0) {
    row("Features", "0");
    row("—", "Upload a file or load the example above");
    return box;
  }

  const geometryLabel = s.geometryTypes.length > 1
    ? `Mixed (${s.geometryTypes.join(", ")})`
    : (s.geometryTypes[0] ?? "—");

  row("Features", s.total.toLocaleString());
  row("Geometry", geometryLabel);
  row("CRS", s.crsWarning ? `${s.crs} ⚠️ ${s.crsWarning}` : (s.crs ?? "Unknown"));
  row("Bounds", s.bounds ? s.bounds.join(", ") : "—");
  row("Attributes", `${s.attributeNames.length}${s.attributeNames.length > 0 ? ` (${s.attributeNames.join(", ")})` : ""}`);
  row("Invalid", String(s.invalid));
  row("Empty", String(s.empty));
  row("Duplicates", String(s.duplicates));

  return box;
}
sharedMapEl = sharedMap.element

Want the full picture instead of just a summary? The full article walks through what each of these stats means and why it matters, in Python and R. Once you know what’s in your file, the Geometry Validator and the Topology Checker are the natural next steps.