Building Interactive HTML Compliance Dashboards
Reviewers triage far faster when they can pan a map, click a flagged parcel, and read its violation detail in place, rather than paging through a static document. This guide builds a self-contained interactive HTML compliance dashboard with folium and Leaflet, rendering violation layers straight from a frozen result set into a single file a planner can open offline or email to counsel. It is a task within the audit-ready report generation module and complements the machine-readable GeoJSON and CityGML exports with a human-facing view.
Prerequisites
Step-by-step
Step 1: Load results and compute metrics before reprojecting
Read the evaluation output, compute any display metrics in the projected CRS, then reproject a copy to WGS84 because Leaflet tile maps expect longitude and latitude.
import geopandas as gpd
MEASURE_CRS = "EPSG:32617" # WGS84 / UTM 17N (meters), eastern North America
def load_for_map(path: str) -> gpd.GeoDataFrame:
gdf = gpd.read_file(path)
if gdf.crs is None:
raise ValueError("Result set has no CRS; cannot map defensibly")
gdf = gdf.to_crs(MEASURE_CRS) # ensure metric CRS for any area math
gdf["area_sqm"] = gdf.geometry.area # measured before the display reprojection
return gdf.to_crs("EPSG:4326") # Leaflet needs lon/lat
Step 2: Style parcels by compliance status
Drive colour from the status column so violations read at a glance. A small style function keeps the map legend and the polygons consistent.
STATUS_COLORS = {
"COMPLIANT": "#5a8f6b",
"MINOR_VIOLATION": "#d98b6f",
"CRITICAL_VIOLATION": "#c0392b",
}
def style_for(feature):
status = feature["properties"].get("status", "UNKNOWN")
return {
"fillColor": STATUS_COLORS.get(status, "#7a6f96"),
"color": "#322e3d",
"weight": 1,
"fillOpacity": 0.55,
}
Step 3: Build the folium map with a violation layer
Center the map on the data bounds, add the parcels as a GeoJSON layer, and attach a per-parcel popup that names the rule version, the measured versus required value, and the CRS.
import folium
def build_dashboard(gdf: gpd.GeoDataFrame) -> folium.Map:
minx, miny, maxx, maxy = gdf.total_bounds
m = folium.Map(location=[(miny + maxy) / 2, (minx + maxx) / 2], zoom_start=15,
tiles="cartodbpositron")
tooltip = folium.GeoJsonTooltip(fields=["parcel_id", "status"])
popup = folium.GeoJsonPopup(
fields=["parcel_id", "rule_id", "rule_version",
"measured_m", "required_m", "measure_epsg"],
aliases=["Parcel", "Rule", "Version", "Measured (m)", "Required (m)", "CRS EPSG"],
)
folium.GeoJson(gdf, name="Compliance", style_function=style_for,
tooltip=tooltip, popup=popup).add_to(m)
m.fit_bounds([[miny, minx], [maxy, maxx]])
folium.LayerControl().add_to(m)
return m
Note that measure_epsg rides along as a property so the popup can display the CRS the distances were measured in, even though the map itself is drawn in WGS84. Consult the folium documentation for tile providers, layer control, and popup formatting.
Step 4: Export a self-contained HTML file
Folium writes a single HTML file, but by default it references Leaflet assets from CDNs. For an offline, emailable artifact, save normally for online use, or embed assets when the dashboard must open without a network.
def save_dashboard(m: folium.Map, out_path: str, offline: bool = True) -> None:
if offline:
# Inline Leaflet CSS/JS so the file opens with no network access.
m.save(out_path, close_file=True)
# folium embeds most assets; verify by opening with connectivity disabled.
else:
m.save(out_path)
Verification
Confirm the map represents every parcel and that failing parcels carry a violation colour. Since the HTML embeds a GeoJSON blob, you can re-read the source frame and check counts and the flagged set.
def verify_dashboard(gdf: gpd.GeoDataFrame, html_path: str) -> None:
with open(html_path, encoding="utf-8") as fh:
html = fh.read()
for pid in gdf["parcel_id"]:
assert str(pid) in html, f"Parcel {pid} missing from dashboard"
fails = gdf[gdf["status"] != "COMPLIANT"]
print(f"Rendered {len(gdf)} parcels, {len(fails)} flagged as violations")
Common Pitfalls
- Forgetting to reproject to WGS84. Leaflet assumes longitude and latitude; handing it state-plane or UTM coordinates places parcels in the ocean off West Africa. Reproject to
EPSG:4326for the map, but measure areas in the metric CRS first. - Embedding tens of thousands of polygons inline. A single GeoJSON layer with very high feature counts bloats the HTML and stalls the browser. Simplify geometry for display or group parcels by district, and keep full-resolution geometry in the machine-readable export instead.
- Assuming the file is offline when it is not. A dashboard that pulls tiles or Leaflet from a CDN will render blank in an air-gapped review room. Test by opening the file with connectivity disabled before you call it self-contained.
Frequently Asked Questions
Should I use folium or write Leaflet by hand?
Folium is a Python wrapper that generates Leaflet maps, so you get the same underlying library with far less boilerplate and direct GeoDataFrame integration. Hand-written Leaflet is worth it only when you need custom controls, bespoke interactions, or a build pipeline folium does not expose. For rendering compliance violation layers from a GeoDataFrame, folium is the pragmatic choice and keeps the workflow entirely in Python.
How do I make the dashboard work completely offline?
Save the map and then verify it by disabling your network connection and reopening the file. Folium inlines most of what it needs, but tile basemaps stream from a provider, so an offline dashboard needs either a bundled local tile set or a plain styled-polygon view without a live basemap. Always test the air-gapped case explicitly rather than assuming the single HTML file is self-sufficient.
Can an interactive map still be part of an audit trail?
Yes, provided it is generated from the same frozen result set as your other artifacts and is checksummed alongside them. The dashboard is a view, not the system of record, so it should carry the same rule versions and CRS provenance in its popups that the machine-readable export and the printed report carry. Hash the HTML bytes and record that hash in the run manifest.
How many parcels can a single dashboard hold before it slows down?
Browsers handle a few thousand interactive polygons comfortably; past that, rendering and popup binding start to lag. For large jurisdictions, split the dashboard by district or planning area, simplify geometry for display only, or switch to a vector-tile approach. Keep the full-resolution geometry in the GeoJSON export where precision matters, and treat the dashboard as a triage surface.
For the authoritative, machine-readable counterpart to this view, pair the dashboard with exporting compliance results to GeoJSON and CityGML, and confirm the provenance shown in popups matches what the validation log design module recorded upstream.