feat(platform): add replaceable geozone data layer

This commit is contained in:
Codex
2026-07-20 21:48:27 +03:00
parent 8a7465cf0e
commit 3446f3edc2
31 changed files with 1342 additions and 128 deletions
@@ -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 });
}