diff --git a/app/static/index.html b/app/static/index.html
index 3900d4f..86cd36e 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -352,6 +352,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; }
@@ -411,6 +412,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 {
@@ -941,6 +943,13 @@
0
+
Fire heat intensity
@@ -1942,7 +1951,8 @@ let acGroup = null, acOn = true, acMilOn = false, acMilSupported = false;
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 = '';
@@ -2210,8 +2220,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(() => {
@@ -3484,6 +3496,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" in HTML
+ on = HTML.split('id="lp-conflicts-on"', 1)[1].split(">", 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