351 lines
13 KiB
Python
351 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
map_export.py - export a FireMapper session map as a standalone interactive
|
|
Leaflet HTML page.
|
|
|
|
Unlike the in-app tkintermapview view (which subsamples to a few hundred frames
|
|
to stay responsive), this renders *every* trigger point and footprint at once.
|
|
It stays legible at that scale by:
|
|
* clustering the trigger markers (leaflet.markercluster),
|
|
* drawing footprints on a canvas renderer (fast with thousands of polygons),
|
|
* giving per-session and per-camera (cam25 / cam45 / thermal / ...) toggles,
|
|
* showing each frame's metadata in a click popup.
|
|
|
|
Each camera is its own layer (keyed by its cam<NN> off-nadir angle) with its own
|
|
colour, so its footprints can be switched on and off independently. Leaflet + the
|
|
markercluster plugin are loaded from a CDN, so an internet connection is required
|
|
- the OpenStreetMap tiles need one anyway.
|
|
|
|
Used by firemapper_gui.py ("Open full map in browser"); also runnable directly:
|
|
|
|
python map_export.py [SESSION ...]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
import webbrowser
|
|
from pathlib import Path
|
|
|
|
import session_map as smap
|
|
|
|
THERMAL_COLOR = "#e65100" # thermal keeps the established orange
|
|
# distinct colours handed to the cameras in ascending off-nadir order
|
|
_CAM_PALETTE = ["#1565c0", "#2e7d32", "#6a1b9a", "#c62828",
|
|
"#00838f", "#9e9d24", "#4527a0"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# layers & colours
|
|
# --------------------------------------------------------------------------- #
|
|
def _layer_key(frame: dict) -> str:
|
|
"""The toggle layer a frame belongs to: 'thermal' or 'cam<NN>'."""
|
|
if frame["kind"] == "thermal":
|
|
return "thermal"
|
|
return f"cam{int(frame['off_nadir'])}"
|
|
|
|
|
|
def _ordered_layers(keys: set[str]) -> list[str]:
|
|
"""Cameras first (ascending off-nadir), thermal last."""
|
|
cams = sorted((k for k in keys if k != "thermal"), key=lambda k: int(k[3:]))
|
|
return cams + (["thermal"] if "thermal" in keys else [])
|
|
|
|
|
|
def _assign_colors(ordered: list[str]) -> dict[str, str]:
|
|
colors: dict[str, str] = {}
|
|
ci = 0
|
|
for lk in ordered:
|
|
if lk == "thermal":
|
|
colors[lk] = THERMAL_COLOR
|
|
else:
|
|
colors[lk] = _CAM_PALETTE[ci % len(_CAM_PALETTE)]
|
|
ci += 1
|
|
return colors
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# feature building
|
|
# --------------------------------------------------------------------------- #
|
|
def _feature_props(frame: dict) -> dict:
|
|
"""Human-readable metadata for a frame's click popup (ordered)."""
|
|
kind = frame["kind"]
|
|
label = f"cam{int(frame['off_nadir'])}" if kind == "cam" else kind
|
|
props: dict[str, str] = {
|
|
"Session": frame["session"],
|
|
"Kind": label,
|
|
"Lat": f"{frame['lat']:.6f}",
|
|
"Lon": f"{frame['lon']:.6f}",
|
|
}
|
|
if frame.get("alt") is not None:
|
|
props["Alt (m)"] = f"{frame['alt']:.1f}"
|
|
ori = smap.camera_orientation(frame)
|
|
if ori:
|
|
props["Heading"] = f"{ori[0]:.1f} deg"
|
|
props["Pitch"] = f"{ori[1]:.1f} deg"
|
|
props["Roll"] = f"{ori[2]:.1f} deg"
|
|
if frame.get("platform_angle") is not None:
|
|
props["Scan angle"] = f"{frame['platform_angle']:.1f} deg"
|
|
return props
|
|
|
|
|
|
def build_payload(frames: list[dict], *, ground_elev: float,
|
|
thermal_fov_deg: tuple[float, float] | None,
|
|
include_markers: bool, include_footprints: bool) -> dict:
|
|
"""Turn frames into the compact JSON payload the HTML page consumes."""
|
|
sessions: list[str] = []
|
|
seen_s: set[str] = set()
|
|
seen_k: set[str] = set()
|
|
markers: list[dict] = []
|
|
foots: list[dict] = []
|
|
for f in frames:
|
|
s = f["session"]
|
|
if s not in seen_s:
|
|
seen_s.add(s)
|
|
sessions.append(s)
|
|
lk = _layer_key(f)
|
|
seen_k.add(lk)
|
|
props = _feature_props(f)
|
|
if include_markers:
|
|
markers.append({"s": s, "k": lk, "ll": [f["lat"], f["lon"]], "p": props})
|
|
if include_footprints:
|
|
corners = smap.footprint(f, ground_elev, thermal_fov_deg=thermal_fov_deg)
|
|
if corners:
|
|
foots.append({"s": s, "k": lk, "c": corners, "p": props})
|
|
layers = _ordered_layers(seen_k)
|
|
return {"sessions": sorted(sessions), "markers": markers, "foots": foots,
|
|
"layers": layers, "colors": _assign_colors(layers)}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# HTML template
|
|
# --------------------------------------------------------------------------- #
|
|
_TEMPLATE = r"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>__TITLE__</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"/>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"/>
|
|
<style>
|
|
html, body { height: 100%; margin: 0; }
|
|
#map { height: 100%; width: 100%; background: #e8e8e8; }
|
|
.fm-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid #fff;
|
|
box-sizing: border-box; }
|
|
.fm-pop { font: 12px/1.4 "Segoe UI", system-ui, sans-serif; border-collapse: collapse; }
|
|
.fm-pop td { padding: 1px 6px 1px 0; vertical-align: top; }
|
|
.fm-pop td:first-child { color: #666; font-weight: 600; white-space: nowrap; }
|
|
.fm-panel {
|
|
font: 13px/1.35 "Segoe UI", system-ui, sans-serif; color: #222;
|
|
background: rgba(255,255,255,0.96); padding: 8px 10px; border-radius: 6px;
|
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4); max-height: 80vh; overflow-y: auto;
|
|
min-width: 180px;
|
|
}
|
|
.fm-panel h4 { margin: 0 0 4px; font-size: 13px; }
|
|
.fm-panel .sub { color: #777; margin: 6px 0 2px; font-size: 11px;
|
|
text-transform: uppercase; letter-spacing: 0.04em; }
|
|
.fm-panel label { display: block; cursor: pointer; white-space: nowrap; }
|
|
.fm-panel .mini { font-size: 11px; }
|
|
.fm-panel a { color: #1565c0; cursor: pointer; text-decoration: none; }
|
|
.fm-panel a:hover { text-decoration: underline; }
|
|
.fm-sw { display: inline-block; width: 10px; height: 10px; border-radius: 50%;
|
|
border: 1px solid #fff; vertical-align: middle; margin: 0 4px; }
|
|
.fm-count { color: #555; margin-top: 6px; font-size: 11px; }
|
|
.fm-sessions { max-height: 34vh; overflow-y: auto; margin-top: 2px;
|
|
border-top: 1px solid #eee; padding-top: 3px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="map"></div>
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
|
<script>
|
|
const DATA = __DATA__;
|
|
const COLORS = DATA.colors;
|
|
|
|
const map = L.map('map', { preferCanvas: true });
|
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19, attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map);
|
|
|
|
// Per-session layers: footprint featureGroups keyed by camera, plus a marker cluster.
|
|
const layers = {}; // session -> { foot: {kind: featureGroup}, cluster }
|
|
function ensure(s) {
|
|
if (!layers[s]) {
|
|
layers[s] = {
|
|
foot: {},
|
|
cluster: L.markerClusterGroup({ chunkedLoading: true, maxClusterRadius: 50 })
|
|
};
|
|
}
|
|
return layers[s];
|
|
}
|
|
function ensureFoot(s, k) {
|
|
const o = ensure(s);
|
|
if (!o.foot[k]) o.foot[k] = L.featureGroup();
|
|
return o.foot[k];
|
|
}
|
|
|
|
function popupHtml(p) {
|
|
let h = '<table class="fm-pop">';
|
|
for (const k in p) h += '<tr><td>' + k + '</td><td>' + p[k] + '</td></tr>';
|
|
return h + '</table>';
|
|
}
|
|
|
|
const allLatLngs = [];
|
|
|
|
for (const f of DATA.foots) {
|
|
const poly = L.polygon(f.c, {
|
|
color: COLORS[f.k] || '#333', weight: 1, opacity: 0.85,
|
|
fill: true, fillOpacity: 0.06
|
|
}).bindPopup(popupHtml(f.p));
|
|
ensureFoot(f.s, f.k).addLayer(poly);
|
|
for (const ll of f.c) allLatLngs.push(ll);
|
|
}
|
|
for (const m of DATA.markers) {
|
|
const mk = L.marker(m.ll, {
|
|
icon: L.divIcon({
|
|
className: '', iconSize: [10, 10], iconAnchor: [5, 5],
|
|
html: '<div class="fm-dot" style="background:' + (COLORS[m.k] || '#333') + '"></div>'
|
|
})
|
|
}).bindPopup(popupHtml(m.p));
|
|
ensure(m.s).cluster.addLayer(mk);
|
|
allLatLngs.push(m.ll);
|
|
}
|
|
|
|
// ----- visibility state -------------------------------------------------- //
|
|
const state = { footKinds: {}, trig: true, sessions: {} };
|
|
for (const k of DATA.layers) state.footKinds[k] = true;
|
|
for (const s of DATA.sessions) state.sessions[s] = true;
|
|
|
|
function setLayer(layer, show) {
|
|
if (show) { if (!map.hasLayer(layer)) map.addLayer(layer); }
|
|
else { if (map.hasLayer(layer)) map.removeLayer(layer); }
|
|
}
|
|
function refresh() {
|
|
for (const s in layers) {
|
|
const on = state.sessions[s];
|
|
for (const k in layers[s].foot) setLayer(layers[s].foot[k], on && state.footKinds[k]);
|
|
setLayer(layers[s].cluster, on && state.trig);
|
|
}
|
|
}
|
|
|
|
// ----- control panel ----------------------------------------------------- //
|
|
const panel = L.control({ position: 'topright' });
|
|
panel.onAdd = function () {
|
|
const div = L.DomUtil.create('div', 'fm-panel');
|
|
L.DomEvent.disableClickPropagation(div);
|
|
L.DomEvent.disableScrollPropagation(div);
|
|
|
|
let html = '<h4>Layers</h4>';
|
|
html += '<div class="sub">Footprints (per camera)</div>';
|
|
for (const k of DATA.layers) {
|
|
html += '<label><input type="checkbox" class="fm-fk" value="' + k + '" checked>'
|
|
+ '<span class="fm-sw" style="background:' + COLORS[k] + '"></span>' + k + '</label>';
|
|
}
|
|
html += '<div class="sub">Triggers</div>';
|
|
html += '<label><input type="checkbox" id="fm-trig" checked> Trigger points (all)</label>';
|
|
html += '<div class="sub">Sessions '
|
|
+ '<a id="fm-all">all</a> / <a id="fm-none">none</a></div>';
|
|
html += '<div class="fm-sessions" id="fm-sess">';
|
|
for (const s of DATA.sessions) {
|
|
html += '<label class="mini"><input type="checkbox" class="fm-s" value="'
|
|
+ s + '" checked> ' + s + '</label>';
|
|
}
|
|
html += '</div>';
|
|
html += '<div class="fm-count"><a id="fm-fit">Fit to all</a> | '
|
|
+ DATA.markers.length + ' points, ' + DATA.foots.length + ' footprints</div>';
|
|
div.innerHTML = html;
|
|
return div;
|
|
};
|
|
panel.addTo(map);
|
|
|
|
for (const cb of document.querySelectorAll('.fm-fk')) {
|
|
cb.onchange = e => { state.footKinds[e.target.value] = e.target.checked; refresh(); };
|
|
}
|
|
document.getElementById('fm-trig').onchange = e => { state.trig = e.target.checked; refresh(); };
|
|
for (const cb of document.querySelectorAll('.fm-s')) {
|
|
cb.onchange = e => { state.sessions[e.target.value] = e.target.checked; refresh(); };
|
|
}
|
|
function setAllSessions(v) {
|
|
for (const cb of document.querySelectorAll('.fm-s')) {
|
|
cb.checked = v; state.sessions[cb.value] = v;
|
|
}
|
|
refresh();
|
|
}
|
|
document.getElementById('fm-all').onclick = () => setAllSessions(true);
|
|
document.getElementById('fm-none').onclick = () => setAllSessions(false);
|
|
|
|
function fitAll() {
|
|
if (allLatLngs.length) map.fitBounds(L.latLngBounds(allLatLngs).pad(0.05));
|
|
else map.setView([49.32, 8.43], 12);
|
|
}
|
|
document.getElementById('fm-fit').onclick = fitAll;
|
|
|
|
refresh();
|
|
fitAll();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def build_html(payload: dict, *, title: str = "FireMapper map") -> str:
|
|
return (_TEMPLATE
|
|
.replace("__TITLE__", title)
|
|
.replace("__DATA__", json.dumps(payload, separators=(",", ":"))))
|
|
|
|
|
|
def export(frames: list[dict], *, ground_elev: float = 110.0,
|
|
thermal_fov_deg: tuple[float, float] | None = smap.THERMAL_FOV_DEFAULT,
|
|
include_markers: bool = True, include_footprints: bool = True,
|
|
out_path: Path | None = None, title: str = "FireMapper map",
|
|
open_browser: bool = True) -> dict:
|
|
"""Build the HTML map for `frames`, write it, and (optionally) open it.
|
|
|
|
Returns {"path", "markers", "footprints"}.
|
|
"""
|
|
payload = build_payload(frames, ground_elev=ground_elev,
|
|
thermal_fov_deg=thermal_fov_deg,
|
|
include_markers=include_markers,
|
|
include_footprints=include_footprints)
|
|
html = build_html(payload, title=title)
|
|
if out_path is None:
|
|
out_path = Path(tempfile.gettempdir()) / "firemapper_map.html"
|
|
out_path = Path(out_path)
|
|
out_path.write_text(html, encoding="utf-8")
|
|
if open_browser:
|
|
webbrowser.open(out_path.as_uri())
|
|
return {"path": str(out_path),
|
|
"markers": len(payload["markers"]),
|
|
"footprints": len(payload["foots"])}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
def _main(argv: list[str]) -> int:
|
|
args = [a for a in argv if not a.startswith("-")]
|
|
if args:
|
|
sessions = [Path(a) for a in args]
|
|
else:
|
|
here = Path(__file__).resolve().parent
|
|
sessions = smap.list_sessions(here)
|
|
if not sessions:
|
|
print("No sessions found.")
|
|
return 1
|
|
frames: list[dict] = []
|
|
for s in sessions:
|
|
frames += smap.iter_session_frames(s)
|
|
if not frames:
|
|
print("No frames with GPS in the selected sessions.")
|
|
return 1
|
|
res = export(frames)
|
|
print(f"Wrote {res['path']} ({res['markers']} points, "
|
|
f"{res['footprints']} footprints)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
raise SystemExit(_main(sys.argv[1:]))
|