feat(map): ingest audited department zone snapshot

This commit is contained in:
Codex
2026-07-21 12:02:11 +03:00
parent 3446f3edc2
commit 343812b90d
19 changed files with 76785 additions and 105 deletions
@@ -2,10 +2,29 @@ import { createHash } from "node:crypto";
import { isBoundedGeoJsonGeometry } from "./geometry.mjs";
export const ZONE_SOURCE_GENERATION_SCHEMA_VERSION = "nodedc.zone-source-generation/v1";
export const ZONE_SOURCE_GENERATION_SCHEMA_VERSION = "nodedc.zone-source-generation/v2";
export const ZONE_SOURCE_ADAPTERS = Object.freeze({
"gelios-rest-v1": Object.freeze({ sourceKind: "live_api", sourceRevision: "gelios-rest-v1" }),
"mmap-snapshot-v1": Object.freeze({ sourceKind: "versioned_snapshot", sourceRevision: "mmap-snapshot-v1" }),
"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",
}),
});
/**
@@ -24,6 +43,8 @@ export function normalizeZoneSourceGeneration(value, {
}
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");
@@ -38,7 +59,7 @@ export function normalizeZoneSourceGeneration(value, {
const facts = [];
const sourceIds = new Set();
for (const [index, rawZone] of value.zones.entries()) {
const zone = normalizeRawZone(rawZone, index, identityCrosswalk, circleSegments, value.adapterId);
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({
@@ -47,15 +68,29 @@ export function normalizeZoneSourceGeneration(value, {
observedAt: generatedAt,
geometry: zone.geometry,
attributes: compact({
area_square_meters: finiteNonNegative(first(rawZone.surfaceArea, rawZone.surface_area)),
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.name, rawZone.display_name), "zone_source_name_required"),
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.maxPermissibleSpeed, rawZone.max_permissible_speed)),
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),
}),
});
}
@@ -78,12 +113,12 @@ export function normalizeZoneSourceGeneration(value, {
});
}
function normalizeRawZone(value, index, identityCrosswalk, circleSegments, adapterId) {
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("gelios-zone-") ? nativeId : `gelios-zone-${nativeId}`;
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_kind, "polygon")).trim().toLowerCase();
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"]))) {
@@ -93,19 +128,23 @@ function normalizeRawZone(value, index, identityCrosswalk, circleSegments, adapt
}
function resolveNativeId(zone, identityCrosswalk, adapterId) {
if (adapterId === "mmap-snapshot-v1") {
const snapshotKey = optionalString(first(zone.snapshotKey, zone.snapshot_key));
if (!snapshotKey || !Object.hasOwn(identityCrosswalk, snapshotKey)) {
throw zoneSourceError("zone_source_identity_crosswalk_required");
}
const mapped = identityCrosswalk[snapshotKey];
if (mapped === undefined || mapped === null || !String(mapped).trim()) {
throw zoneSourceError("zone_source_identity_crosswalk_required");
}
return String(mapped).trim();
}
const direct = first(zone.id, zone.geozoneId, zone.zone_id, zone.sourceId);
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");
}
@@ -201,8 +240,8 @@ function corridorPolygon(points, widthMeters) {
function revision(value, adapter) {
const explicit = optionalString(value.sourceRevision);
if (value.adapterId === "mmap-snapshot-v1") {
const digest = optionalString(value.snapshotDigest);
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}`;
}
@@ -232,6 +271,30 @@ function finiteNonNegative(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);