349 lines
15 KiB
JavaScript
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 });
|
|
}
|