diff --git a/README.md b/README.md index e4b3a4a..df0e9cc 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - [📍 Nokta Alarmları / Point Alarms](#-nokta-alarmları--point-alarms) - [🗺️ Coğrafi Alarmlar / Geo Alarms](#️-coğrafi-alarmlar--geo-alarms) - [📅 Tahmin Alarmları / Forecast Alarms](#-tahmin-alarmları--forecast-alarms) + - [🛣️ Güzergah / Enroute](#️-güzergah--enroute) - [🏗️ Mimari](#️-mimari) - [🔐 Kimlik Doğrulama ve Güvenlik](#-kimlik-doğrulama-ve-güvenlik) - [Dahili Auth Akışı](#dahili-auth-akışı) @@ -37,20 +38,21 @@ ## 🌐 Genel Bakış -Bu MCP server, iklim.co REST API'sinin tüm yeteneklerini **57 araç** (tool) olarak sunar. Araçlar 9 kategoriye ayrılmıştır: +Bu MCP server, iklim.co REST API'sinin tüm yeteneklerini **60 araç** (tool) olarak sunar. Araçlar 10 kategoriye ayrılmıştır: | Kategori | Araç Sayısı | Kapsam | |----------|:-----------:|--------| | ⚡ Lightning | 2 | Yıldırım çarpma verileri | | 🌪️ Thunderstorm | 3 | Fırtına hücresi takibi | -| 🌧️ Precipitation | 2 | Radar yağış verileri | +| 🌧️ Precipitation | 3 | Radar yağış verileri | | 🌤️ Forecast | 3 | Saatlik / günlük / anlık tahmin | | 👤 Auth & User | 11 | Kimlik doğrulama, kullanıcı yönetimi | | 🏢 Account | 8 | Hesap ve abonelik yönetimi | | 📍 Point Alarms | 6 | Nokta tabanlı uyarı abonelikleri | | 🗺️ Geo Alarms | 12 | Coğrafi sınır bazlı uyarılar + il/ilçe/mahalle kataloğu | | 📅 Forecast Alarms | 10 | Eşik bazlı tahmin uyarıları + il/ilçe kataloğu | -| **Toplam** | **57** | | +| 🛣️ Enroute | 2 | Hava durumu farkındalıklı rota izleme | +| **Toplam** | **60** | | --- @@ -284,6 +286,18 @@ Dairesel alan içindeki radar yağış verilerini sorgular. #### `get_precipitations_page` Zaman aralığına göre yağış verilerini sayfalı getirir. `intensityThreshold` **zorunludur**. +#### `get_precipitation_geometry` +Verilen yağış olaylarının (`get_precipitations_*` sonuçlarındaki `eventId`) poligon geometrisini **GeoJSON FeatureCollection** olarak döndürür. API protobuf yanıt verir; MCP bunu GeoJSON'a çevirir. Her feature bir `MultiPolygon`dur ve `intensity`, `radarTimestamp` (epoch ms), `forecast` özelliklerini taşır. + +| Parametre | Tip | Açıklama | +|-----------|-----|----------| +| `eventIds` | string[] | Yağış olay ID'leri (1 – 10) | +| `latitude` | number? | Kırpma dairesi merkez enlemi | +| `longitude` | number? | Kırpma dairesi merkez boylamı | +| `radius` | number? | Kırpma dairesi yarıçapı, metre | + +`latitude`/`longitude` birlikte verilmelidir. Merkez ve yarıçap yalnızca power-user rolüne sahip hesaplarda atlanabilir. + --- ### 🌤️ Hava Tahmini / Forecast @@ -450,7 +464,7 @@ Yeni coğrafi alarm oluşturur. Üç sınır tipi desteklenir: ```json // İdari sınır -{ "type": "ADMINISTRATIVE", "cityId": 6, "districtId": 60 } +{ "type": "ADMINISTRATIVE", "countryCode": "TUR", "cityId": 6, "districtId": 60 } // Poligon { "type": "POLYGON", "polygon": { "exterior": [{"lat": 39.9, "lng": 32.8}, ...] } } @@ -462,11 +476,13 @@ Yeni coğrafi alarm oluşturur. Üç sınır tipi desteklenir: #### `geo_alarm_update` / `geo_alarm_delete` / `geo_alarm_get_by_id` / `geo_alarm_get_by_recipient` / `geo_alarm_list` Nokta alarmlarıyla aynı imza. +İdari sınırda `countryCode` zorunludur: `TUR` (Türkiye) veya `CTR` (KKTC). İl, ilçe ve mahalle ID'leri ülkeye göre tanımlıdır, bu yüzden ilgili ülkenin katalog araçlarından alınmalıdır. `CTR` için mahalle düzeyi bucaktır. + #### 🏙️ Konum Kataloğu | Araç | Açıklama | |------|----------| -| `geo_alarm_list_cities` | Tüm illeri listeler | +| `geo_alarm_list_cities` | Bir ülkenin illerini listeler (`countryCode`: `TUR` veya `CTR`) | | `geo_alarm_get_city` | `cityId` ile il detayı | | `geo_alarm_list_districts` | `cityId` ile ilçeleri listeler | | `geo_alarm_get_district` | `districtId` ile ilçe detayı | @@ -484,7 +500,7 @@ Yeni tahmin alarmı oluşturur. **Sınır tipleri:** ```json -{ "type": "ADMINISTRATIVE", "cityId": 6, "districtId": 60 } +{ "type": "ADMINISTRATIVE", "countryCode": "TUR", "cityId": 6, "districtId": 60 } { "type": "POINT", "latitude": 39.92, "longitude": 32.85 } ``` @@ -508,15 +524,40 @@ Yeni tahmin alarmı oluşturur. #### `forecast_alarm_update` / `forecast_alarm_delete` / `forecast_alarm_get_by_id` / `forecast_alarm_get_by_recipient` / `forecast_alarm_list` Nokta alarmlarıyla aynı imza. +İdari sınırda `countryCode` zorunludur: `TUR` (Türkiye) veya `CTR` (KKTC). İl ve ilçe ID'leri ülkeye göre tanımlıdır, bu yüzden ilgili ülkenin katalog araçlarından alınmalıdır. + #### 🏙️ Konum Kataloğu | Araç | Açıklama | |------|----------| -| `forecast_alarm_list_cities` | Tüm illeri listeler | +| `forecast_alarm_list_cities` | Bir ülkenin illerini listeler (`countryCode`: `TUR` veya `CTR`) | | `forecast_alarm_get_city` | `cityId` ile il detayı | | `forecast_alarm_list_districts` | `cityId` ile ilçeleri listeler | | `forecast_alarm_get_district` | `districtId` ile ilçe detayı | +### 🛣️ Güzergah / Enroute + +#### `enroute_register_route` +Rotayı kaydeder; rota H3 segmentlerine bölünür ve her segment için ETA hesaplanır. `routeId`, mevcut segment (nowcast + tahmin) ve gelecek segmentler (tahmin) döner. Tüm zamanlar epoch ms'dir. + +| Parametre | Tip | Açıklama | +|-----------|-----|----------| +| `origin` | {lat, lng} | Başlangıç noktası | +| `destination` | {lat, lng} | Varış noktası | +| `departureTime` | number | Kalkış zamanı, epoch ms | +| `distanceMeters` | number | Toplam mesafe, metre | +| `durationSeconds` | number | Toplam süre, saniye | +| `geometry` | object | `encodedPolyline` veya `coordinates` (en az biri); `polylinePrecision` 5 (varsayılan) veya 6 | +| `waypoints` | {lat, lng}[]? | Ara duraklar | + +#### `enroute_get_route_weather` +Kayıtlı rota için güncel hava durumunu döndürür. `currentLocation` verilirse mevcut segment yeniden değerlendirilir ve sürücü programın dışındaysa ETA'lar yeniden hesaplanır. + +| Parametre | Tip | Açıklama | +|-----------|-----|----------| +| `routeId` | string (UUID) | `enroute_register_route` yanıtındaki rota ID'si | +| `currentLocation` | {lat, lng}? | Sürücünün mevcut konumu | + --- ## 🏗️ Mimari @@ -528,6 +569,7 @@ src/ ├── auth.ts # JWT token yönetimi (otomatik yenileme) ├── client.ts # HTTP API istemcisi (HMAC imzalama) ├── security.ts # HMAC-SHA256, nonce, idempotency key +├── geojson-proto.ts # Yağış geometrisi protobuf → GeoJSON çözücü └── tools/ ├── lightnings.ts ├── thunderstorms.ts @@ -537,7 +579,8 @@ src/ ├── accounts.ts ├── point-alarms.ts ├── geo-alarms.ts - └── forecast-alarms.ts + ├── forecast-alarms.ts + └── enroute.ts ``` **İstek akışı:** diff --git a/package-lock.json b/package-lock.json index 284d9be..8dd8fbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", + "protobufjs": "^8.8.0", "zod": "^3.23.8" }, "devDependencies": { @@ -804,6 +805,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -960,6 +967,18 @@ "node": ">=16.20.0" } }, + "node_modules/protobufjs": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz", + "integrity": "sha512-N3xhQ5yyBx3vQq4gubBfASzYhJGNzeDbjqBpu61g7UVylsN/qyffU96TKWD3GbbLOKF82VGNRNvv1+BFgE31Eg==", + "license": "BSD-3-Clause", + "dependencies": { + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index ccdf81c..b480342 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", + "protobufjs": "^8.8.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/src/client.ts b/src/client.ts index 96cd427..af10e86 100644 --- a/src/client.ts +++ b/src/client.ts @@ -264,3 +264,64 @@ export async function apiDelete(path: string): Promise { throw error; } } + +// POST for endpoints that answer with a binary body (e.g. protobuf). The body is never read as text, +// so only response metadata and the byte length are logged. +export async function apiPostBinary(path: string, body: unknown): Promise { + const bodyStr = JSON.stringify(body); + const headers = await buildHeaders("POST", path, bodyStr, true); + const startedAt = Date.now(); + let response: Response | undefined; + let responseHeaders: Record | undefined; + + try { + response = await fetch(`${config.baseUrl}${path}`, { + method: "POST", + headers, + body: bodyStr, + }); + responseHeaders = sanitizeHeaders(headersToRecord(response.headers)); + + if (!response.ok) { + const errorBody = await response.text(); + let msg = response.statusText; + if (errorBody) { + try { + msg = (JSON.parse(errorBody) as { message?: string }).message ?? errorBody; + } catch { + msg = errorBody; + } + } + throw new Error(`API error [${response.status}]: ${msg}`); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + logHttpRequest({ + at: new Date().toISOString(), + method: "POST", + path, + status: response.status, + ok: true, + durationMs: Date.now() - startedAt, + requestHeaders: sanitizeHeaders(headers), + requestBody: sanitizeRequestBody(bodyStr, path), + responseHeaders, + responseBody: ``, + }); + return bytes; + } catch (error) { + logHttpRequest({ + at: new Date().toISOString(), + method: "POST", + path, + status: response?.status ?? null, + ok: false, + durationMs: Date.now() - startedAt, + requestHeaders: sanitizeHeaders(headers), + requestBody: sanitizeRequestBody(bodyStr, path), + responseHeaders, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} diff --git a/src/geojson-proto.ts b/src/geojson-proto.ts new file mode 100644 index 0000000..106bad2 --- /dev/null +++ b/src/geojson-proto.ts @@ -0,0 +1,105 @@ +import protobuf from "protobufjs"; + +// Subset of BE-GeoCommons/src/main/resources/proto/geojson.proto needed to decode PrecipitationFeatureCollection. +// Embedded as text (not a .proto file) because only dist/ is shipped in the release package. Keep in sync with the source. +const PROTO_SOURCE = ` +syntax = "proto3"; +package geo; + +message Coordinate { double lng = 1; double lat = 2; } +message Ring { repeated Coordinate coordinates = 1; } +message LineStringShape { repeated Coordinate coordinates = 1; } +message PolygonShape { repeated Ring rings = 1; } +message MultiPointShape { repeated Coordinate points = 1; } +message MultiLineStringShape { repeated LineStringShape line_strings = 1; } +message MultiPolygonShape { repeated PolygonShape polygons = 1; } +message GeometryCollectionShape { repeated Geometry geometries = 1; } + +message Geometry { + enum GeometryType { + POINT = 0; + LINE_STRING = 1; + POLYGON = 2; + MULTI_POINT = 3; + MULTI_LINE_STRING = 4; + MULTI_POLYGON = 5; + GEOMETRY_COLLECTION = 6; + } + GeometryType type = 1; + oneof shape { + Coordinate point = 2; + LineStringShape line_string = 3; + PolygonShape polygon = 4; + MultiPointShape multi_point = 5; + MultiLineStringShape multi_line_string = 6; + MultiPolygonShape multi_polygon = 7; + GeometryCollectionShape geometry_collection = 8; + } +} + +message PrecipitationProperties { string intensity = 1; int64 radar_timestamp = 2; bool forecast = 3; } +message PrecipitationFeature { string id = 1; Geometry geometry = 2; PrecipitationProperties properties = 3; } +message PrecipitationFeatureCollection { repeated PrecipitationFeature features = 1; } +`; + +type Coord = { lng?: number; lat?: number }; +type Pos = [number, number]; + +const pos = (c: Coord): Pos => [c.lng ?? 0, c.lat ?? 0]; +const ring = (r: { coordinates?: Coord[] }): Pos[] => (r.coordinates ?? []).map(pos); +const polygon = (p: { rings?: { coordinates?: Coord[] }[] }): Pos[][] => (p.rings ?? []).map(ring); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function geometryToGeoJson(g: any): unknown { + switch (g.type) { + case "POINT": + return { type: "Point", coordinates: pos(g.point ?? {}) }; + case "LINE_STRING": + return { type: "LineString", coordinates: ring(g.lineString ?? {}) }; + case "POLYGON": + return { type: "Polygon", coordinates: polygon(g.polygon ?? {}) }; + case "MULTI_POINT": + return { type: "MultiPoint", coordinates: (g.multiPoint?.points ?? []).map(pos) }; + case "MULTI_LINE_STRING": + return { + type: "MultiLineString", + coordinates: (g.multiLineString?.lineStrings ?? []).map(ring), + }; + case "MULTI_POLYGON": + return { type: "MultiPolygon", coordinates: (g.multiPolygon?.polygons ?? []).map(polygon) }; + case "GEOMETRY_COLLECTION": + return { + type: "GeometryCollection", + geometries: (g.geometryCollection?.geometries ?? []).map(geometryToGeoJson), + }; + default: + throw new Error(`Unsupported geometry type in response: ${String(g.type)}`); + } +} + +let collectionType: protobuf.Type | undefined; + +export function decodePrecipitationFeatureCollection(bytes: Uint8Array): unknown { + collectionType ??= protobuf.parse(PROTO_SOURCE).root.lookupType("geo.PrecipitationFeatureCollection"); + const message = collectionType.decode(bytes); + const obj = collectionType.toObject(message, { + enums: String, + longs: Number, + defaults: true, + arrays: true, + }) as { features: Array<{ id: string; geometry: unknown; properties: { intensity: string; radarTimestamp: number; forecast: boolean } }> }; + + return { + type: "FeatureCollection", + features: obj.features.map((f) => ({ + type: "Feature", + id: f.id, + geometry: geometryToGeoJson(f.geometry), + properties: { + intensity: f.properties.intensity, + radarTimestamp: f.properties.radarTimestamp, + forecast: f.properties.forecast, + }, + })), + }; +} diff --git a/src/index.ts b/src/index.ts index 258d42b..4222d3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { accountTools, handleAccountTool } from "./tools/accounts.js"; import { pointAlarmTools, handlePointAlarmTool } from "./tools/point-alarms.js"; import { geoAlarmTools, handleGeoAlarmTool } from "./tools/geo-alarms.js"; import { forecastAlarmTools, handleForecastAlarmTool } from "./tools/forecast-alarms.js"; +import { enrouteTools, handleEnrouteTool } from "./tools/enroute.js"; import { initializeHttpLogger, logToolInvocation } from "./http-logger.js"; const toolGroups = [ @@ -26,6 +27,7 @@ const toolGroups = [ { tools: pointAlarmTools, handler: handlePointAlarmTool }, { tools: geoAlarmTools, handler: handleGeoAlarmTool }, { tools: forecastAlarmTools, handler: handleForecastAlarmTool }, + { tools: enrouteTools, handler: handleEnrouteTool }, ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/tools/enroute.ts b/src/tools/enroute.ts new file mode 100644 index 0000000..487b665 --- /dev/null +++ b/src/tools/enroute.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; +import { apiPost } from "../client.js"; + +const geoPointSchema = { + type: "object", + properties: { + lat: { type: "number", description: "Latitude in decimal degrees (-90 to 90)" }, + lng: { type: "number", description: "Longitude in decimal degrees (-180 to 180)" }, + }, + required: ["lat", "lng"], +} as const; + +export const enrouteTools = [ + { + name: "enroute_register_route", + description: + "Register a planned route for weather monitoring. The route is split into H3 segments with an ETA each; returns a routeId plus the current segment (nowcast + forecast weather) and upcoming segments (forecast weather). Use the routeId with enroute_get_route_weather. All timestamps are epoch milliseconds.", + inputSchema: { + type: "object" as const, + properties: { + origin: { ...geoPointSchema, description: "Route start point" }, + destination: { ...geoPointSchema, description: "Route end point" }, + departureTime: { + type: "number", + description: "Planned departure time in epoch milliseconds", + }, + distanceMeters: { type: "number", description: "Total route distance in meters" }, + durationSeconds: { type: "number", description: "Total route duration in seconds" }, + geometry: { + type: "object", + description: + "Route geometry. Provide encodedPolyline or coordinates (at least one).", + properties: { + encodedPolyline: { + type: "string", + description: "Encoded polyline string of the route", + }, + polylinePrecision: { + type: "number", + description: "Polyline precision: 5 (Google, default) or 6 (Mapbox)", + }, + coordinates: { + type: "array", + items: geoPointSchema, + description: "Ordered route coordinates, alternative to encodedPolyline", + }, + }, + }, + waypoints: { + type: "array", + items: geoPointSchema, + description: "Optional intermediate stops", + }, + }, + required: [ + "origin", + "destination", + "departureTime", + "distanceMeters", + "durationSeconds", + "geometry", + ], + }, + }, + { + name: "enroute_get_route_weather", + description: + "Get updated weather for a registered route. Pass the driver's current location so the current segment is re-evaluated and ETAs are recalculated when the driver is off schedule. Without currentLocation the schedule-based position is used.", + inputSchema: { + type: "object" as const, + properties: { + routeId: { + type: "string", + description: "Route ID returned by enroute_register_route", + }, + currentLocation: { ...geoPointSchema, description: "Driver's current position" }, + }, + required: ["routeId"], + }, + }, +] as const; + +const GeoPointSchema = z.object({ + lat: z.number().min(-90).max(90), + lng: z.number().min(-180).max(180), +}); + +const RegisterSchema = z.object({ + origin: GeoPointSchema, + destination: GeoPointSchema, + departureTime: z.number().int().min(0), + distanceMeters: z.number().int().min(0), + durationSeconds: z.number().int().min(0), + geometry: z + .object({ + encodedPolyline: z.string().optional(), + polylinePrecision: z.number().int().min(5).max(6).optional(), + coordinates: z.array(GeoPointSchema).optional(), + }) + .refine((g) => !!g.encodedPolyline?.trim() || (g.coordinates?.length ?? 0) > 0, { + message: "encodedPolyline or coordinates must be provided", + }), + waypoints: z.array(GeoPointSchema).optional(), +}); + +const WeatherSchema = z.object({ + routeId: z.string().uuid(), + currentLocation: GeoPointSchema.optional(), +}); + +export async function handleEnrouteTool( + name: string, + args: Record +): Promise { + switch (name) { + case "enroute_register_route": + return apiPost("/v1/enroute/register", RegisterSchema.parse(args)); + case "enroute_get_route_weather": + return apiPost("/v1/enroute/weather", WeatherSchema.parse(args)); + default: + throw new Error(`Unknown enroute tool: ${name}`); + } +} diff --git a/src/tools/precipitations.ts b/src/tools/precipitations.ts index dce6b73..5a0eb96 100644 --- a/src/tools/precipitations.ts +++ b/src/tools/precipitations.ts @@ -1,5 +1,6 @@ import { z } from "zod"; -import { apiPost } from "../client.js"; +import { apiPost, apiPostBinary } from "../client.js"; +import { decodePrecipitationFeatureCollection } from "../geojson-proto.js"; const INTENSITY_VALUES = ["DRIZZLE", "LIGHT", "MODERATE", "HEAVY", "VERY_HEAVY", "EXTREME"] as const; @@ -82,6 +83,34 @@ export const precipitationTools = [ required: ["backwardInterval", "endTimeEpoch", "intensityThreshold"], }, }, + { + name: "get_precipitation_geometry", + description: + "Get precipitation polygon geometries for given precipitation event IDs (taken from get_precipitations_within / get_precipitations_page results) as a GeoJSON FeatureCollection. Each feature is a MultiPolygon with intensity, radarTimestamp (epoch ms) and forecast properties. center and radius clip the result to a circular area; they may be omitted only for accounts with the power-user role.", + inputSchema: { + type: "object" as const, + properties: { + eventIds: { + type: "array", + items: { type: "string" }, + description: "Precipitation event IDs (1 to 10)", + }, + latitude: { + type: "number", + description: "Latitude of the clipping circle center in decimal degrees (-90 to 90)", + }, + longitude: { + type: "number", + description: "Longitude of the clipping circle center in decimal degrees (-180 to 180)", + }, + radius: { + type: "number", + description: "Clipping circle radius in meters", + }, + }, + required: ["eventIds"], + }, + }, ] as const; const WithinSchema = z.object({ @@ -103,6 +132,18 @@ const PageSchema = z.object({ pageSize: z.number().int().min(1).max(100).default(10), }); +const GeometrySchema = z + .object({ + eventIds: z.array(z.string().min(1)).min(1).max(10), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), + radius: z.number().positive().optional(), + }) + .refine((a) => (a.latitude === undefined) === (a.longitude === undefined), { + message: "latitude and longitude must be provided together", + path: ["latitude"], + }); + export async function handlePrecipitationTool( name: string, args: Record @@ -119,6 +160,17 @@ export async function handlePrecipitationTool( const params = PageSchema.parse(args); return apiPost("/v1/precipitations/page", params); } + case "get_precipitation_geometry": { + const { eventIds, latitude, longitude, radius } = GeometrySchema.parse(args); + const bytes = await apiPostBinary("/v1/precipitations/geometry", { + eventIds, + ...(latitude !== undefined && longitude !== undefined + ? { center: { lat: latitude, lng: longitude } } + : {}), + ...(radius !== undefined ? { radius } : {}), + }); + return decodePrecipitationFeatureCollection(bytes); + } default: throw new Error(`Unknown precipitation tool: ${name}`); }