feat(map): ingest audited department zone snapshot
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user