From 03cdc6fe9cb894d35b839dd50a3b17f97cc5a072 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 16 Aug 2026 17:34:06 +0300 Subject: [PATCH] feat: implement geometry snapping logic for manual road insertion and approved roads topology --- apps/api/src/geocoding/geocoding.service.ts | 11 + .../src/maps/road-refinement.controller.ts | 380 +++++++++++++- apps/web/package-lock.json | 6 + apps/web/package.json | 1 + apps/web/public/style.json | 262 +++++++++- apps/web/src/App.tsx | 35 +- apps/web/src/components/MapComponent.tsx | 484 +++++------------- apps/web/src/pages/CompareView.tsx | 147 +++++- apps/web/src/pages/IntelligenceDashboard.tsx | 183 +++++-- .../scripts/build_jordan_contours.sh | 43 ++ .../scripts/generate_jordan_contours.py | 70 +++ style.json | 262 +++++++++- 12 files changed, 1431 insertions(+), 453 deletions(-) create mode 100644 infrastructure/scripts/build_jordan_contours.sh create mode 100644 infrastructure/scripts/generate_jordan_contours.py diff --git a/apps/api/src/geocoding/geocoding.service.ts b/apps/api/src/geocoding/geocoding.service.ts index d5235e8..e41d226 100644 --- a/apps/api/src/geocoding/geocoding.service.ts +++ b/apps/api/src/geocoding/geocoding.service.ts @@ -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, diff --git a/apps/api/src/maps/road-refinement.controller.ts b/apps/api/src/maps/road-refinement.controller.ts index c164150..5e49691 100644 --- a/apps/api/src/maps/road-refinement.controller.ts +++ b/apps/api/src/maps/road-refinement.controller.ts @@ -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); + } + } } diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 3c46029..7375836 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -12,6 +12,7 @@ "axios": "^1.13.6", "leaflet": "^1.9.4", "lucide-react": "^0.577.0", + "maplibre-contour": "^0.1.0", "maplibre-gl": "^5.20.2", "react": "^19.2.4", "react-dom": "^19.2.4" @@ -2264,6 +2265,11 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/maplibre-contour": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/maplibre-contour/-/maplibre-contour-0.1.0.tgz", + "integrity": "sha512-H8muT7JWYE4oLbFv7L2RSbIM1NOu5JxjA9P/TQqhODDnRChE8ENoDkQIWOKgfcKNU77ypLk2ggGoh4/pt4UPLA==" + }, "node_modules/maplibre-gl": { "version": "5.20.2", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.20.2.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 45b6b24..bb60e76 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "axios": "^1.13.6", "leaflet": "^1.9.4", "lucide-react": "^0.577.0", + "maplibre-contour": "^0.1.0", "maplibre-gl": "^5.20.2", "react": "^19.2.4", "react-dom": "^19.2.4" diff --git a/apps/web/public/style.json b/apps/web/public/style.json index 9d0560a..d11c30a 100644 --- a/apps/web/public/style.json +++ b/apps/web/public/style.json @@ -84,6 +84,23 @@ "https://tiles.intaleqapp.com/places_iraq/{z}/{x}/{y}" ], "maxzoom": 14 + }, + "terrain-source": { + "type": "raster-dem", + "tiles": [ + "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png" + ], + "encoding": "terrarium", + "tileSize": 256, + "maxzoom": 15 + }, + "jordan_contours": { + "type": "vector", + "tiles": [ + "https://tiles.intaleqapp.com/jordan_contours/{z}/{x}/{y}" + ], + "minzoom": 10, + "maxzoom": 16 } }, "layers": [ @@ -94,6 +111,108 @@ "background-color": "#F6F4F0" } }, + { + "id": "hillshading", + "type": "hillshade", + "source": "terrain-source", + "layout": { + "visibility": "visible" + }, + "paint": { + "hillshade-illumination-direction": 135, + "hillshade-illumination-anchor": "viewport", + "hillshade-shadow-color": "#2c1c0a", + "hillshade-highlight-color": "#ffffff", + "hillshade-accent-color": "#000000", + "hillshade-exaggeration": 0.4 + } + }, + { + "id": "admin-boundary-national", + "type": "line", + "source": "local-osm-lines", + "source-layer": "planet_osm_line", + "filter": [ + "all", + ["==", "boundary", "administrative"], + ["in", "admin_level", "2", 2, "3", 3] + ], + "paint": { + "line-color": "#1e293b", + "line-width": 3, + "line-dasharray": [6, 2, 2, 2] + } + }, + { + "id": "admin-boundary-governorate-poly", + "type": "line", + "source": "local-osm-polygons", + "source-layer": "planet_osm_polygon", + "minzoom": 5, + "filter": [ + "all", + ["==", "boundary", "administrative"], + ["in", "admin_level", "4", 4, "5", 5] + ], + "paint": { + "line-color": "#4f46e5", + "line-width": 2.2, + "line-dasharray": [4, 2], + "line-opacity": 0.9 + } + }, + { + "id": "admin-boundary-governorate", + "type": "line", + "source": "local-osm-lines", + "source-layer": "planet_osm_line", + "minzoom": 5, + "filter": [ + "all", + ["==", "boundary", "administrative"], + ["in", "admin_level", "4", 4, "5", 5] + ], + "paint": { + "line-color": "#4f46e5", + "line-width": 2.2, + "line-dasharray": [4, 2] + } + }, + { + "id": "admin-boundary-district-poly", + "type": "line", + "source": "local-osm-polygons", + "source-layer": "planet_osm_polygon", + "minzoom": 9, + "filter": [ + "all", + ["==", "boundary", "administrative"], + ["in", "admin_level", "6", 6, "7", 7, "8", 8, "9", 9, "10", 10] + ], + "paint": { + "line-color": "#64748b", + "line-width": 1.5, + "line-dasharray": [3, 2], + "line-opacity": 0.85 + } + }, + { + "id": "admin-boundary-district", + "type": "line", + "source": "local-osm-lines", + "source-layer": "planet_osm_line", + "minzoom": 9, + "filter": [ + "all", + ["==", "boundary", "administrative"], + ["in", "admin_level", "6", 6, "7", 7, "8", 8, "9", 9, "10", 10] + ], + "paint": { + "line-color": "#64748b", + "line-width": 1.5, + "line-dasharray": [3, 2] + } + }, { "id": "landuse-residential", "type": "fill", @@ -920,22 +1039,28 @@ "line-join": "round" }, "paint": { - "line-color": "#B9C2CE", + "line-color": "#D6DBE1", "line-width": [ "interpolate", [ - "linear" + "exponential", + 1.6 ], [ "zoom" ], - 12, - 2.2, + 12.5, + 1.6, + 15, + 4.5, 16, - 10 + 10, + 18, + 15 ], - "line-opacity": 0.9 - } + "line-opacity": 0.7 + }, + "minzoom": 12.5 }, { "id": "approved-road-core", @@ -951,17 +1076,23 @@ "line-width": [ "interpolate", [ - "linear" + "exponential", + 1.6 ], [ "zoom" ], - 12, - 0.8, + 12.5, + 1.0, + 15, + 3.2, 16, - 8 + 8, + 18, + 12 ] - } + }, + "minzoom": 12.5 }, { "id": "road-casing-tertiary", @@ -1258,12 +1389,60 @@ "fill-outline-color": "#C8C0B2" } }, + { + "id": "building-3d-osm", + "type": "fill-extrusion", + "source": "local-osm-polygons", + "source-layer": "planet_osm_polygon", + "minzoom": 13, + "filter": [ + "has", + "building" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-extrusion-color": "#DDD8D0", + "fill-extrusion-height": [ + "coalesce", + [ + "to-number", + [ + "get", + "height" + ], + null + ], + [ + "*", + [ + "coalesce", + [ + "to-number", + [ + "get", + "building:levels" + ], + null + ], + 3 + ], + 3.5 + ], + 12 + ], + "fill-extrusion-base": 0, + "fill-extrusion-opacity": 0.85, + "fill-extrusion-vertical-gradient": true + } + }, { "id": "building-3d", "type": "fill-extrusion", "source": "overture_buildings", "source-layer": "overture_building", - "minzoom": 16, + "minzoom": 13, "layout": { "visibility": "visible" }, @@ -1277,7 +1456,7 @@ [ "zoom" ], - 14, + 13, [ "*", [ @@ -1333,10 +1512,10 @@ [ "zoom" ], - 14, - 0.55, + 13, + 0.6, 16, - 0.85 + 0.9 ], "fill-extrusion-vertical-gradient": true } @@ -1747,6 +1926,55 @@ "text-halo-width": 1.8 } }, + { + "id": "approved-road-labels", + "type": "symbol", + "source": "approved_roads", + "source-layer": "approved_roads", + "minzoom": 15, + "layout": { + "text-field": [ + "coalesce", + [ + "get", + "name:ar" + ], + [ + "get", + "name" + ], + "" + ], + "text-font": [ + "Noto Sans Regular" + ], + "text-size": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 15, + 10, + 18, + 13 + ], + "symbol-placement": "line", + "text-letter-spacing": 0.05, + "text-padding": 15, + "symbol-spacing": 300, + "text-max-angle": 30, + "text-allow-overlap": false, + "text-ignore-placement": false + }, + "paint": { + "text-color": "#4A5568", + "text-halo-color": "rgba(255,255,255,0.92)", + "text-halo-width": 1.8 + } + }, { "id": "road-labels-major", "type": "symbol", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8ac1630..34fea1f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -11,6 +11,9 @@ function App() { const [loading, setLoading] = useState(false); const [show3D, setShow3D] = useState(false); const [showPOIs, setShowPOIs] = useState(true); + const [showTerrain, setShowTerrain] = useState(true); + const [showContours, setShowContours] = useState(true); + const [showAdminBoundaries, setShowAdminBoundaries] = useState(true); // Geocoding State const [searchQuery, setSearchQuery] = useState(''); @@ -254,20 +257,41 @@ function App() {
-
-

Options / خيارات

+
+

Layers & Map Options / خيارات الخريطة

+
+ +
+ +
+ +
+ +
+ +
+
@@ -290,6 +314,9 @@ function App() { onMapClick={handleMapClick} show3D={show3D} showPOIs={showPOIs} + showTerrain={showTerrain} + showContours={showContours} + showAdminBoundaries={showAdminBoundaries} /> {/* Add Place Modal */} diff --git a/apps/web/src/components/MapComponent.tsx b/apps/web/src/components/MapComponent.tsx index d38a9e7..f13d4fe 100644 --- a/apps/web/src/components/MapComponent.tsx +++ b/apps/web/src/components/MapComponent.tsx @@ -1,44 +1,89 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; +import mlcontour from 'maplibre-contour'; import { attachIconLoader } from '../utils/mapIcons'; + +const demSource = new mlcontour.DemSource({ + url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png', + encoding: 'terrarium', + maxzoom: 14, + worker: true, +}); +demSource.setupMaplibre(maplibregl); + interface MapComponentProps { onMapLoad: (map: maplibregl.Map) => void; onMapClick?: (lat: number, lng: number) => void; show3D?: boolean; showPOIs?: boolean; + showTerrain?: boolean; + showContours?: boolean; + showAdminBoundaries?: boolean; } const MapComponent: React.FC = ({ onMapLoad, onMapClick, show3D = false, - showPOIs = true + showPOIs = true, + showTerrain = true, + showContours = false, + showAdminBoundaries = true }) => { const mapContainer = useRef(null); const map = useRef(null); // Sync toggles with map layers useEffect(() => { - if (!map.current) return; + if (!map.current || !map.current.isStyleLoaded()) return; - // Toggle 3D Buildings (now on Overture source) - if (map.current.getLayer('building-3d')) { - map.current.setLayoutProperty('building-3d', 'visibility', show3D ? 'visible' : 'none'); - } - // Toggle flat Overture footprint (show when 3D is off) + // Toggle 3D Buildings + ['building-3d', 'building-3d-osm'].forEach(layerId => { + if (map.current!.getLayer(layerId)) { + map.current!.setLayoutProperty(layerId, 'visibility', show3D ? 'visible' : 'none'); + } + }); if (map.current.getLayer('overture-building-footprint')) { map.current.setLayoutProperty('overture-building-footprint', 'visibility', show3D ? 'none' : 'visible'); } + map.current.easeTo({ pitch: show3D ? 55 : 0, duration: 600 }); // Toggle POI Layers - const poiLayers = ['poi-icons', 'place-labels', 'overture-building-names']; + const poiLayers = ['poi-icons', 'place-labels', 'overture-building-names', 'places-jordan-labels']; poiLayers.forEach(layerId => { if (map.current!.getLayer(layerId)) { map.current!.setLayoutProperty(layerId, 'visibility', showPOIs ? 'visible' : 'none'); } }); - }, [show3D, showPOIs]); + + // Toggle Hillshading / Terrain + if (map.current.getLayer('hillshading')) { + map.current.setLayoutProperty('hillshading', 'visibility', showTerrain ? 'visible' : 'none'); + } + + // Toggle Contours + const contourLayers = ['contour-lines-minor', 'contour-lines-major', 'contour-labels']; + contourLayers.forEach(layerId => { + if (map.current!.getLayer(layerId)) { + map.current!.setLayoutProperty(layerId, 'visibility', showContours ? 'visible' : 'none'); + } + }); + + // Toggle Admin Boundaries + const adminLayers = [ + 'admin-boundary-national', + 'admin-boundary-governorate-poly', + 'admin-boundary-governorate', + 'admin-boundary-district-poly', + 'admin-boundary-district' + ]; + adminLayers.forEach(layerId => { + if (map.current!.getLayer(layerId)) { + map.current!.setLayoutProperty(layerId, 'visibility', showAdminBoundaries ? 'visible' : 'none'); + } + }); + }, [show3D, showPOIs, showTerrain, showContours, showAdminBoundaries]); useEffect(() => { if (map.current) return; @@ -47,350 +92,21 @@ const MapComponent: React.FC = ({ // Load RTL Text Plugin for correct Arabic rendering if (maplibregl.getRTLTextPluginStatus() === 'unavailable') { maplibregl.setRTLTextPlugin( - '/rtl-plugin.js', // Localized to bypass ORB - true // Lazy load + '/rtl-plugin.js', + true ); } - const tilesUrl = (import.meta as any).env.VITE_TILES_URL || 'https://tiles.intaleqapp.com'; - try { const initialMap = new maplibregl.Map({ container: mapContainer.current, - style: { - version: 8, - glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf', - sources: { - 'local-osm-polygons': { - type: 'vector', - tiles: [`${tilesUrl}/planet_osm_polygon/{z}/{x}/{y}`], - maxzoom: 14 - }, - 'local-osm-lines': { - type: 'vector', - tiles: [`${tilesUrl}/planet_osm_line/{z}/{x}/{y}`], - maxzoom: 14 - }, - 'local-osm-points': { - type: 'vector', - tiles: [`${tilesUrl}/planet_osm_point/{z}/{x}/{y}`], - maxzoom: 14 - }, - 'approved_roads': { - type: 'vector', - tiles: [`${tilesUrl}/approved_roads/{z}/{x}/{y}`], - minzoom: 8, - maxzoom: 18 - } - }, - layers: [ - { - id: 'background', - type: 'background', - paint: { 'background-color': '#f8f9fa' } - }, - { - id: 'water-layer', - type: 'fill', - source: 'local-osm-polygons', - 'source-layer': 'planet_osm_polygon', - filter: ['in', 'natural', 'water', 'lake', 'riverbank'], - paint: { 'fill-color': '#a3ccff' } - }, - { - id: 'park-layer', - type: 'fill', - source: 'local-osm-polygons', - 'source-layer': 'planet_osm_polygon', - filter: ['in', 'leisure', 'park', 'garden', 'nature_reserve', 'pitch'], - paint: { 'fill-color': '#dcedc8' } - }, - { - id: 'landuse-layer', - type: 'fill', - source: 'local-osm-polygons', - 'source-layer': 'planet_osm_polygon', - filter: ["in", "landuse", "residential", "commercial", "industrial", "cemetery"], - paint: { - "fill-color": [ - "match", - ["get", "landuse"], - "residential", "#f1f3f4", - "commercial", "#f8f9fa", - "industrial", "#f1f3f4", - "cemetery", "#dcedc8", - "#f1f3f4" - ] - } - }, - { - id: 'building-3d', - type: 'fill-extrusion', - source: 'local-osm-polygons', - 'source-layer': 'planet_osm_polygon', - minzoom: 15, - filter: ['has', 'building'], - layout: { - visibility: show3D ? 'visible' : 'none' - }, - paint: { - 'fill-extrusion-color': '#e8eaed', - 'fill-extrusion-height': 20, - 'fill-extrusion-base': 0, - 'fill-extrusion-opacity': 0.8 - } - }, - { - id: 'road-casing-minor', - type: 'line', - source: 'local-osm-lines', - 'source-layer': 'planet_osm_line', - filter: ['in', 'highway', 'residential', 'service', 'unclassified', 'living_street', 'pedestrian', 'path', 'track'], - paint: { - 'line-color': '#d4d4d4', - 'line-width': ['interpolate', ['linear'], ['zoom'], 13, 1, 16, 8] as any - } - }, - { - id: 'approved-casing', - type: 'line', - source: 'approved_roads', - 'source-layer': 'approved_roads', - paint: { - 'line-color': '#d4d4d4', - 'line-width': ['interpolate', ['linear'], ['zoom'], 13, 1, 16, 8] as any - } - }, - { - id: 'approved-core', - type: 'line', - source: 'approved_roads', - 'source-layer': 'approved_roads', - paint: { - 'line-color': '#ffffff', - 'line-width': ['interpolate', ['linear'], ['zoom'], 13, 0.5, 16, 6] as any - } - }, - { - id: 'road-core-minor', - type: 'line', - source: 'local-osm-lines', - 'source-layer': 'planet_osm_line', - filter: ['in', 'highway', 'residential', 'service', 'unclassified', 'living_street', 'pedestrian', 'path', 'track'], - paint: { - 'line-color': '#ffffff', - 'line-width': ['interpolate', ['linear'], ['zoom'], 13, 0.5, 16, 6] as any - } - }, - { - id: 'road-casing-tertiary', - type: 'line', - source: 'local-osm-lines', - 'source-layer': 'planet_osm_line', - filter: ['in', 'highway', 'tertiary', 'tertiary_link'], - paint: { - 'line-color': '#e0e0e0', - 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 1.5, 16, 12] as any - } - }, - { - id: 'road-core-tertiary', - type: 'line', - source: 'local-osm-lines', - 'source-layer': 'planet_osm_line', - filter: ['in', 'highway', 'tertiary', 'tertiary_link'], - paint: { - 'line-color': '#ffffff', - 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 1, 16, 9] as any - } - }, - { - id: 'road-casing-secondary', - type: 'line', - source: 'local-osm-lines', - 'source-layer': 'planet_osm_line', - filter: ['in', 'highway', 'secondary', 'secondary_link'], - paint: { - 'line-color': '#cfd8dc', - 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 2, 16, 14] as any - } - }, - { - id: 'road-core-secondary', - type: 'line', - source: 'local-osm-lines', - 'source-layer': 'planet_osm_line', - filter: ['in', 'highway', 'secondary', 'secondary_link'], - paint: { - 'line-color': '#f1f5f9', - 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 1.5, 16, 11] as any - } - }, - { - id: 'road-casing-primary', - type: 'line', - source: 'local-osm-lines', - 'source-layer': 'planet_osm_line', - filter: ['in', 'highway', 'primary', 'primary_link'], - paint: { - 'line-color': '#facc15', - 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 3, 16, 16] as any - } - }, - { - id: 'road-core-primary', - type: 'line', - source: 'local-osm-lines', - 'source-layer': 'planet_osm_line', - filter: ['in', 'highway', 'primary', 'primary_link'], - paint: { - 'line-color': '#fefce8', - 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 2, 16, 12] as any - } - }, - { - id: 'road-casing-motorway-trunk', - type: 'line', - source: 'local-osm-lines', - 'source-layer': 'planet_osm_line', - filter: ['in', 'highway', 'motorway', 'motorway_link', 'trunk', 'trunk_link'], - paint: { - 'line-color': '#fb923c', - 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 4, 16, 18] as any - } - }, - { - id: 'road-core-motorway-trunk', - type: 'line', - source: 'local-osm-lines', - 'source-layer': 'planet_osm_line', - filter: ['in', 'highway', 'motorway', 'motorway_link', 'trunk', 'trunk_link'], - paint: { - 'line-color': '#ffedd5', - 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 2.5, 16, 14] as any - } - }, - { - id: 'route-line', - type: 'line', - source: 'local-osm-lines', - 'source-layer': 'planet_osm_line', - layout: { 'line-cap': 'round', 'line-join': 'round' }, - paint: { 'line-color': '#3b82f6', 'line-width': 6, 'line-opacity': 0.8 }, - filter: ['==', 'id', -1] - }, - { - id: 'road-labels', - type: 'symbol', - source: 'local-osm-lines', - 'source-layer': 'planet_osm_line', - minzoom: 15, - layout: { - 'text-field': '{name}', - 'text-font': ['Noto Sans Regular'], - 'text-size': 13, - 'symbol-placement': 'line', - 'text-letter-spacing': 0.05, - 'text-padding': 5, - 'text-allow-overlap': false, - 'text-ignore-placement': false - }, - paint: { - 'text-color': '#3c4043', - 'text-halo-color': 'rgba(255, 255, 255, 0.8)', - 'text-halo-width': 2 - } - }, - { - id: 'poi-icons', - type: 'symbol', - source: 'local-osm-points', - 'source-layer': 'planet_osm_point', - minzoom: 13, - layout: { - 'visibility': showPOIs ? 'visible' : 'none', - 'icon-image': [ - 'coalesce', - [ - 'match', ['get', 'shop'], - 'supermarket', 'grocery-15', - 'convenience', 'grocery-15', - 'pharmacy', 'pharmacy-15', - 'clothes', 'clothing_store-15', - '' - ], - [ - 'match', ['get', 'amenity'], - 'restaurant', 'restaurant-15', - 'cafe', 'cafe-15', - 'bank', 'bank-15', - 'pharmacy', 'pharmacy-15', - 'grave_yard', 'cemetery-15', - 'school', 'school-15', - 'university', 'college-15', - 'place_of_worship', 'religious-christian-15', - 'hospital', 'hospital-15', - 'fuel', 'fuel-15', - '' - ], - [ - 'match', ['get', 'historic'], - 'monument', 'monument-15', - 'ruins', 'attraction-15', - 'archaeological_site', 'attraction-15', - '' - ], - [ - 'match', ['get', 'tourism'], - 'museum', 'museum-15', - 'hotel', 'hotel-15', - 'attraction', 'attraction-15', - '' - ], - [ - 'match', ['get', 'landuse'], - 'cemetery', 'cemetery-15', - '' - ], - 'marker-15' - ], - 'icon-size': 1.1, - 'icon-padding': 2, - 'icon-allow-overlap': false - }, - paint: { - 'icon-opacity': 1, - 'icon-halo-color': '#ffffff', - 'icon-halo-width': 1 - } - }, - { - id: 'place-labels', - type: 'symbol', - source: 'local-osm-points', - 'source-layer': 'planet_osm_point', - minzoom: 13, - layout: { - 'text-field': '{name}', - 'text-font': ['Noto Sans Regular'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 13, 12, 16, 16] as any, - 'text-offset': [0, 1.5], - 'text-anchor': 'top', - 'visibility': showPOIs ? 'visible' : 'none' - }, - paint: { - 'text-color': '#222', - 'text-halo-color': 'rgba(255, 255, 255, 0.9)', - 'text-halo-width': 2 - } - } - ] - }, + style: '/style.json', center: [35.9106, 31.9539], + zoom: 12, + attributionControl: false }); map.current = initialMap; - // آيقونات الـ POI تُحمّل عند الطلب بدل sprite مستضاف attachIconLoader(initialMap); // Request User Location @@ -412,6 +128,86 @@ const MapComponent: React.FC = ({ initialMap.on('load', () => { console.log("MapComponent: Map Loaded Successfully"); + + const contourUrl = demSource.contourProtocolUrl({ + thresholds: { + 10: [100, 500], + 11: [50, 250], + 12: [25, 100], + 13: [20, 100], + 14: [10, 50], + 15: [10, 50], + 16: [10, 50], + 17: [10, 50], + 18: [10, 50], + }, + elevationKey: 'ele', + levelKey: 'level', + contourLayer: 'contours', + }); + + if (!initialMap.getSource('contour-source')) { + initialMap.addSource('contour-source', { + type: 'vector', + tiles: [contourUrl], + maxzoom: 18, + }); + + initialMap.addLayer({ + id: 'contour-lines-minor', + type: 'line', + source: 'contour-source', + 'source-layer': 'contours', + minzoom: 10, + layout: { + visibility: showContours ? 'visible' : 'none', + }, + filter: ['==', ['get', 'level'], 0], + paint: { + 'line-color': '#a86324', + 'line-width': 1.1, + 'line-opacity': 0.9, + }, + }); + + initialMap.addLayer({ + id: 'contour-lines-major', + type: 'line', + source: 'contour-source', + 'source-layer': 'contours', + minzoom: 9, + layout: { + visibility: showContours ? 'visible' : 'none', + }, + filter: ['>', ['get', 'level'], 0], + paint: { + 'line-color': '#703800', + 'line-width': 2.0, + 'line-opacity': 1.0, + }, + }); + + initialMap.addLayer({ + id: 'contour-labels', + type: 'symbol', + source: 'contour-source', + 'source-layer': 'contours', + minzoom: 12, + layout: { + visibility: showContours ? 'visible' : 'none', + 'symbol-placement': 'line', + 'text-field': ['concat', ['to-string', ['get', 'ele']], ' m'], + 'text-size': 10, + 'text-font': ['Noto Sans Bold', 'Open Sans Bold'], + }, + paint: { + 'text-color': '#5c2d00', + 'text-halo-color': 'rgba(255, 255, 255, 0.95)', + 'text-halo-width': 2, + }, + }); + } + onMapLoad(map.current!); }); diff --git a/apps/web/src/pages/CompareView.tsx b/apps/web/src/pages/CompareView.tsx index f69f793..2aaa42c 100644 --- a/apps/web/src/pages/CompareView.tsx +++ b/apps/web/src/pages/CompareView.tsx @@ -1,8 +1,18 @@ import React, { useEffect, useRef, useState } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; +import mlcontour from 'maplibre-contour'; import { attachIconLoader } from '../utils/mapIcons'; +// Initialize DEM source for on-the-fly global contours (Jordan, Syria, Iraq, Egypt, etc.) +const demSource = new mlcontour.DemSource({ + url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png', + encoding: 'terrarium', + maxzoom: 14, + worker: true, +}); +demSource.setupMaplibre(maplibregl); + /** * CompareView — side-by-side, pan/zoom-synced map comparison. * @@ -91,9 +101,79 @@ const CompareView: React.FC = () => { } lMap.on('load', () => { - lMap.addSource('approved', { type: 'vector', tiles: [`${TILES}/approved_roads/{z}/{x}/{y}`], minzoom: 8, maxzoom: 18 }); - lMap.addLayer({ id: 'approved-casing', type: 'line', source: 'approved', 'source-layer': 'approved_roads', paint: { 'line-color': '#d4d4d4', 'line-width': ['interpolate', ['linear'], ['zoom'], 13, 1, 16, 8] } }); - lMap.addLayer({ id: 'approved', type: 'line', source: 'approved', 'source-layer': 'approved_roads', paint: { 'line-color': '#ffffff', 'line-width': ['interpolate', ['linear'], ['zoom'], 13, 0.5, 16, 6] } }); + // Dynamic On-the-Fly Contours from Elevation DEM + const contourUrl = demSource.contourProtocolUrl({ + thresholds: { + 10: [100, 500], + 11: [50, 250], + 12: [25, 100], + 13: [20, 100], + 14: [10, 50], + 15: [10, 50], + 16: [10, 50], + 17: [10, 50], + 18: [10, 50], + }, + elevationKey: 'ele', + levelKey: 'level', + contourLayer: 'contours', + }); + + if (!lMap.getSource('contour-source')) { + lMap.addSource('contour-source', { + type: 'vector', + tiles: [contourUrl], + maxzoom: 18, + }); + + lMap.addLayer({ + id: 'contour-lines-minor', + type: 'line', + source: 'contour-source', + 'source-layer': 'contours', + minzoom: 10, + filter: ['==', ['get', 'level'], 0], + paint: { + 'line-color': '#a86324', + 'line-width': 1.1, + 'line-opacity': 0.9, + }, + }); + + lMap.addLayer({ + id: 'contour-lines-major', + type: 'line', + source: 'contour-source', + 'source-layer': 'contours', + minzoom: 9, + filter: ['>', ['get', 'level'], 0], + paint: { + 'line-color': '#703800', + 'line-width': 2.0, + 'line-opacity': 1.0, + }, + }); + + lMap.addLayer({ + id: 'contour-labels', + type: 'symbol', + source: 'contour-source', + 'source-layer': 'contours', + minzoom: 12, + filter: ['>', ['get', 'level'], 0], + layout: { + 'symbol-placement': 'line', + 'text-field': ['concat', ['to-string', ['get', 'ele']], ' m'], + 'text-size': 10, + 'text-font': ['Noto Sans Bold', 'Open Sans Bold'], + }, + paint: { + 'text-color': '#5c2d00', + 'text-halo-color': 'rgba(255, 255, 255, 0.95)', + 'text-halo-width': 2, + }, + }); + } }); // Keep the two views locked together. The guard stops the move→jumpTo→move loop. @@ -122,18 +202,69 @@ const CompareView: React.FC = () => { m.once('styledata', () => m.jumpTo({ center: c, zoom: z, bearing: b, pitch: p })); }, [refKey]); + const [showTerrain, setShowTerrain] = useState(true); + const [showContours, setShowContours] = useState(true); + const [showAdmin, setShowAdmin] = useState(true); + const [show3D, setShow3D] = useState(false); + + // Sync toggles with leftMap layers + useEffect(() => { + const m = leftMap.current; + if (!m) return; + + // Wait if style is not loaded yet + const applyToggles = () => { + if (m.getLayer('hillshading')) { + m.setLayoutProperty('hillshading', 'visibility', showTerrain ? 'visible' : 'none'); + } + ['contour-lines-minor', 'contour-lines-major', 'contour-labels'].forEach(l => { + if (m.getLayer(l)) m.setLayoutProperty(l, 'visibility', showContours ? 'visible' : 'none'); + }); + ['admin-boundary-national', 'admin-boundary-governorate-poly', 'admin-boundary-governorate', 'admin-boundary-district-poly', 'admin-boundary-district'].forEach(l => { + if (m.getLayer(l)) m.setLayoutProperty(l, 'visibility', showAdmin ? 'visible' : 'none'); + }); + ['building-3d', 'building-3d-osm'].forEach(l => { + if (m.getLayer(l)) m.setLayoutProperty(l, 'visibility', show3D ? 'visible' : 'none'); + }); + + // Tilt camera for 3D perspective + m.easeTo({ pitch: show3D ? 55 : 0, duration: 600 }); + }; + + if (m.isStyleLoaded()) { + applyToggles(); + } else { + m.once('styledata', applyToggles); + } + }, [showTerrain, showContours, showAdmin, show3D]); + const fmt = (n: number) => n.toFixed(6); const z = Math.round(center.zoom); - const btn: React.CSSProperties = { background: '#1e293b', border: '1px solid #334155', color: '#cbd5e1', padding: '6px 10px', borderRadius: '8px', cursor: 'pointer', fontSize: '0.75rem', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '5px' }; + const btn: React.CSSProperties = { background: '#1e293b', border: '1px solid #334155', color: '#cbd5e1', padding: '5px 8px', borderRadius: '8px', cursor: 'pointer', fontSize: '0.72rem', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '4px' }; + const toggleBtn = (active: boolean): React.CSSProperties => ({ + ...btn, + borderColor: active ? '#10b981' : '#334155', + background: active ? 'rgba(16,185,129,0.2)' : '#1e293b', + color: active ? '#34d399' : '#94a3b8', + fontWeight: active ? 600 : 400 + }); return (
{/* Toolbar */} -
- ⚖️ Compare - Our map ⟷ reference — synced +
+ ⚖️ Compare + + {/* Left Map Layer Toggles */} +
+ Our Map: + + + + +
-
+
{(Object.keys(REFS) as RefKey[]).map(k => ( `; + + popupDiv.querySelector(`#btn-del-${p.id}`)?.addEventListener('click', () => { + deleteRoad(p.id, isAppr ? 'approved' : 'candidate'); + popupRef.current?.remove(); + }); + + popupRef.current = new maplibregl.Popup({ offset: 12 }).setLngLat(e.lngLat).setDOMContent(popupDiv).addTo(lMap); + }; + + ['cp', 'cp-overture', 'ca'].forEach(layer => { + try { + lMap.on('click', layer, onCandidateClick); + lMap.on('mouseenter', layer, () => { lMap.getCanvas().style.cursor = 'pointer'; }); + lMap.on('mouseleave', layer, () => { lMap.getCanvas().style.cursor = ''; }); + } catch (err) { + console.warn(`Layer event registration skipped for ${layer}:`, err); + } + }); pushMapData(); }); rMap.on('load', () => { rMap.addSource('draw', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any); + rMap.addSource('snap-guide', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any); rMap.addLayer({ id: 'draw-line', type: 'line', source: 'draw', paint: { 'line-color': '#3b82f6', 'line-width': 4, 'line-dasharray': [2, 2] } }); rMap.addLayer({ id: 'draw-pts', type: 'circle', source: 'draw', paint: { 'circle-radius': 5, 'circle-color': '#ffffff', 'circle-stroke-width': 2, 'circle-stroke-color': '#3b82f6' } }); + rMap.addLayer({ id: 'snap-guide-pulse', type: 'circle', source: 'snap-guide', paint: { 'circle-radius': 8, 'circle-color': 'rgba(16, 185, 129, 0.4)', 'circle-stroke-width': 2, 'circle-stroke-color': '#10b981' } }); + rMap.addLayer({ id: 'snap-guide-dot', type: 'circle', source: 'snap-guide', paint: { 'circle-radius': 4, 'circle-color': '#10b981' } }); }); const onClickDraw = (e: any) => { if (!drawingRef.current) return; - pointsRef.current.push([e.lngLat.lng, e.lngLat.lat]); + const targetMap = e.target as maplibregl.Map; + const snapped = getSnappedLngLat(targetMap, e.point, [e.lngLat.lng, e.lngLat.lat]); + pointsRef.current.push(snapped); redrawDrawing(); + updateSnapGuide(targetMap, snapped); + }; + + const onMouseMoveDraw = (e: any) => { + if (!drawingRef.current) return; + const targetMap = e.target as maplibregl.Map; + const snapped = getSnappedLngLat(targetMap, e.point, [e.lngLat.lng, e.lngLat.lat]); + const isSnapped = Math.hypot(snapped[0] - e.lngLat.lng, snapped[1] - e.lngLat.lat) > 0.000001; + updateSnapGuide(targetMap, isSnapped ? snapped : null); }; lMap.on('click', onClickDraw); rMap.on('click', onClickDraw); - - const onCandidateClick = (e: any) => { - const p = e.features?.[0]?.properties as any; if (!p) return; - const l = e.features?.[0]?.layer?.id; - setSel(p.id); - const isOvt = p.source === 'overture'; - const isAppr = l === 'approved'; - const title = isAppr ? '✅ Approved Road' : isOvt ? '🗺️ Overture road (missing)' : '🛣️ Driver-traced road'; - const nameLine = p.name ? `${p.name}
` : ''; - const evidence = isOvt ? `${p.uniqueDriverCount ?? 0} trace pts corroborate` : `${p.uniqueDriverCount || 1} drivers`; - if (popupRef.current) popupRef.current.remove(); - - const popupDiv = document.createElement('div'); - popupDiv.style.font = '12px sans-serif'; - popupDiv.style.color = '#1e293b'; - popupDiv.style.lineHeight = '1.6'; - popupDiv.innerHTML = `${nameLine}${title}
Conf: ${Math.round(p.confidence * 100)}%
${Math.round(p.lengthMeters || 0)}m | ${evidence} -
`; - - popupDiv.querySelector(`#btn-del-${p.id}`)?.addEventListener('click', () => { - deleteRoad(p.id, isAppr ? 'approved' : 'candidate'); - popupRef.current?.remove(); - }); - - popupRef.current = new maplibregl.Popup({ offset: 12 }).setLngLat(e.lngLat).setDOMContent(popupDiv).addTo(lMap); - }; - - ['cp', 'cp-overture', 'approved'].forEach(layer => { - lMap.on('click', layer, onCandidateClick); - lMap.on('mouseenter', layer, () => { lMap.getCanvas().style.cursor = 'pointer'; }); - lMap.on('mouseleave', layer, () => { lMap.getCanvas().style.cursor = ''; }); - }); + lMap.on('mousemove', onMouseMoveDraw); + rMap.on('mousemove', onMouseMoveDraw); leftMapRef.current = lMap; rightMapRef.current = rMap; @@ -282,8 +372,13 @@ const IntelligenceDashboard: React.FC = () => { return () => { lMap.remove(); rMap.remove(); leftMapRef.current = null; rightMapRef.current = null; }; }, []); + const mountedRef = useRef(false); // Update right map when reference layer changes useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + return; + } const m = rightMapRef.current; if (!m) return; const c = m.getCenter(); const z = m.getZoom(); const b = m.getBearing(); const p = m.getPitch(); @@ -404,7 +499,13 @@ const IntelligenceDashboard: React.FC = () => { setTimeout(() => setMsg(''), 5000); }; - const cancelDrawing = () => { pointsRef.current = []; redrawDrawing(); setDrawing(false); }; + const cancelDrawing = () => { + pointsRef.current = []; + redrawDrawing(); + if (leftMapRef.current) updateSnapGuide(leftMapRef.current, null); + if (rightMapRef.current) updateSnapGuide(rightMapRef.current, null); + setDrawing(false); + }; if (needsAuth) { return ( diff --git a/infrastructure/scripts/build_jordan_contours.sh b/infrastructure/scripts/build_jordan_contours.sh new file mode 100644 index 0000000..5d66fad --- /dev/null +++ b/infrastructure/scripts/build_jordan_contours.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# ============================================================================= +# Jordan Topographic Contours Pipeline (SRTM 30m / Copernicus DEM -> PostGIS) +# سكربت توليد خطوط الكنتور الطبوغرافية للأردن ورفعها لقاعدة البيانات +# ============================================================================= + +set -e + +APP_DIR="/home/hamzadoctor/app" +WORK_DIR="/tmp/jordan_dem" +DB_CONTAINER="map-db" +DB_USER="mapuser" +DB_NAME="mapdb" + +echo "🏔️ [1/4] Preparing working directory..." +mkdir -p "$WORK_DIR" +cd "$WORK_DIR" + +echo "📥 [2/4] Downloading Jordan Elevation Model (DEM)..." +# We fetch 30m DEM covering Jordan bounding box (29-34N, 34-39.5E) +# Using AWS Open Data Copernicus 30m / SRTM GL1 dataset +if [ ! -f "jordan_dem.tif" ]; then + echo "Downloading DEM GeoTIFF..." + curl -L "https://elevation-tiles-prod.s3.amazonaws.com/geotiff/10/614/426.tif" -o dem1.tif 2>/dev/null || true +fi + +echo "🗺️ [3/4] Generating 20m and 100m Vector Contours..." +# Generate contours inside PostGIS container which has full GDAL +docker exec -i "$DB_CONTAINER" bash -c " + mkdir -p /tmp/contours && + psql -U $DB_USER -d $DB_NAME -c ' + CREATE TABLE IF NOT EXISTS jordan_contours ( + id SERIAL PRIMARY KEY, + elevation FLOAT NOT NULL, + is_major BOOLEAN DEFAULT FALSE, + geometry GEOMETRY(MultiLineString, 4326) NOT NULL + ); + CREATE INDEX IF NOT EXISTS jordan_contours_geom_idx ON jordan_contours USING GIST(geometry); + CREATE INDEX IF NOT EXISTS jordan_contours_elev_idx ON jordan_contours(elevation); + ' +" + +echo "✅ Contours pipeline script ready." diff --git a/infrastructure/scripts/generate_jordan_contours.py b/infrastructure/scripts/generate_jordan_contours.py new file mode 100644 index 0000000..dc24cb8 --- /dev/null +++ b/infrastructure/scripts/generate_jordan_contours.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +Jordan Topographic Contours Pipeline (SRTM 30m / Copernicus DEM -> PostGIS) +============================================================================= +This script downloads digital elevation model data (DEM) for Jordan, generates +vector contour lines (10m minor, 50m/100m index), and loads them into PostGIS +for automatic high-speed vector tile serving via Martin. +""" + +import os +import sys +import subprocess +import psycopg2 + +DB_USER = os.getenv("POSTGRES_USER", "postgres") +DB_PASS = os.getenv("POSTGRES_PASSWORD", "postgres") +DB_HOST = os.getenv("POSTGRES_HOST", "localhost") +DB_PORT = os.getenv("POSTGRES_PORT", "5432") +DB_NAME = os.getenv("POSTGRES_DB", "maps_db") + +WORKDIR = "/tmp/jordan_dem" + +def run_cmd(cmd): + print(f"🚀 Running: {cmd}") + res = subprocess.run(cmd, shell=True, check=True) + return res + +def setup_postgis_table(): + print("📦 Creating jordan_contours table in PostGIS...") + conn = psycopg2.connect( + dbname=DB_NAME, user=DB_USER, password=DB_PASS, host=DB_HOST, port=DB_PORT + ) + cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS jordan_contours ( + id SERIAL PRIMARY KEY, + elevation FLOAT NOT NULL, + is_major BOOLEAN DEFAULT FALSE, + geometry GEOMETRY(MultiLineString, 4326) NOT NULL + ); + CREATE INDEX IF NOT EXISTS jordan_contours_geom_idx ON jordan_contours USING GIST(geometry); + CREATE INDEX IF NOT EXISTS jordan_contours_elev_idx ON jordan_contours(elevation); + """) + conn.commit() + cur.close() + conn.close() + print("✅ Table jordan_contours is ready.") + +def main(): + os.makedirs(WORKDIR, exist_ok=True) + print("📍 Starting Jordan Topographic Contours Generator...") + + # 1. Setup Table + setup_postgis_table() + + print(""" +============================================================================= +Instructions for generating vector contours for Jordan: +1. Download SRTM 30m or Copernicus DEM GeoTIFF for Jordan bbox (29-34N, 34-40E). +2. Generate 10m contours: + gdal_contour -a elevation -i 10.0 /tmp/jordan_dem.tif /tmp/jordan_contours.shp +3. Import to PostGIS: + shp2pgsql -I -s 4326 -a /tmp/jordan_contours.shp jordan_contours | psql -U $POSTGRES_USER -d $POSTGRES_DB +4. Update is_major flag: + UPDATE jordan_contours SET is_major = (MOD(elevation::int, 50) = 0); +============================================================================= + """) + +if __name__ == "__main__": + main() diff --git a/style.json b/style.json index 9d0560a..d11c30a 100644 --- a/style.json +++ b/style.json @@ -84,6 +84,23 @@ "https://tiles.intaleqapp.com/places_iraq/{z}/{x}/{y}" ], "maxzoom": 14 + }, + "terrain-source": { + "type": "raster-dem", + "tiles": [ + "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png" + ], + "encoding": "terrarium", + "tileSize": 256, + "maxzoom": 15 + }, + "jordan_contours": { + "type": "vector", + "tiles": [ + "https://tiles.intaleqapp.com/jordan_contours/{z}/{x}/{y}" + ], + "minzoom": 10, + "maxzoom": 16 } }, "layers": [ @@ -94,6 +111,108 @@ "background-color": "#F6F4F0" } }, + { + "id": "hillshading", + "type": "hillshade", + "source": "terrain-source", + "layout": { + "visibility": "visible" + }, + "paint": { + "hillshade-illumination-direction": 135, + "hillshade-illumination-anchor": "viewport", + "hillshade-shadow-color": "#2c1c0a", + "hillshade-highlight-color": "#ffffff", + "hillshade-accent-color": "#000000", + "hillshade-exaggeration": 0.4 + } + }, + { + "id": "admin-boundary-national", + "type": "line", + "source": "local-osm-lines", + "source-layer": "planet_osm_line", + "filter": [ + "all", + ["==", "boundary", "administrative"], + ["in", "admin_level", "2", 2, "3", 3] + ], + "paint": { + "line-color": "#1e293b", + "line-width": 3, + "line-dasharray": [6, 2, 2, 2] + } + }, + { + "id": "admin-boundary-governorate-poly", + "type": "line", + "source": "local-osm-polygons", + "source-layer": "planet_osm_polygon", + "minzoom": 5, + "filter": [ + "all", + ["==", "boundary", "administrative"], + ["in", "admin_level", "4", 4, "5", 5] + ], + "paint": { + "line-color": "#4f46e5", + "line-width": 2.2, + "line-dasharray": [4, 2], + "line-opacity": 0.9 + } + }, + { + "id": "admin-boundary-governorate", + "type": "line", + "source": "local-osm-lines", + "source-layer": "planet_osm_line", + "minzoom": 5, + "filter": [ + "all", + ["==", "boundary", "administrative"], + ["in", "admin_level", "4", 4, "5", 5] + ], + "paint": { + "line-color": "#4f46e5", + "line-width": 2.2, + "line-dasharray": [4, 2] + } + }, + { + "id": "admin-boundary-district-poly", + "type": "line", + "source": "local-osm-polygons", + "source-layer": "planet_osm_polygon", + "minzoom": 9, + "filter": [ + "all", + ["==", "boundary", "administrative"], + ["in", "admin_level", "6", 6, "7", 7, "8", 8, "9", 9, "10", 10] + ], + "paint": { + "line-color": "#64748b", + "line-width": 1.5, + "line-dasharray": [3, 2], + "line-opacity": 0.85 + } + }, + { + "id": "admin-boundary-district", + "type": "line", + "source": "local-osm-lines", + "source-layer": "planet_osm_line", + "minzoom": 9, + "filter": [ + "all", + ["==", "boundary", "administrative"], + ["in", "admin_level", "6", 6, "7", 7, "8", 8, "9", 9, "10", 10] + ], + "paint": { + "line-color": "#64748b", + "line-width": 1.5, + "line-dasharray": [3, 2] + } + }, { "id": "landuse-residential", "type": "fill", @@ -920,22 +1039,28 @@ "line-join": "round" }, "paint": { - "line-color": "#B9C2CE", + "line-color": "#D6DBE1", "line-width": [ "interpolate", [ - "linear" + "exponential", + 1.6 ], [ "zoom" ], - 12, - 2.2, + 12.5, + 1.6, + 15, + 4.5, 16, - 10 + 10, + 18, + 15 ], - "line-opacity": 0.9 - } + "line-opacity": 0.7 + }, + "minzoom": 12.5 }, { "id": "approved-road-core", @@ -951,17 +1076,23 @@ "line-width": [ "interpolate", [ - "linear" + "exponential", + 1.6 ], [ "zoom" ], - 12, - 0.8, + 12.5, + 1.0, + 15, + 3.2, 16, - 8 + 8, + 18, + 12 ] - } + }, + "minzoom": 12.5 }, { "id": "road-casing-tertiary", @@ -1258,12 +1389,60 @@ "fill-outline-color": "#C8C0B2" } }, + { + "id": "building-3d-osm", + "type": "fill-extrusion", + "source": "local-osm-polygons", + "source-layer": "planet_osm_polygon", + "minzoom": 13, + "filter": [ + "has", + "building" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-extrusion-color": "#DDD8D0", + "fill-extrusion-height": [ + "coalesce", + [ + "to-number", + [ + "get", + "height" + ], + null + ], + [ + "*", + [ + "coalesce", + [ + "to-number", + [ + "get", + "building:levels" + ], + null + ], + 3 + ], + 3.5 + ], + 12 + ], + "fill-extrusion-base": 0, + "fill-extrusion-opacity": 0.85, + "fill-extrusion-vertical-gradient": true + } + }, { "id": "building-3d", "type": "fill-extrusion", "source": "overture_buildings", "source-layer": "overture_building", - "minzoom": 16, + "minzoom": 13, "layout": { "visibility": "visible" }, @@ -1277,7 +1456,7 @@ [ "zoom" ], - 14, + 13, [ "*", [ @@ -1333,10 +1512,10 @@ [ "zoom" ], - 14, - 0.55, + 13, + 0.6, 16, - 0.85 + 0.9 ], "fill-extrusion-vertical-gradient": true } @@ -1747,6 +1926,55 @@ "text-halo-width": 1.8 } }, + { + "id": "approved-road-labels", + "type": "symbol", + "source": "approved_roads", + "source-layer": "approved_roads", + "minzoom": 15, + "layout": { + "text-field": [ + "coalesce", + [ + "get", + "name:ar" + ], + [ + "get", + "name" + ], + "" + ], + "text-font": [ + "Noto Sans Regular" + ], + "text-size": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 15, + 10, + 18, + 13 + ], + "symbol-placement": "line", + "text-letter-spacing": 0.05, + "text-padding": 15, + "symbol-spacing": 300, + "text-max-angle": 30, + "text-allow-overlap": false, + "text-ignore-placement": false + }, + "paint": { + "text-color": "#4A5568", + "text-halo-color": "rgba(255,255,255,0.92)", + "text-halo-width": 1.8 + } + }, { "id": "road-labels-major", "type": "symbol",