feat: add Radiation Watch with seeded anomaly intelligence, map layers, and country exposure (#1735)

This commit is contained in:
Elie Habib
2026-03-17 09:18:06 +04:00
committed by GitHub
parent ffae59f50e
commit 3897f8263d
44 changed files with 2379 additions and 14 deletions

2
api/bootstrap.js vendored
View File

@@ -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',

View File

@@ -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).

View 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),
);

File diff suppressed because one or more lines are too long

View 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.

View File

@@ -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;
}

View 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;
}

View 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};
}
}

View 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);
});

View File

@@ -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',

View File

@@ -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',

View 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,
};

View File

@@ -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();
}
};

View File

@@ -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 }>;
}

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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: [],
};

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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] || '📌';

View File

@@ -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();

View File

@@ -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;

View File

@@ -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';
}
}

View 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';
}
}

View File

@@ -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>

View File

@@ -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 '📍';
}

View File

@@ -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';

View File

@@ -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' },

View File

@@ -33,6 +33,7 @@ export const LAYER_REGISTRY: Record<keyof MapLayers, LayerDefinition> = {
bases: def('bases', '&#127963;', 'militaryBases', 'Military Bases'),
nuclear: def('nuclear', '&#9762;', 'nuclearSites', 'Nuclear Sites'),
irradiators: def('irradiators', '&#9888;', 'gammaIrradiators', 'Gamma Irradiators'),
radiationWatch: def('radiationWatch', '&#9762;', 'radiationWatch', 'Radiation Watch'),
spaceports: def('spaceports', '&#128640;', 'spaceports', 'Spaceports'),
satellites: def('satellites', '&#128752;', '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'],

View File

@@ -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'],
},

View 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);
}
}

View 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" },
});
}
},
},
];
}

View File

@@ -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",

View File

@@ -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})`);

View File

@@ -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',
};

View File

@@ -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;

View File

@@ -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
View 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';
}
}

View File

@@ -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();

View File

@@ -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());
}

View File

@@ -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);

View File

@@ -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;