Feat/gt analytics openclaw (#392)

* feat(telegram): auto-translate OSINT channel posts to English

Cherry-picked from @Bobpick PR #391 (telegram-only slice): server-side translation during fetch, SHOW ORIGINAL toggle in TelegramOsintPopup, and on-demand /api/telegram-feed?lang=.

Co-authored-by: Robert Pickett <bobpickettsr@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(gt): experimental Derived OSINT analytics with lean-node safeguards

Cherry-picked from @Bobpick PR #391 (GT + OpenClaw slice): Bayesian strategic-risk engine, map overlay, OpenClaw commands, and telegram_rhetoric watchdog. Off by default (GT_ANALYTICS_ENABLED=false, gt_risk layer false). 1 vCPU nodes get cgroup detection, UI warning on layer toggle, and lean profile that skips scheduled ingest/Louvain unless GT_ANALYTICS_ACK_LOW_CPU=true. Backtest HUD removed from dashboard (OpenClaw/API regression only).

Co-authored-by: Robert Pickett <bobpickettsr@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Robert Pickett <bobpickettsr@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Shadowbroker
2026-06-16 17:05:46 -06:00
committed by GitHub
parent 9c5a4054f6
commit cfbeabda1e
69 changed files with 8102 additions and 78 deletions
@@ -0,0 +1,77 @@
'use client';
import React from 'react';
import { GripVertical, Minus, Plus } from 'lucide-react';
import { useTranslation } from '@/i18n';
import { useFloatingPanel } from '@/hooks/useFloatingPanel';
import GtBacktestPanel from '@/components/GtBacktestPanel';
import GtTopAlertsStrip from '@/components/GtTopAlertsStrip';
import type { SelectedEntity } from '@/types/dashboard';
interface Props {
layerEnabled?: boolean;
onFlyTo?: (lat: number, lng: number) => void;
onSelectEntity?: (entity: SelectedEntity | null) => void;
}
export default function GtAnalyticsHud({
layerEnabled = false,
onFlyTo,
onSelectEntity,
}: Props) {
const { t } = useTranslation();
const { position, isMinimized, setIsMinimized, isDragging, onDragStart } = useFloatingPanel(
'sb-gt-analytics-hud-v1',
{ defaultPosition: { x: 24, y: 380 } },
);
if (!layerEnabled) return null;
return (
<div
className={`pointer-events-auto fixed z-[201] flex flex-col border border-amber-700/45 bg-black/80 shadow-[0_0_16px_rgba(245,158,11,0.12)] backdrop-blur-sm ${
isMinimized ? 'w-fit' : 'w-[min(92vw,28rem)]'
} ${isDragging ? 'cursor-grabbing select-none' : ''}`}
style={{ left: position.x, top: position.y }}
>
<div
className={`flex items-center gap-2 bg-amber-950/30 px-2 py-1.5 cursor-grab active:cursor-grabbing ${
isMinimized ? '' : 'border-b border-amber-800/35'
}`}
onMouseDown={onDragStart}
title={t('gtHud.dragHint')}
>
<GripVertical size={12} className="shrink-0 text-amber-600/80" />
<span className="whitespace-nowrap text-[10px] font-mono font-bold tracking-widest text-amber-300">
{t('gtHud.title')}
</span>
{!isMinimized && (
<span className="text-[9px] font-mono tracking-wider text-amber-600/70">
{t('gtHud.dragHint')}
</span>
)}
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={() => setIsMinimized((prev) => !prev)}
className="ml-auto p-0.5 text-amber-500 transition-colors hover:text-amber-300"
title={isMinimized ? t('gtHud.expand') : t('gtHud.collapse')}
>
{isMinimized ? <Plus size={14} /> : <Minus size={14} />}
</button>
</div>
{!isMinimized && (
<div className="flex max-h-[min(70vh,28rem)] flex-col overflow-y-auto styled-scrollbar">
<GtBacktestPanel layerEnabled={layerEnabled} embedded />
<GtTopAlertsStrip
layerEnabled={layerEnabled}
onFlyTo={onFlyTo}
onSelectEntity={onSelectEntity}
embedded
/>
</div>
)}
</div>
);
}
+453
View File
@@ -0,0 +1,453 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { CheckCircle2, Minus, Plus, Radar, RefreshCw, XCircle } from 'lucide-react';
import { API_BASE } from '@/lib/api';
import { useTranslation } from '@/i18n';
import type { GtBacktestReport, GtMicroRollingReport, GtRollingReport } from '@/types/dashboard';
interface Props {
layerEnabled?: boolean;
embedded?: boolean;
}
type TabId = 'benchmark' | 'operational';
function pct(value: number | undefined): string {
if (value == null || Number.isNaN(value)) return '—';
return `${(value * 100).toFixed(1)}%`;
}
export default function GtBacktestPanel({ layerEnabled = false, embedded = false }: Props) {
const { t } = useTranslation();
const [isMinimized, setIsMinimized] = useState(false);
const [activeTab, setActiveTab] = useState<TabId>('operational');
const [benchmark, setBenchmark] = useState<GtBacktestReport | null>(null);
const [rolling, setRolling] = useState<GtRollingReport | null>(null);
const [micro, setMicro] = useState<GtMicroRollingReport | null>(null);
const [loadingBenchmark, setLoadingBenchmark] = useState(false);
const [loadingRolling, setLoadingRolling] = useState(false);
const [loadingMicro, setLoadingMicro] = useState(false);
const [showFailures, setShowFailures] = useState(false);
const refreshBenchmark = useCallback(async () => {
if (!layerEnabled) {
setBenchmark(null);
return;
}
setLoadingBenchmark(true);
try {
const res = await fetch(`${API_BASE}/api/analytics/backtest?expanded=true&tune=false`);
if (res.ok) setBenchmark(await res.json());
} catch {
/* non-fatal */
} finally {
setLoadingBenchmark(false);
}
}, [layerEnabled]);
const refreshRolling = useCallback(async () => {
if (!layerEnabled) {
setRolling(null);
return;
}
setLoadingRolling(true);
try {
const res = await fetch(`${API_BASE}/api/analytics/rolling?weeks=8`);
if (res.ok) setRolling(await res.json());
} catch {
/* non-fatal */
} finally {
setLoadingRolling(false);
}
}, [layerEnabled]);
const refreshMicro = useCallback(async () => {
if (!layerEnabled) {
setMicro(null);
return;
}
setLoadingMicro(true);
try {
const res = await fetch(`${API_BASE}/api/analytics/rolling/micro?window_days=3&limit=6`);
if (res.ok) setMicro(await res.json());
} catch {
/* non-fatal */
} finally {
setLoadingMicro(false);
}
}, [layerEnabled]);
const refresh = useCallback(async () => {
await Promise.all([refreshBenchmark(), refreshRolling(), refreshMicro()]);
}, [refreshBenchmark, refreshRolling, refreshMicro]);
useEffect(() => {
refresh();
if (!layerEnabled) return undefined;
const id = setInterval(refresh, 15 * 60_000);
return () => clearInterval(id);
}, [refresh, layerEnabled]);
const failures = (benchmark?.cases || []).filter((row) => !row.correct);
const operationalScorable = Boolean(
rolling && ((rolling.weeks_scorable ?? 0) > 0 || rolling.latest?.scorable),
);
const benchmarkPass = benchmark?.meets_target;
const rollingPass = rolling?.meets_target;
const passBadge =
activeTab === 'benchmark'
? benchmarkPass
: operationalScorable
? rollingPass
: undefined;
const showCollectingBadge =
activeTab === 'operational' && layerEnabled && rolling?.enabled && !operationalScorable;
const loading =
activeTab === 'benchmark'
? loadingBenchmark
: loadingRolling || loadingMicro;
const latest = rolling?.latest;
const microRegions = micro?.ignitions?.length
? micro.ignitions
: (micro?.top_regions || []).slice(0, 4);
const shellClass = embedded
? 'pointer-events-auto flex-shrink-0 border-b border-amber-800/30 bg-black/70'
: 'pointer-events-auto flex-shrink-0 border border-amber-700/40 bg-black/75 backdrop-blur-sm shadow-[0_0_18px_rgba(245,158,11,0.10)]';
return (
<div className={shellClass}>
<div
className="flex items-center justify-between border-b border-amber-700/30 bg-amber-950/20 px-3 py-2.5 cursor-pointer hover:bg-amber-950/40 transition-colors"
onClick={() => setIsMinimized((prev) => !prev)}
>
<div className="flex items-center gap-2">
<Radar size={16} className="text-amber-400" />
<span className="text-[12px] font-mono font-bold tracking-widest text-amber-400">
{t('gtBacktest.title').toUpperCase()}
</span>
{showCollectingBadge && (
<span className="text-[11px] font-mono px-1.5 py-0.5 tracking-wider border bg-amber-900/25 border-amber-700/40 text-amber-300">
{t('gtBacktest.collecting')}
</span>
)}
{layerEnabled && passBadge != null && (
<span
className={`text-[11px] font-mono px-1.5 py-0.5 tracking-wider border ${
passBadge
? 'bg-emerald-900/30 border-emerald-700/40 text-emerald-300'
: 'bg-red-900/30 border-red-700/40 text-red-300'
}`}
>
{passBadge ? t('gtBacktest.pass') : t('gtBacktest.fail')}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
refresh();
}}
title={t('gtBacktest.refresh')}
className="text-amber-600 transition-colors hover:text-amber-400 p-0.5"
>
<RefreshCw size={11} className={loading ? 'animate-spin' : ''} />
</button>
{isMinimized ? (
<Plus size={16} className="text-amber-400" />
) : (
<Minus size={16} className="text-amber-400" />
)}
</div>
</div>
{!isMinimized && (
<div className="px-3 py-2 max-h-60 overflow-y-auto styled-scrollbar space-y-2">
{!layerEnabled ? (
<div className="text-[11px] font-mono tracking-wider text-amber-600/70 py-1">
{t('gtBacktest.layerOff')}
</div>
) : (
<>
<div className="flex gap-1">
{(['operational', 'benchmark'] as TabId[]).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`text-[10px] font-mono tracking-widest px-2 py-0.5 border transition-colors ${
activeTab === tab
? 'border-amber-500/60 bg-amber-900/30 text-amber-200'
: 'border-amber-800/30 text-amber-600/80 hover:text-amber-400'
}`}
>
{tab === 'benchmark'
? t('gtBacktest.tabBenchmark')
: t('gtBacktest.tabOperational')}
</button>
))}
</div>
{activeTab === 'benchmark' ? (
!benchmark?.enabled ? (
<div className="text-[11px] font-mono tracking-wider text-amber-600/70 py-1">
{t('gtBacktest.disabled')}
</div>
) : loadingBenchmark && !benchmark.accuracy ? (
<div className="text-[11px] font-mono tracking-wider text-amber-500/80 py-1">
{t('gtBacktest.loading')}
</div>
) : (
<>
<div className="text-[10px] font-mono tracking-wider text-amber-600/60">
{t('gtBacktest.benchmarkNote')}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="border border-amber-800/30 bg-amber-950/15 px-2 py-1.5">
<div className="text-[10px] font-mono tracking-widest text-amber-600/80">
{t('gtBacktest.accuracy')}
</div>
<div className="text-[13px] font-mono font-bold text-amber-200">
{pct(benchmark.accuracy)}
</div>
</div>
<div className="border border-amber-800/30 bg-amber-950/15 px-2 py-1.5">
<div className="text-[10px] font-mono tracking-widest text-amber-600/80">
{t('gtBacktest.confidence')}
</div>
<div className="text-[13px] font-mono font-bold text-amber-200">
{pct(benchmark.confidence_rate)}
</div>
</div>
</div>
<div className="text-[10px] font-mono tracking-wider text-amber-600/70 leading-relaxed">
{t('gtBacktest.cases').replace('{count}', String(benchmark.total_cases))} ·{' '}
{t('gtBacktest.threshold').replace('{value}', benchmark.alert_threshold.toFixed(2))} ·{' '}
{t('gtBacktest.target').replace('{value}', pct(benchmark.target_confidence))}
</div>
<div className="flex flex-wrap gap-2 text-[10px] font-mono tracking-wider">
<span className="text-emerald-400">TP {benchmark.true_positives}</span>
<span className="text-emerald-400">TN {benchmark.true_negatives}</span>
<span className="text-red-400">FP {benchmark.false_positives}</span>
<span className="text-red-400">FN {benchmark.false_negatives}</span>
</div>
<div className="flex items-center gap-1.5 text-[10px] font-mono tracking-wider text-amber-500/90">
{benchmark.meets_target ? (
<CheckCircle2 size={12} className="text-emerald-400 shrink-0" />
) : (
<XCircle size={12} className="text-red-400 shrink-0" />
)}
<span>
{benchmark.meets_target
? t('gtBacktest.meetsTarget')
: t('gtBacktest.belowTarget')}
</span>
</div>
{failures.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowFailures((prev) => !prev)}
className="text-[10px] font-mono tracking-widest text-red-400 hover:text-red-300"
>
{showFailures ? '' : '+'} {t('gtBacktest.misclassified').replace('{count}', String(failures.length))}
</button>
{showFailures && (
<div className="mt-1 space-y-1">
{failures.map((row) => (
<div
key={row.case_id}
className="border border-red-800/30 bg-red-950/15 px-2 py-1 text-[10px] font-mono text-red-200/90"
>
{row.name} ({row.kind})
</div>
))}
</div>
)}
</div>
)}
</>
)
) : !rolling?.enabled && !micro?.enabled ? (
<div className="text-[11px] font-mono tracking-wider text-amber-600/70 py-1">
{t('gtBacktest.disabled')}
</div>
) : (loadingRolling || loadingMicro) && !rolling?.latest && !micro?.regions_tracked ? (
<div className="text-[11px] font-mono tracking-wider text-amber-500/80 py-1">
{t('gtBacktest.operationalLoading')}
</div>
) : (
<>
<div className="border border-amber-800/25 bg-amber-950/10 px-2 py-1.5 space-y-1">
<div className="text-[10px] font-mono tracking-widest text-amber-500/90">
{t('gtBacktest.microTitle').toUpperCase()}
</div>
{micro?.enabled ? (
<>
<div className="text-[10px] font-mono tracking-wider text-amber-600/75">
{t('gtBacktest.microWindow')
.replace('{days}', String(micro.window_days))
.replace('{delta}', micro.ignition_delta.toFixed(2))}
</div>
<div className="flex flex-wrap gap-2 text-[10px] font-mono tracking-wider">
<span className="text-orange-300">
{t('gtBacktest.microIgnitions').replace(
'{count}',
String(micro.ignition_count)
)}
</span>
<span className="text-amber-300/90">
{t('gtBacktest.microAlerted3d').replace(
'{count}',
String(micro.alerted_3d_count)
)}
</span>
</div>
{microRegions.length > 0 ? (
<div className="space-y-0.5">
{microRegions.map((row) => (
<div
key={row.region}
className="text-[10px] font-mono text-amber-200/85 flex items-center gap-1.5"
>
{row.ignition && (
<span className="text-orange-400 border border-orange-700/40 px-1 text-[9px]">
{t('gtBacktest.microIgnitionBadge')}
</span>
)}
<span>
{t('gtBacktest.microRegionLine')
.replace('{region}', row.region)
.replace('{spot}', pct(row.spot_risk))
.replace('{avg}', pct(row.risk_3d_avg))
.replace('{delta}', pct(row.risk_delta))}
</span>
</div>
))}
</div>
) : (
<div className="text-[10px] font-mono tracking-wider text-amber-600/65">
{t('gtBacktest.microEmpty')}
</div>
)}
</>
) : (
<div className="text-[10px] font-mono tracking-wider text-amber-600/65">
{t('gtBacktest.microEmpty')}
</div>
)}
</div>
<div className="text-[10px] font-mono tracking-widest text-amber-600/80 pt-1">
{t('gtBacktest.tabOperational').toUpperCase()} {t('gtBacktest.operationalTrend')}
</div>
{!rolling || rolling.weeks_stored === 0 ? (
<div className="text-[10px] font-mono tracking-wider text-amber-600/70 py-1">
{t('gtBacktest.operationalEmpty')}
</div>
) : (
<>
<div className="grid grid-cols-2 gap-2">
<div className="border border-amber-800/30 bg-amber-950/15 px-2 py-1.5">
<div className="text-[10px] font-mono tracking-widest text-amber-600/80">
{t('gtBacktest.accuracy')}
</div>
<div className="text-[13px] font-mono font-bold text-amber-200">
{latest?.scorable ? pct(latest.accuracy) : '—'}
</div>
</div>
<div className="border border-amber-800/30 bg-amber-950/15 px-2 py-1.5">
<div className="text-[10px] font-mono tracking-widest text-amber-600/80">
{t('gtBacktest.confidence')}
</div>
<div className="text-[13px] font-mono font-bold text-amber-200">
{latest?.scorable ? pct(latest.confidence_rate) : '—'}
</div>
</div>
</div>
<div className="text-[10px] font-mono tracking-wider text-amber-600/70 leading-relaxed">
{t('gtBacktest.operationalWeeks')
.replace('{stored}', String(rolling.weeks_stored))
.replace('{scorable}', String(rolling.weeks_scorable))}
{latest
? ` · ${t('gtBacktest.operationalLabeled')
.replace('{labeled}', String(latest.labeled))
.replace('{pending}', String(latest.pending))}`
: ''}
</div>
{latest && !latest.scorable && (
<div className="text-[10px] font-mono tracking-wider text-amber-500/80">
{t('gtBacktest.operationalMinLabels').replace(
'{count}',
String(rolling.min_labeled_per_week)
)}
</div>
)}
{latest?.scorable && (
<div className="flex flex-wrap gap-2 text-[10px] font-mono tracking-wider">
<span className="text-emerald-400">TP {latest.true_positives}</span>
<span className="text-emerald-400">TN {latest.true_negatives}</span>
<span className="text-red-400">FP {latest.false_positives}</span>
<span className="text-red-400">FN {latest.false_negatives}</span>
</div>
)}
{(rolling.accuracy_series?.length ?? 0) > 0 && (
<div>
<div className="text-[10px] font-mono tracking-widest text-amber-600/80 mb-1">
{t('gtBacktest.operationalTrend')}
</div>
<div className="flex flex-wrap gap-1.5">
{rolling.accuracy_series.map((point) => (
<span
key={point.week_id}
className="text-[10px] font-mono border border-amber-800/30 bg-amber-950/20 px-1.5 py-0.5 text-amber-200/90"
title={`${point.labeled} labeled`}
>
{point.week_id.replace('-W', 'w')}: {pct(point.accuracy)}
</span>
))}
</div>
</div>
)}
{latest?.scorable && (
<div className="flex items-center gap-1.5 text-[10px] font-mono tracking-wider text-amber-500/90">
{rolling.meets_target ? (
<CheckCircle2 size={12} className="text-emerald-400 shrink-0" />
) : (
<XCircle size={12} className="text-red-400 shrink-0" />
)}
<span>
{rolling.improving_vs_prior
? t('gtBacktest.operationalImproving')
: t('gtBacktest.operationalFlat')}
{' · '}
{rolling.meets_target
? t('gtBacktest.meetsTarget')
: t('gtBacktest.belowTarget')}
</span>
</div>
)}
</>
)}
</>
)}
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,121 @@
'use client';
import React, { useMemo } from 'react';
import { ChevronRight, Radar } from 'lucide-react';
import { useTranslation } from '@/i18n';
import { useDataKey } from '@/hooks/useDataStore';
import { extractGtAlerts } from '@/lib/gtAlerts';
import type { SelectedEntity } from '@/types/dashboard';
interface Props {
layerEnabled?: boolean;
onFlyTo?: (lat: number, lng: number) => void;
onSelectEntity?: (entity: SelectedEntity | null) => void;
embedded?: boolean;
}
function pct(value: number): string {
return `${(value * 100).toFixed(0)}%`;
}
export default function GtTopAlertsStrip({
layerEnabled = false,
onFlyTo,
onSelectEntity,
embedded = false,
}: Props) {
const { t } = useTranslation();
const gtRisk = useDataKey('gt_risk');
const { alerts, trackedRegions, plottedRegions, maxRegions } = useMemo(
() => extractGtAlerts(gtRisk, 8),
[gtRisk],
);
if (!layerEnabled || !gtRisk?.enabled) return null;
const handleSelect = (alert: (typeof alerts)[number]) => {
onFlyTo?.(alert.lat, alert.lng);
onSelectEntity?.({
id: alert.region,
type: 'gt_risk',
name: alert.regionLabel,
extra: {
region: alert.region,
risk: alert.risk,
financial: alert.financial,
unrest: alert.unrest,
conflict: alert.conflict,
contagion: alert.contagion,
lat: alert.lat,
lng: alert.lng,
risk_spot: alert.risk,
risk_3d_avg: alert.risk3d,
risk_delta: alert.riskDelta,
micro_ignition: alert.ignition,
},
});
};
const shellClass = embedded
? 'pointer-events-auto border-t border-amber-800/30 bg-black/70'
: 'pointer-events-auto max-w-[min(92vw,52rem)] border border-amber-700/45 bg-black/80 backdrop-blur-sm shadow-[0_0_16px_rgba(245,158,11,0.12)]';
return (
<div className={shellClass}>
<div className="flex items-center gap-2 border-b border-amber-800/35 bg-amber-950/25 px-2.5 py-1.5">
<Radar size={12} className="text-amber-400 shrink-0" />
<span className="text-[10px] font-mono font-bold tracking-widest text-amber-300">
{t('gtAlerts.title')}
</span>
<span className="text-[9px] font-mono tracking-wider text-amber-600/80">
{t('gtAlerts.counts')
.replace('{plotted}', String(plottedRegions))
.replace('{tracked}', String(trackedRegions))
.replace('{max}', String(maxRegions))}
</span>
</div>
{alerts.length === 0 ? (
<div className="px-2.5 py-2 text-[10px] font-mono tracking-wider text-amber-600/70">
{t('gtAlerts.empty')}
</div>
) : (
<div className="flex items-stretch gap-1 overflow-x-auto styled-scrollbar px-2 py-1.5">
{alerts.map((alert) => (
<button
key={alert.region}
type="button"
onClick={() => handleSelect(alert)}
className="group flex min-w-[9.5rem] shrink-0 flex-col gap-0.5 border border-amber-800/35 bg-amber-950/20 px-2 py-1 text-left transition-colors hover:border-amber-600/50 hover:bg-amber-900/25"
>
<div className="flex items-center gap-1">
<span className="truncate text-[10px] font-mono font-bold uppercase text-amber-100">
{alert.regionLabel}
</span>
{alert.ignition && (
<span className="shrink-0 border border-orange-700/50 px-1 text-[8px] font-mono text-orange-300">
{t('gtAlerts.ignition')}
</span>
)}
<ChevronRight
size={10}
className="ml-auto shrink-0 text-amber-600/60 group-hover:text-amber-400"
/>
</div>
<div className="text-[9px] font-mono tracking-wider text-amber-500/90">
{t('gtAlerts.line')
.replace('{risk}', pct(alert.risk))
.replace('{conflict}', pct(alert.conflict))}
</div>
</button>
))}
</div>
)}
<div className="border-t border-amber-900/30 px-2.5 py-1 text-[9px] font-mono leading-relaxed text-amber-600/65">
{t('gtAlerts.hint')}
</div>
</div>
);
}
+81 -1
View File
@@ -185,6 +185,7 @@ import { CorrelationPopup } from '@/components/MaplibreViewer/popups/Correlation
import { WastewaterPopup } from '@/components/MaplibreViewer/popups/WastewaterPopup';
import { MilitaryBasePopup } from '@/components/MaplibreViewer/popups/MilitaryBasePopup';
import { RegionDossierPanel } from '@/components/MaplibreViewer/popups/RegionDossierPanel';
import { GtRiskPopup } from '@/components/MaplibreViewer/popups/GtRiskPopup';
import { TelegramOsintPopup } from '@/components/MaplibreViewer/popups/TelegramOsintPopup';
import {
buildSentinelTileUrl,
@@ -196,6 +197,7 @@ import {
buildEarthquakesGeoJSON,
buildJammingGeoJSON,
buildCorrelationsGeoJSON,
buildGtRiskGeoJSON,
buildTinygsGeoJSON,
buildShodanGeoJSON,
buildAIIntelGeoJSON,
@@ -306,6 +308,7 @@ const MAP_EXTRA_DATA_KEYS = [
'crowdthreat',
'malware_threats',
'telegram_osint',
'gt_risk',
'datacenters',
'firms_fires',
'fishing_activity',
@@ -778,6 +781,11 @@ const MaplibreViewer = ({
[activeLayers.correlations, activeLayers.contradictions, data?.correlations],
);
const gtRiskGeoJSON = useMemo(
() => (activeLayers.gt_risk ? buildGtRiskGeoJSON(data?.gt_risk) : null),
[activeLayers.gt_risk, data?.gt_risk],
);
const tinygsGeoJSON = useMemo(
() => {
void interpTick;
@@ -1724,6 +1732,7 @@ const MaplibreViewer = ({
correlationsGeoJSON && 'corr-infra-fill',
correlationsGeoJSON && 'corr-contra-fill',
correlationsGeoJSON && 'corr-analysis-fill',
gtRiskGeoJSON && 'gt-risk-heatmap',
].filter(Boolean) as string[];
useEffect(() => {
@@ -1820,7 +1829,7 @@ const MaplibreViewer = ({
return (
<div
className={`relative h-full w-full z-0 isolate ${selectedEntity && ['region_dossier', 'gdelt', 'liveuamap', 'news', 'telegram_osint'].includes(selectedEntity.type) ? 'map-focus-active' : ''}`}
className={`relative h-full w-full z-0 isolate ${selectedEntity && ['region_dossier', 'gdelt', 'liveuamap', 'news', 'telegram_osint', 'gt_risk'].includes(selectedEntity.type) ? 'map-focus-active' : ''}`}
style={pinPlacementMode || sarAoiDropMode ? { cursor: 'crosshair' } : undefined}
>
<Map
@@ -2226,6 +2235,55 @@ const MaplibreViewer = ({
/>
</Source>
{/* Strategic Risk Heatmap — Bayesian posterior scores */}
<Source id="gt-risk-source" type="geojson" data={(gtRiskGeoJSON ?? EMPTY_FC)}>
<Layer
id="gt-risk-heatmap"
type="circle"
minzoom={2}
paint={{
'circle-radius': [
'interpolate',
['linear'],
['zoom'],
2,
['+', 6, ['*', 14, ['get', 'risk']]],
6,
['+', 10, ['*', 28, ['get', 'risk']]],
10,
['+', 14, ['*', 40, ['get', 'risk']]],
],
'circle-color': [
'interpolate',
['linear'],
['get', 'risk'],
0.15,
'#22c55e',
0.35,
'#84cc16',
0.5,
'#eab308',
0.65,
'#f97316',
0.8,
'#ef4444',
],
'circle-opacity': [
'interpolate',
['linear'],
['get', 'risk'],
0.15,
0.22,
0.8,
0.72,
],
'circle-stroke-width': 1,
'circle-stroke-color': '#fbbf24',
'circle-stroke-opacity': 0.35,
}}
/>
</Source>
{/* Correlation Alerts — Emergent Intelligence grid squares */}
<Source id="correlations" type="geojson" data={(correlationsGeoJSON ?? EMPTY_FC)}>
{/* RF Anomaly — grey */}
@@ -5712,6 +5770,28 @@ const MaplibreViewer = ({
return <FishingDestinationRoute vesselLat={event.lat} vesselLng={event.lng} destination={dest} />;
})()}
{(() => {
if (selectedEntity?.type !== 'gt_risk' || !selectedEntity.extra) return null;
const props = selectedEntity.extra as Record<string, unknown>;
const lat = Number(props.lat);
const lng = Number(props.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return (
<GtRiskPopup
region={String(props.region || props.name || selectedEntity.id)}
risk={Number(props.risk ?? 0)}
financial={Number(props.financial ?? 0)}
unrest={Number(props.unrest ?? 0)}
conflict={Number(props.conflict ?? 0)}
contagion={Number(props.contagion ?? 0)}
interpretation={String(props.interpretation || '')}
lat={lat}
lng={lng}
onClose={() => onEntityClick?.(null)}
/>
);
})()}
{(() => {
if (selectedEntity?.type !== 'telegram_osint' || !data?.telegram_osint?.posts) return null;
const allPosts = data.telegram_osint.posts;
@@ -0,0 +1,188 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Popup } from 'react-map-gl/maplibre';
import { Radar } from 'lucide-react';
import { useTranslation } from '@/i18n';
import { API_BASE } from '@/lib/api';
import { formatGtRegionLabel } from '@/lib/gtAlerts';
import type { GtDossier } from '@/types/dashboard';
export interface GtRiskPopupProps {
region: string;
risk: number;
financial?: number;
unrest?: number;
conflict?: number;
contagion?: number;
interpretation?: string;
lat: number;
lng: number;
onClose: () => void;
}
function riskColor(score: number): string {
if (score >= 0.6) return '#ef4444';
if (score >= 0.4) return '#f97316';
if (score >= 0.25) return '#eab308';
return '#22c55e';
}
function formatSignalName(name: string): string {
return name.replace(/_/g, ' ');
}
async function fetchDossier(region: string, lat: number, lng: number): Promise<GtDossier | null> {
const candidates = [
region.trim().toLowerCase(),
`${lat.toFixed(2)},${lng.toFixed(2)}`,
].filter((value, index, list) => value && list.indexOf(value) === index);
let best: GtDossier | null = null;
for (const key of candidates) {
try {
const response = await fetch(`${API_BASE}/api/analytics/dossier/${encodeURIComponent(key)}`);
if (!response.ok) continue;
const payload = (await response.json()) as GtDossier;
if (!payload.enabled) continue;
if (!best || (payload.current_risk ?? 0) >= (best.current_risk ?? 0)) {
best = payload;
}
} catch {
/* optional analytics */
}
}
return best;
}
export function GtRiskPopup({
region,
risk,
financial,
unrest,
conflict,
contagion,
interpretation,
lat,
lng,
onClose,
}: GtRiskPopupProps) {
const { t } = useTranslation();
const color = riskColor(risk);
const [dossier, setDossier] = useState<GtDossier | null>(null);
const [loadingSignals, setLoadingSignals] = useState(true);
useEffect(() => {
let cancelled = false;
setLoadingSignals(true);
void fetchDossier(region, lat, lng).then((result) => {
if (!cancelled) {
setDossier(result);
setLoadingSignals(false);
}
});
return () => {
cancelled = true;
};
}, [region, lat, lng]);
const resolvedInterpretation = interpretation || dossier?.interpretation || '';
const signals = dossier?.recent_signals || [];
return (
<Popup
longitude={lng}
latitude={lat}
closeButton={false}
closeOnClick={false}
onClose={onClose}
className="threat-popup"
maxWidth="360px"
>
<div className="bg-black/95 border border-amber-700/50 rounded-lg overflow-hidden font-mono text-[11px]">
<div className="px-3 py-2 border-b border-amber-800/40 bg-amber-950/40 flex items-center gap-2">
<Radar size={14} className="text-amber-400" />
<span className="text-amber-300 font-bold tracking-widest text-[10px]">
{t('gtRisk.popupTitle')}
</span>
<button
type="button"
onClick={onClose}
className="ml-auto text-[var(--text-muted)] hover:text-white"
>
</button>
</div>
<div className="p-3 flex flex-col gap-2 max-h-72 overflow-y-auto styled-scrollbar">
<div className="flex justify-between items-center">
<span className="text-[var(--text-muted)]">{t('gtRisk.region')}</span>
<span className="text-white font-bold uppercase">{formatGtRegionLabel(region)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--text-muted)]">{t('gtRisk.composite')}</span>
<span className="font-bold" style={{ color }}>
{(risk * 100).toFixed(1)}%
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-[10px]">
<div>
<div className="text-[var(--text-muted)]">{t('gtRisk.financial')}</div>
<div className="text-cyan-300">{((financial ?? 0) * 100).toFixed(0)}%</div>
</div>
<div>
<div className="text-[var(--text-muted)]">{t('gtRisk.unrest')}</div>
<div className="text-orange-300">{((unrest ?? 0) * 100).toFixed(0)}%</div>
</div>
<div>
<div className="text-[var(--text-muted)]">{t('gtRisk.conflict')}</div>
<div className="text-red-300">{((conflict ?? 0) * 100).toFixed(0)}%</div>
</div>
</div>
{contagion != null && contagion > 0 && (
<div className="flex justify-between">
<span className="text-[var(--text-muted)]">{t('gtRisk.contagion')}</span>
<span className="text-purple-300">{(contagion * 100).toFixed(1)}%</span>
</div>
)}
{resolvedInterpretation && (
<p className="text-[var(--text-secondary)] leading-relaxed border-t border-amber-900/40 pt-2">
<span className="text-amber-400 font-bold">&gt;_ </span>
{resolvedInterpretation}
</p>
)}
<div className="border-t border-amber-900/40 pt-2">
<div className="text-[10px] tracking-widest text-amber-500/90 font-bold mb-1.5">
{t('gtRisk.costlySignals')}
</div>
{loadingSignals ? (
<div className="text-[10px] text-amber-600/80">{t('gtRisk.loadingSignals')}</div>
) : signals.length > 0 ? (
<div className="space-y-1.5">
{signals.slice(-4).reverse().map((entry, idx) => (
<div
key={`${entry.timestamp}-${idx}`}
className="border-l-2 border-amber-700/60 pl-2 text-[10px] text-[var(--text-secondary)]"
>
<div className="text-amber-300 uppercase">
{Object.keys(entry.signals || {})
.map(formatSignalName)
.join(', ') || entry.domain}
</div>
<div className="text-[var(--text-muted)] truncate" title={entry.source}>
{entry.source || t('gtRisk.unknownSource')}
</div>
</div>
))}
</div>
) : (
<div className="text-[10px] text-amber-600/75 leading-relaxed">
{t('gtRisk.noSignals')}
</div>
)}
</div>
</div>
</div>
</Popup>
);
}
@@ -1,6 +1,6 @@
'use client';
import React, { useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Popup } from 'react-map-gl/maplibre';
import { Radio } from 'lucide-react';
import { useTranslation } from '@/i18n';
@@ -69,11 +69,58 @@ function riskTheme(rs: number) {
};
}
function postHeadline(post: TelegramOsintPost): string {
return String(post.title || post.description || 'Telegram intercept').trim();
const CYRILLIC_RE = /[\u0400-\u04FF]/;
function containsCyrillic(text: string): boolean {
return CYRILLIC_RE.test(text);
}
function postDetail(post: TelegramOsintPost): string | null {
function sourceLangLabel(post: TelegramOsintPost): string {
if (post.source_lang_label) return post.source_lang_label;
const code = String(post.source_lang || '').trim().toLowerCase();
const labels: Record<string, string> = {
uk: 'Ukrainian',
ru: 'Russian',
en: 'English',
ar: 'Arabic',
he: 'Hebrew',
'zh-cn': 'Chinese',
fr: 'French',
de: 'German',
pl: 'Polish',
};
return labels[code] || code.toUpperCase();
}
function hasTranslation(post: TelegramOsintPost): boolean {
const translated = String(post.title_translated || post.description_translated || '').trim();
const original = String(post.title || post.description || '').trim();
return Boolean(translated && translated !== original);
}
function postHeadline(post: TelegramOsintPost, showOriginal: boolean): string {
const original = String(post.title || post.description || 'Telegram intercept').trim();
const translated = String(post.title_translated || post.description_translated || '').trim();
if (!showOriginal && translated) {
return translated.split('\n', 1)[0].trim();
}
if (!showOriginal && containsCyrillic(original) && translated) {
return translated.split('\n', 1)[0].trim();
}
return original;
}
function postDetail(post: TelegramOsintPost, showOriginal: boolean): string | null {
if (!showOriginal && post.description_translated) {
const translatedTitle = String(post.title_translated || '').trim();
const translatedBody = String(post.description_translated || '').trim();
if (!translatedBody || translatedBody === translatedTitle) return null;
const extra = translatedBody.startsWith(translatedTitle)
? translatedBody.slice(translatedTitle.length).trim()
: translatedBody;
return extra || null;
}
const title = String(post.title || '').trim();
const description = String(post.description || '').trim();
if (!description || description === title || description.startsWith(title)) return null;
@@ -126,10 +173,12 @@ function TelegramPostMedia({ post }: { post: TelegramOsintPost }) {
function TelegramPostCard({ post }: { post: TelegramOsintPost }) {
const { t } = useTranslation();
const [showOriginal, setShowOriginal] = useState(false);
const rs = post.risk_score ?? 1;
const theme = riskTheme(rs);
const headline = postHeadline(post);
const detail = postDetail(post);
const translated = hasTranslation(post);
const headline = postHeadline(post, showOriginal);
const detail = postDetail(post, showOriginal);
const isHigh = rs >= 8;
return (
@@ -150,12 +199,29 @@ function TelegramPostCard({ post }: { post: TelegramOsintPost }) {
<p className="text-[11px] text-[var(--text-muted)] leading-relaxed whitespace-pre-wrap">{detail}</p>
) : null}
{translated && !showOriginal && post.source_lang ? (
<p className="text-[10px] text-cyan-700/80 uppercase tracking-wider">
{t('telegram.translatedFrom').replace('{lang}', sourceLangLabel(post))}
</p>
) : null}
<TelegramPostMedia post={post} />
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<span className={`text-[11px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${theme.badgeClass}`}>
{isHigh ? 'BREAKING' : `LVL: ${rs}/10`}
</span>
{translated ? (
<button
type="button"
onClick={() => setShowOriginal((prev) => !prev)}
className="text-[11px] font-mono text-cyan-600 hover:text-cyan-300 transition-colors"
>
{showOriginal
? t('telegram.showTranslation')
: t('telegram.showOriginal').replace('{lang}', sourceLangLabel(post))}
</button>
) : null}
{post.link ? (
<a
href={post.link}
@@ -172,15 +238,49 @@ function TelegramPostCard({ post }: { post: TelegramOsintPost }) {
}
export function TelegramOsintPopup({ posts, lat, lng, onClose }: TelegramOsintPopupProps) {
const { t } = useTranslation();
const { t, locale } = useTranslation();
const [localizedPosts, setLocalizedPosts] = useState(posts);
useEffect(() => {
setLocalizedPosts(posts);
}, [posts]);
useEffect(() => {
const needsLocalizedFeed = posts.some((post) => !hasTranslation(post));
if (!needsLocalizedFeed) {
return;
}
let cancelled = false;
const controller = new AbortController();
fetch(`/api/telegram-feed?lang=${encodeURIComponent(locale)}`, { signal: controller.signal })
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (cancelled || !payload?.posts) return;
const byId = new Map(
(payload.posts as TelegramOsintPost[]).map((post) => [post.id, post]),
);
setLocalizedPosts(posts.map((post) => byId.get(post.id) || post));
})
.catch(() => {
/* keep feed posts when locale translation fetch fails */
});
return () => {
cancelled = true;
controller.abort();
};
}, [locale, posts]);
const sortedPosts = useMemo(
() =>
[...posts].sort(
[...localizedPosts].sort(
(a, b) =>
(b.risk_score ?? 0) - (a.risk_score ?? 0) ||
String(b.published || '').localeCompare(String(a.published || '')),
),
[posts],
[localizedPosts],
);
const maxRisk = sortedPosts[0]?.risk_score ?? 1;
@@ -252,4 +352,4 @@ export function TelegramOsintPopup({ posts, lat, lng, onClose }: TelegramOsintPo
</div>
</Popup>
);
}
}
+79 -1
View File
@@ -321,7 +321,7 @@ function EmissionsEstimateBlock({ flight }: { flight: any }) {
);
}
function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, onArticleClick, onExpandEntityGraph }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, onArticleClick?: (idx: number, lat?: number, lng?: number, title?: string) => void, onExpandEntityGraph?: () => void }) {
function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, gtDossier, gtDossierLoading, onArticleClick, onExpandEntityGraph }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, gtDossier?: import('@/types/dashboard').GtDossier | null, gtDossierLoading?: boolean, onArticleClick?: (idx: number, lat?: number, lng?: number, title?: string) => void, onExpandEntityGraph?: () => void }) {
const data = useDataKeys([
'news', 'fimi', 'commercial_flights', 'private_flights', 'private_jets',
'military_flights', 'tracked_flights', 'ships', 'gdelt', 'liveuamap',
@@ -535,6 +535,84 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
)}
{/* Sentinel-2 imagery now shown as map popup — see MaplibreViewer */}
{(gtDossierLoading || gtDossier?.enabled) && (
<>
<div className="text-[11px] text-amber-500 tracking-widest font-bold border-b border-amber-900/50 pb-1 mt-2">
STRATEGIC RISK (GT)
</div>
{gtDossierLoading ? (
<div className="text-amber-400/80 text-[11px]">Running game-theoretic analysis...</div>
) : gtDossier ? (
<div className="flex flex-col gap-2">
<div className="flex justify-between">
<span className="text-[var(--text-muted)]">POSTERIOR RISK</span>
<span className="text-amber-300 font-bold">
{((gtDossier.current_risk ?? 0) * 100).toFixed(1)}%
</span>
</div>
{gtDossier.domain_risks && (
<div className="grid grid-cols-3 gap-2 text-[10px]">
<div>
<div className="text-[var(--text-muted)]">FIN</div>
<div className="text-cyan-300">
{((gtDossier.domain_risks.financial ?? 0) * 100).toFixed(0)}%
</div>
</div>
<div>
<div className="text-[var(--text-muted)]">UNREST</div>
<div className="text-orange-300">
{((gtDossier.domain_risks.unrest ?? 0) * 100).toFixed(0)}%
</div>
</div>
<div>
<div className="text-[var(--text-muted)]">CONFLICT</div>
<div className="text-red-300">
{((gtDossier.domain_risks.conflict ?? 0) * 100).toFixed(0)}%
</div>
</div>
</div>
)}
{gtDossier.interpretation && (
<div className="p-2 bg-black/60 border border-amber-800/50 text-[11px] text-amber-200/90 leading-relaxed">
<span className="text-amber-400 font-bold">&gt;_ GT: </span>
{gtDossier.interpretation}
</div>
)}
{gtDossier.recent_signals && gtDossier.recent_signals.length > 0 && (
<div className="flex flex-col gap-1">
<div className="text-[10px] text-[var(--text-muted)] tracking-widest">
COSTLY SIGNALS
</div>
{gtDossier.recent_signals.slice(-3).map((entry, idx) => (
<div
key={`${entry.timestamp}-${idx}`}
className="text-[10px] border-l-2 border-amber-700/60 pl-2 text-[var(--text-secondary)]"
>
<span className="text-amber-300 uppercase">
{Object.keys(entry.signals || {}).join(', ') || entry.domain}
</span>
{' · '}
<span className="text-[var(--text-muted)]">{entry.source}</span>
</div>
))}
</div>
)}
{gtDossier.scenarios && gtDossier.scenarios.length > 0 && (
<div className="flex flex-col gap-1">
<div className="text-[10px] text-[var(--text-muted)] tracking-widest">SCENARIOS</div>
{gtDossier.scenarios.map((scenario) => (
<div key={scenario.name} className="text-[10px] text-[var(--text-secondary)]">
<span className="text-amber-400 font-bold">{scenario.name}: </span>
{scenario.summary}
</div>
))}
</div>
)}
</div>
) : null}
</>
)}
</div>
) : d?.error ? (
<div className="p-4 text-[var(--text-secondary)] text-[12px]">{d.error}</div>
+53 -5
View File
@@ -55,6 +55,8 @@ import { useTheme } from '@/lib/ThemeContext';
import { useTranslation } from '@/i18n';
import SarModeChooserModal from './SarModeChooserModal';
import KiwiSdrConsentDialog from './ui/KiwiSdrConsentDialog';
import { extractGtAlerts } from '@/lib/gtAlerts';
import { gtLeanLayerWarning, useRuntimeProfile } from '@/hooks/useRuntimeProfile';
function relativeTime(iso: string | undefined): string {
if (!iso) return '';
@@ -115,6 +117,7 @@ const FRESHNESS_MAP: Record<string, string> = {
scm_suppliers: 'scm_suppliers',
cyber_threats: 'cyber_threats',
telegram_osint: 'telegram_osint',
gt_risk: 'gt_risk',
};
// POTUS fleet ICAO hex codes for client-side filtering
@@ -726,7 +729,11 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
const [liveuamapModalOpen, setLiveuamapModalOpen] = useState(false);
const [liveuamapPendingEnable, setLiveuamapPendingEnable] = useState<(() => void) | null>(null);
const [gtLeanModalOpen, setGtLeanModalOpen] = useState(false);
const [gtLeanPendingEnable, setGtLeanPendingEnable] = useState<(() => void) | null>(null);
const { needsConsentBeforeEnable, confirmOptIn } = useLiveUamapScraperOptIn();
const runtimeProfile = useRuntimeProfile();
const gtLeanWarning = gtLeanLayerWarning(runtimeProfile);
const withGlobalIncidentsConsent = useCallback(
(layerId: string, turningOn: boolean, apply: () => void) => {
@@ -740,6 +747,18 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
[needsConsentBeforeEnable],
);
const withGtRiskLeanWarning = useCallback(
(layerId: string, turningOn: boolean, apply: () => void) => {
if (layerId === 'gt_risk' && turningOn && gtLeanWarning) {
setGtLeanPendingEnable(() => apply);
setGtLeanModalOpen(true);
return;
}
apply();
},
[gtLeanWarning],
);
const isAllToggleableLayersOn = useMemo(
() =>
Object.entries(activeLayers)
@@ -1371,6 +1390,16 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
count: data?.correlations?.filter((c: { type: string }) => c.type === 'contradiction').length || 0,
icon: Zap,
},
{
id: 'gt_risk',
name: t('layers.derivedOsint'),
source: t('layers.derivedOsintSource'),
count:
extractGtAlerts(data?.gt_risk).plottedRegions ||
data?.gt_risk?.meta?.plotted_regions ||
0,
icon: Radar,
},
{
id: 'day_night',
name: t('layers.dayNight'),
@@ -1394,7 +1423,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
sections.forEach((s) => {
// Keep high-traffic intel overlays visible on first paint (GDELT, Telegram, etc.)
initial[s.label] = s.layers.some((l) =>
['global_incidents', 'telegram_osint', 'ukraine_frontline'].includes(l.id),
['global_incidents', 'telegram_osint', 'ukraine_frontline', 'gt_risk'].includes(l.id),
);
});
return initial;
@@ -1746,10 +1775,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
return;
}
withGlobalIncidentsConsent(layer.id, !active, () => {
setActiveLayers((prev: ActiveLayers) => ({
...prev,
[layer.id]: !active,
}));
withGtRiskLeanWarning(layer.id, !active, () => {
setActiveLayers((prev: ActiveLayers) => ({
...prev,
[layer.id]: !active,
}));
});
});
}}
>
@@ -2081,6 +2112,23 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
})();
}}
/>
<ConfirmDialog
open={gtLeanModalOpen}
title={t('gtLean.title')}
message={gtLeanWarning || t('gtLean.message')}
confirmLabel={t('gtLean.confirm')}
cancelLabel={t('gtLean.cancel')}
danger={false}
onCancel={() => {
setGtLeanModalOpen(false);
setGtLeanPendingEnable(null);
}}
onConfirm={() => {
gtLeanPendingEnable?.();
setGtLeanModalOpen(false);
setGtLeanPendingEnable(null);
}}
/>
</>
);
});
@@ -1956,3 +1956,64 @@ export function buildSarAoisGeoJSON(aois?: SarAoi[]): FC {
if (features.length === 0) return null;
return { type: 'FeatureCollection' as const, features };
}
// ─── Strategic Risk Analytics (GT early warning) ────────────────────────────
export function buildGtRiskGeoJSON(
payload?: {
enabled?: boolean;
heatmap?: { features?: Array<GTRiskHeatmapFeatureLike> };
} | null,
): FC {
const features = payload?.heatmap?.features;
if (!features?.length) return null;
const normalized = features
.map((feature, index) => {
const coords = feature.geometry?.coordinates;
if (!coords || coords.length < 2) return null;
const [lng, lat] = coords;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
if (Math.abs(lat) < 0.001 && Math.abs(lng) < 0.001) return null;
const props = feature.properties || {};
const region = String(props.region || `region-${index}`);
return {
type: 'Feature' as const,
properties: {
...props,
type: 'gt_risk',
id: region,
name: region,
lat,
lng,
risk: Number(props.risk ?? 0),
financial: Number(props.financial ?? 0),
unrest: Number(props.unrest ?? 0),
conflict: Number(props.conflict ?? 0),
contagion: Number(props.contagion ?? 0),
},
geometry: {
type: 'Point' as const,
coordinates: [lng, lat] as [number, number],
},
};
})
.filter(Boolean) as GeoJSON.Feature[];
if (!normalized.length) return null;
return { type: 'FeatureCollection' as const, features: normalized };
}
type GTRiskHeatmapFeatureLike = {
properties?: {
region?: string;
risk?: number;
financial?: number;
unrest?: number;
conflict?: number;
contagion?: number;
};
geometry?: {
coordinates?: [number, number];
};
};