Topology Errors in a GeoJSON: Detect Without Fixing
tool
geometry
bilingual
Upload a GeoJSON and find overlaps, gaps, slivers, duplicates and dangles — a report, not an automatic repair, in Python and R on the same map.
Published
August 31, 2026
In Geometry validity of a GeoJSON we repaired polygons that were individually invalid with make_valid()/st_make_valid(). Here the problem is different: every single feature’s geometry can be perfectly valid, and the dataset can still be broken — two polygons that overlap, a gap between two areas that should touch, a line that ends a millimeter from another without connecting.
This tool doesn’t fix anything. It detects, describes and locates the errors — closing a gap or assigning an overlap is a semantic decision (which polygon is the “correct” one?) that no algorithm can make for you. It’s a data-quality report, not an automatic repair tool.
On load, the code detects whether your data is polygons or lines and runs only the checks that make sense for that type:
Polygons → overlap, gap, sliver, duplicates
Lines → dangle, duplicates
Just want to quickly check a file, without the rest of the reading? Use the standalone tool — same engine, less prose.
1. Upload your file
A .geojson, or a shapefile: either as a single .zip, or as the individual .shp/.dbf/.shx files (and optionally .prj) selected together.
// The input and the writing logic into the virtual filesystem live in// shared/upload.js (WebGeoDS.Upload) — same helper used in// geometry-validity.qmd, not rewritten here.viewof uploadedFiles = WebGeoDS.Upload.createInput()
// The color is decided by JS, not by Python/R: the Python/R cells// only return GeoJSON with properties like "has_error" — neither// language knows or needs to know who's going to draw it.//// Unlike geometry-validity.qmd (always polygons, a single static// VALIDITY_PAINT), here the geometry type changes from one example to// the next (polygons for overlap/gap/sliver/duplicates, lines for// dangle) — so the paint itself depends on the data: setGeoJSON()// (shared/map.js) detects on its own when the layer's TYPE needs to// change and recreates the layer, but the color/style for that type// remains a presentation choice, so it stays here.errorPaint = (data) => {const geometryType = data?.features?.find((f) => f?.geometry?.type)?.geometry?.type??"";const colorExpr = ["case", ["==", ["get","has_error"],true],"#e05252","#3d5a73"];if (geometryType ==="LineString"|| geometryType ==="MultiLineString") {return { "line-color": colorExpr,"line-width":4 }; }if (geometryType ==="Point"|| geometryType ==="MultiPoint") {return { "circle-color": colorExpr,"circle-radius":6 }; }return { "fill-color": colorExpr,"fill-opacity":0.55 };}
// tableCell() is already reactive to the layer we pass it — no need// to build a separate listener cell here (see shared/map.js).// rowClassName colors every row with has_error red, whether it's an// original feature or the synthetic geometry of a gap.//// No explicit containerId: tableCell() creates its own `<div>` and// returns it - this cell is NOT `output: false`, Observable shows it// here automatically, with no hand-written <div> in the markdown.topologyErrorsTable = sharedMap.tableCell( ["topology-errors-py","topology-errors-r"],null, Generators, {rowClassName: (row) => row.has_error==="true"?"webgeods-row-invalid":"" })
resetTopologyErrors =async () => {for (const id of ["topology-errors-py","topology-errors-r"]) {if (sharedMap.getGeoJSON(id)) {await sharedMap.setGeoJSON(id, { type:"FeatureCollection",features: [] }); } }}
Defines all the detection functions — no Python/R map here, that’s already ready above and knows nothing about these calculations. Run it once before the examples below.
loadOverlapButton =loadExampleButton("Load the overlap example", pyOverlapCode, rOverlapCode)
4.2 Gap
Two aligned polygons that should cover a single continuous area instead leave a gap between them — detected by comparing the union of the polygons with the convex hull containing both.
loadGapButton =loadExampleButton("Load the gap example", pyGapCode, rGapCode)
Note
The gap is shown as its own feature (a synthetic geometry, not one of yours), colored like the other error rows. The check only looks at polygon pairs that are close and not already overlapping, using the convex hull of the single pair — not of the whole dataset, which would flag as “gap” even the simple fact that two distant or overlapping shapes aren’t aligned with each other. For a real dataset with a known study area, that area is still the more correct reference, not a convex hull.
4.3 Sliver
An extremely thin polygon — 0.1° wide, 0.001° tall — a typical leftover from an overlay between nearly coincident boundaries. Detected with the Polsby-Popper compactness index (4π·area/perimeter²): below 0.15 is considered a sliver.
loadSliverButton =loadExampleButton("Load the sliver example", pySliverCode, rSliverCode)
4.4 Duplicates
Two features with identical geometry — happens often during merges or repeated imports. The check applies the same way to polygons and lines (topological equality), shown here on polygons.
loadDuplicateButton =loadExampleButton("Load the duplicates example", pyDuplicateCode, rDuplicateCode)
4.5 Dangle
A line network where three segments form a closed loop — each end touches exactly the next segment’s end, no dangle — and a fourth stays isolated, with both ends “dangling”. Notice how the layer on the map switches from fill to line: the code itself decides that, based on the geometry type you feed it.
pyDangleCode =`import geopandas as gpdfrom shapely.geometry import shape# L1-L2-L3 form a closed loop (each end touches the next one): no# dangle. An OPEN path would still have two free ends by definition —# it wouldn't be a clean example of "everything connected".l1 = {"type": "LineString", "coordinates": [[12.45, 41.85], [12.50, 41.85]]}l2 = {"type": "LineString", "coordinates": [[12.50, 41.85], [12.50, 41.90]]}l3 = {"type": "LineString", "coordinates": [[12.50, 41.90], [12.45, 41.85]]}l4 = {"type": "LineString", "coordinates": [[12.52, 41.87], [12.56, 41.87]]}gdf = gpd.GeoDataFrame({"name": ["L1", "L2", "L3", "L4 isolated"]}, geometry=[shape(l1), shape(l2), shape(l3), shape(l4)], crs="EPSG:4326")detect_and_check(gdf)`rDangleCode =`l1 <- sf::st_linestring(rbind(c(12.45, 41.85), c(12.50, 41.85)))l2 <- sf::st_linestring(rbind(c(12.50, 41.85), c(12.50, 41.90)))l3 <- sf::st_linestring(rbind(c(12.50, 41.90), c(12.45, 41.85)))l4 <- sf::st_linestring(rbind(c(12.52, 41.87), c(12.56, 41.87)))data <- sf::st_sf(name = c("L1", "L2", "L3", "L4 isolated"), geometry = sf::st_sfc(l1, l2, l3, l4, crs = 4326))detect_and_check(data)`
loadDangleButton =loadExampleButton("Load the dangle example", pyDangleCode, rDangleCode)
5. Apply to your file
The cells below try to read /uploaded.geojson, then an uploaded shapefile (/uploaded.shp), then a zipped shapefile (or, without an upload, use the overlap example as test data), detect the geometry type and automatically apply only the relevant checks — same detect_and_check() function as every example above, no duplicated logic.
A report that says “23 overlaps, 8 gaps, 4 slivers” isn’t half a tool: it’s a legitimate, complete quality-assurance step in its own right. Fixing these errors almost always requires a human decision — which polygon to move, who to assign a gap to, whether a 2 m² sliver is noise or a real island. A future article may cover repair strategies; this one deliberately stops before that, where geometry alone can no longer answer the question.