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.
This commit is contained in:
parent
c48788d4b6
commit
2ec210aaa4
2 changed files with 169 additions and 1 deletions
|
|
@ -341,6 +341,7 @@
|
||||||
.lp-dot.wfigs { background: #ef4444; box-shadow: 0 0 7px #ef4444; }
|
.lp-dot.wfigs { background: #ef4444; box-shadow: 0 0 7px #ef4444; }
|
||||||
.lp-dot.trains { background: #c084fc; box-shadow: 0 0 7px #c084fc; }
|
.lp-dot.trains { background: #c084fc; box-shadow: 0 0 7px #c084fc; }
|
||||||
.lp-dot.storms { background: #f472b6; box-shadow: 0 0 7px #f472b6; }
|
.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-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-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; }
|
.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 { 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-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-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 / ... ) ── */
|
/* ── Sub-views (News / Events / Alerts / ... ) ── */
|
||||||
.subview {
|
.subview {
|
||||||
|
|
@ -922,6 +924,13 @@
|
||||||
<span class="lp-count" id="lp-storms-count">0</span>
|
<span class="lp-count" id="lp-storms-count">0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="lp-layer" id="conflicts-layer">
|
||||||
|
<div class="lp-row">
|
||||||
|
<label class="lp-name"><input type="checkbox" id="lp-conflicts-on" onchange="toggleConflicts()"> <span class="lp-dot conflicts"></span> Conflicts</label>
|
||||||
|
<span class="lp-count" id="lp-conflicts-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-note">Curated theatres · color by severity · news counts, not LiveUAMap.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="lp-legend">
|
<div class="lp-legend">
|
||||||
<h4>Fire heat intensity</h4>
|
<h4>Fire heat intensity</h4>
|
||||||
|
|
@ -1923,7 +1932,8 @@ let acGroup = null, acOn = true;
|
||||||
let trainsGroup = null, trainsOn = true;
|
let trainsGroup = null, trainsOn = true;
|
||||||
let vesselsGroup = null, vesselsOn = false;
|
let vesselsGroup = null, vesselsOn = false;
|
||||||
let stormsGroup = null, stormsOn = true;
|
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 moveDebounce = null;
|
||||||
let overlayAbort = null;
|
let overlayAbort = null;
|
||||||
let lastCell = '';
|
let lastCell = '';
|
||||||
|
|
@ -2159,8 +2169,10 @@ async function initMap() {
|
||||||
trainsOn = document.getElementById('lp-trains-on').checked;
|
trainsOn = document.getElementById('lp-trains-on').checked;
|
||||||
vesselsOn = document.getElementById('lp-vessels-on').checked;
|
vesselsOn = document.getElementById('lp-vessels-on').checked;
|
||||||
stormsOn = document.getElementById('lp-storms-on').checked;
|
stormsOn = document.getElementById('lp-storms-on').checked;
|
||||||
|
conflictsOn = document.getElementById('lp-conflicts-on').checked;
|
||||||
connectLiveWs();
|
connectLiveWs();
|
||||||
loadChokepoints();
|
loadChokepoints();
|
||||||
|
probeConflicts();
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (firesOn) loadFires();
|
if (firesOn) loadFires();
|
||||||
setTimeout(() => {
|
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(
|
||||||
|
`<div class="blip-pop">` +
|
||||||
|
`<div class="blip-src">${esc(z.severity || '')} · ${esc(count)} events</div>` +
|
||||||
|
`<b>${esc(z.label || '')}</b>` +
|
||||||
|
`<div class="blip-desc">${esc(z.description || '')}</div>` +
|
||||||
|
`</div>`
|
||||||
|
);
|
||||||
|
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 ═══════════════ */
|
/* ═══════════════ INITIAL LOAD ═══════════════ */
|
||||||
initNav();
|
initNav();
|
||||||
initSettings();
|
initSettings();
|
||||||
|
|
|
||||||
66
tests/test_conflicts_frontend.py
Normal file
66
tests/test_conflicts_frontend.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue