carthorse
Version:
A geospatial trail data processing pipeline for building 3D trail databases with elevation data
418 lines • 17.2 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.GeoJSONExportStrategy = void 0;
const fs = __importStar(require("fs"));
const config_loader_1 = require("../config-loader");
class GeoJSONExportStrategy {
constructor(pgClient, config, stagingSchema) {
this.pgClient = pgClient;
this.config = config;
this.stagingSchema = stagingSchema;
this.exportConfig = (0, config_loader_1.getExportConfig)();
}
log(message) {
if (this.config.verbose) {
console.log(`[GeoJSON Export] ${message}`);
}
}
/**
* Export all data from staging schema to GeoJSON
*/
async exportFromStaging() {
console.log('📤 Exporting from staging schema to GeoJSON...');
const features = [];
const layers = this.exportConfig.geojson?.layers || {
trails: true,
edges: true,
nodes: true,
routes: true
};
// Export trails
if (layers.trails && this.config.includeTrails !== false) {
const trailFeatures = await this.exportTrails();
features.push(...trailFeatures);
this.log(`✅ Exported ${trailFeatures.length} trails`);
}
// Export nodes
if (layers.nodes && this.config.includeNodes) {
const nodeFeatures = await this.exportNodes();
features.push(...nodeFeatures);
this.log(`✅ Exported ${nodeFeatures.length} nodes`);
}
// Export edges
if (layers.edges && this.config.includeEdges) {
const edgeFeatures = await this.exportEdges();
features.push(...edgeFeatures);
this.log(`✅ Exported ${edgeFeatures.length} edges`);
}
// Export recommendations/routes
if (layers.routes && this.config.includeRecommendations) {
const recommendationFeatures = await this.exportRecommendations();
features.push(...recommendationFeatures);
this.log(`✅ Exported ${recommendationFeatures.length} routes`);
}
// Create GeoJSON collection
const geojson = {
type: 'FeatureCollection',
features: features
};
// Write to file
fs.writeFileSync(this.config.outputPath, JSON.stringify(geojson, null, 2));
console.log(`✅ GeoJSON export completed: ${this.config.outputPath}`);
console.log(` - Total features: ${features.length}`);
}
/**
* Export trails from staging schema
*/
async exportTrails() {
const trailsResult = await this.pgClient.query(`
SELECT
app_uuid, name, osm_id, trail_type, surface as surface_type,
CASE
WHEN difficulty = 'unknown' THEN 'moderate'
ELSE difficulty
END as difficulty,
ST_AsGeoJSON(geometry, 6, 0) as geojson,
length_km, elevation_gain, elevation_loss, max_elevation, min_elevation, avg_elevation,
bbox_min_lng, bbox_max_lng, bbox_min_lat, bbox_max_lat,
created_at, updated_at
FROM ${this.stagingSchema}.split_trails
ORDER BY name
`);
const trailStyling = this.exportConfig.geojson?.styling?.trails || {
color: "#228B22",
stroke: "#228B22",
strokeWidth: 3,
opacity: 0.8
};
return trailsResult.rows.map(trail => ({
type: "Feature",
geometry: JSON.parse(trail.geojson),
properties: {
type: "trail",
id: trail.app_uuid,
name: trail.name,
region: this.config.region,
osm_id: trail.osm_id,
trail_type: trail.trail_type,
surface_type: trail.surface_type,
difficulty: trail.difficulty,
length_km: trail.length_km,
elevation_gain: trail.elevation_gain,
elevation_loss: trail.elevation_loss,
max_elevation: trail.max_elevation,
min_elevation: trail.min_elevation,
avg_elevation: trail.avg_elevation,
bbox_min_lng: trail.bbox_min_lng,
bbox_max_lng: trail.bbox_max_lng,
bbox_min_lat: trail.bbox_min_lat,
bbox_max_lat: trail.bbox_max_lat,
created_at: trail.created_at,
updated_at: trail.updated_at,
...trailStyling
}
}));
}
/**
* Export nodes from staging schema
*/
async exportNodes() {
try {
const nodesResult = await this.pgClient.query(`
WITH deg AS (
SELECT v.id,
v.the_geom,
COALESCE(src.cnt,0) + COALESCE(tgt.cnt,0) AS degree
FROM ${this.stagingSchema}.ways_noded_vertices_pgr v
LEFT JOIN (
SELECT source AS id, COUNT(*) AS cnt FROM ${this.stagingSchema}.ways_noded GROUP BY source
) src ON src.id = v.id
LEFT JOIN (
SELECT target AS id, COUNT(*) AS cnt FROM ${this.stagingSchema}.ways_noded GROUP BY target
) tgt ON tgt.id = v.id
)
SELECT
id,
id as node_uuid,
ST_Y(the_geom) as lat,
ST_X(the_geom) as lng,
COALESCE(ST_Z(the_geom), 0) as elevation,
degree,
CASE
WHEN degree = 0 THEN 'isolate'
WHEN degree = 1 THEN 'endpoint'
WHEN degree >= 3 THEN 'intersection'
ELSE 'mid'
END as node_type,
'' as connected_trails,
ST_AsGeoJSON(the_geom, 6, 0) as geojson
FROM deg
ORDER BY id
`);
const defaultNodeStyling = this.exportConfig.geojson?.styling?.nodes || {};
const colorByType = {
isolate: '#FF3B30',
endpoint: '#000000',
intersection: '#2ECC40',
mid: '#000000'
};
const radiusByType = {
isolate: 2,
endpoint: 2,
intersection: 3,
mid: 2
};
return nodesResult.rows.map((node) => {
const t = node.node_type;
const color = colorByType[t] || defaultNodeStyling.color || '#FF0000';
const radius = radiusByType[t] ?? defaultNodeStyling.radius ?? 2;
const strokeWidth = defaultNodeStyling.strokeWidth ?? 1;
const fillOpacity = defaultNodeStyling.fillOpacity ?? 0.8;
return {
type: 'Feature',
properties: {
id: node.id,
node_uuid: node.node_uuid,
lat: node.lat,
lng: node.lng,
elevation: node.elevation,
degree: node.degree,
node_type: node.node_type,
connected_trails: node.connected_trails,
type: 'node',
color,
stroke: color,
strokeWidth,
fillOpacity,
radius
},
geometry: JSON.parse(node.geojson)
};
});
}
catch (error) {
this.log(`⚠️ ways_noded_vertices_pgr table not found, skipping nodes export`);
return [];
}
}
/**
* Export edges from staging schema
*/
async exportEdges() {
try {
const edgesResult = await this.pgClient.query(`
SELECT
id,
source,
target,
app_uuid as trail_id,
name as trail_name,
COALESCE(length_km, ST_Length(the_geom::geography) / 1000) as length_km,
COALESCE(elevation_gain, 0) as elevation_gain,
COALESCE(elevation_loss, 0) as elevation_loss,
ST_AsGeoJSON(the_geom, 6, 0) as geojson
FROM ${this.stagingSchema}.ways_noded
WHERE source IS NOT NULL AND target IS NOT NULL
ORDER BY id
`);
const edgeStyling = this.exportConfig.geojson?.styling?.edges || {
color: "#4169E1",
stroke: "#4169E1",
strokeWidth: 1,
fillOpacity: 0.4
};
return edgesResult.rows.map((edge) => ({
type: 'Feature',
properties: {
id: edge.id,
source: edge.source,
target: edge.target,
trail_id: edge.trail_id,
trail_name: edge.trail_name,
length_km: edge.length_km,
elevation_gain: edge.elevation_gain,
elevation_loss: edge.elevation_loss,
created_at: edge.created_at,
type: 'edge',
color: edgeStyling.color,
stroke: edgeStyling.stroke,
strokeWidth: edgeStyling.strokeWidth,
fillOpacity: edgeStyling.fillOpacity
},
geometry: JSON.parse(edge.geojson)
}));
}
catch (error) {
this.log(`⚠️ ways_noded table not found, skipping edges export`);
return [];
}
}
/**
* Export recommendations from staging schema
*/
async exportRecommendations() {
try {
console.log(`[GeoJSON Export] 🔍 Checking for route_recommendations in schema: ${this.stagingSchema}`);
// First check if the table exists
const tableExists = await this.pgClient.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = $1 AND table_name = 'route_recommendations'
);
`, [this.stagingSchema]);
console.log(`[GeoJSON Export] Table exists check result:`, tableExists.rows[0]);
const recommendationsResult = await this.pgClient.query(`
SELECT
route_uuid, region, input_length_km, input_elevation_gain,
recommended_length_km, recommended_elevation_gain,
route_score, route_type, route_name, route_shape, trail_count,
route_path, route_edges, created_at
FROM ${this.stagingSchema}.route_recommendations
ORDER BY created_at DESC
`);
console.log(`[GeoJSON Export] Found ${recommendationsResult.rows.length} route recommendations`);
const routeStyling = this.exportConfig.geojson?.styling?.routes || {
color: '#FF8C00',
stroke: '#FF8C00',
strokeWidth: 10,
fillOpacity: 1.0,
zIndex: 1000,
dashArray: '6,4'
};
const features = [];
for (const rec of recommendationsResult.rows) {
// Extract coordinates from ways_noded table using edge IDs from route_path
let coordinates = [];
try {
if (rec.route_path) {
// route_path is already a JavaScript object, not a JSON string
const routePath = typeof rec.route_path === 'string' ? JSON.parse(rec.route_path) : rec.route_path;
const edgeIds = this.extractEdgeIdsFromRoutePath(routePath);
if (edgeIds.length > 0) {
// Query ways_noded directly for the route geometry and combine using PostGIS
// Use CASE statement to preserve the order of edges as they appear in the route
const edgesResult = await this.pgClient.query(`
SELECT ST_AsGeoJSON(
ST_LineMerge(
ST_Collect(
the_geom ORDER BY
CASE id
${edgeIds.map((id, index) => `WHEN ${id} THEN ${index}`).join(' ')}
ELSE 999999
END
)
), 6, 0
) as geojson
FROM ${this.stagingSchema}.ways_noded
WHERE id = ANY($1::integer[])
`, [edgeIds]);
// Extract coordinates from the combined geometry
if (edgesResult.rows.length > 0 && edgesResult.rows[0].geojson) {
const geom = JSON.parse(edgesResult.rows[0].geojson);
if (geom.coordinates && Array.isArray(geom.coordinates)) {
coordinates = geom.coordinates;
}
}
}
}
}
catch (error) {
this.log(`⚠️ Failed to extract coordinates for route ${rec.route_uuid}: ${error}`);
// Skip this route - no fallback coordinates
continue;
}
features.push({
type: 'Feature',
properties: {
id: rec.route_uuid,
route_uuid: rec.route_uuid,
region: rec.region,
input_length_km: rec.input_length_km,
input_elevation_gain: rec.input_elevation_gain,
recommended_length_km: rec.recommended_length_km,
recommended_elevation_gain: rec.recommended_elevation_gain,
route_score: rec.route_score,
route_type: rec.route_type,
route_name: rec.route_name,
route_shape: rec.route_shape,
trail_count: rec.trail_count,
route_path: rec.route_path,
route_edges: rec.route_edges,
created_at: rec.created_at,
type: 'route',
color: routeStyling.color,
stroke: routeStyling.stroke,
strokeWidth: routeStyling.strokeWidth,
fillOpacity: routeStyling.fillOpacity,
zIndex: routeStyling.zIndex || 1000,
dashArray: routeStyling.dashArray,
layer: 'routes',
layerOrder: 4 // Routes on top (1=trails, 2=edges, 3=nodes, 4=routes)
},
geometry: {
type: 'LineString',
coordinates: coordinates
}
});
}
return features;
}
catch (error) {
this.log(`⚠️ route_recommendations table not found, skipping recommendations export`);
this.log(`⚠️ Error details: ${error}`);
return [];
}
}
/**
* Extract edge IDs from route path JSON
*/
extractEdgeIdsFromRoutePath(routePath) {
try {
if (routePath.steps && Array.isArray(routePath.steps)) {
return routePath.steps
.map((step) => step.edge)
.filter((edge) => edge !== -1 && edge !== null && edge !== undefined);
}
return [];
}
catch (error) {
this.log(`⚠️ Failed to extract edge IDs from route path: ${error}`);
return [];
}
}
}
exports.GeoJSONExportStrategy = GeoJSONExportStrategy;
//# sourceMappingURL=geojson-export-strategy.js.map