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 => (