// Dashboard KPI cards + donut + critical assets + tables. /** * useSort — tiny helper for sortable tables. * keys: { colKey: (row) => comparableValue } * initial: { col, dir } * Returns { rows, sort, toggle }. */ const useSort = (data, keys, initial) => { const [sort, setSort] = React.useState(initial); const rows = React.useMemo(() => { if (!sort || !sort.col) return data; const getter = keys[sort.col]; if (!getter) return data; const sorted = [...data].sort((a, b) => { const va = getter(a), vb = getter(b); if (va == null && vb == null) return 0; if (va == null) return 1; if (vb == null) return -1; if (typeof va === 'number' && typeof vb === 'number') return va - vb; return String(va).localeCompare(String(vb), undefined, { numeric: true, sensitivity: 'base' }); }); return sort.dir === 'desc' ? sorted.reverse() : sorted; }, [data, sort, keys]); const toggle = (col) => { setSort(s => { if (!s || s.col !== col) return { col, dir: 'asc' }; if (s.dir === 'asc') return { col, dir: 'desc' }; return { col: null, dir: null }; }); }; return { rows, sort, toggle }; }; /** SortHeader — th with click-to-sort + visual state. */ const SortHeader = ({ colKey, children, sort, onSort, style }) => { const active = sort && sort.col === colKey; const dir = active ? sort.dir : null; const arrow = dir === 'asc' ? '↑' : dir === 'desc' ? '↓' : '↕'; const cls = 'th-sort' + (active ? ' sorted' : ''); return ( onSort(colKey)}> {children} {arrow} ); }; const KpiCard = ({ kind, label, sub, value, share }) => (
{label}
{value}
{sub}
); /** * PerspectiveKpi — one tile per risk perspective (growth / fall / clearance). * Surfaces the alarm count as the headline (the actionable number), * and shows alarm/alert/stable breakdown with a segmented bar. */ const PerspectiveKpi = ({ p }) => { const total = p.alarm + p.alert + p.stable; const at = p.alarm + p.alert; // spans needing attention // Tree fall risk → open the Tree List (panelMode=trees) on the map. // Other perspectives → open the Asset List. const isTreeFall = p.id === 'fall'; const isGrowth = p.id === 'growth'; const isClearance = p.id === 'clearance'; const hashFrag = isTreeFall ? `tier=${p.alarm > 0 ? 'alarm' : 'alert'}` : isGrowth ? `tier=${p.alarm > 0 ? 'alarm' : 'alert'}` : isClearance ? `tier=${p.alarm > 0 ? 'red' : 'yellow'}` : `perspective=${p.id}`; const mapFile = isGrowth ? 'vegetation.html' : isClearance ? 'clearance.html' : 'map.html'; const href = `../map/${mapFile}#${hashFrag}`; const cls = 'kpi perspective ' + (p.alarm > 0 ? 'has-alarm' : p.alert > 0 ? 'has-alert' : 'is-stable'); const inner = (
{p.label}
{at}
/ {total} spans need review
Alarm {p.alarm} Alert {p.alert} Stable {p.stable}
); return {inner}; }; const DonutChart = ({ size = 220, thickness = 28, segments }) => { const cx = size / 2, cy = size / 2; const r = (size - thickness) / 2; const circ = 2 * Math.PI * r; const total = segments.reduce((s, x) => s + x.value, 0); let offset = 0; const GAP = 4; // px gap between segments return ( {segments.map((s, i) => { const len = (s.value / total) * circ; const dash = Math.max(0, len - GAP); const el = ( ); offset += len; return el; })} ); }; const RiskDonut = () => { const w = SPAN_KPIS.worstCase; const segments = [ { name: 'Stable', value: w.stable, color: 'var(--status-stable)' }, { name: 'Alert', value: w.alert, color: 'var(--status-alert)' }, { name: 'Alarm', value: w.alarm, color: 'var(--status-alarm)' }, ]; return (

Risk distribution per span

Each span's worst-case classification across all perspectives.
{SPAN_KPIS.total}
Spans
{segments.map(s => (
{s.name} {Math.round((s.value / SPAN_KPIS.total) * 100)}% {s.value}
))}
); }; const CriticalAssets = () => { const { rows, sort, toggle } = useSort( CRITICAL_ASSETS, { asset: r => r.label, alarms: r => r.alarms, alerts: r => r.alerts, }, { col: 'alarms', dir: 'desc' } ); const goMap = (a) => { window.location.href = `../map/map.html#asset=${encodeURIComponent(a.id || a.label)}`; }; return (

Assets in critical state

See all on map
AssetAlarm treesAlert {rows.map(a => ( goMap(a)} title="Open on map"> ))}
{a.label} {a.alarms} {a.alerts}
); }; const ClusterTable = () => { // High-risk clusters = any cluster with at least one tree within fall range. const DATA = (window.CLUSTERS || []).filter(c => c.tier !== 'stable'); const RANK = { alarm: 2, alert: 1, stable: 0 }; const { rows, sort, toggle } = useSort( DATA, { id: r => parseInt(String(r.spanNum), 10), span: r => r.spanNum, trees: r => r.treeCount, breach: r => r.breaching, closest: r => r.closestDist, vitality: r => r.vitality, risk: r => RANK[r.tier] * 1000 - r.closestDist, // alarm first, then closest }, { col: 'risk', dir: 'desc' } ); return (

High-risk tree clusters

Clusters with at least one tree within fall range of the corridor, ranked by severity.
Click row to open on map
ClusterAssetTreesAt-riskClosest treeAvg. vitality {rows.map(c => ( { window.location.href = `../map/map.html#cluster=${c.id}`; }} title="Open this cluster on the map"> ))}
Coordinates Risk
{c.label} {c.span} {c.treeCount} {c.breaching} / {c.treeCount} {c.closestDist.toFixed(1)} m {Math.round(c.vitality * 100)}% {c.lat}, {c.lng} {c.tier === 'alarm' ? 'Alarm' : 'Alert'}
); }; const InterventionsTable = () => { // Parse '25 Apr 2026' → timestamp for numeric sort const dateTs = (s) => { const d = new Date(s + ' UTC'); return isNaN(d) ? 0 : d.getTime(); }; const statusOrder = { 'cut': 0, 'partial': 1, 'no-cut': 2 }; const { rows, sort, toggle } = useSort( INTERVENTIONS, { date: r => dateTs(r.date), type: r => r.type, action: r => r.action, asset: r => parseInt(String(r.asset).replace(/[^0-9]/g, ''), 10), contractor: r => r.contractor, status: r => r.status ? statusOrder[r.status] : 99, }, { col: 'date', dir: 'asc' } ); return (

Interventions

Scheduled & recent tree-work orders.
DateTypeStatusActionAssetContractor {rows.map((x, i) => ( ))} {rows.length === 0 && ( )}
{x.date} {x.type} {x.status ? {CUT_STATUS[x.status].label} : Pending} {x.action} {x.asset} {x.contractor}
No intervention records in this export.
); }; const AnalysisDatePicker = ({ value, onChange }) => { const [open, setOpen] = React.useState(false); const ref = React.useRef(); React.useEffect(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, []); return (
{open && (
{ANALYSIS_DATES.map(d => (
{ onChange(d); setOpen(false); }}> {d}
))}
)}
); }; Object.assign(window, { KpiCard, PerspectiveKpi, DonutChart, RiskDonut, CriticalAssets, ClusterTable, InterventionsTable, AnalysisDatePicker, });