carthorse
Version:
A geospatial trail data processing pipeline for building 3D trail databases with elevation data
806 lines • 40.3 kB
JavaScript
;
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.RoutePatternSqlHelpers = void 0;
const route_discovery_config_loader_1 = require("../../config/route-discovery-config-loader");
class RoutePatternSqlHelpers {
constructor(pgClient) {
this.pgClient = pgClient;
this.graphSigCache = null;
this.configLoader = route_discovery_config_loader_1.RouteDiscoveryConfigLoader.getInstance();
}
async getGraphSignature(stagingSchema, region, bbox) {
if (this.graphSigCache)
return this.graphSigCache;
const { getNetworkCacheConfig } = await Promise.resolve().then(() => __importStar(require('../config-loader')));
const cacheCfg = getNetworkCacheConfig();
if (!cacheCfg.enableCompletedNetworkCache) {
this.graphSigCache = 'nocache';
return this.graphSigCache;
}
const { RouteCacheService } = await Promise.resolve().then(() => __importStar(require('../cache/route-cache')));
const cache = new RouteCacheService(this.pgClient, cacheCfg.cacheSchema);
await cache.ensureSchemaAndTables();
this.graphSigCache = await cache.computeGraphSignature(stagingSchema, region, bbox);
return this.graphSigCache;
}
/**
* Load out-and-back route patterns
*/
async loadOutAndBackPatterns() {
console.log('📋 Loading out-and-back route patterns...');
const patternsResult = await this.pgClient.query(`
SELECT * FROM public.route_patterns
WHERE route_shape = 'out-and-back'
ORDER BY target_distance_km DESC
`);
const patterns = patternsResult.rows;
console.log(`✅ Loaded ${patterns.length} out-and-back route patterns`);
console.log('🔍 Out-and-back patterns to process:');
for (const pattern of patterns) {
console.log(` - ${pattern.pattern_name}: ${pattern.target_distance_km}km, ${pattern.target_elevation_gain}m elevation`);
}
if (patterns.length === 0) {
throw new Error('No out-and-back patterns found');
}
return patterns;
}
/**
* Load loop route patterns
*/
async loadLoopPatterns() {
console.log('📋 Loading loop route patterns...');
const patternsResult = await this.pgClient.query(`
SELECT * FROM public.route_patterns
WHERE route_shape = 'loop'
ORDER BY target_distance_km DESC
`);
const patterns = patternsResult.rows;
console.log(`✅ Loaded ${patterns.length} loop route patterns`);
console.log('🔍 Loop patterns to process (largest first):');
for (const pattern of patterns) {
console.log(` - ${pattern.pattern_name}: ${pattern.target_distance_km}km, ${pattern.target_elevation_gain}m elevation`);
}
if (patterns.length === 0) {
throw new Error('No loop patterns found');
}
return patterns;
}
/**
* Load point-to-point route patterns
*/
async loadPointToPointPatterns() {
console.log('📋 Loading point-to-point route patterns...');
const patternsResult = await this.pgClient.query(`
SELECT * FROM public.route_patterns
WHERE route_shape = 'point-to-point'
ORDER BY target_distance_km DESC
`);
const patterns = patternsResult.rows;
console.log(`✅ Loaded ${patterns.length} point-to-point route patterns`);
console.log('🔍 Point-to-point patterns to process (largest first):');
for (const pattern of patterns) {
console.log(` - ${pattern.pattern_name}: ${pattern.target_distance_km}km, ${pattern.target_elevation_gain}m elevation`);
}
if (patterns.length === 0) {
console.log('⚠️ No point-to-point patterns found - this is normal for some regions');
return [];
}
return patterns;
}
/**
* Generate loop routes using pgRouting's hawickcircuits with improved tolerance handling
* This finds all cycles in the graph that meet distance/elevation criteria
*/
async generateLoopRoutes(stagingSchema, targetDistance, targetElevation, tolerancePercent = 20) {
console.log(`🔄 Generating loop routes: ${targetDistance}km, ${targetElevation}m elevation (with ${tolerancePercent}% tolerance)`);
// Calculate tolerance ranges
const minDistance = targetDistance * (1 - tolerancePercent / 100);
const maxDistance = targetDistance * (1 + tolerancePercent / 100);
const minElevation = targetElevation * (1 - tolerancePercent / 100);
const maxElevation = targetElevation * (1 + tolerancePercent / 100);
console.log(`📏 Distance range: ${minDistance.toFixed(1)}-${maxDistance.toFixed(1)}km`);
console.log(`⛰️ Elevation range: ${minElevation.toFixed(0)}-${maxElevation.toFixed(0)}m`);
// For larger loops (10+km), use a different approach with tolerance
if (targetDistance >= 10) {
console.log(`🔍 Using large loop detection with ${tolerancePercent}% tolerance for ${targetDistance}km target`);
return await this.generateLargeLoops(stagingSchema, targetDistance, targetElevation, tolerancePercent);
}
// For smaller loops, use hawickcircuits (keeping original approach for now)
console.log(`🔍 Using hawickcircuits for smaller loops`);
const cyclesResult = await this.pgClient.query(`
SELECT
path_id as cycle_id,
edge as edge_id,
cost,
agg_cost,
path_seq
FROM pgr_hawickcircuits(
'SELECT id, source, target, length_km as cost FROM ${stagingSchema}.ways_noded'
)
ORDER BY path_id, path_seq
`);
console.log(`🔍 Found ${cyclesResult.rows.length} total edges in cycles with tolerance`);
// Debug: Show some cycle details
if (cyclesResult.rows.length > 0) {
const uniqueCycles = new Set(cyclesResult.rows.map(r => r.cycle_id));
console.log(`🔍 DEBUG: Found ${uniqueCycles.size} unique cycles with tolerance`);
}
return cyclesResult.rows;
}
/**
* Generate large out-and-back routes (10+km) by finding paths that can form long routes
*/
async generateLargeLoops(stagingSchema, targetDistance, targetElevation, tolerancePercent) {
console.log(`🔍 LARGE OUT-AND-BACK DETECTION CALLED: ${targetDistance}km target`);
console.log(`🔍 Generating large out-and-back routes (${targetDistance}km target)`);
// Get high-degree nodes as potential route anchors
const anchorNodes = await this.pgClient.query(`
SELECT nm.pg_id as node_id, nm.connection_count,
ST_X(v.the_geom) as lon, ST_Y(v.the_geom) as lat
FROM ${stagingSchema}.node_mapping nm
JOIN ${stagingSchema}.ways_noded_vertices_pgr v ON nm.pg_id = v.id
WHERE nm.connection_count >= 3
ORDER BY nm.connection_count DESC
LIMIT 20
`);
console.log(`🔍 Found ${anchorNodes.rows.length} anchor nodes for large out-and-back routes`);
const largeRoutes = [];
for (const anchor of anchorNodes.rows.slice(0, 10)) {
console.log(`🔍 Exploring large out-and-back routes from anchor node ${anchor.node_id} (${anchor.connection_count} connections)`);
// Find potential out-and-back paths from this anchor
const routePaths = await this.findLargeLoopPaths(stagingSchema, anchor.node_id, targetDistance, targetElevation);
largeRoutes.push(...routePaths);
}
console.log(`✅ Generated ${largeRoutes.length} large out-and-back route candidates`);
return largeRoutes;
}
/**
* Find potential large out-and-back paths from an anchor node with 100m tolerance
*/
async findLargeLoopPaths(stagingSchema, anchorNode, targetDistance, targetElevation) {
console.log(`🔍 Finding large out-and-back paths from anchor node ${anchorNode} for ${targetDistance}km target (with 100m tolerance)`);
// Find nodes reachable within target distance, including nearby nodes within 100m
const reachableNodes = await this.pgClient.query(`
WITH direct_reachable AS (
SELECT DISTINCT end_vid as node_id, agg_cost as distance_km
FROM pgr_dijkstra(
'SELECT id, source, target, length_km as cost FROM ${stagingSchema}.ways_noded',
$1::bigint,
(SELECT array_agg(pg_id) FROM ${stagingSchema}.node_mapping WHERE connection_count >= 2),
false
)
WHERE agg_cost BETWEEN $2 * 0.3 AND $2 * 0.7
AND end_vid != $1
),
nearby_nodes AS (
SELECT DISTINCT nm2.pg_id as node_id,
ST_Distance(v1.the_geom, v2.the_geom) as distance_meters
FROM ${stagingSchema}.node_mapping nm1
JOIN ${stagingSchema}.ways_noded_vertices_pgr v1 ON nm1.pg_id = v1.id
JOIN ${stagingSchema}.ways_noded_vertices_pgr v2 ON v2.id != v1.id
JOIN ${stagingSchema}.node_mapping nm2 ON nm2.pg_id = v2.id
WHERE nm1.pg_id = $1
AND nm2.connection_count >= 2
AND ST_Distance(v1.the_geom, v2.the_geom) <= 100
AND nm2.pg_id != $1
)
SELECT node_id, distance_km, 'direct' as connection_type
FROM direct_reachable
UNION ALL
SELECT node_id, distance_meters/1000.0 as distance_km, 'nearby' as connection_type
FROM nearby_nodes
ORDER BY distance_km DESC
LIMIT 15
`, [anchorNode, targetDistance]);
console.log(`🔍 Found ${reachableNodes.rows.length} reachable nodes (including nearby nodes within 100m)`);
const routePaths = [];
for (const destNode of reachableNodes.rows.slice(0, 8)) {
console.log(`🔍 Exploring out-and-back route from ${anchorNode} → ${destNode.node_id} (${destNode.distance_km.toFixed(1)}km outbound, ${destNode.connection_type} connection)`);
// Try to find a return path that creates an out-and-back route
const returnPaths = await this.pgClient.query(`
SELECT * FROM pgr_ksp(
'SELECT id, source, target, length_km as cost FROM ${stagingSchema}.ways_noded',
$1::bigint, $2::bigint, 3, false, false
)
`, [destNode.node_id, anchorNode]);
console.log(`🔍 Found ${returnPaths.rows.length} return paths`);
for (const returnPath of returnPaths.rows.slice(0, 2)) {
// Calculate total out-and-back distance
const totalDistance = destNode.distance_km + returnPath.agg_cost;
console.log(`🔍 Out-and-back candidate: ${destNode.distance_km.toFixed(1)}km out + ${returnPath.agg_cost.toFixed(1)}km back = ${totalDistance.toFixed(1)}km total`);
if (totalDistance >= targetDistance * 0.8 && totalDistance <= targetDistance * 1.2) {
console.log(`✅ Valid large out-and-back route found: ${totalDistance.toFixed(1)}km`);
routePaths.push({
anchor_node: anchorNode,
dest_node: destNode.node_id,
outbound_distance: destNode.distance_km,
return_distance: returnPath.agg_cost,
total_distance: totalDistance,
path_id: returnPath.path_id,
connection_type: destNode.connection_type,
route_type: 'out-and-back' // Mark as out-and-back, not loop
});
}
}
}
console.log(`✅ Found ${routePaths.length} valid large out-and-back route candidates`);
return routePaths;
}
/**
* Group cycle edges into distinct cycles
*/
groupCycles(cycleEdges) {
const cycles = new Map();
for (const edge of cycleEdges) {
if (!cycles.has(edge.cycle_id)) {
cycles.set(edge.cycle_id, []);
}
cycles.get(edge.cycle_id).push(edge);
}
return cycles;
}
/**
* Filter cycles by distance and elevation criteria
*/
async filterCyclesByCriteria(stagingSchema, cycles, minDistance, maxDistance, minElevation, maxElevation) {
const validLoops = [];
console.log(`🔍 DEBUG: Filtering ${cycles.size} cycles with criteria: ${minDistance}-${maxDistance}km, ${minElevation}-${maxElevation}m`);
for (const [cycleId, edges] of cycles) {
// Calculate total distance and elevation for this cycle
const edgeIds = edges.map(e => parseInt(e.edge_id)).filter(id => id > 0); // Convert strings to integers, filter out -1
console.log(`🔍 DEBUG: Cycle ${cycleId} edge IDs: ${edgeIds.join(', ')}`);
console.log(`🔍 DEBUG: Cycle ${cycleId} has ${edgeIds.length} valid edge IDs`);
if (edgeIds.length === 0) {
console.log(`⚠️ DEBUG: Cycle ${cycleId} has no valid edge IDs, skipping`);
continue;
}
const cycleMetrics = await this.calculateCycleMetrics(stagingSchema, edgeIds);
console.log(`🔍 DEBUG: Cycle ${cycleId} metrics: ${cycleMetrics.totalDistance.toFixed(2)}km, ${cycleMetrics.totalElevationGain.toFixed(0)}m`);
// Check if cycle meets criteria
if (cycleMetrics.totalDistance >= minDistance &&
cycleMetrics.totalDistance <= maxDistance &&
cycleMetrics.totalElevationGain >= minElevation &&
cycleMetrics.totalElevationGain <= maxElevation) {
console.log(`✅ DEBUG: Cycle ${cycleId} meets criteria!`);
validLoops.push({
cycle_id: cycleId,
edges: edges,
total_distance: cycleMetrics.totalDistance,
total_elevation_gain: cycleMetrics.totalElevationGain,
trail_count: cycleMetrics.trailCount,
route_shape: 'loop'
});
}
else {
console.log(`❌ DEBUG: Cycle ${cycleId} filtered out (distance: ${cycleMetrics.totalDistance.toFixed(2)}km, elevation: ${cycleMetrics.totalElevationGain.toFixed(0)}m)`);
}
}
console.log(`🔍 DEBUG: Returning ${validLoops.length} valid loops`);
return validLoops;
}
/**
* Calculate metrics for a cycle
*/
async calculateCycleMetrics(stagingSchema, edgeIds) {
console.log(`🔍 DEBUG: calculateCycleMetrics called with edgeIds: ${edgeIds.join(', ')} (type: ${typeof edgeIds[0]})`);
const metricsResult = await this.pgClient.query(`
SELECT
SUM(w.length_km) as total_distance,
SUM(w.elevation_gain) as total_elevation_gain,
COUNT(DISTINCT em.original_trail_id) as trail_count
FROM ${stagingSchema}.ways_noded w
JOIN ${stagingSchema}.edge_mapping em ON w.id = em.pg_id
WHERE w.id = ANY($1::integer[])
`, [edgeIds]);
const metrics = metricsResult.rows[0];
console.log(`🔍 DEBUG: calculateCycleMetrics result: ${JSON.stringify(metrics)}`);
return {
totalDistance: parseFloat(metrics.total_distance) || 0,
totalElevationGain: parseFloat(metrics.total_elevation_gain) || 0,
trailCount: parseInt(metrics.trail_count) || 0
};
}
/**
* Validate that a route only uses actual trail edges
* This prevents artificial connections between distant nodes
*/
async validateRouteEdges(stagingSchema, edgeIds) {
if (edgeIds.length === 0) {
return { isValid: false, reason: 'No edges provided' };
}
// Check that all edges exist and are valid trail edges
const validationResult = await this.pgClient.query(`
SELECT
COUNT(*) as total_edges,
COUNT(*) FILTER (WHERE source IS NOT NULL AND target IS NOT NULL) as connected_edges,
COUNT(*) FILTER (WHERE app_uuid IS NOT NULL AND name IS NOT NULL) as trail_edges,
COUNT(*) FILTER (WHERE length_km <= 2.0) as reasonable_length_edges,
COUNT(*) FILTER (WHERE length_km > 2.0) as long_edges,
MAX(length_km) as max_edge_length,
MIN(length_km) as min_edge_length
FROM ${stagingSchema}.ways_noded
WHERE id = ANY($1::integer[])
`, [edgeIds]);
const stats = validationResult.rows[0];
// Validation checks
if (stats.total_edges !== edgeIds.length) {
return { isValid: false, reason: `Missing edges: expected ${edgeIds.length}, found ${stats.total_edges}` };
}
if (stats.connected_edges !== edgeIds.length) {
return { isValid: false, reason: `Disconnected edges: ${edgeIds.length - stats.connected_edges} edges have null source/target` };
}
if (stats.trail_edges !== edgeIds.length) {
return { isValid: false, reason: `Non-trail edges: ${edgeIds.length - stats.trail_edges} edges missing app_uuid or name` };
}
if (stats.long_edges > 0) {
return { isValid: false, reason: `Long edges detected: ${stats.long_edges} edges > 2km (max: ${stats.max_edge_length.toFixed(2)}km)` };
}
if (stats.max_edge_length > 2.0) {
return { isValid: false, reason: `Edge too long: ${stats.max_edge_length.toFixed(2)}km exceeds 2km limit` };
}
return { isValid: true };
}
/**
* Execute KSP routing between two nodes with enhanced diversity
*/
async executeKspRouting(stagingSchema, startNode, endNode, kValue = 10) {
// Cache layer (optional)
const { getNetworkCacheConfig } = await Promise.resolve().then(() => __importStar(require('../config-loader')));
const cacheCfg = getNetworkCacheConfig();
const constraintsSig = 'len<=2.0&trailOnly&named';
let graphSig = 'nocache';
if (cacheCfg.enableCompletedNetworkCache) {
graphSig = await this.getGraphSignature(stagingSchema, 'unknown');
const { RouteCacheService } = await Promise.resolve().then(() => __importStar(require('../cache/route-cache')));
const cache = new RouteCacheService(this.pgClient, cacheCfg.cacheSchema);
const hit = await cache.getKspPaths(graphSig, startNode, endNode, kValue, constraintsSig);
if (hit && hit.paths && hit.paths.length > 0) {
// Reconstruct rows in the same shape as pgr_ksp output (edge list per path)
const rows = [];
hit.paths.forEach((edgeList, idx) => {
let seq = 1;
edgeList.forEach((edgeId) => {
rows.push({ path_id: idx + 1, path_seq: seq++, edge: edgeId });
});
});
return rows;
}
}
// Miss: query pgr_ksp and then cache
const kspResult = await this.pgClient.query(`
SELECT * FROM pgr_ksp(
'SELECT id, source, target, length_km as cost
FROM ${stagingSchema}.ways_noded
WHERE source IS NOT NULL
AND target IS NOT NULL
AND length_km <= 2.0 -- Prevent use of extremely long edges (>2km)
AND app_uuid IS NOT NULL -- Ensure edge is part of actual trail
AND name IS NOT NULL -- Ensure edge has a trail name
ORDER BY id',
$1::bigint, $2::bigint, $3, false, false
)
`, [startNode, endNode, kValue]);
const rows = kspResult.rows;
if (cacheCfg.enableCompletedNetworkCache && rows && rows.length > 0) {
// Group by path_id into edge lists and store
const pathsMap = new Map();
for (const r of rows) {
if (!pathsMap.has(r.path_id))
pathsMap.set(r.path_id, []);
if (typeof r.edge === 'number' && r.edge !== -1)
pathsMap.get(r.path_id).push(r.edge);
}
const paths = Array.from(pathsMap.keys()).sort((a, b) => a - b).map((pid) => pathsMap.get(pid));
const { RouteCacheService } = await Promise.resolve().then(() => __importStar(require('../cache/route-cache')));
const cache = new RouteCacheService(this.pgClient, cacheCfg.cacheSchema);
await cache.setKspPaths(graphSig, startNode, endNode, kValue, constraintsSig, paths);
}
return rows;
}
/**
* Execute A* routing for more efficient pathfinding
*/
async executeAstarRouting(stagingSchema, startNode, endNode) {
const astarResult = await this.pgClient.query(`
SELECT * FROM pgr_astar(
'SELECT id, source, target, length_km as cost,
ST_X(ST_StartPoint(the_geom)) as x1, ST_Y(ST_StartPoint(the_geom)) as y1,
ST_X(ST_EndPoint(the_geom)) as x2, ST_Y(ST_EndPoint(the_geom)) as y2
FROM ${stagingSchema}.ways_noded
WHERE source IS NOT NULL
AND target IS NOT NULL
AND length_km <= 2.0 -- Prevent use of extremely long edges (>2km)
AND app_uuid IS NOT NULL -- Ensure edge is part of actual trail
AND name IS NOT NULL -- Ensure edge has a trail name
ORDER BY id',
$1::bigint, $2::bigint, false
)
`, [startNode, endNode]);
return astarResult.rows;
}
/**
* Execute bidirectional Dijkstra for better performance on large networks
*/
async executeBidirectionalDijkstra(stagingSchema, startNode, endNode) {
const bdResult = await this.pgClient.query(`
SELECT * FROM pgr_bddijkstra(
'SELECT id, source, target, length_km as cost
FROM ${stagingSchema}.ways_noded
WHERE source IS NOT NULL
AND target IS NOT NULL
AND length_km <= 2.0 -- Prevent use of extremely long edges (>2km)
AND app_uuid IS NOT NULL -- Ensure edge is part of actual trail
AND name IS NOT NULL -- Ensure edge has a trail name
ORDER BY id',
$1::bigint, $2::bigint, false
)
`, [startNode, endNode]);
return bdResult.rows;
}
/**
* Execute Chinese Postman for optimal trail coverage
* This finds the shortest route that covers all edges at least once
*/
async executeChinesePostman(stagingSchema) {
const cpResult = await this.pgClient.query(`
SELECT * FROM pgr_chinesepostman(
'SELECT id, source, target, length_km as cost
FROM ${stagingSchema}.ways_noded
WHERE source IS NOT NULL
AND target IS NOT NULL
AND length_km <= 2.0 -- Prevent use of extremely long edges (>2km)
AND app_uuid IS NOT NULL -- Ensure edge is part of actual trail
AND name IS NOT NULL -- Ensure edge has a trail name
ORDER BY id'
)
`);
return cpResult.rows;
}
/**
* Execute Hawick Circuits for finding all cycles in the network
* This is excellent for loop route generation
*/
async executeHawickCircuits(stagingSchema) {
const hcResult = await this.pgClient.query(`
SELECT * FROM pgr_hawickcircuits(
'SELECT id, source, target, length_km as cost
FROM ${stagingSchema}.ways_noded
WHERE source IS NOT NULL
AND target IS NOT NULL
AND length_km <= 2.0 -- Prevent use of extremely long edges (>2km)
AND app_uuid IS NOT NULL -- Ensure edge is part of actual trail
AND name IS NOT NULL -- Ensure edge has a trail name
ORDER BY id'
)
`);
return hcResult.rows;
}
/**
* Execute withPointsKSP for routes that can start/end at any point along trails
* This allows for more flexible route generation
*/
async executeWithPointsKsp(stagingSchema, startNode, endNode) {
const wpkspResult = await this.pgClient.query(`
SELECT * FROM pgr_withpointsksp(
'SELECT id, source, target, length_km as cost FROM ${stagingSchema}.ways_noded',
'SELECT pid, edge_id, fraction FROM ${stagingSchema}.points_of_interest',
ARRAY[$1::bigint], ARRAY[$2::bigint], 6, 'd', false, false
)
`, [startNode, endNode]);
return wpkspResult.rows;
}
/**
* Get route edges by IDs with split trail metadata
*/
async getRouteEdges(stagingSchema, edgeIds) {
const routeEdges = await this.pgClient.query(`
SELECT
w.*,
COALESCE(em.app_uuid, 'unknown') as app_uuid,
COALESCE(em.trail_name, 'Unnamed Trail') as trail_name,
w.length_km as trail_length_km,
w.elevation_gain as trail_elevation_gain,
w.elevation_loss as elevation_loss,
'hiking' as trail_type,
'dirt' as surface,
'moderate' as difficulty,
0 as max_elevation,
0 as min_elevation,
0 as avg_elevation
FROM ${stagingSchema}.ways_noded w
LEFT JOIN ${stagingSchema}.edge_mapping em ON w.id = em.pg_id
WHERE w.id = ANY($1::integer[])
ORDER BY w.id
`, [edgeIds]);
return routeEdges.rows;
}
/**
* Store route recommendation
*/
async storeRouteRecommendation(stagingSchema, recommendation) {
try {
console.log(`💾 Storing route recommendation: ${recommendation.route_uuid}`);
console.log(` - Schema: ${stagingSchema}`);
console.log(` - Route name: ${recommendation.route_name}`);
console.log(` - Route type: ${recommendation.route_type}`);
console.log(` - Trail count: ${recommendation.trail_count}`);
// Generate complete_route_data in the expected API format
const completeRouteData = {
routeId: recommendation.route_uuid,
routeName: recommendation.route_name,
routeType: recommendation.trail_count === 1 ? 'single' : 'multi',
totalDistance: recommendation.recommended_length_km,
totalElevationGain: recommendation.recommended_elevation_gain,
routeShape: recommendation.route_shape,
similarityScore: recommendation.similarity_score,
trailSegments: recommendation.route_edges?.map((edge, index) => ({
trailId: edge.trail_id || edge.trail_uuid,
appUuid: edge.app_uuid,
osmId: edge.osm_id,
name: edge.trail_name || edge.name,
geometry: edge.geometry || edge.the_geom,
distance: edge.distance_km || edge.length_km,
elevationGain: edge.elevation_gain,
elevationLoss: edge.elevation_loss
})) || [],
connectivity: {
segmentConnections: [],
routeContinuity: true,
gaps: []
},
combinedPath: recommendation.route_path,
combinedBbox: null, // Will be calculated if needed
createdAt: new Date().toISOString(),
region: recommendation.region,
inputParameters: {
targetDistance: recommendation.input_length_km,
targetElevationGain: recommendation.input_elevation_gain,
distanceTolerance: 10, // Default tolerance
elevationTolerance: 20 // Default tolerance
}
};
await this.pgClient.query(`
INSERT INTO ${stagingSchema}.route_recommendations (
route_uuid, route_name, route_type, route_shape,
input_length_km, input_elevation_gain,
recommended_length_km, recommended_elevation_gain,
route_path, route_edges, trail_count, route_score,
similarity_score, region, complete_route_data, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, CURRENT_TIMESTAMP)
`, [
recommendation.route_uuid, recommendation.route_name, recommendation.route_type, recommendation.route_shape,
recommendation.input_length_km, recommendation.input_elevation_gain,
recommendation.recommended_length_km, recommendation.recommended_elevation_gain,
recommendation.route_path, JSON.stringify(recommendation.route_edges),
recommendation.trail_count, recommendation.route_score, recommendation.similarity_score, recommendation.region,
JSON.stringify(completeRouteData)
]);
// Populate route_trails table with individual trail segments
if (recommendation.route_edges && recommendation.route_edges.length > 0) {
console.log(`💾 Storing ${recommendation.route_edges.length} trail segments for route: ${recommendation.route_uuid}`);
for (let i = 0; i < recommendation.route_edges.length; i++) {
const edge = recommendation.route_edges[i];
await this.pgClient.query(`
INSERT INTO ${stagingSchema}.route_trails (
route_uuid, trail_id, trail_name, segment_order,
segment_length_km, segment_elevation_gain, trail_type, surface, difficulty, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP)
`, [
recommendation.route_uuid,
edge.trail_id || edge.trail_uuid || edge.app_uuid,
edge.trail_name || edge.name,
i + 1, // segment_order (1-based)
edge.distance_km || edge.length_km,
edge.elevation_gain || 0,
edge.trail_type || 'hiking',
edge.surface || 'unknown',
edge.difficulty || 'moderate'
]);
}
console.log(`✅ Successfully stored ${recommendation.route_edges.length} trail segments for route: ${recommendation.route_uuid}`);
}
console.log(`✅ Successfully stored route: ${recommendation.route_uuid}`);
}
catch (error) {
console.error(`❌ Failed to store route ${recommendation.route_uuid}:`, error);
console.error(` - Schema: ${stagingSchema}`);
console.error(` - Recommendation data:`, JSON.stringify(recommendation, null, 2));
throw error;
}
}
/**
* Get network entry points for route generation
* @param stagingSchema The staging schema name
* @param useTrailheadsOnly If true, only return trailhead nodes. If false, use default logic.
* @param maxEntryPoints Maximum number of entry points to return
* @param trailheadLocations Optional array of trailhead coordinate locations
*/
async getNetworkEntryPoints(stagingSchema, useTrailheadsOnly = false, maxEntryPoints = 50, trailheadLocations) {
console.log(`🔍 Finding network entry points${useTrailheadsOnly ? ' (trailheads only)' : ''}...`);
if (useTrailheadsOnly) {
// Load trailhead configuration from YAML
const config = this.configLoader.loadConfig();
const trailheadConfig = config.trailheads;
console.log(`🔍 Trailhead config: enabled=${trailheadConfig.enabled}, strategy=${trailheadConfig.selectionStrategy}, locations=${trailheadConfig.locations?.length || 0}`);
if (!trailheadConfig.enabled) {
console.log('⚠️ Trailheads disabled in config - falling back to default entry points');
return this.getDefaultNetworkEntryPoints(stagingSchema, maxEntryPoints);
}
// Use coordinate-based trailhead finding from YAML config
if (trailheadConfig.selectionStrategy === 'coordinates' && trailheadConfig.locations && trailheadConfig.locations.length > 0) {
console.log(`✅ Using ${trailheadConfig.locations.length} trailhead locations from YAML config`);
return this.findNearestEdgeEndpointsToTrailheads(stagingSchema, trailheadConfig.locations, trailheadConfig.maxTrailheads);
}
// Use manual trailhead nodes (if any exist in database)
if (trailheadConfig.selectionStrategy === 'manual') {
console.log('🔍 Looking for manual trailhead nodes in database...');
const manualTrailheadNodes = await this.pgClient.query(`
SELECT
rn.id,
rn.node_type,
COALESCE(nm.connection_count, 1) as connection_count,
rn.lat as lat,
rn.lng as lon,
'manual_trailhead' as entry_type
FROM ${stagingSchema}.routing_nodes rn
LEFT JOIN ${stagingSchema}.node_mapping nm ON rn.id = nm.pg_id
WHERE rn.node_type = 'trailhead'
ORDER BY nm.connection_count ASC, rn.id
LIMIT $1
`, [trailheadConfig.maxTrailheads]);
console.log(`✅ Found ${manualTrailheadNodes.rows.length} manual trailhead nodes`);
if (manualTrailheadNodes.rows.length === 0) {
console.warn('⚠️ No manual trailheads found - falling back to default entry points');
return this.getDefaultNetworkEntryPoints(stagingSchema, maxEntryPoints);
}
return manualTrailheadNodes.rows;
}
// Fallback to default entry points
console.log('⚠️ No trailhead strategy matched - falling back to default entry points');
return this.getDefaultNetworkEntryPoints(stagingSchema, maxEntryPoints);
}
// Default behavior: use all available nodes
console.log('✅ Using default network entry points (all available nodes)');
return this.getDefaultNetworkEntryPoints(stagingSchema, maxEntryPoints);
}
/**
* Get default network entry points (all available nodes)
*/
async getDefaultNetworkEntryPoints(stagingSchema, maxEntryPoints = 50) {
const entryPoints = await this.pgClient.query(`
SELECT
v.id,
'endpoint' as node_type,
COALESCE(nm.connection_count, 1) as connection_count,
ST_Y(v.the_geom) as lat,
ST_X(v.the_geom) as lon,
'default' as entry_type
FROM ${stagingSchema}.ways_noded_vertices_pgr v
LEFT JOIN ${stagingSchema}.node_mapping nm ON v.id = nm.pg_id
WHERE nm.node_type IN ('intersection', 'endpoint')
ORDER BY nm.connection_count DESC, v.id
LIMIT $1
`, [maxEntryPoints]);
return entryPoints.rows;
}
/**
* Find nearest edge endpoints to trailhead coordinates
*/
async findNearestEdgeEndpointsToTrailheads(stagingSchema, trailheadLocations, maxTrailheads = 50) {
const trailheadNodes = [];
for (const location of trailheadLocations.slice(0, maxTrailheads)) {
const tolerance = location.tolerance_meters || 50;
// Find the nearest node to this coordinate location
const nearestNode = await this.pgClient.query(`
SELECT
v.id,
'endpoint' as node_type,
COALESCE(nm.connection_count, 1) as connection_count,
ST_Y(v.the_geom) as lat,
ST_X(v.the_geom) as lon,
ST_Distance(
ST_SetSRID(ST_MakePoint($1, $2), 4326),
v.the_geom
) * 111000 as distance_meters
FROM ${stagingSchema}.ways_noded_vertices_pgr v
LEFT JOIN ${stagingSchema}.node_mapping nm ON v.id = nm.pg_id
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint($1, $2), 4326),
v.the_geom,
$3 / 111000.0
)
ORDER BY distance_meters ASC
LIMIT 1
`, [location.lng, location.lat, tolerance]);
if (nearestNode.rows.length > 0) {
const node = nearestNode.rows[0];
console.log(`✅ Found trailhead node: ID ${node.id} at ${node.lat}, ${node.lon} (distance: ${node.distance_meters.toFixed(1)}m)`);
trailheadNodes.push(node);
}
else {
console.log(`❌ No routing nodes found within ${tolerance}m of ${location.lat}, ${location.lng}`);
}
}
console.log(`🔍 Found ${trailheadNodes.length} trailhead nodes total`);
return trailheadNodes.slice(0, maxTrailheads);
}
/**
* Find nodes reachable from a starting node within a maximum distance
*/
async findReachableNodes(stagingSchema, startNode, maxDistance) {
const { getNetworkCacheConfig } = await Promise.resolve().then(() => __importStar(require('../config-loader')));
const cacheCfg = getNetworkCacheConfig();
let graphSig = 'nocache';
if (cacheCfg.enableCompletedNetworkCache) {
graphSig = await this.getGraphSignature(stagingSchema, 'unknown');
const { RouteCacheService } = await Promise.resolve().then(() => __importStar(require('../cache/route-cache')));
const cache = new RouteCacheService(this.pgClient, cacheCfg.cacheSchema);
const hit = await cache.getReachableNodes(graphSig, startNode, maxDistance);
if (hit && hit.results) {
return hit.results.map(r => ({ node_id: r.node_id, distance_km: r.distance_km }));
}
}
const reachableNodes = await this.pgClient.query(`
SELECT DISTINCT end_vid as node_id, agg_cost as distance_km
FROM pgr_dijkstra(
'SELECT id, source, target, length_km as cost
FROM ${stagingSchema}.ways_noded
WHERE source IS NOT NULL
AND target IS NOT NULL
AND length_km <= 2.0 -- Prevent use of extremely long edges (>2km)
AND app_uuid IS NOT NULL -- Ensure edge is part of actual trail
AND name IS NOT NULL -- Ensure edge has a trail name
ORDER BY id',
$1::bigint,
(SELECT array_agg(pg_id) FROM ${stagingSchema}.node_mapping WHERE node_type IN ('intersection', 'endpoint')),
false
)
WHERE agg_cost <= $2
AND end_vid != $1
ORDER BY agg_cost DESC
LIMIT 10
`, [startNode, maxDistance]);
const rows = reachableNodes.rows;
if (cacheCfg.enableCompletedNetworkCache && rows) {
const { RouteCacheService } = await Promise.resolve().then(() => __importStar(require('../cache/route-cache')));
const cache = new RouteCacheService(this.pgClient, cacheCfg.cacheSchema);
await cache.setReachableNodes(graphSig, startNode, maxDistance, rows.map((r) => ({ node_id: r.node_id, distance_km: r.distance_km })));
}
return rows;
}
}
exports.RoutePatternSqlHelpers = RoutePatternSqlHelpers;
//# sourceMappingURL=route-pattern-sql-helpers.js.map