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.
// 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;
}// 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 = nullpyInspect = 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;
}
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.