// ───────────────────────────────────────────────────────────────────────────── // TreeGuard 1.1 — Tree Fall map (CLUSTER model, real data) // // Vegetation is resolved as CLUSTERS of neighbouring trees (one cluster per // conductor span). Cluster definitions come from window.CLUSTERS (shared with // the dashboard) and window.LINES (the 3 HV lines). The map shows ONE line at a // time; its spans are laid left→right along a scrollable corridor. // // UNITS — kept deliberately separate (the data spans kilometres, the canvas is // pixels): // * tier classification runs in METRES (dist, fall, corridor all in m) // * placement / drawing runs in PIXELS (perp = dist_m * PERP, etc.) // At PX_PER_M ≠ 1 these must not be mixed, or spans get the wrong tier. // ───────────────────────────────────────────────────────────────────────────── const H = 760; // canvas height (px) const SPAN_W = 140; // px per span along the corridor (readable; line scrolls) const PERP = 2.2; // px per metre — perpendicular (the risk axis) const ALONG = 1.3; // px per metre — along-line (visual spread of members only) const MARGIN = 90; // px padding at each end of the corridor const LINES = window.LINES || []; const ALL_CLUSTERS = window.CLUSTERS || []; const TIER_RANK = { stable: 0, alert: 1, alarm: 2 }; // Height-aware fall-risk tier: a tree strikes the conductor when it is tall enough // to span the 3D gap to it (m.gap, precomputed in the ETL). `margin` is the growth/ // uncertainty allowance (the slider). Mirrors tools/build_data.py. const tierAt = (h, gap, margin) => (h >= gap ? 'alarm' : (h + margin >= gap ? 'alert' : 'stable')); const tierLabel = (t) => (t === 'alarm' ? 'Alarm' : t === 'alert' ? 'Alert' : 'Clear'); // Corridor polygon — buffer (px) on each side of the tower spine (bisector offset). const buildCorridor = (towers, halfPx) => { const top = [], bot = []; const n = towers.length; for (let i = 0; i < n; i++) { let dx, dy; if (i === 0) { dx = towers[1].x - towers[0].x; dy = towers[1].y - towers[0].y; } else if (i === n - 1) { dx = towers[n-1].x - towers[n-2].x; dy = towers[n-1].y - towers[n-2].y; } else { const a = towers[i-1], b = towers[i], c = towers[i+1]; const dx1 = b.x - a.x, dy1 = b.y - a.y, dx2 = c.x - b.x, dy2 = c.y - b.y; const l1 = Math.hypot(dx1, dy1) || 1, l2 = Math.hypot(dx2, dy2) || 1; dx = dx1/l1 + dx2/l2; dy = dy1/l1 + dy2/l2; } const len = Math.hypot(dx, dy) || 1; const nx = -dy/len, ny = dx/len; const t = towers[i]; top.push({ x: t.x + nx*halfPx, y: t.y + ny*halfPx }); bot.push({ x: t.x - nx*halfPx, y: t.y - ny*halfPx }); } return [...top, ...[...bot].reverse()] .map((p,i) => `${i===0?'M':'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ') + ' Z'; }; // Build the schematic geometry for one line: towers laid left→right with a gentle // decorative wiggle; members projected by (da along-line, dist perpendicular). const buildLineGeom = (lineId) => { const cl = ALL_CLUSTERS.filter(c => c.line === lineId).slice().sort((a, b) => a.spanNum - b.spanNum); const n = cl.length; const W = MARGIN * 2 + Math.max(1, n) * SPAN_W; const cy0 = H / 2; const towers = Array.from({ length: n + 1 }, (_, i) => { const x = MARGIN + i * SPAN_W; const t = i / Math.max(1, n); const y = cy0 + Math.sin(t * Math.PI * 4.2 + 0.4) * 46 + Math.sin(t * Math.PI * 9.1 + 1.0) * 16; return { id: i + 1, x, y }; }); const frame = (spanNum) => { const a = towers[spanNum - 1], b = towers[spanNum] || towers[spanNum - 1]; const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len; return { a, b, ux, uy, nx: -uy, ny: ux }; }; const clusters = cl.map(c => { const f = frame(c.spanNum); const ax = (f.a.x + f.b.x) / 2, ay = (f.a.y + f.b.y) / 2; // anchor = span midpoint const members = c.members.map((m, i) => { const along = m.da * ALONG, perp = m.dist * PERP; return { ...m, mi: i, x: ax + f.ux * along + f.nx * m.side * perp, y: ay + f.uy * along + f.ny * m.side * perp, crownPx: Math.max(2.5, m.crownR * PERP), fallPx: m.fall * PERP, }; }); const cx = members.reduce((s, m) => s + m.x, 0) / members.length; const cy = members.reduce((s, m) => s + m.y, 0) / members.length; const hitR = Math.max(...members.map(m => Math.hypot(m.x - cx, m.y - cy) + m.crownPx)) + 6; return { ...c, ax, ay, cx, cy, hitR, members }; }); return { W, H, towers, clusters }; }; // Contour ellipses for terrain texture, tiled across the (wide) canvas. const buildContours = (W) => Array.from({ length: Math.max(8, Math.ceil(W / 220)) }, (_, i) => { const a = i * 37.3; return { cx: (i * 233) % W, cy: 380 + Math.cos(a) * 230, rx: 110 + i % 5 * 44, ry: 70 + i % 7 * 28, rot: i * 19, major: i % 4 === 0 }; }); // Tree-fall radius envelope — outer support outline of every member's fall disk (px). const buildEnvelopePath = (members, cx, cy, samples = 64) => { const pts = []; for (let i = 0; i < samples; i++) { const th = (i / samples) * Math.PI * 2; const ux = Math.cos(th), uy = Math.sin(th); let best = -Infinity; for (const m of members) { const d = (m.x - cx) * ux + (m.y - cy) * uy + m.fallPx; if (d > best) best = d; } pts.push([cx + ux * best, cy + uy * best]); } return pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ') + ' Z'; }; const ClusterMarker = React.memo(({ c, analysisM, selected, onEnter, onMove, onLeave, onClick }) => { const vis = c.members.filter(m => (m.dist - m.crownR) <= analysisM); if (!vis.length) return null; const envelope = buildEnvelopePath(vis, c.cx, c.cy); return ( {vis.map(m => )} {selected && } onEnter(c,e)} onMouseMove={e=>onMove(c,e)} onMouseLeave={onLeave} onClick={e=>onClick && onClick(c,e)} /> ); }); const MapCanvas = ({ filters, selectedSpan, onSpanClick, onSpanInfo, setTip, tip, analysisM, analysisDraftM, dirty, onMapInteract, geom, clusters, spanTier, focusCluster, selectedCluster, onClusterClick }) => { const ref = React.useRef(); // .map-stage — wheel target + measured viewport const svgRef = React.useRef(); // the — drag target + getScreenCTM source const sizeRef = React.useRef({ w: 0, h: 0 }); // live container size (px) const geomRef = React.useRef(geom); geomRef.current = geom; // live world geometry (W) const panRef = React.useRef(null); // active drag {sx,sy,vx,vy,vw,vh,rw,rh,moved} const draggedRef = React.useRef(false); // last gesture moved → suppress click const [locked, setLocked] = React.useState(null); const [panning, setPanning] = React.useState(false); const { W, towers } = geom; // viewBox window into world coords (0..W × 0..H). Seeded to a height-filling guess so the // first paint isn't the whole 16 000 px line; the measure effect refits to the exact aspect. const [view, setView] = React.useState(() => ({ x: 0, y: 0, w: Math.min(W, H * 1.7), h: H })); const viewRef = React.useRef(view); viewRef.current = view; // current view for non-reactive reads const analysisPx = analysisM * PERP, analysisDraftPx = analysisDraftM * PERP; // ── viewBox clamps ────────────────────────────────────────────────────────── // Zoom-out is capped near the height-filling width (the strip keeps ~filling the // viewport — no zooming out to a thin band); zoom-in stops at ~1.4 spans. Panning is // bounded to the world; a line narrower/shorter than the view is centred. const clampWidth = React.useCallback((w) => { const { w: cW, h: cH } = sizeRef.current; const aspect = (cW && cH) ? cW / cH : 16 / 9; const maxW = H * aspect * 1.6; const minW = Math.min(SPAN_W * 1.4, maxW); return Math.min(Math.max(w, minW), maxW); }, []); const place = React.useCallback((x, y, w, h) => { const Wn = geomRef.current.W; const nx = (w >= Wn) ? (Wn - w) / 2 : Math.min(Math.max(x, 0), Wn - w); const ny = (h >= H) ? (H - h) / 2 : Math.min(Math.max(y, 0), H - h); return { x: nx, y: ny, w, h }; }, []); const makeInitial = React.useCallback(() => { const { w: cW, h: cH } = sizeRef.current; const aspect = (cW && cH) ? cW / cH : 16 / 9; return place(0, 0, H * aspect, H); // fill the height, anchored at the line's left edge }, [place]); // Dismiss stale overlays (pinned tooltip, hover tip, span popover) on any pan/zoom. const dismissRef = React.useRef(() => {}); dismissRef.current = () => { setLocked(null); setTip(null); onMapInteract && onMapInteract(); }; // Measure the stage and (re)fit the viewBox to its aspect. On resize, the centre/zoom are // preserved (only the height is re-derived); the first measure seeds the initial view. React.useEffect(() => { const el = ref.current; if (!el) return; const measure = () => { const r = el.getBoundingClientRect(); const prev = sizeRef.current; sizeRef.current = { w: r.width, h: r.height }; setView(v => { if (!prev.w || !prev.h) return makeInitial(); const aspect = (r.width && r.height) ? r.width / r.height : 16 / 9; const cx = v.x + v.w / 2, cy = v.y + v.h / 2, hh = v.w / aspect; return place(cx - v.w / 2, cy - hh / 2, v.w, hh); }); }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); window.addEventListener('resize', measure); // covers window resize; RO covers panel/layout changes return () => { ro.disconnect(); window.removeEventListener('resize', measure); }; }, [makeInitial, place]); // Reset the view whenever the line (geometry) changes. React.useEffect(() => { setView(makeInitial()); }, [geom, makeInitial]); // Wheel = zoom toward the cursor. Native non-passive listener — React's synthetic onWheel // is passive and can't preventDefault (the page would scroll instead of the map zooming). React.useEffect(() => { const el = ref.current; if (!el) return; const onWheel = (e) => { e.preventDefault(); const r = el.getBoundingClientRect(); setView(v => { const { w: cW, h: cH } = sizeRef.current; const aspect = (cW && cH) ? cW / cH : v.w / v.h; const fx = (e.clientX - r.left) / r.width, fy = (e.clientY - r.top) / r.height; const wx = v.x + fx * v.w, wy = v.y + fy * v.h; // world point under the cursor const w = clampWidth(v.w * (e.deltaY < 0 ? 0.85 : 1 / 0.85)), hh = w / aspect; return place(wx - fx * w, wy - fy * hh, w, hh); // keep that point under the cursor }); dismissRef.current(); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, [clampWidth, place]); // Drag = pan. A move > 4 px commits the drag (and flags draggedRef so the trailing click is // suppressed); listeners live on window so a fast drag that leaves the canvas still tracks. React.useEffect(() => { if (!panning) return; const onMove = (e) => { const p = panRef.current; if (!p) return; const dx = e.clientX - p.sx, dy = e.clientY - p.sy; if (!p.moved && Math.hypot(dx, dy) > 4) { p.moved = true; draggedRef.current = true; dismissRef.current(); } if (!p.moved) return; const wx = p.vx - dx * (p.vw / p.rw), wy = p.vy - dy * (p.vh / p.rh); setView(v => place(wx, wy, v.w, v.h)); }; const onUp = () => { panRef.current = null; setPanning(false); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [panning, place]); const onSvgMouseDown = (e) => { if (e.button !== 0) return; e.preventDefault(); const r = svgRef.current.getBoundingClientRect(); panRef.current = { sx: e.clientX, sy: e.clientY, vx: view.x, vy: view.y, vw: view.w, vh: view.h, rw: r.width, rh: r.height, moved: false }; draggedRef.current = false; setPanning(true); }; const zoomBy = (factor) => { setView(v => { const { w: cW, h: cH } = sizeRef.current; const aspect = (cW && cH) ? cW / cH : v.w / v.h; const cx = v.x + v.w / 2, cy = v.y + v.h / 2; const w = clampWidth(v.w * factor), hh = w / aspect; return place(cx - w / 2, cy - hh / 2, w, hh); }); dismissRef.current(); }; // Deep-link / list focus → centre the viewBox on the cluster and pin its tooltip. The pin is // computed directly from the new viewBox (world→screen is an exact linear map because the // viewBox aspect matches the container) — no RAF/getScreenCTM, so it can't race React's commit. React.useEffect(() => { if (focusCluster == null || !ref.current) return; const c = clusters.find(x => x.id === focusCluster); if (!c) { setLocked(null); return; } const cv = viewRef.current; // current zoom (read, don't depend on it) const nv = place(c.cx - cv.w / 2, c.cy - cv.h / 2, cv.w, cv.h); setView(nv); const r = ref.current.getBoundingClientRect(); setLocked({ c, cx: ((c.cx - nv.x) / nv.w) * r.width, cy: ((c.cy - nv.y) / nv.h) * r.height }); }, [focusCluster, clusters, analysisM, place]); const h = { onEnter: (c, e) => { if (panRef.current) return; const r = ref.current.getBoundingClientRect(); setTip({ c, cx: e.clientX - r.left, cy: e.clientY - r.top }); }, onMove: (c, e) => { if (panRef.current) return; const r = ref.current.getBoundingClientRect(); setTip({ c, cx: e.clientX - r.left, cy: e.clientY - r.top }); }, onLeave: () => setTip(null), onClick: (c, e) => { e.stopPropagation(); if (draggedRef.current) { draggedRef.current = false; return; } const r = ref.current.getBoundingClientRect(); setLocked({ c, cx: e.clientX - r.left, cy: e.clientY - r.top }); onClusterClick && onClusterClick(c); }, }; const onBgClick = (e) => { if (draggedRef.current) { draggedRef.current = false; return; } if (!(e.target.classList && e.target.classList.contains('cluster-hit'))) setLocked(null); }; const tt = tip || locked; // Scale bar is a fixed 60 screen px — relabel it from the live zoom (world m under those px). const scaleM = (view && sizeRef.current.w) ? Math.round(60 * (view.w / sizeRef.current.w) / PERP) : Math.round(60 / PERP); const spanColor = (spanNum) => { const t = spanTier(spanNum); return t === 'alarm' ? 'var(--status-alarm)' : t === 'alert' ? 'var(--status-alert)' : null; }; return (
{buildContours(W).map((c,i)=>( ))} {/* Analysis zone — the perpendicular band we examine (hatched). Risk is height- based (3D reach), so there is no separate perpendicular "safety corridor" band. */} {dirty && analysisDraftPx !== analysisPx && ( )} {['stable','alert','alarm'].map(tier => clusters.filter(c => c.tier === tier && filters[c.tier]).map(c => ( )) )} {towers.slice(0,-1).map((a,si)=>{ const b = towers[si+1]; const col = spanColor(si+1); const handle = (e) => { if (draggedRef.current) { draggedRef.current = false; return; } const r = ref.current.getBoundingClientRect(); onSpanInfo && onSpanInfo(si+1, e.clientX - r.left, e.clientY - r.top); onSpanClick(si+1); }; return col ? ( ) : ( ); })} {towers.map(tw=>( {tw.id % 5 === 1 && ( T{tw.id} )} ))}
Tree-cluster fall risk
Alarm
A tree is tall enough to fall onto the conductor
Alert
A tree reaches the conductor within the growth margin
Clear
No tree can reach the conductor
Filled shape = the cluster's canopy outline · dashed outer line = tree-fall radius of the outermost canopies
{scaleM} m
{tt && (() => { const c = tt.c; const sw = ref.current ? ref.current.getBoundingClientRect().width : 1200; const sh = ref.current ? ref.current.getBoundingClientRect().height : 700; const left = tt.cx + 16 + 264 > sw ? Math.max(8, tt.cx - 264) : tt.cx + 16; const top = Math.max(8, Math.min(tt.cy + 14, sh - 250)); const breachers = c.members.filter(m => m.ltier !== 'stable').sort((a,b)=>a.dist-b.dist); return (
{c.label} {tierLabel(c.tier)}
SpanT{c.spanNum}–T{c.spanNum+1} · {c.span}
Trees in cluster{c.treeCount}
At-risk trees{c.breaching}
Closest tree{c.closestDist.toFixed(1)} m
Avg vitality{Math.round(c.vitality*100)}%
Tallest{c.tallest.toFixed(1)} m
{breachers.length > 0 && (
Nearest at-risk trees
{breachers.slice(0,3).map((m,i)=>(
{m.h.toFixed(0)} m tall {m.dist.toFixed(1)} m from line
))}
)}
{c.lat}°N, {Math.abs(c.lng)}°W
); })()}
); }; // ───────────────────────────────────────────────────────────────────────────── // APP // ───────────────────────────────────────────────────────────────────────────── const App = () => { const [filters, setFilters] = React.useState({ alarm:true, alert:true, stable:true }); const toggle = (k) => setFilters(f=>({...f,[k]:!f[k]})); const [filterOpen, setFilterOpen] = React.useState(false); const [selectedLine, setSelectedLine] = React.useState((LINES[0] || {}).id); // `corridorM` is the fall-risk MARGIN (growth/uncertainty allowance, metres) — the // tier slider. (Name kept for brevity; it is no longer a perpendicular corridor width.) const [corridorM, setCorridorM] = React.useState(5); const [analysisM, setAnalysisM] = React.useState(125); const [corridorApplied, setCorridorApplied] = React.useState(5); const [analysisApplied, setAnalysisApplied] = React.useState(125); const dirty = corridorM !== corridorApplied || analysisM !== analysisApplied; React.useEffect(() => { const handler = () => setFilterOpen(o => !o); window.addEventListener('spotlite:toggle-filters', handler); return () => window.removeEventListener('spotlite:toggle-filters', handler); }, []); const geom = React.useMemo(() => buildLineGeom(selectedLine), [selectedLine]); // Live cluster classification under the applied corridor + analysis area. // Only the TIER is recomputed live (the closest stored members govern it); // treeCount / breaching / closestDist stay the true ETL aggregates (the stored // members are capped, so they can't be re-counted accurately here). const liveClusters = React.useMemo(() => geom.clusters.map(c => { let worst = 'stable'; const members = c.members.map(m => { const lt = (m.dist - m.crownR) <= analysisApplied ? tierAt(m.h, m.gap, corridorApplied) : 'stable'; if (TIER_RANK[lt] > TIER_RANK[worst]) worst = lt; return m.ltier === lt ? m : { ...m, ltier: lt }; }); return { ...c, members, tier: worst }; }), [geom, corridorApplied, analysisApplied]); const counts = React.useMemo(() => { const o = { alarm:0, alert:0, stable:0 }; liveClusters.forEach(c => o[c.tier]++); return o; }, [liveClusters]); const [selectedSpan, setSelectedSpan] = React.useState(null); const [selectedCluster, setSelectedCluster] = React.useState(null); const [tip, setTip] = React.useState(null); const [focusCluster, setFocusCluster] = React.useState(null); const [spanInfo, setSpanInfo] = React.useState(null); const [search, setSearch] = React.useState(''); const [panelMode, setPanelMode] = React.useState('clusters'); const [clusterTab, setClusterTab] = React.useState('alarms'); // Deep links from the dashboard (#cluster= / #tier= / #asset=). React.useEffect(() => { const hash = (window.location.hash || '').replace(/^#/, ''); if (!hash) return; const params = new URLSearchParams(hash); if (params.has('cluster')) { const id = params.get('cluster'); const c = ALL_CLUSTERS.find(x => x.id === id); if (c) { setSelectedLine(c.line); setPanelMode('clusters'); setClusterTab(c.tier === 'alert' ? 'alerts' : 'alarms'); setSelectedCluster(c.id); setSelectedSpan(c.spanNum); setFocusCluster(c.id); return; } } if (params.has('tier')) { const tier = params.get('tier'); if (tier === 'alarm' || tier === 'alert') { setPanelMode('clusters'); setClusterTab(tier === 'alarm' ? 'alarms' : 'alerts'); return; } } if (params.has('asset')) { const c = ALL_CLUSTERS.find(x => x.id === params.get('asset')); if (c) { setSelectedLine(c.line); setPanelMode('assets'); setSelectedSpan(c.spanNum); } return; } }, []); // ── Span tier (one cluster per span) + baseline-delta exploration ── const BASE_CORRIDOR_M = 5, BASE_ANALYSIS_M = 125; const spanTierAt = React.useCallback((spanNum, corM, anaM) => { let worst = 'stable'; const c = geom.clusters.find(x => x.spanNum === spanNum); if (!c) return worst; for (const m of c.members) { if ((m.dist - m.crownR) > anaM) continue; const t = tierAt(m.h, m.gap, corM); if (TIER_RANK[t] > TIER_RANK[worst]) worst = t; } return worst; }, [geom]); const spanTier = React.useCallback((spanNum) => spanTierAt(spanNum, corridorApplied, analysisApplied), [spanTierAt, corridorApplied, analysisApplied]); const spanDelta = React.useCallback((spanNum) => { const cur = spanTierAt(spanNum, corridorApplied, analysisApplied); const base = spanTierAt(spanNum, BASE_CORRIDOR_M, BASE_ANALYSIS_M); if (cur === base) return null; return { dir: TIER_RANK[cur] > TIER_RANK[base] ? 'up' : 'down', from: base, to: cur }; }, [spanTierAt, corridorApplied, analysisApplied]); const baselineChanged = corridorApplied !== BASE_CORRIDOR_M || analysisApplied !== BASE_ANALYSIS_M; const hiddenByAnalysis = React.useMemo(() => { if (analysisApplied >= BASE_ANALYSIS_M) return { alarm: 0, alert: 0 }; let alarm = 0, alert = 0; for (const c of geom.clusters) { for (const m of c.members) { const within = (m.dist - m.crownR) <= analysisApplied; const wouldShow = (m.dist - m.crownR) <= BASE_ANALYSIS_M; if (within || !wouldShow) continue; const t = tierAt(m.h, m.gap, corridorApplied); if (t === 'alarm') alarm++; else if (t === 'alert') alert++; } } return { alarm, alert }; }, [geom, corridorApplied, analysisApplied]); const changedSpanCount = React.useMemo( () => geom.clusters.filter(c => spanDelta(c.spanNum)).length, [geom, spanDelta]); // Span (asset) list — this line's spans, sorted by live risk. const SPAN_LIST = React.useMemo(() => geom.clusters.map(c => { const t = spanTier(c.spanNum); return { id: c.spanNum, seq: c.spanSeq, label: `Span ${c.spanSeq} · T${c.spanNum}→T${c.spanNum+1}`, tier: t === 'stable' ? null : t, delta: spanDelta(c.spanNum) }; }).sort((a,b)=>{ const o={alarm:0,alert:1,null:2}; return (o[a.tier]??2)-(o[b.tier]??2) || a.seq-b.seq; }), [geom, spanTier, spanDelta]); const clusterTier = clusterTab === 'alarms' ? 'alarm' : 'alert'; const CLUSTER_LIST = liveClusters .filter(c => c.tier === clusterTier) .filter(c => { if (!search) return true; const q = search.toLowerCase(); return c.label.toLowerCase().includes(q) || c.id.toLowerCase().includes(q) || c.span.toLowerCase().includes(q) || String(c.spanSeq).includes(q); }) .sort((a,b) => a.closestDist - b.closestDist); const filteredSpans = SPAN_LIST.filter(s => !search || s.label.toLowerCase().includes(search.toLowerCase())); const togglePanel = (mode) => setPanelMode(m => m === mode ? null : mode); const ASSET_COUNT = SPAN_LIST.length; const CLUSTER_TOTAL = counts.alarm + counts.alert; const selectedLineObj = LINES.find(L => L.id === selectedLine) || {}; const switchLine = (id) => { if (id === selectedLine) return; setSelectedLine(id); setSelectedSpan(null); setSelectedCluster(null); setFocusCluster(null); setTip(null); setSpanInfo(null); }; const selectCluster = (c) => { setSelectedCluster(c.id === selectedCluster ? null : c.id); setSelectedSpan(c.spanNum); setFocusCluster(c.id === selectedCluster ? null : c.id); }; return (
{LINES.map(L => ( ))}
Dashboard
{baselineChanged && (
Criticality re-evaluated for {corridorApplied} m margin / {analysisApplied} m analysis {changedSpanCount > 0 && <> · {changedSpanCount} span{changedSpanCount===1?'':'s'} changed} {(hiddenByAnalysis.alarm > 0 || hiddenByAnalysis.alert > 0) && ( <> · hidden: {hiddenByAnalysis.alarm} alarm, {hiddenByAnalysis.alert} alert )}
)}
setSelectedSpan(id===selectedSpan?null:id)} tip={tip} setTip={setTip} analysisM={analysisApplied} analysisDraftM={analysisM} dirty={dirty} geom={geom} clusters={liveClusters} spanTier={spanTier} focusCluster={focusCluster} selectedCluster={selectedCluster} onMapInteract={() => setSpanInfo(null)} onClusterClick={(c)=>{ setSelectedCluster(c.id); setSelectedSpan(c.spanNum); setPanelMode('clusters'); setClusterTab(c.tier==='alert'?'alerts':'alarms'); }} onSpanInfo={(spanNum, sx, sy) => { const c = liveClusters.find(x => x.spanNum === spanNum); setSpanInfo({ n: spanNum, seq: c ? c.spanSeq : spanNum, designation: c ? c.designation : '', tier: spanTier(spanNum), alarms: c ? c.alarms : 0, alerts: c ? c.alerts : 0, x: sx, y: sy }); }} /> setSpanInfo(null)}/>
); }; ReactDOM.createRoot(document.getElementById('root')).render();