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:
anoracleofra-code
2026-03-26 05:58:04 -06:00
parent d363013742
commit 668ce16dc7
363 changed files with 170456 additions and 23229 deletions
+105 -27
View File
@@ -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.
}
+171
View File
@@ -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);
}
+394 -26
View File
@@ -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(() => {
+79 -26
View File
@@ -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);
}, []);