mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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
9
api/imagery/v1/[rpc].ts
Normal 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),
|
||||
);
|
||||
29
proto/worldmonitor/imagery/v1/search_imagery.proto
Normal file
29
proto/worldmonitor/imagery/v1/search_imagery.proto
Normal 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;
|
||||
}
|
||||
14
proto/worldmonitor/imagery/v1/service.proto
Normal file
14
proto/worldmonitor/imagery/v1/service.proto
Normal 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};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
6
server/worldmonitor/imagery/v1/handler.ts
Normal file
6
server/worldmonitor/imagery/v1/handler.ts
Normal 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,
|
||||
};
|
||||
224
server/worldmonitor/imagery/v1/search-imagery.ts
Normal file
224
server/worldmonitor/imagery/v1/search-imagery.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;">⚠ 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>🛰 ${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();
|
||||
|
||||
@@ -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;">🛰</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;">🛰 ${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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
67
src/components/SatelliteImageryPanel.ts
Normal file
67
src/components/SatelliteImageryPanel.ts
Normal 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>
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export const LAYER_REGISTRY: Record<keyof MapLayers, LayerDefinition> = {
|
||||
irradiators: def('irradiators', '⚠', 'gammaIrradiators', 'Gamma Irradiators'),
|
||||
spaceports: def('spaceports', '🚀', 'spaceports', 'Spaceports'),
|
||||
satellites: def('satellites', '🛰', 'satellites', 'Orbital Surveillance', ['globe']),
|
||||
satelliteImagery: def('satelliteImagery', '🛰', 'satelliteImagery', 'Satellite Imagery', ['flat', 'globe']),
|
||||
cables: def('cables', '🔌', 'underseaCables', 'Undersea Cables'),
|
||||
pipelines: def('pipelines', '🛢', 'pipelines', 'Pipelines'),
|
||||
datacenters: def('datacenters', '🖥', 'aiDataCenters', 'AI Data Centers'),
|
||||
@@ -41,6 +42,7 @@ export const LAYER_REGISTRY: Record<keyof MapLayers, LayerDefinition> = {
|
||||
ais: def('ais', '🚢', 'shipTraffic', 'Ship Traffic'),
|
||||
tradeRoutes: def('tradeRoutes', '⚓', 'tradeRoutes', 'Trade Routes'),
|
||||
flights: def('flights', '✈', 'flightDelays', 'Flight Delays'),
|
||||
notamOverlay: def('notamOverlay', '⚠', 'notamOverlay', 'NOTAM Closures'),
|
||||
protests: def('protests', '📢', 'protests', 'Protests'),
|
||||
ucdpEvents: def('ucdpEvents', '⚔', 'ucdpEvents', 'Armed Conflict Events'),
|
||||
displacement: def('displacement', '👥', '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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
145
src/generated/server/worldmonitor/imagery/v1/service_server.ts
Normal file
145
src/generated/server/worldmonitor/imagery/v1/service_server.ts
Normal 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
23
src/services/imagery.ts
Normal 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 ?? [];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user