import { createHash } from "node:crypto"; import { isBoundedGeoJsonGeometry } from "./geometry.mjs"; export const ZONE_SOURCE_GENERATION_SCHEMA_VERSION = "nodedc.zone-source-generation/v2"; export const ZONE_SOURCE_ADAPTERS = Object.freeze({ "depttrans-api-snapshot-v1": Object.freeze({ authorityId: "moscow-department-of-transport", datasetId: "pmd-slow-zones", sourceIdPrefix: "depttrans-pmd-slow-zone-", sourceKind: "versioned_snapshot", sourceRevision: "depttrans-api-snapshot-v1", }), "depttrans-mmap-snapshot-v1": Object.freeze({ authorityId: "moscow-department-of-transport", datasetId: "pmd-slow-zones", sourceIdPrefix: "depttrans-pmd-slow-zone-", sourceKind: "versioned_snapshot", sourceRevision: "depttrans-mmap-snapshot-v1", }), "gelios-rest-v1": Object.freeze({ authorityId: "gelios-account", datasetId: "gelios-geofences", sourceIdPrefix: "gelios-zone-", sourceKind: "live_api", sourceRevision: "gelios-rest-v1", }), }); /** * Convert one already-complete source generation into map.zone facts. Fetching, * credentials and page iteration stay in Engine; this function is the common * adapter boundary used by both live REST and immutable snapshot sources. */ export function normalizeZoneSourceGeneration(value, { identityCrosswalk = {}, maxZones = 5000, maxBytes = 16 * 1024 * 1024, circleSegments = 64, } = {}) { if (!isPlainObject(value) || value.schemaVersion !== ZONE_SOURCE_GENERATION_SCHEMA_VERSION) { throw zoneSourceError("zone_source_generation_schema_invalid"); } const adapter = ZONE_SOURCE_ADAPTERS[value.adapterId]; if (!adapter) throw zoneSourceError("zone_source_adapter_unsupported"); if (value.authorityId !== adapter.authorityId) throw zoneSourceError("zone_source_authority_mismatch"); if (value.datasetId !== adapter.datasetId) throw zoneSourceError("zone_source_dataset_mismatch"); if (!isPlainObject(identityCrosswalk)) throw zoneSourceError("zone_source_identity_crosswalk_invalid"); if (value.complete !== true) throw zoneSourceError("zone_source_generation_incomplete"); const generatedAt = iso(value.generatedAt, "zone_source_generated_at_invalid"); if (!Array.isArray(value.zones) || value.zones.length > maxZones) { throw zoneSourceError("zone_source_zone_limit_exceeded"); } if (byteLength(value) > maxBytes) throw zoneSourceError("zone_source_generation_bytes_exceeded"); if (!Number.isInteger(circleSegments) || circleSegments < 16 || circleSegments > 256) { throw zoneSourceError("zone_source_circle_segments_invalid"); } const facts = []; const sourceIds = new Set(); for (const [index, rawZone] of value.zones.entries()) { const zone = normalizeRawZone(rawZone, index, identityCrosswalk, circleSegments, value.adapterId, adapter); if (sourceIds.has(zone.sourceId)) throw zoneSourceError("zone_source_identity_duplicate"); sourceIds.add(zone.sourceId); facts.push({ sourceId: zone.sourceId, semanticType: "map.zone", observedAt: generatedAt, geometry: zone.geometry, attributes: compact({ area_square_meters: finiteNonNegative(first(rawZone.areaSquareMeters, rawZone.surfaceArea, rawZone.surface_area, rawZone.area)), description: optionalString(first(rawZone.description, rawZone.descr)), display_name: requiredString(first(rawZone.displayName, rawZone.name, rawZone.display_name), "zone_source_name_required"), applies_to_couriers: optionalBoolean(first(rawZone.appliesToCouriers, rawZone.courier)), applies_to_kicksharing: optionalBoolean(first(rawZone.appliesToKicksharing, rawZone.kick_sharing)), dataset_authority: adapter.authorityId, dataset_id: adapter.datasetId, geometry_kind: zone.geometryKind, max_speed_kph: finiteNonNegative(first( rawZone.maxSpeedKph, rawZone.max_speed_kph, rawZone.maxPermissibleSpeed, rawZone.max_permissible_speed, )), perimeter_meters: finiteNonNegative(rawZone.perimeter), schedule_timezone: optionalString(first(rawZone.scheduleTimezone, value.timezone)), source_adapter: value.adapterId, source_created_at: optionalString(first(rawZone.sourceCreatedAt, rawZone.create_dttm)), source_kind: adapter.sourceKind, source_revision: revision(value, adapter), source_updated_at: optionalString(first(rawZone.sourceUpdatedAt, rawZone.update_dttm)), style_color: optionalString(first(rawZone.color, rawZone.style_color)), weekly_speed_schedule: normalizeWeeklySchedule(rawZone.weeklySpeedSchedule), }), }); } facts.sort((left, right) => left.sourceId.localeCompare(right.sourceId)); const digest = createHash("sha256").update(stableJson({ adapterId: value.adapterId, generatedAt, revision: revision(value, adapter), facts, })).digest("hex"); return Object.freeze({ schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION, adapterId: value.adapterId, sourceKind: adapter.sourceKind, sourceRevision: revision(value, adapter), generatedAt, complete: true, digest, facts: Object.freeze(facts.map(deepFreeze)), }); } function normalizeRawZone(value, index, identityCrosswalk, circleSegments, adapterId, adapter) { if (!isPlainObject(value)) throw zoneSourceError(`zone_source_zone_${index}_invalid`); const nativeId = resolveNativeId(value, identityCrosswalk, adapterId); const sourceId = nativeId.startsWith(adapter.sourceIdPrefix) ? nativeId : `${adapter.sourceIdPrefix}${nativeId}`; if (!/^[a-z][a-z0-9._:-]{2,127}$/.test(sourceId)) throw zoneSourceError("zone_source_identity_invalid"); const rawType = String(first(value.type, value.geometry?.type, value.geometry_kind, "polygon")).trim().toLowerCase(); const geometryKind = rawType === "circle" ? "circle" : rawType === "line" || rawType === "corridor" ? "corridor" : "polygon"; const geometry = normalizeGeometry(value, geometryKind, circleSegments); if (!isBoundedGeoJsonGeometry(geometry, new Set(["Polygon", "MultiPolygon"]))) { throw zoneSourceError(`zone_source_zone_${index}_geometry_invalid`); } return { sourceId, geometryKind, geometry }; } function resolveNativeId(zone, identityCrosswalk, adapterId) { const direct = first( zone.nativeId, zone.pmd_slow_zone_id, zone.id, zone.geozoneId, zone.zone_id, zone.sourceId, ); if (direct !== undefined && direct !== null && String(direct).trim()) return String(direct).trim(); if (adapterId === "depttrans-mmap-snapshot-v1" || adapterId === "depttrans-api-snapshot-v1") { const snapshotKey = optionalString(first(zone.snapshotKey, zone.snapshot_key)); if (snapshotKey && Object.hasOwn(identityCrosswalk, snapshotKey)) { const mapped = identityCrosswalk[snapshotKey]; if (mapped !== undefined && mapped !== null && String(mapped).trim()) return String(mapped).trim(); } throw zoneSourceError("zone_source_identity_crosswalk_required"); } throw zoneSourceError("zone_source_native_identity_required"); } function normalizeGeometry(zone, geometryKind, circleSegments) { if (isBoundedGeoJsonGeometry(zone.geometry, new Set(["Polygon", "MultiPolygon"]))) { return structuredClone(zone.geometry); } const points = parseCoordinates(first(zone.points, zone.coords, zone.coordinates)); if (geometryKind === "circle") { if (!points.length) throw zoneSourceError("zone_source_circle_center_required"); const radius = finiteNonNegative(zone.radius); if (!radius || radius <= 0) throw zoneSourceError("zone_source_circle_radius_required"); return circlePolygon(points[0], radius, circleSegments); } if (geometryKind === "corridor") { const linePoints = parseCoordinates(first(zone.line, zone.points, zone.coords, zone.coordinates)); const width = finiteNonNegative(first(zone.lineWidthMeters, zone.line_width_meters, zone.width, zone.radius)); if (linePoints.length < 2 || !width || width <= 0) { throw zoneSourceError("zone_source_corridor_path_and_width_required"); } return corridorPolygon(linePoints, width); } if (points.length < 3) throw zoneSourceError("zone_source_polygon_points_required"); const ring = [...points]; if (!samePosition(ring[0], ring.at(-1))) ring.push([...ring[0]]); return { type: "Polygon", coordinates: [ring] }; } function parseCoordinates(value) { if (typeof value === "string") { return value.split(";").map((entry) => entry.trim()).filter(Boolean).map((entry) => { const [latitude, longitude] = entry.split(",").map(Number); return position(longitude, latitude); }); } if (!Array.isArray(value)) return []; return value.map((entry) => { if (Array.isArray(entry)) { const [latitude, longitude] = entry.map(Number); return position(longitude, latitude); } if (!isPlainObject(entry)) throw zoneSourceError("zone_source_coordinate_invalid"); return position( firstNumber(entry.longitude, entry.lon, entry.lng, entry.x), firstNumber(entry.latitude, entry.lat, entry.y), ); }); } function circlePolygon([longitude, latitude], radiusMeters, segments) { const earthRadius = 6_371_008.8; const angularDistance = radiusMeters / earthRadius; const latitudeRad = radians(latitude); const longitudeRad = radians(longitude); const ring = []; for (let index = 0; index <= segments; index += 1) { const bearing = 2 * Math.PI * index / segments; const targetLatitude = Math.asin( Math.sin(latitudeRad) * Math.cos(angularDistance) + Math.cos(latitudeRad) * Math.sin(angularDistance) * Math.cos(bearing), ); const targetLongitude = longitudeRad + Math.atan2( Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitudeRad), Math.cos(angularDistance) - Math.sin(latitudeRad) * Math.sin(targetLatitude), ); ring.push([degrees(targetLongitude), degrees(targetLatitude)]); } ring[ring.length - 1] = [...ring[0]]; return { type: "Polygon", coordinates: [ring] }; } function corridorPolygon(points, widthMeters) { const halfWidth = widthMeters / 2; const left = []; const right = []; for (let index = 0; index < points.length; index += 1) { const previous = points[Math.max(0, index - 1)]; const next = points[Math.min(points.length - 1, index + 1)]; const meanLatitude = radians((previous[1] + next[1]) / 2); const dx = (next[0] - previous[0]) * Math.cos(meanLatitude); const dy = next[1] - previous[1]; const length = Math.hypot(dx, dy); if (!length) throw zoneSourceError("zone_source_corridor_zero_length_segment"); const metersPerDegreeLatitude = 111_320; const longitudeOffset = (-dy / length) * halfWidth / (metersPerDegreeLatitude * Math.max(0.01, Math.cos(radians(points[index][1])))); const latitudeOffset = (dx / length) * halfWidth / metersPerDegreeLatitude; left.push([points[index][0] + longitudeOffset, points[index][1] + latitudeOffset]); right.push([points[index][0] - longitudeOffset, points[index][1] - latitudeOffset]); } const ring = [...left, ...right.reverse(), [...left[0]]]; return { type: "Polygon", coordinates: [ring] }; } function revision(value, adapter) { const explicit = optionalString(value.sourceRevision); if (adapter.sourceKind === "versioned_snapshot") { const digest = optionalString(first(value.contentDigest, value.snapshotDigest)); if (!digest || !/^[a-f0-9]{64}$/.test(digest)) throw zoneSourceError("zone_source_snapshot_digest_required"); return `${explicit || adapter.sourceRevision}@sha256:${digest}`; } return explicit || adapter.sourceRevision; } function position(longitude, latitude) { if (![longitude, latitude].every(Number.isFinite) || longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) { throw zoneSourceError("zone_source_coordinate_invalid"); } return [longitude, latitude]; } function first(...values) { return values.find((value) => value !== undefined && value !== null && value !== ""); } function firstNumber(...values) { const value = first(...values); const number = typeof value === "number" ? value : Number(String(value ?? "").trim()); return Number.isFinite(number) ? number : Number.NaN; } function finiteNonNegative(value) { const number = firstNumber(value); return Number.isFinite(number) && number >= 0 ? number : undefined; } function optionalBoolean(value) { return typeof value === "boolean" ? value : undefined; } function normalizeWeeklySchedule(value) { if (value === undefined || value === null) return undefined; if (!Array.isArray(value)) throw zoneSourceError("zone_source_weekly_schedule_invalid"); const entries = value.map((entry) => { if (!isPlainObject(entry)) throw zoneSourceError("zone_source_weekly_schedule_invalid"); const day = Number(entry.dayOfWeek); const speed = Number(entry.speedLimitKph); const start = optionalString(entry.startTime); const end = optionalString(entry.endTime); if (!Number.isInteger(day) || day < 1 || day > 7 || !Number.isFinite(speed) || speed < 0 || speed > 200 || !/^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(start || "") || !/^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(end || "")) { throw zoneSourceError("zone_source_weekly_schedule_invalid"); } return `${day}@${start}-${end}=${speed}`; }); return [...new Set(entries)].sort(); } function requiredString(value, code) { const result = optionalString(value); if (!result) throw zoneSourceError(code); return result; } function optionalString(value) { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function iso(value, code) { const date = new Date(String(value ?? "")); if (Number.isNaN(date.getTime())) throw zoneSourceError(code); return date.toISOString(); } function compact(value) { return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)); } function samePosition(left, right) { return Array.isArray(left) && Array.isArray(right) && left[0] === right[0] && left[1] === right[1]; } function stableJson(value) { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (isPlainObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; return JSON.stringify(value); } function byteLength(value) { try { return Buffer.byteLength(JSON.stringify(value)); } catch { return Number.POSITIVE_INFINITY; } } function deepFreeze(value) { if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; Object.freeze(value); Object.values(value).forEach(deepFreeze); return value; } function isPlainObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function radians(value) { return value * Math.PI / 180; } function degrees(value) { return value * 180 / Math.PI; } function zoneSourceError(code) { return Object.assign(new Error(code), { code }); }