feat: implement manual routing sync system, update map tile URLs to HTTPS, and migrate admin session storage to localStorage
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
# دليل المستتخدم: إضافة دولة جديدة على نظام خرائط انطلاقة
|
||||||
|
|
||||||
|
هذا الدليل يوضح لك الخطوات البرمجية والإدارية اللازمة بالترتيب لإضافة دولة جديدة (مثل مصر أو السعودية) إلى نظام الخرائط الخاص بك، وتهيئتها بالكامل (ملاحة، أماكن، وحدود إدارية).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## المرحلة الأولى: إعداد بيانات الملاحة (OSM & Routing)
|
||||||
|
|
||||||
|
لإضافة شوارع الدولة ومبانيها لتعمل عليها الملاحة (GraphHopper):
|
||||||
|
|
||||||
|
1. **تعديل سكربت التحديث `update-data.sh`:**
|
||||||
|
- قم بالدخول إلى الملف `infrastructure/scripts/update-data.sh`.
|
||||||
|
- أضف رابط تحميل الدولة من موقع [Geofabrik](https://download.geofabrik.de/).
|
||||||
|
- أضف أمر حقن الدولة الجديدة في قاعدة البيانات `PostGIS` باستخدام أمر `osm2pgsql --append`.
|
||||||
|
- أضف مسار الملف الجديد إلى أمر الدمج `osmium merge` لكي يُدمج مع الخريطة الأساسية `master_map.osm.pbf`.
|
||||||
|
|
||||||
|
2. **تشغيل السكربت:**
|
||||||
|
بمجرد تشغيل السكربت، سيتم تحميل الشوارع والمباني، وسيُعاد بناء محرك التوجيه (GraphHopper) ليتعرف على مسارات الدولة الجديدة تلقائياً.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## المرحلة الثانية: جلب الأماكن والحدود الإدارية (Overture Maps)
|
||||||
|
|
||||||
|
لجلب المحلات التجارية، المطاعم، والحدود الجغرافية للدولة الجديدة:
|
||||||
|
|
||||||
|
1. **تعديل سكربت الأماكن `overture_ingest.sh`:**
|
||||||
|
- قم بالدخول إلى الملف `infrastructure/scripts/overture_ingest.sh`.
|
||||||
|
- حدد الإحداثيات المربعة (BBOX) للدولة الجديدة (مثال: `BBOX_EGYPT="22.0,24.0,31.5,37.0"`).
|
||||||
|
- أضف أمر استدعاء الدالة أسفل الملف:
|
||||||
|
```bash
|
||||||
|
process_city "egypt" "$BBOX_EGYPT" "division_area"
|
||||||
|
process_city "egypt" "$BBOX_EGYPT" "place"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **تشغيل السكربت:**
|
||||||
|
- سيقوم السكربت بحقن كل المحلات التجارية في جدول `overture_place` لتصبح قابلة للبحث مباشرة.
|
||||||
|
- **ملاحظة:** السكربت سيقوم بتحميل ملف الحدود الإدارية ويتركه بصيغة GeoJSON (مثال: `overture_egypt_division_area.geojson`) لكي نستخدمه في المرحلة الثالثة.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## المرحلة الثالثة: التأسيس الإداري (عبر الـ Postman)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> هذه الروابط هي أدوات لمرة واحدة فقط (Setup Tools). لا تستخدمها بشكل دوري لكي لا تفقد أي تعديلات يدوية قمت بها على مسميات الأحياء لاحقاً.
|
||||||
|
|
||||||
|
هنا نستخدم الروابط التي سألت عنها لتأسيس (المحافظات، الألوية، والأحياء) للدولة الجديدة بالترتيب:
|
||||||
|
|
||||||
|
### 1. استيراد الحدود الإدارية الكبرى
|
||||||
|
هذا الرابط يقرأ ملف الـ GeoJSON الذي تم تحميله في المرحلة السابقة، ويقوم بإنشاء المحافظات والألوية/المقاطعات في جدول `admin_boundaries`.
|
||||||
|
- **Method:** `POST`
|
||||||
|
- **URL:** `https://map-saas.intaleqapp.com/api/geocoding/import-boundaries`
|
||||||
|
- **Query Params:**
|
||||||
|
- `country` = `EG` (رمز الدولة)
|
||||||
|
- `filePath` = `/data/overture_egypt_division_area.geojson`
|
||||||
|
|
||||||
|
### 2. مزامنة نقاط الأحياء من OSM
|
||||||
|
هذا الرابط يتصل بسيرفرات الخرائط العالمية لجلب مراكز الأحياء ونقاطها (Neighborhoods) ووضعها في جدول `neighborhood_polygons`.
|
||||||
|
- **Method:** `POST`
|
||||||
|
- **URL:** `https://map-saas.intaleqapp.com/api/geocoding/admin/sync-neighborhoods`
|
||||||
|
- **Query Params:**
|
||||||
|
- `bbox` = `22.0,24.0,31.5,37.0` (إحداثيات الدولة الجديدة)
|
||||||
|
- `country` = `egypt`
|
||||||
|
|
||||||
|
### 3. توليد مضلعات الأحياء هندسياً (Voronoi Polygons)
|
||||||
|
نقاط الأحياء لوحدها غير كافية لمعرفة أين يبدأ وأين ينتهي الحي. هذا الرابط الأخير يقوم بعملية حسابية هندسية لتقسيم المساحات بين نقاط الأحياء (بناءً على حدود المحافظات التي جلبناها في الخطوة 1) لتوليد مضلعات حقيقية.
|
||||||
|
- **Method:** `POST`
|
||||||
|
- **URL:** `https://map-saas.intaleqapp.com/api/geocoding/admin/generate-voronoi`
|
||||||
|
- **Query Params:** (لا يوجد، ستقوم الوظيفة بحساب المضلعات لكل النقاط الناقصة).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## المرحلة الرابعة: ربط محرك البحث (الجي كودينج)
|
||||||
|
|
||||||
|
إذا كنت لا تزال تستخدم جداول قديمة مخصصة لكل دولة مثل (`places_syria` و `places_jordan`) في الـ API الخاص بك:
|
||||||
|
1. اذهب إلى ملف `apps/api/src/geocoding/geocoding.service.ts`.
|
||||||
|
2. تأكد من إضافة دالة أو `UNION ALL` لجلب البيانات من `places_egypt` أو الجدول المخصص للدولة الجديدة (رغم أن الاعتماد على `overture_place` الشامل أصبح كافياً الآن).
|
||||||
|
|
||||||
|
**تهانينا! 🎉 بهذا تكون الدولة الجديدة قد أُضيفت بشكل متكامل (شوارع، ملاحة، أماكن، ومناطق إدارية).**
|
||||||
@@ -112,6 +112,58 @@ export class GeocodingService {
|
|||||||
ORDER BY (o.name <-> $1) ASC LIMIT 10
|
ORDER BY (o.name <-> $1) ASC LIMIT 10
|
||||||
`, [cleanQuery, lat || null, lon || null, radius]));
|
`, [cleanQuery, lat || null, lon || null, radius]));
|
||||||
|
|
||||||
|
queryPromises.push(this.osmPointsRepository.query(`
|
||||||
|
SELECT
|
||||||
|
id, name_ar as name, name_ar, name_en, 'admin' as category,
|
||||||
|
'' as neighbourhood, '' as district, '' as governorate,
|
||||||
|
ST_Y(ST_Centroid(geom))::text as latitude, ST_X(ST_Centroid(geom))::text as longitude, '' as address, 'admin_boundary' as region, 'admin' as source,
|
||||||
|
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
||||||
|
similarity(COALESCE(name_ar, ''), $1) as relevance
|
||||||
|
FROM admin_boundaries
|
||||||
|
WHERE name_ar % $1
|
||||||
|
AND ($2::float IS NULL OR ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
|
||||||
|
ORDER BY (name_ar <-> $1) ASC LIMIT 5
|
||||||
|
`, [cleanQuery, lat || null, lon || null, radius]));
|
||||||
|
|
||||||
|
queryPromises.push(this.osmPointsRepository.query(`
|
||||||
|
SELECT
|
||||||
|
id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, COALESCE(names->>'primary', names->>'common', 'Street') as name_ar, '' as name_en, 'street' as category,
|
||||||
|
'' as neighbourhood, '' as district, '' as governorate,
|
||||||
|
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture' as region, 'overture_global' as source,
|
||||||
|
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
||||||
|
GREATEST(similarity(COALESCE(names->>'primary', ''), $1), similarity(COALESCE(names->>'common', ''), $1)) as relevance
|
||||||
|
FROM overture_segment
|
||||||
|
WHERE (names->>'primary' % $1 OR names->>'common' % $1)
|
||||||
|
AND ($2::float IS NULL OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
|
||||||
|
ORDER BY (COALESCE(names->>'primary', '') <-> $1) ASC LIMIT 10
|
||||||
|
`, [cleanQuery, lat || null, lon || null, radius]));
|
||||||
|
|
||||||
|
queryPromises.push(this.osmPointsRepository.query(`
|
||||||
|
SELECT
|
||||||
|
id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, COALESCE(names->>'primary', names->>'common', 'Building') as name_ar, '' as name_en, 'building' as category,
|
||||||
|
'' as neighbourhood, '' as district, '' as governorate,
|
||||||
|
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture' as region, 'overture_global' as source,
|
||||||
|
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
||||||
|
GREATEST(similarity(COALESCE(names->>'primary', ''), $1), similarity(COALESCE(names->>'common', ''), $1)) as relevance
|
||||||
|
FROM overture_building
|
||||||
|
WHERE (names->>'primary' % $1 OR names->>'common' % $1)
|
||||||
|
AND ($2::float IS NULL OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
|
||||||
|
ORDER BY (COALESCE(names->>'primary', '') <-> $1) ASC LIMIT 10
|
||||||
|
`, [cleanQuery, lat || null, lon || null, radius]));
|
||||||
|
|
||||||
|
queryPromises.push(this.osmPointsRepository.query(`
|
||||||
|
SELECT
|
||||||
|
id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, COALESCE(names->>'primary', names->>'common', 'Place') as name_ar, '' as name_en, 'place' as category,
|
||||||
|
'' as neighbourhood, '' as district, '' as governorate,
|
||||||
|
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture' as region, 'overture_global' as source,
|
||||||
|
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
||||||
|
GREATEST(similarity(COALESCE(names->>'primary', ''), $1), similarity(COALESCE(names->>'common', ''), $1)) as relevance
|
||||||
|
FROM overture_place
|
||||||
|
WHERE (names->>'primary' % $1 OR names->>'common' % $1)
|
||||||
|
AND ($2::float IS NULL OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
|
||||||
|
ORDER BY (COALESCE(names->>'primary', '') <-> $1) ASC LIMIT 10
|
||||||
|
`, [cleanQuery, lat || null, lon || null, radius]));
|
||||||
|
|
||||||
const executionResults = await Promise.race([
|
const executionResults = await Promise.race([
|
||||||
Promise.allSettled(queryPromises),
|
Promise.allSettled(queryPromises),
|
||||||
new Promise<any>((_, reject) => setTimeout(() => reject(new Error('QUERY_TIMEOUT')), this.DB_TIMEOUT_MS))
|
new Promise<any>((_, reject) => setTimeout(() => reject(new Error('QUERY_TIMEOUT')), this.DB_TIMEOUT_MS))
|
||||||
@@ -184,7 +236,9 @@ export class GeocodingService {
|
|||||||
try {
|
try {
|
||||||
const repo = this.getRepositoryForCoords(lat, lng);
|
const repo = this.getRepositoryForCoords(lat, lng);
|
||||||
const tableName = this.getTableNameForRepo(repo);
|
const tableName = this.getTableNameForRepo(repo);
|
||||||
const query = `
|
const queryPromises: Promise<any[]>[] = [];
|
||||||
|
|
||||||
|
queryPromises.push(repo.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.id, p.name, p.name_ar, p.category,
|
p.id, p.name, p.name_ar, p.category,
|
||||||
n.name_ar as neighbourhood,
|
n.name_ar as neighbourhood,
|
||||||
@@ -197,9 +251,67 @@ export class GeocodingService {
|
|||||||
LEFT JOIN admin_boundaries d ON p.sub_district_id = d.id
|
LEFT JOIN admin_boundaries d ON p.sub_district_id = d.id
|
||||||
LEFT JOIN admin_boundaries g ON p.governorate_id = g.id
|
LEFT JOIN admin_boundaries g ON p.governorate_id = g.id
|
||||||
WHERE p.location IS NOT NULL
|
WHERE p.location IS NOT NULL
|
||||||
ORDER BY p.location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 3
|
ORDER BY p.location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
|
||||||
`;
|
`, [lng, lat]));
|
||||||
return await repo.query(query, [lng, lat]);
|
|
||||||
|
queryPromises.push(repo.query(`
|
||||||
|
SELECT
|
||||||
|
o.osm_id::text as id, o.name, o.name_ar, COALESCE(o.amenity, o.shop, 'place') as category,
|
||||||
|
'' as neighbourhood, '' as district, '' as governorate,
|
||||||
|
o.latitude, o.longitude, o.addr_street as address, 'osm_global' as source,
|
||||||
|
ST_DistanceSphere(o.geom, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
||||||
|
FROM osm_points_with_area o
|
||||||
|
WHERE o.geom IS NOT NULL AND (o.name IS NOT NULL OR o.name_ar IS NOT NULL)
|
||||||
|
ORDER BY o.geom <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
|
||||||
|
`, [lng, lat]));
|
||||||
|
|
||||||
|
queryPromises.push(repo.query(`
|
||||||
|
SELECT
|
||||||
|
id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, COALESCE(names->>'primary', names->>'common', 'Street') as name_ar, 'street' as category,
|
||||||
|
'' as neighbourhood, '' as district, '' as governorate,
|
||||||
|
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture_global' as source,
|
||||||
|
ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
||||||
|
FROM overture_segment
|
||||||
|
WHERE location IS NOT NULL AND (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
|
||||||
|
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(names->>'primary', names->>'common', 'Building') as name, COALESCE(names->>'primary', names->>'common', 'Building') as name_ar, 'building' as category,
|
||||||
|
'' as neighbourhood, '' as district, '' as governorate,
|
||||||
|
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture_global' as source,
|
||||||
|
ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
||||||
|
FROM overture_building
|
||||||
|
WHERE location IS NOT NULL AND (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
|
||||||
|
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(names->>'primary', names->>'common', 'Place') as name, COALESCE(names->>'primary', names->>'common', 'Place') as name_ar, 'place' as category,
|
||||||
|
'' as neighbourhood, '' as district, '' as governorate,
|
||||||
|
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture_global' as source,
|
||||||
|
ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
||||||
|
FROM overture_place
|
||||||
|
WHERE location IS NOT NULL AND (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
|
||||||
|
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
|
||||||
|
`, [lng, lat]));
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(queryPromises);
|
||||||
|
let allResults: any[] = [];
|
||||||
|
results.forEach(res => {
|
||||||
|
if (res.status === 'fulfilled' && res.value) allResults.push(...res.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return allResults
|
||||||
|
.sort((a, b) => Number(a.distance) - Number(b.distance))
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(r => ({
|
||||||
|
...r,
|
||||||
|
latitude: parseFloat(r.latitude),
|
||||||
|
longitude: parseFloat(r.longitude)
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Reverse geocoding error:', error);
|
this.logger.error('Reverse geocoding error:', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -252,6 +364,7 @@ export class GeocodingService {
|
|||||||
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_egypt
|
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_egypt
|
||||||
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, 'building' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_building WHERE names->>'primary' IS NOT NULL LIMIT 500
|
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, 'building' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_building WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||||
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, 'street' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_segment WHERE names->>'primary' IS NOT NULL LIMIT 500
|
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, 'street' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_segment WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||||
|
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, 'place' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_place WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||||
`;
|
`;
|
||||||
const res = await this.placesSyriaRepository.query(q);
|
const res = await this.placesSyriaRepository.query(q);
|
||||||
const features = res.map(p => ({
|
const features = res.map(p => ({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Query, UseGuards, Res } from '@nestjs/common';
|
import { Controller, Get, Post, Query, UseGuards, Res } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -12,6 +12,12 @@ import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
|||||||
export class MapsController {
|
export class MapsController {
|
||||||
constructor(private readonly mapsService: MapsService) { }
|
constructor(private readonly mapsService: MapsService) { }
|
||||||
|
|
||||||
|
@Post('sync-routes')
|
||||||
|
@ApiOperation({ summary: 'Request GraphHopper routing sync 🔄' })
|
||||||
|
async syncRoutes() {
|
||||||
|
return this.mapsService.requestRoutingSync();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('style.json')
|
@Get('style.json')
|
||||||
@ApiOperation({ summary: 'Get MapLibre style JSON 🎨' })
|
@ApiOperation({ summary: 'Get MapLibre style JSON 🎨' })
|
||||||
async getStyleJson(@Query('theme') theme: string, @Res() res: Response) {
|
async getStyleJson(@Query('theme') theme: string, @Res() res: Response) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import axios from 'axios';
|
|||||||
import { RoadSegmentStat } from './road-stat.entity';
|
import { RoadSegmentStat } from './road-stat.entity';
|
||||||
import { TrafficGridService } from './traffic-grid.service';
|
import { TrafficGridService } from './traffic-grid.service';
|
||||||
import { GeocodingService } from '../geocoding/geocoding.service';
|
import { GeocodingService } from '../geocoding/geocoding.service';
|
||||||
|
import { RedisService } from '../common/redis.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MapsService {
|
export class MapsService {
|
||||||
@@ -19,10 +20,16 @@ export class MapsService {
|
|||||||
private trafficGrid: TrafficGridService,
|
private trafficGrid: TrafficGridService,
|
||||||
private geocodingService: GeocodingService,
|
private geocodingService: GeocodingService,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
|
private redisService: RedisService,
|
||||||
) {
|
) {
|
||||||
this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080');
|
this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async requestRoutingSync() {
|
||||||
|
await this.redisService.set('routing_sync_requested', '1');
|
||||||
|
return { success: true, message: 'Routing sync requested and scheduled for the next minute.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async getRoute(waypoints: [number, number][], profile: string = 'car', steps: boolean = false, locale: string = 'en', alternatives: boolean = false) {
|
async getRoute(waypoints: [number, number][], profile: string = 'car', steps: boolean = false, locale: string = 'en', alternatives: boolean = false) {
|
||||||
if (waypoints.length < 2) {
|
if (waypoints.length < 2) {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
"approved_roads": {
|
"approved_roads": {
|
||||||
"type": "vector",
|
"type": "vector",
|
||||||
"tiles": [
|
"tiles": [
|
||||||
"http://188.68.36.205:3202/approved_roads/{z}/{x}/{y}"
|
"https://tiles.intaleqapp.com/approved_roads/{z}/{x}/{y}"
|
||||||
],
|
],
|
||||||
"minzoom": 8,
|
"minzoom": 8,
|
||||||
"maxzoom": 18
|
"maxzoom": 18
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import 'maplibre-gl/dist/maplibre-gl.css';
|
|||||||
// Martin vector-tile host (serves planet_osm_*, approved_roads, …). This is a
|
// Martin vector-tile host (serves planet_osm_*, approved_roads, …). This is a
|
||||||
// DIFFERENT host from where the app + its style.json are served, so it must be
|
// DIFFERENT host from where the app + its style.json are served, so it must be
|
||||||
// absolute — an empty default resolved to the app host, which serves no tiles.
|
// absolute — an empty default resolved to the app host, which serves no tiles.
|
||||||
const TILES = (import.meta as any).env.VITE_TILES_URL || 'http://188.68.36.205:3202';
|
const TILES = (import.meta as any).env.VITE_TILES_URL || 'https://tiles.intaleqapp.com';
|
||||||
|
|
||||||
type RefKey = 'google-sat' | 'esri-sat' | 'esri-streets' | 'osm';
|
type RefKey = 'google-sat' | 'esri-sat' | 'esri-streets' | 'osm';
|
||||||
const REFS: Record<RefKey, { label: string; tiles: string; attribution: string; maxzoom: number }> = {
|
const REFS: Record<RefKey, { label: string; tiles: string; attribution: string; maxzoom: number }> = {
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ const IntelligenceDashboard: React.FC = () => {
|
|||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [tab, setTab] = useState<'summary' | 'candidates' | 'closures'>('candidates');
|
const [tab, setTab] = useState<'summary' | 'candidates' | 'closures'>('candidates');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [apiKey, setApiKey] = useState(sessionStorage.getItem('map_admin_key') || '');
|
const [apiKey, setApiKey] = useState(localStorage.getItem('map_admin_key') || '');
|
||||||
const [needsAuth, setNeedsAuth] = useState(!sessionStorage.getItem('map_admin_key'));
|
const [needsAuth, setNeedsAuth] = useState(!localStorage.getItem('map_admin_key'));
|
||||||
const [sel, setSel] = useState<string | null>(null);
|
const [sel, setSel] = useState<string | null>(null);
|
||||||
|
|
||||||
// Right Map State
|
// Right Map State
|
||||||
@@ -111,8 +111,8 @@ const IntelligenceDashboard: React.FC = () => {
|
|||||||
|
|
||||||
if (!sr.ok || !cr.ok || !clr.ok) {
|
if (!sr.ok || !cr.ok || !clr.ok) {
|
||||||
if (sr.status === 401 || sr.status === 403) {
|
if (sr.status === 401 || sr.status === 403) {
|
||||||
|
localStorage.removeItem('map_admin_key');
|
||||||
setNeedsAuth(true);
|
setNeedsAuth(true);
|
||||||
sessionStorage.removeItem('map_admin_key');
|
|
||||||
setApiKey('');
|
setApiKey('');
|
||||||
throw new Error('Invalid or unauthorized API Key');
|
throw new Error('Invalid or unauthorized API Key');
|
||||||
}
|
}
|
||||||
@@ -371,6 +371,17 @@ const IntelligenceDashboard: React.FC = () => {
|
|||||||
setTimeout(() => setMsg(''), 5000);
|
setTimeout(() => setMsg(''), 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const syncRoutes = async () => {
|
||||||
|
if (!confirm('This will request a full routing rebuild on the server which takes 5-10 minutes. Continue?')) return;
|
||||||
|
try {
|
||||||
|
setMsg('Requesting route sync...');
|
||||||
|
const r = await fetch(`${API}/maps/sync-routes`, { method: 'POST', headers: { 'x-api-key': apiKey } });
|
||||||
|
if (!r.ok) throw new Error('Failed');
|
||||||
|
setMsg('✅ Routing sync requested! Check back in 5-10 min.');
|
||||||
|
} catch (e: any) { setMsg(e.message || 'Error'); }
|
||||||
|
setTimeout(() => setMsg(''), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
const cancelDrawing = () => { pointsRef.current = []; redrawDrawing(); setDrawing(false); };
|
const cancelDrawing = () => { pointsRef.current = []; redrawDrawing(); setDrawing(false); };
|
||||||
|
|
||||||
if (needsAuth) {
|
if (needsAuth) {
|
||||||
@@ -389,7 +400,7 @@ const IntelligenceDashboard: React.FC = () => {
|
|||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
const val = e.currentTarget.value;
|
const val = e.currentTarget.value;
|
||||||
if (val) {
|
if (val) {
|
||||||
sessionStorage.setItem('map_admin_key', val);
|
localStorage.setItem('map_admin_key', val);
|
||||||
setApiKey(val);
|
setApiKey(val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,10 +427,15 @@ const IntelligenceDashboard: React.FC = () => {
|
|||||||
<div style={{ fontSize: '0.7rem', color: '#64748b' }}>Intaleq SaaS v2 · Split Compare</div>
|
<div style={{ fontSize: '0.7rem', color: '#64748b' }}>Intaleq SaaS v2 · Split Compare</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
<button onClick={syncRoutes} style={{ background: 'rgba(16,185,129,0.15)', border: '1px solid #10b981', color: '#34d399', padding: '6px 10px', borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.78rem' }} title="Sync Approved Roads to Router">
|
||||||
|
<RefreshCw size={13} /> Sync Routes
|
||||||
|
</button>
|
||||||
<button onClick={runAI} style={{ background: 'rgba(99,102,241,0.15)', border: '1px solid #6366f1', color: '#818cf8', padding: '6px 10px', borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.78rem' }}>
|
<button onClick={runAI} style={{ background: 'rgba(99,102,241,0.15)', border: '1px solid #6366f1', color: '#818cf8', padding: '6px 10px', borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.78rem' }}>
|
||||||
<RefreshCw size={13} /> Run
|
<RefreshCw size={13} /> Run
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', padding: '1rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', padding: '1rem' }}>
|
||||||
{[
|
{[
|
||||||
|
|||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# check_routing_sync.sh
|
||||||
|
# Runs via cron every minute to check if the admin dashboard requested
|
||||||
|
# a routing graph rebuild (due to a newly approved road).
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
APP_DIR="/home/hamzadoctor/app"
|
||||||
|
cd "${APP_DIR}"
|
||||||
|
|
||||||
|
# Read flag from Redis using the redis container
|
||||||
|
SYNC_REQ=$(docker compose exec -T redis redis-cli get routing_sync_requested | tr -d '\r')
|
||||||
|
|
||||||
|
if [ "$SYNC_REQ" = "1" ] || [ "$SYNC_REQ" = "\"1\"" ]; then
|
||||||
|
echo "$(date): Sync requested! Triggering delta apply and GH rebuild..."
|
||||||
|
|
||||||
|
# 1. Delete the flag immediately so we don't trigger it again
|
||||||
|
docker compose exec -T redis redis-cli del routing_sync_requested
|
||||||
|
|
||||||
|
# 2. Apply delta
|
||||||
|
if [ -f "${APP_DIR}/infrastructure/scripts/apply-delta.sh" ]; then
|
||||||
|
bash "${APP_DIR}/infrastructure/scripts/apply-delta.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Restart GraphHopper to rebuild index
|
||||||
|
docker compose stop routing
|
||||||
|
rm -rf "${APP_DIR}/infrastructure/osm-data/graph-cache" "${APP_DIR}/infrastructure/osm-data/default-gh"
|
||||||
|
docker compose up -d routing
|
||||||
|
|
||||||
|
echo "$(date): Routing rebuild triggered successfully."
|
||||||
|
fi
|
||||||
@@ -21,6 +21,9 @@ BBOX_SYRIA="35.7,32.3,42.4,37.3"
|
|||||||
|
|
||||||
echo "🚀 Starting Overture Data Pipeline (Jordan & Syria)..."
|
echo "🚀 Starting Overture Data Pipeline (Jordan & Syria)..."
|
||||||
|
|
||||||
|
echo "🧹 Clearing old Overture tables to prevent duplication..."
|
||||||
|
docker compose -f /home/hamzadoctor/app/docker-compose.yml exec -T db psql -U $DB_USER -d $DB_NAME -c "DROP TABLE IF EXISTS overture_place, overture_building, overture_segment;"
|
||||||
|
|
||||||
# 1. Setup Python Environment
|
# 1. Setup Python Environment
|
||||||
# ... (rest of setup)
|
# ... (rest of setup)
|
||||||
if [ ! -d "venv_overture" ]; then
|
if [ ! -d "venv_overture" ]; then
|
||||||
@@ -68,9 +71,12 @@ process_city() {
|
|||||||
# 3. Execution (Jordan & Syria)
|
# 3. Execution (Jordan & Syria)
|
||||||
# Jordan
|
# Jordan
|
||||||
process_city "jordan_north" "$BBOX_JORDAN_NORTH" "division_area"
|
process_city "jordan_north" "$BBOX_JORDAN_NORTH" "division_area"
|
||||||
|
process_city "jordan_north" "$BBOX_JORDAN_NORTH" "place"
|
||||||
process_city "jordan_south" "$BBOX_JORDAN_SOUTH" "division_area"
|
process_city "jordan_south" "$BBOX_JORDAN_SOUTH" "division_area"
|
||||||
|
process_city "jordan_south" "$BBOX_JORDAN_SOUTH" "place"
|
||||||
|
|
||||||
# Syria
|
# Syria
|
||||||
process_city "syria" "$BBOX_SYRIA" "division_area"
|
process_city "syria" "$BBOX_SYRIA" "division_area"
|
||||||
|
process_city "syria" "$BBOX_SYRIA" "place"
|
||||||
|
|
||||||
echo "🎉 Administrative boundary GeoJSONs ready for import!"
|
echo "🎉 Administrative boundary GeoJSONs ready for import!"
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ echo "0 3 */10 * * /home/hamzadoctor/app/infrastructure/scripts/update-data.sh >
|
|||||||
# 2. Discover road closures daily at 4:00 AM
|
# 2. Discover road closures daily at 4:00 AM
|
||||||
echo "0 4 * * * curl -X POST -H \"x-api-key: intaleq_secret_2026\" http://localhost:3200/api/map-refinement/roads/discover-closures >> /home/hamzadoctor/app/infrastructure/logs/closures.log 2>&1" >> $CRON_FILE
|
echo "0 4 * * * curl -X POST -H \"x-api-key: intaleq_secret_2026\" http://localhost:3200/api/map-refinement/roads/discover-closures >> /home/hamzadoctor/app/infrastructure/logs/closures.log 2>&1" >> $CRON_FILE
|
||||||
|
|
||||||
|
# 3. Check for manual routing sync requests every minute
|
||||||
|
echo "* * * * * /home/hamzadoctor/app/infrastructure/scripts/check_routing_sync.sh >> /home/hamzadoctor/app/infrastructure/logs/routing_sync.log 2>&1" >> $CRON_FILE
|
||||||
|
|
||||||
|
# 4. Update Overture Maps Data on the 1st of every month at 2:00 AM
|
||||||
|
echo "0 2 1 * * /home/hamzadoctor/app/infrastructure/scripts/overture_ingest.sh >> /home/hamzadoctor/app/infrastructure/logs/overture_ingest.log 2>&1" >> $CRON_FILE
|
||||||
|
|
||||||
# Install crontab
|
# Install crontab
|
||||||
crontab $CRON_FILE
|
crontab $CRON_FILE
|
||||||
rm $CRON_FILE
|
rm $CRON_FILE
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
"approved_roads": {
|
"approved_roads": {
|
||||||
"type": "vector",
|
"type": "vector",
|
||||||
"tiles": [
|
"tiles": [
|
||||||
"http://188.68.36.205:3202/approved_roads/{z}/{x}/{y}"
|
"https://tiles.intaleqapp.com/approved_roads/{z}/{x}/{y}"
|
||||||
],
|
],
|
||||||
"minzoom": 8,
|
"minzoom": 8,
|
||||||
"maxzoom": 18
|
"maxzoom": 18
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
"approved_roads": {
|
"approved_roads": {
|
||||||
"type": "vector",
|
"type": "vector",
|
||||||
"tiles": [
|
"tiles": [
|
||||||
"http://188.68.36.205:3202/approved_roads/{z}/{x}/{y}"
|
"https://tiles.intaleqapp.com/approved_roads/{z}/{x}/{y}"
|
||||||
],
|
],
|
||||||
"minzoom": 8,
|
"minzoom": 8,
|
||||||
"maxzoom": 18
|
"maxzoom": 18
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@
|
|||||||
"approved_roads": {
|
"approved_roads": {
|
||||||
"type": "vector",
|
"type": "vector",
|
||||||
"tiles": [
|
"tiles": [
|
||||||
"http://188.68.36.205:3202/approved_roads/{z}/{x}/{y}"
|
"https://tiles.intaleqapp.com/approved_roads/{z}/{x}/{y}"
|
||||||
],
|
],
|
||||||
"minzoom": 8,
|
"minzoom": 8,
|
||||||
"maxzoom": 18
|
"maxzoom": 18
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@
|
|||||||
"approved_roads": {
|
"approved_roads": {
|
||||||
"type": "vector",
|
"type": "vector",
|
||||||
"tiles": [
|
"tiles": [
|
||||||
"http://188.68.36.205:3202/approved_roads/{z}/{x}/{y}"
|
"https://tiles.intaleqapp.com/approved_roads/{z}/{x}/{y}"
|
||||||
],
|
],
|
||||||
"minzoom": 8,
|
"minzoom": 8,
|
||||||
"maxzoom": 18
|
"maxzoom": 18
|
||||||
|
|||||||
Reference in New Issue
Block a user