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:4326 for 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.

Designing for Triage, Not for Browsing

A compliance dashboard has one job: get a reviewer to the cases that need them, in the order that matters. Most dashboards instead present everything equally and leave the prioritising to the reader.

A work queue, not a browsing experienceDefault sort, per-row information, treatment of indeterminate cases and filter behaviour, each chosen so a reviewer reaches the right case quickly.Do thisInstead ofDefault viewA queue sorted by margin or ageEverything, unsortedRow contentParcel, rule, outcome, marginA status badge and a linkIndeterminate casesProminent — they need judgementFiltered out by defaultFilter stateIn the URL, so it can be sharedIn memory, so it cannot
The dashboard’s job is to get someone to the eleven cases that need them today.

The default view should be a work queue, sorted by something meaningful — margin, so the closest calls surface first, or age, so nothing sits forgotten. Every row should carry enough to decide without opening it: parcel, rule, outcome, margin. And the indeterminate cases should be prominent rather than filtered out by default, because they are the ones requiring human judgement and the ones a pass/fail-oriented view hides.

Filters should match the questions people ask — by district, by rule, by outcome, by run — and the current filter state belongs in the URL so a reviewer can send a colleague exactly what they are looking at. That single detail eliminates most of the “which view were you on?” traffic that dashboards otherwise generate.

Resist the temptation to add charts that nobody acts on. A count of violations by month looks informative and changes nobody’s afternoon; a list of the eleven cases needing review today does.

Saying What the Dashboard Is Not

A dashboard is the most convenient view of a compliance run and the least authoritative, and the gap between those two facts causes real problems when a screenshot ends up attached to a decision.

Saying what the dashboard is notThe run and its rule version stated permanently, a link to the authoritative record on every row, and no derived values presented as findings.Run, timestamp and rule version, always visiblenot in a tooltipLink to the issued report on every rowthe authoritative record is elsewhereNothing derived presented as a findingestimates labelled as estimatesPinned to a run, not to live dataso two people see the same thing
A screenshot of a dashboard ends up attached to a decision. Design for that.

Three measures keep it honest. State the run it is showing, with its timestamp and rule version, somewhere permanent on the page rather than in a tooltip. Say plainly that the authoritative record is the issued report or certificate, and link to it from each row. And avoid presenting anything the underlying records do not contain — a derived estimate, a projection, an “expected” outcome — without labelling it as such.

The same reasoning applies to live data. A dashboard that re-queries as data changes is useful for operations and misleading as evidence, because two people looking at it on different days see different things and neither can reproduce what the other saw. Pinning the view to a run, and offering run selection explicitly, resolves the ambiguity.

Keeping It Fast Without a Backend

Compliance dashboards are usually small — a county run produces tens of thousands of rows, not millions — and that puts them comfortably within reach of a static page with the data embedded or fetched as a file.

A static page is usually enoughA county run’s verdicts fit comfortably in a browser; rendering visible rows only and filtering in memory keeps a single-file dashboard fast.Run verdictstens of thousands of rowsPacked into the pagea few megabytesFilter in memoryno round tripsRender visible rowsinstant, no backend
A dashboard that is one file with its data inside it can be archived with the run and still work in five years.

The advantages are considerable for this use case: no server to operate, no authentication layer to build, no database to keep in step with the run, and an artefact that can be archived alongside the run it describes. A dashboard that is a single file with its data inside it can be attached to a record, opened in five years, and will still work.

The technique that makes it feel fast is to render only the visible rows and to do filtering in memory over a compact representation. Thirty thousand rows of six fields is a few megabytes as JSON and considerably less as a packed structure, which is well within what a browser handles without ceremony.

Where the dataset genuinely outgrows this — a state-wide run, or a multi-year archive — the honest move is a real backend rather than a heroically optimised static page. But that threshold is much further away than most teams assume, and reaching for a service first costs an operational dependency that a compliance artefact does not otherwise need.

Part of: Audit-ready report generation

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.