feat(map): add NOTAM overlay + satellite imagery integration (#1356)

* feat(map): add NOTAM overlay + satellite imagery integration

NOTAM Overlay:
- Expand airport monitoring from MENA-only to 64 global airports
- Add ScatterplotLayer (55km red rings) on flat map for airspace closures
- Add CSS-pulsing ring markers on globe for closures
- Independent of flights layer toggle (works when flights OFF)
- Bump NOTAM cache key v1 to v2

Satellite Imagery:
- Add Capella SAR STAC catalog proxy at /api/imagery/v1
- SSRF protection via URL allowlist + bbox/datetime validation
- SatelliteImageryPanel with preview thumbnails and scene metadata
- PolygonLayer footprints on flat map with viewport-triggered search
- Polygon footprints on globe with "Search this area" button
- Full variant only, default disabled

Layer key propagation across all 23+ files including variants,
harnesses, registry, URL state, and renderer channels.

* fix(imagery): wire panel data flow, fix viewport race, add datetime filter

P1 fixes:
- Imagery scenes now flow through MapContainer.setOnImageryUpdate()
  callback, making data available to both renderers and panel
- Add version guard to fetchImageryForViewport() preventing stale
  responses from overwriting newer viewport data
- Wire SatelliteImageryPanel.update() and setOnSearchArea() in
  panel-layout.ts (panel was previously unhooked)
- Globe mode "Search this area" fetches via MapContainer.getBbox()

P2 fix:
- search-imagery.ts now filters STAC items by datetime range when
  the client provides the datetime parameter

Also:
- Add MapContainer.getBbox() for viewport-aware imagery fetching
- Add DeckGLMap.getBbox() public method
- Data-loader layer toggle triggers initial imagery fetch

* fix(imagery): complete source filter + fix date-only end bound

- Filter STAC items by constellation when source param is provided,
  making the API contract match actual behavior
- Date-only end bounds (YYYY-MM-DD without T) now include the full
  day (23:59:59.999Z) instead of only midnight
This commit is contained in:
Elie Habib
2026-03-10 07:19:02 +04:00
committed by GitHub
parent b56c896730
commit fc134647a5
30 changed files with 853 additions and 16 deletions

View File

@@ -48,7 +48,7 @@ const STANDALONE_KEYS = {
usniFleetStale: 'usni-fleet:sebuf:stale:v1',
faaDelays: 'aviation:delays:faa:v1',
intlDelays: 'aviation:delays:intl:v3',
notamClosures: 'aviation:notam:closures:v1',
notamClosures: 'aviation:notam:closures:v2',
positiveEventsLive: 'positive-events:geo:v1',
cableHealth: 'cable-health-v1',
cyberThreatsRpc: 'cyber:threats:v2',

9
api/imagery/v1/[rpc].ts Normal file
View File

@@ -0,0 +1,9 @@
export const config = { runtime: 'edge' };
import { createDomainGateway, serverOptions } from '../../../server/gateway';
import { createImageryServiceRoutes } from '../../../src/generated/server/worldmonitor/imagery/v1/service_server';
import { imageryHandler } from '../../../server/worldmonitor/imagery/v1/handler';
export default createDomainGateway(
createImageryServiceRoutes(imageryHandler, serverOptions),
);

View File

@@ -0,0 +1,29 @@
syntax = "proto3";
package worldmonitor.imagery.v1;
import "sebuf/http/annotations.proto";
message SearchImageryRequest {
string bbox = 1 [(sebuf.http.query) = {name: "bbox"}];
string datetime = 2 [(sebuf.http.query) = {name: "datetime"}];
string source = 3 [(sebuf.http.query) = {name: "source"}];
int32 limit = 4 [(sebuf.http.query) = {name: "limit"}];
}
message ImageryScene {
string id = 1;
string satellite = 2;
string datetime = 3;
double resolution_m = 4;
string mode = 5;
string geometry_geojson = 6;
string preview_url = 7;
string asset_url = 8;
}
message SearchImageryResponse {
repeated ImageryScene scenes = 1;
int32 total_results = 2;
bool cache_hit = 3;
}

View File

@@ -0,0 +1,14 @@
syntax = "proto3";
package worldmonitor.imagery.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/imagery/v1/search_imagery.proto";
service ImageryService {
option (sebuf.http.service_config) = {base_path: "/api/imagery/v1"};
rpc SearchImagery(SearchImageryRequest) returns (SearchImageryResponse) {
option (sebuf.http.config) = {path: "/search-imagery", method: HTTP_METHOD_GET};
}
}

View File

@@ -5,7 +5,7 @@ import { loadEnvFile, CHROME_UA, getRedisCredentials, acquireLock, releaseLock,
loadEnvFile(import.meta.url);
const FAA_CACHE_KEY = 'aviation:delays:faa:v1';
const NOTAM_CACHE_KEY = 'aviation:notam:closures:v1';
const NOTAM_CACHE_KEY = 'aviation:notam:closures:v2';
const CACHE_TTL = 7200;
const FAA_URL = 'https://nasstatus.faa.gov/api/airport-status-information';
@@ -19,11 +19,23 @@ const FAA_AIRPORTS = [
'LGA', 'BWI', 'SLC', 'SAN', 'IAD', 'DCA', 'MDW', 'TPA', 'HNL', 'PDX',
];
const MENA_AIRPORTS_ICAO = [
const MONITORED_AIRPORTS_ICAO = [
// MENA
'OEJN', 'OERK', 'OEMA', 'OEDF', 'OMDB', 'OMAA', 'OMSJ',
'OTHH', 'OBBI', 'OOMS', 'OKBK', 'OLBA', 'OJAI', 'OSDI',
'ORBI', 'OIIE', 'OISS', 'OIMM', 'OIKB', 'HECA', 'GMMN',
'DTTA', 'DAAG', 'HLLT',
// Europe
'EGLL', 'LFPG', 'EDDF', 'EHAM', 'LEMD', 'LIRF', 'LTFM',
'LSZH', 'LOWW', 'EKCH', 'ENGM', 'ESSA', 'EFHK', 'EPWA',
// Americas
'KJFK', 'KLAX', 'KORD', 'KATL', 'KDFW', 'KDEN', 'KSFO',
'CYYZ', 'MMMX', 'SBGR', 'SCEL', 'SKBO',
// APAC
'RJTT', 'RKSI', 'VHHH', 'WSSS', 'VTBS', 'VIDP', 'YSSY',
'ZBAA', 'ZPPP', 'WMKK',
// Africa
'FAOR', 'DNMM', 'HKJK', 'GABS',
];
function parseDelayTypeFromReason(reason) {
@@ -177,8 +189,8 @@ async function seedNotamClosures() {
return null;
}
console.log(`[NOTAM] Fetching closures for ${MENA_AIRPORTS_ICAO.length} MENA airports...`);
const locations = MENA_AIRPORTS_ICAO.join(',');
console.log(`[NOTAM] Fetching closures for ${MONITORED_AIRPORTS_ICAO.length} monitored airports...`);
const locations = MONITORED_AIRPORTS_ICAO.join(',');
const now = Math.floor(Date.now() / 1000);
let notams = [];
@@ -212,7 +224,7 @@ async function seedNotamClosures() {
for (const n of notams) {
const icao = n.itema || n.location || '';
if (!icao || !MENA_AIRPORTS_ICAO.includes(icao)) continue;
if (!icao || !MONITORED_AIRPORTS_ICAO.includes(icao)) continue;
if (n.endvalidity && n.endvalidity < now) continue;
const code23 = (n.code23 || '').toUpperCase();

View File

@@ -125,6 +125,8 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
'/api/news/v1/list-feed-digest': 'slow',
'/api/intelligence/v1/classify-event': 'static',
'/api/news/v1/summarize-article-cache': 'slow',
'/api/imagery/v1/search-imagery': 'static',
};
const PREMIUM_RPC_PATHS = new Set([

View File

@@ -500,7 +500,7 @@ export function buildNotamAlert(airport: MonitoredAirport, reason: string): Airp
// ---------- Shared NOTAM loader (used by both list-airport-delays and get-airport-ops-summary) ----------
const NOTAM_CACHE_KEY = 'aviation:notam:closures:v1';
const NOTAM_CACHE_KEY = 'aviation:notam:closures:v2';
const NOTAM_CACHE_TTL = 7200;
const SEED_FRESHNESS_MS = 45 * 60 * 1000;
@@ -528,8 +528,8 @@ export async function loadNotamClosures(): Promise<LoadedNotamResult | null> {
try {
notamResult = await cachedFetchJson<LoadedNotamResult>(
NOTAM_CACHE_KEY, NOTAM_CACHE_TTL, async () => {
const mena = MONITORED_AIRPORTS.filter(a => a.region === 'mena');
const result = await fetchNotamClosures(mena);
const allAirports = MONITORED_AIRPORTS;
const result = await fetchNotamClosures(allAirports);
const closedIcaos = [...result.closedIcaoCodes];
const reasons: Record<string, string> = {};
for (const [icao, reason] of result.notamsByIcao) reasons[icao] = reason;

View File

@@ -0,0 +1,6 @@
import type { ImageryServiceHandler } from '../../../../src/generated/server/worldmonitor/imagery/v1/service_server';
import { searchImagery } from './search-imagery';
export const imageryHandler: ImageryServiceHandler = {
searchImagery,
};

View File

@@ -0,0 +1,224 @@
import type {
ServerContext,
SearchImageryRequest,
SearchImageryResponse,
ImageryScene,
} from '../../../../src/generated/server/worldmonitor/imagery/v1/service_server';
import { cachedFetchJson } from '../../../_shared/redis';
import { CHROME_UA } from '../../../_shared/constants';
const STAC_BASE = 'https://capella-open-data.s3.us-west-2.amazonaws.com';
const STAC_HOST = 'capella-open-data.s3.us-west-2.amazonaws.com';
const CACHE_TTL = 3600;
function fnv1a(str: string): number {
let hash = 2166136261;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function validateBbox(bbox: string): [number, number, number, number] | null {
const parts = bbox.split(',').map(Number);
if (parts.length !== 4 || parts.some(isNaN)) return null;
const w = parts[0]!;
const s = parts[1]!;
const e = parts[2]!;
const n = parts[3]!;
if (w < -180 || w > 180 || e < -180 || e > 180) return null;
if (s < -90 || s > 90 || n < -90 || n > 90) return null;
if (w >= e || s >= n) return null;
return [w, s, e, n];
}
function validateDatetime(dt: string): boolean {
if (!dt) return true;
const isoPattern = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?(\/\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?)?$/;
if (!isoPattern.test(dt)) return false;
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const now = new Date();
return dt.split('/').every(part => {
const d = new Date(part.slice(0, 10));
return d >= oneYearAgo && d <= now;
});
}
function isAllowedStacUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.hostname === STAC_HOST && parsed.protocol === 'https:';
} catch { return false; }
}
function cacheKey(bbox: string, datetime: string, source: string, limit: number): string {
const hash = fnv1a(`${bbox}|${datetime}|${source}|${limit}`).toString(36);
return `imagery:search:${hash}`;
}
function bboxIntersects(
sceneBbox: [number, number, number, number],
queryBbox: [number, number, number, number],
): boolean {
const [sw, ss, se, sn] = sceneBbox;
const [qw, qs, qe, qn] = queryBbox;
return sw < qe && se > qw && ss < qn && sn > qs;
}
interface StacItem {
id: string;
properties: {
datetime?: string;
constellation?: string;
'sar:instrument_mode'?: string;
'sar:resolution_range'?: number;
gsd?: number;
};
geometry: unknown;
bbox?: [number, number, number, number];
assets?: Record<string, { href?: string; type?: string }>;
links?: Array<{ rel: string; href: string; type?: string }>;
}
interface StacCollection {
links: Array<{ rel: string; href: string }>;
}
function mapStacItem(item: StacItem): ImageryScene {
const props = item.properties;
const preview = item.assets?.['thumbnail']?.href
?? item.links?.find(l => l.rel === 'thumbnail')?.href
?? '';
const asset = item.assets?.['HH']?.href
?? item.assets?.['VV']?.href
?? Object.values(item.assets ?? {})[0]?.href
?? '';
return {
id: item.id,
satellite: props.constellation ?? 'capella',
datetime: props.datetime ?? '',
resolutionM: props.gsd ?? props['sar:resolution_range'] ?? 0,
mode: props['sar:instrument_mode'] ?? '',
geometryGeojson: JSON.stringify(item.geometry),
previewUrl: preview,
assetUrl: asset,
};
}
export async function searchImagery(
_ctx: ServerContext,
req: SearchImageryRequest,
): Promise<SearchImageryResponse> {
if (!req.bbox) {
return { scenes: [], totalResults: 0, cacheHit: false };
}
const parsedBbox = validateBbox(req.bbox);
if (!parsedBbox) {
return { scenes: [], totalResults: 0, cacheHit: false };
}
if (!validateDatetime(req.datetime)) {
return { scenes: [], totalResults: 0, cacheHit: false };
}
const limit = Math.max(1, Math.min(50, req.limit || 10));
const key = cacheKey(req.bbox, req.datetime, req.source, limit);
try {
const result = await cachedFetchJson<{ scenes: ImageryScene[]; totalResults: number }>(
key,
CACHE_TTL,
async () => {
const catalogUrl = `${STAC_BASE}/catalog.json`;
const catalogResp = await fetch(catalogUrl, {
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
});
if (!catalogResp.ok) {
console.warn(`[Imagery] Catalog fetch failed: ${catalogResp.status}`);
return { scenes: [], totalResults: 0 };
}
const catalog = (await catalogResp.json()) as StacCollection;
const childLinks = catalog.links.filter(l => l.rel === 'child');
const allScenes: ImageryScene[] = [];
const collectionsToFetch = childLinks.slice(0, 5);
for (const link of collectionsToFetch) {
if (allScenes.length >= limit) break;
try {
const collUrl = link.href.startsWith('http')
? link.href
: `${STAC_BASE}/${link.href}`;
if (!isAllowedStacUrl(collUrl)) continue;
const collResp = await fetch(collUrl, {
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
signal: AbortSignal.timeout(8_000),
});
if (!collResp.ok) continue;
const collection = (await collResp.json()) as StacCollection;
const itemLinks = collection.links.filter(l => l.rel === 'item');
for (const itemLink of itemLinks.slice(0, 20)) {
if (allScenes.length >= limit) break;
try {
const itemUrl = itemLink.href.startsWith('http')
? itemLink.href
: `${STAC_BASE}/${itemLink.href}`;
if (!isAllowedStacUrl(itemUrl)) continue;
const itemResp = await fetch(itemUrl, {
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
signal: AbortSignal.timeout(5_000),
});
if (!itemResp.ok) continue;
const item = (await itemResp.json()) as StacItem;
if (item.bbox && !bboxIntersects(item.bbox, parsedBbox)) continue;
if (req.datetime && item.properties.datetime) {
const itemDate = new Date(item.properties.datetime);
const parts = req.datetime.split('/');
const start = new Date(parts[0]!);
const endStr = parts[1] ?? parts[0]!;
const isDateOnly = !endStr.includes('T');
const end = isDateOnly
? new Date(new Date(endStr).getTime() + 86_400_000 - 1)
: new Date(endStr);
if (itemDate < start || itemDate > end) continue;
}
if (req.source && item.properties.constellation) {
if (item.properties.constellation.toLowerCase() !== req.source.toLowerCase()) continue;
}
allScenes.push(mapStacItem(item));
} catch {
continue;
}
}
} catch {
continue;
}
}
return { scenes: allScenes.slice(0, limit), totalResults: allScenes.length };
},
);
if (result) {
return { scenes: result.scenes, totalResults: result.totalResults, cacheHit: true };
}
return { scenes: [], totalResults: 0, cacheHit: false };
} catch (err) {
console.warn(`[Imagery] Search failed: ${err instanceof Error ? err.message : 'unknown'}`);
return { scenes: [], totalResults: 0, cacheHit: false };
}
}

View File

@@ -61,6 +61,7 @@ export interface IntelligenceCache {
iranEvents?: IranEvent[];
orefAlerts?: { alertCount: number; historyCount24h: number };
advisories?: SecurityAdvisory[];
imageryScenes?: Array<{ id: string; satellite: string; datetime: string; resolutionM: number; mode: string; geometryGeojson: string; previewUrl: string; assetUrl: string }>;
}
export interface AppModule {

View File

@@ -508,6 +508,9 @@ export class DataLoaderManager implements AppModule {
case 'flights':
await this.loadFlightDelays();
break;
case 'notamOverlay':
await this.loadFlightDelays();
break;
case 'military':
await this.loadMilitary();
break;
@@ -528,6 +531,17 @@ export class DataLoaderManager implements AppModule {
case 'satellites':
await this.loadSatellites();
break;
case 'satelliteImagery': {
const bbox = this.ctx.map?.getBbox();
if (bbox) {
const { fetchImageryScenes } = await import('@/services/imagery');
const scenes = await fetchImageryScenes({ bbox, limit: 20 });
this.ctx.map?.setImageryScenes(scenes);
const panel = this.ctx.panels['satellite-imagery'] as import('@/components/SatelliteImageryPanel').SatelliteImageryPanel | undefined;
panel?.update(scenes);
}
break;
}
case 'ucdpEvents':
case 'displacement':
case 'climate':

View File

@@ -38,6 +38,7 @@ import {
AviationCommandBar,
} from '@/components';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { SatelliteImageryPanel } from '@/components/SatelliteImageryPanel';
import { focusInvestmentOnMap } from '@/services/investments-focus';
import { debounce, saveToStorage, loadFromStorage } from '@/utils';
import { escapeHtml } from '@/utils/sanitize';
@@ -588,6 +589,23 @@ export class PanelLayoutManager implements AppModule {
this.createPanel('cascade', () => new CascadePanel());
this.createPanel('satellite-fires', () => new SatelliteFiresPanel());
const imageryPanel = this.createPanel('satellite-imagery', () => new SatelliteImageryPanel());
if (imageryPanel) {
this.ctx.map?.setOnImageryUpdate((scenes) => {
imageryPanel.update(scenes);
this.ctx.map?.setImageryScenes(scenes);
});
imageryPanel.setOnSearchArea(async () => {
const bbox = this.ctx.map?.getBbox();
if (!bbox) return;
try {
const { fetchImageryScenes } = await import('@/services/imagery');
const scenes = await fetchImageryScenes({ bbox, limit: 20 });
imageryPanel.update(scenes);
this.ctx.map?.setImageryScenes(scenes);
} catch { /* search failed */ }
});
}
if (this.shouldCreatePanel('strategic-risk')) {
const strategicRiskPanel = new StrategicRiskPanel();

View File

@@ -41,6 +41,8 @@ import type { AirportDelayAlert, PositionSample } from '@/services/aviation';
import { fetchAircraftPositions } from '@/services/aviation';
import { type IranEvent, getIranEventColor, getIranEventRadius } from '@/services/conflict';
import type { GpsJamHex } from '@/services/gps-interference';
import { fetchImageryScenes } from '@/services/imagery';
import type { ImageryScene } from '@/generated/server/worldmonitor/imagery/v1/service_server';
import type { DisplacementFlow } from '@/services/displacement';
import type { Earthquake } from '@/services/earthquakes';
import type { ClimateAnomaly } from '@/services/climate';
@@ -326,6 +328,9 @@ export class DeckGLMap {
private tradeRouteSegments: TradeRouteSegment[] = resolveTradeRouteSegments();
private positiveEvents: PositiveGeoEvent[] = [];
private kindnessPoints: KindnessPoint[] = [];
private imageryScenes: ImageryScene[] = [];
private imagerySearchTimer: ReturnType<typeof setTimeout> | null = null;
private imagerySearchVersion = 0;
// Phase 8 overlay data
private happinessScores: Map<string, number> = new Map();
@@ -352,6 +357,7 @@ export class DeckGLMap {
private onLayerChange?: (layer: keyof MapLayers, enabled: boolean, source: 'user' | 'programmatic') => void;
private onStateChange?: (state: DeckMapState) => void;
private onAircraftPositionsUpdate?: (positions: PositionSample[]) => void;
private onImageryUpdate?: (scenes: ImageryScene[]) => void;
// Highlighted assets
private highlightedAssets: Record<AssetType, Set<string>> = {
@@ -684,6 +690,10 @@ export class DeckGLMap {
this.debouncedFetchAircraft();
this.state.zoom = this.maplibreMap?.getZoom() ?? this.state.zoom;
this.onStateChange?.(this.getState());
if (this.state.layers.satelliteImagery) {
if (this.imagerySearchTimer) clearTimeout(this.imagerySearchTimer);
this.imagerySearchTimer = setTimeout(() => this.fetchImageryForViewport(), 500);
}
});
this.maplibreMap.on('move', () => {
@@ -1168,7 +1178,7 @@ export class DeckGLMap {
const filteredWeatherAlerts = mapLayers.weather ? this.filterByTime(this.weatherAlerts, (alert) => alert.onset) : [];
const filteredOutages = mapLayers.outages ? this.filterByTime(this.outages, (outage) => outage.pubDate) : [];
const filteredCableAdvisories = mapLayers.cables ? this.filterByTime(this.cableAdvisories, (advisory) => advisory.reported) : [];
const filteredFlightDelays = mapLayers.flights ? this.filterByTime(this.flightDelays, (delay) => delay.updatedAt) : [];
const filteredFlightDelays = (mapLayers.flights || mapLayers.notamOverlay) ? this.filterByTime(this.flightDelays, (delay) => delay.updatedAt) : [];
const filteredMilitaryFlights = mapLayers.military ? this.filterByTime(this.militaryFlights, (flight) => flight.lastSeen) : [];
const filteredMilitaryVessels = mapLayers.military ? this.filterByTime(this.militaryVessels, (vessel) => vessel.lastAisUpdate) : [];
const filteredMilitaryFlightClusters = mapLayers.military ? this.filterMilitaryFlightClustersByTime(this.militaryFlightClusters) : [];
@@ -1317,6 +1327,14 @@ export class DeckGLMap {
layers.push(this.createFlightDelaysLayer(filteredFlightDelays));
}
// NOTAM overlay (airspace closure rings)
if (mapLayers.notamOverlay && filteredFlightDelays.length > 0) {
const closures = filteredFlightDelays.filter(d => d.delayType === 'closure');
if (closures.length > 0) {
layers.push(this.createNotamOverlayLayer(closures));
}
}
// Aircraft positions layer (live tracking, under flights toggle)
if (mapLayers.flights && this.aircraftPositions.length > 0) {
layers.push(this.createAircraftPositionsLayer());
@@ -1469,6 +1487,10 @@ export class DeckGLMap {
layers.push(this.createRenewableInstallationsLayer());
}
if (mapLayers.satelliteImagery && this.imageryScenes.length > 0) {
layers.push(this.createImageryFootprintLayer());
}
// News geo-locations (always shown if data exists)
if (this.newsLocations.length > 0) {
layers.push(...this.createNewsLocationsLayer());
@@ -1790,6 +1812,22 @@ export class DeckGLMap {
});
}
private createNotamOverlayLayer(closures: AirportDelayAlert[]): ScatterplotLayer {
return new ScatterplotLayer({
id: 'notam-overlay-layer',
data: closures,
getPosition: (d) => [d.lon, d.lat],
getRadius: 55000,
getFillColor: [255, 40, 40, 100] as [number, number, number, number],
getLineColor: [255, 40, 40, 200] as [number, number, number, number],
stroked: true,
lineWidthMinPixels: 2,
radiusMinPixels: 8,
radiusMaxPixels: 40,
pickable: true,
});
}
private createAircraftPositionsLayer(): IconLayer<PositionSample> {
return new IconLayer<PositionSample>({
id: 'aircraft-positions-layer',
@@ -3042,6 +3080,43 @@ export class DeckGLMap {
});
}
private createImageryFootprintLayer(): PolygonLayer {
return new PolygonLayer({
id: 'satellite-imagery-layer',
data: this.imageryScenes.filter(s => s.geometryGeojson),
getPolygon: (d: ImageryScene) => {
try {
const geom = JSON.parse(d.geometryGeojson);
if (geom.type === 'Polygon') return geom.coordinates[0];
return [];
} catch { return []; }
},
getFillColor: [0, 180, 255, 40] as [number, number, number, number],
getLineColor: [0, 180, 255, 180] as [number, number, number, number],
lineWidthMinPixels: 1,
pickable: true,
});
}
private async fetchImageryForViewport(): Promise<void> {
const map = this.maplibreMap;
if (!map) return;
const bounds = map.getBounds();
const bbox = `${bounds.getWest().toFixed(4)},${bounds.getSouth().toFixed(4)},${bounds.getEast().toFixed(4)},${bounds.getNorth().toFixed(4)}`;
const version = ++this.imagerySearchVersion;
try {
const scenes = await fetchImageryScenes({ bbox, limit: 20 });
if (version !== this.imagerySearchVersion) return;
this.imageryScenes = scenes;
this.onImageryUpdate?.(scenes);
this.render();
} catch { /* viewport fetch failed silently */ }
}
public setOnImageryUpdate(callback: (scenes: ImageryScene[]) => void): void {
this.onImageryUpdate = callback;
}
private getTooltip(info: PickingInfo): { html: string } | null {
if (!info.object) return null;
@@ -3152,6 +3227,8 @@ export class DeckGLMap {
}
case 'flight-delays-layer':
return { html: `<div class="deckgl-tooltip"><strong>${text(obj.name)} (${text(obj.iata)})</strong><br/>${text(obj.severity)}: ${text(obj.reason)}</div>` };
case 'notam-overlay-layer':
return { html: `<div class="deckgl-tooltip"><strong style="color:#ff2828;">&#9888; NOTAM CLOSURE</strong><br/>${text(obj.name)} (${text(obj.iata)})<br/><span style="opacity:.7">${text((obj.reason || '').slice(0, 100))}</span></div>` };
case 'aircraft-positions-layer':
return { html: `<div class="deckgl-tooltip"><strong>${text(obj.callsign || obj.icao24)}</strong><br/>${obj.altitudeFt?.toLocaleString() ?? 0} ft · ${obj.groundSpeedKts ?? 0} kts · ${Math.round(obj.trackDeg ?? 0)}°</div>` };
case 'apt-groups-layer':
@@ -3243,6 +3320,8 @@ export class DeckGLMap {
</div>`,
};
}
case 'satellite-imagery-layer':
return { html: `<div class="deckgl-tooltip"><strong>&#128752; ${text(obj.satellite)}</strong><br/>${text(obj.datetime)}<br/>Res: ${obj.resolutionM}m ${text(obj.mode)}</div>` };
default:
return null;
}
@@ -4023,6 +4102,12 @@ export class DeckGLMap {
return null;
}
public getBbox(): string | null {
if (!this.maplibreMap) return null;
const b = this.maplibreMap.getBounds();
return `${b.getWest().toFixed(4)},${b.getSouth().toFixed(4)},${b.getEast().toFixed(4)},${b.getNorth().toFixed(4)}`;
}
public setTimeRange(range: TimeRange): void {
this.state.timeRange = range;
this.rebuildProtestSupercluster();
@@ -4270,6 +4355,11 @@ export class DeckGLMap {
this.render();
}
public setImageryScenes(scenes: ImageryScene[]): void {
this.imageryScenes = scenes;
this.render();
}
public setOutages(outages: InternetOutage[]): void {
this.outages = outages;
this.render();

View File

@@ -42,6 +42,7 @@ import type { DisplacementFlow } from '@/services/displacement';
import type { ClimateAnomaly } from '@/services/climate';
import type { GpsJamHex } from '@/services/gps-interference';
import type { SatellitePosition } from '@/services/satellites';
import type { ImageryScene } from '@/generated/server/worldmonitor/imagery/v1/service_server';
const SAT_COUNTRY_COLORS: Record<string, string> = { CN: '#ff2020', RU: '#ff8800', US: '#4488ff', EU: '#44cc44', KR: '#aa66ff', IN: '#ff66aa', TR: '#ff4466', OTHER: '#ccccff' };
const SAT_TYPE_EMOJI: Record<string, string> = { sar: '\u{1F4E1}', optical: '\u{1F4F7}', military: '\u{1F396}', sigint: '\u{1F4FB}' };
@@ -247,6 +248,11 @@ interface FlightDelayMarker extends BaseMarker {
avgDelayMinutes: number;
reason: string;
}
interface NotamRingMarker extends BaseMarker {
_kind: 'notamRing';
name: string;
reason: string;
}
interface NewsLocationMarker extends BaseMarker {
_kind: 'newsLocation';
id: string;
@@ -297,6 +303,14 @@ interface SatFootprintMarker extends BaseMarker {
country: string;
noradId: string;
}
interface ImagerySceneMarker extends BaseMarker {
_kind: 'imageryScene';
satellite: string;
datetime: string;
resolutionM: number;
mode: string;
previewUrl: string;
}
interface GlobePath {
id: string;
name: string;
@@ -308,13 +322,16 @@ interface GlobePath {
interface GlobePolygon {
coords: number[][][];
name: string;
_kind: 'cii' | 'conflict';
_kind: 'cii' | 'conflict' | 'imageryFootprint';
level?: string;
score?: number;
intensity?: string;
parties?: string[];
casualties?: string;
satellite?: string;
datetime?: string;
}
type GlobeMarker =
| ConflictMarker | HotspotMarker | FlightMarker | VesselMarker
@@ -323,8 +340,8 @@ type GlobeMarker =
| UcdpMarker | DisplacementMarker | ClimateMarker | GpsJamMarker | TechMarker
| ConflictZoneMarker | MilBaseMarker | NuclearSiteMarker | IrradiatorSiteMarker | SpaceportSiteMarker
| EarthquakeMarker | EconomicMarker | DatacenterMarker | WaterwayMarker | MineralMarker
| FlightDelayMarker | CableAdvisoryMarker | RepairShipMarker | AisDisruptionMarker
| NewsLocationMarker | FlashMarker | SatelliteMarker | SatFootprintMarker;
| FlightDelayMarker | NotamRingMarker | CableAdvisoryMarker | RepairShipMarker | AisDisruptionMarker
| NewsLocationMarker | FlashMarker | SatelliteMarker | SatFootprintMarker | ImagerySceneMarker;
interface GlobeControlsLike {
autoRotate: boolean;
@@ -395,6 +412,7 @@ export class GlobeMap {
private waterwayMarkers: WaterwayMarker[] = [];
private mineralMarkers: MineralMarker[] = [];
private flightDelayMarkers: FlightDelayMarker[] = [];
private notamRingMarkers: NotamRingMarker[] = [];
private newsLocationMarkers: NewsLocationMarker[] = [];
private flashMarkers: FlashMarker[] = [];
private cableAdvisoryMarkers: CableAdvisoryMarker[] = [];
@@ -403,6 +421,8 @@ export class GlobeMap {
private satelliteMarkers: SatelliteMarker[] = [];
private satelliteTrailPaths: GlobePath[] = [];
private satelliteFootprintMarkers: SatFootprintMarker[] = [];
private imagerySceneMarkers: ImagerySceneMarker[] = [];
private imageryFootprintPolygons: GlobePolygon[] = [];
private satBeamGroup: any = null;
private tradeRouteSegments: TradeRouteSegment[] = [];
private globePaths: GlobePath[] = [];
@@ -652,16 +672,19 @@ export class GlobeMap {
.polygonCapColor((d: GlobePolygon) => {
if (d._kind === 'cii') return GlobeMap.CII_GLOBE_COLORS[d.level!] ?? 'rgba(0,0,0,0)';
if (d._kind === 'conflict') return GlobeMap.CONFLICT_CAP[d.intensity!] ?? GlobeMap.CONFLICT_CAP.low;
if (d._kind === 'imageryFootprint') return 'rgba(0,180,255,0.08)';
return 'rgba(255,60,60,0.15)';
})
.polygonSideColor((d: GlobePolygon) => {
if (d._kind === 'cii') return 'rgba(0,0,0,0)';
if (d._kind === 'conflict') return GlobeMap.CONFLICT_SIDE[d.intensity!] ?? GlobeMap.CONFLICT_SIDE.low;
if (d._kind === 'imageryFootprint') return 'rgba(0,180,255,0.04)';
return 'rgba(255,60,60,0.08)';
})
.polygonStrokeColor((d: GlobePolygon) => {
if (d._kind === 'cii') return 'rgba(80,80,80,0.3)';
if (d._kind === 'conflict') return GlobeMap.CONFLICT_STROKE[d.intensity!] ?? GlobeMap.CONFLICT_STROKE.low;
if (d._kind === 'imageryFootprint') return '#00b4ff';
return '#ff4444';
})
.polygonAltitude((d: GlobePolygon) => {
@@ -909,6 +932,9 @@ export class GlobeMap {
const sc = d.severity === 'severe' ? '#ff2020' : d.severity === 'major' ? '#ff6600' : d.severity === 'moderate' ? '#ffaa00' : '#ffee44';
el.innerHTML = `<div style="font-size:11px;color:${sc};text-shadow:0 0 4px ${sc}88;">✈</div>`;
el.title = `${d.iata}${d.severity}`;
} else if (d._kind === 'notamRing') {
el.innerHTML = `<div style="position:relative;width:20px;height:20px;display:flex;align-items:center;justify-content:center;"><div style="position:absolute;inset:-3px;border-radius:50%;border:2px solid #ff282888;${this.pulseStyle('2s')}"></div><div style="font-size:12px;color:#ff2828;text-shadow:0 0 6px #ff282888;">⚠</div></div>`;
el.title = `NOTAM: ${d.name}`;
} else if (d._kind === 'cableAdvisory') {
const sc = d.severity === 'fault' ? '#ff2020' : '#ff8800';
el.innerHTML = `<div style="font-size:11px;color:${sc};text-shadow:0 0 4px ${sc}88;">🔌</div>`;
@@ -941,6 +967,9 @@ export class GlobeMap {
const c = colors[(d as SatFootprintMarker).country] || '#ccccff';
el.innerHTML = `<div style="width:12px;height:12px;border-radius:50%;border:1px solid ${c}66;background:${c}15;margin:-6px 0 0 -6px"></div>`;
el.style.pointerEvents = 'none';
} else if (d._kind === 'imageryScene') {
el.innerHTML = `<div style="font-size:11px;color:#00b4ff;text-shadow:0 0 4px #00b4ff88;">&#128752;</div>`;
el.title = `${d.satellite} ${d.datetime}`;
} else if (d._kind === 'flash') {
el.style.pointerEvents = 'none';
el.innerHTML = `
@@ -1107,6 +1136,10 @@ export class GlobeMap {
`<br><span style="opacity:.7;">${esc(d.delayType.replace(/_/g, ' '))}` +
(d.avgDelayMinutes > 0 ? ` · avg ${d.avgDelayMinutes}min` : '') + `</span>` +
(d.reason ? `<br><span style="opacity:.5;white-space:normal;display:block;">${esc(d.reason.slice(0, 70))}</span>` : '');
} else if (d._kind === 'notamRing') {
html = `<span style="color:#ff2828;font-weight:bold;">⚠ NOTAM CLOSURE</span>` +
`<br><span style="opacity:.7;">${esc(d.name)}</span>` +
(d.reason ? `<br><span style="opacity:.5;white-space:normal;display:block;">${esc(d.reason.slice(0, 100))}</span>` : '');
} else if (d._kind === 'cableAdvisory') {
const sc = d.severity === 'fault' ? '#ff2020' : '#ff8800';
html = `<span style="color:${sc};font-weight:bold;">🔌 ${esc(d.severity.toUpperCase())}${esc(d.title.slice(0, 50))}</span>` +
@@ -1144,6 +1177,10 @@ export class GlobeMap {
`<span style="opacity:.5;">Incl.</span><span>${d.inclination.toFixed(1)}\u00B0</span>` +
`<span style="opacity:.5;">Velocity</span><span>${d.velocity.toFixed(1)} km/s</span>` +
`</div></div>`;
} else if (d._kind === 'imageryScene') {
html = `<span style="color:#00b4ff;font-weight:bold;">&#128752; ${esc(d.satellite)}</span>` +
`<br><span style="opacity:.7;">${esc(d.datetime)}</span>` +
`<br><span style="opacity:.5;">Res: ${d.resolutionM}m · ${esc(d.mode)}</span>`;
}
el.innerHTML = html;
if (d._kind === 'satellite') el.style.maxWidth = '300px';
@@ -1329,6 +1366,7 @@ export class GlobeMap {
if (this.layers.waterways) markers.push(...this.waterwayMarkers);
if (this.layers.minerals) markers.push(...this.mineralMarkers);
if (this.layers.flights) markers.push(...this.flightDelayMarkers);
if (this.layers.notamOverlay) markers.push(...this.notamRingMarkers);
if (this.layers.ais) markers.push(...this.aisMarkers);
if (this.layers.iranAttacks) markers.push(...this.iranMarkers);
if (this.layers.outages) markers.push(...this.outageMarkers);
@@ -1344,6 +1382,7 @@ export class GlobeMap {
markers.push(...this.satelliteFootprintMarkers);
}
if (this.layers.techEvents) markers.push(...this.techMarkers);
if (this.layers.satelliteImagery) markers.push(...this.imagerySceneMarkers);
if (this.layers.cables) {
markers.push(...this.cableAdvisoryMarkers);
markers.push(...this.repairShipMarkers);
@@ -1445,6 +1484,10 @@ export class GlobeMap {
}
}
if (this.layers.satelliteImagery) {
polys.push(...this.imageryFootprintPolygons);
}
(this.globe as any).polygonsData(polys);
}
@@ -1642,7 +1685,9 @@ export class GlobeMap {
['pipelines', { markers: false, arcs: false, paths: true, polygons: false }],
['conflicts', { markers: true, arcs: false, paths: false, polygons: true }],
['cables', { markers: true, arcs: false, paths: true, polygons: false }],
['satellites', { markers: true, arcs: false, paths: true, polygons: false }],
['satellites', { markers: true, arcs: false, paths: true, polygons: false }],
['satelliteImagery', { markers: true, arcs: false, paths: false, polygons: true }],
['notamOverlay', { markers: true, arcs: false, paths: false, polygons: false }],
]);
private flushLayerChannels(layer: keyof MapLayers): void {
@@ -1896,6 +1941,47 @@ export class GlobeMap {
}));
this.flushMarkers();
}
public setImageryScenes(scenes: ImageryScene[]): void {
const valid = (scenes ?? []).filter(s => {
try {
const geom = JSON.parse(s.geometryGeojson);
return geom?.type === 'Polygon' && geom.coordinates?.[0]?.[0];
} catch { return false; }
});
this.imagerySceneMarkers = valid.map(s => {
const geom = JSON.parse(s.geometryGeojson);
const coords = geom.coordinates[0] as number[][];
const lats = coords.map(c => c[1] ?? 0);
const lons = coords.map(c => c[0] ?? 0);
const centerLat = (Math.min(...lats) + Math.max(...lats)) / 2;
const centerLon = (Math.min(...lons) + Math.max(...lons)) / 2;
return {
_kind: 'imageryScene' as const,
_lat: centerLat,
_lng: centerLon,
satellite: s.satellite,
datetime: s.datetime,
resolutionM: s.resolutionM,
mode: s.mode,
previewUrl: s.previewUrl,
};
});
this.imageryFootprintPolygons = valid.map(s => {
const geom = JSON.parse(s.geometryGeojson);
return {
coords: geom.coordinates as number[][][],
name: `${s.satellite} ${s.datetime}`,
_kind: 'imageryFootprint' as const,
satellite: s.satellite,
datetime: s.datetime,
};
});
if (this.layers.satelliteImagery) {
this.flushMarkers();
this.flushPolygons();
}
}
public setOutages(outages: InternetOutage[]): void {
this.outageMarkers = (outages ?? []).filter(o => o.lat != null && o.lon != null).map(o => ({
_kind: 'outage' as const,
@@ -1985,6 +2071,15 @@ export class GlobeMap {
avgDelayMinutes: d.avgDelayMinutes,
reason: d.reason ?? '',
}));
this.notamRingMarkers = (delays ?? [])
.filter(d => d.lat != null && d.lon != null && d.delayType === 'closure')
.map(d => ({
_kind: 'notamRing' as const,
_lat: d.lat,
_lng: d.lon,
name: d.name || d.iata,
reason: d.reason || 'Airspace closure',
}));
this.flushMarkers();
}
public setNewsLocations(data: Array<{ lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }>): void {

View File

@@ -41,6 +41,7 @@ import type { RenewableInstallation } from '@/services/renewable-installations';
import type { GpsJamHex } from '@/services/gps-interference';
import type { SatellitePosition } from '@/services/satellites';
import type { IranEvent } from '@/services/conflict';
import type { ImageryScene } from '@/generated/server/worldmonitor/imagery/v1/service_server';
export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all';
export type MapView = 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania';
@@ -93,6 +94,7 @@ export class MapContainer {
private cachedOnCountryClicked: ((country: CountryClickPayload) => void) | null = null;
private cachedOnHotspotClicked: ((hotspot: Hotspot) => void) | null = null;
private cachedOnAircraftPositionsUpdate: ((positions: PositionSample[]) => void) | null = null;
private cachedOnImageryUpdate: ((scenes: ImageryScene[]) => void) | null = null;
// ─── Data cache (survives map mode switches) ───────────────────────────────
private cachedEarthquakes: Earthquake[] | null = null;
@@ -130,6 +132,7 @@ export class MapContainer {
private cachedHotspotActivity: NewsItem[] | null = null;
private cachedEscalationFlights: MilitaryFlight[] | null = null;
private cachedEscalationVessels: MilitaryVessel[] | null = null;
private cachedImageryScenes: ImageryScene[] | null = null;
constructor(container: HTMLElement, initialState: MapContainerState, preferGlobe = false) {
this.container = container;
@@ -258,6 +261,7 @@ export class MapContainer {
if (this.cachedOnCountryClicked) this.onCountryClicked(this.cachedOnCountryClicked);
if (this.cachedOnHotspotClicked) this.onHotspotClicked(this.cachedOnHotspotClicked);
if (this.cachedOnAircraftPositionsUpdate) this.setOnAircraftPositionsUpdate(this.cachedOnAircraftPositionsUpdate);
if (this.cachedOnImageryUpdate) this.setOnImageryUpdate(this.cachedOnImageryUpdate);
// 2. Re-push all cached data
if (this.cachedEarthquakes) this.setEarthquakes(this.cachedEarthquakes);
@@ -290,6 +294,7 @@ export class MapContainer {
if (this.cachedRenewableInstallations) this.setRenewableInstallations(this.cachedRenewableInstallations);
if (this.cachedHotspotActivity) this.updateHotspotActivity(this.cachedHotspotActivity);
if (this.cachedEscalationFlights && this.cachedEscalationVessels) this.updateMilitaryForEscalation(this.cachedEscalationFlights, this.cachedEscalationVessels);
if (this.cachedImageryScenes) this.setImageryScenes(this.cachedImageryScenes);
}
public isGlobeMode(): boolean {
@@ -389,6 +394,12 @@ export class MapContainer {
if (this.useDeckGL) { this.deckGLMap?.setEarthquakes(earthquakes); } else { this.svgMap?.setEarthquakes(earthquakes); }
}
public setImageryScenes(scenes: ImageryScene[]): void {
this.cachedImageryScenes = scenes;
if (this.useGlobe) { this.globeMap?.setImageryScenes(scenes); return; }
if (this.useDeckGL) { this.deckGLMap?.setImageryScenes(scenes); }
}
public setWeatherAlerts(alerts: WeatherAlert[]): void {
this.cachedWeatherAlerts = alerts;
if (this.useGlobe) { this.globeMap?.setWeatherAlerts(alerts); return; }
@@ -681,6 +692,24 @@ export class MapContainer {
}
}
public setOnImageryUpdate(callback: (scenes: ImageryScene[]) => void): void {
this.cachedOnImageryUpdate = callback;
if (this.useDeckGL) {
this.deckGLMap?.setOnImageryUpdate(callback);
}
}
public getBbox(): string | null {
if (this.useDeckGL) return this.deckGLMap?.getBbox() ?? null;
if (this.useGlobe) {
const center = this.globeMap?.getCenter();
if (!center) return null;
const R = 5;
return `${(center.lon - R).toFixed(4)},${(center.lat - R).toFixed(4)},${(center.lon + R).toFixed(4)},${(center.lat + R).toFixed(4)}`;
}
return null;
}
public onStateChanged(callback: (state: MapContainerState) => void): void {
this.cachedOnStateChanged = callback;
if (this.useGlobe) { this.globeMap?.onStateChanged(callback); return; }
@@ -891,6 +920,7 @@ export class MapContainer {
this.cachedOnCountryClicked = null;
this.cachedOnHotspotClicked = null;
this.cachedOnAircraftPositionsUpdate = null;
this.cachedOnImageryUpdate = null;
this.cachedEarthquakes = null;
this.cachedWeatherAlerts = null;
this.cachedOutages = null;
@@ -926,5 +956,6 @@ export class MapContainer {
this.cachedHotspotActivity = null;
this.cachedEscalationFlights = null;
this.cachedEscalationVessels = null;
this.cachedImageryScenes = null;
}
}

View File

@@ -0,0 +1,67 @@
import { Panel } from './Panel';
import type { ImageryScene } from '@/generated/server/worldmonitor/imagery/v1/service_server';
import { escapeHtml } from '@/utils/sanitize';
export class SatelliteImageryPanel extends Panel {
private scenes: ImageryScene[] = [];
private onSearchArea: (() => void) | null = null;
constructor() {
super({
id: 'satellite-imagery',
title: 'Satellite Imagery',
showCount: true,
trackActivity: true,
infoTooltip: 'Recent satellite imagery footprints from SAR and optical sensors',
});
this.showLoading('Scanning imagery catalog...');
this.element.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('.imagery-search-btn');
if (btn) this.onSearchArea?.();
});
}
public setOnSearchArea(cb: () => void): void {
this.onSearchArea = cb;
}
public update(scenes: ImageryScene[]): void {
this.scenes = scenes;
this.setCount(scenes.length);
this.render();
}
private render(): void {
if (this.scenes.length === 0) {
this.setContent(`<div class="panel-empty">No imagery scenes in view</div>`);
return;
}
const rows = this.scenes.slice(0, 20).map(s => {
const dt = s.datetime ? new Date(s.datetime).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
const preview = s.previewUrl
? `<img src="${escapeHtml(s.previewUrl)}" alt="" style="width:40px;height:40px;object-fit:cover;border-radius:2px;margin-right:6px;vertical-align:middle;" loading="lazy" onerror="this.style.display='none'">`
: '';
return `<div class="imagery-row" style="display:flex;align-items:center;padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.05);">
${preview}
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:11px;color:#00b4ff;">${escapeHtml(s.satellite)}</div>
<div style="font-size:10px;opacity:.7;">${escapeHtml(dt)} · ${s.resolutionM}m · ${escapeHtml(s.mode)}</div>
</div>
</div>`;
}).join('');
const searchBtn = this.onSearchArea
? `<button class="imagery-search-btn" style="width:100%;margin-top:8px;padding:6px;background:rgba(0,180,255,0.15);border:1px solid rgba(0,180,255,0.3);border-radius:3px;color:#00b4ff;cursor:pointer;font-size:11px;">Search this area</button>`
: '';
this.setContent(`
<div class="imagery-panel-content">
${rows}
<div class="imagery-footer" style="margin-top:4px;font-size:10px;opacity:.5;">
${this.scenes.length} scene${this.scenes.length !== 1 ? 's' : ''} found
</div>
${searchBtn}
</div>
`);
}
}

View File

@@ -34,6 +34,7 @@ export const LAYER_REGISTRY: Record<keyof MapLayers, LayerDefinition> = {
irradiators: def('irradiators', '&#9888;', 'gammaIrradiators', 'Gamma Irradiators'),
spaceports: def('spaceports', '&#128640;', 'spaceports', 'Spaceports'),
satellites: def('satellites', '&#128752;', 'satellites', 'Orbital Surveillance', ['globe']),
satelliteImagery: def('satelliteImagery', '&#128752;', 'satelliteImagery', 'Satellite Imagery', ['flat', 'globe']),
cables: def('cables', '&#128268;', 'underseaCables', 'Undersea Cables'),
pipelines: def('pipelines', '&#128738;', 'pipelines', 'Pipelines'),
datacenters: def('datacenters', '&#128421;', 'aiDataCenters', 'AI Data Centers'),
@@ -41,6 +42,7 @@ export const LAYER_REGISTRY: Record<keyof MapLayers, LayerDefinition> = {
ais: def('ais', '&#128674;', 'shipTraffic', 'Ship Traffic'),
tradeRoutes: def('tradeRoutes', '&#9875;', 'tradeRoutes', 'Trade Routes'),
flights: def('flights', '&#9992;', 'flightDelays', 'Flight Delays'),
notamOverlay: def('notamOverlay', '&#9888;', 'notamOverlay', 'NOTAM Closures'),
protests: def('protests', '&#128226;', 'protests', 'Protests'),
ucdpEvents: def('ucdpEvents', '&#9876;', 'ucdpEvents', 'Armed Conflict Events'),
displacement: def('displacement', '&#128101;', 'displacementFlows', 'Displacement Flows'),
@@ -82,11 +84,11 @@ const VARIANT_LAYER_ORDER: Record<MapVariant, Array<keyof MapLayers>> = {
'iranAttacks', 'hotspots', 'conflicts',
'bases', 'nuclear', 'irradiators', 'spaceports',
'cables', 'pipelines', 'datacenters', 'military',
'ais', 'tradeRoutes', 'flights', 'protests',
'ais', 'tradeRoutes', 'flights', 'notamOverlay', 'protests',
'ucdpEvents', 'displacement', 'climate', 'weather',
'outages', 'cyberThreats', 'natural', 'fires',
'waterways', 'economic', 'minerals', 'gpsJamming',
'satellites', 'ciiChoropleth', 'dayNight',
'satellites', 'satelliteImagery', 'ciiChoropleth', 'dayNight',
],
tech: [
'startupHubs', 'techHQs', 'accelerators', 'cloudRegions',

View File

@@ -45,6 +45,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
layoffs: { name: 'Layoffs Tracker', enabled: true, priority: 2 },
monitors: { name: 'My Monitors', enabled: true, priority: 2 },
'satellite-fires': { name: 'Fires', enabled: true, priority: 2 },
'satellite-imagery': { name: 'Satellite Imagery', enabled: false, priority: 2 },
'macro-signals': { name: 'Market Radar', enabled: true, priority: 2 },
'gulf-economies': { name: 'Gulf Economies', enabled: false, priority: 2 },
'etf-flows': { name: 'BTC ETF Tracker', enabled: true, priority: 2 },
@@ -66,6 +67,8 @@ const FULL_MAP_LAYERS: MapLayers = {
iranAttacks: _desktop ? false : true,
gpsJamming: false,
satellites: false,
satelliteImagery: false,
notamOverlay: false,
conflicts: true,
bases: _desktop ? false : true,
@@ -124,6 +127,8 @@ const FULL_MOBILE_MAP_LAYERS: MapLayers = {
iranAttacks: true,
gpsJamming: false,
satellites: false,
satelliteImagery: false,
notamOverlay: false,
conflicts: true,
bases: false,
@@ -223,6 +228,8 @@ const TECH_PANELS: Record<string, PanelConfig> = {
const TECH_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
notamOverlay: false,
conflicts: false,
bases: false,
@@ -281,6 +288,8 @@ const TECH_MAP_LAYERS: MapLayers = {
const TECH_MOBILE_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
notamOverlay: false,
conflicts: false,
bases: false,
@@ -382,6 +391,8 @@ const FINANCE_PANELS: Record<string, PanelConfig> = {
const FINANCE_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
notamOverlay: false,
conflicts: false,
bases: false,
@@ -440,6 +451,8 @@ const FINANCE_MAP_LAYERS: MapLayers = {
const FINANCE_MOBILE_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
notamOverlay: false,
conflicts: false,
bases: false,
@@ -514,6 +527,8 @@ const HAPPY_PANELS: Record<string, PanelConfig> = {
const HAPPY_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
notamOverlay: false,
conflicts: false,
bases: false,
@@ -572,6 +587,8 @@ const HAPPY_MAP_LAYERS: MapLayers = {
const HAPPY_MOBILE_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
notamOverlay: false,
conflicts: false,
bases: false,
@@ -661,6 +678,8 @@ const COMMODITY_PANELS: Record<string, PanelConfig> = {
const COMMODITY_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
notamOverlay: false,
conflicts: false,
bases: false,
@@ -719,6 +738,8 @@ const COMMODITY_MAP_LAYERS: MapLayers = {
const COMMODITY_MOBILE_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
notamOverlay: false,
conflicts: false,
bases: false,

View File

@@ -65,6 +65,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
// Geopolitical / military
gpsJamming: false,
satellites: false,
satelliteImagery: false,
iranAttacks: false,
conflicts: false,
bases: false,
@@ -80,6 +81,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
// Transport / tracking
ais: true, // Commodity shipping, tanker routes, bulk carriers
flights: false,
notamOverlay: false,
// Infrastructure
cables: true, // Undersea cables (trade comms)
outages: true, // Power outages affect operations
@@ -134,6 +136,7 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
// All others disabled on mobile
gpsJamming: false,
satellites: false,
satelliteImagery: false,
iranAttacks: false,
conflicts: false,
bases: false,
@@ -147,6 +150,7 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
protests: false,
ais: false,
flights: false,
notamOverlay: false,
cables: false,
outages: false,
datacenters: false,

View File

@@ -175,6 +175,7 @@ export const DEFAULT_PANELS: Record<string, PanelConfig> = {
export const DEFAULT_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
conflicts: false,
bases: false,
@@ -193,6 +194,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
datacenters: false,
protests: false,
flights: false,
notamOverlay: false,
military: false,
natural: true,
spaceports: false,
@@ -233,6 +235,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
conflicts: false,
bases: false,
@@ -251,6 +254,7 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
datacenters: false,
protests: false,
flights: false,
notamOverlay: false,
military: false,
natural: true,
spaceports: false,

View File

@@ -48,12 +48,14 @@ export const DEFAULT_PANELS: Record<string, PanelConfig> = {
'etf-flows': { name: 'BTC ETF Tracker', enabled: true, priority: 2 },
stablecoins: { name: 'Stablecoins', enabled: true, priority: 2 },
monitors: { name: 'My Monitors', enabled: true, priority: 2 },
'satellite-imagery': { name: 'Satellite Imagery', enabled: false, priority: 2 },
};
// Map layers for geopolitical view
export const DEFAULT_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
conflicts: true,
bases: true,
@@ -72,6 +74,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
datacenters: false,
protests: false,
flights: false,
notamOverlay: false,
military: false,
natural: false,
spaceports: false,
@@ -112,6 +115,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
conflicts: true,
bases: false,
@@ -130,6 +134,7 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
datacenters: false,
protests: false,
flights: false,
notamOverlay: false,
military: false,
natural: true,
spaceports: false,

View File

@@ -22,6 +22,7 @@ export const DEFAULT_PANELS: Record<string, PanelConfig> = {
export const DEFAULT_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
conflicts: false,
bases: false,
@@ -40,6 +41,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
datacenters: false,
protests: false,
flights: false,
notamOverlay: false,
military: false,
natural: false,
spaceports: false,
@@ -81,6 +83,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
conflicts: false,
bases: false,
@@ -99,6 +102,7 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
datacenters: false,
protests: false,
flights: false,
notamOverlay: false,
military: false,
natural: false,
spaceports: false,

View File

@@ -216,6 +216,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
// Keep only relevant layers, set others to false
gpsJamming: false,
satellites: false,
satelliteImagery: false,
conflicts: false,
bases: false,
@@ -234,6 +235,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
datacenters: true,
protests: false,
flights: false,
notamOverlay: false,
military: false,
natural: true,
spaceports: false,
@@ -274,6 +276,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
conflicts: false,
bases: false,
@@ -292,6 +295,7 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
datacenters: true,
protests: false,
flights: false,
notamOverlay: false,
military: false,
natural: true,
spaceports: false,

View File

@@ -138,6 +138,7 @@ app.style.margin = '0 auto';
const allLayersEnabled: MapLayers = {
gpsJamming: true,
satellites: false,
satelliteImagery: false,
conflicts: true,
bases: true,
@@ -156,6 +157,7 @@ const allLayersEnabled: MapLayers = {
datacenters: true,
protests: true,
flights: true,
notamOverlay: true,
military: true,
natural: true,
spaceports: true,
@@ -191,6 +193,7 @@ const allLayersEnabled: MapLayers = {
const allLayersDisabled: MapLayers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
conflicts: false,
bases: false,
@@ -209,6 +212,7 @@ const allLayersDisabled: MapLayers = {
datacenters: false,
protests: false,
flights: false,
notamOverlay: false,
military: false,
natural: false,
spaceports: false,

View File

@@ -87,6 +87,8 @@ window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const layers = {
gpsJamming: false,
satellites: false,
satelliteImagery: false,
notamOverlay: false,
conflicts: false,
bases: false,
cables: false,

View File

@@ -0,0 +1,145 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/imagery/v1/service.proto
export interface SearchImageryRequest {
bbox: string;
datetime: string;
source: string;
limit: number;
}
export interface ImageryScene {
id: string;
satellite: string;
datetime: string;
resolutionM: number;
mode: string;
geometryGeojson: string;
previewUrl: string;
assetUrl: string;
}
export interface SearchImageryResponse {
scenes: ImageryScene[];
totalResults: number;
cacheHit: boolean;
}
// ---- Framework types ----
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface ServerContext {
request: Request;
pathParams: Record<string, string>;
headers: Record<string, string>;
}
export interface ServerOptions {
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined;
}
export interface RouteDescriptor {
method: string;
path: string;
handler: (req: Request) => Promise<Response>;
}
export interface ImageryServiceHandler {
searchImagery(ctx: ServerContext, req: SearchImageryRequest): Promise<SearchImageryResponse>;
}
function makeHandler<Req, Res>(
methodName: string,
path: string,
parseReq: (params: URLSearchParams) => Req,
handlerFn: (ctx: ServerContext, req: Req) => Promise<Res>,
options?: ServerOptions,
): RouteDescriptor {
return {
method: "GET",
path,
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const url = new URL(req.url, "http://localhost");
const params = url.searchParams;
const body = parseReq(params);
if (options?.validateRequest) {
const violations = options.validateRequest(methodName, body);
if (violations) throw new ValidationError(violations);
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handlerFn(ctx, body);
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) return options.onError(err, req);
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
};
}
export function createImageryServiceRoutes(
handler: ImageryServiceHandler,
options?: ServerOptions,
): RouteDescriptor[] {
return [
makeHandler(
"searchImagery",
"/api/imagery/v1/search-imagery",
(p) => ({
bbox: p.get("bbox") ?? "",
datetime: p.get("datetime") ?? "",
source: p.get("source") ?? "",
limit: Number(p.get("limit") ?? "10"),
}),
handler.searchImagery.bind(handler),
options,
),
];
}

23
src/services/imagery.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { ImageryScene } from '@/generated/server/worldmonitor/imagery/v1/service_server';
export type { ImageryScene };
export interface ImagerySearchParams {
bbox: string;
datetime?: string;
source?: string;
limit?: number;
}
export async function fetchImageryScenes(params: ImagerySearchParams): Promise<ImageryScene[]> {
const url = new URL('/api/imagery/v1/search-imagery', window.location.origin);
url.searchParams.set('bbox', params.bbox);
if (params.datetime) url.searchParams.set('datetime', params.datetime);
if (params.source) url.searchParams.set('source', params.source);
if (params.limit) url.searchParams.set('limit', String(params.limit));
const resp = await fetch(url.toString(), { signal: AbortSignal.timeout(15_000) });
if (!resp.ok) return [];
const data = await resp.json();
return data.scenes ?? [];
}

View File

@@ -43,3 +43,4 @@ export * from './breaking-news-alerts';
export * from './daily-market-brief';
export * from './stock-analysis-history';
export * from './stock-backtest';
export * from './imagery';

View File

@@ -555,6 +555,10 @@ export interface MapLayers {
gpsJamming: boolean;
// Satellite orbital tracking
satellites: boolean;
// Satellite imagery footprints
satelliteImagery: boolean;
// NOTAM overlay (airspace closures)
notamOverlay: boolean;
// CII choropleth layer
ciiChoropleth: boolean;

View File

@@ -19,6 +19,7 @@ const LAYER_KEYS: (keyof MapLayers)[] = [
'datacenters',
'protests',
'flights',
'notamOverlay',
'military',
'natural',
'spaceports',
@@ -36,6 +37,7 @@ const LAYER_KEYS: (keyof MapLayers)[] = [
'iranAttacks',
'gpsJamming',
'satellites',
'satelliteImagery',
'ciiChoropleth',
];