diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py
index b5b5da9..028c71b 100644
--- a/src/central/gui/routes.py
+++ b/src/central/gui/routes.py
@@ -20,7 +20,7 @@ logger = logging.getLogger("central.gui.routes")
from fastapi import APIRouter, Depends, Form, Request
-from fastapi.responses import HTMLResponse, RedirectResponse, Response
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from central.bootstrap_config import get_settings
from central.gui.csrf import (
reuse_or_generate_pre_auth_csrf,
@@ -3457,6 +3457,126 @@ def _decorate_table_events(events: list[dict]) -> None:
+# --- Fused fire view (v0.9.14): WFIGS perimeters + nearby FIRMS hotspots -----
+# FIRMS hotspots carry no IrwinID, so the link is spatial+temporal: a hotspot is
+# "confirmed" (part of a known fire) when it lies within FIRE_FUSE_RADIUS_M of a
+# live perimeter AND within FIRE_FUSE_WINDOW_H of it. Hotspots matching no
+# perimeter are "unconfirmed" (possible new fire ahead of an official perimeter).
+# Read-only: SELECT-only, no DML.
+FIRE_FUSE_RADIUS_M = 1000
+FIRE_FUSE_WINDOW_H = 72
+
+
+def _fused_bbox(params: dict) -> tuple[float, float, float, float] | None:
+ """Parse optional north/south/east/west into (west, south, east, north), or
+ None if absent/degenerate/out-of-range (mirrors _parse_events_params)."""
+ try:
+ n = float(params["north"]); so = float(params["south"])
+ e = float(params["east"]); w = float(params["west"])
+ except (KeyError, TypeError, ValueError):
+ return None
+ if -90 <= so < n <= 90 and -180 <= w < e <= 180:
+ return (w, so, e, n)
+ return None
+
+
+def _shape_fused_fire(row) -> dict[str, Any]:
+ """Shape a confirmed-fire row (perimeter + aggregated hotspots) for JSON."""
+ return {
+ "id": row["id"],
+ "time": row["time"].isoformat(),
+ "incident_name": row["incident_name"],
+ "irwin_id": row["irwin_id"],
+ "acres": row["acres"],
+ "cause": row["cause"],
+ "geometry": row["geometry"],
+ "hotspot_count": row["hotspot_count"],
+ "max_frp": row["max_frp"],
+ "hotspots": row["hotspots"] or [],
+ }
+
+
+def _shape_unconfirmed_hotspot(row) -> dict[str, Any]:
+ """Shape an unconfirmed FIRMS hotspot (no nearby perimeter) for JSON."""
+ return {
+ "id": row["id"],
+ "time": row["time"].isoformat(),
+ "geometry": row["geometry"],
+ "frp": row["frp"],
+ "confidence": row["confidence"],
+ "satellite": row["satellite"],
+ }
+
+
+@router.get("/events/fire-fused.json")
+async def fire_fused(request: Request) -> Response:
+ """Fused fire view: each live WFIGS perimeter with its nearby/contemporaneous
+ FIRMS hotspots, plus standalone ("unconfirmed") hotspots. Honors an optional
+ north/south/east/west viewport bbox. Read-only."""
+ pool = get_pool()
+ bbox = _fused_bbox(dict(request.query_params))
+
+ confirmed_sql = """
+ SELECT p.id, p.time,
+ p.payload->'data'->'data'->'raw'->>'poly_IncidentName' AS incident_name,
+ p.payload->'data'->'data'->'raw'->>'attr_IrwinID' AS irwin_id,
+ (p.payload->'data'->'data'->'raw'->>'poly_GISAcres')::float AS acres,
+ p.payload->'data'->'data'->'raw'->>'attr_FireCause' AS cause,
+ ST_AsGeoJSON(p.geom)::jsonb AS geometry,
+ count(h.id) AS hotspot_count,
+ max((h.payload->'data'->'data'->>'frp')::float) AS max_frp,
+ COALESCE(jsonb_agg(jsonb_build_object(
+ 'geometry', ST_AsGeoJSON(h.geom)::jsonb,
+ 'frp', h.payload->'data'->'data'->>'frp',
+ 'confidence', h.payload->'data'->'data'->>'confidence',
+ 'satellite', h.payload->'data'->'data'->>'satellite',
+ 'time', h.time
+ ) ORDER BY h.time DESC) FILTER (WHERE h.id IS NOT NULL), '[]'::jsonb) AS hotspots
+ FROM events p
+ LEFT JOIN events h
+ ON h.adapter = 'firms' AND h.category NOT LIKE '%.removed' AND h.geom IS NOT NULL
+ AND ST_DWithin(p.geom::geography, h.geom::geography, $1)
+ AND h.time > p.time - ($2 * interval '1 hour')
+ WHERE p.adapter = 'wfigs_perimeters' AND p.category NOT LIKE '%.removed'
+ AND p.geom IS NOT NULL{p_bbox}
+ GROUP BY p.id, p.time
+ ORDER BY hotspot_count DESC
+ """
+ unconfirmed_sql = """
+ SELECT h.id, h.time,
+ ST_AsGeoJSON(h.geom)::jsonb AS geometry,
+ h.payload->'data'->'data'->>'frp' AS frp,
+ h.payload->'data'->'data'->>'confidence' AS confidence,
+ h.payload->'data'->'data'->>'satellite' AS satellite
+ FROM events h
+ WHERE h.adapter = 'firms' AND h.category NOT LIKE '%.removed'
+ AND h.geom IS NOT NULL{h_bbox}
+ AND NOT EXISTS (
+ SELECT 1 FROM events p
+ WHERE p.adapter = 'wfigs_perimeters' AND p.category NOT LIKE '%.removed'
+ AND p.geom IS NOT NULL
+ AND ST_DWithin(p.geom::geography, h.geom::geography, $1)
+ AND h.time > p.time - ($2 * interval '1 hour')
+ )
+ ORDER BY h.time DESC
+ """
+ args: list[Any] = [FIRE_FUSE_RADIUS_M, FIRE_FUSE_WINDOW_H]
+ p_bbox = h_bbox = ""
+ if bbox:
+ p_bbox = "\n AND ST_Intersects(p.geom, ST_MakeEnvelope($3, $4, $5, $6, 4326))"
+ h_bbox = "\n AND ST_Intersects(h.geom, ST_MakeEnvelope($3, $4, $5, $6, 4326))"
+ args.extend(bbox)
+
+ async with pool.acquire() as conn:
+ confirmed = await conn.fetch(confirmed_sql.format(p_bbox=p_bbox), *args)
+ unconfirmed = await conn.fetch(unconfirmed_sql.format(h_bbox=h_bbox), *args)
+
+ return JSONResponse({
+ "fires": [_shape_fused_fire(r) for r in confirmed],
+ "unconfirmed": [_shape_unconfirmed_hotspot(r) for r in unconfirmed],
+ })
+
+
@router.get("/events.json")
async def events_json(request: Request):
"""
diff --git a/src/central/gui/static/css/central.css b/src/central/gui/static/css/central.css
index 5f7af6c..735ec39 100644
--- a/src/central/gui/static/css/central.css
+++ b/src/central/gui/static/css/central.css
@@ -29,6 +29,9 @@
--shadow-pop: 0 4px 16px rgba(0,0,0,0.08);
--radius: 6px;
--radius-sm: 4px;
+ /* Fused fire view (v0.9.14): retune here without touching JS. */
+ --fire-confirmed: #d7261e; /* perimeter + hotspots */
+ --fire-unconfirmed: #f59e0b; /* hotspots only -- possible new fire */
}
/* ─── element base ──────────────────────────────────────────────────────── */
@@ -578,6 +581,9 @@ table tbody tr:hover { background: var(--bg-soft); }
.evt-triangle { border: none; clip-path: polygon(50% 0%, 100% 100%, 0% 100%); }
.evt-star { border: none; clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); }
.evt-marker.evt-hl { filter: drop-shadow(0 0 4px #ff3333); transform: scale(1.35); }
+/* Fused-fire centroid glyph: larger, with a soft glow to read as 'a fire'. */
+.evt-marker.fire-glyph { width: 17px; height: 17px; border-color: rgba(0,0,0,0.7);
+ box-shadow: 0 0 6px 1px rgba(215,38,30,0.55); }
/* pagination */
.paginator { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 16px; font-size: 13px; }
diff --git a/src/central/gui/templates/events_list.html b/src/central/gui/templates/events_list.html
index 7532b0a..d5102cc 100644
--- a/src/central/gui/templates/events_list.html
+++ b/src/central/gui/templates/events_list.html
@@ -96,6 +96,10 @@
Filter table by map view
+
@@ -203,6 +207,7 @@
spiderfyOnMaxZoom: true
}).addTo(map);
var polyGroup = L.featureGroup().addTo(map);
+ var fireFusedGroup = L.featureGroup().addTo(map); // v0.9.14 fused fire view
var highlightedRow = null;
var highlightedLayer = null;
var isInitialLoad = true;
@@ -299,15 +304,112 @@
return html;
}
+ // --- Fused fire view (v0.9.14) -----------------------------------------
+ // Fire-domain adapters whose rows are replaced by the fused renderer.
+ var FIRE_ADAPTERS = { firms: 1, wfigs_perimeters: 1, wfigs_incidents: 1 };
+ var fuseFireToggle = document.getElementById("fuse-fire-toggle");
+ if (fuseFireToggle) fuseFireToggle.addEventListener("change", rebindEventLayers);
+ function fuseFireOn() { return fuseFireToggle && fuseFireToggle.checked; }
+ // Colors come from CSS vars so they can be retuned without code.
+ function cssVar(name, fallback) {
+ var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+ return v || fallback;
+ }
+ function fireConfirmedColor() { return cssVar("--fire-confirmed", "#d7261e"); }
+ function fireUnconfirmedColor() { return cssVar("--fire-unconfirmed", "#f59e0b"); }
+
+ function fireFusedPopup(fire) {
+ var name = fire.incident_name || "(unnamed fire)";
+ var n = fire.hotspot_count || 0;
+ var html = "🔥 " + name + "
Confirmed fire (perimeter + " +
+ n + " hotspot" + (n === 1 ? "" : "s") + ")";
+ if (fire.acres != null) html += "
" + Math.round(fire.acres).toLocaleString() + " acres";
+ if (fire.cause) html += "
Cause: " + fire.cause;
+ if (fire.max_frp != null) html += "
Max FRP: " + fire.max_frp;
+ if (fire.irwin_id) html += "
" + fire.irwin_id + "";
+ return html;
+ }
+ function unconfirmedPopup(h) {
+ var html = "⚠ Unconfirmed hotspot
Satellite detection, no perimeter yet";
+ if (h.satellite) html += "
Satellite: " + h.satellite;
+ if (h.frp) html += "
FRP: " + h.frp;
+ if (h.confidence) html += "
Confidence: " + h.confidence;
+ var t = h.time ? new Date(h.time).toLocaleString() : "";
+ if (t) html += "
" + t + "";
+ return html;
+ }
+
+ function drawConfirmedFire(fire, color) {
+ if (fire.geometry) {
+ var poly = L.geoJSON(fire.geometry, { style: { color: color, weight: 2,
+ fillColor: color, opacity: 0.9, fillOpacity: 0.25 } });
+ poly.bindPopup(fireFusedPopup(fire));
+ poly.addTo(fireFusedGroup);
+ }
+ (fire.hotspots || []).forEach(function(h) {
+ if (!h.geometry) return;
+ L.geoJSON(h.geometry, { pointToLayer: function(f, ll) {
+ return L.circleMarker(ll, { radius: 3, color: color, weight: 1,
+ fillColor: color, fillOpacity: 0.8 }); },
+ style: { color: color, weight: 1, fillColor: color, fillOpacity: 0.55 }
+ }).addTo(fireFusedGroup);
+ });
+ // Collapsed glyph at the perimeter centroid; click expands (zoom to bounds).
+ var center = centroidLatLng(fire.geometry);
+ if (center) {
+ var glyph = L.marker(center, { icon: L.divIcon({ className: "",
+ html: '