mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-09 05:47:51 +02:00
v0.9.6: InfoNet hashchain, Wormhole gate encryption, mesh reputation, 16 community contributors
Gate messages now propagate via the Infonet hashchain as encrypted blobs — every node syncs them through normal chain sync while only Gate members with MLS keys can decrypt. Added mesh reputation system, peer push workers, voluntary Wormhole opt-in for node participation, fork recovery, killwormhole scripts, obfuscated terminology, and hardened the self-updater to protect encryption keys and chain state during updates. New features: Shodan search, train tracking, Sentinel Hub imagery, 8 new intelligence layers, CCTV expansion to 11,000+ cameras across 6 countries, Mesh Terminal CLI, prediction markets, desktop-shell scaffold, and comprehensive mesh test suite (215 frontend + backend tests passing). Community contributors: @wa1id, @AlborzNazari, @adust09, @Xpirix, @imqdcr, @csysp, @suranyami, @chr0n1x, @johan-martensson, @singularfailure, @smithbh, @OrfeoTerkuci, @deuza, @tm-const, @Elhard1, @ttulttul
This commit is contained in:
@@ -1,25 +1,47 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { mergeData, setBackendStatus as setStoreBackendStatus } from "./useDataStore";
|
||||
|
||||
export type BackendStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
type FastDataProbe = {
|
||||
commercial_flights?: unknown[];
|
||||
military_flights?: unknown[];
|
||||
tracked_flights?: unknown[];
|
||||
ships?: unknown[];
|
||||
sigint?: unknown[];
|
||||
cctv?: unknown[];
|
||||
};
|
||||
|
||||
function hasMeaningfulFastData(json: FastDataProbe): boolean {
|
||||
return (
|
||||
(json.commercial_flights?.length || 0) > 100 ||
|
||||
(json.military_flights?.length || 0) > 25 ||
|
||||
(json.tracked_flights?.length || 0) > 10 ||
|
||||
(json.ships?.length || 0) > 100 ||
|
||||
(json.sigint?.length || 0) > 100 ||
|
||||
(json.cctv?.length || 0) > 100
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Event name dispatched by page.tsx when a layer toggle changes.
|
||||
* useDataPolling listens for this to immediately refetch slow-tier data
|
||||
* so toggled layers (power plants, GDELT, etc.) appear without the usual
|
||||
* 120-second wait.
|
||||
*/
|
||||
export const LAYER_TOGGLE_EVENT = 'sb:layer-toggle';
|
||||
|
||||
/**
|
||||
* Polls the backend for fast and slow data tiers.
|
||||
*
|
||||
* Matches the proven GitHub polling pattern:
|
||||
* - Empty useEffect dependency array (no restarts on viewport change)
|
||||
* - No viewport bbox filtering (full data every poll)
|
||||
* - Adaptive startup polling (3s retry → 15s/120s steady state)
|
||||
* - ETag conditional requests for bandwidth savings
|
||||
* - AbortController for clean unmount
|
||||
* All data is fetched globally (no bbox filtering) — the backend returns its
|
||||
* full in-memory cache and MapLibre culls off-screen entities on the GPU.
|
||||
* This eliminates the "empty map when zooming out" lag.
|
||||
*
|
||||
* The AIS stream viewport POST (/api/viewport) is still handled separately
|
||||
* by useViewportBounds to limit upstream AIS ingestion.
|
||||
*/
|
||||
export function useDataPolling() {
|
||||
const dataRef = useRef<any>({});
|
||||
const [dataVersion, setDataVersion] = useState(0);
|
||||
const data = dataRef.current;
|
||||
|
||||
const [backendStatus, setBackendStatus] = useState<BackendStatus>('connecting');
|
||||
|
||||
const fastEtag = useRef<string | null>(null);
|
||||
const slowEtag = useRef<string | null>(null);
|
||||
|
||||
@@ -27,43 +49,84 @@ export function useDataPolling() {
|
||||
let hasData = false;
|
||||
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
const fastAbortRef = { current: null as AbortController | null };
|
||||
const slowAbortRef = { current: null as AbortController | null };
|
||||
|
||||
const fetchFastData = async () => {
|
||||
if (fastTimerId) {
|
||||
clearTimeout(fastTimerId);
|
||||
fastTimerId = null;
|
||||
}
|
||||
if (fastAbortRef.current) return;
|
||||
const controller = new AbortController();
|
||||
fastAbortRef.current = controller;
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers });
|
||||
if (res.status === 304) { setBackendStatus('connected'); scheduleNext('fast'); return; }
|
||||
const res = await fetch(`${API_BASE}/api/live-data/fast`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.status === 304) {
|
||||
setStoreBackendStatus('connected');
|
||||
scheduleNext('fast');
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
setBackendStatus('connected');
|
||||
setStoreBackendStatus('connected');
|
||||
fastEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
setDataVersion(v => v + 1);
|
||||
const flights = json.commercial_flights?.length || 0;
|
||||
if (flights > 100) hasData = true;
|
||||
mergeData(json);
|
||||
if (hasMeaningfulFastData(json)) hasData = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching fast live data", e);
|
||||
setBackendStatus('disconnected');
|
||||
const aborted =
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'name' in e &&
|
||||
(e as { name?: string }).name === 'AbortError';
|
||||
if (!aborted) {
|
||||
console.error("Failed fetching fast live data", e);
|
||||
setStoreBackendStatus('disconnected');
|
||||
}
|
||||
} finally {
|
||||
if (fastAbortRef.current === controller) {
|
||||
fastAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
scheduleNext('fast');
|
||||
};
|
||||
|
||||
const fetchSlowData = async () => {
|
||||
if (slowAbortRef.current) return;
|
||||
const controller = new AbortController();
|
||||
slowAbortRef.current = controller;
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (slowEtag.current) headers['If-None-Match'] = slowEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/slow`, { headers });
|
||||
const res = await fetch(`${API_BASE}/api/live-data/slow`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.status === 304) { scheduleNext('slow'); return; }
|
||||
if (res.ok) {
|
||||
slowEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
setDataVersion(v => v + 1);
|
||||
mergeData(json);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching slow live data", e);
|
||||
const aborted =
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'name' in e &&
|
||||
(e as { name?: string }).name === 'AbortError';
|
||||
if (!aborted) {
|
||||
console.error("Failed fetching slow live data", e);
|
||||
}
|
||||
} finally {
|
||||
if (slowAbortRef.current === controller) {
|
||||
slowAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
scheduleNext('slow');
|
||||
};
|
||||
@@ -79,14 +142,29 @@ export function useDataPolling() {
|
||||
}
|
||||
};
|
||||
|
||||
// When a layer toggle fires, immediately refetch slow data so the user
|
||||
// doesn't wait up to 120s for power plants / GDELT / etc. to appear.
|
||||
const onLayerToggle = () => {
|
||||
slowEtag.current = null; // invalidate ETag → guarantees fresh payload
|
||||
if (slowTimerId) clearTimeout(slowTimerId);
|
||||
slowTimerId = null;
|
||||
fetchSlowData();
|
||||
};
|
||||
window.addEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
|
||||
|
||||
fetchFastData();
|
||||
fetchSlowData();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
|
||||
if (fastTimerId) clearTimeout(fastTimerId);
|
||||
if (slowTimerId) clearTimeout(slowTimerId);
|
||||
if (fastAbortRef.current) fastAbortRef.current.abort();
|
||||
if (slowAbortRef.current) slowAbortRef.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, dataVersion, backendStatus };
|
||||
// Data and backend status are now accessed via useDataStore hooks
|
||||
// (useDataKey, useDataKeys, useDataSnapshot, useBackendStatus).
|
||||
// This hook is a pure side-effect — it starts polling and writes to the store.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Granular reactive data store — replaces the monolithic `data` prop cascade.
|
||||
*
|
||||
* Components subscribe to individual keys via useDataKey("ships") or
|
||||
* useDataKeys(["ships", "sigint"]) and ONLY re-render when those specific
|
||||
* keys change. This eliminates the re-render cascade where every 15-second
|
||||
* fast poll forced all 8+ dashboard components to reconcile.
|
||||
*
|
||||
* Built on React 18 useSyncExternalStore — zero dependencies, tear-free reads.
|
||||
*/
|
||||
import { useSyncExternalStore, useRef, useMemo } from "react";
|
||||
import type { DashboardData } from "@/types/dashboard";
|
||||
import type { BackendStatus } from "./useDataPolling";
|
||||
|
||||
// ── Store singleton ──────────────────────────────────────────────────────
|
||||
type Listener = () => void;
|
||||
|
||||
/** Per-key listener sets — only listeners subscribed to changed keys fire. */
|
||||
const keyListeners = new Map<string, Set<Listener>>();
|
||||
/** Global listeners — fire on ANY key change (used by useDataSnapshot). */
|
||||
const globalListeners = new Set<Listener>();
|
||||
|
||||
const store: Record<string, unknown> = {};
|
||||
|
||||
let backendStatus: BackendStatus = "connecting";
|
||||
const statusListeners = new Set<Listener>();
|
||||
|
||||
// ── Write API (called from useDataPolling) ───────────────────────────────
|
||||
|
||||
/** Merge a partial payload into the store, notifying only affected keys. */
|
||||
export function mergeData(patch: Record<string, unknown>) {
|
||||
const changedKeys: string[] = [];
|
||||
for (const key of Object.keys(patch)) {
|
||||
const next = patch[key];
|
||||
if (store[key] !== next) {
|
||||
store[key] = next;
|
||||
changedKeys.push(key);
|
||||
}
|
||||
}
|
||||
// Notify per-key subscribers
|
||||
for (const key of changedKeys) {
|
||||
const set = keyListeners.get(key);
|
||||
if (set) for (const fn of set) fn();
|
||||
}
|
||||
// Notify global subscribers only if something actually changed
|
||||
if (changedKeys.length > 0) {
|
||||
for (const fn of globalListeners) fn();
|
||||
}
|
||||
}
|
||||
|
||||
export function setBackendStatus(next: BackendStatus) {
|
||||
if (backendStatus === next) return;
|
||||
backendStatus = next;
|
||||
for (const fn of statusListeners) fn();
|
||||
}
|
||||
|
||||
// ── Read API (hooks) ─────────────────────────────────────────────────────
|
||||
|
||||
/** Subscribe to a single data key. Component only re-renders when that key's
|
||||
* reference identity changes. */
|
||||
export function useDataKey<K extends keyof DashboardData>(key: K): DashboardData[K] {
|
||||
const subscribe = useMemo(() => {
|
||||
return (onStoreChange: Listener) => {
|
||||
let set = keyListeners.get(key as string);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
keyListeners.set(key as string, set);
|
||||
}
|
||||
set.add(onStoreChange);
|
||||
return () => {
|
||||
set!.delete(onStoreChange);
|
||||
if (set!.size === 0) keyListeners.delete(key as string);
|
||||
};
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
const getSnapshot = useMemo(() => {
|
||||
return () => store[key as string] as DashboardData[K];
|
||||
}, [key]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
/** Subscribe to multiple keys. Returns a stable object whose identity only
|
||||
* changes when any of the subscribed keys change. */
|
||||
export function useDataKeys<K extends keyof DashboardData>(
|
||||
keys: readonly K[],
|
||||
): Pick<DashboardData, K> {
|
||||
// Stable key list — avoid re-subscribing on every render
|
||||
const keysRef = useRef(keys);
|
||||
const keysStr = keys.join(",");
|
||||
const stableKeys = useMemo(() => {
|
||||
keysRef.current = keys;
|
||||
return keys;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keysStr]);
|
||||
|
||||
const subscribe = useMemo(() => {
|
||||
return (onStoreChange: Listener) => {
|
||||
const unsubs: (() => void)[] = [];
|
||||
for (const key of stableKeys) {
|
||||
let set = keyListeners.get(key as string);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
keyListeners.set(key as string, set);
|
||||
}
|
||||
set.add(onStoreChange);
|
||||
unsubs.push(() => {
|
||||
set!.delete(onStoreChange);
|
||||
if (set!.size === 0) keyListeners.delete(key as string);
|
||||
});
|
||||
}
|
||||
return () => { for (const u of unsubs) u(); };
|
||||
};
|
||||
}, [stableKeys]);
|
||||
|
||||
// Build a snapshot object whose identity is stable across renders when the
|
||||
// underlying values haven't changed.
|
||||
const prevRef = useRef<Pick<DashboardData, K> | null>(null);
|
||||
const getSnapshot = useMemo(() => {
|
||||
return () => {
|
||||
const prev = prevRef.current;
|
||||
let same = prev !== null;
|
||||
const obj = {} as Record<string, unknown>;
|
||||
for (const key of stableKeys) {
|
||||
const val = store[key as string];
|
||||
obj[key as string] = val;
|
||||
if (same && prev![key as string as K] !== val) same = false;
|
||||
}
|
||||
if (same) return prev!;
|
||||
const next = obj as Pick<DashboardData, K>;
|
||||
prevRef.current = next;
|
||||
return next;
|
||||
};
|
||||
}, [stableKeys]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
/** Subscribe to backend connection status. */
|
||||
export function useBackendStatus(): BackendStatus {
|
||||
const subscribe = useMemo(() => {
|
||||
return (onStoreChange: Listener) => {
|
||||
statusListeners.add(onStoreChange);
|
||||
return () => { statusListeners.delete(onStoreChange); };
|
||||
};
|
||||
}, []);
|
||||
const getSnapshot = useMemo(() => () => backendStatus, []);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
/** Full snapshot — used only by components that genuinely need everything
|
||||
* (e.g. MaplibreViewer). Re-renders on ANY key change, same as before. */
|
||||
export function useDataSnapshot(): Record<string, unknown> {
|
||||
const prevRef = useRef<Record<string, unknown>>(store);
|
||||
const subscribe = useMemo(() => {
|
||||
return (onStoreChange: Listener) => {
|
||||
globalListeners.add(onStoreChange);
|
||||
return () => { globalListeners.delete(onStoreChange); };
|
||||
};
|
||||
}, []);
|
||||
const getSnapshot = useMemo(() => {
|
||||
return () => {
|
||||
// Return the same store reference — identity changes via globalListeners
|
||||
// already guarantee a re-render when mergeData is called.
|
||||
prevRef.current = store;
|
||||
return store;
|
||||
};
|
||||
}, []);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -1,38 +1,406 @@
|
||||
import { useCallback, useState, useEffect } from "react";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import type { RegionDossier, SelectedEntity } from "@/types/dashboard";
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import type { RegionDossier, SelectedEntity } from '@/types/dashboard';
|
||||
|
||||
// ─── CACHE ─────────────────────────────────────────────────────────────────
|
||||
// Simple in-memory cache keyed by rounded lat/lng (0.1° ≈ 11km grid), 24h TTL.
|
||||
const _dossierCache = new Map<string, { data: RegionDossier; ts: number }>();
|
||||
const CACHE_TTL = 86400_000; // 24 hours in ms
|
||||
|
||||
function getCached(lat: number, lng: number): RegionDossier | null {
|
||||
const key = `${Math.round(lat * 10) / 10}_${Math.round(lng * 10) / 10}`;
|
||||
const entry = _dossierCache.get(key);
|
||||
if (entry && Date.now() - entry.ts < CACHE_TTL) return entry.data;
|
||||
if (entry) _dossierCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCache(lat: number, lng: number, data: RegionDossier) {
|
||||
const key = `${Math.round(lat * 10) / 10}_${Math.round(lng * 10) / 10}`;
|
||||
_dossierCache.set(key, { data, ts: Date.now() });
|
||||
// Evict oldest entries if cache exceeds 500
|
||||
if (_dossierCache.size > 500) {
|
||||
const oldest = _dossierCache.keys().next().value;
|
||||
if (oldest) _dossierCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ESRI WORLD IMAGERY FALLBACK ───────────────────────────────────────────
|
||||
function buildLocalSentinelFallback(lat: number, lng: number) {
|
||||
const latSpan = 0.18;
|
||||
const lngSpan = 0.24;
|
||||
const bbox = `${lng - lngSpan},${lat - latSpan},${lng + lngSpan},${lat + latSpan}`;
|
||||
const base =
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/export';
|
||||
return {
|
||||
found: true,
|
||||
scene_id: null,
|
||||
datetime: null,
|
||||
cloud_cover: null,
|
||||
thumbnail_url: `${base}?bbox=${bbox}&bboxSR=4326&imageSR=4326&size=640,360&format=png32&f=image`,
|
||||
fullres_url: `${base}?bbox=${bbox}&bboxSR=4326&imageSR=4326&size=1600,900&format=png32&f=image`,
|
||||
bbox: [lng - lngSpan, lat - latSpan, lng + lngSpan, lat + latSpan],
|
||||
platform: 'Esri World Imagery',
|
||||
fallback: true,
|
||||
message: 'Using local imagery fallback while live satellite search completes.',
|
||||
};
|
||||
}
|
||||
|
||||
function buildLimitedDossier(lat: number, lng: number, error?: string): RegionDossier {
|
||||
return {
|
||||
lat,
|
||||
lng,
|
||||
coordinates: { lat, lng },
|
||||
location: {
|
||||
display_name: `${lat.toFixed(4)}, ${lng.toFixed(4)}`,
|
||||
},
|
||||
country: {
|
||||
name: 'LIMITED INTEL',
|
||||
official_name: '',
|
||||
leader: 'Unknown',
|
||||
government_type: 'Unavailable',
|
||||
population: 0,
|
||||
capital: 'Unknown',
|
||||
languages: [],
|
||||
currencies: [],
|
||||
region: '',
|
||||
subregion: '',
|
||||
area_km2: 0,
|
||||
flag_emoji: '',
|
||||
},
|
||||
local: {
|
||||
name: 'Selected coordinates',
|
||||
state: '',
|
||||
description: 'Fallback dossier',
|
||||
summary:
|
||||
'Live region enrichment is currently unavailable or slow. Local coordinates and fallback imagery are still available.',
|
||||
thumbnail: '',
|
||||
},
|
||||
warning: error || 'Region dossier is using local fallback data.',
|
||||
} as RegionDossier;
|
||||
}
|
||||
|
||||
// ─── BROWSER-DIRECT API CALLS ──────────────────────────────────────────────
|
||||
// All external APIs below support CORS — no backend proxy needed.
|
||||
|
||||
/** Reverse geocode via Nominatim (direct browser call). */
|
||||
async function reverseGeocode(lat: number, lng: number) {
|
||||
const url =
|
||||
`https://nominatim.openstreetmap.org/reverse?` +
|
||||
`lat=${lat}&lon=${lng}&format=json&zoom=10&addressdetails=1&accept-language=en`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'ShadowBroker-OSINT/1.0 (live-risk-dashboard)' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Nominatim HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const addr = data.address || {};
|
||||
return {
|
||||
city: addr.city || addr.town || addr.village || addr.county || '',
|
||||
state: addr.state || addr.region || '',
|
||||
country: addr.country || '',
|
||||
country_code: (addr.country_code || '').toUpperCase(),
|
||||
display_name: data.display_name || '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetch country data from RestCountries (direct browser call). */
|
||||
async function fetchCountryData(countryCode: string) {
|
||||
if (!countryCode) return {};
|
||||
const url =
|
||||
`https://restcountries.com/v3.1/alpha/${countryCode}` +
|
||||
`?fields=name,population,capital,languages,region,subregion,area,currencies,borders,flag`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`RestCountries HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data[0] || {} : data || {};
|
||||
}
|
||||
|
||||
/** Fetch head of state + government type from Wikidata SPARQL (direct browser call). */
|
||||
async function fetchLeader(countryName: string) {
|
||||
if (!countryName) return { leader: 'Unknown', government_type: 'Unknown' };
|
||||
const safeName = countryName.replace(/"/g, '\\"').replace(/'/g, "\\'");
|
||||
const sparql = `
|
||||
SELECT ?leaderLabel ?govTypeLabel WHERE {
|
||||
?country wdt:P31 wd:Q6256 ;
|
||||
rdfs:label "${safeName}"@en .
|
||||
OPTIONAL { ?country wdt:P35 ?leader . }
|
||||
OPTIONAL { ?country wdt:P122 ?govType . }
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
||||
} LIMIT 1
|
||||
`;
|
||||
const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(sparql)}&format=json`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: 'application/sparql-results+json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Wikidata HTTP ${res.status}`);
|
||||
const results = (await res.json()).results?.bindings || [];
|
||||
if (results.length > 0) {
|
||||
return {
|
||||
leader: results[0].leaderLabel?.value || 'Unknown',
|
||||
government_type: results[0].govTypeLabel?.value || 'Unknown',
|
||||
};
|
||||
}
|
||||
return { leader: 'Unknown', government_type: 'Unknown' };
|
||||
}
|
||||
|
||||
/** Fetch Wikipedia summary for a place (direct browser call). */
|
||||
async function fetchLocalWikiSummary(placeName: string, countryName = '') {
|
||||
if (!placeName) return {};
|
||||
const candidates = [placeName];
|
||||
if (countryName) candidates.push(`${placeName}, ${countryName}`);
|
||||
|
||||
for (const name of candidates) {
|
||||
try {
|
||||
const slug = encodeURIComponent(name.replace(/ /g, '_'));
|
||||
const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${slug}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) continue;
|
||||
const data = await res.json();
|
||||
if (data.type === 'disambiguation') continue;
|
||||
return {
|
||||
description: data.description || '',
|
||||
extract: data.extract || '',
|
||||
thumbnail: data.thumbnail?.source || '',
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Search for Sentinel-2 imagery via Microsoft Planetary Computer STAC (direct browser call). */
|
||||
async function fetchSentinel2Direct(lat: number, lng: number) {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const payload = {
|
||||
collections: ['sentinel-2-l2a'],
|
||||
intersects: { type: 'Point', coordinates: [lng, lat] },
|
||||
datetime: `${thirtyDaysAgo.toISOString()}/${now.toISOString()}`,
|
||||
sortby: [{ field: 'datetime', direction: 'desc' }],
|
||||
limit: 3,
|
||||
query: { 'eo:cloud_cover': { lt: 30 } },
|
||||
};
|
||||
|
||||
const res = await fetch('https://planetarycomputer.microsoft.com/api/stac/v1/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Planetary Computer HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const features = data.features || [];
|
||||
if (!features.length) return null; // No scenes — caller uses Esri fallback
|
||||
|
||||
const item = features[0];
|
||||
const assets = item.assets || {};
|
||||
const rendered = assets.rendered_preview || {};
|
||||
const thumbnail = assets.thumbnail || {};
|
||||
|
||||
return {
|
||||
found: true,
|
||||
scene_id: item.id,
|
||||
datetime: item.properties?.datetime,
|
||||
cloud_cover: item.properties?.['eo:cloud_cover'],
|
||||
thumbnail_url: thumbnail.href || rendered.href,
|
||||
fullres_url: rendered.href || thumbnail.href,
|
||||
bbox: item.bbox ? [...item.bbox] : null,
|
||||
platform: item.properties?.platform || 'Sentinel-2',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── MAIN HOOK ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useRegionDossier(
|
||||
selectedEntity: SelectedEntity | null,
|
||||
setSelectedEntity: (entity: SelectedEntity | null) => void
|
||||
setSelectedEntity: (entity: SelectedEntity | null) => void,
|
||||
) {
|
||||
const [regionDossier, setRegionDossier] = useState<RegionDossier | null>(null);
|
||||
const [regionDossierLoading, setRegionDossierLoading] = useState(false);
|
||||
|
||||
const handleMapRightClick = useCallback(async (coords: { lat: number; lng: number }) => {
|
||||
setSelectedEntity({ type: 'region_dossier', id: `${coords.lat.toFixed(4)}_${coords.lng.toFixed(4)}`, extra: coords });
|
||||
setRegionDossierLoading(true);
|
||||
setRegionDossier(null);
|
||||
try {
|
||||
const [dossierRes, sentinelRes] = await Promise.allSettled([
|
||||
fetch(`${API_BASE}/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`),
|
||||
fetch(`${API_BASE}/api/sentinel2/search?lat=${coords.lat}&lng=${coords.lng}`),
|
||||
]);
|
||||
let dossierData: Record<string, unknown> = {};
|
||||
if (dossierRes.status === 'fulfilled' && dossierRes.value.ok) {
|
||||
dossierData = await dossierRes.value.json();
|
||||
const handleMapRightClick = useCallback(
|
||||
async (coords: { lat: number; lng: number }) => {
|
||||
const { lat, lng } = coords;
|
||||
const esriFallback = buildLocalSentinelFallback(lat, lng);
|
||||
|
||||
setSelectedEntity({
|
||||
type: 'region_dossier',
|
||||
id: `${lat.toFixed(4)}_${lng.toFixed(4)}`,
|
||||
extra: coords,
|
||||
});
|
||||
setRegionDossierLoading(true);
|
||||
|
||||
// Check cache first
|
||||
const cached = getCached(lat, lng);
|
||||
if (cached) {
|
||||
setRegionDossier(cached);
|
||||
setRegionDossierLoading(false);
|
||||
return;
|
||||
}
|
||||
let sentinelData = null;
|
||||
if (sentinelRes.status === 'fulfilled' && sentinelRes.value.ok) {
|
||||
sentinelData = await sentinelRes.value.json();
|
||||
|
||||
// Show fallback immediately while API calls are in flight
|
||||
setRegionDossier({
|
||||
...buildLimitedDossier(lat, lng),
|
||||
sentinel2: esriFallback,
|
||||
});
|
||||
|
||||
try {
|
||||
// ── Phase 1: Geocode + Sentinel-2 in parallel ──────────────────
|
||||
const [geoResult, sentinelResult] = await Promise.allSettled([
|
||||
reverseGeocode(lat, lng),
|
||||
fetchSentinel2Direct(lat, lng),
|
||||
]);
|
||||
|
||||
// Parse geocode
|
||||
let geo = { city: '', state: '', country: '', country_code: '', display_name: '' };
|
||||
if (geoResult.status === 'fulfilled') {
|
||||
geo = geoResult.value;
|
||||
} else {
|
||||
console.warn('[Dossier] Reverse geocode failed:', geoResult.reason);
|
||||
}
|
||||
|
||||
// Parse sentinel
|
||||
let sentinel2: Record<string, unknown> = esriFallback;
|
||||
if (sentinelResult.status === 'fulfilled' && sentinelResult.value) {
|
||||
sentinel2 = sentinelResult.value;
|
||||
} else if (sentinelResult.status === 'rejected') {
|
||||
console.warn('[Dossier] Sentinel-2 search failed:', sentinelResult.reason);
|
||||
}
|
||||
// sentinelResult fulfilled but null → no scenes found, keep Esri fallback
|
||||
|
||||
// If no country found (ocean, uninhabited), show limited dossier
|
||||
if (!geo.country) {
|
||||
const result: RegionDossier = {
|
||||
lat,
|
||||
lng,
|
||||
coordinates: { lat, lng },
|
||||
location: geo.display_name
|
||||
? geo
|
||||
: { display_name: `${lat.toFixed(4)}, ${lng.toFixed(4)}` },
|
||||
country: null,
|
||||
local: null,
|
||||
error: 'No country data — possibly international waters or uninhabited area',
|
||||
sentinel2,
|
||||
} as RegionDossier;
|
||||
setRegionDossier(result);
|
||||
setCache(lat, lng, result);
|
||||
setRegionDossierLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Phase 2: Country + Leader + Wiki in parallel ───────────────
|
||||
const [countryResult, leaderResult, localWikiResult, countryWikiResult] =
|
||||
await Promise.allSettled([
|
||||
fetchCountryData(geo.country_code),
|
||||
fetchLeader(geo.country),
|
||||
fetchLocalWikiSummary(geo.city || geo.state, geo.country),
|
||||
fetchLocalWikiSummary(geo.country, ''),
|
||||
]);
|
||||
|
||||
// Parse country data
|
||||
let countryData: Record<string, unknown> = {};
|
||||
if (countryResult.status === 'fulfilled') {
|
||||
countryData = countryResult.value as Record<string, unknown>;
|
||||
} else {
|
||||
console.warn('[Dossier] Country data failed:', countryResult.reason);
|
||||
}
|
||||
|
||||
// Parse leader data
|
||||
let leaderData = { leader: 'Unknown', government_type: 'Unknown' };
|
||||
if (leaderResult.status === 'fulfilled') {
|
||||
leaderData = leaderResult.value;
|
||||
} else {
|
||||
console.warn('[Dossier] Leader data failed:', leaderResult.reason);
|
||||
}
|
||||
|
||||
// Parse local wiki
|
||||
let localData: Record<string, string> = {};
|
||||
if (localWikiResult.status === 'fulfilled') {
|
||||
localData = localWikiResult.value as Record<string, string>;
|
||||
} else {
|
||||
console.warn('[Dossier] Local wiki failed:', localWikiResult.reason);
|
||||
}
|
||||
|
||||
// If no local data, try country wiki summary
|
||||
if (!localData.extract && countryWikiResult.status === 'fulfilled') {
|
||||
const cw = countryWikiResult.value as Record<string, string>;
|
||||
if (cw.extract) localData = cw;
|
||||
}
|
||||
|
||||
// Build languages list
|
||||
const languages = countryData.languages as Record<string, string> | undefined;
|
||||
const langList = languages ? Object.values(languages) : [];
|
||||
|
||||
// Build currencies list
|
||||
const currencies = countryData.currencies as
|
||||
| Record<string, { name: string; symbol?: string }>
|
||||
| undefined;
|
||||
const currencyList: string[] = [];
|
||||
if (currencies) {
|
||||
for (const v of Object.values(currencies)) {
|
||||
if (v && typeof v === 'object') {
|
||||
const sym = v.symbol || '';
|
||||
const nm = v.name || '';
|
||||
currencyList.push(sym ? `${nm} (${sym})` : nm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nameData = countryData.name as
|
||||
| { common?: string; official?: string }
|
||||
| undefined;
|
||||
const capitalData = countryData.capital as string[] | undefined;
|
||||
|
||||
// ── Assemble final dossier (exact same shape as backend) ───────
|
||||
const result: RegionDossier = {
|
||||
lat,
|
||||
lng,
|
||||
coordinates: { lat, lng },
|
||||
location: {
|
||||
city: geo.city,
|
||||
state: geo.state,
|
||||
country: geo.country,
|
||||
country_code: geo.country_code,
|
||||
display_name: geo.display_name,
|
||||
},
|
||||
country: {
|
||||
name: nameData?.common || geo.country,
|
||||
official_name: nameData?.official || '',
|
||||
leader: leaderData.leader,
|
||||
government_type: leaderData.government_type,
|
||||
population: (countryData.population as number) || 0,
|
||||
capital: capitalData?.length ? capitalData[0] : 'Unknown',
|
||||
languages: langList,
|
||||
currencies: currencyList,
|
||||
region: (countryData.region as string) || '',
|
||||
subregion: (countryData.subregion as string) || '',
|
||||
area_km2: (countryData.area as number) || 0,
|
||||
flag_emoji: (countryData.flag as string) || '',
|
||||
},
|
||||
local: {
|
||||
name: geo.city,
|
||||
state: geo.state,
|
||||
description: localData.description || '',
|
||||
summary: localData.extract || '',
|
||||
thumbnail: localData.thumbnail || '',
|
||||
},
|
||||
sentinel2,
|
||||
} as RegionDossier;
|
||||
|
||||
setRegionDossier(result);
|
||||
setCache(lat, lng, result);
|
||||
} catch (e) {
|
||||
console.error('[Dossier] Unexpected error:', e);
|
||||
setRegionDossier({
|
||||
...buildLimitedDossier(lat, lng, 'Region dossier request failed unexpectedly'),
|
||||
sentinel2: esriFallback,
|
||||
});
|
||||
} finally {
|
||||
setRegionDossierLoading(false);
|
||||
}
|
||||
setRegionDossier({ lat: coords.lat, lng: coords.lng, ...dossierData, sentinel2: sentinelData });
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch region dossier", e);
|
||||
} finally {
|
||||
setRegionDossierLoading(false);
|
||||
}
|
||||
}, [setSelectedEntity]);
|
||||
},
|
||||
[setSelectedEntity],
|
||||
);
|
||||
|
||||
// Clear dossier when selecting a different entity type
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,16 +1,65 @@
|
||||
import { useCallback, useState, useRef } from "react";
|
||||
import { GEOCODE_THROTTLE_MS, GEOCODE_DISTANCE_THRESHOLD, GEOCODE_CACHE_SIZE } from "@/lib/constants";
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
GEOCODE_THROTTLE_MS,
|
||||
GEOCODE_DISTANCE_THRESHOLD,
|
||||
GEOCODE_CACHE_SIZE,
|
||||
} from '@/lib/constants';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
|
||||
const REVERSE_GEOCODE_TIMEOUT_MS = 1200;
|
||||
const REVERSE_GEOCODE_MIN_INTERVAL_MS = 2500;
|
||||
const REVERSE_GEOCODE_GRID_DECIMALS = 1;
|
||||
const MOUSE_COORDS_UI_INTERVAL_MS = 80;
|
||||
const MOUSE_COORDS_DISPLAY_DECIMALS = 4;
|
||||
|
||||
async function fetchJsonWithTimeout(url: string, timeoutMs: number, signal?: AbortSignal) {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
const onAbort = () => controller.abort();
|
||||
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return await response.json();
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export function useReverseGeocode() {
|
||||
const [mouseCoords, setMouseCoords] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [locationLabel, setLocationLabel] = useState('');
|
||||
const geocodeCache = useRef<Map<string, string>>(new Map());
|
||||
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const coordsUiTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastGeocodedPos = useRef<{ lat: number; lng: number } | null>(null);
|
||||
const geocodeAbort = useRef<AbortController | null>(null);
|
||||
const lastRequestAt = useRef(0);
|
||||
const lastUiCoordsKey = useRef('');
|
||||
const pendingUiCoords = useRef<{ lat: number; lng: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
|
||||
if (coordsUiTimer.current) clearTimeout(coordsUiTimer.current);
|
||||
if (geocodeAbort.current) geocodeAbort.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseCoords = useCallback((coords: { lat: number; lng: number }) => {
|
||||
setMouseCoords(coords);
|
||||
pendingUiCoords.current = coords;
|
||||
if (!coordsUiTimer.current) {
|
||||
coordsUiTimer.current = setTimeout(() => {
|
||||
coordsUiTimer.current = null;
|
||||
const next = pendingUiCoords.current;
|
||||
if (!next) return;
|
||||
const uiKey = `${next.lat.toFixed(MOUSE_COORDS_DISPLAY_DECIMALS)},${next.lng.toFixed(MOUSE_COORDS_DISPLAY_DECIMALS)}`;
|
||||
if (uiKey === lastUiCoordsKey.current) return;
|
||||
lastUiCoordsKey.current = uiKey;
|
||||
setMouseCoords(next);
|
||||
}, MOUSE_COORDS_UI_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
|
||||
geocodeTimer.current = setTimeout(async () => {
|
||||
@@ -20,7 +69,7 @@ export function useReverseGeocode() {
|
||||
if (dLat < GEOCODE_DISTANCE_THRESHOLD && dLng < GEOCODE_DISTANCE_THRESHOLD) return;
|
||||
}
|
||||
|
||||
const gridKey = `${(coords.lat).toFixed(2)},${(coords.lng).toFixed(2)}`;
|
||||
const gridKey = `${coords.lat.toFixed(REVERSE_GEOCODE_GRID_DECIMALS)},${coords.lng.toFixed(REVERSE_GEOCODE_GRID_DECIMALS)}`;
|
||||
const cached = geocodeCache.current.get(gridKey);
|
||||
if (cached) {
|
||||
setLocationLabel(cached);
|
||||
@@ -28,36 +77,40 @@ export function useReverseGeocode() {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastRequestAt.current < REVERSE_GEOCODE_MIN_INTERVAL_MS) return;
|
||||
lastRequestAt.current = now;
|
||||
|
||||
if (geocodeAbort.current) geocodeAbort.current.abort();
|
||||
geocodeAbort.current = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${coords.lat}&lon=${coords.lng}&format=json&zoom=10&addressdetails=1`,
|
||||
{ headers: { 'Accept-Language': 'en' }, signal: geocodeAbort.current.signal }
|
||||
const data = await fetchJsonWithTimeout(
|
||||
`${API_BASE}/api/geocode/reverse?lat=${coords.lat}&lng=${coords.lng}&local_only=1`,
|
||||
REVERSE_GEOCODE_TIMEOUT_MS,
|
||||
geocodeAbort.current.signal,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const addr = data.address || {};
|
||||
const city = addr.city || addr.town || addr.village || addr.county || '';
|
||||
const state = addr.state || addr.region || '';
|
||||
const country = addr.country || '';
|
||||
const parts = [city, state, country].filter(Boolean);
|
||||
const label = parts.join(', ') || data.display_name?.split(',').slice(0, 3).join(',') || 'Unknown';
|
||||
const label = data?.label || 'Unknown';
|
||||
|
||||
if (geocodeCache.current.size > GEOCODE_CACHE_SIZE) {
|
||||
const iter = geocodeCache.current.keys();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const key = iter.next().value;
|
||||
if (key !== undefined) geocodeCache.current.delete(key);
|
||||
}
|
||||
if (geocodeCache.current.size > GEOCODE_CACHE_SIZE) {
|
||||
const iter = geocodeCache.current.keys();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const key = iter.next().value;
|
||||
if (key !== undefined) geocodeCache.current.delete(key);
|
||||
}
|
||||
geocodeCache.current.set(gridKey, label);
|
||||
setLocationLabel(label);
|
||||
lastGeocodedPos.current = coords;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') { /* Silently fail - keep last label */ }
|
||||
geocodeCache.current.set(gridKey, label);
|
||||
setLocationLabel(label);
|
||||
lastGeocodedPos.current = coords;
|
||||
} catch (err) {
|
||||
const isAbort =
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
(err as { name?: string }).name === 'AbortError';
|
||||
if (!isAbort) {
|
||||
/* Silently fail - keep last label */
|
||||
}
|
||||
}
|
||||
}, GEOCODE_THROTTLE_MS);
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user