carthorse
Version:
A geospatial trail data processing pipeline for building 3D trail databases with elevation data
995 lines (991 loc) • 47.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.NetworkConnectivityAnalyzer = void 0;
class NetworkConnectivityAnalyzer {
constructor(pgClient, config) {
this.pgClient = pgClient;
this.config = config;
}
/**
* Analyze network connectivity and identify missing connections
*/
async analyzeConnectivity() {
console.log('🔍 Analyzing network connectivity...');
const missingConnections = await this.findMissingConnections();
const disconnectedComponents = await this.findDisconnectedComponents();
const connectivityScore = await this.calculateConnectivityScore();
const networkMetrics = await this.calculateNetworkMetrics();
// Added: Analyze missing trail segments if enabled
let missingTrailSegments = [];
if (this.config.analyzeMissingTrails) {
const trailAnalysis = await this.analyzeMissingTrailSegments();
missingTrailSegments = trailAnalysis.missing_segments;
console.log(`🔍 Missing trail analysis: ${trailAnalysis.lost_trails_count} trails lost during processing`);
}
const recommendations = this.generateRecommendations(missingConnections, disconnectedComponents, networkMetrics, missingTrailSegments);
console.log(`✅ Connectivity analysis complete:`);
console.log(` 🔗 Missing connections: ${missingConnections.length}`);
console.log(` 🧩 Disconnected components: ${disconnectedComponents.length}`);
console.log(` 📊 Connectivity score: ${connectivityScore.toFixed(2)}%`);
console.log(` 📈 Network metrics calculated`);
if (this.config.analyzeMissingTrails) {
console.log(` 🚫 Missing trail segments: ${missingTrailSegments.length}`);
}
return {
missingConnections,
disconnectedComponents,
connectivityScore,
networkMetrics,
recommendations,
missingTrailSegments
};
}
/**
* Perform dry-run analysis to visualize potential connector nodes
*/
async performDryRunAnalysis() {
console.log('🔍 Performing dry-run analysis of potential connector nodes...');
// Get missing connections
const missingConnections = await this.findMissingConnections();
// Limit the number of connections to analyze
const maxConnectors = this.config.maxConnectorsToAnalyze || 50;
const connectionsToAnalyze = missingConnections.slice(0, maxConnectors);
console.log(`📊 Analyzing ${connectionsToAnalyze.length} potential connections...`);
// Generate potential connector nodes
const potentialConnectors = [];
for (const connection of connectionsToAnalyze) {
const connector = await this.analyzePotentialConnector(connection);
if (connector.impact_score >= (this.config.minImpactScore || 0)) {
potentialConnectors.push(connector);
}
}
// Sort by impact score (highest first)
potentialConnectors.sort((a, b) => b.impact_score - a.impact_score);
// Calculate estimated network improvements
const estimatedImprovements = await this.calculateEstimatedNetworkImprovements(potentialConnectors);
// Generate visualization data
const visualizationData = await this.generateVisualizationData(potentialConnectors);
// Get recommended connectors (top 20% by impact score)
const recommendedCount = Math.min(20, Math.ceil(potentialConnectors.length * 0.2));
const recommendedConnectors = potentialConnectors.slice(0, recommendedCount);
const analysis = {
potential_connectors: potentialConnectors,
estimated_network_improvements: estimatedImprovements,
recommended_connectors: recommendedConnectors,
visualization_data: visualizationData
};
console.log(`✅ Dry-run analysis complete:`);
console.log(` 🔗 Potential connectors: ${potentialConnectors.length}`);
console.log(` ⭐ Recommended connectors: ${recommendedConnectors.length}`);
console.log(` 📈 Estimated connectivity improvement: ${estimatedImprovements.connectivity_score_increase.toFixed(2)}%`);
return analysis;
}
/**
* Analyze a single potential connector and calculate its impact
*/
async analyzePotentialConnector(connection) {
// Calculate midpoint position
const midpoint = [
(connection.trail1_endpoint[0] + connection.trail2_endpoint[0]) / 2,
(connection.trail1_endpoint[1] + connection.trail2_endpoint[1]) / 2
];
// Calculate impact score based on multiple factors
let impactScore = 0;
const benefits = [];
// Factor 1: Distance (closer is better)
const distanceScore = Math.max(0, 100 - (connection.distance_meters / this.config.maxConnectionDistance) * 100);
impactScore += distanceScore * 0.3;
// Factor 2: Trail length (longer trails are more valuable)
const trail1Length = await this.getTrailLength(connection.trail1_id);
const trail2Length = await this.getTrailLength(connection.trail2_id);
const lengthScore = Math.min(100, (trail1Length + trail2Length) / 10); // Normalize to 0-100
impactScore += lengthScore * 0.2;
// Factor 3: Elevation gain (more challenging trails are valuable)
const trail1Elevation = await this.getTrailElevation(connection.trail1_id);
const trail2Elevation = await this.getTrailElevation(connection.trail2_id);
const elevationScore = Math.min(100, (trail1Elevation + trail2Elevation) / 20); // Normalize to 0-100
impactScore += elevationScore * 0.2;
// Factor 4: Network position (connecting isolated areas is valuable)
const networkPositionScore = await this.calculateNetworkPositionScore(connection);
impactScore += networkPositionScore * 0.3;
// Determine benefits
if (connection.distance_meters <= this.config.intersectionTolerance) {
benefits.push('High-priority connection (within intersection tolerance)');
}
if (trail1Length + trail2Length > 10) {
benefits.push('Connects long trails (>10km combined)');
}
if (trail1Elevation + trail2Elevation > 1000) {
benefits.push('Connects challenging trails (>1000m combined elevation)');
}
if (networkPositionScore > 70) {
benefits.push('Improves network connectivity significantly');
}
// Estimate route diversity improvement
const estimatedRouteImprovement = Math.min(50, impactScore * 0.5);
return {
id: `connector-${connection.trail1_id}-${connection.trail2_id}`,
position: midpoint,
connection_type: connection.connection_type,
connected_trails: [connection.trail1_name, connection.trail2_name],
distance_meters: connection.distance_meters,
impact_score: Math.round(impactScore),
benefits,
estimated_route_improvement: estimatedRouteImprovement
};
}
/**
* Calculate network position score for a connection
*/
async calculateNetworkPositionScore(connection) {
// Check if this connection would bridge disconnected components
const component1 = await this.getTrailComponent(connection.trail1_id);
const component2 = await this.getTrailComponent(connection.trail2_id);
if (component1 !== component2) {
return 90; // High score for bridging components
}
// Check if it connects to isolated trails
const trail1Isolation = await this.getTrailIsolationScore(connection.trail1_id);
const trail2Isolation = await this.getTrailIsolationScore(connection.trail2_id);
return Math.max(trail1Isolation, trail2Isolation);
}
/**
* Get the component ID for a trail
*/
async getTrailComponent(trailId) {
const result = await this.pgClient.query(`
SELECT component
FROM pgr_strongComponents(
'SELECT id, source, target, length_km as cost FROM ${this.config.stagingSchema}.ways_noded'
) sc
JOIN ${this.config.stagingSchema}.ways_noded_vertices_pgr v ON sc.node = v.id
JOIN ${this.config.stagingSchema}.node_mapping nm ON v.id = nm.pg_id
JOIN ${this.config.stagingSchema}.edge_mapping em ON nm.pg_id = em.pg_id
WHERE em.app_uuid = $1
LIMIT 1
`, [trailId]);
return result.rows[0]?.component || -1;
}
/**
* Calculate isolation score for a trail (0-100, higher = more isolated)
*/
async getTrailIsolationScore(trailId) {
const result = await this.pgClient.query(`
SELECT COUNT(DISTINCT em2.app_uuid) as connection_count
FROM ${this.config.stagingSchema}.edge_mapping em1
JOIN ${this.config.stagingSchema}.routing_edges re1 ON em1.pg_id = re1.id
JOIN ${this.config.stagingSchema}.routing_edges re2 ON re1.source = re2.source OR re1.target = re2.target
JOIN ${this.config.stagingSchema}.edge_mapping em2 ON re2.id = em2.pg_id
WHERE em1.app_uuid = $1 AND em2.app_uuid != $1
`, [trailId]);
const connectionCount = parseInt(result.rows[0]?.connection_count || '0');
return Math.max(0, 100 - connectionCount * 10); // Higher score for fewer connections
}
/**
* Get trail length
*/
async getTrailLength(trailId) {
const result = await this.pgClient.query(`
SELECT length_km FROM ${this.config.stagingSchema}.edge_mapping
WHERE app_uuid = $1 LIMIT 1
`, [trailId]);
return parseFloat(result.rows[0]?.length_km || '0');
}
/**
* Get trail elevation gain
*/
async getTrailElevation(trailId) {
const result = await this.pgClient.query(`
SELECT elevation_gain FROM ${this.config.stagingSchema}.edge_mapping
WHERE app_uuid = $1 LIMIT 1
`, [trailId]);
return parseFloat(result.rows[0]?.elevation_gain || '0');
}
/**
* Calculate estimated network improvements from adding connectors
*/
async calculateEstimatedNetworkImprovements(connectors) {
// Simple estimation based on connector count and impact scores
const totalImpact = connectors.reduce((sum, c) => sum + c.impact_score, 0);
const avgImpact = totalImpact / Math.max(1, connectors.length);
// Estimate improvements based on impact scores
const connectivityScoreIncrease = Math.min(20, avgImpact * 0.2);
const componentReduction = Math.min(connectors.length * 0.1, 5);
const averagePathLengthDecrease = Math.min(2, avgImpact * 0.02);
const networkDensityIncrease = Math.min(10, avgImpact * 0.1);
const routeDiversityImprovement = Math.min(30, avgImpact * 0.3);
return {
connectivity_score_increase: connectivityScoreIncrease,
component_reduction: componentReduction,
average_path_length_decrease: averagePathLengthDecrease,
network_density_increase: networkDensityIncrease,
route_diversity_improvement: routeDiversityImprovement
};
}
/**
* Generate visualization data for potential connectors
*/
async generateVisualizationData(connectors) {
const connectorNodes = connectors.map(connector => ({
type: 'Feature',
properties: {
id: connector.id,
impact_score: connector.impact_score,
connection_type: connector.connection_type,
connected_trails: connector.connected_trails,
distance_meters: connector.distance_meters,
benefits: connector.benefits,
estimated_route_improvement: connector.estimated_route_improvement,
// Styling properties
markerSize: Math.max(2, connector.impact_score / 20),
markerColor: connector.impact_score > 80 ? '#ff0000' :
connector.impact_score > 60 ? '#ff6600' :
connector.impact_score > 40 ? '#ffcc00' : '#00ff00',
markerSymbol: 'circle',
opacity: 0.8
},
geometry: {
type: 'Point',
coordinates: connector.position
}
}));
const connectionLines = connectors.map(connector => {
// Get the actual trail endpoints for visualization
const trail1Endpoint = connector.connected_trails[0] ? [connector.position[0] - 0.001, connector.position[1] - 0.001] : connector.position;
const trail2Endpoint = connector.connected_trails[1] ? [connector.position[0] + 0.001, connector.position[1] + 0.001] : connector.position;
return {
type: 'Feature',
properties: {
id: connector.id,
impact_score: connector.impact_score,
distance_meters: connector.distance_meters,
// Styling properties
strokeColor: connector.impact_score > 80 ? '#ff0000' :
connector.impact_score > 60 ? '#ff6600' :
connector.impact_score > 40 ? '#ffcc00' : '#00ff00',
strokeWidth: Math.max(1, connector.impact_score / 20),
strokeOpacity: 0.6,
strokeDashArray: connector.impact_score > 80 ? 'none' : '5,5' // Dashed for lower priority
},
geometry: {
type: 'LineString',
coordinates: [trail1Endpoint, trail2Endpoint]
}
};
});
const impactHeatmap = connectors.map(connector => ({
type: 'Feature',
properties: {
id: connector.id,
impact_score: connector.impact_score,
weight: connector.impact_score / 100 // Normalize to 0-1 for heatmap
},
geometry: {
type: 'Point',
coordinates: connector.position
}
}));
return {
connector_nodes: connectorNodes,
connection_lines: connectionLines,
impact_heatmap: impactHeatmap
};
}
/**
* Find missing connections between trails within tolerance using PostGIS spatial functions
* Enhanced to verify against original trail_master_db.trails table
*/
async findMissingConnections() {
console.log('🔍 Finding missing trail connections...');
const result = await this.pgClient.query(`
WITH trail_endpoints AS (
-- Get all trail endpoints with their coordinates using PostGIS
SELECT
t.app_uuid as trail_id,
t.name as trail_name,
ST_StartPoint(t.geometry) as start_point,
ST_EndPoint(t.geometry) as end_point,
ST_X(ST_StartPoint(t.geometry)) as start_lon,
ST_Y(ST_StartPoint(t.geometry)) as start_lat,
ST_X(ST_EndPoint(t.geometry)) as end_lon,
ST_Y(ST_EndPoint(t.geometry)) as end_lat,
ST_Length(t.geometry) as trail_length
FROM ${this.config.stagingSchema}.trails t
WHERE t.geometry IS NOT NULL
AND ST_Length(t.geometry) >= $1
),
potential_connections AS (
-- Find all potential connections using PostGIS spatial functions
-- Focus on actual trail intersections within tolerance
SELECT
t1.trail_id as trail1_id,
t1.trail_name as trail1_name,
t1.start_lon as trail1_lon,
t1.start_lat as trail1_lat,
t1.start_point as trail1_point,
t2.trail_id as trail2_id,
t2.trail_name as trail2_name,
t2.start_lon as trail2_lon,
t2.start_lat as trail2_lat,
t2.start_point as trail2_point,
ST_Distance(t1.start_point, t2.start_point) as distance_meters,
'endpoint-to-endpoint' as connection_type
FROM trail_endpoints t1
CROSS JOIN trail_endpoints t2
WHERE t1.trail_id < t2.trail_id -- Avoid duplicates
AND ST_DWithin(t1.start_point, t2.start_point, $2) -- Use precise tolerance
AND ST_Distance(t1.start_point, t2.start_point) > 0
UNION ALL
SELECT
t1.trail_id as trail1_id,
t1.trail_name as trail1_name,
t1.end_lon as trail1_lon,
t1.end_lat as trail1_lat,
t1.end_point as trail1_point,
t2.trail_id as trail2_id,
t2.trail_name as trail2_name,
t2.start_lon as trail2_lon,
t2.start_lat as trail2_lat,
t2.start_point as trail2_point,
ST_Distance(t1.end_point, t2.start_point) as distance_meters,
'endpoint-to-endpoint' as connection_type
FROM trail_endpoints t1
CROSS JOIN trail_endpoints t2
WHERE t1.trail_id != t2.trail_id
AND ST_DWithin(t1.end_point, t2.start_point, $2) -- Use precise tolerance
AND ST_Distance(t1.end_point, t2.start_point) > 0
UNION ALL
SELECT
t1.trail_id as trail1_id,
t1.trail_name as trail1_name,
t1.end_lon as trail1_lon,
t1.end_lat as trail1_lat,
t1.end_point as trail1_point,
t2.trail_id as trail2_id,
t2.trail_name as trail2_name,
t2.end_lon as trail2_lon,
t2.end_lat as trail2_lat,
t2.end_point as trail2_point,
ST_Distance(t1.end_point, t2.end_point) as distance_meters,
'endpoint-to-endpoint' as connection_type
FROM trail_endpoints t1
CROSS JOIN trail_endpoints t2
WHERE t1.trail_id < t2.trail_id
AND ST_DWithin(t1.end_point, t2.end_point, $2) -- Use precise tolerance
AND ST_Distance(t1.end_point, t2.end_point) > 0
),
existing_connections AS (
-- Get existing connections from routing edges using pgRouting
SELECT DISTINCT
em1.app_uuid as trail1_id,
em2.app_uuid as trail2_id
FROM ${this.config.stagingSchema}.routing_edges re1
JOIN ${this.config.stagingSchema}.edge_mapping em1 ON re1.id = em1.pg_id
JOIN ${this.config.stagingSchema}.routing_edges re2 ON re1.source = re2.source OR re1.target = re2.target
JOIN ${this.config.stagingSchema}.edge_mapping em2 ON re2.id = em2.pg_id
WHERE em1.app_uuid != em2.app_uuid
),
missing_connections AS (
-- Find connections that don't exist in routing_edges
SELECT
pc.trail1_id,
pc.trail1_name,
ARRAY[pc.trail1_lon, pc.trail1_lat] as trail1_endpoint,
pc.trail2_id,
pc.trail2_name,
ARRAY[pc.trail2_lon, pc.trail2_lat] as trail2_endpoint,
pc.distance_meters,
pc.connection_type,
CASE
WHEN pc.distance_meters <= $3 THEN $3
WHEN pc.distance_meters <= $4 THEN $4
ELSE $5
END as recommended_tolerance
FROM potential_connections pc
LEFT JOIN existing_connections ec ON
(pc.trail1_id = ec.trail1_id AND pc.trail2_id = ec.trail2_id) OR
(pc.trail1_id = ec.trail2_id AND pc.trail2_id = ec.trail1_id)
WHERE ec.trail1_id IS NULL -- Only missing connections
),
verified_missing_connections AS (
-- Verify that missing connections correspond to actual trails in trail_master_db
SELECT
mc.*,
-- Check if there's an actual trail in trail_master_db that could connect these points
EXISTS (
SELECT 1 FROM public.trails t
WHERE ST_DWithin(
ST_StartPoint(t.geometry),
ST_MakePoint(mc.trail1_endpoint[1], mc.trail1_endpoint[2]),
$6
)
AND ST_DWithin(
ST_EndPoint(t.geometry),
ST_MakePoint(mc.trail2_endpoint[1], mc.trail2_endpoint[2]),
$6
)
AND t.geometry IS NOT NULL
) as has_original_trail,
-- Find the actual trail name if it exists
(
SELECT t.name
FROM public.trails t
WHERE ST_DWithin(
ST_StartPoint(t.geometry),
ST_MakePoint(mc.trail1_endpoint[1], mc.trail1_endpoint[2]),
$6
)
AND ST_DWithin(
ST_EndPoint(t.geometry),
ST_MakePoint(mc.trail2_endpoint[1], mc.trail2_endpoint[2]),
$6
)
AND t.geometry IS NOT NULL
LIMIT 1
) as original_trail_name
FROM missing_connections mc
)
SELECT
trail1_id,
trail1_name,
trail1_endpoint,
trail2_id,
trail2_name,
trail2_endpoint,
distance_meters,
connection_type,
recommended_tolerance,
has_original_trail,
original_trail_name
FROM verified_missing_connections
WHERE has_original_trail = true -- Only include connections with actual trails
ORDER BY distance_meters ASC
LIMIT 100
`, [
this.config.minTrailLength,
this.config.maxConnectionDistance, // Now 50m for broader detection
this.config.intersectionTolerance,
this.config.endpointTolerance,
this.config.maxConnectionDistance,
50 // Verification tolerance for checking against original trails
]);
const missingConnections = result.rows.map(row => ({
trail1_id: row.trail1_id,
trail1_name: row.trail1_name,
trail1_endpoint: row.trail1_endpoint,
trail2_id: row.trail2_id,
trail2_name: row.trail2_name,
trail2_endpoint: row.trail2_endpoint,
distance_meters: row.distance_meters,
connection_type: row.connection_type,
recommended_tolerance: row.recommended_tolerance,
has_original_trail: row.has_original_trail,
original_trail_name: row.original_trail_name
}));
console.log(`✅ Found ${missingConnections.length} missing connections within ${this.config.maxConnectionDistance}m tolerance (verified against original trails)`);
if (missingConnections.length > 0) {
console.log('🔍 Verified missing connections:');
missingConnections.slice(0, 5).forEach(conn => {
console.log(` • ${conn.trail1_name} ↔ ${conn.trail2_name} (${conn.distance_meters.toFixed(1)}m) - Original trail: ${conn.original_trail_name || 'Unknown'}`);
});
}
return missingConnections;
}
/**
* Find disconnected components using pgRouting's strongly connected components
*/
async findDisconnectedComponents() {
console.log('🔍 Finding disconnected trail components...');
const result = await this.pgClient.query(`
WITH strongly_connected_components AS (
-- Use pgRouting's strongly connected components analysis
SELECT
component,
COUNT(*) as node_count
FROM pgr_strongComponents(
'SELECT id, source, target, length_km as cost FROM ${this.config.stagingSchema}.ways_noded'
)
GROUP BY component
ORDER BY node_count DESC
),
component_trails AS (
-- Map components to trails
SELECT
scc.component,
scc.node_count,
ARRAY_AGG(DISTINCT t.name) as trails,
SUM(ST_Length(t.geometry)) as total_length_km,
ARRAY[
ST_XMin(ST_Collect(t.geometry)),
ST_YMin(ST_Collect(t.geometry)),
ST_XMax(ST_Collect(t.geometry)),
ST_YMax(ST_Collect(t.geometry))
] as bounding_box
FROM strongly_connected_components scc
JOIN ${this.config.stagingSchema}.ways_noded_vertices_pgr v ON v.id IN (
SELECT node FROM pgr_strongComponents(
'SELECT id, source, target, length_km as cost FROM ${this.config.stagingSchema}.ways_noded'
) WHERE component = scc.component
)
JOIN ${this.config.stagingSchema}.node_mapping nm ON v.id = nm.pg_id
JOIN ${this.config.stagingSchema}.edge_mapping em ON nm.pg_id = em.pg_id
JOIN ${this.config.stagingSchema}.trails t ON em.app_uuid = t.app_uuid
GROUP BY scc.component, scc.node_count
)
SELECT
component,
node_count as trail_count,
trails,
total_length_km,
bounding_box
FROM component_trails
ORDER BY trail_count DESC
`);
const disconnectedComponents = result.rows.map(row => ({
component_id: parseInt(row.component),
trail_count: parseInt(row.trail_count),
trails: row.trails,
total_length_km: parseFloat(row.total_length_km),
bounding_box: row.bounding_box
}));
console.log(`✅ Found ${disconnectedComponents.length} disconnected components`);
return disconnectedComponents;
}
/**
* Calculate comprehensive network metrics using pgRouting and PostGIS
*/
async calculateNetworkMetrics() {
console.log('📊 Calculating network metrics...');
const result = await this.pgClient.query(`
WITH network_stats AS (
-- Basic network statistics
SELECT
COUNT(DISTINCT v.id) as total_nodes,
COUNT(DISTINCT e.id) as total_edges,
AVG(degree) as average_degree
FROM ${this.config.stagingSchema}.ways_noded_vertices_pgr v
CROSS JOIN ${this.config.stagingSchema}.ways_noded e
CROSS JOIN LATERAL (
SELECT COUNT(*) as degree
FROM ${this.config.stagingSchema}.ways_noded
WHERE source = v.id OR target = v.id
) deg
),
isolated_nodes AS (
-- Find isolated nodes (degree = 0)
SELECT COUNT(*) as isolated_count
FROM ${this.config.stagingSchema}.ways_noded_vertices_pgr v
WHERE NOT EXISTS (
SELECT 1 FROM ${this.config.stagingSchema}.ways_noded e
WHERE e.source = v.id OR e.target = v.id
)
),
articulation_points AS (
-- Find articulation points using pgRouting
SELECT COUNT(DISTINCT node) as articulation_count
FROM pgr_articulationPoints(
'SELECT id, source, target, length_km as cost FROM ${this.config.stagingSchema}.ways_noded'
)
),
bridges AS (
-- Find bridges using pgRouting
SELECT COUNT(*) as bridge_count
FROM pgr_bridges(
'SELECT id, source, target, length_km as cost FROM ${this.config.stagingSchema}.ways_noded'
)
),
component_analysis AS (
-- Analyze strongly connected components
SELECT
COUNT(*) as component_count,
MAX(node_count) as largest_component_size
FROM (
SELECT component, COUNT(*) as node_count
FROM pgr_strongComponents(
'SELECT id, source, target, length_km as cost FROM ${this.config.stagingSchema}.ways_noded'
)
GROUP BY component
) comp
),
path_analysis AS (
-- Calculate average path length and network diameter
SELECT
AVG(agg_cost) as avg_path_length,
MAX(agg_cost) as network_diameter
FROM (
SELECT DISTINCT agg_cost
FROM pgr_dijkstra(
'SELECT id, source, target, length_km as cost FROM ${this.config.stagingSchema}.ways_noded',
(SELECT id FROM ${this.config.stagingSchema}.ways_noded_vertices_pgr LIMIT 1),
(SELECT array_agg(id) FROM ${this.config.stagingSchema}.ways_noded_vertices_pgr),
false
)
WHERE agg_cost > 0 AND agg_cost < 1000 -- Filter out unreachable nodes
) paths
)
SELECT
ns.total_nodes,
ns.total_edges,
in.isolated_count as isolated_nodes,
ap.articulation_count as articulation_points,
b.bridge_count as bridges,
ns.average_degree,
CASE
WHEN ns.total_nodes > 1 THEN
(ns.total_edges::float / (ns.total_nodes * (ns.total_nodes - 1) / 2)) * 100
ELSE 0
END as network_density,
ca.largest_component_size,
ca.component_count,
COALESCE(pa.avg_path_length, 0) as average_path_length,
COALESCE(pa.network_diameter, 0) as network_diameter
FROM network_stats ns
CROSS JOIN isolated_nodes in
CROSS JOIN articulation_points ap
CROSS JOIN bridges b
CROSS JOIN component_analysis ca
CROSS JOIN path_analysis pa
`);
const metrics = result.rows[0];
const networkMetrics = {
total_nodes: parseInt(metrics.total_nodes),
total_edges: parseInt(metrics.total_edges),
isolated_nodes: parseInt(metrics.isolated_nodes),
articulation_points: parseInt(metrics.articulation_points),
bridges: parseInt(metrics.bridges),
average_degree: parseFloat(metrics.average_degree),
network_density: parseFloat(metrics.network_density),
largest_component_size: parseInt(metrics.largest_component_size),
component_count: parseInt(metrics.component_count),
average_path_length: parseFloat(metrics.average_path_length),
network_diameter: parseFloat(metrics.network_diameter)
};
console.log(`✅ Network metrics calculated`);
return networkMetrics;
}
/**
* Calculate overall network connectivity score
*/
async calculateConnectivityScore() {
const result = await this.pgClient.query(`
WITH trail_connections AS (
-- Get all trail connections through routing edges
SELECT DISTINCT
em1.app_uuid as trail1_id,
em2.app_uuid as trail2_id
FROM ${this.config.stagingSchema}.routing_edges re1
JOIN ${this.config.stagingSchema}.edge_mapping em1 ON re1.id = em1.pg_id
JOIN ${this.config.stagingSchema}.routing_edges re2 ON re1.source = re2.source OR re1.target = re2.target
JOIN ${this.config.stagingSchema}.edge_mapping em2 ON re2.id = em2.pg_id
WHERE em1.app_uuid != em2.app_uuid
),
connected_trails AS (
-- Get all trails that have connections
SELECT DISTINCT trail1_id as trail_id FROM trail_connections
UNION
SELECT DISTINCT trail2_id as trail_id FROM trail_connections
),
trail_stats AS (
SELECT
COUNT(DISTINCT app_uuid) as total_trails,
COUNT(DISTINCT CASE WHEN app_uuid IN (SELECT trail_id FROM connected_trails) THEN app_uuid END) as connected_trails
FROM ${this.config.stagingSchema}.edge_mapping
)
SELECT
CASE
WHEN total_trails = 0 THEN 0
ELSE (connected_trails::float / total_trails::float) * 100
END as connectivity_score
FROM trail_stats
`);
return parseFloat(result.rows[0]?.connectivity_score || '0');
}
/**
* Analyze missing trail segments that exist in production but not in routing network
*/
async analyzeMissingTrailSegments() {
console.log('🔍 Analyzing missing trail segments...');
const productionSchema = this.config.productionSchema || 'public';
// Get original trails from production
const originalTrailsResult = await this.pgClient.query(`
SELECT
app_uuid,
name,
length_km,
elevation_gain,
elevation_loss,
ST_AsText(geometry) as geometry_wkt,
region,
trail_type,
surface,
difficulty,
id as original_trail_id
FROM ${productionSchema}.trails
WHERE region = $1
AND geometry IS NOT NULL
AND ST_IsValid(geometry)
AND ST_Length(geometry) >= $2
`, [this.config.stagingSchema.split('_')[1], this.config.minTrailLength]);
// Get processed trails in routing network
const processedTrailsResult = await this.pgClient.query(`
SELECT DISTINCT
em.app_uuid,
em.trail_name as name,
em.length_km,
em.elevation_gain,
em.elevation_loss
FROM ${this.config.stagingSchema}.edge_mapping em
JOIN ${this.config.stagingSchema}.routing_edges re ON em.pg_id = re.id
`);
const originalTrails = new Set(originalTrailsResult.rows.map(row => row.app_uuid));
const processedTrails = new Set(processedTrailsResult.rows.map(row => row.app_uuid));
// Find missing trails
const missingTrailUuids = new Set([...originalTrails].filter(uuid => !processedTrails.has(uuid)));
const missingSegments = [];
for (const row of originalTrailsResult.rows) {
if (missingTrailUuids.has(row.app_uuid)) {
// Determine why the trail was lost
let reason = 'unknown';
// Check if it's in ways_noded but not in routing_edges
const inWaysNoded = await this.pgClient.query(`
SELECT COUNT(*) as count FROM ${this.config.stagingSchema}.ways_noded
WHERE old_id = $1
`, [row.original_trail_id]);
if (parseInt(inWaysNoded.rows[0].count) === 0) {
reason = 'invalid_geometry';
}
else {
// Check if it's in ways_noded but not in routing_edges
const inRoutingEdges = await this.pgClient.query(`
SELECT COUNT(*) as count FROM ${this.config.stagingSchema}.routing_edges re
JOIN ${this.config.stagingSchema}.ways_noded wn ON re.id = wn.id
WHERE wn.old_id = $1
`, [row.original_trail_id]);
if (parseInt(inRoutingEdges.rows[0].count) === 0) {
reason = 'topology_error';
}
else {
reason = 'node_network_error';
}
}
missingSegments.push({
app_uuid: row.app_uuid,
name: row.name,
length_km: row.length_km,
elevation_gain: row.elevation_gain,
elevation_loss: row.elevation_loss,
geometry: row.geometry_wkt,
reason_lost: reason,
original_trail_id: row.original_trail_id,
region: row.region,
trail_type: row.trail_type,
surface: row.surface,
difficulty: row.difficulty
});
}
}
const analysis = {
original_trails_count: originalTrails.size,
processed_trails_count: processedTrails.size,
lost_trails_count: missingSegments.length,
missing_segments: missingSegments,
restoration_recommendations: this.generateTrailRestorationRecommendations(missingSegments)
};
console.log(`✅ Missing trail analysis complete:`);
console.log(` 📊 Original trails: ${analysis.original_trails_count}`);
console.log(` ✅ Processed trails: ${analysis.processed_trails_count}`);
console.log(` ❌ Lost trails: ${analysis.lost_trails_count}`);
return analysis;
}
/**
* Generate recommendations for restoring missing trail segments
*/
generateTrailRestorationRecommendations(missingSegments) {
const recommendations = [];
if (missingSegments.length === 0) {
recommendations.push('All original trails were successfully processed into the routing network');
return recommendations;
}
// Group by reason lost
const byReason = missingSegments.reduce((acc, segment) => {
if (!acc[segment.reason_lost]) {
acc[segment.reason_lost] = [];
}
acc[segment.reason_lost].push(segment);
return acc;
}, {});
if (byReason.invalid_geometry) {
recommendations.push(`Fix ${byReason.invalid_geometry.length} trails with invalid geometries`);
}
if (byReason.too_short) {
recommendations.push(`Restore ${byReason.too_short.length} trails that were too short (increase minTrailLength)`);
}
if (byReason.topology_error) {
recommendations.push(`Fix ${byReason.topology_error.length} trails with topology errors (check for self-intersections)`);
}
if (byReason.node_network_error) {
recommendations.push(`Restore ${byReason.node_network_error.length} trails lost during node network processing`);
}
if (byReason.unknown) {
recommendations.push(`Investigate ${byReason.unknown.length} trails lost for unknown reasons`);
}
// Add specific recommendations based on trail characteristics
const longTrails = missingSegments.filter(s => s.length_km > 5);
if (longTrails.length > 0) {
recommendations.push(`Priority: Restore ${longTrails.length} long trails (>5km) for better route diversity`);
}
const highElevationTrails = missingSegments.filter(s => s.elevation_gain > 500);
if (highElevationTrails.length > 0) {
recommendations.push(`Priority: Restore ${highElevationTrails.length} high-elevation trails (>500m gain) for challenging routes`);
}
return recommendations;
}
/**
* Generate SQL to restore missing trail segments to the routing network
*/
async generateTrailRestorationSQL(missingSegments) {
console.log('🔧 Generating SQL to restore missing trail segments...');
let sql = `-- Restore missing trail segments to improve network connectivity\n`;
sql += `-- Generated by NetworkConnectivityAnalyzer\n\n`;
// Group by reason for different restoration strategies
const byReason = missingSegments.reduce((acc, segment) => {
if (!acc[segment.reason_lost]) {
acc[segment.reason_lost] = [];
}
acc[segment.reason_lost].push(segment);
return acc;
}, {});
// Strategy 1: Restore trails with invalid geometries by fixing them
if (byReason.invalid_geometry) {
sql += `-- Fix and restore trails with invalid geometries\n`;
for (const segment of byReason.invalid_geometry.slice(0, 20)) { // Limit to prevent huge SQL
sql += `-- Restore: ${segment.name} (${segment.length_km.toFixed(2)}km)\n`;
sql += `INSERT INTO ${this.config.stagingSchema}.ways_noded (old_id, app_uuid, name, the_geom, length_km, elevation_gain, elevation_loss)\n`;
sql += `SELECT \n`;
sql += ` ${segment.original_trail_id} as old_id,\n`;
sql += ` '${segment.app_uuid}' as app_uuid,\n`;
sql += ` '${segment.name.replace(/'/g, "''")}' as name,\n`;
sql += ` ST_GeomFromText('${segment.geometry}', 4326) as the_geom,\n`;
sql += ` ${segment.length_km} as length_km,\n`;
sql += ` ${segment.elevation_gain} as elevation_gain,\n`;
sql += ` ${segment.elevation_loss} as elevation_loss\n`;
sql += `WHERE NOT EXISTS (\n`;
sql += ` SELECT 1 FROM ${this.config.stagingSchema}.ways_noded WHERE old_id = ${segment.original_trail_id}\n`;
sql += `);\n\n`;
}
}
// Strategy 2: Restore trails lost during topology creation
if (byReason.topology_error) {
sql += `-- Restore trails lost during topology creation\n`;
sql += `-- These trails exist in ways_noded but not in routing_edges\n`;
for (const segment of byReason.topology_error.slice(0, 20)) {
sql += `-- Restore routing edge for: ${segment.name}\n`;
sql += `INSERT INTO ${this.config.stagingSchema}.routing_edges (id, source, target, trail_id, geometry, length_km, elevation_gain, elevation_loss)\n`;
sql += `SELECT \n`;
sql += ` wn.id,\n`;
sql += ` wn.source,\n`;
sql += ` wn.target,\n`;
sql += ` '${segment.app_uuid}' as trail_id,\n`;
sql += ` wn.the_geom as geometry,\n`;
sql += ` wn.length_km,\n`;
sql += ` wn.elevation_gain,\n`;
sql += ` wn.elevation_loss\n`;
sql += `FROM ${this.config.stagingSchema}.ways_noded wn\n`;
sql += `WHERE wn.old_id = ${segment.original_trail_id}\n`;
sql += ` AND NOT EXISTS (\n`;
sql += ` SELECT 1 FROM ${this.config.stagingSchema}.routing_edges re WHERE re.id = wn.id\n`;
sql += ` );\n\n`;
}
}
// Strategy 3: Recreate edge mapping for restored trails
sql += `-- Recreate edge mapping for restored trails\n`;
sql += `INSERT INTO ${this.config.stagingSchema}.edge_mapping (pg_id, original_trail_id, app_uuid, trail_name, length_km, elevation_gain, elevation_loss, trail_type, surface, difficulty, max_elevation, min_elevation, avg_elevation)\n`;
sql += `SELECT \n`;
sql += ` wn.id as pg_id,\n`;
sql += ` wn.old_id as original_trail_id,\n`;
sql += ` wn.app_uuid,\n`;
sql += ` COALESCE(wn.name, 'Restored Trail') as trail_name,\n`;
sql += ` wn.length_km,\n`;
sql += ` wn.elevation_gain,\n`;
sql += ` wn.elevation_loss,\n`;
sql += ` 'hiking' as trail_type,\n`;
sql += ` 'dirt' as surface,\n`;
sql += ` 'moderate' as difficulty,\n`;
sql += ` 0 as max_elevation,\n`;
sql += ` 0 as min_elevation,\n`;
sql += ` 0 as avg_elevation\n`;
sql += `FROM ${this.config.stagingSchema}.ways_noded wn\n`;
sql += `WHERE wn.old_id IN (${missingSegments.map(s => s.original_trail_id).filter(id => id).join(',')})\n`;
sql += ` AND NOT EXISTS (\n`;
sql += ` SELECT 1 FROM ${this.config.stagingSchema}.edge_mapping em WHERE em.pg_id = wn.id\n`;
sql += ` );\n\n`;
return sql;
}
/**
* Generate recommendations for improving connectivity
*/
generateRecommendations(missingConnections, disconnectedComponents, networkMetrics, missingTrailSegments) {
const recommendations = [];
if (missingConnections.length > 0) {
recommendations.push(`Add ${missingConnections.length} missing trail connections within ${this.config.maxConnectionDistance}m tolerance`);
const closeConnections = missingConnections.filter(c => c.distance_meters <= this.config.intersectionTolerance);
if (closeConnections.length > 0) {
recommendations.push(`${closeConnections.length} connections are within ${this.config.intersectionTolerance}m and should be high priority`);
}
}
if (disconnectedComponents.length > 1) {
recommendations.push(`Connect ${disconnectedComponents.length} disconnected trail components`);
const largestComponent = disconnectedComponents[0];
const otherComponents = disconnectedComponents.slice(1);
recommendations.push(`Largest component has ${largestComponent.trail_count} trails, ${otherComponents.length} smaller components need connection`);
}
// Network metrics based recommendations
if (networkMetrics.isolated_nodes > 0) {
recommendations.push(`Remove ${networkMetrics.isolated_nodes} isolated nodes to improve connectivity`);
}
if (networkMetrics.articulation_points > 0) {
recommendations.push(`Add connections around ${networkMetrics.articulation_points} articulation points to improve network resilience`);
}
if (networkMetrics.network_density < 10) {
recommendations.push(`Low network density (${networkMetrics.network_density.toFixed(1)}%) - add more connections for better route diversity`);
}
if (networkMetrics.average_path_length > 10) {
recommendations.push(`High average path length (${networkMetrics.average_path_length.toFixed(1)}km) - add shortcuts for better connectivity`);
}
if (networkMetrics.component_count > 1) {
recommendations.push(`Network has ${networkMetrics.component_count} disconnected components - connect them for better route options`);
}
if (missingConnections.length === 0 && disconnectedComponents.length <= 1 && networkMetrics.network_density > 20) {
recommendations.push('Network connectivity is good - focus on route generation improvements');
}
if (missingTrailSegments.length > 0) {
recommendations.push(`${missingTrailSegments.length} trail segments were lost during processing and need restoration.`);
recommendations.push(...this.generateTrailRestorationRecommendations(missingTrailSegments));
}
return recommendations;
}
/**
* Generate SQL to add missing connections to the routing network
*/
async generateConnectionSQL(missingConnections) {
console.log('🔧 Generating SQL to add missing connections...');
let sql = `-- Add missing trail connections to improve route diversity\n`;
sql += `-- Generated by NetworkConnectivityAnalyzer using PostGIS and pgRouting\n\n`;
for (const connection of missingConnections.slice(0, 50)) { // Limit to top 50
sql += `-- Connect ${connection.trail1_name} to ${connection.trail2_name} (${connection.distance_meters.toFixed(1)}m)\n`;
sql += `INSERT INTO ${this.config.stagingSchema}.routing_edges (source, target, trail_id, geometry, length_km, elevation_gain, elevation_loss)\n`;
sql += `SELECT \n`;
sql += ` (SELECT id FROM ${this.config.stagingSchema}.ways_noded_vertices_pgr WHERE ST_DWithin(the_geom, ST_SetSRID(ST_MakePoint(${connection.trail1_endpoint[0]}, ${connection.trail1_endpoint[1]}), 4326), ${connection.recommended_tolerance})) as source,\n`;
sql += ` (SELECT id FROM ${this.config.stagingSchema}.ways_noded_vertices_pgr WHERE ST_DWithin(the_geom, ST_SetSRID(ST_MakePoint(${connection.trail2_endpoint[0]}, ${connection.trail2_endpoint[1]}), 4326), ${connection.recommended_tolerance})) as target,\n`;
sql += ` 'connection-${connection.trail1_id}-${connection.trail2_id}' as trail_id,\n`;
sql += ` ST_SetSRID(ST_MakeLine(ST_MakePoint(${connection.trail1_endpoint[0]}, ${connection.trail1_endpoint[1]}), ST_MakePoint(${connection.trail2_endpoint[0]}, ${connection.trail2_endpoint[1]})), 4326) as geometry,\n`;
sql += ` ${connection.distance_meters / 1000} as length_km,\n`;
sql += ` 0 as elevation_gain,\n`;
sql += ` 0 as elevation_loss\n`;
sql += `WHERE source IS NOT NULL AND target IS NOT NULL;\n\n`;
}
return sql;
}
}
exports.NetworkConnectivityAnalyzer = NetworkConnectivityAnalyzer;
//# sourceMappingURL=network-connectivity-analyzer.js.map