feat(map): cache and search reference stations
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("usage: generate-reference-station-seed.mjs <input.geojson> <output.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const source = JSON.parse(await readFile(resolve(inputPath), "utf8"));
|
||||
if (source?.type !== "FeatureCollection" || !Array.isArray(source.features)) {
|
||||
throw new Error("station_seed_feature_collection_required");
|
||||
}
|
||||
|
||||
const generatedAt = isIso(source.timestamp) ? new Date(source.timestamp).toISOString() : new Date(0).toISOString();
|
||||
const facts = source.features.map((feature, index) => normalizeFeature(feature, generatedAt, index));
|
||||
facts.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
|
||||
const contentDigest = createHash("sha256")
|
||||
.update(JSON.stringify(facts))
|
||||
.digest("hex");
|
||||
const counts = Object.fromEntries(["metro", "railway_station", "railway_terminal"].map((category) => [
|
||||
category,
|
||||
facts.filter((fact) => fact.attributes.category === category).length,
|
||||
]));
|
||||
const document = {
|
||||
schemaVersion: "nodedc.map-reference-seed/v1",
|
||||
profileId: "transport-stations.v1",
|
||||
sourceRevision: `seed-${contentDigest.slice(0, 16)}`,
|
||||
generatedAt,
|
||||
contentDigest,
|
||||
complete: true,
|
||||
facts,
|
||||
metadata: {
|
||||
factCount: facts.length,
|
||||
categoryCounts: counts,
|
||||
},
|
||||
};
|
||||
|
||||
await mkdir(dirname(resolve(outputPath)), { recursive: true });
|
||||
await writeFile(resolve(outputPath), `${JSON.stringify(document)}\n`, "utf8");
|
||||
console.log(JSON.stringify({ output: resolve(outputPath), ...document.metadata, contentDigest }));
|
||||
|
||||
function normalizeFeature(feature, generatedAt, index) {
|
||||
if (feature?.type !== "Feature" || feature?.geometry?.type !== "Point") {
|
||||
throw new Error(`station_seed_feature_${index}_invalid`);
|
||||
}
|
||||
const coordinates = feature.geometry.coordinates;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2) {
|
||||
throw new Error(`station_seed_feature_${index}_coordinates_invalid`);
|
||||
}
|
||||
const longitude = Number(coordinates[0]);
|
||||
const latitude = Number(coordinates[1]);
|
||||
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180
|
||||
|| !Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
|
||||
throw new Error(`station_seed_feature_${index}_coordinates_invalid`);
|
||||
}
|
||||
const properties = feature.properties && typeof feature.properties === "object" ? feature.properties : {};
|
||||
const category = normalizeCategory(properties.sort);
|
||||
const nativeId = String(feature.id || properties["@id"] || "").trim();
|
||||
const identity = nativeId.match(/^(node|way|relation)\/([1-9]\d*)$/);
|
||||
if (!identity) throw new Error(`station_seed_feature_${index}_identity_invalid`);
|
||||
const attributes = compact({
|
||||
name: firstString(properties.name, properties["name:ru"], properties.official_name, properties.loc_name),
|
||||
category,
|
||||
network: optionalString(properties.network),
|
||||
operator: optionalString(properties.operator),
|
||||
official_name: optionalString(properties.official_name),
|
||||
local_name: optionalString(properties.loc_name),
|
||||
uic_ref: optionalString(properties.uic_ref),
|
||||
wheelchair: optionalString(properties.wheelchair),
|
||||
});
|
||||
if (!attributes.name) throw new Error(`station_seed_feature_${index}_name_required`);
|
||||
return {
|
||||
sourceId: `osm.${identity[1]}.${identity[2]}`,
|
||||
semanticType: category === "railway_terminal" ? "map.terminal" : "map.station",
|
||||
observedAt: generatedAt,
|
||||
receivedAt: generatedAt,
|
||||
attributes,
|
||||
geometry: { type: "Point", coordinates: [longitude, latitude] },
|
||||
presentationStatus: "active",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCategory(value) {
|
||||
if (value === "metro") return "metro";
|
||||
if (value === "terminal") return "railway_terminal";
|
||||
if (value === "rzd_station") return "railway_station";
|
||||
throw new Error("station_seed_category_invalid");
|
||||
}
|
||||
|
||||
function firstString(...values) {
|
||||
return values.map(optionalString).find(Boolean);
|
||||
}
|
||||
|
||||
function optionalString(value) {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.trim();
|
||||
return normalized && normalized.length <= 256 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function compact(value) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
||||
}
|
||||
|
||||
function isIso(value) {
|
||||
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
Reference in New Issue
Block a user