mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-28 16:59:55 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">>_ </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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: </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>
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user