Files
NODEDC_PLATFORM/packages/external-provider-contract/src/zone-source.mjs
T

349 lines
15 KiB
JavaScript

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