feat: implement geometry snapping logic for manual road insertion and approved roads topology

This commit is contained in:
Hamza-Ayed
2026-08-16 17:34:06 +03:00
parent b3ffa7c71d
commit 03cdc6fe9c
12 changed files with 1431 additions and 453 deletions
@@ -467,6 +467,17 @@ export class GeocodingService {
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
`, [lng, lat]));
queryPromises.push(repo.query(`
SELECT
id::text, COALESCE(name, 'Street') as name, COALESCE(name, 'طريق معتمد') as name_ar, 'street' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(geometry::geometry))::text as latitude, ST_X(ST_Centroid(geometry::geometry))::text as longitude, '' as address, 'approved_road' as source,
ST_DistanceSphere(geometry::geometry, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
FROM approved_roads
WHERE geometry IS NOT NULL AND name IS NOT NULL AND trim(name) != ''
ORDER BY geometry::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
`, [lng, lat]).catch(() => []));
const [results, iraqiAddress] = await Promise.all([
Promise.allSettled(queryPromises),
iraqiAddressPromise,
+358 -22
View File
@@ -91,20 +91,100 @@ export class RoadRefinementController {
const name = body.name || 'Unnamed Road';
const highway = body.highway || 'residential';
const result = await this.dataSource.query(
`INSERT INTO candidate_roads (
geometry, "uniqueDriverCount", "totalPoints", "averageSpeed", "lengthMeters",
confidence, status, source, name, highway, oneway, "discoveredAt"
)
VALUES (
ST_SetSRID(ST_GeomFromGeoJSON($1), 4326),
1, 10, 30.0,
ST_Length(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON($1), 4326), 3857)),
0.90, 'pending', 'manual', $2, $3, 0, NOW()
)
RETURNING id, name, highway, confidence, status`,
[geojsonStr, name, highway]
);
let result;
try {
result = await this.dataSource.query(
`WITH raw_input AS (
SELECT ST_SetSRID(ST_GeomFromGeoJSON($1), 4326) AS geom
),
start_snap AS (
SELECT COALESCE(
(
SELECT ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)
FROM raw_input r
CROSS JOIN LATERAL (SELECT ST_Transform(ST_StartPoint(r.geom), 3857) AS pt) p
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
JOIN planet_osm_ways w ON w.id = l.osm_id
CROSS JOIN LATERAL unnest(w.nodes) AS wn(node_id)
JOIN planet_osm_nodes n ON n.id = wn.node_id
WHERE ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, ST_StartPoint(r.geom)::geography) < 30
ORDER BY ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, ST_StartPoint(r.geom)::geography) ASC
LIMIT 1
),
(
SELECT ST_Transform(ST_ClosestPoint(l.way, ST_Transform(ST_StartPoint(r.geom), 3857)), 4326)
FROM raw_input r
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(ST_Transform(ST_StartPoint(r.geom), 3857), 60)
WHERE ST_Distance(ST_Transform(l.way, 4326)::geography, ST_StartPoint(r.geom)::geography) < 30
ORDER BY ST_Distance(l.way, ST_Transform(ST_StartPoint(r.geom), 3857)) ASC
LIMIT 1
),
(SELECT ST_StartPoint(geom) FROM raw_input)
) AS pt
),
end_snap AS (
SELECT COALESCE(
(
SELECT ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)
FROM raw_input r
CROSS JOIN LATERAL (SELECT ST_Transform(ST_EndPoint(r.geom), 3857) AS pt) p
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
JOIN planet_osm_ways w ON w.id = l.osm_id
CROSS JOIN LATERAL unnest(w.nodes) AS wn(node_id)
JOIN planet_osm_nodes n ON n.id = wn.node_id
WHERE ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, ST_EndPoint(r.geom)::geography) < 30
ORDER BY ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, ST_EndPoint(r.geom)::geography) ASC
LIMIT 1
),
(
SELECT ST_Transform(ST_ClosestPoint(l.way, ST_Transform(ST_EndPoint(r.geom), 3857)), 4326)
FROM raw_input r
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(ST_Transform(ST_EndPoint(r.geom), 3857), 60)
WHERE ST_Distance(ST_Transform(l.way, 4326)::geography, ST_EndPoint(r.geom)::geography) < 30
ORDER BY ST_Distance(l.way, ST_Transform(ST_EndPoint(r.geom), 3857)) ASC
LIMIT 1
),
(SELECT ST_EndPoint(geom) FROM raw_input)
) AS pt
),
snapped_input AS (
SELECT ST_SetPoint(
ST_SetPoint(r.geom, 0, s.pt),
ST_NPoints(r.geom) - 1,
e.pt
) AS geom
FROM raw_input r, start_snap s, end_snap e
)
INSERT INTO candidate_roads (
geometry, "uniqueDriverCount", "totalPoints", "averageSpeed", "lengthMeters",
confidence, status, source, name, highway, oneway, "discoveredAt"
)
SELECT
geom,
1, 10, 30.0,
ST_Length(ST_Transform(geom, 3857)),
0.90, 'pending', 'manual', $2, $3, 0, NOW()
FROM snapped_input
RETURNING id, name, highway, confidence, status`,
[geojsonStr, name, highway]
);
} catch (err: any) {
this.logger.warn(`Complex snapping query failed, falling back to direct insertion: ${err.message}`);
result = await this.dataSource.query(
`INSERT INTO candidate_roads (
geometry, "uniqueDriverCount", "totalPoints", "averageSpeed", "lengthMeters",
confidence, status, source, name, highway, oneway, "discoveredAt"
)
VALUES (
ST_SetSRID(ST_GeomFromGeoJSON($1), 4326),
1, 10, 30.0,
ST_Length(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON($1), 4326), 3857)),
0.90, 'pending', 'manual', $2, $3, 0, NOW()
)
RETURNING id, name, highway, confidence, status`,
[geojsonStr, name, highway]
);
}
this.logger.log(`✍️ Manual candidate road registered: ${result[0]?.id} (${name})`);
return { success: true, candidate: result[0] };
@@ -115,7 +195,7 @@ export class RoadRefinementController {
}
@Patch('candidates/:id/approve')
@ApiOperation({ summary: 'Approve candidate road and move to approved_roads ✅' })
@ApiOperation({ summary: 'Approve candidate road and move to approved_roads with auto-snapped topology ✅' })
@ApiParam({ name: 'id', description: 'Candidate UUID' })
async approveCandidate(@Param('id') id: string) {
try {
@@ -123,7 +203,7 @@ export class RoadRefinementController {
await this.dataSource.query(`
CREATE TABLE IF NOT EXISTS approved_roads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
candidate_id UUID,
candidate_id UUID UNIQUE,
geometry GEOMETRY(LineString, 4326),
name VARCHAR(255),
highway VARCHAR(32),
@@ -135,6 +215,7 @@ export class RoadRefinementController {
approved_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS approved_roads_geom_idx ON approved_roads USING GIST(geometry);
CREATE UNIQUE INDEX IF NOT EXISTS approved_roads_cand_uidx ON approved_roads(candidate_id) WHERE candidate_id IS NOT NULL;
`);
// Update candidate status
@@ -143,15 +224,160 @@ export class RoadRefinementController {
reviewedAt: new Date(),
});
// Insert into approved_roads
// Snap geometry & resolve OSM start_node / end_node for routing
await this.dataSource.query(`
INSERT INTO approved_roads (candidate_id, geometry, name, highway, confidence, "uniqueDriverCount", oneway)
SELECT id, geometry::geometry, name, COALESCE(highway, 'residential'), confidence, "uniqueDriverCount", oneway
FROM candidate_roads
WHERE id = $1
ON CONFLICT DO NOTHING
DO $$
DECLARE
cand RECORD;
start_pt GEOMETRY;
end_pt GEOMETRY;
snapped_geom GEOMETRY;
s_node BIGINT := NULL;
e_node BIGINT := NULL;
node_rec RECORD;
line_rec RECORD;
BEGIN
SELECT id, geometry::geometry as geom, name, COALESCE(highway, 'residential') as highway, confidence, "uniqueDriverCount", oneway
INTO cand
FROM candidate_roads WHERE id = $1;
IF FOUND THEN
snapped_geom := cand.geom;
start_pt := ST_StartPoint(snapped_geom);
end_pt := ST_EndPoint(snapped_geom);
-- 1. Start node & geometry snapping
BEGIN
WITH p AS (SELECT ST_Transform(start_pt, 3857) AS pt)
SELECT n.id, n.lon/1e7 as lon, n.lat/1e7 as lat
INTO node_rec
FROM p
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
JOIN planet_osm_ways w ON w.id = l.osm_id
CROSS JOIN LATERAL unnest(w.nodes) AS wn(node_id)
JOIN planet_osm_nodes n ON n.id = wn.node_id
WHERE ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, start_pt::geography) < 30
ORDER BY ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, start_pt::geography) ASC
LIMIT 1;
IF FOUND THEN
s_node := node_rec.id;
snapped_geom := ST_SetPoint(snapped_geom, 0, ST_SetSRID(ST_MakePoint(node_rec.lon, node_rec.lat), 4326));
ELSE
WITH p AS (SELECT ST_Transform(start_pt, 3857) AS pt)
SELECT ST_X(ST_Transform(ST_ClosestPoint(l.way, p.pt), 4326)) as lon,
ST_Y(ST_Transform(ST_ClosestPoint(l.way, p.pt), 4326)) as lat
INTO line_rec
FROM p
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
WHERE ST_Distance(ST_Transform(l.way, 4326)::geography, start_pt::geography) < 30
ORDER BY ST_Distance(l.way, p.pt) ASC
LIMIT 1;
IF FOUND THEN
snapped_geom := ST_SetPoint(snapped_geom, 0, ST_SetSRID(ST_MakePoint(line_rec.lon, line_rec.lat), 4326));
END IF;
END IF;
EXCEPTION WHEN OTHERS THEN
NULL;
END;
-- 2. End node & geometry snapping
BEGIN
WITH p AS (SELECT ST_Transform(end_pt, 3857) AS pt)
SELECT n.id, n.lon/1e7 as lon, n.lat/1e7 as lat
INTO node_rec
FROM p
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
JOIN planet_osm_ways w ON w.id = l.osm_id
CROSS JOIN LATERAL unnest(w.nodes) AS wn(node_id)
JOIN planet_osm_nodes n ON n.id = wn.node_id
WHERE ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, end_pt::geography) < 30
ORDER BY ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, end_pt::geography) ASC
LIMIT 1;
IF FOUND THEN
e_node := node_rec.id;
snapped_geom := ST_SetPoint(snapped_geom, ST_NPoints(snapped_geom) - 1, ST_SetSRID(ST_MakePoint(node_rec.lon, node_rec.lat), 4326));
ELSE
WITH p AS (SELECT ST_Transform(end_pt, 3857) AS pt)
SELECT ST_X(ST_Transform(ST_ClosestPoint(l.way, p.pt), 4326)) as lon,
ST_Y(ST_Transform(ST_ClosestPoint(l.way, p.pt), 4326)) as lat
INTO line_rec
FROM p
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
WHERE ST_Distance(ST_Transform(l.way, 4326)::geography, end_pt::geography) < 30
ORDER BY ST_Distance(l.way, p.pt) ASC
LIMIT 1;
IF FOUND THEN
snapped_geom := ST_SetPoint(snapped_geom, ST_NPoints(snapped_geom) - 1, ST_SetSRID(ST_MakePoint(line_rec.lon, line_rec.lat), 4326));
END IF;
END IF;
EXCEPTION WHEN OTHERS THEN
NULL;
END;
-- Update candidate_roads
UPDATE candidate_roads SET geometry = snapped_geom WHERE id = cand.id;
-- Insert or update approved_roads
INSERT INTO approved_roads (
candidate_id, geometry, name, highway, confidence, "uniqueDriverCount", oneway, start_node, end_node
)
VALUES (
cand.id, snapped_geom, cand.name, cand.highway, cand.confidence, cand."uniqueDriverCount", cand.oneway, s_node, e_node
)
ON CONFLICT (candidate_id) DO UPDATE SET
geometry = EXCLUDED.geometry,
start_node = EXCLUDED.start_node,
end_node = EXCLUDED.end_node;
END IF;
END $$;
`, [id]);
// If road has a name, index it into places geocoding table
try {
await this.dataSource.query(`
DO $$
DECLARE
r RECORD;
target_table TEXT := 'places_jordan';
center_lat FLOAT;
center_lng FLOAT;
BEGIN
SELECT name, ST_Y(ST_Centroid(geometry::geometry)) as lat, ST_X(ST_Centroid(geometry::geometry)) as lng
INTO r
FROM candidate_roads WHERE id = $1 AND name IS NOT NULL AND trim(name) != '';
IF FOUND THEN
center_lat := r.lat;
center_lng := r.lng;
IF center_lat >= 29 AND center_lat <= 37.5 AND center_lng >= 38.7 AND center_lng <= 48.8 THEN
target_table := 'places_iraq';
ELSIF center_lat >= 29 AND center_lat <= 37.5 AND center_lng >= 34.5 AND center_lng <= 42.5 THEN
IF center_lat > 32.5 AND center_lng > 35.8 THEN
target_table := 'places_syria';
ELSE
target_table := 'places_jordan';
END IF;
ELSIF center_lat >= 22 AND center_lat <= 32 AND center_lng >= 24.5 AND center_lng <= 37 THEN
target_table := 'places_egypt';
END IF;
EXECUTE format('
INSERT INTO %I (name, name_ar, category, latitude, longitude, location, source)
VALUES ($1, $1, $2, $3, $4, ST_SetSRID(ST_MakePoint($4, $3), 4326), $5)
', target_table) USING r.name, 'street', center_lat, center_lng, 'approved_road';
END IF;
END $$;
`, [id]);
} catch (err: any) {
this.logger.warn(`Could not index approved road to places: ${err.message}`);
}
this.logger.log(`✅ Road candidate ${id} approved & published.`);
return { success: true, id, status: 'approved' };
} catch (e: any) {
@@ -231,4 +457,114 @@ export class RoadRefinementController {
async discoverClosures() {
return { success: true, roadsClosed: 0 };
}
@Post('snap-existing-roads')
@ApiOperation({ summary: 'Auto-snap and resolve topology nodes for all existing approved roads' })
async snapExistingApprovedRoads() {
try {
await this.dataSource.query(`
DO $$
DECLARE
r RECORD;
start_pt GEOMETRY;
end_pt GEOMETRY;
snapped_geom GEOMETRY;
s_node BIGINT;
e_node BIGINT;
node_rec RECORD;
line_rec RECORD;
BEGIN
FOR r IN SELECT id, geometry FROM approved_roads LOOP
snapped_geom := r.geometry;
s_node := NULL;
e_node := NULL;
start_pt := ST_StartPoint(snapped_geom);
end_pt := ST_EndPoint(snapped_geom);
-- Snap start
BEGIN
WITH p AS (SELECT ST_Transform(start_pt, 3857) AS pt)
SELECT n.id, n.lon/1e7 as lon, n.lat/1e7 as lat
INTO node_rec
FROM p
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
JOIN planet_osm_ways w ON w.id = l.osm_id
CROSS JOIN LATERAL unnest(w.nodes) AS wn(node_id)
JOIN planet_osm_nodes n ON n.id = wn.node_id
WHERE ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, start_pt::geography) < 30
ORDER BY ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, start_pt::geography) ASC
LIMIT 1;
IF FOUND THEN
s_node := node_rec.id;
snapped_geom := ST_SetPoint(snapped_geom, 0, ST_SetSRID(ST_MakePoint(node_rec.lon, node_rec.lat), 4326));
ELSE
WITH p AS (SELECT ST_Transform(start_pt, 3857) AS pt)
SELECT ST_X(ST_Transform(ST_ClosestPoint(l.way, p.pt), 4326)) as lon,
ST_Y(ST_Transform(ST_ClosestPoint(l.way, p.pt), 4326)) as lat
INTO line_rec
FROM p
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
WHERE ST_Distance(ST_Transform(l.way, 4326)::geography, start_pt::geography) < 30
ORDER BY ST_Distance(l.way, p.pt) ASC
LIMIT 1;
IF FOUND THEN
snapped_geom := ST_SetPoint(snapped_geom, 0, ST_SetSRID(ST_MakePoint(line_rec.lon, line_rec.lat), 4326));
END IF;
END IF;
EXCEPTION WHEN OTHERS THEN
NULL;
END;
-- Snap end
BEGIN
WITH p AS (SELECT ST_Transform(end_pt, 3857) AS pt)
SELECT n.id, n.lon/1e7 as lon, n.lat/1e7 as lat
INTO node_rec
FROM p
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
JOIN planet_osm_ways w ON w.id = l.osm_id
CROSS JOIN LATERAL unnest(w.nodes) AS wn(node_id)
JOIN planet_osm_nodes n ON n.id = wn.node_id
WHERE ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, end_pt::geography) < 30
ORDER BY ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, end_pt::geography) ASC
LIMIT 1;
IF FOUND THEN
e_node := node_rec.id;
snapped_geom := ST_SetPoint(snapped_geom, ST_NPoints(snapped_geom) - 1, ST_SetSRID(ST_MakePoint(node_rec.lon, node_rec.lat), 4326));
ELSE
WITH p AS (SELECT ST_Transform(end_pt, 3857) AS pt)
SELECT ST_X(ST_Transform(ST_ClosestPoint(l.way, p.pt), 4326)) as lon,
ST_Y(ST_Transform(ST_ClosestPoint(l.way, p.pt), 4326)) as lat
INTO line_rec
FROM p
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
WHERE ST_Distance(ST_Transform(l.way, 4326)::geography, end_pt::geography) < 30
ORDER BY ST_Distance(l.way, p.pt) ASC
LIMIT 1;
IF FOUND THEN
snapped_geom := ST_SetPoint(snapped_geom, ST_NPoints(snapped_geom) - 1, ST_SetSRID(ST_MakePoint(line_rec.lon, line_rec.lat), 4326));
END IF;
END IF;
EXCEPTION WHEN OTHERS THEN
NULL;
END;
UPDATE approved_roads
SET geometry = snapped_geom,
start_node = COALESCE(s_node, start_node),
end_node = COALESCE(e_node, end_node)
WHERE id = r.id;
END LOOP;
END $$;
`);
return { success: true, message: 'All approved roads snapped & nodes resolved' };
} catch (e: any) {
this.logger.error(`Failed to snap existing roads: ${e.message}`);
throw new HttpException(e.message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}