#!/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 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'.""" 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""" __TITLE__
""" 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:]))