webgeods
  • Home
  • Tools
    • All tools

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

    • Inspect
    • Validate
    • Check Topology
    • CRS
  • About

CRS Inspector & Converter

tool
geometry
Upload a GeoJSON or a Shapefile, find its coordinate reference system, spot a CRS that doesn’t match its own coordinates, and convert to a different CRS. Free, runs entirely in your browser: the file never leaves your computer.
Published

September 6, 2026

Upload a file — .geojson, or a shapefile as a single .zip or as .shp/.dbf/.shx selected together — and find out what coordinate reference system it’s actually in, whether that CRS is consistent with its own coordinates, and convert it to a different one if you need to.

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
// Which kind of file is currently loaded ("geojson" | "zip" |
// "shapefile" | null) — drives the download button below: a
// shapefile in, a shapefile (zipped) out, instead of always GeoJSON.
// Same pattern as geojson-shapefile-validator.qmd.
mutable uploadKind = 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 uploadKind = result.kind;
      mutable uploadStatus = "⌛ Inspecting...";
      await autoInspect();
      mutable uploadStatus = result.message;
    } else {
      mutable uploadKind = null;
      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: "crs-inspector" });
  return map;
}

The code itself isn’t shown here — it’s a fixed, non-editable check using GeoPandas/PyPROJ underneath; see the full article for the actual code, editable and explained line by line.

// No fallback on page load (same reasoning as every other tool's own
// history) — the page starts genuinely empty until the user either
// uploads a file or clicks "Load example" below. Deliberately
// mismatched: no CRS is declared (GeoJSON has none), so it's read as
// WGS84 by default (per spec) — but the coordinates themselves are in
// the hundreds of thousands, Web Mercator magnitude. That mismatch IS
// the example: this tool's whole point is catching exactly this.
EXAMPLE_GEOJSON = JSON.stringify({
  type: "FeatureCollection",
  features: [{
    type: "Feature",
    properties: { name: "mismatched-example" },
    geometry: {
      type: "Polygon",
      coordinates: [[[1390000, 5140000], [1391000, 5140000], [1391000, 5141000], [1390000, 5141000], [1390000, 5140000]]]
    }
  }]
})
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 uploadKind = "geojson";
      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("crs-inspect-py").run();
}
// 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.
mutable inspectSummary = null
pyInspect = WebGeoDS.getCellValue("crs-inspect-py", Generators)
{
  if (pyInspect) {
    await sharedMap.setGeoJSON("crs-py", pyInspect.mapFeatures);
    // Zooms to whatever was just inspected — an upload or "Load
    // example" — same as every other tool. Especially relevant here:
    // the mismatched example's whole point is landing far from the
    // map's fixed initial view. A no-op on an empty FeatureCollection.
    await sharedMap.fitToData(pyInspect.mapFeatures);
    mutable inspectSummary = pyInspect.summary;
  }
}
resetInspector = async () => {
  if (sharedMap.getGeoJSON("crs-py")) {
    await sharedMap.setGeoJSON("crs-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;
}
// A plain text input, not a dropdown of "common" CRSes: any EPSG code
// is a valid target, and a free-text field with a sensible default
// covers that without a list to maintain. Read imperatively by id in
// convertButton's onclick below (same reasoning as
// topology-checker.qmd's sliders — referencing this OJS variable
// directly there would recreate convertButton on every keystroke).
targetCrsControl = {
  const input = document.createElement("input");
  input.type = "text";
  input.id = "target-crs-input";
  input.value = "4326";
  input.placeholder = "EPSG code, e.g. 4326";
  input.size = 10;
  input.className = "webgeods-panel-status";
  return input;
}
// base64 -> raw bytes, for the convert cell's shapefile branch
// (binary data can only cross the Pyodide->JS bridge as a cell value
// via a text encoding — same reasoning as
// geojson-shapefile-validator.qmd's base64ToBytes).
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;
}
// "shapefile", not "zip" too: unlike download in the Validator/other
// tools (mirroring the ORIGINAL upload format), a CRS conversion's
// output format is a free choice independent of how the file was
// selected — a zip-selected shapefile and an shp/dbf/shx-selected one
// both convert to the same thing here, a single re-zipped shapefile,
// which is why crs-convert-py's own `uploadKindPy` check only tests
// for "shapefile" contains both cases (see the assignment below).
convertButton = {
  const button = document.createElement("button");
  button.className = "webgeods-panel-btn";
  button.textContent = "⇄ Convert & Download";
  button.disabled = !inspectSummary || inspectSummary.total === 0;
  button.onclick = async () => {
    button.disabled = true;
    const originalText = button.textContent;
    button.textContent = "⌛ Converting...";
    try {
      window.targetCrs = document.getElementById("target-crs-input").value;
      window.uploadKindPy = (uploadKind === "zip" || uploadKind === "shapefile") ? "shapefile" : "geojson";
      await window.WebGeoDS.CodeCell.find("crs-convert-py").run();
      const result = document.getElementById("crs-convert-py").value;
      const base = WebGeoDS.Upload.baseName(uploadedFiles);
      const epsg = window.targetCrs.replace(/[^0-9]/g, "") || "converted";
      const options = { tool: "crs-inspector" };
      if (result.kind === "shapefile") {
        WebGeoDS.downloadBlob(base64ToBytes(result.data), base ? `${base}-epsg${epsg}.zip` : `converted-epsg${epsg}.zip`, "application/zip", options);
      } else {
        WebGeoDS.downloadBlob(JSON.stringify(result.data, null, 2), base ? `${base}-epsg${epsg}.geojson` : `converted-epsg${epsg}.geojson`, "application/geo+json", options);
      }
    } finally {
      button.disabled = false;
      button.textContent = originalText;
    }
  };
  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, resetMapButton);
  return row;
}
convertRow = {
  const row = document.createElement("div");
  row.className = "webgeods-panel-row";
  const label = document.createElement("span");
  label.className = "webgeods-panel-status";
  label.textContent = "Convert to CRS:";
  row.append(label, targetCrsControl, convertButton);
  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.

// Physically positioned here, between the panel and the map, matching
// the GeoSpatial File Inspector's own layout (a summary card is more
// useful read before the map than after it). The mismatch warning, if
// any, gets its own visually distinct row instead of being folded
// into a regular stat — it's the reason this tool exists.
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;
  }

  row("Features", s.total.toLocaleString());
  row("CRS", s.crsWarning ? `${s.crs} ⚠️ ${s.crsWarning}` : (s.crs ?? "Unknown"));
  row("Type", s.isGeographic ? "Geographic (degrees)" : "Projected (usually meters)");
  row("Bounds", s.bounds ? s.bounds.join(", ") : "—");
  if (s.mismatchWarning) {
    row("⚠️ Mismatch", s.mismatchWarning);
  } else {
    row("Mismatch check", "✓ CRS and coordinates agree");
  }

  return box;
}

sharedMapEl = sharedMap.element

Want to understand the magnitude heuristic, see the actual code, or compare against R’s independent implementation? The full article walks through why “where does this land on a map” is a CRS clue in its own right. Once the CRS is sorted out, the Geometry Validator and the Topology Checker are the natural next checks — and the GeoSpatial File Inspector is the fastest way to see everything about a file at once, CRS included.