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