| import { getRpcBaseUrl } from '@/services/rpc-client'; |
| import type { ClimateAnomaly as ProtoClimateAnomaly, Co2DataPoint as ProtoCo2DataPoint, Co2Monitoring as ProtoCo2Monitoring, AnomalySeverity as ProtoAnomalySeverity, AnomalyType as ProtoAnomalyType, GetCo2MonitoringResponse, GetOceanIceDataResponse, ListClimateAnomaliesResponse, ListClimateDisastersResponse } from '@/generated/client/worldmonitor/climate/v1/service_client'; |
| import { createCircuitBreaker } from '@/utils'; |
| import { getHydratedData } from '@/services/bootstrap'; |
| import { |
| normalizeHydratedOceanIce, |
| toDisplayOceanIceData, |
| type OceanIceIndicators, |
| type OceanIceSeedSnakeShape, |
| } from './ocean-ice'; |
| import { ClimateServiceClient } from '@/services/generated-rpc-clients'; |
|
|
| |
| |
| |
| |
| export interface ClimateAnomaly { |
| |
| |
| |
| |
| zone: string; |
| lat: number; |
| lon: number; |
| |
| |
| |
| tempDelta: number; |
| |
| |
| |
| precipDelta: number; |
| severity: 'normal' | 'moderate' | 'extreme'; |
| type: 'warm' | 'cold' | 'wet' | 'dry' | 'mixed'; |
| period: string; |
| } |
|
|
| export interface ClimateFetchResult { |
| ok: boolean; |
| anomalies: ClimateAnomaly[]; |
| } |
|
|
| export interface Co2DataPoint { |
| month: string; |
| ppm: number; |
| |
| anomaly: number; |
| } |
|
|
| export interface Co2Monitoring { |
| currentPpm: number; |
| yearAgoPpm: number; |
| annualGrowthRate: number; |
| preIndustrialBaseline: number; |
| monthlyAverage: number; |
| trend12m: Co2DataPoint[]; |
| methanePpb: number; |
| nitrousOxidePpb: number; |
| measuredAt?: Date; |
| station: string; |
| } |
|
|
| export type { OceanIceIndicators, OceanIceTrendPoint } from './ocean-ice'; |
|
|
| const client = new ClimateServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); |
| const breaker = createCircuitBreaker<ListClimateAnomaliesResponse>({ name: 'Climate Anomalies', cacheTtlMs: 20 * 60 * 1000, persistCache: true }); |
| const co2Breaker = createCircuitBreaker<GetCo2MonitoringResponse>({ name: 'CO2 Monitoring', cacheTtlMs: 6 * 60 * 60 * 1000, persistCache: true }); |
| const oceanIceBreaker = createCircuitBreaker<GetOceanIceDataResponse>({ name: 'Ocean Ice', cacheTtlMs: 26 * 60 * 60 * 1000, persistCache: true }); |
|
|
| const emptyClimateFallback: ListClimateAnomaliesResponse = { anomalies: [] }; |
| const emptyCo2Fallback: GetCo2MonitoringResponse = {}; |
| const emptyOceanIceFallback: GetOceanIceDataResponse = {}; |
|
|
| export async function fetchClimateAnomalies(): Promise<ClimateFetchResult> { |
| const hydrated = getHydratedData('climateAnomalies') as ListClimateAnomaliesResponse | undefined; |
| if (hydrated && (hydrated.anomalies ?? []).length > 0) { |
| const anomalies = hydrated.anomalies.map(toDisplayAnomaly).filter(a => a.severity !== 'normal'); |
| if (anomalies.length > 0) return { ok: true, anomalies }; |
| } |
|
|
| const response = await breaker.execute(async () => { |
| return client.listClimateAnomalies({ minSeverity: 'ANOMALY_SEVERITY_UNSPECIFIED', pageSize: 0, cursor: '' }); |
| }, emptyClimateFallback, { shouldCache: (r) => r.anomalies.length > 0 }); |
| const anomalies = (response.anomalies ?? []) |
| .map(toDisplayAnomaly) |
| .filter(a => a.severity !== 'normal'); |
| return { ok: true, anomalies }; |
| } |
|
|
| export async function fetchCo2Monitoring(): Promise<Co2Monitoring | null> { |
| const hydrated = getHydratedData('co2Monitoring') as GetCo2MonitoringResponse | undefined; |
| if (hydrated?.monitoring) { |
| return toDisplayCo2Monitoring(hydrated.monitoring); |
| } |
|
|
| const response = await co2Breaker.execute(async () => { |
| return client.getCo2Monitoring({}); |
| }, emptyCo2Fallback, { shouldCache: (result) => Boolean(result.monitoring?.currentPpm) }); |
|
|
| return response.monitoring ? toDisplayCo2Monitoring(response.monitoring) : null; |
| } |
|
|
| export function getHydratedClimateDisasters(): ListClimateDisastersResponse | undefined { |
| return getHydratedData('climateDisasters') as ListClimateDisastersResponse | undefined; |
| } |
|
|
| export async function fetchOceanIceData(): Promise<OceanIceIndicators | null> { |
| const hydrated = getHydratedData('oceanIce') as GetOceanIceDataResponse | OceanIceSeedSnakeShape | undefined; |
| const hydratedProto = normalizeHydratedOceanIce(hydrated); |
| if (hydratedProto) { |
| return toDisplayOceanIceData(hydratedProto); |
| } |
|
|
| const response = await oceanIceBreaker.execute(async () => { |
| return client.getOceanIceData({}); |
| }, emptyOceanIceFallback, { shouldCache: (result) => Boolean(result.data) }); |
|
|
| return response.data ? toDisplayOceanIceData(response.data) : null; |
| } |
|
|
| |
| export function getSeverityIcon(anomaly: ClimateAnomaly): string { |
| switch (anomaly.type) { |
| case 'warm': return '\u{1F321}\u{FE0F}'; |
| case 'cold': return '\u{2744}\u{FE0F}'; |
| case 'wet': return '\u{1F327}\u{FE0F}'; |
| case 'dry': return '\u{2600}\u{FE0F}'; |
| case 'mixed': return '\u{26A1}'; |
| default: return '\u{1F321}\u{FE0F}'; |
| } |
| } |
|
|
| export function formatDelta(value: number, unit: string): string { |
| const sign = value > 0 ? '+' : ''; |
| return `${sign}${value.toFixed(1)}${unit}`; |
| } |
|
|
| |
| function toDisplayAnomaly(proto: ProtoClimateAnomaly): ClimateAnomaly { |
| return { |
| zone: proto.zone, |
| lat: proto.location?.latitude ?? 0, |
| lon: proto.location?.longitude ?? 0, |
| tempDelta: proto.tempDelta, |
| precipDelta: proto.precipDelta, |
| severity: mapSeverity(proto.severity), |
| type: mapType(proto.type), |
| period: proto.period, |
| }; |
| } |
|
|
| function toDisplayCo2Monitoring(proto: ProtoCo2Monitoring): Co2Monitoring { |
| const measuredAt = Number(proto.measuredAt); |
| return { |
| currentPpm: proto.currentPpm, |
| yearAgoPpm: proto.yearAgoPpm, |
| annualGrowthRate: proto.annualGrowthRate, |
| preIndustrialBaseline: proto.preIndustrialBaseline, |
| monthlyAverage: proto.monthlyAverage, |
| trend12m: (proto.trend12m ?? []).map(toDisplayCo2Point), |
| methanePpb: proto.methanePpb, |
| nitrousOxidePpb: proto.nitrousOxidePpb, |
| measuredAt: Number.isFinite(measuredAt) && measuredAt > 0 ? new Date(measuredAt) : undefined, |
| station: proto.station, |
| }; |
| } |
|
|
| function toDisplayCo2Point(proto: ProtoCo2DataPoint): Co2DataPoint { |
| return { |
| month: proto.month, |
| ppm: proto.ppm, |
| anomaly: proto.anomaly, |
| }; |
| } |
|
|
| function mapSeverity(s: ProtoAnomalySeverity): ClimateAnomaly['severity'] { |
| switch (s) { |
| case 'ANOMALY_SEVERITY_EXTREME': return 'extreme'; |
| case 'ANOMALY_SEVERITY_MODERATE': return 'moderate'; |
| default: return 'normal'; |
| } |
| } |
|
|
| function mapType(t: ProtoAnomalyType): ClimateAnomaly['type'] { |
| switch (t) { |
| case 'ANOMALY_TYPE_WARM': return 'warm'; |
| case 'ANOMALY_TYPE_COLD': return 'cold'; |
| case 'ANOMALY_TYPE_WET': return 'wet'; |
| case 'ANOMALY_TYPE_DRY': return 'dry'; |
| case 'ANOMALY_TYPE_MIXED': return 'mixed'; |
| default: return 'warm'; |
| } |
| } |
|
|