mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat: add Radiation Watch with seeded anomaly intelligence, map layers, and country exposure (#1735)
This commit is contained in:
2
api/bootstrap.js
vendored
2
api/bootstrap.js
vendored
@@ -22,6 +22,7 @@ const BOOTSTRAP_CACHE_KEYS = {
|
||||
minerals: 'supply_chain:minerals:v2',
|
||||
giving: 'giving:summary:v1',
|
||||
climateAnomalies: 'climate:anomalies:v1',
|
||||
radiationWatch: 'radiation:observations:v1',
|
||||
wildfires: 'wildfire:fires:v1',
|
||||
cyberThreats: 'cyber:threats-bootstrap:v2',
|
||||
techReadiness: 'economic:worldbank-techreadiness:v1',
|
||||
@@ -54,6 +55,7 @@ const BOOTSTRAP_CACHE_KEYS = {
|
||||
const SLOW_KEYS = new Set([
|
||||
'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving',
|
||||
'sectors', 'etfFlows', 'wildfires', 'climateAnomalies',
|
||||
'radiationWatch',
|
||||
'cyberThreats', 'techReadiness', 'progressData', 'renewableEnergy',
|
||||
'naturalEvents',
|
||||
'cryptoQuotes', 'gulfQuotes', 'stablecoinMarkets', 'unrestEvents', 'ucdpEvents',
|
||||
|
||||
@@ -35,6 +35,7 @@ const BOOTSTRAP_KEYS = {
|
||||
forecasts: 'forecast:predictions:v2',
|
||||
securityAdvisories: 'intelligence:advisories-bootstrap:v1',
|
||||
customsRevenue: 'trade:customs-revenue:v1',
|
||||
radiationWatch: 'radiation:observations:v1',
|
||||
};
|
||||
|
||||
const STANDALONE_KEYS = {
|
||||
@@ -129,6 +130,7 @@ const SEED_META = {
|
||||
usniFleet: { key: 'seed-meta:military:usni-fleet', maxStaleMin: 420 },
|
||||
securityAdvisories: { key: 'seed-meta:intelligence:advisories', maxStaleMin: 90 },
|
||||
customsRevenue: { key: 'seed-meta:trade:customs-revenue', maxStaleMin: 1440 },
|
||||
radiationWatch: { key: 'seed-meta:radiation:observations', maxStaleMin: 30 },
|
||||
};
|
||||
|
||||
// Standalone keys that are populated on-demand by RPC handlers (not seeds).
|
||||
|
||||
9
api/radiation/v1/[rpc].ts
Normal file
9
api/radiation/v1/[rpc].ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const config = { runtime: 'edge' };
|
||||
|
||||
import { createDomainGateway, serverOptions } from '../../../server/gateway';
|
||||
import { createRadiationServiceRoutes } from '../../../src/generated/server/worldmonitor/radiation/v1/service_server';
|
||||
import { radiationHandler } from '../../../server/worldmonitor/radiation/v1/handler';
|
||||
|
||||
export default createDomainGateway(
|
||||
createRadiationServiceRoutes(radiationHandler, serverOptions),
|
||||
);
|
||||
1
docs/api/RadiationService.openapi.json
Normal file
1
docs/api/RadiationService.openapi.json
Normal file
File diff suppressed because one or more lines are too long
239
docs/api/RadiationService.openapi.yaml
Normal file
239
docs/api/RadiationService.openapi.yaml
Normal file
@@ -0,0 +1,239 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: RadiationService API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/radiation/v1/list-radiation-observations:
|
||||
get:
|
||||
tags:
|
||||
- RadiationService
|
||||
summary: ListRadiationObservations
|
||||
description: ListRadiationObservations retrieves normalized EPA RadNet and Safecast readings.
|
||||
operationId: ListRadiationObservations
|
||||
parameters:
|
||||
- name: max_items
|
||||
in: query
|
||||
description: Maximum items to return (1-25). Zero uses the service default.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ListRadiationObservationsResponse'
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
default:
|
||||
description: Error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
components:
|
||||
schemas:
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
description: Error message (e.g., 'user not found', 'database connection failed')
|
||||
description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.
|
||||
FieldViolation:
|
||||
type: object
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
description: The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')
|
||||
description:
|
||||
type: string
|
||||
description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')
|
||||
required:
|
||||
- field
|
||||
- description
|
||||
description: FieldViolation describes a single validation error for a specific field.
|
||||
ValidationError:
|
||||
type: object
|
||||
properties:
|
||||
violations:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/FieldViolation'
|
||||
description: List of validation violations
|
||||
required:
|
||||
- violations
|
||||
description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.
|
||||
ListRadiationObservationsRequest:
|
||||
type: object
|
||||
properties:
|
||||
maxItems:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Maximum items to return (1-25). Zero uses the service default.
|
||||
description: ListRadiationObservationsRequest specifies optional result limits.
|
||||
ListRadiationObservationsResponse:
|
||||
type: object
|
||||
properties:
|
||||
observations:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RadiationObservation'
|
||||
fetchedAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Time the service synthesized the response, as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
epaCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Number of EPA RadNet observations included.
|
||||
safecastCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Number of Safecast observations included.
|
||||
anomalyCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Total observations classified above normal.
|
||||
elevatedCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Observations classified as elevated.
|
||||
spikeCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Observations classified as spike-level anomalies.
|
||||
corroboratedCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Observations corroborated by more than one source.
|
||||
lowConfidenceCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Observations that remain low-confidence after synthesis.
|
||||
conflictingCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Observations where contributing sources materially disagree.
|
||||
convertedFromCpmCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Observations whose normalized value was derived from CPM.
|
||||
description: ListRadiationObservationsResponse contains normalized readings plus coverage metadata.
|
||||
RadiationObservation:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
maxLength: 160
|
||||
minLength: 1
|
||||
description: Unique source-specific observation identifier.
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- RADIATION_SOURCE_UNSPECIFIED
|
||||
- RADIATION_SOURCE_EPA_RADNET
|
||||
- RADIATION_SOURCE_SAFECAST
|
||||
description: RadiationSource identifies the upstream measurement network.
|
||||
locationName:
|
||||
type: string
|
||||
description: Human-readable location label.
|
||||
country:
|
||||
type: string
|
||||
description: Country or territory label.
|
||||
location:
|
||||
$ref: '#/components/schemas/GeoCoordinates'
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
description: Dose equivalent rate normalized to nSv/h.
|
||||
unit:
|
||||
type: string
|
||||
description: Display unit, currently always nSv/h after normalization.
|
||||
observedAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Time the observation was recorded, as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
freshness:
|
||||
type: string
|
||||
enum:
|
||||
- RADIATION_FRESHNESS_UNSPECIFIED
|
||||
- RADIATION_FRESHNESS_LIVE
|
||||
- RADIATION_FRESHNESS_RECENT
|
||||
- RADIATION_FRESHNESS_HISTORICAL
|
||||
description: RadiationFreshness groups observations by recency.
|
||||
baselineValue:
|
||||
type: number
|
||||
format: double
|
||||
description: Rolling baseline for this station in nSv/h.
|
||||
delta:
|
||||
type: number
|
||||
format: double
|
||||
description: Current reading minus rolling baseline in nSv/h.
|
||||
zScore:
|
||||
type: number
|
||||
format: double
|
||||
description: Standard deviation distance from the rolling baseline.
|
||||
severity:
|
||||
type: string
|
||||
enum:
|
||||
- RADIATION_SEVERITY_UNSPECIFIED
|
||||
- RADIATION_SEVERITY_NORMAL
|
||||
- RADIATION_SEVERITY_ELEVATED
|
||||
- RADIATION_SEVERITY_SPIKE
|
||||
description: RadiationSeverity classifies whether a reading is behaving normally.
|
||||
contributingSources:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- RADIATION_SOURCE_UNSPECIFIED
|
||||
- RADIATION_SOURCE_EPA_RADNET
|
||||
- RADIATION_SOURCE_SAFECAST
|
||||
description: RadiationSource identifies the upstream measurement network.
|
||||
confidence:
|
||||
type: string
|
||||
enum:
|
||||
- RADIATION_CONFIDENCE_UNSPECIFIED
|
||||
- RADIATION_CONFIDENCE_LOW
|
||||
- RADIATION_CONFIDENCE_MEDIUM
|
||||
- RADIATION_CONFIDENCE_HIGH
|
||||
description: RadiationConfidence represents how strongly the reading is supported.
|
||||
corroborated:
|
||||
type: boolean
|
||||
description: Whether a second source corroborated the observed pattern.
|
||||
conflictingSources:
|
||||
type: boolean
|
||||
description: Whether contributing sources materially disagree.
|
||||
convertedFromCpm:
|
||||
type: boolean
|
||||
description: True when the value was converted from CPM using a generic fallback.
|
||||
sourceCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Number of distinct contributing sources.
|
||||
required:
|
||||
- id
|
||||
description: RadiationObservation represents a normalized ambient dose-rate reading.
|
||||
GeoCoordinates:
|
||||
type: object
|
||||
properties:
|
||||
latitude:
|
||||
type: number
|
||||
maximum: 90
|
||||
minimum: -90
|
||||
format: double
|
||||
description: Latitude in decimal degrees (-90 to 90).
|
||||
longitude:
|
||||
type: number
|
||||
maximum: 180
|
||||
minimum: -180
|
||||
format: double
|
||||
description: Longitude in decimal degrees (-180 to 180).
|
||||
description: GeoCoordinates represents a geographic location using WGS84 coordinates.
|
||||
@@ -0,0 +1,48 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.radiation.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/radiation/v1/radiation_observation.proto";
|
||||
|
||||
// ListRadiationObservationsRequest specifies optional result limits.
|
||||
message ListRadiationObservationsRequest {
|
||||
// Maximum items to return (1-25). Zero uses the service default.
|
||||
int32 max_items = 1 [(sebuf.http.query) = { name: "max_items" }];
|
||||
}
|
||||
|
||||
// ListRadiationObservationsResponse contains normalized readings plus coverage metadata.
|
||||
message ListRadiationObservationsResponse {
|
||||
// Normalized observations from EPA RadNet and Safecast.
|
||||
repeated RadiationObservation observations = 1;
|
||||
|
||||
// Time the service synthesized the response, as Unix epoch milliseconds.
|
||||
int64 fetched_at = 2 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
|
||||
// Number of EPA RadNet observations included.
|
||||
int32 epa_count = 3;
|
||||
|
||||
// Number of Safecast observations included.
|
||||
int32 safecast_count = 4;
|
||||
|
||||
// Total observations classified above normal.
|
||||
int32 anomaly_count = 5;
|
||||
|
||||
// Observations classified as elevated.
|
||||
int32 elevated_count = 6;
|
||||
|
||||
// Observations classified as spike-level anomalies.
|
||||
int32 spike_count = 7;
|
||||
|
||||
// Observations corroborated by more than one source.
|
||||
int32 corroborated_count = 8;
|
||||
|
||||
// Observations that remain low-confidence after synthesis.
|
||||
int32 low_confidence_count = 9;
|
||||
|
||||
// Observations where contributing sources materially disagree.
|
||||
int32 conflicting_count = 10;
|
||||
|
||||
// Observations whose normalized value was derived from CPM.
|
||||
int32 converted_from_cpm_count = 11;
|
||||
}
|
||||
102
proto/worldmonitor/radiation/v1/radiation_observation.proto
Normal file
102
proto/worldmonitor/radiation/v1/radiation_observation.proto
Normal file
@@ -0,0 +1,102 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.radiation.v1;
|
||||
|
||||
import "buf/validate/validate.proto";
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/core/v1/geo.proto";
|
||||
|
||||
// RadiationObservation represents a normalized ambient dose-rate reading.
|
||||
message RadiationObservation {
|
||||
// Unique source-specific observation identifier.
|
||||
string id = 1 [
|
||||
(buf.validate.field).required = true,
|
||||
(buf.validate.field).string.min_len = 1,
|
||||
(buf.validate.field).string.max_len = 160
|
||||
];
|
||||
|
||||
// Upstream source for the observation.
|
||||
RadiationSource source = 2;
|
||||
|
||||
// Human-readable location label.
|
||||
string location_name = 3;
|
||||
|
||||
// Country or territory label.
|
||||
string country = 4;
|
||||
|
||||
// Geographic location of the reading.
|
||||
worldmonitor.core.v1.GeoCoordinates location = 5;
|
||||
|
||||
// Dose equivalent rate normalized to nSv/h.
|
||||
double value = 6;
|
||||
|
||||
// Display unit, currently always nSv/h after normalization.
|
||||
string unit = 7;
|
||||
|
||||
// Time the observation was recorded, as Unix epoch milliseconds.
|
||||
int64 observed_at = 8 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
|
||||
// Freshness bucket derived from observation age.
|
||||
RadiationFreshness freshness = 9;
|
||||
|
||||
// Rolling baseline for this station in nSv/h.
|
||||
double baseline_value = 10;
|
||||
|
||||
// Current reading minus rolling baseline in nSv/h.
|
||||
double delta = 11;
|
||||
|
||||
// Standard deviation distance from the rolling baseline.
|
||||
double z_score = 12;
|
||||
|
||||
// Interpreted anomaly severity for the current reading.
|
||||
RadiationSeverity severity = 13;
|
||||
|
||||
// Sources contributing to this synthesized observation.
|
||||
repeated RadiationSource contributing_sources = 14;
|
||||
|
||||
// Confidence in the current synthesized observation.
|
||||
RadiationConfidence confidence = 15;
|
||||
|
||||
// Whether a second source corroborated the observed pattern.
|
||||
bool corroborated = 16;
|
||||
|
||||
// Whether contributing sources materially disagree.
|
||||
bool conflicting_sources = 17;
|
||||
|
||||
// True when the value was converted from CPM using a generic fallback.
|
||||
bool converted_from_cpm = 18;
|
||||
|
||||
// Number of distinct contributing sources.
|
||||
int32 source_count = 19;
|
||||
}
|
||||
|
||||
// RadiationSource identifies the upstream measurement network.
|
||||
enum RadiationSource {
|
||||
RADIATION_SOURCE_UNSPECIFIED = 0;
|
||||
RADIATION_SOURCE_EPA_RADNET = 1;
|
||||
RADIATION_SOURCE_SAFECAST = 2;
|
||||
}
|
||||
|
||||
// RadiationFreshness groups observations by recency.
|
||||
enum RadiationFreshness {
|
||||
RADIATION_FRESHNESS_UNSPECIFIED = 0;
|
||||
RADIATION_FRESHNESS_LIVE = 1;
|
||||
RADIATION_FRESHNESS_RECENT = 2;
|
||||
RADIATION_FRESHNESS_HISTORICAL = 3;
|
||||
}
|
||||
|
||||
// RadiationSeverity classifies whether a reading is behaving normally.
|
||||
enum RadiationSeverity {
|
||||
RADIATION_SEVERITY_UNSPECIFIED = 0;
|
||||
RADIATION_SEVERITY_NORMAL = 1;
|
||||
RADIATION_SEVERITY_ELEVATED = 2;
|
||||
RADIATION_SEVERITY_SPIKE = 3;
|
||||
}
|
||||
|
||||
// RadiationConfidence represents how strongly the reading is supported.
|
||||
enum RadiationConfidence {
|
||||
RADIATION_CONFIDENCE_UNSPECIFIED = 0;
|
||||
RADIATION_CONFIDENCE_LOW = 1;
|
||||
RADIATION_CONFIDENCE_MEDIUM = 2;
|
||||
RADIATION_CONFIDENCE_HIGH = 3;
|
||||
}
|
||||
16
proto/worldmonitor/radiation/v1/service.proto
Normal file
16
proto/worldmonitor/radiation/v1/service.proto
Normal file
@@ -0,0 +1,16 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.radiation.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/radiation/v1/list_radiation_observations.proto";
|
||||
|
||||
// RadiationService provides normalized environmental radiation readings.
|
||||
service RadiationService {
|
||||
option (sebuf.http.service_config) = {base_path: "/api/radiation/v1"};
|
||||
|
||||
// ListRadiationObservations retrieves normalized EPA RadNet and Safecast readings.
|
||||
rpc ListRadiationObservations(ListRadiationObservationsRequest) returns (ListRadiationObservationsResponse) {
|
||||
option (sebuf.http.config) = {path: "/list-radiation-observations", method: HTTP_METHOD_GET};
|
||||
}
|
||||
}
|
||||
473
scripts/seed-radiation-watch.mjs
Normal file
473
scripts/seed-radiation-watch.mjs
Normal file
@@ -0,0 +1,473 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
|
||||
|
||||
loadEnvFile(import.meta.url);
|
||||
|
||||
const CANONICAL_KEY = 'radiation:observations:v1';
|
||||
const CACHE_TTL = 7200;
|
||||
const EPA_TIMEOUT_MS = 20_000;
|
||||
const SAFECAST_TIMEOUT_MS = 20_000;
|
||||
const BASELINE_WINDOW_SIZE = 168;
|
||||
const BASELINE_MIN_SAMPLES = 48;
|
||||
const SAFECAST_BASELINE_WINDOW_SIZE = 96;
|
||||
const SAFECAST_MIN_SAMPLES = 24;
|
||||
const SAFECAST_DISTANCE_KM = 120;
|
||||
const SAFECAST_LOOKBACK_DAYS = 400;
|
||||
const SAFECAST_CPM_PER_USV_H = 350;
|
||||
|
||||
const EPA_SITES = [
|
||||
{ anchorId: 'us-anchorage', state: 'AK', slug: 'ANCHORAGE', name: 'Anchorage', country: 'United States', lat: 61.2181, lon: -149.9003 },
|
||||
{ anchorId: 'us-san-francisco', state: 'CA', slug: 'SAN%20FRANCISCO', name: 'San Francisco', country: 'United States', lat: 37.7749, lon: -122.4194 },
|
||||
{ anchorId: 'us-washington-dc', state: 'DC', slug: 'WASHINGTON', name: 'Washington, DC', country: 'United States', lat: 38.9072, lon: -77.0369 },
|
||||
{ anchorId: 'us-honolulu', state: 'HI', slug: 'HONOLULU', name: 'Honolulu', country: 'United States', lat: 21.3099, lon: -157.8581 },
|
||||
{ anchorId: 'us-chicago', state: 'IL', slug: 'CHICAGO', name: 'Chicago', country: 'United States', lat: 41.8781, lon: -87.6298 },
|
||||
{ anchorId: 'us-boston', state: 'MA', slug: 'BOSTON', name: 'Boston', country: 'United States', lat: 42.3601, lon: -71.0589 },
|
||||
{ anchorId: 'us-albany', state: 'NY', slug: 'ALBANY', name: 'Albany', country: 'United States', lat: 42.6526, lon: -73.7562 },
|
||||
{ anchorId: 'us-philadelphia', state: 'PA', slug: 'PHILADELPHIA', name: 'Philadelphia', country: 'United States', lat: 39.9526, lon: -75.1652 },
|
||||
{ anchorId: 'us-houston', state: 'TX', slug: 'HOUSTON', name: 'Houston', country: 'United States', lat: 29.7604, lon: -95.3698 },
|
||||
{ anchorId: 'us-seattle', state: 'WA', slug: 'SEATTLE', name: 'Seattle', country: 'United States', lat: 47.6062, lon: -122.3321 },
|
||||
];
|
||||
|
||||
const SAFECAST_SITES = [
|
||||
...EPA_SITES.map(({ anchorId, name, country, lat, lon }) => ({ anchorId, name, country, lat, lon })),
|
||||
{ anchorId: 'jp-tokyo', name: 'Tokyo', country: 'Japan', lat: 35.6895, lon: 139.6917 },
|
||||
{ anchorId: 'jp-fukushima', name: 'Fukushima', country: 'Japan', lat: 37.7608, lon: 140.4747 },
|
||||
];
|
||||
|
||||
function round(value, digits = 1) {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function parseRadNetTimestamp(raw) {
|
||||
const match = String(raw || '').trim().match(/^(\d{2})\/(\d{2})\/(\d{4}) (\d{2}):(\d{2}):(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const [, month, day, year, hour, minute, second] = match;
|
||||
return Date.UTC(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second),
|
||||
);
|
||||
}
|
||||
|
||||
function classifyFreshness(observedAt) {
|
||||
const ageMs = Date.now() - observedAt;
|
||||
if (ageMs <= 6 * 60 * 60 * 1000) return 'RADIATION_FRESHNESS_LIVE';
|
||||
if (ageMs <= 14 * 24 * 60 * 60 * 1000) return 'RADIATION_FRESHNESS_RECENT';
|
||||
return 'RADIATION_FRESHNESS_HISTORICAL';
|
||||
}
|
||||
|
||||
function classifySeverity(delta, zScore, freshness) {
|
||||
if (freshness === 'RADIATION_FRESHNESS_HISTORICAL') return 'RADIATION_SEVERITY_NORMAL';
|
||||
if (delta >= 15 || zScore >= 3) return 'RADIATION_SEVERITY_SPIKE';
|
||||
if (delta >= 8 || zScore >= 2) return 'RADIATION_SEVERITY_ELEVATED';
|
||||
return 'RADIATION_SEVERITY_NORMAL';
|
||||
}
|
||||
|
||||
function severityRank(value) {
|
||||
switch (value) {
|
||||
case 'RADIATION_SEVERITY_SPIKE': return 3;
|
||||
case 'RADIATION_SEVERITY_ELEVATED': return 2;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function freshnessRank(value) {
|
||||
switch (value) {
|
||||
case 'RADIATION_FRESHNESS_LIVE': return 3;
|
||||
case 'RADIATION_FRESHNESS_RECENT': return 2;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function confidenceRank(value) {
|
||||
switch (value) {
|
||||
case 'RADIATION_CONFIDENCE_HIGH': return 3;
|
||||
case 'RADIATION_CONFIDENCE_MEDIUM': return 2;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function average(values) {
|
||||
return values.length > 0
|
||||
? values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
: 0;
|
||||
}
|
||||
|
||||
function stdDev(values, mean) {
|
||||
if (values.length < 2) return 0;
|
||||
const variance = values.reduce((sum, value) => sum + ((value - mean) ** 2), 0) / (values.length - 1);
|
||||
return Math.sqrt(Math.max(variance, 0));
|
||||
}
|
||||
|
||||
function downgradeConfidence(value) {
|
||||
if (value === 'RADIATION_CONFIDENCE_HIGH') return 'RADIATION_CONFIDENCE_MEDIUM';
|
||||
return 'RADIATION_CONFIDENCE_LOW';
|
||||
}
|
||||
|
||||
function normalizeUnit(value, unit) {
|
||||
const normalizedUnit = String(unit || '').trim().replace('μ', 'u').replace('µ', 'u');
|
||||
if (!Number.isFinite(value)) return null;
|
||||
if (normalizedUnit === 'nSv/h') {
|
||||
return { value, unit: 'nSv/h', convertedFromCpm: false, directUnit: true };
|
||||
}
|
||||
if (normalizedUnit === 'uSv/h') {
|
||||
return { value: value * 1000, unit: 'nSv/h', convertedFromCpm: false, directUnit: true };
|
||||
}
|
||||
if (normalizedUnit === 'cpm') {
|
||||
return {
|
||||
value: (value / SAFECAST_CPM_PER_USV_H) * 1000,
|
||||
unit: 'nSv/h',
|
||||
convertedFromCpm: true,
|
||||
directUnit: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseApprovedReadings(csv) {
|
||||
const lines = String(csv || '').trim().split(/\r?\n/);
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const readings = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) continue;
|
||||
const columns = line.split(',');
|
||||
if (columns.length < 3) continue;
|
||||
const status = columns[columns.length - 1]?.trim().toUpperCase();
|
||||
if (status !== 'APPROVED') continue;
|
||||
const observedAt = parseRadNetTimestamp(columns[1] ?? '');
|
||||
const value = Number(columns[2] ?? '');
|
||||
if (!observedAt || !Number.isFinite(value)) continue;
|
||||
readings.push({ observedAt, value });
|
||||
}
|
||||
|
||||
return readings.sort((a, b) => a.observedAt - b.observedAt);
|
||||
}
|
||||
|
||||
function buildBaseObservation({
|
||||
id,
|
||||
anchorId,
|
||||
source,
|
||||
locationName,
|
||||
country,
|
||||
lat,
|
||||
lon,
|
||||
value,
|
||||
unit,
|
||||
observedAt,
|
||||
freshness,
|
||||
baselineValue,
|
||||
delta,
|
||||
zScore,
|
||||
severity,
|
||||
baselineSamples,
|
||||
convertedFromCpm,
|
||||
directUnit,
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
anchorId,
|
||||
source,
|
||||
locationName,
|
||||
country,
|
||||
location: {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
},
|
||||
value: round(value, 1),
|
||||
unit,
|
||||
observedAt,
|
||||
freshness,
|
||||
baselineValue: round(baselineValue, 1),
|
||||
delta: round(delta, 1),
|
||||
zScore: round(zScore, 2),
|
||||
severity,
|
||||
contributingSources: [source],
|
||||
confidence: 'RADIATION_CONFIDENCE_LOW',
|
||||
corroborated: false,
|
||||
conflictingSources: false,
|
||||
convertedFromCpm,
|
||||
sourceCount: 1,
|
||||
_baselineSamples: baselineSamples,
|
||||
_directUnit: directUnit,
|
||||
};
|
||||
}
|
||||
|
||||
function toEpaObservation(site, readings) {
|
||||
if (readings.length < 2) return null;
|
||||
|
||||
const latest = readings[readings.length - 1];
|
||||
const freshness = classifyFreshness(latest.observedAt);
|
||||
const baselineReadings = readings.slice(-1 - BASELINE_WINDOW_SIZE, -1);
|
||||
const baselineValues = baselineReadings.map((reading) => reading.value);
|
||||
const baselineValue = baselineValues.length > 0 ? average(baselineValues) : latest.value;
|
||||
const sigma = baselineValues.length >= BASELINE_MIN_SAMPLES ? stdDev(baselineValues, baselineValue) : 0;
|
||||
const delta = latest.value - baselineValue;
|
||||
const zScore = sigma > 0 ? delta / sigma : 0;
|
||||
const severity = classifySeverity(delta, zScore, freshness);
|
||||
|
||||
return buildBaseObservation({
|
||||
id: `epa:${site.state}:${site.slug}:${latest.observedAt}`,
|
||||
anchorId: site.anchorId,
|
||||
source: 'RADIATION_SOURCE_EPA_RADNET',
|
||||
locationName: site.name,
|
||||
country: site.country,
|
||||
lat: site.lat,
|
||||
lon: site.lon,
|
||||
value: latest.value,
|
||||
unit: 'nSv/h',
|
||||
observedAt: latest.observedAt,
|
||||
freshness,
|
||||
baselineValue,
|
||||
delta,
|
||||
zScore,
|
||||
severity,
|
||||
baselineSamples: baselineValues.length,
|
||||
convertedFromCpm: false,
|
||||
directUnit: true,
|
||||
});
|
||||
}
|
||||
|
||||
function toSafecastObservation(site, measurements) {
|
||||
if (measurements.length < 2) return null;
|
||||
|
||||
const latest = measurements[measurements.length - 1];
|
||||
const freshness = classifyFreshness(latest.observedAt);
|
||||
const baselineReadings = measurements.slice(-1 - SAFECAST_BASELINE_WINDOW_SIZE, -1);
|
||||
const baselineValues = baselineReadings.map((reading) => reading.value);
|
||||
const baselineValue = baselineValues.length > 0 ? average(baselineValues) : latest.value;
|
||||
const sigma = baselineValues.length >= SAFECAST_MIN_SAMPLES ? stdDev(baselineValues, baselineValue) : 0;
|
||||
const delta = latest.value - baselineValue;
|
||||
const zScore = sigma > 0 ? delta / sigma : 0;
|
||||
const severity = classifySeverity(delta, zScore, freshness);
|
||||
|
||||
return buildBaseObservation({
|
||||
id: `safecast:${site.anchorId}:${latest.id ?? latest.observedAt}`,
|
||||
anchorId: site.anchorId,
|
||||
source: 'RADIATION_SOURCE_SAFECAST',
|
||||
locationName: latest.locationName || site.name,
|
||||
country: site.country,
|
||||
lat: latest.lat,
|
||||
lon: latest.lon,
|
||||
value: latest.value,
|
||||
unit: latest.unit,
|
||||
observedAt: latest.observedAt,
|
||||
freshness,
|
||||
baselineValue,
|
||||
delta,
|
||||
zScore,
|
||||
severity,
|
||||
baselineSamples: baselineValues.length,
|
||||
convertedFromCpm: latest.convertedFromCpm,
|
||||
directUnit: latest.directUnit,
|
||||
});
|
||||
}
|
||||
|
||||
function baseConfidence(observation) {
|
||||
if (observation.freshness === 'RADIATION_FRESHNESS_HISTORICAL') return 'RADIATION_CONFIDENCE_LOW';
|
||||
if (observation.convertedFromCpm) return 'RADIATION_CONFIDENCE_LOW';
|
||||
if (observation._baselineSamples >= BASELINE_MIN_SAMPLES) return 'RADIATION_CONFIDENCE_MEDIUM';
|
||||
if (observation._directUnit && observation._baselineSamples >= SAFECAST_MIN_SAMPLES) return 'RADIATION_CONFIDENCE_MEDIUM';
|
||||
return 'RADIATION_CONFIDENCE_LOW';
|
||||
}
|
||||
|
||||
function observationPriority(observation) {
|
||||
return (
|
||||
severityRank(observation.severity) * 10000 +
|
||||
freshnessRank(observation.freshness) * 1000 +
|
||||
(observation._directUnit ? 200 : 0) +
|
||||
Math.min(observation._baselineSamples || 0, 199)
|
||||
);
|
||||
}
|
||||
|
||||
function supportsSameSignal(primary, secondary) {
|
||||
if (primary.severity === 'RADIATION_SEVERITY_NORMAL' && secondary.severity === 'RADIATION_SEVERITY_NORMAL') {
|
||||
return Math.abs(primary.value - secondary.value) <= 15;
|
||||
}
|
||||
if (primary.severity !== 'RADIATION_SEVERITY_NORMAL' && secondary.severity !== 'RADIATION_SEVERITY_NORMAL') {
|
||||
const sameDirection = Math.sign(primary.delta || 0.1) === Math.sign(secondary.delta || 0.1);
|
||||
return sameDirection && Math.abs(primary.delta - secondary.delta) <= 20;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function materiallyConflicts(primary, secondary) {
|
||||
if (primary.severity === 'RADIATION_SEVERITY_NORMAL' && secondary.severity === 'RADIATION_SEVERITY_NORMAL') {
|
||||
return false;
|
||||
}
|
||||
if (primary.severity === 'RADIATION_SEVERITY_NORMAL' || secondary.severity === 'RADIATION_SEVERITY_NORMAL') {
|
||||
return true;
|
||||
}
|
||||
const oppositeDirection = Math.sign(primary.delta || 0.1) !== Math.sign(secondary.delta || 0.1);
|
||||
return oppositeDirection || Math.abs(primary.delta - secondary.delta) > 30;
|
||||
}
|
||||
|
||||
function finalizeObservationGroup(group) {
|
||||
const sorted = [...group].sort((a, b) => {
|
||||
const priorityDelta = observationPriority(b) - observationPriority(a);
|
||||
if (priorityDelta !== 0) return priorityDelta;
|
||||
return b.observedAt - a.observedAt;
|
||||
});
|
||||
const primary = sorted[0];
|
||||
if (!primary) {
|
||||
throw new Error('Cannot finalize empty radiation observation group');
|
||||
}
|
||||
const distinctSources = [...new Set(sorted.map((observation) => observation.source))];
|
||||
const alternateSources = sorted.filter((observation) => observation.source !== primary.source);
|
||||
const corroborated = alternateSources.some((observation) => supportsSameSignal(primary, observation));
|
||||
const conflictingSources = alternateSources.some((observation) => materiallyConflicts(primary, observation));
|
||||
|
||||
let confidence = baseConfidence(primary);
|
||||
if (corroborated && distinctSources.length >= 2) confidence = 'RADIATION_CONFIDENCE_HIGH';
|
||||
if (conflictingSources) confidence = downgradeConfidence(confidence);
|
||||
|
||||
return {
|
||||
id: primary.id,
|
||||
source: primary.source,
|
||||
locationName: primary.locationName,
|
||||
country: primary.country,
|
||||
location: primary.location,
|
||||
value: primary.value,
|
||||
unit: primary.unit,
|
||||
observedAt: primary.observedAt,
|
||||
freshness: primary.freshness,
|
||||
baselineValue: primary.baselineValue,
|
||||
delta: primary.delta,
|
||||
zScore: primary.zScore,
|
||||
severity: primary.severity,
|
||||
contributingSources: distinctSources,
|
||||
confidence,
|
||||
corroborated,
|
||||
conflictingSources,
|
||||
convertedFromCpm: sorted.some((observation) => observation.convertedFromCpm),
|
||||
sourceCount: distinctSources.length,
|
||||
};
|
||||
}
|
||||
|
||||
function sortFinalObservations(a, b) {
|
||||
const severityDelta = severityRank(b.severity) - severityRank(a.severity);
|
||||
if (severityDelta !== 0) return severityDelta;
|
||||
const confidenceDelta = confidenceRank(b.confidence) - confidenceRank(a.confidence);
|
||||
if (confidenceDelta !== 0) return confidenceDelta;
|
||||
if (a.corroborated !== b.corroborated) return a.corroborated ? -1 : 1;
|
||||
const freshnessDelta = freshnessRank(b.freshness) - freshnessRank(a.freshness);
|
||||
if (freshnessDelta !== 0) return freshnessDelta;
|
||||
return b.observedAt - a.observedAt;
|
||||
}
|
||||
|
||||
function summarizeObservations(observations) {
|
||||
const sorted = [...observations].sort(sortFinalObservations);
|
||||
return {
|
||||
observations: sorted,
|
||||
fetchedAt: Date.now(),
|
||||
epaCount: sorted.filter((item) => item.contributingSources.includes('RADIATION_SOURCE_EPA_RADNET')).length,
|
||||
safecastCount: sorted.filter((item) => item.contributingSources.includes('RADIATION_SOURCE_SAFECAST')).length,
|
||||
anomalyCount: sorted.filter((item) => item.severity !== 'RADIATION_SEVERITY_NORMAL').length,
|
||||
elevatedCount: sorted.filter((item) => item.severity === 'RADIATION_SEVERITY_ELEVATED').length,
|
||||
spikeCount: sorted.filter((item) => item.severity === 'RADIATION_SEVERITY_SPIKE').length,
|
||||
corroboratedCount: sorted.filter((item) => item.corroborated).length,
|
||||
lowConfidenceCount: sorted.filter((item) => item.confidence === 'RADIATION_CONFIDENCE_LOW').length,
|
||||
conflictingCount: sorted.filter((item) => item.conflictingSources).length,
|
||||
convertedFromCpmCount: sorted.filter((item) => item.convertedFromCpm).length,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchEpaObservation(site, year) {
|
||||
const url = `https://radnet.epa.gov/cdx-radnet-rest/api/rest/csv/${year}/fixed/${site.state}/${site.slug}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(EPA_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) throw new Error(`EPA RadNet ${response.status} for ${site.name}`);
|
||||
const csv = await response.text();
|
||||
return toEpaObservation(site, parseApprovedReadings(csv));
|
||||
}
|
||||
|
||||
async function fetchSafecastObservation(site, capturedAfter) {
|
||||
const params = new URLSearchParams({
|
||||
distance: String(SAFECAST_DISTANCE_KM),
|
||||
latitude: String(site.lat),
|
||||
longitude: String(site.lon),
|
||||
captured_after: capturedAfter,
|
||||
});
|
||||
const response = await fetch(`https://api.safecast.org/measurements.json?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(SAFECAST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Safecast ${response.status} for ${site.name}`);
|
||||
|
||||
const measurements = await response.json();
|
||||
const normalized = (Array.isArray(measurements) ? measurements : [])
|
||||
.map((measurement) => {
|
||||
const numericValue = Number(measurement?.value);
|
||||
const normalizedUnit = normalizeUnit(numericValue, measurement?.unit);
|
||||
const observedAt = measurement?.captured_at ? Date.parse(measurement.captured_at) : NaN;
|
||||
const lat = Number(measurement?.latitude);
|
||||
const lon = Number(measurement?.longitude);
|
||||
|
||||
if (!normalizedUnit || !Number.isFinite(observedAt) || !Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: measurement?.id ?? null,
|
||||
locationName: typeof measurement?.location_name === 'string' ? measurement.location_name.trim() : '',
|
||||
observedAt,
|
||||
lat,
|
||||
lon,
|
||||
value: normalizedUnit.value,
|
||||
unit: normalizedUnit.unit,
|
||||
convertedFromCpm: normalizedUnit.convertedFromCpm,
|
||||
directUnit: normalizedUnit.directUnit,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.observedAt - b.observedAt);
|
||||
|
||||
return toSafecastObservation(site, normalized);
|
||||
}
|
||||
|
||||
async function fetchRadiationWatch() {
|
||||
const currentYear = new Date().getUTCFullYear();
|
||||
const capturedAfter = new Date(Date.now() - SAFECAST_LOOKBACK_DAYS * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const results = await Promise.allSettled([
|
||||
...EPA_SITES.map((site) => fetchEpaObservation(site, currentYear)),
|
||||
...SAFECAST_SITES.map((site) => fetchSafecastObservation(site, capturedAfter)),
|
||||
]);
|
||||
|
||||
const grouped = new Map();
|
||||
for (const result of results) {
|
||||
if (result.status !== 'fulfilled') {
|
||||
console.log(` [RADIATION] ${result.reason?.message ?? result.reason}`);
|
||||
continue;
|
||||
}
|
||||
if (!result.value) continue;
|
||||
|
||||
const group = grouped.get(result.value.anchorId) || [];
|
||||
group.push(result.value);
|
||||
grouped.set(result.value.anchorId, group);
|
||||
}
|
||||
|
||||
const observations = [...grouped.values()].map((group) => finalizeObservationGroup(group));
|
||||
return summarizeObservations(observations);
|
||||
}
|
||||
|
||||
function validate(data) {
|
||||
return Array.isArray(data?.observations) && data.observations.length > 0;
|
||||
}
|
||||
|
||||
runSeed('radiation', 'observations', CANONICAL_KEY, fetchRadiationWatch, {
|
||||
validateFn: validate,
|
||||
ttlSeconds: CACHE_TTL,
|
||||
sourceVersion: 'epa-radnet-safecast-merge-v1',
|
||||
recordCount: (data) => data?.observations?.length ?? 0,
|
||||
}).catch((err) => {
|
||||
console.error('FATAL:', err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = {
|
||||
minerals: 'supply_chain:minerals:v2',
|
||||
giving: 'giving:summary:v1',
|
||||
climateAnomalies: 'climate:anomalies:v1',
|
||||
radiationWatch: 'radiation:observations:v1',
|
||||
wildfires: 'wildfire:fires:v1',
|
||||
marketQuotes: 'market:stocks-bootstrap:v1',
|
||||
commodityQuotes: 'market:commodities-bootstrap:v1',
|
||||
@@ -54,7 +55,7 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
|
||||
minerals: 'slow', giving: 'slow', sectors: 'slow',
|
||||
progressData: 'slow', renewableEnergy: 'slow',
|
||||
etfFlows: 'slow', shippingRates: 'fast', wildfires: 'slow',
|
||||
climateAnomalies: 'slow', cyberThreats: 'slow', techReadiness: 'slow',
|
||||
climateAnomalies: 'slow', radiationWatch: 'slow', cyberThreats: 'slow', techReadiness: 'slow',
|
||||
theaterPosture: 'fast', naturalEvents: 'slow',
|
||||
cryptoQuotes: 'slow', gulfQuotes: 'slow', stablecoinMarkets: 'slow',
|
||||
unrestEvents: 'slow', ucdpEvents: 'slow', techEvents: 'slow',
|
||||
|
||||
@@ -89,6 +89,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
'/api/giving/v1/get-giving-summary': 'static',
|
||||
'/api/intelligence/v1/get-country-intel-brief': 'static',
|
||||
'/api/climate/v1/list-climate-anomalies': 'static',
|
||||
'/api/radiation/v1/list-radiation-observations': 'slow',
|
||||
'/api/research/v1/list-tech-events': 'static',
|
||||
'/api/military/v1/get-usni-fleet-report': 'static',
|
||||
'/api/conflict/v1/list-ucdp-events': 'static',
|
||||
|
||||
7
server/worldmonitor/radiation/v1/handler.ts
Normal file
7
server/worldmonitor/radiation/v1/handler.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { RadiationServiceHandler } from '../../../../src/generated/server/worldmonitor/radiation/v1/service_server';
|
||||
|
||||
import { listRadiationObservations } from './list-radiation-observations';
|
||||
|
||||
export const radiationHandler: RadiationServiceHandler = {
|
||||
listRadiationObservations,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
ListRadiationObservationsRequest,
|
||||
ListRadiationObservationsResponse,
|
||||
RadiationServiceHandler,
|
||||
ServerContext,
|
||||
} from '../../../../src/generated/server/worldmonitor/radiation/v1/service_server';
|
||||
|
||||
import { getCachedJson } from '../../../_shared/redis';
|
||||
|
||||
const REDIS_CACHE_KEY = 'radiation:observations:v1';
|
||||
const DEFAULT_MAX_ITEMS = 18;
|
||||
const MAX_ITEMS_LIMIT = 25;
|
||||
|
||||
// All fetch/parse/scoring logic lives in the Railway seed script
|
||||
// (scripts/seed-radiation-watch.mjs). This handler reads pre-built
|
||||
// data from Redis only (gold standard: Vercel reads, Railway writes).
|
||||
|
||||
|
||||
function clampMaxItems(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) return DEFAULT_MAX_ITEMS;
|
||||
return Math.min(Math.max(Math.trunc(value), 1), MAX_ITEMS_LIMIT);
|
||||
}
|
||||
|
||||
|
||||
function emptyResponse(): ListRadiationObservationsResponse {
|
||||
return {
|
||||
observations: [],
|
||||
fetchedAt: Date.now(),
|
||||
epaCount: 0,
|
||||
safecastCount: 0,
|
||||
anomalyCount: 0,
|
||||
elevatedCount: 0,
|
||||
spikeCount: 0,
|
||||
corroboratedCount: 0,
|
||||
lowConfidenceCount: 0,
|
||||
conflictingCount: 0,
|
||||
convertedFromCpmCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const listRadiationObservations: RadiationServiceHandler['listRadiationObservations'] = async (
|
||||
_ctx: ServerContext,
|
||||
req: ListRadiationObservationsRequest,
|
||||
): Promise<ListRadiationObservationsResponse> => {
|
||||
const maxItems = clampMaxItems(req.maxItems);
|
||||
try {
|
||||
const data = await getCachedJson(REDIS_CACHE_KEY, true) as ListRadiationObservationsResponse | null;
|
||||
if (!data?.observations?.length) return emptyResponse();
|
||||
return {
|
||||
...data,
|
||||
observations: (data.observations ?? []).slice(0, maxItems),
|
||||
};
|
||||
} catch {
|
||||
return emptyResponse();
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { InternetOutage, SocialUnrestEvent, MilitaryFlight, MilitaryFlightCluster, MilitaryVessel, MilitaryVesselCluster, USNIFleetReport, PanelConfig, MapLayers, NewsItem, MarketData, ClusteredEvent, CyberThreat, Monitor } from '@/types';
|
||||
import type { AirportDelayAlert, PositionSample } from '@/services/aviation';
|
||||
import type { IranEvent } from '@/generated/client/worldmonitor/conflict/v1/service_client';
|
||||
import type { RadiationWatchResult } from '@/services/radiation';
|
||||
import type { SecurityAdvisory } from '@/services/security-advisories';
|
||||
import type { Earthquake } from '@/services/earthquakes';
|
||||
|
||||
@@ -17,6 +18,7 @@ export interface IntelligenceCache {
|
||||
iranEvents?: IranEvent[];
|
||||
orefAlerts?: { alertCount: number; historyCount24h: number };
|
||||
advisories?: SecurityAdvisory[];
|
||||
radiation?: RadiationWatchResult;
|
||||
imageryScenes?: Array<{ id: string; satellite: string; datetime: string; resolutionM: number; mode: string; geometryGeojson: string; previewUrl: string; assetUrl: string }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -358,6 +358,7 @@ export class CountryIntelManager implements AppModule {
|
||||
if (signals.cyberThreats > 0) lines.push(`🛡️ Cyber threat indicators: ${signals.cyberThreats}`);
|
||||
if (signals.aisDisruptions > 0) lines.push(`🚢 Maritime AIS disruptions: ${signals.aisDisruptions}`);
|
||||
if (signals.satelliteFires > 0) lines.push(`🔥 Satellite fire detections: ${signals.satelliteFires}`);
|
||||
if (signals.radiationAnomalies > 0) lines.push(`☢️ Radiation anomalies: ${signals.radiationAnomalies}`);
|
||||
if (signals.temporalAnomalies > 0) lines.push(`⏱️ Temporal anomaly alerts: ${signals.temporalAnomalies}`);
|
||||
if (signals.earthquakes > 0) lines.push(t('countryBrief.fallback.recentEarthquakes', { count: String(signals.earthquakes) }));
|
||||
if (signals.orefHistory24h > 0) lines.push(`🚨 Sirens in past 24h: ${signals.orefHistory24h}`);
|
||||
@@ -426,7 +427,7 @@ export class CountryIntelManager implements AppModule {
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`Signals: critical_news=${signals.criticalNews}, protests=${signals.protests}, active_strikes=${signals.activeStrikes}, military_flights=${signals.militaryFlights}, military_vessels=${signals.militaryVessels}, outages=${signals.outages}, aviation_disruptions=${signals.aviationDisruptions}, travel_advisories=${signals.travelAdvisories}, oref_sirens=${signals.orefSirens}, oref_24h=${signals.orefHistory24h}, gps_jamming_hexes=${signals.gpsJammingHexes}, ais_disruptions=${signals.aisDisruptions}, satellite_fires=${signals.satelliteFires}, temporal_anomalies=${signals.temporalAnomalies}, cyber_threats=${signals.cyberThreats}, earthquakes=${signals.earthquakes}, conflict_events=${signals.conflictEvents}`,
|
||||
`Signals: critical_news=${signals.criticalNews}, protests=${signals.protests}, active_strikes=${signals.activeStrikes}, military_flights=${signals.militaryFlights}, military_vessels=${signals.militaryVessels}, outages=${signals.outages}, aviation_disruptions=${signals.aviationDisruptions}, travel_advisories=${signals.travelAdvisories}, oref_sirens=${signals.orefSirens}, oref_24h=${signals.orefHistory24h}, gps_jamming_hexes=${signals.gpsJammingHexes}, ais_disruptions=${signals.aisDisruptions}, satellite_fires=${signals.satelliteFires}, radiation_anomalies=${signals.radiationAnomalies}, temporal_anomalies=${signals.temporalAnomalies}, cyber_threats=${signals.cyberThreats}, earthquakes=${signals.earthquakes}, conflict_events=${signals.conflictEvents}`,
|
||||
);
|
||||
|
||||
if (signals.travelAdvisoryMaxLevel) {
|
||||
@@ -553,12 +554,14 @@ export class CountryIntelManager implements AppModule {
|
||||
const signalTypeCounts = {
|
||||
aisDisruptions: 0,
|
||||
satelliteFires: 0,
|
||||
radiationAnomalies: 0,
|
||||
temporalAnomalies: 0,
|
||||
};
|
||||
if (countryCluster) {
|
||||
for (const s of countryCluster.signals) {
|
||||
if (s.type === 'ais_disruption') signalTypeCounts.aisDisruptions++;
|
||||
else if (s.type === 'satellite_fire') signalTypeCounts.satelliteFires++;
|
||||
else if (s.type === 'radiation_anomaly') signalTypeCounts.radiationAnomalies++;
|
||||
else if (s.type === 'temporal_anomaly') signalTypeCounts.temporalAnomalies++;
|
||||
}
|
||||
}
|
||||
@@ -658,6 +661,7 @@ export class CountryIntelManager implements AppModule {
|
||||
outages,
|
||||
aisDisruptions: signalTypeCounts.aisDisruptions,
|
||||
satelliteFires: signalTypeCounts.satelliteFires,
|
||||
radiationAnomalies: signalTypeCounts.radiationAnomalies,
|
||||
temporalAnomalies: signalTypeCounts.temporalAnomalies > 0 ? signalTypeCounts.temporalAnomalies : globalTemporalAnomalies,
|
||||
cyberThreats,
|
||||
earthquakes,
|
||||
@@ -838,6 +842,7 @@ export class CountryIntelManager implements AppModule {
|
||||
if (type === 'protest') return 'PROTEST';
|
||||
if (type === 'internet_outage') return 'OUTAGE';
|
||||
if (type === 'satellite_fire') return 'DISASTER';
|
||||
if (type === 'radiation_anomaly') return 'DISASTER';
|
||||
if (type === 'ais_disruption') return 'OUTAGE';
|
||||
if (type === 'active_strike') return 'MILITARY';
|
||||
if (type === 'temporal_anomaly') return 'CYBER';
|
||||
@@ -849,6 +854,7 @@ export class CountryIntelManager implements AppModule {
|
||||
severity: 'low' | 'medium' | 'high',
|
||||
): CountryDeepDiveSignalDetails['recentHigh'][number]['severity'] {
|
||||
if (type === 'active_strike' && severity === 'high') return 'critical';
|
||||
if (type === 'radiation_anomaly' && severity === 'high') return 'critical';
|
||||
if (severity === 'high') return 'high';
|
||||
if (severity === 'medium') return 'medium';
|
||||
return 'low';
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
fetchShippingRates,
|
||||
fetchChokepointStatus,
|
||||
fetchCriticalMinerals,
|
||||
fetchRadiationWatch,
|
||||
} from '@/services';
|
||||
import { getMarketWatchlistEntries } from '@/services/market-watchlist';
|
||||
import { fetchStockAnalysesForTargets, getStockAnalysisTargets } from '@/services/stock-analysis';
|
||||
@@ -473,6 +474,9 @@ export class DataLoaderManager implements AppModule {
|
||||
if (SITE_VARIANT !== 'happy' && (this.ctx.mapLayers.techEvents || SITE_VARIANT === 'tech')) tasks.push({ name: 'techEvents', task: runGuarded('techEvents', () => this.loadTechEvents()) });
|
||||
if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.satellites && this.ctx.map?.isGlobeMode?.()) tasks.push({ name: 'satellites', task: runGuarded('satellites', () => this.loadSatellites()) });
|
||||
if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.webcams) tasks.push({ name: 'webcams', task: runGuarded('webcams', () => this.loadWebcams()) });
|
||||
if (SITE_VARIANT !== 'happy' && (this.ctx.panels['radiation-watch'] || this.ctx.mapLayers.radiationWatch)) {
|
||||
tasks.push({ name: 'radiation', task: runGuarded('radiation', () => this.loadRadiationWatch()) });
|
||||
}
|
||||
|
||||
if (SITE_VARIANT !== 'happy') {
|
||||
tasks.push({ name: 'techReadiness', task: runGuarded('techReadiness', () => (this.ctx.panels['tech-readiness'] as TechReadinessPanel)?.refresh()) });
|
||||
@@ -575,6 +579,9 @@ export class DataLoaderManager implements AppModule {
|
||||
case 'webcams':
|
||||
await this.loadWebcams();
|
||||
break;
|
||||
case 'radiationWatch':
|
||||
await this.loadRadiationWatch();
|
||||
break;
|
||||
case 'ucdpEvents':
|
||||
case 'displacement':
|
||||
case 'climate':
|
||||
@@ -2666,6 +2673,25 @@ export class DataLoaderManager implements AppModule {
|
||||
}
|
||||
}
|
||||
|
||||
async loadRadiationWatch(): Promise<void> {
|
||||
try {
|
||||
const result = await fetchRadiationWatch();
|
||||
const anomalies = result.observations.filter((observation) => observation.severity !== 'normal');
|
||||
this.callPanel('radiation-watch', 'setData', result);
|
||||
this.ctx.intelligenceCache.radiation = result;
|
||||
signalAggregator.ingestRadiationObservations(result.observations);
|
||||
this.ctx.map?.setRadiationObservations(anomalies);
|
||||
this.ctx.map?.setLayerReady('radiationWatch', anomalies.length > 0);
|
||||
if (result.observations.length > 0) {
|
||||
dataFreshness.recordUpdate('radiation', result.observations.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[App] Radiation watch fetch failed:', error);
|
||||
this.ctx.map?.setLayerReady('radiationWatch', false);
|
||||
dataFreshness.recordError('radiation', String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async loadTelegramIntel(): Promise<void> {
|
||||
if (isDesktopRuntime() && !getSecretState('WORLDMONITOR_API_KEY').present) return;
|
||||
try {
|
||||
|
||||
@@ -691,6 +691,14 @@ export class PanelLayoutManager implements AppModule {
|
||||
}),
|
||||
);
|
||||
|
||||
this.lazyPanel('radiation-watch', () =>
|
||||
import('@/components/RadiationWatchPanel').then(m => {
|
||||
const p = new m.RadiationWatchPanel();
|
||||
p.setLocationClickHandler((lat: number, lon: number) => { this.ctx.map?.setCenter(lat, lon, 4); });
|
||||
return p;
|
||||
}),
|
||||
);
|
||||
|
||||
const _wmKeyPresent = getSecretState('WORLDMONITOR_API_KEY').present;
|
||||
const _lockPanels = this.ctx.isDesktopApp && !_wmKeyPresent;
|
||||
|
||||
|
||||
@@ -241,6 +241,7 @@ export class CountryBriefPage implements CountryBriefPanel {
|
||||
if (signals.outages > 0) chips.push(`<span class="signal-chip outage">🌐 ${signals.outages} ${t('modals.countryBrief.signals.outages')}</span>`);
|
||||
if (signals.aisDisruptions > 0) chips.push(`<span class="signal-chip outage">🚢 ${signals.aisDisruptions} AIS Disruptions</span>`);
|
||||
if (signals.satelliteFires > 0) chips.push(`<span class="signal-chip climate">🔥 ${signals.satelliteFires} Satellite Fires</span>`);
|
||||
if (signals.radiationAnomalies > 0) chips.push(`<span class="signal-chip outage">☢️ ${signals.radiationAnomalies} Radiation Anomalies</span>`);
|
||||
if (signals.temporalAnomalies > 0) chips.push(`<span class="signal-chip outage">⏱️ ${signals.temporalAnomalies} Temporal Anomalies</span>`);
|
||||
if (signals.cyberThreats > 0) chips.push(`<span class="signal-chip conflict">🛡️ ${signals.cyberThreats} Cyber Threats</span>`);
|
||||
if (signals.earthquakes > 0) chips.push(`<span class="signal-chip quake">🌍 ${signals.earthquakes} ${t('modals.countryBrief.signals.earthquakes')}</span>`);
|
||||
@@ -693,6 +694,7 @@ export class CountryBriefPage implements CountryBriefPanel {
|
||||
outages: this.currentSignals.outages,
|
||||
aisDisruptions: this.currentSignals.aisDisruptions,
|
||||
satelliteFires: this.currentSignals.satelliteFires,
|
||||
radiationAnomalies: this.currentSignals.radiationAnomalies,
|
||||
temporalAnomalies: this.currentSignals.temporalAnomalies,
|
||||
cyberThreats: this.currentSignals.cyberThreats,
|
||||
earthquakes: this.currentSignals.earthquakes,
|
||||
|
||||
@@ -743,6 +743,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
|
||||
this.addSignalChip(chips, signals.outages, t('countryBrief.chips.outages'), '🌐', 'outage');
|
||||
this.addSignalChip(chips, signals.aisDisruptions, t('countryBrief.chips.aisDisruptions'), '🚢', 'outage');
|
||||
this.addSignalChip(chips, signals.satelliteFires, t('countryBrief.chips.satelliteFires'), '🔥', 'climate');
|
||||
this.addSignalChip(chips, signals.radiationAnomalies, 'Radiation anomalies', '☢️', 'outage');
|
||||
this.addSignalChip(chips, signals.temporalAnomalies, t('countryBrief.chips.temporalAnomalies'), '⏱️', 'outage');
|
||||
this.addSignalChip(chips, signals.cyberThreats, t('countryBrief.chips.cyberThreats'), '🛡️', 'conflict');
|
||||
this.addSignalChip(chips, signals.earthquakes, t('countryBrief.chips.earthquakes'), '🌍', 'quake');
|
||||
@@ -774,7 +775,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
|
||||
const seeded: CountryDeepDiveSignalDetails = {
|
||||
critical: signals.criticalNews + Math.max(0, signals.activeStrikes),
|
||||
high: signals.militaryFlights + signals.militaryVessels + signals.protests,
|
||||
medium: signals.outages + signals.cyberThreats + signals.aisDisruptions,
|
||||
medium: signals.outages + signals.cyberThreats + signals.aisDisruptions + signals.radiationAnomalies,
|
||||
low: signals.earthquakes + signals.temporalAnomalies + signals.satelliteFires,
|
||||
recentHigh: [],
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ import type { ImageryScene } from '@/generated/server/worldmonitor/imagery/v1/se
|
||||
import type { DisplacementFlow } from '@/services/displacement';
|
||||
import type { Earthquake } from '@/services/earthquakes';
|
||||
import type { ClimateAnomaly } from '@/services/climate';
|
||||
import type { RadiationObservation } from '@/services/radiation';
|
||||
import { ArcLayer } from '@deck.gl/layers';
|
||||
import { HeatmapLayer } from '@deck.gl/aggregation-layers';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
@@ -331,6 +332,7 @@ export class DeckGLMap {
|
||||
private displacementFlows: DisplacementFlow[] = [];
|
||||
private gpsJammingHexes: GpsJamHex[] = [];
|
||||
private climateAnomalies: ClimateAnomaly[] = [];
|
||||
private radiationObservations: RadiationObservation[] = [];
|
||||
private tradeRouteSegments: TradeRouteSegment[] = resolveTradeRouteSegments();
|
||||
private positiveEvents: PositiveGeoEvent[] = [];
|
||||
private kindnessPoints: KindnessPoint[] = [];
|
||||
@@ -1283,6 +1285,11 @@ export class DeckGLMap {
|
||||
layers.push(...this.createNaturalEventsLayers(filteredNaturalEvents));
|
||||
}
|
||||
|
||||
if (mapLayers.radiationWatch && this.radiationObservations.length > 0) {
|
||||
layers.push(this.createRadiationLayer());
|
||||
}
|
||||
layers.push(this.createEmptyGhost('radiation-watch-layer'));
|
||||
|
||||
// Satellite fires layer (NASA FIRMS)
|
||||
if (mapLayers.fires && this.firmsFireData.length > 0) {
|
||||
layers.push(this.createFiresLayer());
|
||||
@@ -2168,6 +2175,33 @@ export class DeckGLMap {
|
||||
});
|
||||
}
|
||||
|
||||
private createRadiationLayer(): ScatterplotLayer<RadiationObservation> {
|
||||
return new ScatterplotLayer<RadiationObservation>({
|
||||
id: 'radiation-watch-layer',
|
||||
data: this.radiationObservations,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
getRadius: (d) => {
|
||||
const base = d.severity === 'spike' ? 26000 : 18000;
|
||||
if (d.corroborated) return base * 1.15;
|
||||
if (d.confidence === 'low') return base * 0.85;
|
||||
return base;
|
||||
},
|
||||
getFillColor: (d) => (
|
||||
d.severity === 'spike'
|
||||
? [255, 48, 48, 220]
|
||||
: d.confidence === 'low'
|
||||
? [255, 174, 0, 150]
|
||||
: [255, 174, 0, 200]
|
||||
) as [number, number, number, number],
|
||||
getLineColor: [255, 255, 255, 200],
|
||||
stroked: true,
|
||||
lineWidthMinPixels: 2,
|
||||
radiusMinPixels: 6,
|
||||
radiusMaxPixels: 20,
|
||||
pickable: true,
|
||||
});
|
||||
}
|
||||
|
||||
private createAisDensityLayer(): ScatterplotLayer {
|
||||
return new ScatterplotLayer({
|
||||
id: 'ais-density-layer',
|
||||
@@ -3364,6 +3398,13 @@ export class DeckGLMap {
|
||||
return { html: `<div class="deckgl-tooltip"><strong>${text(obj.title)}</strong><br/>${text(obj.location)}</div>` };
|
||||
case 'irradiators-layer':
|
||||
return { html: `<div class="deckgl-tooltip"><strong>${text(obj.name)}</strong><br/>${text(obj.type || t('components.deckgl.layers.gammaIrradiators'))}</div>` };
|
||||
case 'radiation-watch-layer': {
|
||||
const severityLabel = obj.severity === 'spike' ? t('components.deckgl.layers.radiationSpike') : t('components.deckgl.layers.radiationElevated');
|
||||
const delta = Number(obj.delta || 0);
|
||||
const confidence = String(obj.confidence || 'low').toUpperCase();
|
||||
const corroboration = obj.corroborated ? 'CONFIRMED' : obj.conflictingSources ? 'CONFLICTING' : confidence;
|
||||
return { html: `<div class="deckgl-tooltip"><strong>${severityLabel}</strong><br/>${text(obj.location)}<br/>${Number(obj.value).toFixed(1)} ${text(obj.unit)} · ${delta >= 0 ? '+' : ''}${delta.toFixed(1)} vs baseline<br/>${text(corroboration)}</div>` };
|
||||
}
|
||||
case 'spaceports-layer':
|
||||
return { html: `<div class="deckgl-tooltip"><strong>${text(obj.name)}</strong><br/>${text(obj.country || t('components.deckgl.layers.spaceports'))}</div>` };
|
||||
case 'ports-layer': {
|
||||
@@ -3671,6 +3712,7 @@ export class DeckGLMap {
|
||||
'bases-layer': 'base',
|
||||
'nuclear-layer': 'nuclear',
|
||||
'irradiators-layer': 'irradiator',
|
||||
'radiation-watch-layer': 'radiation',
|
||||
'datacenters-layer': 'datacenter',
|
||||
'cables-layer': 'cable',
|
||||
'pipelines-layer': 'pipeline',
|
||||
@@ -4776,6 +4818,11 @@ export class DeckGLMap {
|
||||
this.render();
|
||||
}
|
||||
|
||||
public setRadiationObservations(observations: RadiationObservation[]): void {
|
||||
this.radiationObservations = observations;
|
||||
this.render();
|
||||
}
|
||||
|
||||
public setWebcams(markers: Array<WebcamEntry | WebcamCluster>): void {
|
||||
this.webcamData = markers;
|
||||
this.render();
|
||||
|
||||
@@ -48,6 +48,7 @@ import { isAllowedPreviewUrl } from '@/utils/imagery-preview';
|
||||
import { getCategoryStyle } from '@/services/webcams';
|
||||
import { pinWebcam, isPinned } from '@/services/webcams/pinned-store';
|
||||
import type { WebcamEntry, WebcamCluster } from '@/generated/client/worldmonitor/webcam/v1/service_client';
|
||||
import type { RadiationObservation } from '@/services/radiation';
|
||||
|
||||
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}' };
|
||||
@@ -231,6 +232,27 @@ interface EarthquakeMarker extends BaseMarker {
|
||||
place: string;
|
||||
magnitude: number;
|
||||
}
|
||||
interface RadiationMarker extends BaseMarker {
|
||||
_kind: 'radiation';
|
||||
id: string;
|
||||
location: string;
|
||||
country: string;
|
||||
source: RadiationObservation['source'];
|
||||
contributingSources: RadiationObservation['contributingSources'];
|
||||
value: number;
|
||||
unit: string;
|
||||
observedAt: Date;
|
||||
freshness: RadiationObservation['freshness'];
|
||||
baselineValue: number;
|
||||
delta: number;
|
||||
zScore: number;
|
||||
severity: 'normal' | 'elevated' | 'spike';
|
||||
confidence: RadiationObservation['confidence'];
|
||||
corroborated: boolean;
|
||||
conflictingSources: boolean;
|
||||
convertedFromCpm: boolean;
|
||||
sourceCount: number;
|
||||
}
|
||||
interface EconomicMarker extends BaseMarker {
|
||||
_kind: 'economic';
|
||||
id: string;
|
||||
@@ -380,7 +402,7 @@ type GlobeMarker =
|
||||
| CyberMarker | FireMarker | ProtestMarker
|
||||
| UcdpMarker | DisplacementMarker | ClimateMarker | GpsJamMarker | TechMarker
|
||||
| ConflictZoneMarker | MilBaseMarker | NuclearSiteMarker | IrradiatorSiteMarker | SpaceportSiteMarker
|
||||
| EarthquakeMarker | EconomicMarker | DatacenterMarker | WaterwayMarker | MineralMarker
|
||||
| EarthquakeMarker | RadiationMarker | EconomicMarker | DatacenterMarker | WaterwayMarker | MineralMarker
|
||||
| FlightDelayMarker | NotamRingMarker | CableAdvisoryMarker | RepairShipMarker | AisDisruptionMarker
|
||||
| NewsLocationMarker | FlashMarker | SatelliteMarker | SatFootprintMarker | ImagerySceneMarker
|
||||
| WebcamMarkerData | WebcamClusterData;
|
||||
@@ -455,6 +477,7 @@ export class GlobeMap {
|
||||
private irradiatorSiteMarkers: IrradiatorSiteMarker[] = [];
|
||||
private spaceportSiteMarkers: SpaceportSiteMarker[] = [];
|
||||
private earthquakeMarkers: EarthquakeMarker[] = [];
|
||||
private radiationMarkers: RadiationMarker[] = [];
|
||||
private economicMarkers: EconomicMarker[] = [];
|
||||
private datacenterMarkers: DatacenterMarker[] = [];
|
||||
private waterwayMarkers: WaterwayMarker[] = [];
|
||||
@@ -971,6 +994,18 @@ export class GlobeMap {
|
||||
const c = severityColors[d.severity] ?? '#88aaff';
|
||||
el.innerHTML = GlobeMap.wrapHit(`<div style="font-size:9px;color:${c};text-shadow:0 0 4px ${c}88;font-weight:bold;">⚡</div>`);
|
||||
el.title = d.headline;
|
||||
} else if (d._kind === 'radiation') {
|
||||
const c = d.severity === 'spike' ? '#ff3030' : '#ffaa00';
|
||||
const ring = d.severity === 'spike'
|
||||
? `<div style="position:absolute;inset:-5px;border-radius:50%;border:2px solid ${c}66;${this.pulseStyle('1.8s')}"></div>`
|
||||
: '';
|
||||
const confirmRing = d.corroborated
|
||||
? '<div style="position:absolute;inset:-9px;border-radius:50%;border:1px dashed #7dd3fc88;"></div>'
|
||||
: '';
|
||||
el.innerHTML = GlobeMap.wrapHit(
|
||||
`<div style="position:relative;display:inline-flex;align-items:center;justify-content:center;">${ring}${confirmRing}<div style="font-size:11px;color:${c};text-shadow:0 0 5px ${c}88;opacity:${d.confidence === 'low' ? 0.75 : 1};">☢</div></div>`
|
||||
);
|
||||
el.title = `${d.location} · ${d.severity} · ${d.confidence}`;
|
||||
} else if (d._kind === 'natural') {
|
||||
const typeIcons: Record<string, string> = {
|
||||
earthquakes: '〽', volcanoes: '🌋', severeStorms: '🌀',
|
||||
@@ -1200,6 +1235,41 @@ export class GlobeMap {
|
||||
// Fly to cluster and zoom in (reduce altitude by 60%)
|
||||
this.globe.pointOfView({ lat: d._lat, lng: d._lng, altitude: pov.altitude * 0.4 }, 800);
|
||||
}
|
||||
if (d._kind === 'radiation' && this.popup) {
|
||||
const aRect = anchor.getBoundingClientRect();
|
||||
const cRect = this.container.getBoundingClientRect();
|
||||
const x = aRect.left - cRect.left + aRect.width / 2;
|
||||
const y = aRect.top - cRect.top;
|
||||
this.hideTooltip();
|
||||
this.popup.show({
|
||||
type: 'radiation',
|
||||
data: {
|
||||
id: d.id,
|
||||
source: d.source,
|
||||
contributingSources: d.contributingSources,
|
||||
location: d.location,
|
||||
country: d.country,
|
||||
lat: d._lat,
|
||||
lon: d._lng,
|
||||
value: d.value,
|
||||
unit: d.unit,
|
||||
observedAt: d.observedAt,
|
||||
freshness: d.freshness,
|
||||
baselineValue: d.baselineValue,
|
||||
delta: d.delta,
|
||||
zScore: d.zScore,
|
||||
severity: d.severity,
|
||||
confidence: d.confidence,
|
||||
corroborated: d.corroborated,
|
||||
conflictingSources: d.conflictingSources,
|
||||
convertedFromCpm: d.convertedFromCpm,
|
||||
sourceCount: d.sourceCount,
|
||||
},
|
||||
x,
|
||||
y,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.showMarkerTooltip(d, anchor);
|
||||
}
|
||||
|
||||
@@ -1278,6 +1348,12 @@ export class GlobeMap {
|
||||
const wc = d.severity === 'Extreme' ? '#ff0044' : d.severity === 'Severe' ? '#ff6600' : '#88aaff';
|
||||
html = `<span style="color:${wc};font-weight:bold;">⚡ ${esc(d.severity)}</span>` +
|
||||
`<br><span style="opacity:.7;white-space:normal;display:block;">${esc(d.headline.slice(0, 90))}</span>`;
|
||||
} else if (d._kind === 'radiation') {
|
||||
const rc = d.severity === 'spike' ? '#ff3030' : '#ffaa00';
|
||||
html = `<span style="color:${rc};font-weight:bold;">☢ ${esc(d.severity.toUpperCase())}</span>` +
|
||||
`<br><span style="opacity:.7;">${esc(d.location)}, ${esc(d.country)}</span>` +
|
||||
`<br><span style="opacity:.5;">${d.value.toFixed(1)} ${esc(d.unit)} · ${d.delta >= 0 ? '+' : ''}${d.delta.toFixed(1)} vs baseline</span>` +
|
||||
`<br><span style="opacity:.55;font-size:10px;">${esc(d.confidence.toUpperCase())}${d.corroborated ? ' · CONFIRMED' : ''}${d.conflictingSources ? ' · CONFLICT' : ''}</span>`;
|
||||
} else if (d._kind === 'natural') {
|
||||
html = `<span style="font-weight:bold;">${esc(d.title.slice(0, 60))}</span>` +
|
||||
`<br><span style="opacity:.7;">${esc(d.category)}</span>`;
|
||||
@@ -1822,6 +1898,7 @@ export class GlobeMap {
|
||||
markers.push(...this.naturalMarkers);
|
||||
markers.push(...this.earthquakeMarkers);
|
||||
}
|
||||
if (this.layers.radiationWatch) markers.push(...this.radiationMarkers);
|
||||
if (this.layers.economic) markers.push(...this.economicMarkers);
|
||||
if (this.layers.datacenters) markers.push(...this.datacenterMarkers);
|
||||
if (this.layers.waterways) markers.push(...this.waterwayMarkers);
|
||||
@@ -2565,6 +2642,34 @@ export class GlobeMap {
|
||||
}));
|
||||
this.flushMarkers();
|
||||
}
|
||||
|
||||
public setRadiationObservations(observations: RadiationObservation[]): void {
|
||||
this.radiationMarkers = (observations ?? []).map((observation) => ({
|
||||
_kind: 'radiation' as const,
|
||||
_lat: observation.lat,
|
||||
_lng: observation.lon,
|
||||
id: observation.id,
|
||||
location: observation.location,
|
||||
country: observation.country,
|
||||
source: observation.source,
|
||||
contributingSources: observation.contributingSources,
|
||||
value: observation.value,
|
||||
unit: observation.unit,
|
||||
observedAt: observation.observedAt,
|
||||
freshness: observation.freshness,
|
||||
baselineValue: observation.baselineValue,
|
||||
delta: observation.delta,
|
||||
zScore: observation.zScore,
|
||||
severity: observation.severity,
|
||||
confidence: observation.confidence,
|
||||
corroborated: observation.corroborated,
|
||||
conflictingSources: observation.conflictingSources,
|
||||
convertedFromCpm: observation.convertedFromCpm,
|
||||
sourceCount: observation.sourceCount,
|
||||
}));
|
||||
this.flushMarkers();
|
||||
}
|
||||
|
||||
public setImageryScenes(scenes: ImageryScene[]): void {
|
||||
const valid = (scenes ?? []).filter(s => {
|
||||
try {
|
||||
|
||||
@@ -419,6 +419,7 @@ export class IntelligenceFindingsBadge {
|
||||
}
|
||||
if (alert.type === 'convergence') return t('components.intelligenceFindings.insights.convergence');
|
||||
if (alert.type === 'cascade') return t('components.intelligenceFindings.insights.cascade');
|
||||
if (alert.type === 'radiation') return 'Elevated radiation readings warrant validation against recent baseline and nearby industrial or environmental activity';
|
||||
return t('components.intelligenceFindings.insights.review');
|
||||
}
|
||||
|
||||
@@ -442,6 +443,7 @@ export class IntelligenceFindingsBadge {
|
||||
// Unified alerts
|
||||
cii_spike: '🔴',
|
||||
cascade: '⚡',
|
||||
radiation: '☢️',
|
||||
composite: '🔗',
|
||||
};
|
||||
return icons[type] || '📌';
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { TechHubActivity } from '@/services/tech-activity';
|
||||
import type { GeoHubActivity } from '@/services/geo-activity';
|
||||
import { getNaturalEventIcon } from '@/services/eonet';
|
||||
import type { WeatherAlert } from '@/services/weather';
|
||||
import type { RadiationObservation } from '@/services/radiation';
|
||||
import { getSeverityColor } from '@/services/weather';
|
||||
import { startSmartPollLoop, type SmartPollLoopHandle } from '@/services/runtime';
|
||||
import {
|
||||
@@ -124,6 +125,7 @@ export class MapComponent {
|
||||
private hotspots: HotspotWithBreaking[];
|
||||
private earthquakes: Earthquake[] = [];
|
||||
private weatherAlerts: WeatherAlert[] = [];
|
||||
private radiationObservations: RadiationObservation[] = [];
|
||||
private outages: InternetOutage[] = [];
|
||||
private aisDisruptions: AisDisruptionEvent[] = [];
|
||||
private aisDensity: AisDensityZone[] = [];
|
||||
@@ -1697,6 +1699,39 @@ export class MapComponent {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.layers.radiationWatch) {
|
||||
this.radiationObservations.forEach((observation) => {
|
||||
const pos = projection([observation.lon, observation.lat]);
|
||||
if (!pos) return;
|
||||
|
||||
const div = document.createElement('div');
|
||||
const color = observation.severity === 'spike' ? '#ff3030' : '#ffaa00';
|
||||
div.className = `radiation-watch-marker radiation-watch-marker-${observation.severity}`;
|
||||
div.style.left = `${pos[0]}px`;
|
||||
div.style.top = `${pos[1]}px`;
|
||||
div.style.width = '14px';
|
||||
div.style.height = '14px';
|
||||
div.style.borderRadius = '50%';
|
||||
div.style.background = color;
|
||||
div.style.border = '2px solid rgba(255,255,255,0.75)';
|
||||
div.style.boxShadow = `0 0 10px ${color}88`;
|
||||
div.title = `${observation.location}: ${observation.value.toFixed(1)} ${observation.unit}`;
|
||||
|
||||
div.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
this.popup.show({
|
||||
type: 'radiation',
|
||||
data: observation,
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
});
|
||||
|
||||
this.overlays.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
// Internet Outages (severity colors)
|
||||
if (this.state.layers.outages) {
|
||||
this.outages.forEach((outage) => {
|
||||
@@ -3847,6 +3882,11 @@ export class MapComponent {
|
||||
this.render();
|
||||
}
|
||||
|
||||
public setRadiationObservations(observations: RadiationObservation[]): void {
|
||||
this.radiationObservations = observations;
|
||||
this.render();
|
||||
}
|
||||
|
||||
public setOutages(outages: InternetOutage[]): void {
|
||||
this.outages = outages;
|
||||
this.render();
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { KindnessPoint } from '@/services/kindness-data';
|
||||
import type { HappinessData } from '@/services/happiness-data';
|
||||
import type { SpeciesRecovery } from '@/services/conservation-data';
|
||||
import type { RenewableInstallation } from '@/services/renewable-installations';
|
||||
import type { RadiationObservation } from '@/services/radiation';
|
||||
import type { GpsJamHex } from '@/services/gps-interference';
|
||||
import type { SatellitePosition } from '@/services/satellites';
|
||||
import type { IranEvent } from '@/services/conflict';
|
||||
@@ -119,6 +120,7 @@ export class MapContainer {
|
||||
private cachedUcdpEvents: UcdpGeoEvent[] | null = null;
|
||||
private cachedDisplacementFlows: DisplacementFlow[] | null = null;
|
||||
private cachedClimateAnomalies: ClimateAnomaly[] | null = null;
|
||||
private cachedRadiationObservations: RadiationObservation[] | null = null;
|
||||
private cachedGpsJamming: GpsJamHex[] | null = null;
|
||||
private cachedSatellites: SatellitePosition[] | null = null;
|
||||
private cachedCyberThreats: CyberThreat[] | null = null;
|
||||
@@ -283,6 +285,7 @@ export class MapContainer {
|
||||
if (this.cachedUcdpEvents) this.setUcdpEvents(this.cachedUcdpEvents);
|
||||
if (this.cachedDisplacementFlows) this.setDisplacementFlows(this.cachedDisplacementFlows);
|
||||
if (this.cachedClimateAnomalies) this.setClimateAnomalies(this.cachedClimateAnomalies);
|
||||
if (this.cachedRadiationObservations) this.setRadiationObservations(this.cachedRadiationObservations);
|
||||
if (this.cachedGpsJamming) this.setGpsJamming(this.cachedGpsJamming);
|
||||
if (this.cachedSatellites) this.setSatellites(this.cachedSatellites);
|
||||
if (this.cachedCyberThreats) this.setCyberThreats(this.cachedCyberThreats);
|
||||
@@ -551,6 +554,16 @@ export class MapContainer {
|
||||
}
|
||||
}
|
||||
|
||||
public setRadiationObservations(observations: RadiationObservation[]): void {
|
||||
this.cachedRadiationObservations = observations;
|
||||
if (this.useGlobe) { this.globeMap?.setRadiationObservations(observations); return; }
|
||||
if (this.useDeckGL) {
|
||||
this.deckGLMap?.setRadiationObservations(observations);
|
||||
} else {
|
||||
this.svgMap?.setRadiationObservations(observations);
|
||||
}
|
||||
}
|
||||
|
||||
public setGpsJamming(hexes: GpsJamHex[]): void {
|
||||
this.cachedGpsJamming = hexes;
|
||||
if (this.useGlobe) { this.globeMap?.setGpsJamming(hexes); return; }
|
||||
@@ -950,6 +963,7 @@ export class MapContainer {
|
||||
this.cachedUcdpEvents = null;
|
||||
this.cachedDisplacementFlows = null;
|
||||
this.cachedClimateAnomalies = null;
|
||||
this.cachedRadiationObservations = null;
|
||||
this.cachedGpsJamming = null;
|
||||
this.cachedSatellites = null;
|
||||
this.cachedCyberThreats = null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ConflictZone, Hotspot, NewsItem, MilitaryBase, StrategicWaterway,
|
||||
import type { AirportDelayAlert, PositionSample } from '@/services/aviation';
|
||||
import type { Earthquake } from '@/services/earthquakes';
|
||||
import type { WeatherAlert } from '@/services/weather';
|
||||
import type { RadiationObservation } from '@/services/radiation';
|
||||
import { UNDERSEA_CABLES } from '@/config';
|
||||
import type { StartupHub, Accelerator, TechHQ, CloudRegion } from '@/config/tech-geo';
|
||||
import type { TechHubActivity } from '@/services/tech-activity';
|
||||
@@ -15,7 +16,7 @@ import { getHotspotEscalation, getEscalationChange24h } from '@/services/hotspot
|
||||
import { getCableHealthRecord } from '@/services/cable-health';
|
||||
import { nameToCountryCode } from '@/services/country-geometry';
|
||||
|
||||
export type PopupType = 'conflict' | 'hotspot' | 'earthquake' | 'weather' | 'base' | 'waterway' | 'apt' | 'cyberThreat' | 'nuclear' | 'economic' | 'irradiator' | 'pipeline' | 'cable' | 'cable-advisory' | 'repair-ship' | 'outage' | 'datacenter' | 'datacenterCluster' | 'ais' | 'protest' | 'protestCluster' | 'flight' | 'aircraft' | 'militaryFlight' | 'militaryVessel' | 'militaryFlightCluster' | 'militaryVesselCluster' | 'natEvent' | 'port' | 'spaceport' | 'mineral' | 'startupHub' | 'cloudRegion' | 'techHQ' | 'accelerator' | 'techEvent' | 'techHQCluster' | 'techEventCluster' | 'techActivity' | 'geoActivity' | 'stockExchange' | 'financialCenter' | 'centralBank' | 'commodityHub' | 'iranEvent' | 'gpsJamming';
|
||||
export type PopupType = 'conflict' | 'hotspot' | 'earthquake' | 'weather' | 'base' | 'waterway' | 'apt' | 'cyberThreat' | 'nuclear' | 'economic' | 'irradiator' | 'pipeline' | 'cable' | 'cable-advisory' | 'repair-ship' | 'outage' | 'datacenter' | 'datacenterCluster' | 'ais' | 'protest' | 'protestCluster' | 'flight' | 'aircraft' | 'militaryFlight' | 'militaryVessel' | 'militaryFlightCluster' | 'militaryVesselCluster' | 'natEvent' | 'port' | 'spaceport' | 'mineral' | 'startupHub' | 'cloudRegion' | 'techHQ' | 'accelerator' | 'techEvent' | 'techHQCluster' | 'techEventCluster' | 'techActivity' | 'geoActivity' | 'stockExchange' | 'financialCenter' | 'centralBank' | 'commodityHub' | 'iranEvent' | 'gpsJamming' | 'radiation';
|
||||
|
||||
interface TechEventPopupData {
|
||||
id: string;
|
||||
@@ -144,7 +145,7 @@ interface DatacenterClusterData {
|
||||
|
||||
interface PopupData {
|
||||
type: PopupType;
|
||||
data: ConflictZone | Hotspot | Earthquake | WeatherAlert | MilitaryBase | StrategicWaterway | APTGroup | CyberThreat | NuclearFacility | EconomicCenter | GammaIrradiator | Pipeline | UnderseaCable | CableAdvisory | RepairShip | InternetOutage | AIDataCenter | AisDisruptionEvent | SocialUnrestEvent | AirportDelayAlert | PositionSample | MilitaryFlight | MilitaryVessel | MilitaryFlightCluster | MilitaryVesselCluster | NaturalEvent | Port | Spaceport | CriticalMineralProject | StartupHub | CloudRegion | TechHQ | Accelerator | TechEventPopupData | TechHQClusterData | TechEventClusterData | ProtestClusterData | DatacenterClusterData | TechHubActivity | GeoHubActivity | StockExchangePopupData | FinancialCenterPopupData | CentralBankPopupData | CommodityHubPopupData | IranEventPopupData | GpsJammingPopupData;
|
||||
data: ConflictZone | Hotspot | Earthquake | WeatherAlert | MilitaryBase | StrategicWaterway | APTGroup | CyberThreat | NuclearFacility | EconomicCenter | GammaIrradiator | Pipeline | UnderseaCable | CableAdvisory | RepairShip | InternetOutage | AIDataCenter | AisDisruptionEvent | SocialUnrestEvent | AirportDelayAlert | PositionSample | MilitaryFlight | MilitaryVessel | MilitaryFlightCluster | MilitaryVesselCluster | NaturalEvent | Port | Spaceport | CriticalMineralProject | StartupHub | CloudRegion | TechHQ | Accelerator | TechEventPopupData | TechHQClusterData | TechEventClusterData | ProtestClusterData | DatacenterClusterData | TechHubActivity | GeoHubActivity | StockExchangePopupData | FinancialCenterPopupData | CentralBankPopupData | CommodityHubPopupData | IranEventPopupData | GpsJammingPopupData | RadiationObservation;
|
||||
relatedNews?: NewsItem[];
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -473,11 +474,61 @@ export class MapPopup {
|
||||
return this.renderIranEventPopup(data.data as IranEventPopupData);
|
||||
case 'gpsJamming':
|
||||
return this.renderGpsJammingPopup(data.data as GpsJammingPopupData);
|
||||
case 'radiation':
|
||||
return this.renderRadiationPopup(data.data as RadiationObservation);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private renderRadiationPopup(observation: RadiationObservation): string {
|
||||
const severityClass = observation.severity === 'spike' ? 'high' : 'medium';
|
||||
const delta = `${observation.delta >= 0 ? '+' : ''}${observation.delta.toFixed(1)} ${escapeHtml(observation.unit)}`;
|
||||
const provenance = formatRadiationSources(observation);
|
||||
const confidence = formatRadiationConfidence(observation.confidence);
|
||||
const flags = [
|
||||
observation.corroborated ? 'Confirmed' : '',
|
||||
observation.conflictingSources ? 'Conflicting sources' : '',
|
||||
observation.convertedFromCpm ? 'CPM-derived component' : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<div class="popup-header outage">
|
||||
<span class="popup-title">☢ ${escapeHtml(observation.location.toUpperCase())}</span>
|
||||
<span class="popup-badge ${severityClass}">${escapeHtml(observation.severity.toUpperCase())}</span>
|
||||
<button class="popup-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="popup-body">
|
||||
<div class="popup-stats">
|
||||
<div class="popup-stat">
|
||||
<span class="stat-label">Reading</span>
|
||||
<span class="stat-value">${observation.value.toFixed(1)} ${escapeHtml(observation.unit)}</span>
|
||||
</div>
|
||||
<div class="popup-stat">
|
||||
<span class="stat-label">Baseline</span>
|
||||
<span class="stat-value">${observation.baselineValue.toFixed(1)} ${escapeHtml(observation.unit)}</span>
|
||||
</div>
|
||||
<div class="popup-stat">
|
||||
<span class="stat-label">Delta</span>
|
||||
<span class="stat-value">${delta}</span>
|
||||
</div>
|
||||
<div class="popup-stat">
|
||||
<span class="stat-label">Confidence</span>
|
||||
<span class="stat-value">${escapeHtml(confidence)}</span>
|
||||
</div>
|
||||
<div class="popup-stat">
|
||||
<span class="stat-label">Sources</span>
|
||||
<span class="stat-value">${escapeHtml(provenance)}</span>
|
||||
</div>
|
||||
<div class="popup-stat">
|
||||
<span class="stat-label">Source count</span>
|
||||
<span class="stat-value">${observation.sourceCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="popup-description">${escapeHtml(observation.country)} · z-score ${observation.zScore.toFixed(2)} · ${escapeHtml(observation.freshness)}${flags ? ` · ${escapeHtml(flags)}` : ''}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
private renderConflictPopup(conflict: ConflictZone): string {
|
||||
const severityClass = conflict.intensity === 'high' ? 'high' : conflict.intensity === 'medium' ? 'medium' : 'low';
|
||||
@@ -2926,3 +2977,19 @@ export class MapPopup {
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRadiationSources(observation: RadiationObservation): string {
|
||||
const uniqueSources = [...new Set(observation.contributingSources)];
|
||||
return uniqueSources.length > 0 ? uniqueSources.join(' + ') : observation.source;
|
||||
}
|
||||
|
||||
function formatRadiationConfidence(confidence: RadiationObservation['confidence']): string {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'High';
|
||||
case 'medium':
|
||||
return 'Medium';
|
||||
default:
|
||||
return 'Low';
|
||||
}
|
||||
}
|
||||
|
||||
176
src/components/RadiationWatchPanel.ts
Normal file
176
src/components/RadiationWatchPanel.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Panel } from './Panel';
|
||||
import type { RadiationObservation, RadiationWatchResult } from '@/services/radiation';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
|
||||
export class RadiationWatchPanel extends Panel {
|
||||
private observations: RadiationObservation[] = [];
|
||||
private fetchedAt: Date | null = null;
|
||||
private summary: RadiationWatchResult['summary'] = {
|
||||
anomalyCount: 0,
|
||||
elevatedCount: 0,
|
||||
spikeCount: 0,
|
||||
corroboratedCount: 0,
|
||||
lowConfidenceCount: 0,
|
||||
conflictingCount: 0,
|
||||
convertedFromCpmCount: 0,
|
||||
};
|
||||
private onLocationClick?: (lat: number, lon: number) => void;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'radiation-watch',
|
||||
title: 'Radiation Watch',
|
||||
showCount: true,
|
||||
trackActivity: true,
|
||||
infoTooltip: 'Seeded EPA RadNet and Safecast readings with anomaly scoring and source-confidence synthesis. This panel answers what is normal, what is elevated, and which anomalies are confirmed versus tentative.',
|
||||
});
|
||||
this.showLoading('Loading radiation data...');
|
||||
|
||||
this.content.addEventListener('click', (e) => {
|
||||
const row = (e.target as HTMLElement).closest<HTMLElement>('.radiation-row');
|
||||
if (!row) return;
|
||||
const lat = Number(row.dataset.lat);
|
||||
const lon = Number(row.dataset.lon);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) this.onLocationClick?.(lat, lon);
|
||||
});
|
||||
}
|
||||
|
||||
public setLocationClickHandler(handler: (lat: number, lon: number) => void): void {
|
||||
this.onLocationClick = handler;
|
||||
}
|
||||
|
||||
public setData(data: RadiationWatchResult): void {
|
||||
this.observations = data.observations;
|
||||
this.fetchedAt = data.fetchedAt;
|
||||
this.summary = data.summary;
|
||||
this.setCount(data.observations.length);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (this.observations.length === 0) {
|
||||
this.setContent('<div class="panel-empty">No radiation observations available.</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = this.observations.map((obs) => {
|
||||
const observed = formatObservedAt(obs.observedAt);
|
||||
const reading = formatReading(obs.value, obs.unit);
|
||||
const baseline = formatReading(obs.baselineValue, obs.unit);
|
||||
const delta = formatDelta(obs.delta, obs.unit, obs.zScore);
|
||||
const sourceLine = formatSourceLine(obs);
|
||||
const confidence = formatConfidence(obs.confidence);
|
||||
const flags = [
|
||||
`<span class="radiation-badge radiation-confidence radiation-confidence-${obs.confidence}">${escapeHtml(confidence)}</span>`,
|
||||
obs.corroborated ? '<span class="radiation-badge radiation-flag-confirmed">confirmed</span>' : '',
|
||||
obs.conflictingSources ? '<span class="radiation-badge radiation-flag-conflict">conflict</span>' : '',
|
||||
obs.convertedFromCpm ? '<span class="radiation-badge radiation-flag-converted">CPM-derived</span>' : '',
|
||||
`<span class="radiation-badge radiation-freshness radiation-freshness-${obs.freshness}">${escapeHtml(obs.freshness)}</span>`,
|
||||
].filter(Boolean).join('');
|
||||
return `
|
||||
<tr class="radiation-row" data-lat="${obs.lat}" data-lon="${obs.lon}">
|
||||
<td class="radiation-location">
|
||||
<div class="radiation-location-name">${escapeHtml(obs.location)}</div>
|
||||
<div class="radiation-location-meta">${escapeHtml(sourceLine)} · ${escapeHtml(baseline)} baseline</div>
|
||||
<div class="radiation-location-flags">${flags}</div>
|
||||
</td>
|
||||
<td class="radiation-reading">${escapeHtml(reading)}</td>
|
||||
<td class="radiation-delta">${escapeHtml(delta)}</td>
|
||||
<td><span class="radiation-severity radiation-severity-${obs.severity}">${escapeHtml(obs.severity)}</span></td>
|
||||
<td class="radiation-observed">${escapeHtml(observed)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const summary = `
|
||||
<div class="radiation-summary">
|
||||
<div class="radiation-summary-card">
|
||||
<span class="radiation-summary-label">Anomalies</span>
|
||||
<span class="radiation-summary-value">${this.summary.anomalyCount}</span>
|
||||
</div>
|
||||
<div class="radiation-summary-card">
|
||||
<span class="radiation-summary-label">Elevated</span>
|
||||
<span class="radiation-summary-value">${this.summary.elevatedCount}</span>
|
||||
</div>
|
||||
<div class="radiation-summary-card radiation-summary-card-confirmed">
|
||||
<span class="radiation-summary-label">Confirmed</span>
|
||||
<span class="radiation-summary-value">${this.summary.corroboratedCount}</span>
|
||||
</div>
|
||||
<div class="radiation-summary-card radiation-summary-card-low-confidence">
|
||||
<span class="radiation-summary-label">Low Confidence</span>
|
||||
<span class="radiation-summary-value">${this.summary.lowConfidenceCount}</span>
|
||||
</div>
|
||||
<div class="radiation-summary-card radiation-summary-card-conflict">
|
||||
<span class="radiation-summary-label">Conflicts</span>
|
||||
<span class="radiation-summary-value">${this.summary.conflictingCount}</span>
|
||||
</div>
|
||||
<div class="radiation-summary-card radiation-summary-card-spike">
|
||||
<span class="radiation-summary-label">Spikes</span>
|
||||
<span class="radiation-summary-value">${this.summary.spikeCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const footer = this.fetchedAt
|
||||
? `Updated ${this.fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
||||
: '';
|
||||
|
||||
this.setContent(`
|
||||
<div class="radiation-panel-content">
|
||||
${summary}
|
||||
<table class="radiation-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Station</th>
|
||||
<th>Reading</th>
|
||||
<th>Delta</th>
|
||||
<th>Status</th>
|
||||
<th>Observed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
<div class="radiation-footer">${escapeHtml(footer)}</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
function formatReading(value: number, unit: string): string {
|
||||
const precision = unit === 'nSv/h' ? 1 : 0;
|
||||
return `${value.toFixed(precision)} ${unit}`;
|
||||
}
|
||||
|
||||
function formatDelta(value: number, unit: string, zScore: number): string {
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return `${sign}${value.toFixed(1)} ${unit} · z${zScore.toFixed(1)}`;
|
||||
}
|
||||
|
||||
function formatObservedAt(date: Date): string {
|
||||
const ageMs = Date.now() - date.getTime();
|
||||
if (ageMs < 24 * 60 * 60 * 1000) {
|
||||
const hours = Math.max(1, Math.floor(ageMs / (60 * 60 * 1000)));
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
const days = Math.floor(ageMs / (24 * 60 * 60 * 1000));
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatSourceLine(observation: RadiationObservation): string {
|
||||
const uniqueSources = [...new Set(observation.contributingSources)];
|
||||
if (uniqueSources.length <= 1) return observation.source;
|
||||
return uniqueSources.join(' + ');
|
||||
}
|
||||
|
||||
function formatConfidence(value: RadiationObservation['confidence']): string {
|
||||
switch (value) {
|
||||
case 'high':
|
||||
return 'high confidence';
|
||||
case 'medium':
|
||||
return 'medium confidence';
|
||||
default:
|
||||
return 'low confidence';
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ export class SignalModal {
|
||||
cii_spike: '📊',
|
||||
convergence: '🌍',
|
||||
cascade: '⚡',
|
||||
radiation: '☢️',
|
||||
composite: '🔗',
|
||||
};
|
||||
|
||||
@@ -205,6 +206,40 @@ export class SignalModal {
|
||||
`;
|
||||
}
|
||||
|
||||
if (alert.components.radiation) {
|
||||
const radiation = alert.components.radiation;
|
||||
detailsHtml += `
|
||||
<div class="signal-context-item">
|
||||
<span class="context-label">Station</span>
|
||||
<span class="context-value">${escapeHtml(radiation.siteName)}</span>
|
||||
</div>
|
||||
<div class="signal-context-item">
|
||||
<span class="context-label">Reading</span>
|
||||
<span class="context-value">${radiation.value.toFixed(1)} ${escapeHtml(radiation.unit)}</span>
|
||||
</div>
|
||||
<div class="signal-context-item">
|
||||
<span class="context-label">Baseline</span>
|
||||
<span class="context-value">${radiation.baselineValue.toFixed(1)} ${escapeHtml(radiation.unit)}</span>
|
||||
</div>
|
||||
<div class="signal-context-item">
|
||||
<span class="context-label">Delta / z-score</span>
|
||||
<span class="context-value">+${radiation.delta.toFixed(1)} / ${radiation.zScore.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="signal-context-item">
|
||||
<span class="context-label">Confidence</span>
|
||||
<span class="context-value">${escapeHtml(radiation.confidence)}${radiation.corroborated ? ' · confirmed' : ''}${radiation.conflictingSources ? ' · conflicting' : ''}</span>
|
||||
</div>
|
||||
<div class="signal-context-item">
|
||||
<span class="context-label">Sources</span>
|
||||
<span class="context-value">${escapeHtml(radiation.contributingSources.join(' + '))} (${radiation.sourceCount})</span>
|
||||
</div>
|
||||
<div class="signal-context-item">
|
||||
<span class="context-label">Anomalies in batch</span>
|
||||
<span class="context-value">${radiation.anomalyCount} total (${radiation.spikeCount} spike, ${radiation.elevatedCount} elevated, ${radiation.corroboratedCount} confirmed)</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="signal-item" style="border-left-color: ${color}">
|
||||
<div class="signal-type">${icon} ${alert.type.toUpperCase().replace('_', ' ')}</div>
|
||||
|
||||
@@ -209,6 +209,7 @@ export class StrategicRiskPanel extends Panel {
|
||||
case 'convergence': return '🎯';
|
||||
case 'cii_spike': return '📊';
|
||||
case 'cascade': return '🔗';
|
||||
case 'radiation': return '☢️';
|
||||
case 'composite': return '⚠️';
|
||||
default: return '📍';
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export * from './UnifiedSettings';
|
||||
export * from './TradePolicyPanel';
|
||||
export * from './SupplyChainPanel';
|
||||
export * from './SecurityAdvisoriesPanel';
|
||||
export * from './RadiationWatchPanel';
|
||||
export * from './OrefSirensPanel';
|
||||
export * from './TelegramIntelPanel';
|
||||
export * from './BreakingNewsBanner';
|
||||
|
||||
@@ -27,6 +27,7 @@ export const LAYER_KEY_MAP: Record<string, keyof MapLayers> = {
|
||||
gps: 'gpsJamming',
|
||||
cii: 'ciiChoropleth',
|
||||
iran: 'iranAttacks',
|
||||
radiation: 'radiationWatch',
|
||||
natural: 'natural',
|
||||
};
|
||||
|
||||
@@ -72,6 +73,7 @@ export const COMMANDS: Command[] = [
|
||||
{ id: 'layer:ucdp', keywords: ['ucdp', 'armed conflict', 'armed conflict events'], label: 'Toggle armed conflict events', icon: '\u2694\uFE0F', category: 'layers' },
|
||||
{ id: 'layer:iran', keywords: ['iran', 'iran attacks'], label: 'Toggle Iran attacks', icon: '\u{1F3AF}', category: 'layers' },
|
||||
{ id: 'layer:irradiators', keywords: ['irradiators', 'gamma', 'radiation'], label: 'Toggle gamma irradiators', icon: '\u2623\uFE0F', category: 'layers' },
|
||||
{ id: 'layer:radiation', keywords: ['radiation', 'radnet', 'safecast', 'anomalies'], label: 'Toggle radiation anomalies', icon: '\u2622\uFE0F', category: 'layers' },
|
||||
{ id: 'layer:spaceports', keywords: ['spaceports', 'launch sites', 'rockets'], label: 'Toggle spaceports', icon: '\u{1F680}', category: 'layers' },
|
||||
{ id: 'layer:datacenters', keywords: ['datacenters', 'data centers', 'ai data'], label: 'Toggle AI data centers', icon: '\u{1F5A5}\uFE0F', category: 'layers' },
|
||||
{ id: 'layer:military', keywords: ['military activity', 'mil activity'], label: 'Toggle military activity', icon: '\u{1F396}\uFE0F', category: 'layers' },
|
||||
@@ -139,6 +141,7 @@ export const COMMANDS: Command[] = [
|
||||
{ id: 'panel:tech-readiness', keywords: ['tech readiness', 'digital readiness', 'technology index'], label: 'Panel: Tech Readiness Index', icon: '\u{1F4F1}', category: 'panels' },
|
||||
{ id: 'panel:world-clock', keywords: ['clock', 'world clock', 'time zones', 'timezone'], label: 'Panel: World Clock', icon: '\u{1F570}\uFE0F', category: 'panels' },
|
||||
{ id: 'panel:layoffs', keywords: ['layoffs', 'layoff tracker', 'job cuts', 'redundancies'], label: 'Panel: Layoffs Tracker', icon: '\u{1F4C9}', category: 'panels' },
|
||||
{ id: 'panel:radiation-watch', keywords: ['radiation', 'nuclear', 'radnet', 'safecast', 'radiation watch'], label: 'Panel: Radiation Watch', icon: '\u2622\uFE0F', category: 'panels' },
|
||||
|
||||
// View / settings
|
||||
{ id: 'view:dark', keywords: ['dark', 'dark mode', 'night'], label: 'Switch to dark mode', icon: '\u{1F319}', category: 'view' },
|
||||
|
||||
@@ -33,6 +33,7 @@ export const LAYER_REGISTRY: Record<keyof MapLayers, LayerDefinition> = {
|
||||
bases: def('bases', '🏛', 'militaryBases', 'Military Bases'),
|
||||
nuclear: def('nuclear', '☢', 'nuclearSites', 'Nuclear Sites'),
|
||||
irradiators: def('irradiators', '⚠', 'gammaIrradiators', 'Gamma Irradiators'),
|
||||
radiationWatch: def('radiationWatch', '☢', 'radiationWatch', 'Radiation Watch'),
|
||||
spaceports: def('spaceports', '🚀', 'spaceports', 'Spaceports'),
|
||||
satellites: def('satellites', '🛰', 'satellites', 'Orbital Surveillance', ['flat', 'globe']),
|
||||
|
||||
@@ -83,7 +84,7 @@ export const LAYER_REGISTRY: Record<keyof MapLayers, LayerDefinition> = {
|
||||
const VARIANT_LAYER_ORDER: Record<MapVariant, Array<keyof MapLayers>> = {
|
||||
full: [
|
||||
'iranAttacks', 'hotspots', 'conflicts',
|
||||
'bases', 'nuclear', 'irradiators', 'spaceports',
|
||||
'bases', 'nuclear', 'irradiators', 'radiationWatch', 'spaceports',
|
||||
'cables', 'pipelines', 'datacenters', 'military',
|
||||
'ais', 'tradeRoutes', 'flights', 'protests',
|
||||
'ucdpEvents', 'displacement', 'climate', 'weather',
|
||||
@@ -161,7 +162,10 @@ export const LAYER_SYNONYMS: Record<string, Array<keyof MapLayers>> = {
|
||||
navy: ['military', 'ais'],
|
||||
missile: ['iranAttacks', 'military'],
|
||||
nuke: ['nuclear'],
|
||||
radiation: ['nuclear', 'irradiators'],
|
||||
radiation: ['radiationWatch', 'nuclear', 'irradiators'],
|
||||
radnet: ['radiationWatch'],
|
||||
safecast: ['radiationWatch'],
|
||||
anomaly: ['radiationWatch', 'climate'],
|
||||
space: ['spaceports', 'satellites'],
|
||||
orbit: ['satellites'],
|
||||
internet: ['outages', 'cables', 'cyberThreats'],
|
||||
|
||||
@@ -61,6 +61,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
|
||||
climate: { name: 'Climate Anomalies', enabled: true, priority: 2 },
|
||||
'population-exposure': { name: 'Population Exposure', enabled: true, priority: 2 },
|
||||
'security-advisories': { name: 'Security Advisories', enabled: true, priority: 2 },
|
||||
'radiation-watch': { name: 'Radiation Watch', enabled: true, priority: 2 },
|
||||
'oref-sirens': { name: 'Israel Sirens', enabled: true, priority: 2, ...(_desktop && { premium: 'locked' as const }) },
|
||||
'telegram-intel': { name: 'Telegram Intel', enabled: true, priority: 2, ...(_desktop && { premium: 'locked' as const }) },
|
||||
'airline-intel': { name: 'Airline Intelligence', enabled: true, priority: 2 },
|
||||
@@ -82,6 +83,7 @@ const FULL_MAP_LAYERS: MapLayers = {
|
||||
ais: false,
|
||||
nuclear: true,
|
||||
irradiators: false,
|
||||
radiationWatch: false,
|
||||
sanctions: true,
|
||||
weather: true,
|
||||
economic: true,
|
||||
@@ -142,6 +144,7 @@ const FULL_MOBILE_MAP_LAYERS: MapLayers = {
|
||||
ais: false,
|
||||
nuclear: false,
|
||||
irradiators: false,
|
||||
radiationWatch: false,
|
||||
sanctions: true,
|
||||
weather: true,
|
||||
economic: false,
|
||||
@@ -846,6 +849,7 @@ export const LAYER_TO_SOURCE: Partial<Record<keyof MapLayers, DataSourceId[]>> =
|
||||
ucdpEvents: ['ucdp_events'],
|
||||
displacement: ['unhcr'],
|
||||
climate: ['climate'],
|
||||
radiationWatch: ['radiation'],
|
||||
};
|
||||
|
||||
// ============================================
|
||||
@@ -890,7 +894,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
|
||||
},
|
||||
dataTracking: {
|
||||
labelKey: 'header.panelCatDataTracking',
|
||||
panelKeys: ['monitors', 'satellite-fires', 'ucdp-events', 'displacement', 'climate', 'population-exposure', 'security-advisories', 'oref-sirens', 'world-clock', 'tech-readiness'],
|
||||
panelKeys: ['monitors', 'satellite-fires', 'ucdp-events', 'displacement', 'climate', 'population-exposure', 'security-advisories', 'radiation-watch', 'oref-sirens', 'world-clock', 'tech-readiness'],
|
||||
variants: ['full'],
|
||||
},
|
||||
|
||||
|
||||
146
src/generated/client/worldmonitor/radiation/v1/service_client.ts
Normal file
146
src/generated/client/worldmonitor/radiation/v1/service_client.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/radiation/v1/service.proto
|
||||
|
||||
export interface ListRadiationObservationsRequest {
|
||||
maxItems: number;
|
||||
}
|
||||
|
||||
export interface ListRadiationObservationsResponse {
|
||||
observations: RadiationObservation[];
|
||||
fetchedAt: number;
|
||||
epaCount: number;
|
||||
safecastCount: number;
|
||||
anomalyCount: number;
|
||||
elevatedCount: number;
|
||||
spikeCount: number;
|
||||
corroboratedCount: number;
|
||||
lowConfidenceCount: number;
|
||||
conflictingCount: number;
|
||||
convertedFromCpmCount: number;
|
||||
}
|
||||
|
||||
export interface RadiationObservation {
|
||||
id: string;
|
||||
source: RadiationSource;
|
||||
locationName: string;
|
||||
country: string;
|
||||
location?: GeoCoordinates;
|
||||
value: number;
|
||||
unit: string;
|
||||
observedAt: number;
|
||||
freshness: RadiationFreshness;
|
||||
baselineValue: number;
|
||||
delta: number;
|
||||
zScore: number;
|
||||
severity: RadiationSeverity;
|
||||
contributingSources: RadiationSource[];
|
||||
confidence: RadiationConfidence;
|
||||
corroborated: boolean;
|
||||
conflictingSources: boolean;
|
||||
convertedFromCpm: boolean;
|
||||
sourceCount: number;
|
||||
}
|
||||
|
||||
export interface GeoCoordinates {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export type RadiationConfidence = "RADIATION_CONFIDENCE_UNSPECIFIED" | "RADIATION_CONFIDENCE_LOW" | "RADIATION_CONFIDENCE_MEDIUM" | "RADIATION_CONFIDENCE_HIGH";
|
||||
|
||||
export type RadiationFreshness = "RADIATION_FRESHNESS_UNSPECIFIED" | "RADIATION_FRESHNESS_LIVE" | "RADIATION_FRESHNESS_RECENT" | "RADIATION_FRESHNESS_HISTORICAL";
|
||||
|
||||
export type RadiationSeverity = "RADIATION_SEVERITY_UNSPECIFIED" | "RADIATION_SEVERITY_NORMAL" | "RADIATION_SEVERITY_ELEVATED" | "RADIATION_SEVERITY_SPIKE";
|
||||
|
||||
export type RadiationSource = "RADIATION_SOURCE_UNSPECIFIED" | "RADIATION_SOURCE_EPA_RADNET" | "RADIATION_SOURCE_SAFECAST";
|
||||
|
||||
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 RadiationServiceClientOptions {
|
||||
fetch?: typeof fetch;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RadiationServiceCallOptions {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class RadiationServiceClient {
|
||||
private baseURL: string;
|
||||
private fetchFn: typeof fetch;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
|
||||
constructor(baseURL: string, options?: RadiationServiceClientOptions) {
|
||||
this.baseURL = baseURL.replace(/\/+$/, "");
|
||||
this.fetchFn = options?.fetch ?? globalThis.fetch;
|
||||
this.defaultHeaders = { ...options?.defaultHeaders };
|
||||
}
|
||||
|
||||
async listRadiationObservations(req: ListRadiationObservationsRequest, options?: RadiationServiceCallOptions): Promise<ListRadiationObservationsResponse> {
|
||||
let path = "/api/radiation/v1/list-radiation-observations";
|
||||
const params = new URLSearchParams();
|
||||
if (req.maxItems != null && req.maxItems !== 0) params.set("max_items", String(req.maxItems));
|
||||
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as ListRadiationObservationsResponse;
|
||||
}
|
||||
|
||||
private async handleError(resp: Response): Promise<never> {
|
||||
const body = await resp.text();
|
||||
if (resp.status === 400) {
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (parsed.violations) {
|
||||
throw new ValidationError(parsed.violations);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationError) throw e;
|
||||
}
|
||||
}
|
||||
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
|
||||
}
|
||||
}
|
||||
|
||||
160
src/generated/server/worldmonitor/radiation/v1/service_server.ts
Normal file
160
src/generated/server/worldmonitor/radiation/v1/service_server.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/radiation/v1/service.proto
|
||||
|
||||
export interface ListRadiationObservationsRequest {
|
||||
maxItems: number;
|
||||
}
|
||||
|
||||
export interface ListRadiationObservationsResponse {
|
||||
observations: RadiationObservation[];
|
||||
fetchedAt: number;
|
||||
epaCount: number;
|
||||
safecastCount: number;
|
||||
anomalyCount: number;
|
||||
elevatedCount: number;
|
||||
spikeCount: number;
|
||||
corroboratedCount: number;
|
||||
lowConfidenceCount: number;
|
||||
conflictingCount: number;
|
||||
convertedFromCpmCount: number;
|
||||
}
|
||||
|
||||
export interface RadiationObservation {
|
||||
id: string;
|
||||
source: RadiationSource;
|
||||
locationName: string;
|
||||
country: string;
|
||||
location?: GeoCoordinates;
|
||||
value: number;
|
||||
unit: string;
|
||||
observedAt: number;
|
||||
freshness: RadiationFreshness;
|
||||
baselineValue: number;
|
||||
delta: number;
|
||||
zScore: number;
|
||||
severity: RadiationSeverity;
|
||||
contributingSources: RadiationSource[];
|
||||
confidence: RadiationConfidence;
|
||||
corroborated: boolean;
|
||||
conflictingSources: boolean;
|
||||
convertedFromCpm: boolean;
|
||||
sourceCount: number;
|
||||
}
|
||||
|
||||
export interface GeoCoordinates {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export type RadiationConfidence = "RADIATION_CONFIDENCE_UNSPECIFIED" | "RADIATION_CONFIDENCE_LOW" | "RADIATION_CONFIDENCE_MEDIUM" | "RADIATION_CONFIDENCE_HIGH";
|
||||
|
||||
export type RadiationFreshness = "RADIATION_FRESHNESS_UNSPECIFIED" | "RADIATION_FRESHNESS_LIVE" | "RADIATION_FRESHNESS_RECENT" | "RADIATION_FRESHNESS_HISTORICAL";
|
||||
|
||||
export type RadiationSeverity = "RADIATION_SEVERITY_UNSPECIFIED" | "RADIATION_SEVERITY_NORMAL" | "RADIATION_SEVERITY_ELEVATED" | "RADIATION_SEVERITY_SPIKE";
|
||||
|
||||
export type RadiationSource = "RADIATION_SOURCE_UNSPECIFIED" | "RADIATION_SOURCE_EPA_RADNET" | "RADIATION_SOURCE_SAFECAST";
|
||||
|
||||
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 RadiationServiceHandler {
|
||||
listRadiationObservations(ctx: ServerContext, req: ListRadiationObservationsRequest): Promise<ListRadiationObservationsResponse>;
|
||||
}
|
||||
|
||||
export function createRadiationServiceRoutes(
|
||||
handler: RadiationServiceHandler,
|
||||
options?: ServerOptions,
|
||||
): RouteDescriptor[] {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/radiation/v1/list-radiation-observations",
|
||||
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: ListRadiationObservationsRequest = {
|
||||
maxItems: Number(params.get("max_items") ?? "0"),
|
||||
};
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("listRadiationObservations", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.listRadiationObservations(ctx, body);
|
||||
return new Response(JSON.stringify(result as ListRadiationObservationsResponse), {
|
||||
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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1081,6 +1081,8 @@
|
||||
"militaryBases": "Military Bases",
|
||||
"nuclearSites": "Nuclear Sites",
|
||||
"gammaIrradiators": "Gamma Irradiators",
|
||||
"radiationSpike": "Radiation spike",
|
||||
"radiationElevated": "Elevated radiation",
|
||||
"spaceports": "Spaceports",
|
||||
"satellites": "Orbital Surveillance",
|
||||
"pipelines": "Pipelines",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getLocationName, type GeoConvergenceAlert } from './geo-convergence';
|
||||
import type { CountryScore } from './country-instability';
|
||||
import { getLatestRadiationWatch, type RadiationObservation } from './radiation';
|
||||
import type { CascadeResult, CascadeImpactLevel } from '@/types';
|
||||
import { calculateCII, isInLearningMode } from './country-instability';
|
||||
import { getCountryNameByCode } from './country-geometry';
|
||||
@@ -7,7 +8,7 @@ import { t } from '@/services/i18n';
|
||||
import type { TheaterPostureSummary } from '@/services/military-surge';
|
||||
|
||||
export type AlertPriority = 'critical' | 'high' | 'medium' | 'low';
|
||||
export type AlertType = 'convergence' | 'cii_spike' | 'cascade' | 'composite';
|
||||
export type AlertType = 'convergence' | 'cii_spike' | 'cascade' | 'radiation' | 'composite';
|
||||
|
||||
export interface UnifiedAlert {
|
||||
id: string;
|
||||
@@ -19,6 +20,7 @@ export interface UnifiedAlert {
|
||||
convergence?: GeoConvergenceAlert;
|
||||
ciiChange?: CIIChangeAlert;
|
||||
cascade?: CascadeAlert;
|
||||
radiation?: RadiationAlert;
|
||||
};
|
||||
location?: { lat: number; lon: number };
|
||||
countries: string[];
|
||||
@@ -43,6 +45,30 @@ export interface CascadeAlert {
|
||||
highestImpact: CascadeImpactLevel;
|
||||
}
|
||||
|
||||
export interface RadiationAlert {
|
||||
siteId: string;
|
||||
siteName: string;
|
||||
country: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
baselineValue: number;
|
||||
delta: number;
|
||||
zScore: number;
|
||||
severity: 'elevated' | 'spike';
|
||||
confidence: RadiationObservation['confidence'];
|
||||
corroborated: boolean;
|
||||
conflictingSources: boolean;
|
||||
convertedFromCpm: boolean;
|
||||
sourceCount: number;
|
||||
contributingSources: RadiationObservation['contributingSources'];
|
||||
anomalyCount: number;
|
||||
elevatedCount: number;
|
||||
spikeCount: number;
|
||||
corroboratedCount: number;
|
||||
lowConfidenceCount: number;
|
||||
conflictingCount: number;
|
||||
}
|
||||
|
||||
export interface StrategicRiskOverview {
|
||||
convergenceAlerts: number;
|
||||
avgCIIDeviation: number;
|
||||
@@ -108,6 +134,22 @@ function getPriorityFromConvergence(score: number, typeCount: number): AlertPrio
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function getPriorityFromRadiation(observation: RadiationObservation, spikeCount: number): AlertPriority {
|
||||
let score = 0;
|
||||
if (observation.severity === 'spike') score += 4;
|
||||
else if (observation.severity === 'elevated') score += 2;
|
||||
if (observation.corroborated) score += 2;
|
||||
if (observation.confidence === 'high') score += 2;
|
||||
else if (observation.confidence === 'medium') score += 1;
|
||||
if (observation.conflictingSources) score -= 2;
|
||||
if (observation.convertedFromCpm) score -= 1;
|
||||
if (spikeCount > 1 && observation.corroborated) score += 1;
|
||||
if (score >= 7) return 'critical';
|
||||
if (score >= 4) return 'high';
|
||||
if (score >= 2) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function buildConvergenceAlert(convergence: GeoConvergenceAlert, alertId: string): UnifiedAlert {
|
||||
const location = getCountriesNearLocation(convergence.lat, convergence.lon).join(', ') || 'Unknown';
|
||||
return {
|
||||
@@ -196,6 +238,86 @@ export function createCascadeAlert(cascade: CascadeResult): UnifiedAlert | null
|
||||
return addAndMergeAlert(alert);
|
||||
}
|
||||
|
||||
function getRadiationRank(observation: RadiationObservation): number {
|
||||
const severityRank = observation.severity === 'spike' ? 2 : observation.severity === 'elevated' ? 1 : 0;
|
||||
const confidenceRank = observation.confidence === 'high' ? 2 : observation.confidence === 'medium' ? 1 : 0;
|
||||
const corroborationBonus = observation.corroborated ? 300 : 0;
|
||||
const conflictPenalty = observation.conflictingSources ? 250 : 0;
|
||||
return severityRank * 1000 + confidenceRank * 200 + corroborationBonus + observation.zScore * 100 + observation.delta - conflictPenalty;
|
||||
}
|
||||
|
||||
function createRadiationAlert(): UnifiedAlert | null {
|
||||
const watch = getLatestRadiationWatch();
|
||||
if (!watch || watch.summary.anomalyCount === 0) {
|
||||
for (let i = alerts.length - 1; i >= 0; i--) {
|
||||
if (alerts[i]?.type === 'radiation') alerts.splice(i, 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const anomalies = watch.observations.filter(o => o.severity !== 'normal');
|
||||
if (anomalies.length === 0) return null;
|
||||
|
||||
const strongest = [...anomalies].sort((a, b) => getRadiationRank(b) - getRadiationRank(a))[0];
|
||||
if (!strongest) return null;
|
||||
|
||||
const countries = strongest.country ? [strongest.country] : getCountriesNearLocation(strongest.lat, strongest.lon);
|
||||
const radiation: RadiationAlert = {
|
||||
siteId: strongest.id,
|
||||
siteName: strongest.location,
|
||||
country: strongest.country,
|
||||
value: strongest.value,
|
||||
unit: strongest.unit,
|
||||
baselineValue: strongest.baselineValue,
|
||||
delta: strongest.delta,
|
||||
zScore: strongest.zScore,
|
||||
severity: strongest.severity === 'spike' ? 'spike' : 'elevated',
|
||||
confidence: strongest.confidence,
|
||||
corroborated: strongest.corroborated,
|
||||
conflictingSources: strongest.conflictingSources,
|
||||
convertedFromCpm: strongest.convertedFromCpm,
|
||||
sourceCount: strongest.sourceCount,
|
||||
contributingSources: strongest.contributingSources,
|
||||
anomalyCount: watch.summary.anomalyCount,
|
||||
elevatedCount: watch.summary.elevatedCount,
|
||||
spikeCount: watch.summary.spikeCount,
|
||||
corroboratedCount: watch.summary.corroboratedCount,
|
||||
lowConfidenceCount: watch.summary.lowConfidenceCount,
|
||||
conflictingCount: watch.summary.conflictingCount,
|
||||
};
|
||||
|
||||
const qualifier = strongest.corroborated
|
||||
? 'Confirmed'
|
||||
: strongest.conflictingSources
|
||||
? 'Conflicting'
|
||||
: strongest.confidence === 'low'
|
||||
? 'Potential'
|
||||
: 'Elevated';
|
||||
const title = strongest.severity === 'spike'
|
||||
? `${qualifier} radiation spike at ${strongest.location}`
|
||||
: `${qualifier} radiation anomaly at ${strongest.location}`;
|
||||
const confidenceClause = strongest.corroborated
|
||||
? `Confirmed by ${strongest.contributingSources.join(' + ')}.`
|
||||
: strongest.conflictingSources
|
||||
? `Sources disagree across ${strongest.contributingSources.join(' + ')}.`
|
||||
: `Confidence is ${strongest.confidence}.`;
|
||||
const summary = watch.summary.spikeCount > 0
|
||||
? `${watch.summary.spikeCount} spike and ${watch.summary.elevatedCount} elevated reading${watch.summary.anomalyCount === 1 ? '' : 's'} detected, with ${watch.summary.corroboratedCount} confirmed anomaly${watch.summary.corroboratedCount === 1 ? '' : 'ies'}. Highest site is ${strongest.location} (${strongest.value.toFixed(1)} ${strongest.unit}, +${strongest.delta.toFixed(1)} vs baseline). ${confidenceClause}`
|
||||
: `${watch.summary.elevatedCount} elevated radiation reading${watch.summary.elevatedCount === 1 ? '' : 's'} detected, with ${watch.summary.corroboratedCount} confirmed anomaly${watch.summary.corroboratedCount === 1 ? '' : 'ies'}. Highest site is ${strongest.location} (${strongest.value.toFixed(1)} ${strongest.unit}, +${strongest.delta.toFixed(1)} vs baseline). ${confidenceClause}`;
|
||||
|
||||
return addAndMergeAlert({
|
||||
id: 'radiation-watch',
|
||||
type: 'radiation',
|
||||
priority: getPriorityFromRadiation(strongest, watch.summary.spikeCount),
|
||||
title,
|
||||
summary,
|
||||
components: { radiation },
|
||||
location: { lat: strongest.lat, lon: strongest.lon },
|
||||
countries,
|
||||
timestamp: strongest.observedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldMergeAlerts(a: UnifiedAlert, b: UnifiedAlert): boolean {
|
||||
const sameCountry = a.countries.some(c => b.countries.includes(c));
|
||||
const sameTime =
|
||||
@@ -406,6 +528,7 @@ function updateAlerts(convergenceAlerts: GeoConvergenceAlert[]): void {
|
||||
|
||||
// Check for CII changes (alerts are added internally via addAndMergeAlert)
|
||||
checkCIIChanges();
|
||||
createRadiationAlert();
|
||||
|
||||
// Sort by timestamp (newest first) and limit to 100
|
||||
alerts.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
@@ -426,6 +549,17 @@ export function calculateStrategicRiskOverview(
|
||||
updateAlerts(convergenceAlerts);
|
||||
|
||||
const ciiRiskScore = calculateCIIRiskScore(ciiScores);
|
||||
const radiationWatch = getLatestRadiationWatch();
|
||||
const radiationScore = radiationWatch
|
||||
? Math.min(
|
||||
12,
|
||||
radiationWatch.summary.spikeCount * 4 +
|
||||
radiationWatch.summary.elevatedCount * 2 +
|
||||
radiationWatch.summary.corroboratedCount * 3 -
|
||||
radiationWatch.summary.lowConfidenceCount -
|
||||
radiationWatch.summary.conflictingCount
|
||||
)
|
||||
: 0;
|
||||
|
||||
// Weights for composite score
|
||||
const convergenceWeight = 0.3; // Geo convergence of multiple event types
|
||||
@@ -455,7 +589,8 @@ export function calculateStrategicRiskOverview(
|
||||
ciiRiskScore * ciiWeight +
|
||||
infraScore * infraWeight +
|
||||
theaterBoost +
|
||||
breakingBoost
|
||||
breakingBoost +
|
||||
radiationScore
|
||||
));
|
||||
|
||||
const trend = determineTrend(composite);
|
||||
@@ -470,7 +605,7 @@ export function calculateStrategicRiskOverview(
|
||||
infrastructureIncidents: countInfrastructureIncidents(),
|
||||
compositeScore: composite,
|
||||
trend,
|
||||
topRisks: identifyTopRisks(convergenceAlerts, ciiScores),
|
||||
topRisks: identifyTopRisks(convergenceAlerts, ciiScores, radiationWatch?.observations ?? []),
|
||||
topConvergenceZones: convergenceAlerts
|
||||
.slice(0, 3)
|
||||
.map(a => ({ cellId: a.cellId, lat: a.lat, lon: a.lon, score: a.score })),
|
||||
@@ -526,7 +661,8 @@ function countInfrastructureIncidents(): number {
|
||||
|
||||
function identifyTopRisks(
|
||||
convergence: GeoConvergenceAlert[],
|
||||
cii: CountryScore[]
|
||||
cii: CountryScore[],
|
||||
radiation: RadiationObservation[]
|
||||
): string[] {
|
||||
const risks: string[] = [];
|
||||
|
||||
@@ -536,6 +672,20 @@ function identifyTopRisks(
|
||||
risks.push(`Convergence: ${location} (score: ${top.score})`);
|
||||
}
|
||||
|
||||
const strongestRadiation = radiation
|
||||
.filter(observation => observation.severity !== 'normal')
|
||||
.sort((a, b) => getRadiationRank(b) - getRadiationRank(a))[0];
|
||||
if (strongestRadiation) {
|
||||
const status = strongestRadiation.corroborated
|
||||
? strongestRadiation.severity === 'spike' ? 'Confirmed radiation spike' : 'Confirmed radiation anomaly'
|
||||
: strongestRadiation.conflictingSources
|
||||
? 'Conflicting radiation signal'
|
||||
: strongestRadiation.severity === 'spike'
|
||||
? 'Potential radiation spike'
|
||||
: 'Elevated radiation';
|
||||
risks.push(`${status}: ${strongestRadiation.location} (+${strongestRadiation.delta.toFixed(1)} ${strongestRadiation.unit})`);
|
||||
}
|
||||
|
||||
const critical = cii.filter(s => s.level === 'critical' || s.level === 'high');
|
||||
for (const c of critical.slice(0, 2)) {
|
||||
risks.push(`${c.name} instability: ${c.score} (${c.level})`);
|
||||
|
||||
@@ -74,6 +74,7 @@ const SOURCE_METADATA: Record<DataSourceId, { name: string; requiredForRisk: boo
|
||||
wto_trade: { name: 'WTO Trade Policy', requiredForRisk: false, panelId: 'trade-policy' },
|
||||
supply_chain: { name: 'Supply Chain Intelligence', requiredForRisk: false, panelId: 'supply-chain' },
|
||||
security_advisories: { name: 'Security Advisories', requiredForRisk: false, panelId: 'security-advisories' },
|
||||
radiation: { name: 'Radiation Watch', requiredForRisk: false, panelId: 'radiation-watch' },
|
||||
gpsjam: { name: 'GPS/GNSS Interference', requiredForRisk: false, panelId: 'map' },
|
||||
treasury_revenue: { name: 'Treasury Customs Revenue', requiredForRisk: false, panelId: 'trade-policy' },
|
||||
};
|
||||
@@ -335,6 +336,7 @@ const INTELLIGENCE_GAP_MESSAGES: Record<DataSourceId, string> = {
|
||||
wto_trade: 'Trade policy intelligence unavailable—WTO data not updating',
|
||||
supply_chain: 'Supply chain disruption status unavailable—chokepoint monitoring offline',
|
||||
security_advisories: 'Government travel advisory data unavailable—security alerts may be missed',
|
||||
radiation: 'Radiation monitoring degraded—EPA RadNet and Safecast observations unavailable',
|
||||
gpsjam: 'GPS/GNSS interference data unavailable—jamming zones undetected',
|
||||
treasury_revenue: 'US Treasury customs revenue data unavailable',
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ const SIGNAL_TYPE_LABELS: Record<SignalType, string> = {
|
||||
protest: 'protests',
|
||||
ais_disruption: 'shipping disruption',
|
||||
satellite_fire: 'satellite fires',
|
||||
radiation_anomaly: 'radiation anomalies',
|
||||
temporal_anomaly: 'anomaly detection',
|
||||
active_strike: 'active strikes',
|
||||
};
|
||||
@@ -31,6 +32,7 @@ const SIGNAL_TYPE_ICONS: Record<SignalType, string> = {
|
||||
protest: '📢',
|
||||
ais_disruption: '🚢',
|
||||
satellite_fire: '🔥',
|
||||
radiation_anomaly: '☢️',
|
||||
temporal_anomaly: '📊',
|
||||
active_strike: '💥',
|
||||
};
|
||||
@@ -289,6 +291,7 @@ class FocalPointDetector {
|
||||
(signals.signalTypes.has('military_vessel') && /navy|naval|ships|fleet|carrier/.test(lower)) ||
|
||||
(signals.signalTypes.has('protest') && /protest|demonstrat|unrest|riot/.test(lower)) ||
|
||||
(signals.signalTypes.has('internet_outage') && /internet|blackout|outage|connectivity/.test(lower)) ||
|
||||
(signals.signalTypes.has('radiation_anomaly') && /nuclear|radiation|reactor|contamination|radnet/.test(lower)) ||
|
||||
(signals.signalTypes.has('active_strike') && /strike|attack|bomb|missile|target|hit/.test(lower));
|
||||
})) {
|
||||
bonus += 5;
|
||||
|
||||
@@ -39,6 +39,7 @@ export { generateSummary, translateText } from './summarization';
|
||||
export * from './cached-theater-posture';
|
||||
export * from './trade';
|
||||
export * from './supply-chain';
|
||||
export * from './radiation';
|
||||
export * from './breaking-news-alerts';
|
||||
export * from './daily-market-brief';
|
||||
export * from './stock-analysis-history';
|
||||
|
||||
192
src/services/radiation.ts
Normal file
192
src/services/radiation.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { createCircuitBreaker } from '@/utils';
|
||||
import { getRpcBaseUrl } from '@/services/rpc-client';
|
||||
import { getHydratedData } from '@/services/bootstrap';
|
||||
import {
|
||||
RadiationServiceClient,
|
||||
type RadiationConfidence as ProtoRadiationConfidence,
|
||||
type RadiationFreshness as ProtoRadiationFreshness,
|
||||
type RadiationObservation as ProtoRadiationObservation,
|
||||
type RadiationSeverity as ProtoRadiationSeverity,
|
||||
type RadiationSource as ProtoRadiationSource,
|
||||
type ListRadiationObservationsResponse,
|
||||
} from '@/generated/client/worldmonitor/radiation/v1/service_client';
|
||||
|
||||
export type RadiationFreshness = 'live' | 'recent' | 'historical';
|
||||
export type RadiationSeverity = 'normal' | 'elevated' | 'spike';
|
||||
export type RadiationConfidence = 'low' | 'medium' | 'high';
|
||||
export type RadiationSourceLabel = 'EPA RadNet' | 'Safecast';
|
||||
|
||||
export interface RadiationObservation {
|
||||
id: string;
|
||||
source: RadiationSourceLabel;
|
||||
contributingSources: RadiationSourceLabel[];
|
||||
location: string;
|
||||
country: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
value: number;
|
||||
unit: string;
|
||||
observedAt: Date;
|
||||
freshness: RadiationFreshness;
|
||||
baselineValue: number;
|
||||
delta: number;
|
||||
zScore: number;
|
||||
severity: RadiationSeverity;
|
||||
confidence: RadiationConfidence;
|
||||
corroborated: boolean;
|
||||
conflictingSources: boolean;
|
||||
convertedFromCpm: boolean;
|
||||
sourceCount: number;
|
||||
}
|
||||
|
||||
export interface RadiationWatchResult {
|
||||
fetchedAt: Date;
|
||||
observations: RadiationObservation[];
|
||||
coverage: { epa: number; safecast: number };
|
||||
summary: {
|
||||
anomalyCount: number;
|
||||
elevatedCount: number;
|
||||
spikeCount: number;
|
||||
corroboratedCount: number;
|
||||
lowConfidenceCount: number;
|
||||
conflictingCount: number;
|
||||
convertedFromCpmCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
let latestRadiationWatchResult: RadiationWatchResult | null = null;
|
||||
|
||||
const breaker = createCircuitBreaker<RadiationWatchResult>({
|
||||
name: 'Radiation Watch',
|
||||
cacheTtlMs: 15 * 60 * 1000,
|
||||
persistCache: true,
|
||||
});
|
||||
const client = new RadiationServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) });
|
||||
|
||||
const emptyResult: RadiationWatchResult = {
|
||||
fetchedAt: new Date(0),
|
||||
observations: [],
|
||||
coverage: { epa: 0, safecast: 0 },
|
||||
summary: {
|
||||
anomalyCount: 0,
|
||||
elevatedCount: 0,
|
||||
spikeCount: 0,
|
||||
corroboratedCount: 0,
|
||||
lowConfidenceCount: 0,
|
||||
conflictingCount: 0,
|
||||
convertedFromCpmCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
function toObservation(raw: ProtoRadiationObservation): RadiationObservation {
|
||||
return {
|
||||
id: raw.id,
|
||||
source: mapSource(raw.source),
|
||||
contributingSources: (raw.contributingSources ?? []).map(mapSource),
|
||||
location: raw.locationName,
|
||||
country: raw.country,
|
||||
lat: raw.location?.latitude ?? 0,
|
||||
lon: raw.location?.longitude ?? 0,
|
||||
value: raw.value,
|
||||
unit: raw.unit,
|
||||
observedAt: new Date(raw.observedAt),
|
||||
freshness: mapFreshness(raw.freshness),
|
||||
baselineValue: raw.baselineValue ?? raw.value,
|
||||
delta: raw.delta ?? 0,
|
||||
zScore: raw.zScore ?? 0,
|
||||
severity: mapSeverity(raw.severity),
|
||||
confidence: mapConfidence(raw.confidence),
|
||||
corroborated: raw.corroborated ?? false,
|
||||
conflictingSources: raw.conflictingSources ?? false,
|
||||
convertedFromCpm: raw.convertedFromCpm ?? false,
|
||||
sourceCount: raw.sourceCount ?? Math.max(1, raw.contributingSources?.length ?? 1),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchRadiationWatch(): Promise<RadiationWatchResult> {
|
||||
const hydrated = getHydratedData('radiationWatch') as ListRadiationObservationsResponse | undefined;
|
||||
if (hydrated?.observations?.length) {
|
||||
const result = toResult(hydrated);
|
||||
latestRadiationWatchResult = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
return breaker.execute(async () => {
|
||||
const response = await client.listRadiationObservations({
|
||||
maxItems: 18,
|
||||
}, {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
const result = toResult(response);
|
||||
latestRadiationWatchResult = result;
|
||||
return result;
|
||||
}, emptyResult);
|
||||
}
|
||||
|
||||
export function getLatestRadiationWatch(): RadiationWatchResult | null {
|
||||
return latestRadiationWatchResult;
|
||||
}
|
||||
|
||||
function toResult(response: ListRadiationObservationsResponse): RadiationWatchResult {
|
||||
return {
|
||||
fetchedAt: new Date(response.fetchedAt),
|
||||
observations: (response.observations ?? []).map(toObservation),
|
||||
coverage: {
|
||||
epa: response.epaCount ?? 0,
|
||||
safecast: response.safecastCount ?? 0,
|
||||
},
|
||||
summary: {
|
||||
anomalyCount: response.anomalyCount ?? 0,
|
||||
elevatedCount: response.elevatedCount ?? 0,
|
||||
spikeCount: response.spikeCount ?? 0,
|
||||
corroboratedCount: response.corroboratedCount ?? 0,
|
||||
lowConfidenceCount: response.lowConfidenceCount ?? 0,
|
||||
conflictingCount: response.conflictingCount ?? 0,
|
||||
convertedFromCpmCount: response.convertedFromCpmCount ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapSource(source: ProtoRadiationSource): RadiationSourceLabel {
|
||||
switch (source) {
|
||||
case 'RADIATION_SOURCE_EPA_RADNET':
|
||||
return 'EPA RadNet';
|
||||
case 'RADIATION_SOURCE_SAFECAST':
|
||||
return 'Safecast';
|
||||
default:
|
||||
return 'Safecast';
|
||||
}
|
||||
}
|
||||
|
||||
function mapFreshness(freshness: ProtoRadiationFreshness): RadiationFreshness {
|
||||
switch (freshness) {
|
||||
case 'RADIATION_FRESHNESS_LIVE':
|
||||
return 'live';
|
||||
case 'RADIATION_FRESHNESS_RECENT':
|
||||
return 'recent';
|
||||
default:
|
||||
return 'historical';
|
||||
}
|
||||
}
|
||||
|
||||
function mapSeverity(severity: ProtoRadiationSeverity): RadiationSeverity {
|
||||
switch (severity) {
|
||||
case 'RADIATION_SEVERITY_SPIKE':
|
||||
return 'spike';
|
||||
case 'RADIATION_SEVERITY_ELEVATED':
|
||||
return 'elevated';
|
||||
default:
|
||||
return 'normal';
|
||||
}
|
||||
}
|
||||
|
||||
function mapConfidence(confidence: ProtoRadiationConfidence): RadiationConfidence {
|
||||
switch (confidence) {
|
||||
case 'RADIATION_CONFIDENCE_HIGH':
|
||||
return 'high';
|
||||
case 'RADIATION_CONFIDENCE_MEDIUM':
|
||||
return 'medium';
|
||||
default:
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SocialUnrestEvent,
|
||||
AisDisruptionEvent,
|
||||
} from '@/types';
|
||||
import type { RadiationObservation } from './radiation';
|
||||
import { getCountryAtCoordinates, getCountryNameByCode, nameToCountryCode, ME_STRIKE_BOUNDS, resolveCountryFromBounds } from './country-geometry';
|
||||
|
||||
export type SignalType =
|
||||
@@ -20,6 +21,7 @@ export type SignalType =
|
||||
| 'protest'
|
||||
| 'ais_disruption'
|
||||
| 'satellite_fire' // NASA FIRMS thermal anomalies
|
||||
| 'radiation_anomaly' // Radiation readings meaningfully above local baseline
|
||||
| 'temporal_anomaly' // Baseline deviation alerts
|
||||
| 'active_strike' // Iran attack / military conflict events
|
||||
|
||||
@@ -263,6 +265,27 @@ class SignalAggregator {
|
||||
this.pruneOld();
|
||||
}
|
||||
|
||||
ingestRadiationObservations(observations: RadiationObservation[]): void {
|
||||
this.clearSignalType('radiation_anomaly');
|
||||
|
||||
for (const observation of observations) {
|
||||
if (observation.severity === 'normal') continue;
|
||||
const code = normalizeCountryCode(observation.country) || this.coordsToCountry(observation.lat, observation.lon);
|
||||
|
||||
this.signals.push({
|
||||
type: 'radiation_anomaly',
|
||||
country: code,
|
||||
countryName: getCountryName(code),
|
||||
lat: observation.lat,
|
||||
lon: observation.lon,
|
||||
severity: observation.severity === 'spike' ? 'high' : 'medium',
|
||||
title: `${observation.severity === 'spike' ? 'Radiation spike' : 'Elevated radiation'} at ${observation.location} (${observation.delta >= 0 ? '+' : ''}${observation.delta.toFixed(1)} ${observation.unit} vs baseline)`,
|
||||
timestamp: observation.observedAt,
|
||||
});
|
||||
}
|
||||
this.pruneOld();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -479,6 +502,7 @@ class SignalAggregator {
|
||||
protest: 'civil unrest',
|
||||
ais_disruption: 'shipping anomalies',
|
||||
satellite_fire: 'thermal anomalies',
|
||||
radiation_anomaly: 'radiation anomalies',
|
||||
temporal_anomaly: 'baseline anomalies',
|
||||
active_strike: 'active strikes',
|
||||
};
|
||||
@@ -535,6 +559,7 @@ class SignalAggregator {
|
||||
protest: 0,
|
||||
ais_disruption: 0,
|
||||
satellite_fire: 0,
|
||||
radiation_anomaly: 0,
|
||||
temporal_anomaly: 0,
|
||||
active_strike: 0,
|
||||
};
|
||||
@@ -563,4 +588,3 @@ class SignalAggregator {
|
||||
}
|
||||
|
||||
export const signalAggregator = new SignalAggregator();
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ function humanizeSignalType(type: string): string {
|
||||
naval_vessel: 'Naval Vessels',
|
||||
ais_gap: 'AIS Gaps',
|
||||
satellite_fire: 'Satellite Fires',
|
||||
radiation_anomaly: 'Radiation Anomalies',
|
||||
};
|
||||
return map[type] || type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -8554,6 +8554,186 @@ a.prediction-link:hover {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.radiation-panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.radiation-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.radiation-summary-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--overlay-subtle);
|
||||
}
|
||||
|
||||
.radiation-summary-card-spike {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.radiation-summary-card-confirmed {
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
|
||||
.radiation-summary-card-low-confidence {
|
||||
border-color: rgba(245, 158, 11, 0.35);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
.radiation-summary-card-conflict {
|
||||
border-color: rgba(125, 211, 252, 0.35);
|
||||
background: rgba(125, 211, 252, 0.08);
|
||||
}
|
||||
|
||||
.radiation-summary-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.radiation-summary-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.radiation-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.radiation-table th,
|
||||
.radiation-table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.radiation-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radiation-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.radiation-reading {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.radiation-location-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.radiation-location-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.radiation-location-flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.radiation-delta {
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.radiation-freshness,
|
||||
.radiation-severity,
|
||||
.radiation-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.radiation-confidence-high {
|
||||
color: var(--semantic-normal);
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
|
||||
.radiation-confidence-medium {
|
||||
color: var(--semantic-elevated);
|
||||
border-color: rgba(245, 158, 11, 0.35);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
.radiation-confidence-low {
|
||||
color: #7dd3fc;
|
||||
border-color: rgba(125, 211, 252, 0.35);
|
||||
background: rgba(125, 211, 252, 0.08);
|
||||
}
|
||||
|
||||
.radiation-flag-confirmed {
|
||||
color: var(--semantic-normal);
|
||||
}
|
||||
|
||||
.radiation-flag-conflict {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.radiation-flag-converted {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.radiation-severity-normal {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.radiation-severity-elevated {
|
||||
color: var(--semantic-elevated);
|
||||
border-color: rgba(234, 179, 8, 0.35);
|
||||
background: rgba(234, 179, 8, 0.08);
|
||||
}
|
||||
|
||||
.radiation-severity-spike {
|
||||
color: var(--semantic-critical);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.radiation-freshness-live {
|
||||
color: var(--semantic-normal);
|
||||
}
|
||||
|
||||
.radiation-freshness-recent {
|
||||
color: var(--semantic-elevated);
|
||||
}
|
||||
|
||||
.radiation-freshness-historical {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.radiation-footer {
|
||||
padding: 0 8px 8px 8px;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.economic-source {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
|
||||
@@ -30,6 +30,7 @@ export type DataSourceId =
|
||||
| 'supply_chain'
|
||||
| 'security_advisories'
|
||||
| 'gpsjam'
|
||||
| 'radiation'
|
||||
| 'treasury_revenue';
|
||||
|
||||
// AppContext lives in src/app/app-context.ts because it references
|
||||
@@ -577,6 +578,7 @@ export interface MapLayers {
|
||||
ais: boolean;
|
||||
nuclear: boolean;
|
||||
irradiators: boolean;
|
||||
radiationWatch?: boolean;
|
||||
sanctions: boolean;
|
||||
weather: boolean;
|
||||
economic: boolean;
|
||||
@@ -1442,6 +1444,7 @@ export interface CountryBriefSignals {
|
||||
outages: number;
|
||||
aisDisruptions: number;
|
||||
satelliteFires: number;
|
||||
radiationAnomalies: number;
|
||||
temporalAnomalies: number;
|
||||
cyberThreats: number;
|
||||
earthquakes: number;
|
||||
|
||||
Reference in New Issue
Block a user