File size: 7,496 Bytes
9d2d895 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | 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';
// Re-export consumer-friendly type matching legacy shape exactly.
// Consumers import this type from '@/services/climate' and see the same
// lat/lon/severity/type fields they always used. The proto -> legacy
// mapping happens internally in toDisplayAnomaly().
export interface ClimateAnomaly {
/**
* A named geographic region or label where the anomaly is occurring
* (e.g., "Northern Europe", "Southeast Asia").
*/
zone: string;
lat: number;
lon: number;
/**
* The temperature deviation from the historical average, measured in degrees Celsius (°C).
*/
tempDelta: number;
/**
* The precipitation deviation from the historical average, measured in millimeters.
*/
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;
// Year-over-year delta vs the same calendar month, in ppm.
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;
}
// Presentation helpers (used by ClimateAnomalyPanel)
export function getSeverityIcon(anomaly: ClimateAnomaly): string {
switch (anomaly.type) {
case 'warm': return '\u{1F321}\u{FE0F}'; // thermometer
case 'cold': return '\u{2744}\u{FE0F}'; // snowflake
case 'wet': return '\u{1F327}\u{FE0F}'; // rain
case 'dry': return '\u{2600}\u{FE0F}'; // sun
case 'mixed': return '\u{26A1}'; // lightning
default: return '\u{1F321}\u{FE0F}'; // thermometer
}
}
export function formatDelta(value: number, unit: string): string {
const sign = value > 0 ? '+' : '';
return `${sign}${value.toFixed(1)}${unit}`;
}
// Internal: Map proto ClimateAnomaly -> consumer-friendly shape
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';
}
}
|