| import type { TheaterPostureSummary } from './military-surge'; |
| import { getRpcBaseUrl } from '@/services/rpc-client'; |
| import type { GetTheaterPostureResponse, TheaterPosture } from '@/generated/client/worldmonitor/military/v1/service_client'; |
| import { createCircuitBreaker } from '@/utils'; |
| import { getHydratedData } from '@/services/bootstrap'; |
| import { MilitaryServiceClient } from '@/services/generated-rpc-clients'; |
|
|
| |
|
|
| const client = new MilitaryServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); |
|
|
| |
|
|
| export interface CachedTheaterPosture { |
| postures: TheaterPostureSummary[]; |
| totalFlights: number; |
| timestamp: string; |
| cached: boolean; |
| stale?: boolean; |
| error?: string; |
| } |
|
|
| |
|
|
| interface TheaterMeta { |
| name: string; |
| shortName: string; |
| targetNation: string | null; |
| centerLat: number; |
| centerLon: number; |
| bounds: { north: number; south: number; east: number; west: number }; |
| } |
|
|
| const THEATER_META: Record<string, TheaterMeta> = { |
| 'iran-theater': { name: 'Iran Theater', shortName: 'IRAN', targetNation: 'Iran', centerLat: 31, centerLon: 47.5, bounds: { north: 42, south: 20, east: 65, west: 30 } }, |
| 'taiwan-theater': { name: 'Taiwan Strait', shortName: 'TAIWAN', targetNation: 'Taiwan', centerLat: 24, centerLon: 122.5, bounds: { north: 30, south: 18, east: 130, west: 115 } }, |
| 'baltic-theater': { name: 'Baltic Theater', shortName: 'BALTIC', targetNation: null, centerLat: 58.5, centerLon: 21, bounds: { north: 65, south: 52, east: 32, west: 10 } }, |
| 'blacksea-theater': { name: 'Black Sea', shortName: 'BLACK SEA', targetNation: null, centerLat: 44, centerLon: 34, bounds: { north: 48, south: 40, east: 42, west: 26 } }, |
| 'korea-theater': { name: 'Korean Peninsula', shortName: 'KOREA', targetNation: 'North Korea', centerLat: 38, centerLon: 128, bounds: { north: 43, south: 33, east: 132, west: 124 } }, |
| 'south-china-sea': { name: 'South China Sea', shortName: 'SCS', targetNation: null, centerLat: 15, centerLon: 113, bounds: { north: 25, south: 5, east: 121, west: 105 } }, |
| 'east-med-theater': { name: 'Eastern Mediterranean', shortName: 'E.MED', targetNation: null, centerLat: 35, centerLon: 31, bounds: { north: 37, south: 33, east: 37, west: 25 } }, |
| 'israel-gaza-theater': { name: 'Israel/Gaza', shortName: 'GAZA', targetNation: 'Gaza', centerLat: 31, centerLon: 34.5, bounds: { north: 33, south: 29, east: 36, west: 33 } }, |
| 'yemen-redsea-theater': { name: 'Yemen/Red Sea', shortName: 'RED SEA', targetNation: 'Yemen', centerLat: 16.5, centerLon: 43, bounds: { north: 22, south: 11, east: 54, west: 32 } }, |
| }; |
|
|
| function toPostureSummary(proto: TheaterPosture): TheaterPostureSummary { |
| const meta = THEATER_META[proto.theater]; |
| const strikeCapable = proto.activeOperations.includes('strike_capable'); |
| const postureLevel = (proto.postureLevel === 'critical' || proto.postureLevel === 'elevated') |
| ? proto.postureLevel as 'critical' | 'elevated' |
| : 'normal' as const; |
|
|
| return { |
| theaterId: proto.theater, |
| theaterName: meta?.name ?? proto.theater, |
| shortName: meta?.shortName ?? proto.theater, |
| targetNation: meta?.targetNation ?? null, |
| fighters: 0, |
| tankers: 0, |
| awacs: 0, |
| reconnaissance: 0, |
| transport: 0, |
| bombers: 0, |
| drones: 0, |
| totalAircraft: proto.activeFlights, |
| destroyers: 0, |
| frigates: 0, |
| carriers: 0, |
| submarines: 0, |
| patrol: 0, |
| auxiliaryVessels: 0, |
| totalVessels: proto.trackedVessels, |
| byOperator: {}, |
| postureLevel, |
| strikeCapable, |
| trend: 'stable', |
| changePercent: 0, |
| summary: '', |
| headline: postureLevel === 'critical' |
| ? `Critical military buildup - ${meta?.name ?? proto.theater}` |
| : postureLevel === 'elevated' |
| ? `Elevated military activity - ${meta?.name ?? proto.theater}` |
| : `Normal activity - ${meta?.name ?? proto.theater}`, |
| centerLat: meta?.centerLat ?? 0, |
| centerLon: meta?.centerLon ?? 0, |
| bounds: meta?.bounds, |
| }; |
| } |
|
|
| export function toPostureData(resp: GetTheaterPostureResponse): CachedTheaterPosture { |
| const postures = resp.theaters.map(toPostureSummary); |
| const totalFlights = postures.reduce((sum, p) => sum + p.totalAircraft, 0); |
| return { |
| postures, |
| totalFlights, |
| timestamp: new Date().toISOString(), |
| cached: true, |
| }; |
| } |
|
|
| |
|
|
| const breaker = createCircuitBreaker<CachedTheaterPosture>({ |
| name: 'Theater Posture', |
| cacheTtlMs: 15 * 60 * 1000, |
| persistCache: true, |
| }); |
|
|
| function emptyFallback(): CachedTheaterPosture { |
| return { |
| postures: [], |
| totalFlights: 0, |
| timestamp: new Date().toISOString(), |
| cached: true, |
| }; |
| } |
|
|
| |
|
|
| const LS_KEY = 'wm:theater-posture'; |
| const LS_MAX_STALENESS_MS = 24 * 60 * 60 * 1000; |
|
|
| function createAbortError(): DOMException { |
| return new DOMException('The operation was aborted.', 'AbortError'); |
| } |
|
|
| function withCallerAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> { |
| if (!signal) return promise; |
| if (signal.aborted) return Promise.reject(createAbortError()); |
|
|
| return new Promise<T>((resolve, reject) => { |
| const onAbort = () => { |
| signal.removeEventListener('abort', onAbort); |
| reject(createAbortError()); |
| }; |
| signal.addEventListener('abort', onAbort, { once: true }); |
|
|
| promise.then( |
| (value) => { |
| signal.removeEventListener('abort', onAbort); |
| resolve(value); |
| }, |
| (error) => { |
| signal.removeEventListener('abort', onAbort); |
| reject(error); |
| }, |
| ); |
| }); |
| } |
|
|
| function loadFromStorage(): CachedTheaterPosture | null { |
| try { |
| const raw = localStorage.getItem(LS_KEY); |
| if (!raw) return null; |
| const { data, savedAt } = JSON.parse(raw); |
| if (!Number.isFinite(savedAt) || !Array.isArray(data?.postures)) { |
| localStorage.removeItem(LS_KEY); |
| return null; |
| } |
| if (Date.now() - savedAt > LS_MAX_STALENESS_MS) { |
| localStorage.removeItem(LS_KEY); |
| return null; |
| } |
| return data; |
| } catch { return null; } |
| } |
|
|
| function saveToStorage(data: CachedTheaterPosture): void { |
| try { |
| localStorage.setItem(LS_KEY, JSON.stringify({ data, savedAt: Date.now() })); |
| } catch { } |
| } |
|
|
| |
| const stored = loadFromStorage(); |
| if (stored) breaker.recordSuccess(stored); |
|
|
| export async function fetchCachedTheaterPosture(signal?: AbortSignal): Promise<CachedTheaterPosture | null> { |
| if (signal?.aborted) throw createAbortError(); |
|
|
| |
| if (breaker.getCached() === null) { |
| const hydrated = getHydratedData('theaterPosture') as GetTheaterPostureResponse | undefined; |
| if (hydrated?.theaters?.length) { |
| const data = toPostureData(hydrated); |
| breaker.recordSuccess(data); |
| saveToStorage(data); |
| return data; |
| } |
| } |
|
|
| |
| const result = await withCallerAbort( |
| breaker.execute(async () => { |
| const resp = await client.getTheaterPosture({ theater: '' }); |
| const data = toPostureData(resp); |
| saveToStorage(data); |
| return data; |
| }, emptyFallback(), { shouldCache: (r) => r.postures.length > 0 }), |
| signal, |
| ); |
|
|
| if (!result || !Array.isArray(result.postures) || result.postures.length === 0) { |
| return null; |
| } |
|
|
| return result; |
| } |
|
|
| export function getCachedPosture(): CachedTheaterPosture | null { |
| return breaker.getCached(); |
| } |
|
|
| export function hasCachedPosture(): boolean { |
| return breaker.getCached() !== null; |
| } |
|
|