From 2ec210aaa42972ab87684d80a17903a8d4e32867 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Mon, 31 Aug 2026 21:37:10 -0400 Subject: [PATCH] feat(map): Conflicts layer toggle from /api/conflicts Default-off LAYERS row paints L.circleMarker theatres colored by severity (war/high/elevated). Popup shows label, description, and eventCount. Catalog is fetched once (not on moveend). A 404 hides the toggle. Null lat/lon rows are skipped so they never pin at 0,0. --- app/static/index.html | 104 ++++++++++++++++++++++++++++++- tests/test_conflicts_frontend.py | 66 ++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 tests/test_conflicts_frontend.py diff --git a/app/static/index.html b/app/static/index.html index 02122d1..9298d03 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -341,6 +341,7 @@ .lp-dot.wfigs { background: #ef4444; box-shadow: 0 0 7px #ef4444; } .lp-dot.trains { background: #c084fc; box-shadow: 0 0 7px #c084fc; } .lp-dot.storms { background: #f472b6; box-shadow: 0 0 7px #f472b6; } + .lp-dot.conflicts { background: #ff2a6d; box-shadow: 0 0 7px #ff2a6d; } .lp-count { font-family: 'Share Tech Mono', monospace; font-size: 0.7rem; color: var(--cyan); } .lp-sub { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; } .lp-opacity { display: flex; align-items: center; gap: 0.4rem; font-size: 0.62rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } @@ -396,6 +397,7 @@ .blip-pop { min-width: 200px; max-width: 260px; } .blip-pop .blip-src { font-family: 'Share Tech Mono', monospace; font-size: 0.62rem; color: var(--cyan); text-transform: uppercase; letter-spacing: 0.08em; } .blip-pop .blip-time { font-size: 0.7rem; color: var(--muted); font-family: 'Share Tech Mono', monospace; margin: 0.2rem 0 0.3rem; } + .blip-pop .blip-desc { font-size: 0.72rem; color: var(--text); line-height: 1.35; margin-top: 0.15rem; } /* ── Sub-views (News / Events / Alerts / ... ) ── */ .subview { @@ -922,6 +924,13 @@ 0 +
+
+ + 0 +
+
Curated theatres · color by severity · news counts, not LiveUAMap.
+

Fire heat intensity

@@ -1923,7 +1932,8 @@ let acGroup = null, acOn = true; let trainsGroup = null, trainsOn = true; let vesselsGroup = null, vesselsOn = false; let stormsGroup = null, stormsOn = true; -let overlayReq = {ac:0, trains:0, vessels:0, alerts:0, perim:0, incidents:0, storms:0, sar:0}; +let conflictsGroup = null, conflictsOn = false, conflictsCache = null; +let overlayReq = {ac:0, trains:0, vessels:0, alerts:0, perim:0, incidents:0, storms:0, sar:0, conflicts:0}; let moveDebounce = null; let overlayAbort = null; let lastCell = ''; @@ -2159,8 +2169,10 @@ async function initMap() { trainsOn = document.getElementById('lp-trains-on').checked; vesselsOn = document.getElementById('lp-vessels-on').checked; stormsOn = document.getElementById('lp-storms-on').checked; + conflictsOn = document.getElementById('lp-conflicts-on').checked; connectLiveWs(); loadChokepoints(); + probeConflicts(); requestAnimationFrame(() => { if (firesOn) loadFires(); setTimeout(() => { @@ -3411,6 +3423,96 @@ async function loadStorms() { } } +function conflictSeverityColor(sev) { + const s = String(sev || '').toLowerCase(); + if (s === 'war') return '#ff2a6d'; + if (s === 'high') return '#fb923c'; + if (s === 'elevated') return '#facc15'; + return '#35e0ff'; +} +function hideConflictsToggle() { + const row = document.getElementById('conflicts-layer'); + if (row) row.hidden = true; + const cb = document.getElementById('lp-conflicts-on'); + if (cb) cb.checked = false; + conflictsOn = false; + conflictsCache = null; + conflictsGroup = dropLayer(conflictsGroup); +} +function paintConflicts() { + if (!map || !conflictsOn) return; + const zones = (conflictsCache && Array.isArray(conflictsCache.zones)) ? conflictsCache.zones : []; + conflictsGroup = dropLayer(conflictsGroup); + const markers = []; + for (const z of zones) { + if (z.lat == null || z.lon == null) continue; + const lat = Number(z.lat), lon = Number(z.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + const col = conflictSeverityColor(z.severity); + const sev = String(z.severity || '').toLowerCase(); + const radius = sev === 'war' ? 10 : sev === 'high' ? 8 : 7; + const m = L.circleMarker([lat, lon], { + radius, + color: col, + fillColor: col, + fillOpacity: 0.28, + weight: 2, + className: 'conflict-zone', + }); + const n = Number(z.eventCount); + const count = Number.isFinite(n) ? n : 0; + m.bindPopup( + `
` + + `
${esc(z.severity || '')} · ${esc(count)} events
` + + `${esc(z.label || '')}` + + `
${esc(z.description || '')}
` + + `
` + ); + markers.push(m); + } + conflictsGroup = L.layerGroup(markers).addTo(map); + const countEl = document.getElementById('lp-conflicts-count'); + if (countEl) countEl.textContent = markers.length.toLocaleString(); + addExtraAttrib('Curated OSINT conflict catalog'); +} +async function probeConflicts() { + await loadConflicts(false); +} +async function toggleConflicts() { + const cb = document.getElementById('lp-conflicts-on'); + conflictsOn = !!(cb && cb.checked); + if (conflictsOn) await loadConflicts(true); + else conflictsGroup = dropLayer(conflictsGroup); +} +async function loadConflicts(paint) { + const shouldPaint = paint === true || conflictsOn; + const req = ++overlayReq.conflicts; + const countEl = document.getElementById('lp-conflicts-count'); + try { + if (!conflictsCache) { + // Own fetch — catalog is viewport-independent; overlayAbort on + // moveend must not cancel this (and we never refetch on pan). + const r = await fetch(`${API}/api/conflicts`); + if (req !== overlayReq.conflicts) return; + if (r.status === 404) { + hideConflictsToggle(); + return; + } + if (!r.ok) throw new Error('conflicts ' + r.status); + const body = await r.json(); + if (req !== overlayReq.conflicts) return; + conflictsCache = body && typeof body === 'object' ? body : { zones: [] }; + } + const zones = Array.isArray(conflictsCache.zones) ? conflictsCache.zones : []; + if (countEl) countEl.textContent = zones.length.toLocaleString(); + if (shouldPaint) paintConflicts(); + } catch (e) { + if (req !== overlayReq.conflicts) return; + console.error('Conflicts load failed', e); + if (countEl) countEl.textContent = 'err'; + } +} + /* ═══════════════ INITIAL LOAD ═══════════════ */ initNav(); initSettings(); diff --git a/tests/test_conflicts_frontend.py b/tests/test_conflicts_frontend.py new file mode 100644 index 0000000..058fd3b --- /dev/null +++ b/tests/test_conflicts_frontend.py @@ -0,0 +1,66 @@ +"""Conflicts Leaflet overlay: default-off toggle, catalog fetch, no jitter.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +HTML = (ROOT / "app/static/index.html").read_text() + + +def _fn(name: str, until: str | None = None) -> str: + chunk = HTML.split(f"function {name}", 1)[1] + if until: + chunk = chunk.split(until, 1)[0] + return chunk + + +def test_conflicts_toggle_default_off(): + assert 'id="lp-conflicts-on"' in HTML + assert 'id="conflicts-layer"' in HTML + assert "> Conflicts<" in HTML or "> Conflicts", 1)[0] + assert "checked" not in on + + +def test_conflicts_fetches_catalog_not_liveuamap(): + js = _fn("loadConflicts", "/* ═══════════════ INITIAL LOAD") + assert "/api/conflicts" in js + assert "liveuamap.com" not in HTML.lower() + assert "Math.random" not in js + assert "jitter" not in js.lower() + + +def test_conflicts_not_refetched_on_moveend(): + refresh = HTML.split("function refreshLiveOverlays", 1)[1].split( + "function addExtraAttrib", 1 + )[0] + assert "loadConflicts" not in refresh + assert "probeConflicts" not in refresh + init = HTML.split("function initMap", 1)[1].split("function readMapPrefs", 1)[0] + assert "probeConflicts()" in init + assert "loadConflicts(true)" not in init + assert "paintConflicts()" not in init + + +def test_conflicts_hides_toggle_on_404(): + js = _fn("loadConflicts", "/* ═══════════════ INITIAL LOAD") + assert "r.status === 404" in js + assert "hideConflictsToggle()" in js + hide = _fn("hideConflictsToggle", "function paintConflicts") + assert "row.hidden = true" in hide + assert "lp-conflicts-on" in hide + + +def test_conflicts_popup_and_severity_colors(): + paint = _fn("paintConflicts", "async function probeConflicts") + assert "z.label" in paint + assert "z.description" in paint + assert "eventCount" in paint + assert "L.circleMarker" in paint + assert "z.lat == null || z.lon == null" in paint + assert "Number.isFinite(lat)" in paint + color = _fn("conflictSeverityColor", "function hideConflictsToggle") + assert "war" in color and "#ff2a6d" in color + assert "high" in color and "#fb923c" in color + assert "elevated" in color and "#facc15" in color