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