feat(platform): add replaceable geozone data layer
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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_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" }),
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 (!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);
|
||||
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.surfaceArea, rawZone.surface_area)),
|
||||
description: optionalString(first(rawZone.description, rawZone.descr)),
|
||||
display_name: requiredString(first(rawZone.name, rawZone.display_name), "zone_source_name_required"),
|
||||
geometry_kind: zone.geometryKind,
|
||||
max_speed_kph: finiteNonNegative(first(rawZone.maxPermissibleSpeed, rawZone.max_permissible_speed)),
|
||||
perimeter_meters: finiteNonNegative(rawZone.perimeter),
|
||||
source_kind: adapter.sourceKind,
|
||||
source_revision: revision(value, adapter),
|
||||
style_color: optionalString(first(rawZone.color, rawZone.style_color)),
|
||||
}),
|
||||
});
|
||||
}
|
||||
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) {
|
||||
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}`;
|
||||
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 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) {
|
||||
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);
|
||||
if (direct !== undefined && direct !== null && String(direct).trim()) return String(direct).trim();
|
||||
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 (value.adapterId === "mmap-snapshot-v1") {
|
||||
const digest = optionalString(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 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user