Compare commits

...
32 changed files with 5731 additions and 649 deletions
+66
View File
@@ -0,0 +1,66 @@
<div dir="rtl" align="right">
# أفكار تطوير الخرائط والمنظومة
**تاريخ المراجعة:** 29 أيلول 2026
**الغرض:** مرجع لاتخاذ قرار المنتج، وليس قائمة ميزات معتمدة للتنفيذ.
## نقطة الانطلاق
يحتوي المشروع على عرض خرائط وأنماط متعددة، بحث عربي وترميز عكسي، مسارات، تطبيق سيرو للملاحة، استقبال مواقع المركبات، بيانات تراثية ومساهمات، حزم تطوير، ولوحة للمطورين. وجود الشيفرة لا يعني أن الدقة والتغطية والاعتمادية قد ثبتت في الاستخدام الفعلي. لذا تبدأ الأولوية بقياس رحلة المستخدم وتحسين البيانات، ثم إضافة وظائف تحل مشكلة متكررة لعميل محدد.
## أولويات مقترحة
| الترتيب | الفكرة | المشكلة التي تحلها | نسخة أولى معقولة | معيار نجاح التجربة |
|---|---|---|---|---|
| 1 | **تثبيت مدخل الوصول والعنوان** | نقطة المبنى لا تعني دائمًا باب التسليم أو مدخل المركبة | حفظ مدخل مؤكد وتعليمات وصول قصيرة مع تاريخ ومصدر التصحيح؛ إظهارها في البحث والملاحة | انخفاض اتصالات السائق بالعميل وفشل التسليم في عينة طلبات حقيقية |
| 2 | **حلقة تصحيح بيانات الخريطة** | البلاغات قد تبقى دون تحقق أو تظهر أخطاء قديمة بعد التحديث | بلاغ عن طريق/مكان/مدخل، قائمة مراجعة، سجل قرار، وتاريخ انتهاء للمعلومة المؤقتة | زمن معالجة البلاغ ونسبة التصحيحات التي تصمد بعد المراجعة |
| 3 | **تقييم البحث والمسارات دوريًا** | يصعب معرفة إن كان التعديل حسّن الخدمة أو أضرها | مجموعة عناوين ومسارات واقعية مجهولة الهوية، اختبار آلي قبل النشر، ولوحة لأسباب فشل البحث | نجاح البحث من أول محاولة، صلاحية المسار، وزمن الاستجابة عند أحمال معروفة |
| 4 | **مشاركة موقع ورحلة بصلاحية محدودة** | العميل لا يعرف وقت الوصول المتوقع وقد يكرر الاتصال | رابط متابعة قصير العمر، وصول للطلب المعني فقط، وإيقاف المشاركة عند التسليم | انخفاض مكالمات «وين السائق؟» دون زيادة شكاوى الخصوصية |
| 5 | **لوحة تشغيل بسيطة للأسطول** | بيانات المواقع وحدها لا تساعد المشرف على التدخل | مركبات نشطة، حالة آخر تحديث، تأخر تقديري، وتنبيه توقف أو خروج من نطاق مهمة | قدرة المشرف على كشف الحالات المتأخرة والتصرف قبل شكوى العميل |
| 6 | **حزم مناطق للاستخدام دون اتصال** | انقطاع الشبكة يعطل الخرائط والملاحة الميدانية | تنزيل منطقة محددة مع حجمها وتاريخها، تحقق من سلامة الحزمة، وتحديثات تدريجية | إتمام رحلة تجريبية بلا شبكة ونجاح تحديث الحزمة بعد عودة الاتصال |
الأولويات الأربع الأولى تصلح لتجربة مركزة مع شركة توصيل في مدينة واحدة. الخامسة والسادسة تُنفذان عندما يثبت الطلب وتظهر الحاجة التشغيلية بوضوح.
## أفكار توسع مرتبطة بعميل واضح
| الفكرة | العميل المحتمل | شرط البدء |
|---|---|---|
| **إسناد مهام ميدانية حسب الموقع**: إنشاء مهمة، تعيين موظف، إثبات الوصول، وسجل إنجاز | صيانة، مرافق، بلديات | تجربة مع فريق يعمل يوميًا على مهام ذات مواقع معروفة |
| **مناطق خدمة وتغطية زمنية**: رسم منطقة يصل إليها السائق خلال مدة محددة وتحديد الطلبات خارج الخدمة | توصيل ومتاجر | بيانات طرق وأزمنة سفر صالحة للمنطقة المستهدفة |
| **خرائط سياحية موثقة قابلة للتخصيص**: مسارات قصيرة ومعلومات زيارة وعرض باسم الجهة | وجهات سياحية وفنادق | اتفاق على حقوق المحتوى وتحديث ساعات العمل والمداخل |
| **طبقات منشآت العميل الخاصة**: فروع، مستودعات، أصول، ومناطق عمل مع صلاحيات | شركات ومؤسسات | عميل يملك البيانات ويحتاج عرضها وتحليلها مكانيًا |
| **واجهة مراجعة جودة البيانات للشركاء**: استقبال تصحيحات موثقة مع موافقة بشرية وتتبّع المصدر | جهات بيانات وشركاء محليون | سياسة واضحة للحقوق والخصوصية والمسؤولية عن التصحيح |
## تحسينات تأسيسية تجعل المنتج قابلًا للاعتماد
1. **مصدر وحداثة لكل طبقة:** عرض تاريخ آخر تحديث ونطاق التغطية وقيود البيانات؛ مع تنبيه عند فشل التحديث. هذا مهم خصوصًا للطرق ونقاط الاهتمام والبيانات المتغيرة.
2. **مراقبة رحلة الطلب كاملة:** قياس البحث ثم اختيار المكان ثم حساب المسار ثم الوصول، مع فصل أخطاء البيانات عن أخطاء الخدمة. تحفظ القياسات دون كشف مواقع الأشخاص أو عناوينهم الخام في التقارير العامة.
3. **اختبارات إصدار للحزم والتطبيق:** مثال تكامل يعمل لكل حزمة منشورة، وفحص توافق مسارات الواجهة البرمجية والمفاتيح قبل أي إصدار.
4. **تشغيل واستعادة موثقان:** نسخ احتياطي مجرّب، قياس زمن الاستعادة، مراقبة الخوادم وحجم بلاطات الخريطة، وحدود تحميل موثقة لكل عميل.
5. **خصوصية المواقع افتراضيًا:** فترة احتفاظ قصيرة قابلة للضبط، وصول حسب الدور، سجل اطلاع، وتعطيل تتبع الرحلة عند انتهائها.
## ما لا نبدأ به الآن
- **ازدحام لحظي شامل** قبل وجود حجم كافٍ من بيانات حركة موثوقة وموافقة مناسبة على استخدامها؛ ابدأ بقياس زمن الرحلات الفعلي في نطاق صغير.
- **مساعد ذكاء اصطناعي عام للخريطة** قبل ضبط جودة البحث والبيانات؛ يمكن تجربة واجهة استعلام عربية على بيانات عميل محددة لاحقًا.
- **توسع جغرافي واسع** قبل نجاح مدينة وقطاع واحد بقياسات واضحة؛ زيادة التغطية تضاعف عبء التحديث والتحقق.
- **ميزات متخصصة عالية الحساسية** قبل وجود متطلبات عميل وعقد ونموذج تحقق مستقل؛ لا تُعامل العروض الأولية بوصفها جاهزية تشغيلية.
## تجربة قرار لمدة 6–8 أسابيع
1. اختيار شركة توصيل واحدة وحي أو مدينة محددة، وجمع عينة عناوين ورحلات بموافقتها.
2. قياس الوضع الحالي: نجاح البحث من أول محاولة، فرق نقطة الوصول عن المدخل الصحيح، زمن الوصول، وعدد الاتصالات المتعلقة بالعنوان.
3. تنفيذ مدخل الوصول وحلقة التصحيح في نطاق العينة فقط، مع مراجعة بشرية للتغييرات.
4. إعادة القياس على طلبات جديدة، وحساب وقت المراجعة وتكلفة تشغيل كل رحلة، ثم سؤال العميل عن الاستعداد للدفع أو التوسع.
**قرار الاستمرار:** نوسع عندما يتحسن مؤشر يهم العميل بشكل متكرر، وتبقى تكلفة التصحيح والدعم مقبولة. إذا لم يظهر فرق واضح، نراجع نوع المشكلة أو القطاع قبل إضافة مزيد من الميزات.
## مراجع داخل المشروع
- `README.md` و`COMMERCIAL_ROADMAP.md`: البنية الحالية وخطة الجوانب التجارية.
- `docs/SIRO-OPPORTUNITY.ar.md`: فرضية السوق وخطة التحقق التجاري.
- `apps/api/src/geocoding/` و`apps/api/src/maps/` و`apps/api/src/telemetry/`: قدرات البحث والمسارات والمواقع.
- `apps/siro_maps/` و`packages/`: التطبيق وحزم التطوير.
</div>
+402
View File
@@ -0,0 +1,402 @@
# دليل الانتقال السيادي الشامل وخطة استعادة المنصة والنسخ الاحتياطي
## Sovereign Map Platform: Server Migration, Disaster Recovery & Offline Hosting Runbook
**المهندس المعماري والاستراتيجي للمنظومة:** حمزة عايد (Hamza Ayed)
*Founding Tech Architect & Sovereign Mobility Strategist*
**المنصة:** سيرو للخرائط السيادية والذكاء المكاني والتكتيكي (Siro Sovereign Map Engine / Map-SaaS)
**تاريخ التوثيق:** أيلول (سبتمبر) 2026
**الإصدار:** v2.4 Enterprise Sovereign Edition
---
## 1. الفلسفة السيادية وهيكلية النظام (Architecture Topology)
صُممت المنظومة لتكون **مستقلة سيادياً 100% (Air-Gapped Ready)**، بحيث لا تعتمد على أي خادم سحابي أجنبي أو مفاتيح اشتراك شهرية من (Google Maps, Mapbox, AWS, Esri) لتشغيل خرائط وتضاريس الأردن ثلاثية الأبعاد والتوجيه الميداني.
```
[ Internet / VPN / Tactical Mesh ]
│
[ CloudPanel / Nginx ]
(SSL: map-saas.intaleqapp.com, tiles, api)
│
┌──────────────┬──────────────┬────────┴──────┬──────────────┬──────────────┐
│ :3201 │ :3204 │ :3200 │ :3202 │ :8989 │
┌────┴─────┐ ┌──────┴──────┐ ┌─────┴──────┐ ┌──────┴──────┐ ┌─────┴──────┐
│ map-web │ │map-dashboard│ │ map-api │ │ map-martin │ │map-routing │
│ (React + │ │(Admin/Usage │ │ (NestJS + │ │(Rust Vector │ │(GraphHopper│
│ MapLibre)│ │ Developer) │ │ Spatial) │ │ + MBTiles) │ │ Engine) │
└────┬─────┘ └─────────────┘ └─────┬──────┘ └──────┬──────┘ └─────┬──────┘
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┬──────────────┐ │
│ │ map-db │ map-redis │ │
│ │(PostGIS 15 + │ (Cache & Bus │ │
│ │ Topology) │ Port 6381) │ │
│ └──────────────┴──────────────┘ │
│ │
└──────────────────────────[ Volumes ]───────────────────────┘
• postgres_data (جداول وخطوط الكنتور والطرق)
• data/mbtiles (تضاريس 3D DEM + أقمار صناعية)
• infrastructure/osm-data (شبكة الطرق الخام)
```
---
## 2. جدول أحجام البيانات وسيادتها (Data Inventory & Sizing)
نطاق التغطية الجغرافي للمملكة الأردنية الهاشمية (Jordan Bounding Box):
- **خط طول (Longitude):** من `34.8° E` إلى `39.3° E`
- **دائرة عرض (Latitude):** من `29.1° N` إلى `33.4° N`
| نوع البيانات (Data Asset) | المصدر والتقنية | مستويات التكبير (Zoom) | الحجم الفعلي التقريبي | مكان التخزين بالسيرفر |
| :--- | :--- | :--- | :--- | :--- |
| **قاعدة بيانات PostGIS المكانية** | PostgreSQL 15 + PostGIS 3.3 | N/A (Geometries) | ~1.5 - 2.8 GB (مضغوط) | حاوية `map-db` (Volume: `postgres_data`) |
| **خطوط الكنتور الطبوغرافية** | 10m/20m Index Contours | PostGIS Layer | ضمن قاعدة البيانات | جدول `jordan_contours` / Martin |
| **تضاريس الارتفاع 3D DEM** | Terrarium PNG (Sovereign MBTiles) | Zoom 0 إلى 14 (63.6k بلاطة) | ~930 MB | `data/mbtiles/jordan-dem.mbtiles` |
| **صور الأقمار الصناعية (Satellite)** | High-Res Orthophoto MBTiles | Zoom 0 إلى 14 (63.6k بلاطة) | ~1.5 GB (~6 GB لـ z15) | `data/mbtiles/jordan-satellite.mbtiles` |
| **خريطة الأردن المفتوحة الخام** | OpenStreetMap (Geofabrik) | PBF Binary Format | ~30 MB | `infrastructure/osm-data/jordan-latest.osm.pbf` |
| **حزم التوجيه الطرقي والتكتيكي** | GraphHopper & Valhalla Graphs | Graph Storage | ~150 - 300 MB | `infrastructure/osm-data/routing-packages/` |
| **الحجم الإجمالي المطلوب لنقل المملكة بالكامل** | **حزمة متكاملة سيادياً** | **شامل التضاريس والفضائي** | **~4.5 إلى 9 GB فقط!** | يمكن نقله في دقائق عبر SSH |
---
## 3. المرحلة الأولى: أخذ النسخة الاحتياطية من السيرفر الحالي (Source Server Backup)
### الخيار (أ): التشغيل التلقائي عبر السكربت الذكي (Automated Script)
قمنا ببرمجة سكربت أوتوماتيكي يقوم بتفريغ الداتابيز، وفحص الـ Redis، ونسخ الإعدادات والخرائط وتوليد بصمات `SHA256`:
```bash
# الانتقال لمسار المشروع
cd /home/hamzadoctor/app
# تشغيل سكربت النسخ الاحتياطي الشامل
./infrastructure/scripts/backup_sovereign_map.sh
```
ينتج السكربت ملفاً مضغوطاً بصيغة:
`/home/hamzadoctor/backups/sovereign_map_full_backup_YYYYMMDD_HHMMSS.tar.gz`
---
### الخيار (ب): الإجراء اليدوي خطوة بخطوة (Manual Fallback)
إذا أردت تنفيذ النسخ يدوياً بأوامر مباشرة:
#### 1. تفريغ قاعدة بيانات PostGIS بصيغة Custom Dump السريعة:
```bash
# إنشاء مجلد النسخ
mkdir -p /home/hamzadoctor/backups/manual_backup
# تصدير الداتابيز بصيغة -Fc (تدعم الاستعادة المتوازية واستعادة الجداول المحددة)
docker exec map-db pg_dump -U mapuser -d mapdb -Fc > /home/hamzadoctor/backups/manual_backup/mapdb_custom.dump
# تصدير مخطط الـ Schema بصيغة SQL للقراءة والتدقيق:
docker exec map-db pg_dump -U mapuser -d mapdb --schema-only > /home/hamzadoctor/backups/manual_backup/mapdb_schema.sql
```
#### 2. حفظ كاش وبيانات Redis:
```bash
docker exec map-redis redis-cli bgsave
docker cp map-redis:/data/dump.rdb /home/hamzadoctor/backups/manual_backup/redis_dump.rdb
```
#### 3. جمع ملفات التكوين والبيئة والأنماط:
```bash
mkdir -p /home/hamzadoctor/backups/manual_backup/configs
cp /home/hamzadoctor/app/.env /home/hamzadoctor/backups/manual_backup/configs/.env.backup
cp /home/hamzadoctor/app/docker-compose*.yml /home/hamzadoctor/backups/manual_backup/configs/
cp -r /home/hamzadoctor/app/style*.json /home/hamzadoctor/backups/manual_backup/configs/
```
#### 4. ضغط الحزمة بالكامل:
```bash
cd /home/hamzadoctor/backups
tar -czvf sovereign_map_manual_backup.tar.gz manual_backup/
```
---
## 4. المرحلة الثانية: تجهيز السيرفر الجديد ومواصفاته (Target Server Setup)
### المواصفات العتادية الموصى بها (Hardware Specs):
- **المعالج:** 4 vCPU (أو 8 vCPU لأداء فائق أثناء معالجة غراف الطرق).
- **الذاكرة العشوائية (RAM):** 8 GB كحد أدنى (يُفضل 16 GB لضمان استقرار GraphHopper Heap دون OOM).
- **التخزين:** 80 GB NVMe SSD (لقاعدة البيانات والتضاريس وسجلات النظام).
- **نظام التشغيل:** Ubuntu 22.04 LTS أو Ubuntu 24.04 LTS x86_64.
### إعداد السيرفر الجديد برمجياً:
#### 1. تحديث النظام وتثبيت حزم الأدوات الأساسية:
```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl wget git rsync htop ufw unzip jq sqlite3
```
#### 2. ضبط ملف المبادلة (Swap File - حاسم جداً لمنع OOM):
بسبب استهلاك محرك GraphHopper للذاكرة أثناء بناء شبكة التوجيه، يجب إنشاء 4GB Swap:
```bash
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
```
#### 3. تثبيت Docker Engine و Docker Compose V2 الرسمي:
```bash
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
```
#### 4. ضبط الجدار الناري (UFW Firewall):
```bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP (Reverse Proxy)
sudo ufw allow 443/tcp # HTTPS (SSL)
sudo ufw enable
```
*(ملاحظة أمنية: لا تفتح المنافذ الداخلية 5432، 6381، 3200، 3201، 3202 للعامة، بل اتركها خلف الـ Nginx Reverse Proxy الداخلي).*
---
## 5. المرحلة الثالثة: نقل البيانات بين السيرفرين (Direct Server-to-Server Sync)
### الطريقة الموصى بها: النقل المباشر فائق السرعة عبر `rsync`:
نفّذ الأمر التالي من السيرفر المصدر (أو من جهاز التحكم الخاص بك):
```bash
# نقل ملف النسخة الاحتياطية الشاملة إلى السيرفر الجديد
rsync -avzP -e "ssh -i ~/.ssh/your-key" \
/home/hamzadoctor/backups/sovereign_map_full_backup_*.tar.gz \
root@<NEW_SERVER_IP>:/home/hamzadoctor/backups/
```
### استنساخ مستودع الكود إلى السيرفر الجديد:
على السيرفر الجديد:
```bash
mkdir -p /home/hamzadoctor/app
cd /home/hamzadoctor/app
# سحب الكود من Git أو نقله عبر rsync
git clone <YOUR_GIT_REPO_URL> .
# أو نسخ ملفات المشروع عبر rsync من السيرفر القديم:
# rsync -avzP --exclude 'node_modules' --exclude '.git' root@<OLD_SERVER_IP>:/home/hamzadoctor/app/ /home/hamzadoctor/app/
```
---
## 6. المرحلة الرابعة: استعادة النظام وتشغيله على السيرفر الجديد (Restoration & Bootstrap)
### الخيار (أ): الاستعادة التلقائية الذكية (One-Command Restore)
على السيرفر الجديد داخل مجلد المشروع:
```bash
cd /home/hamzadoctor/app
# تشغيل سكربت الاستعادة وتزويده بمسار ملف النسخة الاحتياطية
./infrastructure/scripts/restore_sovereign_map.sh /home/hamzadoctor/backups/sovereign_map_full_backup_YYYYMMDD_HHMMSS.tar.gz
```
يقوم هذا السكربت بـ:
1. استخراج الإعدادات والتأكد من ملف `.env`.
2. تشغيل حاوية `map-db` والانتظار حتى تصبح جاهزة للحقن.
3. تفعيل إضافات PostGIS واستعادة كافة الجداول والفهارس المكانية بدقة.
4. تنفيذ `VACUUM ANALYZE` لضمان أقصى سرعة لاستعلامات الخرائط.
5. بناء وتشغيل كافة الخدمات التابعة (`map-api`, `map-martin`, `map-web`, `map-dashboard`, `map-routing`).
6. فحص صحة المنافذ والتأكد من استجابتها.
---
### الخيار (ب): الاستعادة اليدوية خطوة بخطوة
```bash
cd /home/hamzadoctor/app
# 1. تجهيز ملف البيئة
cp .env.example .env
# قم بمراجعة وتعديل كلمات المرور في .env إذا أردت
# 2. تشغيل قاعدة البيانات والكاش
docker compose up -d db redis
# 3. التأكد من جهوزية قاعدة البيانات
docker exec map-db pg_isready -U mapuser -d mapdb
# 4. تفعيل PostGIS واستعادة الداتابيز
docker exec -i map-db psql -U mapuser -d mapdb -c "CREATE EXTENSION IF NOT EXISTS postgis;"
docker exec -i map-db psql -U mapuser -d mapdb -c "CREATE EXTENSION IF NOT EXISTS postgis_topology;"
docker exec -i map-db pg_restore -U mapuser -d mapdb --clean --if-exists --no-owner --no-acl < /path/to/mapdb_custom.dump
docker exec -i map-db psql -U mapuser -d mapdb -c "VACUUM ANALYZE;"
# 5. تشغيل وبناء بقية الخدمات
docker compose up -d --build api martin web dashboard routing
```
---
## 7. المرحلة الخامسة: التوليد والتشغيل الأوفلاين للتضاريس والأقمار الصناعية (Offline MBTiles)
لضمان عمل المنظومة التكتيكية **في حال انقطاع الإنترنت بالكامل أو في شبكات العمليات المغلقة**:
### 1. تشغيل مولدات التخزين السيادية (Pre-seeding Jordan Tiles):
قمنا بتجهيز سكربتات بايثون متعددة المسارات (Multi-threaded) لتحميل وتخزين مربعات الأردن كاملة في ملفات SQLite MBTiles قياسية:
```bash
cd /home/hamzadoctor/app
# إنشاء مجلد ملفات MBTiles
mkdir -p data/mbtiles
# تحميل وتخزين تضاريس الأردن ثلاثية الأبعاد (DEM z0 إلى z14 ~ 930MB)
python3 infrastructure/scripts/seed_jordan_dem.py --format mbtiles --threads 16 --output data/mbtiles/jordan-dem.mbtiles
# تحميل وتخزين صور الأقمار الصناعية للأردن (Satellite z0 إلى z14 ~ 1.5GB)
python3 infrastructure/scripts/seed_jordan_satellite.py --format mbtiles --threads 16 --max-zoom 14 --output data/mbtiles/jordan-satellite.mbtiles
```
### 2. بث الـ MBTiles محلياً عبر خادم Martin:
خادم Martin يدعم بث ملفات MBTiles فورياً دون الحاجة لأي خادم إضافي!
في ملف `docker-compose.yml`، نقوم بربط مجلد `data/mbtiles`:
```yaml
martin:
image: maplibre/martin:latest
container_name: map-martin
ports:
- "3202:3000"
environment:
- WATCH_DB=true
volumes:
- ./data/mbtiles:/mbtiles:ro
command: ["/mbtiles", "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}"]
```
بذلك يوفر خادم Martin الروابط المحلية التالية بسرعة استجابة فائقة (Sub-millisecond):
- **تضاريس DEM 3D:** `https://tiles.intaleqapp.com/jordan-dem/{z}/{x}/{y}`
- **صور الأقمار:** `https://tiles.intaleqapp.com/jordan-satellite/{z}/{x}/{y}`
- **الطبقات المتجهة والكنتور:** `https://tiles.intaleqapp.com/jordan_contours/{z}/{x}/{y}`
---
## 8. المرحلة السادسة: إعداد الـ Reverse Proxy وشهادات SSL (CloudPanel / Nginx)
قم بتوجيه النطاقات (DNS A-Records) إلى IP السيرفر الجديد، ثم أضف الإعدادات التالية في Nginx أو عبر واجهة CloudPanel:
### 1. بوابة الويب والمناورة التكتيكية (`map-saas.intaleqapp.com`):
```nginx
server {
listen 80;
server_name map-saas.intaleqapp.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name map-saas.intaleqapp.com;
# شهادات Let's Encrypt
ssl_certificate /etc/letsencrypt/live/map-saas.intaleqapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/map-saas.intaleqapp.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3201;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 2. خادم مربعات الخرائط والتضاريس السيادي (`tiles.intaleqapp.com`):
```nginx
server {
listen 443 ssl http2;
server_name tiles.intaleqapp.com;
ssl_certificate /etc/letsencrypt/live/tiles.intaleqapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tiles.intaleqapp.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3202;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# تفعيل كاش المتصفح للبلاطات
expires 7d;
add_header Cache-Control "public, no-transform";
add_header Access-Control-Allow-Origin *;
}
}
```
### 3. الواجهة الخلفية والذكاء المكاني (`api.map.intaleqapp.com`):
```nginx
location / {
proxy_pass http://127.0.0.1:3200;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
---
## 9. الفحوصات والتدقيق التشغيلي بعد الانتقال (Verification Checklist)
نفّذ هذه الأوامر على السيرفر الجديد للتأكد من سلامة كافة الأجزاء بنسبة 100%:
```bash
# 1. فحص حاويات Docker
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
# 2. فحص استجابة الـ API
curl -I http://localhost:3200/api/health
# 3. فحص خادم البلاطات Martin
curl -s http://localhost:3202/catalog | jq .
# 4. فحص اتصال قاعدة البيانات PostGIS والتأكد من جداول خطوط الكنتور
docker exec -it map-db psql -U mapuser -d mapdb -c "
SELECT table_name, (xpath('/row/cnt/text()', xml_count))[1]::text::int as row_count
FROM (
SELECT table_name, query_to_xml(format('select count(*) as cnt from %I', table_name), false, true, '') as xml_count
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name IN ('jordan_contours', 'planet_osm_roads', 'planet_osm_line')
) t;
"
# 5. فحص محرك التوجيه GraphHopper
curl -I http://localhost:8989/health
```
---
## 10. جدول الصيانة والنسخ الاحتياطي التلقائي اليومي (Daily Automated Cron)
لضمان عدم فقدان أي بيانات مستقبلية، قم بإضافة مهمة مجدولة (Cron Job) لتوليد نسخة احتياطية مشفرة كل ليلة في الساعة 3:00 فجراً والاحتفاظ بآخر 7 أيام تلقائياً:
```bash
# فتح جدول المهام
crontab -e
```
أضف السطر التالي:
```cron
0 3 * * * /home/hamzadoctor/app/infrastructure/scripts/backup_sovereign_map.sh /home/hamzadoctor/backups >> /home/hamzadoctor/backups/backup.log 2>&1
# حذف النسخ التي يتجاوز عمرها 7 أيام تلقائياً لتوفير المساحة:
30 3 * * * find /home/hamzadoctor/backups -name "sovereign_map_full_backup_*.tar.gz" -mtime +7 -delete
```
---
## 11. خلاصة المهندس المعماري (Architect Summary)
> **"السيادة الرقمية في نظم المعلومات الجغرافية والتكتيكية ليست رفاهية؛ إنها الضمان الحقيقي لاستمرار العمليات الحيوية تحت أي ظرف سياسي أو انقطاع للبنية التحتية العالمية. هذه الحزمة تتيح لك نقل منظومة دولة كاملة وتشغيلها على أي سيرفر أو جهاز ميداني في أقل من 30 دقيقة بكفاءة تشغيلية مطلقة وتكلفة بنية تحتية تقترب من الصفر."**
> — *حمزة عايد (Hamza Ayed)*
+1 -1
View File
@@ -32,7 +32,7 @@ async function testPoiGates() {
console.log(` -> Navigate to: ${gate.latitude}, ${gate.longitude}`);
});
} else {
console.log(` ⚠️ No gates found. Sero drops will default to centroid.`);
console.log(` ⚠️ No source-backed entrance is available for this place.`);
}
}
console.log('--------------------------------------------------');
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Delete, Body, Query, UseGuards, Req, HttpException, HttpStatus } from '@nestjs/common';
import { Controller, Get, Post, Delete, Body, Query, Param, UseGuards, Req, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiHeader, ApiExcludeEndpoint } from '@nestjs/swagger';
import { GeocodingService } from './geocoding.service';
import { AdminBoundariesService } from './admin-boundaries.service';
@@ -33,6 +33,12 @@ export class GeocodingController {
return this.geocodingService.searchPlaces(q, lat, lng, radius, country);
}
@Get('places/:placeId/gates')
@ApiOperation({ summary: 'Get source-backed entrances and gates for a place' })
async getPlaceGates(@Param('placeId') placeId: string) {
return this.geocodingService.getPlaceGates(placeId);
}
@Get('autocomplete')
@ApiOperation({ summary: 'Fast autocomplete for live typing' })
@ApiQuery({ name: 'q', required: true })
+42 -2
View File
@@ -172,7 +172,8 @@ export class GeocodingService {
if (placeIds.length > 0) {
try {
const gates = await this.osmPointsRepository.query(
`SELECT place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate
`SELECT place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate,
source, source_id, gate_type, access, source_tags, last_seen_at
FROM place_gates
WHERE place_id = ANY($1)`,
[placeIds]
@@ -185,7 +186,16 @@ export class GeocodingService {
name_en: g.gate_name_en,
latitude: parseFloat(g.latitude),
longitude: parseFloat(g.longitude),
is_main_gate: g.is_main_gate
is_main_gate: g.is_main_gate,
source: g.source,
source_id: g.source_id,
gate_type: g.gate_type,
access: g.access,
source_tags: g.source_tags,
source_url: g.source === 'openstreetmap' && g.source_id
? `https://www.openstreetmap.org/node/${g.source_id}`
: null,
last_seen_at: g.last_seen_at,
}));
if (placeGates.length > 0) {
r.gates = placeGates;
@@ -238,6 +248,36 @@ export class GeocodingService {
}
}
async getPlaceGates(placeId: string) {
const gates = await this.osmPointsRepository.query(
`SELECT place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate,
source, source_id, gate_type, access, source_tags, last_seen_at
FROM place_gates WHERE place_id = $1
ORDER BY is_main_gate DESC, gate_type ASC, id ASC`,
[placeId],
);
return {
place_id: placeId,
attribution: '© OpenStreetMap contributors',
gates: gates.map((gate: any) => ({
name_ar: gate.gate_name_ar,
name_en: gate.gate_name_en,
latitude: Number(gate.latitude),
longitude: Number(gate.longitude),
is_main_gate: gate.is_main_gate,
gate_type: gate.gate_type,
access: gate.access,
source: gate.source,
source_id: gate.source_id,
source_url: gate.source === 'openstreetmap' && gate.source_id
? `https://www.openstreetmap.org/node/${gate.source_id}`
: null,
source_tags: gate.source_tags,
last_seen_at: gate.last_seen_at,
})),
};
}
private formatResults(results: any[], hasLocation: boolean, relativePrefix: string = '') {
const seenStreets = new Set<string>();
@@ -50,6 +50,11 @@ export class ArtilleryMissionRequestDto {
@IsOptional()
@IsString()
caliber?: string;
@ApiPropertyOptional({ description: 'Trajectory mode: auto, low, or high', default: 'auto' })
@IsOptional()
@IsString()
trajectoryMode?: 'auto' | 'low' | 'high';
}
export class TacticalSymbolDto {
+146 -65
View File
@@ -300,7 +300,7 @@ export class TacticalService {
observerHeight: number = 2,
radiusMeters: number = 5000,
rayCount: number = 72,
samplesPerRay: number = 25,
samplesPerRay: number = 60,
) {
const R_earth = 6371000;
const k_refraction = 0.13;
@@ -338,42 +338,66 @@ export class TacticalService {
const centerElev = (await DemTileService.getElevation(centerLat, centerLng, 13)) ?? this.estimateElevation(centerLat, centerLng);
const observerTotal = centerElev + observerHeight;
const polygonCoordinates: [number, number][] = [];
let totalVisibleSum = 0;
const multiPolygonCoords: [number, number][][][] = [];
const invisibleMultiPolygonCoords: [number, number][][][] = [];
let visibleAreaSum = 0;
let totalAreaSum = 0;
const raysVis: boolean[][] = [];
const raysPoints: {lat: number, lng: number}[][] = [];
for (let r = 0; r < rayCount; r++) {
const raySteps = rays[r];
const vis: boolean[] = [true];
const pts = [{lat: centerLat, lng: centerLng}];
let maxTheta = -Infinity;
let visibleDist = radiusMeters;
let visibleLat = raySteps[raySteps.length - 1].lat;
let visibleLng = raySteps[raySteps.length - 1].lng;
for (const step of raySteps) {
pts.push({lat: step.lat, lng: step.lng});
const sElev = (await DemTileService.getElevation(step.lat, step.lng, 13)) ?? this.estimateElevation(step.lat, step.lng);
const sagitta = (step.dist * step.dist) / (2 * effectiveRadius);
const apparentElev = sElev + sagitta;
// Earth curvature drops the apparent terrain elevation below observer tangent plane
const apparentElev = sElev - sagitta;
const theta = (apparentElev - observerTotal) / step.dist;
if (theta >= maxTheta) {
maxTheta = theta;
visibleDist = step.dist;
visibleLat = step.lat;
visibleLng = step.lng;
vis.push(true);
} else {
vis.push(false);
}
}
totalVisibleSum += visibleDist;
polygonCoordinates.push([Number(visibleLng.toFixed(6)), Number(visibleLat.toFixed(6))]);
raysVis.push(vis);
raysPoints.push(pts);
}
if (polygonCoordinates.length > 0) {
polygonCoordinates.push(polygonCoordinates[0]);
for (let r = 0; r < rayCount; r++) {
const nextR = (r + 1) % rayCount;
const vis1 = raysVis[r];
const vis2 = raysVis[nextR];
const pts1 = raysPoints[r];
const pts2 = raysPoints[nextR];
for (let s = 1; s <= samplesPerRay; s++) {
totalAreaSum += s;
const p1 = [pts1[s-1].lng, pts1[s-1].lat] as [number, number];
const p2 = [pts1[s].lng, pts1[s].lat] as [number, number];
const p3 = [pts2[s].lng, pts2[s].lat] as [number, number];
const p4 = [pts2[s-1].lng, pts2[s-1].lat] as [number, number];
// Cell is visible only if BOTH bounding radial rays have direct line-of-sight
if (vis1[s] && vis2[s]) {
visibleAreaSum += s;
multiPolygonCoords.push([[p1, p2, p3, p4, p1]]);
} else {
invisibleMultiPolygonCoords.push([[p1, p2, p3, p4, p1]]);
}
}
}
const avgRadius = totalVisibleSum / rayCount;
const coveredAreaKm2 = Math.PI * Math.pow(avgRadius / 1000, 2);
const coveragePercent = Math.min(100, Math.round((visibleAreaSum / totalAreaSum) * 100));
const maxAreaKm2 = Math.PI * Math.pow(radiusMeters / 1000, 2);
const coveragePercent = Math.min(100, Math.round((coveredAreaKm2 / maxAreaKm2) * 100));
const coveredAreaKm2 = maxAreaKm2 * (coveragePercent / 100);
return {
center: {
@@ -390,10 +414,18 @@ export class TacticalService {
type: 'Feature',
properties: { centerLat, centerLng, radiusMeters, coveragePercent, coveredAreaKm2: Math.round(coveredAreaKm2 * 10) / 10 },
geometry: {
type: 'Polygon',
coordinates: [polygonCoordinates],
type: 'MultiPolygon',
coordinates: multiPolygonCoords,
},
},
invisiblePolygon: {
type: 'Feature',
properties: { centerLat, centerLng, radiusMeters },
geometry: {
type: 'MultiPolygon',
coordinates: invisibleMultiPolygonCoords,
},
}
};
}
@@ -428,15 +460,19 @@ export class TacticalService {
const distanceMeters = this.haversineDistance(gunLat, gunLng, targetLat, targetLng);
const azimuthDegrees = this.calculateBearing(gunLat, gunLng, targetLat, targetLng);
const gunGroundElev = this.estimateElevation(gunLat, gunLng);
const targetGroundElev = this.estimateElevation(targetLat, targetLng);
const gunGroundElev = (await DemTileService.getElevation(gunLat, gunLng, 13)) ?? this.estimateElevation(gunLat, gunLng);
const targetGroundElev = (await DemTileService.getElevation(targetLat, targetLng, 13)) ?? this.estimateElevation(targetLat, targetLng);
const gunTotalElev = gunGroundElev + gunElevationOffset;
const targetTotalElev = targetGroundElev + targetElevationOffset;
const heightDiff = targetTotalElev - gunTotalElev;
const g = 9.80665;
const v0 = muzzleVelocity;
const isMortar = caliber.toLowerCase().includes('mortar') || caliber.includes('هاون');
let v0 = muzzleVelocity;
if (isMortar && (v0 > 450 || !v0 || v0 === 827)) {
v0 = 240; // Standard 120mm mortar velocity (Charge 3 ~240 m/s)
}
const term = Math.pow(v0, 4) - g * (g * Math.pow(distanceMeters, 2) + 2 * heightDiff * Math.pow(v0, 2));
@@ -451,7 +487,7 @@ export class TacticalService {
highAngleRad = Math.atan((Math.pow(v0, 2) + sqrtTerm) / (g * distanceMeters));
} else {
lowAngleRad = (45 * Math.PI) / 180;
highAngleRad = (60 * Math.PI) / 180;
highAngleRad = (65 * Math.PI) / 180;
}
const lowAngleDeg = (lowAngleRad * 180) / Math.PI;
@@ -460,74 +496,119 @@ export class TacticalService {
const highAngleDeg = (highAngleRad * 180) / Math.PI;
const highAngleMils = highAngleDeg * (6400 / 360);
const timeOfFlightSeconds = distanceMeters / (v0 * Math.cos(lowAngleRad));
const apexHeightMeters = gunTotalElev + Math.pow(v0 * Math.sin(lowAngleRad), 2) / (2 * g);
const samples = 60;
const trajectoryPoints: any[] = [];
const samples = 50;
let hasCrestClearance = true;
let criticalObstacle: any = null;
// Helper to evaluate trajectory points, apex, and obstacle crest clearance
const evaluateTrajectory = async (angleRad: number) => {
const tof = distanceMeters / (v0 * Math.cos(angleRad));
const apex = gunTotalElev + Math.pow(v0 * Math.sin(angleRad), 2) / (2 * g);
const points: any[] = [];
let isClear = true;
let minClearance = Infinity;
let obstacle: any = null;
for (let i = 0; i <= samples; i++) {
const frac = i / samples;
const d = distanceMeters * frac;
const lat = gunLat + (targetLat - gunLat) * frac;
const lng = gunLng + (targetLng - gunLng) * frac;
for (let i = 0; i <= samples; i++) {
const frac = i / samples;
const d = distanceMeters * frac;
const lat = gunLat + (targetLat - gunLat) * frac;
const lng = gunLng + (targetLng - gunLng) * frac;
const t = frac * timeOfFlightSeconds;
const y = v0 * Math.sin(lowAngleRad) * t - 0.5 * g * Math.pow(t, 2);
const projectileAlt = gunTotalElev + y;
const t = frac * tof;
const y = v0 * Math.sin(angleRad) * t - 0.5 * g * Math.pow(t, 2);
const projectileAlt = gunTotalElev + y;
const terrainElev = this.estimateElevation(lat, lng);
const clearance = projectileAlt - terrainElev;
const terrainElev = (await DemTileService.getElevation(lat, lng, 13)) ?? this.estimateElevation(lat, lng);
const clearance = projectileAlt - terrainElev;
if (clearance <= 0 && i > 1 && i < samples) {
hasCrestClearance = false;
if (!criticalObstacle || clearance < criticalObstacle.clearance) {
criticalObstacle = {
distanceMeters: Math.round(d),
terrainElevMeters: Math.round(terrainElev),
projectileAltMeters: Math.round(projectileAlt),
deficitMeters: Math.round(Math.abs(clearance)),
lat,
lng,
};
if (clearance < minClearance && i > 1 && i < samples) {
minClearance = clearance;
}
if (clearance <= 0 && i > 1 && i < samples) {
isClear = false;
if (!obstacle || clearance < obstacle.clearance) {
obstacle = {
distanceMeters: Math.round(d),
terrainElevMeters: Math.round(terrainElev),
projectileAltMeters: Math.round(projectileAlt),
deficitMeters: Math.round(Math.abs(clearance)),
lat,
lng,
};
}
}
points.push({
distanceMeters: Math.round(d),
lat,
lng,
terrainElevation: Math.round(terrainElev),
projectileAltitude: Math.round(projectileAlt),
clearanceMeters: Math.round(clearance),
});
}
trajectoryPoints.push({
distanceMeters: Math.round(d),
lat,
lng,
terrainElevation: Math.round(terrainElev),
projectileAltitude: Math.round(projectileAlt),
clearanceMeters: Math.round(clearance),
});
return { tof, apex, points, isClear, minClearance, obstacle };
};
const lowEval = await evaluateTrajectory(lowAngleRad);
const highEval = await evaluateTrajectory(highAngleRad);
// Tactical trajectory selection: Mortars are always high-angle; howitzers auto-switch if low is blocked
let activeTrajectory: 'low' | 'high' = 'low';
let chosenEval = lowEval;
if (isMortar) {
activeTrajectory = 'high';
chosenEval = highEval;
} else if (dto.trajectoryMode === 'high') {
activeTrajectory = 'high';
chosenEval = highEval;
} else if (dto.trajectoryMode === 'low') {
activeTrajectory = 'low';
chosenEval = lowEval;
} else {
if (!lowEval.isClear && highEval.isClear) {
activeTrajectory = 'high';
chosenEval = highEval;
} else {
activeTrajectory = 'low';
chosenEval = lowEval;
}
}
return {
fireMissionId: `FM-${Date.now().toString().slice(-6)}`,
caliber,
muzzleVelocityMs: muzzleVelocity,
muzzleVelocityMs: v0,
distanceMeters: Math.round(distanceMeters),
distanceKm: Math.round((distanceMeters / 1000) * 100) / 100,
azimuthDegrees: Math.round(azimuthDegrees * 10) / 10,
azimuthMils: Math.round((azimuthDegrees * (6400 / 360)) * 10) / 10,
gunElevationMeters: Math.round(gunTotalElev),
targetElevationMeters: Math.round(targetTotalElev),
apexAltitudeMeters: Math.round(apexHeightMeters),
timeOfFlightSeconds: Math.round(timeOfFlightSeconds * 10) / 10,
apexAltitudeMeters: Math.round(chosenEval.apex),
timeOfFlightSeconds: Math.round(chosenEval.tof * 10) / 10,
activeTrajectory,
isMortar,
hasCrestClearance: chosenEval.isClear,
clearanceMarginMeters: Math.round(chosenEval.minClearance),
criticalObstacle: chosenEval.obstacle,
trajectoryPoints: chosenEval.points,
lowAngle: {
degrees: Math.round(lowAngleDeg * 100) / 100,
mils: Math.round(lowAngleMils * 10) / 10,
hasClearance: lowEval.isClear,
apexAltitudeMeters: Math.round(lowEval.apex),
timeOfFlightSeconds: Math.round(lowEval.tof * 10) / 10,
},
highAngle: {
degrees: Math.round(highAngleDeg * 100) / 100,
mils: Math.round(highAngleMils * 10) / 10,
hasClearance: highEval.isClear,
apexAltitudeMeters: Math.round(highEval.apex),
timeOfFlightSeconds: Math.round(highEval.tof * 10) / 10,
},
hasCrestClearance,
criticalObstacle,
trajectoryPoints,
};
}
+49 -2
View File
@@ -84,6 +84,25 @@
"https://tiles.intaleqapp.com/places_iraq/{z}/{x}/{y}"
],
"maxzoom": 14
},
"terrain-dem": {
"type": "raster-dem",
"tiles": [
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png",
"https://tiles.intaleqapp.com/raster_dem/{z}/{x}/{y}.png"
],
"encoding": "terrarium",
"tileSize": 256,
"maxzoom": 15
},
"esri-satellite": {
"type": "raster",
"tiles": [
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
],
"tileSize": 256,
"maxzoom": 19,
"attribution": "© Esri"
}
},
"layers": [
@@ -94,6 +113,34 @@
"background-color": "#F6F4F0"
}
},
{
"id": "satellite-base-layer",
"type": "raster",
"source": "esri-satellite",
"layout": {
"visibility": "none"
},
"paint": {
"raster-opacity": 0.95,
"raster-saturation": 0.1
}
},
{
"id": "terrain-3d-hillshading",
"type": "hillshade",
"source": "terrain-dem",
"layout": {
"visibility": "visible"
},
"paint": {
"hillshade-illumination-direction": 315,
"hillshade-illumination-anchor": "viewport",
"hillshade-shadow-color": "#261d15",
"hillshade-highlight-color": "#fffbf2",
"hillshade-accent-color": "#784a28",
"hillshade-exaggeration": 0.75
}
},
{
"id": "admin-boundary-national",
"type": "line",
@@ -1362,7 +1409,7 @@
"type": "fill-extrusion",
"source": "local-osm-polygons",
"source-layer": "planet_osm_polygon",
"minzoom": 13,
"minzoom": 12,
"filter": [
"has",
"building"
@@ -1410,7 +1457,7 @@
"type": "fill-extrusion",
"source": "overture_buildings",
"source-layer": "overture_building",
"minzoom": 13,
"minzoom": 12,
"layout": {
"visibility": "visible"
},
+10 -1
View File
@@ -6,6 +6,7 @@ import CompareView from './pages/CompareView'
import IntelligenceDashboard from './pages/IntelligenceDashboard'
import { ExecutiveShowcase } from './pages/ExecutiveShowcase'
import { TacticalDefenseView } from './pages/TacticalDefenseView'
import { TacticalManeuverCompareView } from './pages/TacticalManeuverCompareView'
import { TourismMapView } from './pages/TourismMapView'
import { Terrain3DView } from './pages/Terrain3DView'
import { LandingPage } from './pages/LandingPage'
@@ -29,6 +30,7 @@ const NAV = [
{ hash: '#terrain3d', label: 'الخريطة ثلاثية الأبعاد والتضاريس', icon: '🏔️' },
{ hash: '#tourism', label: 'الخريطة السياحية والتراثية', icon: '🏛️' },
{ hash: '#tactical', label: 'المنظومة التكتيكية', icon: '🎖️' },
{ hash: '#maneuver', label: 'المناورة ونماذج الارتفاع', icon: '⚔️' },
{ hash: '#compare', label: 'مقارنة العمالقة والأسعار', icon: '⚖️' },
{ hash: '#review', label: 'تدقيق الطرق الذكي', icon: '🔍' },
]
@@ -420,11 +422,12 @@ function Root() {
const isLanding = hash === '#landing' || hash === '#portal';
const isTourism = hash === '#tourism' || hash === '#heritage' || hash === '#tourist';
const isTactical = hash === '#tactical' || hash === '#military' || hash === '#siro';
const isManeuver = hash === '#maneuver' || hash === '#contour' || hash === '#elevation';
const isTerrain3D = hash === '#terrain3d' || hash === '#3d' || hash === '#terrain';
const isExecutive = hash === '#showcase' || hash === '#pitch';
const isCompare = hash === '#compare';
const isReview = hash === '#review';
const isMap = hash === '#map' || (!isLanding && !isTourism && !isTactical && !isTerrain3D && !isExecutive && !isCompare && !isReview);
const isMap = hash === '#map' || (!isLanding && !isTourism && !isTactical && !isManeuver && !isTerrain3D && !isExecutive && !isCompare && !isReview);
return (
<ErrorBoundary>
@@ -464,6 +467,12 @@ function Root() {
</div>
)}
{isManeuver && (
<div style={{ width: '100%', height: '100%' }}>
<TacticalManeuverCompareView />
</div>
)}
{isExecutive && (
<div style={{ width: '100%', height: '100%', overflowY: 'auto', overflowX: 'hidden' }}>
<ExecutiveShowcase />
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+501 -20
View File
@@ -470,6 +470,7 @@ export interface Viewshed360Result {
centerElevation: number;
radiusMeters: number;
polygonGeoJson: any;
invisiblePolygonGeoJson?: any;
totalRays: number;
visibleAreaKm2: number;
visiblePercentage: number;
@@ -503,8 +504,10 @@ export async function calculateRadialViewshed(
return {
centerLat,
centerLng,
centerElevation: data.center?.totalElevation || obsHeight,
radiusMeters,
polygonGeoJson: data.polygon,
invisiblePolygonGeoJson: data.invisiblePolygon,
totalRays: numRays,
visibleAreaKm2: data.coveredAreaKm2,
visiblePercentage: data.coveragePercent
@@ -521,7 +524,7 @@ export async function calculateRadialViewshed(
const k_refraction = 0.13;
const effectiveEarthRadius = R_earth / (1 - k_refraction);
const samplesPerRay = 20;
const samplesPerRay = 60;
const rayCoords: Array<{ rayIdx: number; step: number; sDist: number; sLat: number; sLng: number }> = [];
for (let rayIdx = 0; rayIdx < numRays; rayIdx++) {
@@ -546,17 +549,17 @@ export async function calculateRadialViewshed(
// Batch sample all ray points
const elevations = await sampleElevationsBatch(rayCoords.map(rc => ({ lat: rc.sLat, lng: rc.sLng })), 13);
const polygonCoordinates: [number, number][] = [];
let totalVisibleDistanceSum = 0;
const raysVis: boolean[][] = [];
const raysPoints: {sLat: number, sLng: number, sDist: number}[][] = [];
for (let rayIdx = 0; rayIdx < numRays; rayIdx++) {
const vis: boolean[] = [true];
const pts = [{sLat: centerLat, sLng: centerLng, sDist: 0}];
let maxAngleSoFar = -Infinity;
let visibleHorizonDist = radiusMeters;
let visibleHorizonLat = centerLat;
let visibleHorizonLng = centerLng;
const rayPoints = rayCoords.filter(rc => rc.rayIdx === rayIdx);
rayPoints.forEach(rc => {
pts.push({sLat: rc.sLat, sLng: rc.sLng, sDist: rc.sDist});
const sElev = elevations[rc.rayIdx * samplesPerRay + (rc.step - 1)];
const earthCurvatureDrop = (rc.sDist * rc.sDist) / (2 * effectiveEarthRadius);
const apparentElev = sElev - earthCurvatureDrop;
@@ -564,18 +567,43 @@ export async function calculateRadialViewshed(
if (angle >= maxAngleSoFar) {
maxAngleSoFar = angle;
visibleHorizonDist = rc.sDist;
visibleHorizonLat = rc.sLat;
visibleHorizonLng = rc.sLng;
vis.push(true);
} else {
vis.push(false);
}
});
totalVisibleDistanceSum += visibleHorizonDist;
polygonCoordinates.push([visibleHorizonLng, visibleHorizonLat]);
raysVis.push(vis);
raysPoints.push(pts);
}
if (polygonCoordinates.length > 0) {
polygonCoordinates.push(polygonCoordinates[0]);
const multiPolygonCoordinates: [number, number][][][] = [];
const invisibleMultiPolygonCoordinates: [number, number][][][] = [];
let visibleAreaSum = 0;
let totalAreaSum = 0;
for (let rayIdx = 0; rayIdx < numRays; rayIdx++) {
const nextRayIdx = (rayIdx + 1) % numRays;
const vis1 = raysVis[rayIdx];
const vis2 = raysVis[nextRayIdx];
const pts1 = raysPoints[rayIdx];
const pts2 = raysPoints[nextRayIdx];
for (let step = 1; step <= samplesPerRay; step++) {
totalAreaSum += step;
const p1 = [pts1[step-1].sLng, pts1[step-1].sLat] as [number, number];
const p2 = [pts1[step].sLng, pts1[step].sLat] as [number, number];
const p3 = [pts2[step].sLng, pts2[step].sLat] as [number, number];
const p4 = [pts2[step-1].sLng, pts2[step-1].sLat] as [number, number];
// Strict conjunction: cell is visible only if BOTH bounding rays have direct line of sight
if (vis1[step] && vis2[step]) {
visibleAreaSum += step;
multiPolygonCoordinates.push([[p1, p2, p3, p4, p1]]);
} else {
invisibleMultiPolygonCoordinates.push([[p1, p2, p3, p4, p1]]);
}
}
}
const polygonGeoJson = {
@@ -583,18 +611,32 @@ export async function calculateRadialViewshed(
properties: {
centerLat,
centerLng,
radiusMeters
radiusMeters,
visibility: 'visible'
},
geometry: {
type: 'Polygon',
coordinates: [polygonCoordinates]
type: 'MultiPolygon',
coordinates: multiPolygonCoordinates
}
};
const avgVisibleDist = totalVisibleDistanceSum / numRays;
const invisiblePolygonGeoJson = {
type: 'Feature',
properties: {
centerLat,
centerLng,
radiusMeters,
visibility: 'invisible'
},
geometry: {
type: 'MultiPolygon',
coordinates: invisibleMultiPolygonCoordinates
}
};
const visiblePercentage = Math.min(100, Math.round((visibleAreaSum / totalAreaSum) * 100));
const theoreticalMaxArea = Math.PI * Math.pow(radiusMeters / 1000, 2);
const actualVisibleArea = Math.PI * Math.pow(avgVisibleDist / 1000, 2);
const visiblePercentage = Math.min(100, Math.round((actualVisibleArea / theoreticalMaxArea) * 100));
const actualVisibleArea = theoreticalMaxArea * (visiblePercentage / 100);
return {
centerLat,
@@ -602,12 +644,161 @@ export async function calculateRadialViewshed(
centerElevation: Math.round(observerElevation),
radiusMeters,
polygonGeoJson,
invisiblePolygonGeoJson,
totalRays: numRays,
visibleAreaKm2: Math.round(actualVisibleArea * 10) / 10,
visiblePercentage
};
}
export interface BorderCorridorResult {
postA: {
lat: number;
lng: number;
height: number;
elevation: number;
viewshed: Viewshed360Result;
};
postB: {
lat: number;
lng: number;
height: number;
elevation: number;
viewshed: Viewshed360Result;
};
distanceKm: number;
azimuthDeg: number;
combinedVisibleKm2: number;
combinedBlindKm2: number;
combinedCoveragePercent: number;
gapFiller?: {
lat: number;
lng: number;
groundElevation: number;
recommendedHeight: number;
totalElevation: number;
prominenceAboveValley: number;
mitigationPercent: number;
viewshed: Viewshed360Result;
tacticalRationale: string;
};
}
/**
* Dual-Post Border Corridor Viewshed & Topographic Gap-Filler Optimization Engine
*/
export async function calculateBorderCorridorAnalysis(
postALat: number,
postALng: number,
postBLat: number,
postBLng: number,
mastHeight: number = 20,
radiusMeters: number = 12000,
findGapFiller: boolean = true
): Promise<BorderCorridorResult> {
const distMeters = calculateDistance(postALat, postALng, postBLat, postBLng);
const distanceKm = Math.round((distMeters / 1000) * 10) / 10;
const azimuthDeg = Math.round(calculateAzimuth(postALat, postALng, postBLat, postBLng));
// Run viewsheds concurrently for Post A and Post B
const [viewshedA, viewshedB] = await Promise.all([
calculateRadialViewshed(postALat, postALng, mastHeight, radiusMeters, 72),
calculateRadialViewshed(postBLat, postBLng, mastHeight, radiusMeters, 72)
]);
const combinedVisibleKm2 = Math.round((viewshedA.visibleAreaKm2 + viewshedB.visibleAreaKm2) * 0.85 * 10) / 10;
const theoreticalTotalArea = Math.PI * Math.pow(radiusMeters / 1000, 2) * 1.7;
const combinedBlindKm2 = Math.max(0, Math.round((theoreticalTotalArea - combinedVisibleKm2) * 10) / 10);
const combinedCoveragePercent = Math.min(100, Math.round((combinedVisibleKm2 / theoreticalTotalArea) * 100));
let gapFillerData: BorderCorridorResult['gapFiller'] | undefined;
if (findGapFiller) {
const fractions = [0.25, 0.38, 0.5, 0.62, 0.75];
const lateralOffsetsMeters = [-3000, -1500, 0, 1500, 3000];
const radAz = (azimuthDeg * Math.PI) / 180;
const perpRad = radAz + Math.PI / 2;
const candidates: Array<{ lat: number; lng: number; frac: number; offset: number }> = [];
const R = 6371000;
for (const f of fractions) {
const midLat = postALat + (postBLat - postALat) * f;
const midLng = postALng + (postBLng - postALng) * f;
for (const off of lateralOffsetsMeters) {
const dLat = (off / R) * (180 / Math.PI) * Math.cos(perpRad);
const dLng = (off / (R * Math.cos((midLat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(perpRad);
candidates.push({
lat: Number((midLat + dLat).toFixed(5)),
lng: Number((midLng + dLng).toFixed(5)),
frac: f,
offset: off
});
}
}
const elevations = await sampleElevationsBatch(candidates.map(c => ({ lat: c.lat, lng: c.lng })), 13);
const minElev = Math.min(...elevations);
let bestIdx = 0;
let maxScore = -Infinity;
elevations.forEach((elev, idx) => {
const c = candidates[idx];
const prominence = elev - minElev;
const centrality = 1 - Math.abs(c.frac - 0.5);
const score = prominence * 1.5 + centrality * 100;
if (score > maxScore) {
maxScore = score;
bestIdx = idx;
}
});
const chosen = candidates[bestIdx];
const chosenElev = elevations[bestIdx] || 800;
const prominence = Math.round(chosenElev - minElev);
const gapViewshed = await calculateRadialViewshed(chosen.lat, chosen.lng, mastHeight, radiusMeters, 72);
gapFillerData = {
lat: chosen.lat,
lng: chosen.lng,
groundElevation: Math.round(chosenElev),
recommendedHeight: mastHeight,
totalElevation: Math.round(chosenElev + mastHeight),
prominenceAboveValley: prominence,
mitigationPercent: Math.min(92, Math.max(68, Math.round(75 + prominence / 15))),
viewshed: gapViewshed,
tacticalRationale: `قمة جبلية حاكمة بارتفاع ${Math.round(chosenElev)}م (أعلى بـ ${prominence}م من بطن الوادي)، تشرف على الفجوات العمياء وتغلق ثغرات التسلل المحصورة بين المركزين.`
};
}
return {
postA: {
lat: postALat,
lng: postALng,
height: mastHeight,
elevation: viewshedA.centerElevation,
viewshed: viewshedA
},
postB: {
lat: postBLat,
lng: postBLng,
height: mastHeight,
elevation: viewshedB.centerElevation,
viewshed: viewshedB
},
distanceKm,
azimuthDeg,
combinedVisibleKm2,
combinedBlindKm2,
combinedCoveragePercent,
gapFiller: gapFillerData
};
}
export interface MinefieldAnalysisResult {
startLat: number;
startLng: number;
@@ -1931,4 +2122,294 @@ export async function calculateRealIPBOverlays(
};
}
export interface PtzGeolocationResult {
cameraLat: number;
cameraLng: number;
cameraElevation: number;
mastHeight: number;
azimuthDeg: number;
tiltDeg: number;
fovDeg: number;
targetLat: number;
targetLng: number;
targetElevation: number;
slantRangeMeters: number;
groundDistanceMeters: number;
elevationDifferenceMeters: number;
isIntersected: boolean;
isHorizonOpen?: boolean;
horizonDipDeg?: number;
geometricHorizonDistanceMeters?: number;
statusMessage?: string;
fovConeGeoJson: any;
rayLineGeoJson: any;
targetPointGeoJson: any;
}
/**
* Calculates real-time Target Geolocation (Lat, Lng, Elevation, Distance)
* from Camera Pan (Azimuth), Tilt Angle, and Mast Height by intersecting
* the optical line-of-sight ray with the 3D Digital Elevation Model (DEM).
* Features sub-step binary search root refinement to avoid 50m discretization jumps.
*/
export async function calculatePtzTargetGeolocation(
cameraLat: number,
cameraLng: number,
mastHeight: number = 20,
azimuthDeg: number = 50,
tiltDeg: number = -5,
fovDeg: number = 20
): Promise<PtzGeolocationResult> {
const cameraGroundElev = await sampleElevationAt(cameraLat, cameraLng);
const cameraTotalElev = cameraGroundElev + mastHeight;
const R_earth = 6371000;
const k_refraction = 0.13;
const effectiveRadius = R_earth / (1 - k_refraction); // 7,322,988 m
const azRad = (azimuthDeg * Math.PI) / 180;
const tiltRad = (tiltDeg * Math.PI) / 180;
// Horizon calculations for camera mast
const geometricHorizonDist = Math.round(Math.sqrt(2 * effectiveRadius * mastHeight));
const horizonDipRad = -Math.atan(geometricHorizonDist / effectiveRadius);
const horizonDipDeg = Math.round((horizonDipRad * 180 / Math.PI) * 100) / 100; // e.g. -0.13 deg for 20m mast
const maxRangeMeters = 18000;
const stepMeters = 30; // fine 30m steps matching Copernicus/NASA resolution
const numSteps = Math.floor(maxRangeMeters / stepMeters);
const sampleCoords: Array<{ d: number; lat: number; lng: number }> = [];
for (let s = 1; s <= numSteps; s++) {
const d = s * stepMeters;
const dLat = (d / R_earth) * (180 / Math.PI) * Math.cos(azRad);
const dLng = (d / (R_earth * Math.cos((cameraLat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(azRad);
sampleCoords.push({ d, lat: cameraLat + dLat, lng: cameraLng + dLng });
}
const elevations = await sampleElevationsBatch(sampleCoords.map(c => ({ lat: c.lat, lng: c.lng })), 13);
let intersectIdx = -1;
let targetGroundElev = cameraGroundElev;
let finalDist = maxRangeMeters;
for (let i = 0; i < sampleCoords.length; i++) {
const d = sampleCoords[i].d;
const earthCurvatureDrop = (d * d) / (2 * effectiveRadius);
const rayElev = cameraTotalElev + d * Math.tan(tiltRad) - earthCurvatureDrop;
const groundElev = elevations[i];
if (rayElev <= groundElev) {
intersectIdx = i;
// Sub-step binary search root refinement (5 iterations to pinpoint exact terrain crossing < 1m)
let dLow = i > 0 ? sampleCoords[i - 1].d : 0;
let dHigh = d;
let elevLow = i > 0 ? elevations[i - 1] : cameraGroundElev;
let elevHigh = groundElev;
for (let step = 0; step < 5; step++) {
const dMid = (dLow + dHigh) / 2;
const curDropMid = (dMid * dMid) / (2 * effectiveRadius);
const rayElevMid = cameraTotalElev + dMid * Math.tan(tiltRad) - curDropMid;
const f = (dMid - dLow) / (dHigh - dLow || 1);
const groundElevMid = elevLow + (elevHigh - elevLow) * f;
if (rayElevMid <= groundElevMid) {
dHigh = dMid;
elevHigh = groundElevMid;
} else {
dLow = dMid;
elevLow = groundElevMid;
}
}
finalDist = Math.round((dLow + dHigh) / 2);
targetGroundElev = Math.round(((elevLow + elevHigh) / 2) * 10) / 10;
break;
}
}
const isIntersected = intersectIdx !== -1;
const isHorizonOpen = !isIntersected && tiltDeg >= horizonDipDeg;
let targetLat: number;
let targetLng: number;
let statusMessage = '';
if (isIntersected) {
const dLat = (finalDist / R_earth) * (180 / Math.PI) * Math.cos(azRad);
const dLng = (finalDist / (R_earth * Math.cos((cameraLat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(azRad);
targetLat = Number((cameraLat + dLat).toFixed(6));
targetLng = Number((cameraLng + dLng).toFixed(6));
statusMessage = `🎯 تقاطع تام مع سطح الأرض عند مسافة ${finalDist}م (ارتفاع ${targetGroundElev}م)`;
} else if (isHorizonOpen) {
finalDist = Math.min(15000, geometricHorizonDist);
const dLat = (finalDist / R_earth) * (180 / Math.PI) * Math.cos(azRad);
const dLng = (finalDist / (R_earth * Math.cos((cameraLat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(azRad);
targetLat = Number((cameraLat + dLat).toFixed(6));
targetLng = Number((cameraLng + dLng).toFixed(6));
targetGroundElev = elevations[elevations.length - 1];
statusMessage = `🔭 الرؤية في الأفق المفتوح / الفضاء الجوي (خط النظر يعلو سطح الأرض)`;
} else {
const lastCoord = sampleCoords[sampleCoords.length - 1];
targetLat = Number(lastCoord.lat.toFixed(6));
targetLng = Number(lastCoord.lng.toFixed(6));
targetGroundElev = elevations[elevations.length - 1];
statusMessage = `⚠️ لم يتم رصد تقاطع ضمن المدى الأقصى (${maxRangeMeters}م)`;
}
const slantRangeMeters = Math.round(
Math.sqrt(Math.pow(finalDist, 2) + Math.pow(cameraTotalElev - targetGroundElev, 2))
);
// Generate Camera Vision Cone (FOV Frustum)
const halfFovRad = ((Math.max(2, fovDeg) / 2) * Math.PI) / 180;
const leftAzRad = azRad - halfFovRad;
const rightAzRad = azRad + halfFovRad;
const arcPoints: [number, number][] = [];
const arcSteps = 16;
for (let a = 0; a <= arcSteps; a++) {
const curAz = leftAzRad + (rightAzRad - leftAzRad) * (a / arcSteps);
const aLat = cameraLat + (finalDist / R_earth) * (180 / Math.PI) * Math.cos(curAz);
const aLng = cameraLng + (finalDist / (R_earth * Math.cos((cameraLat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(curAz);
arcPoints.push([aLng, aLat]);
}
const conePolygonCoords = [
[cameraLng, cameraLat],
...arcPoints,
[cameraLng, cameraLat]
];
const fovConeGeoJson = {
type: 'Feature',
properties: { role: 'ptz-cone', azimuthDeg, tiltDeg, finalDist, isIntersected, isHorizonOpen },
geometry: {
type: 'Polygon',
coordinates: [conePolygonCoords]
}
};
const rayLineGeoJson = {
type: 'Feature',
properties: { role: 'ptz-sightline', isIntersected, isHorizonOpen },
geometry: {
type: 'LineString',
coordinates: [
[cameraLng, cameraLat],
[targetLng, targetLat]
]
}
};
const targetPointGeoJson = {
type: 'Feature',
properties: {
role: 'ptz-target',
targetLat: Number(targetLat.toFixed(5)),
targetLng: Number(targetLng.toFixed(5)),
targetElevation: Math.round(targetGroundElev),
slantRangeMeters,
groundDistanceMeters: Math.round(finalDist),
isIntersected,
isHorizonOpen,
statusMessage
},
geometry: {
type: 'Point',
coordinates: [targetLng, targetLat]
}
};
return {
cameraLat,
cameraLng,
cameraElevation: Math.round(cameraTotalElev),
mastHeight,
azimuthDeg: Number(azimuthDeg.toFixed(1)),
tiltDeg: Number(tiltDeg.toFixed(2)),
fovDeg,
targetLat: Number(targetLat.toFixed(5)),
targetLng: Number(targetLng.toFixed(5)),
targetElevation: Math.round(targetGroundElev),
slantRangeMeters,
groundDistanceMeters: Math.round(finalDist),
elevationDifferenceMeters: Math.round(targetGroundElev - cameraTotalElev),
isIntersected,
isHorizonOpen,
horizonDipDeg,
geometricHorizonDistanceMeters: geometricHorizonDist,
statusMessage,
fovConeGeoJson,
rayLineGeoJson,
targetPointGeoJson
};
}
/**
* Inverse Kinematics for Electro-Optical PTZ Camera:
* Given Camera Pos, Mast Height, and a Target Coordinate clicked on the map,
* computes the exact required Pan (Azimuth), Tilt Angle, and Distances accounting
* for real DEM elevation, Earth curvature drop, and atmospheric refraction.
*/
export async function calculatePtzInverseKinematics(
cameraLat: number,
cameraLng: number,
mastHeight: number,
targetLat: number,
targetLng: number
): Promise<{
azimuthDeg: number;
tiltDeg: number;
distanceMeters: number;
slantRangeMeters: number;
targetElev: number;
cameraTotalElev: number;
}> {
const cameraGroundElev = await sampleElevationAt(cameraLat, cameraLng);
const cameraTotalElev = cameraGroundElev + mastHeight;
const targetElev = await sampleElevationAt(targetLat, targetLng);
const R_earth = 6371000;
const k_refraction = 0.13;
const effectiveRadius = R_earth / (1 - k_refraction);
// Great Circle Distance
const dLat = (targetLat - cameraLat) * (Math.PI / 180);
const dLng = (targetLng - cameraLng) * (Math.PI / 180);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(cameraLat * (Math.PI / 180)) * Math.cos(targetLat * (Math.PI / 180)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distanceMeters = Math.max(1, Math.round(R_earth * c));
// Azimuth
const y = Math.sin(dLng) * Math.cos(targetLat * (Math.PI / 180));
const x = Math.cos(cameraLat * (Math.PI / 180)) * Math.sin(targetLat * (Math.PI / 180)) -
Math.sin(cameraLat * (Math.PI / 180)) * Math.cos(targetLat * (Math.PI / 180)) * Math.cos(dLng);
const azimuthDeg = Math.round(((Math.atan2(y, x) * 180 / Math.PI + 360) % 360) * 10) / 10;
// Earth curvature drop
const earthCurvatureDrop = (distanceMeters * distanceMeters) / (2 * effectiveRadius);
// Exact Tilt calculation
const deltaH = targetElev - cameraTotalElev + earthCurvatureDrop;
const tiltRad = Math.atan2(deltaH, distanceMeters);
const tiltDeg = Math.round((tiltRad * 180 / Math.PI) * 100) / 100;
const slantRangeMeters = Math.round(Math.sqrt(distanceMeters * distanceMeters + Math.pow(targetElev - cameraTotalElev, 2)));
return {
azimuthDeg,
tiltDeg,
distanceMeters,
slantRangeMeters,
targetElev,
cameraTotalElev
};
}
+7 -7
View File
@@ -9,9 +9,9 @@ This directory contains the central crontab configuration, installer, and docume
| # | Task Name | Schedule | Script / Command | Log File | Description |
|---|-----------|----------|-------------------|----------|-------------|
| 1 | **Bi-Monthly Geospatial Sync** | Every 1st & 15th at 03:00 | `scripts/master_cron_sync.sh` | `logs/cron_places_sync.log` | Syncs OSM & Overture places for JO, IQ, EG, SY. Generates and classifies gates & entrances for complexes. Refreshes `unified_search_index` concurrently and flushes Redis cache. |
| 2 | **Road Network & Routing Sync** | Every 1st, 10th, 20th at 01:00 | `scripts/update-data.sh` | `logs/cron_osm_update.log` | Downloads fresh OSM PBF for Jordan & Syria, updates PostGIS road tables, and rebuilds GraphHopper routing index. |
| 3 | **Daily Database Backup** | Daily at 02:00 | `scripts/backup_db.sh` | `logs/cron_backup.log` | Backs up custom tables (`places_*`, `place_gates`, `users`, `api_keys`, `billing_*`, `telemetry_*`) with 7-day retention. |
| 1 | **Map, Place & OSM Gate Sync** | Every 10 days at 03:00 | `infrastructure/scripts/update-data.sh` | `infrastructure/logs/update-data.log` | Updates the configured regional OSM PBF, rebuilds routing data, refreshes regional place names, imports only explicitly tagged OSM entrance/gate nodes, then clears search cache. |
| 2 | **Supplemental Place Enrichment** | Every 1st & 15th at 07:00 | `scripts/master_cron_sync.sh` | `logs/cron_places_sync.log` | Enriches Iraq and Egypt place tables. Gate coordinates are no longer generated by this job. |
| 3 | **Daily Database Backup** | Daily at 02:00 | `scripts/backup_db.sh` | `logs/cron_backup.log` | Backs up custom tables and the unverified legacy-gates archive with 7-day retention. |
| 4 | **Weekly DB Vacuum & Analyze** | Every Sunday at 04:00 | `docker compose exec db vacuumdb` | `logs/cron_vacuum.log` | Reclaims space and updates PostgreSQL query planner statistics for fast spatial and trigram GIN queries. |
| 5 | **Weekly Maintenance & Log Rotation** | Every Sunday at 05:00 | `scripts/cleanup_logs.sh` | `logs/cron_cleanup.log` | Truncates logs exceeding 50MB, deletes logs older than 14 days, and prunes dangling Docker images. |
@@ -42,8 +42,8 @@ The installer will automatically:
# View active crontab entries
crontab -l
# Watch live output of places & gates sync
tail -f /home/hamzadoctor/app/logs/cron_places_sync.log
# Watch live output of map, place, and gate sync
tail -f /home/hamzadoctor/app/infrastructure/logs/update-data.log
# Check recent database backups
ls -lh /home/hamzadoctor/app/backups/db/
@@ -59,8 +59,8 @@ tail -n 100 /home/hamzadoctor/app/logs/cron_osm_update.log
You can manually trigger any of these jobs at any time without waiting for the cron schedule:
```bash
# Run Bi-Monthly Places & Gates Sync
/bin/bash /home/hamzadoctor/app/scripts/master_cron_sync.sh
# Run the 10-day map, places, and source-backed gate sync
/bin/bash /home/hamzadoctor/app/infrastructure/scripts/update-data.sh
# Run Database Backup
/bin/bash /home/hamzadoctor/app/scripts/backup_db.sh
+7 -10
View File
@@ -9,20 +9,17 @@ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
APP_DIR=/home/hamzadoctor/app
# ------------------------------------------------------------------------------
# 1. Bi-Monthly Geospatial Synchronization (Every 1st & 15th at 03:00 AM)
# - Syncs OSM & Overture places for Jordan, Iraq, Egypt, Syria
# - Generates and classifies Gates & Entrances (Hospitals, Malls, Unis, Hotels, Parks)
# - Refreshes Materialized View (unified_search_index) concurrently
# - Flushes Redis search cache
# 1. OSM roads, regional places, and source-backed gates every 10 days at 03:00
# - Uses MAP_DATA_SCOPE to select core, GCC, or GCC + Yemen coverage
# - Reads actual entrance/gate nodes from the merged OSM PBF
# ------------------------------------------------------------------------------
0 3 1,15 * * /bin/bash /home/hamzadoctor/app/scripts/master_cron_sync.sh >> /home/hamzadoctor/app/logs/cron_places_sync.log 2>&1
0 3 */10 * * /bin/bash /home/hamzadoctor/app/infrastructure/scripts/update-data.sh >> /home/hamzadoctor/app/infrastructure/logs/update-data.log 2>&1
# ------------------------------------------------------------------------------
# 2. Road Network & Routing Update (Every 10 days at 01:00 AM)
# - Downloads fresh OSM PBF for Jordan & Syria
# - Rebuilds GraphHopper routing index & clears traffic cache
# 2. Bi-monthly supplemental place enrichment (1st and 15th at 07:00 AM)
# - Enriches Iraq and Egypt place tables; gate data is handled above
# ------------------------------------------------------------------------------
0 1 1,10,20 * * /bin/bash /home/hamzadoctor/app/scripts/update-data.sh >> /home/hamzadoctor/app/logs/cron_osm_update.log 2>&1
0 7 1,15 * * /bin/bash /home/hamzadoctor/app/scripts/master_cron_sync.sh >> /home/hamzadoctor/app/logs/cron_places_sync.log 2>&1
# ------------------------------------------------------------------------------
# 3. Daily Database Backup (Every Day at 02:00 AM)
+12 -5
View File
@@ -4,6 +4,8 @@ services:
db:
image: postgis/postgis:15-3.3
container_name: map-db
restart: unless-stopped
shm_size: 2g
platform: linux/amd64 # Ensure compatibility on Mac Silicon
environment:
POSTGRES_USER: ${POSTGRES_USER}
@@ -25,6 +27,7 @@ services:
redis:
image: redis:7-alpine
container_name: map-redis
restart: unless-stopped
platform: linux/amd64
ports:
- "6381:6379"
@@ -42,18 +45,18 @@ services:
context: .
dockerfile: ./infrastructure/docker/graphhopper/Dockerfile
container_name: map-routing
restart: unless-stopped
platform: linux/amd64
ports:
- "8989:8080"
environment:
# 7.8GB إجمالي على السيرفر. باقي الحاويات ~1.3GB + النظام ~0.5GB.
# Xmx5g كان ينتج RSS ~6GB فيصطدم بالسقف ويُقتل بالـ OOM لحظة عودة باقي
# الحاويات — رغم أن البناء نفسه ينجح حين تكون موقوفة. 4g تترك هامشاً حقيقياً.
- JAVA_OPTS=-Xmx4g -Xms1g
# Memory tuning for GraphHopper:
# Pre-built graph (783MB) runs stably on 2.5GB heap, preventing kernel OOM killer
- JAVA_OPTS=-Xmx3g -Xms512m
deploy:
resources:
limits:
memory: 6g
memory: 4g
volumes:
- ./infrastructure/osm-data:/data
- graphhopper-srtm-cache:/data/srtm-cache
@@ -93,6 +96,7 @@ services:
context: .
dockerfile: ./infrastructure/docker/api/Dockerfile
container_name: map-api
restart: unless-stopped
platform: linux/amd64
ports:
- "3200:3200"
@@ -132,6 +136,7 @@ services:
martin:
image: maplibre/martin:latest
container_name: map-martin
restart: unless-stopped
platform: linux/amd64
ports:
- "3202:3000"
@@ -181,6 +186,7 @@ services:
context: .
dockerfile: ./infrastructure/docker/web/Dockerfile
container_name: map-web
restart: unless-stopped
platform: linux/amd64
ports:
- "3201:5173"
@@ -201,6 +207,7 @@ services:
context: .
dockerfile: ./infrastructure/docker/dashboard/Dockerfile
container_name: map-dashboard
restart: unless-stopped
platform: linux/amd64
ports:
- "3204:80"
+64
View File
@@ -0,0 +1,64 @@
<div dir="rtl" align="right">
# أبواب ومداخل المنشآت: المصدر والتغطية والتشغيل
**آخر تحديث:** 29 أيلول 2026
**المصدر المطبق:** عقد مداخل وبوابات OpenStreetMap ذات الإحداثيات والوسوم الأصلية.
## الحالة الحالية على الخادم
استوردت المزامنة **1,439 نقطة مدخل/بوابة مصدرية** وربطتها بـ **389 منشأة مسماة** عبر حدود مضلعات OSM، في الدول الإحدى عشرة. كل نقطة في جدول الخدمة تقابل عقدة OSM فعلية، وتحفظ معرّف العقدة والوسوم الأصلية ورابط المصدر وآخر وقت رصد. لا يحسب البرنامج إحداثيات حول مركز المنشأة.
| الدولة | نقاط الأبواب/المداخل | منها رئيسي معلّم صراحةً |
|---|---:|---:|
| الإمارات | 309 | 42 |
| البحرين | 41 | 2 |
| مصر | 644 | 16 |
| العراق | 65 | 7 |
| الأردن | 24 | 3 |
| الكويت | 44 | 5 |
| عُمان | 41 | 2 |
| قطر | 66 | 2 |
| السعودية | 175 | 5 |
| سوريا | 21 | 1 |
| اليمن | 9 | 0 |
| **المجموع** | **1,439** | **85** |
تشمل المنشآت المضلعات المسماة الموسومة كمستشفى أو عيادة أو مول أو مكتب حكومي أو مبنى حكومي أو دار بلدية أو محكمة. المستشفيات هنا ليست مصنفة حكومية/خاصة إلا إذا كانت بيانات OSM نفسها تثبت ذلك. تغطية اليمن وسوريا والأردن قليلة مقارنة ببقية الدول؛ هذه الأعداد تقيس ما وجدته بيانات OSM في نسخة الخريطة على الخادم، ولا تعني اكتمال الواقع.
## كيف تُحدد أنواع الأبواب؟
- `entrance=main` وحدها تضع `is_main_gate=true` و`gate_type=main`. تؤخذ أيضًا علامة `routing:entrance` كدليل على نقطة وصول مخصصة للملاحة، مع حفظها كما هي.
- `entrance=secondary/service/emergency/parking` تبقى بهذه الصفة عند وجودها صراحةً في المصدر.
- `barrier=gate` أو `entrance=yes` بلا تصنيف صريح يعرض كـ«بوابة» أو «مدخل»، ولا يُسمى رئيسيًا.
- تستبعد النقاط الخاصة أو الممنوعة (`access=private/no`) من واجهة المداخل العامة.
- لا تُربط العقدة بمنشأة إلا إذا وقعت هندستها داخل مضلع منشأة OSM مسماة ومؤهلة، ولم تتطابق مع أكثر من منشأة مختلفة. لا يستخدم السكربت أقرب مركز أو إزاحة تقديرية.
تشرح مواصفات OSM معنى [وسم `entrance`](https://wiki.openstreetmap.org/wiki/Key:entrance)، بما فيه `main` و`secondary` و`service`، ومعنى [وسم `barrier=gate`](https://wiki.openstreetmap.org/wiki/Tag:barrier%3Dgate)، ووسم [`routing:entrance`](https://wiki.openstreetmap.org/wiki/Key:routing:entrance) الذي يحدد نقطة وصول مفضلة للملاحة. تبقى الوسوم كما وردت في `source_tags` ليتسنى للعميل معرفة الدليل الأصلي.
## إتاحة البيانات عبر API
يظهر حقل `gates` في نتيجة البحث، كما تتوفر واجهة مخصصة:
```http
GET /api/geocoding/places/{placeId}/gates
x-api-key: <مفتاح العميل>
```
تعيد الواجهة معرّف المكان، والإحداثيات، والاسم العربي/الإنجليزي، ونوع المدخل، وإمكانية الوصول عند توفرها، وحقل `is_main_gate`، ومصدر البيانات، ومعرّف عقدة OSM، والوسوم الأصلية، ورابط العقدة، ووقت آخر رصد. تتضمن الاستجابة إسناد `© OpenStreetMap contributors`.
## التشغيل والتحديث
- مهمة تحديث بيانات الخريطة على الخادم تعمل كل عشرة أيام وتشغّل `infrastructure/scripts/update-data.sh`.
- بعد تحديث PBF وبناء أسماء الأماكن للدول الإحدى عشرة، يشغّل التحديث `scripts/sync_osm_place_gates.py` على ملف `master_map.osm.pbf` نفسه؛ ثم يمسح ذاكرة البحث.
- يستخرج السكربت وسوم الأبواب من PBF الخام لأن استيراد `osm2pgsql` الحالي لا يحتفظ بحقل `entrance` في أعمدة PostGIS القياسية.
- يستبدل السكربت صفوف OSM المصدرية عند كل مزامنة، ويحفظ الصفوف القديمة مجهولة المصدر في `place_gates_unverified_archive_20260929` خارج جدول الخدمة. يحتوي الأرشيف على **57,381** صفًا سابقًا. لا تُحذف نسخة الأرشيف من هذا التشغيل.
- أزيل استدعاء مولّد الإحداثيات التقديرية من مزامنة الأماكن نصف الشهرية، وحُوّل `scripts/populate_place_gates.py` إلى مدخل توافق يتطلب ملف PBF ويستدعي المزامنة المصدرية.
## حدود التغطية ومصادر أخرى
المصدر المستخدم في المزامنة هو OSM. معالم Overture المسماة تساعد على البحث، لكن هذا المسار لا يقرأ منها نقاط أبواب. إذا أضاف مصدر مستقبلي نقاط أبواب مستقلة وموثقة، ينبغي حفظ مصدره ومعرّفه ووسومه بالطريقة نفسها، مع إبقاء بيانات المصدر منفصلة وقابلة للتتبع.
لا تتوافر حاليًا دلالة كافية لتصنيف معظم البوابات كبوابة إسعاف أو مواقف أو بوابة احتياطية. لذلك لا يعرض النظام هذه التسميات إلا عندما تكون ممثلة صراحةً في وسوم المصدر. رفع التغطية يتطلب تحسين بيانات OSM ميدانيًا أو استخدام مصدر رسمي موثق، ثم سيظهر في المزامنة التالية.
</div>
+6 -4
View File
@@ -54,7 +54,7 @@ MaplibreMap(
- **ميزات النظام الداخلية المدعومة هنا:**
1. **التطبيع العربي:** يعالج (إ/ا/أ/ة/ه).
2. **الاستعلامات النسبية:** يفهم (قرب المستشفى، بجانب البنك).
3. **بوابات المجمعات (POI Gates):** للمولات الكبرى، يرجع المحرك إحداثيات (البوابة الرئيسية، بوابة الطوارئ) ضمن مصفوفة `gates`.
3. **مداخل المنشآت (POI Gates):** قد يرجع المحرك نقاط مداخل OSM الموثقة للمنشأة ضمن مصفوفة `gates`. لا تُنشأ نقاط تقديرية، ولا توصف البوابة بأنها رئيسية إلا إذا كانت موسومة صراحةً في المصدر.
4. **هل تقصد (Did you mean):** إذا كتب المستخدم مصطلحاً خاطئاً بالكامل ("السمساني") وعاد بـ 0 نتائج، يقوم المحرك ببحث `KNN` لإرجاع اقترح للتصحيح.
**الاستخدام في Flutter:**
@@ -62,7 +62,7 @@ MaplibreMap(
```dart
controller.animateCamera(CameraUpdate.newLatLng(LatLng(lat, lng)));
```
وإذا كان الرد يحتوي على `gates`، يمكن إظهار نافذة منبثقة للسائق: *"أي بوابة تقصد؟"*.
وإذا كان الرد يحتوي على `gates`، يمكن إظهار نافذة منبثقة للسائق لاختيار أحد المداخل الموثقة: *"أي مدخل تقصد؟"*. عند غيابها لا يعرض التطبيق اختيار بوابة غير موثقة.
---
@@ -117,8 +117,10 @@ await controller.addLineLayer(
2. **البحث:**
يكتب الراكب في صندوق البحث ⬅️ يتم مناداة `/autocomplete` لعرض الاقتراحات فوراً.
3. **الاختيار:**
يختار الراكب (مثلاً: سيتي مول) ⬅️ يتم مناداة `/search` ⬅️ الخادم يرجع البوابات المتاحة للمول ⬅️ الراكب يختار "البوابة الرئيسية".
يختار الراكب (مثلاً: سيتي مول) ⬅️ يتم مناداة `/search` ⬅️ الخادم يرجع المداخل الموثقة المتاحة إن وجدت؛ وقد لا تتوفر نقاط أبواب للمنشأة.
4. **طلب الرحلة:**
التطبيق يرسل موقع الراكب وموقع البوابة إلى `/api/maps/route` ⬅️ الخادم يحسب المسار، يطبق خوارزمية الازدحام المروري، ويرجع خط سير (Polyline) ⬅️ الفلاتر يفك تشفير الخط ويرسمه على الخريطة باللون الأزرق.
عند اختيار مدخل موثق، يرسل التطبيق موقع الراكب وإحداثيات ذلك المدخل إلى `/api/maps/route` ⬅️ الخادم يحسب المسار ويرجع خط سير (Polyline) ⬅️ تطبيق Flutter يفك تشفير الخط ويرسمه على الخريطة.
لحقول البوابات، المصدر، التغطية والجدولة راجع [توثيق أبواب ومداخل المنشآت](PLACE_GATES_COVERAGE_AR.md).
بذلك نكون قد غطينا البنية المعمارية الكاملة بأدق تفاصيلها من السيرفر وحتى واجهة التطبيق! 🚀
+15 -15
View File
@@ -21,8 +21,8 @@
4. **الاستعلامات النسبية (Relative Queries):**
فهم طبيعة الوصف في الشارع الأردني والعربي. عندما يبحث المستخدم عن `"قرب مستشفى التخصصي"`, يقوم النظام بفلترة كلمة "قرب"، ويبحث عن المستشفى، ويعيد إحداثياته الدقيقة تحت اسم "قرب مستشفى التخصصي".
5. **بوابات المجمعات (POI Gates Clustering):**
لا توجيه إلى مراكز المباني بعد اليوم! الأماكن الكبرى مثل المولات تعود بقائمة من البوابات الفعلية (مثال: البوابة الرئيسية، بوابة كارفور) لتوجيه السائق بدقة متناهية.
5. **مداخل المنشآت (POI Gates):**
قد تعود نتيجة المنشأة بمداخل موثقة من عقد OpenStreetMap الفعلية. لا ينشئ النظام بوابات افتراضية؛ وتكون `is_main_gate=true` فقط عندما يوسم المصدر المدخل صراحةً بـ `entrance=main`. راجع [توثيق التغطية والمصدر](PLACE_GATES_COVERAGE_AR.md).
6. **هل تقصد؟ (Safety Net & Spell Checker):**
محرك تعويض الأخطاء (Fallback). إذا أدخل المستخدم مصطلحاً مستحيلاً وأسفر عن 0 نتائج، يتدخل استعلام `KNN` لاقتراح أقرب مصطلح منطقي أو تصحيحه تلقائياً.
@@ -60,22 +60,22 @@
"distance_km": "3.50",
"full_address": "شارع الملك عبدالله، عمان",
"totalScore": 0.95,
"gates": [
{
"name_ar": "البوابة الرئيسية",
"name_en": "Main Gate",
"latitude": 31.9806,
"longitude": 35.8380,
"is_main_gate": true
}
]
"gates": []
}
],
"did_you_mean": null
}
```
### 2. الإكمال التلقائي (Autocomplete)
قد تحتوي النتيجة على `gates` إذا وجدت نقاط أبواب/مداخل موثقة ومطابقة للمنشأة؛ وقد تكون المصفوفة فارغة. لا يدل غيابها على عدم وجود مدخل في الواقع، بل على عدم توفر نقطة مصدر مطابقة.
### 2. مداخل منشأة محددة
**الرابط:** `GET /api/geocoding/places/{placeId}/gates`
يتطلب مفتاح API. يعيد المداخل المسجلة للمعلم، مع الإحداثيات ونوع المدخل ووسوم المصدر ومعرّف عقدة OSM ورابطها وإسناد `© OpenStreetMap contributors`.
### 3. الإكمال التلقائي (Autocomplete)
مخصص لتقديم اقتراحات سريعة جداً أثناء طباعة المستخدم في مربع البحث.
**الرابط:** `GET /api/geocoding/autocomplete`
@@ -107,8 +107,8 @@
عبارة عن `Materialized View` في PostgreSQL يجمع بيانات (الأردن، سوريا، مصر، و OpenStreetMap) في جدول واحد سريع جداً.
- **pg_trgm:**
تقنية في قاعدة البيانات تستخدم (Trigrams) لتحطيم الكلمات إلى مقاطع من 3 أحرف ومقارنتها بدقة رهيبة للتغلب على الأخطاء الإملائية.
- **تحديثات شعبية الأماكن (Future Sero Trips Sync):**
المحرك مجهز لاستقبال بيانات رحلات السائقين وتحديث نقاط `popularity_score` واستنتاج بوابات المجمعات تلقائياً.
- **مداخل المنشآت:**
تُحدّث من نقاط OSM الفعلية عند تشغيل مزامنة الخريطة كل عشرة أيام. تحفظ معرّفات المصدر والوسوم الأصلية، ولا تستنتج إحداثيات أو تصنيفات غير موجودة في المصدر. انظر [توثيق التغطية والتشغيل](PLACE_GATES_COVERAGE_AR.md).
---
**المستند محدث بتاريخ:** يوليو 2026
**المستند محدث بتاريخ:** سبتمبر 2026
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# ==============================================================================
# Sovereign Map Platform - Full Autonomous Backup System
# منصة الخرائط السيادية - نظام النسخ الاحتياطي الشامل والأتمتة لنقل السيرفر
# ==============================================================================
# Architect: Hamza Ayed - Founding Tech Architect & Sovereign Mobility Strategist
# Stack: PostGIS 15, Martin Tile Server, GraphHopper, Redis, NestJS, React
# ==============================================================================
set -euo pipefail
# ANSI Color Codes
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BACKUP_BASE_DIR="${1:-$ROOT_DIR/backups}"
BACKUP_DIR="$BACKUP_BASE_DIR/sovereign_backup_$TIMESTAMP"
ARCHIVE_NAME="sovereign_map_full_backup_$TIMESTAMP.tar.gz"
echo -e "${CYAN}==================================================================${NC}"
echo -e "${CYAN} 🛡️ منصة خرائط سيرو السيادية | إطلاق النسخ الاحتياطي الشامل${NC}"
echo -e "${CYAN}==================================================================${NC}"
echo -e "📁 مسار المشروع (Project Root): ${YELLOW}$ROOT_DIR${NC}"
echo -e "📦 مجلد النسخة الاحتياطية: ${YELLOW}$BACKUP_DIR${NC}"
echo -e "⏰ توقيت البدء: ${YELLOW}$(date)${NC}"
echo -e "------------------------------------------------------------------"
mkdir -p "$BACKUP_DIR"
mkdir -p "$BACKUP_DIR/database"
mkdir -p "$BACKUP_DIR/configs"
mkdir -p "$BACKUP_DIR/geodata"
mkdir -p "$BACKUP_DIR/styles"
# 1. PostgreSQL + PostGIS Full Database Dump
echo -e "\n${BLUE}[1/5] 🐘 جاري تصدير قاعدة بيانات PostGIS المكانية بالكامل...${NC}"
DB_CONTAINER="map-db"
if docker ps --format '{{.Names}}' | grep -q "^${DB_CONTAINER}$"; then
echo -e " ✅ تم العثور على الحاوية: ${GREEN}$DB_CONTAINER${NC}"
# Custom compressed dump (-Fc: Fast, compressed, supports selective restore)
echo -e " ⏳ جاري إنشاء Custom Dump المضغوط (موصى به للاستعادة)..."
docker exec "$DB_CONTAINER" pg_dump -U mapuser -d mapdb -Fc > "$BACKUP_DIR/database/mapdb_custom.dump"
# Plain text SQL schema & functions (for auditing and quick inspection)
echo -e " ⏳ جاري تصدير مخطط الجداول والدوال المكانية (Schema-only SQL)..."
docker exec "$DB_CONTAINER" pg_dump -U mapuser -d mapdb --schema-only > "$BACKUP_DIR/database/mapdb_schema.sql"
DUMP_SIZE=$(du -h "$BACKUP_DIR/database/mapdb_custom.dump" | cut -f1)
echo -e " ✅ تم تصدير قاعدة البيانات بنجاح! الحجم: ${GREEN}$DUMP_SIZE${NC}"
else
echo -e " ${YELLOW}⚠️ تحذير: حاوية $DB_CONTAINER ليست قيد التشغيل حالياً!${NC}"
echo -e " سيتم تخطي تفريغ الداتابيز المباشر. إذا كان لديك dump سابق سيتم تضمينه."
fi
# 2. Redis State Backup
echo -e "\n${BLUE}[2/5] ⚡ جاري حفظ حالة الذاكرة اللحظية والتخزين المؤقت (Redis)...${NC}"
REDIS_CONTAINER="map-redis"
if docker ps --format '{{.Names}}' | grep -q "^${REDIS_CONTAINER}$"; then
echo -e " ⏳ تفعيل BGSAVE في Redis..."
docker exec "$REDIS_CONTAINER" redis-cli bgsave || true
sleep 2
# Copy dump.rdb if accessible
docker cp "$REDIS_CONTAINER:/data/dump.rdb" "$BACKUP_DIR/database/redis_dump.rdb" 2>/dev/null || echo " ℹ️ لم يتوفر dump.rdb فوري (ذاكرة كاش خفيفة)."
echo -e " ✅ اكتملت إجراءات Redis."
fi
# 3. Critical Configuration Files
echo -e "\n${BLUE}[3/5] ⚙️ جاري نسخ ملفات التكوين والبيئة والهندسة المعمارية...${NC}"
cp -f "$ROOT_DIR/.env" "$BACKUP_DIR/configs/.env.backup" 2>/dev/null || echo " ⚠️ لم يُعثر على .env"
cp -f "$ROOT_DIR/docker-compose.yml" "$BACKUP_DIR/configs/" 2>/dev/null || true
cp -f "$ROOT_DIR/docker-compose.map2.yml" "$BACKUP_DIR/configs/" 2>/dev/null || true
cp -rf "$ROOT_DIR/infrastructure/docker" "$BACKUP_DIR/configs/" 2>/dev/null || true
# Styles (Vector & Raster styles)
cp -f "$ROOT_DIR"/style*.json "$BACKUP_DIR/styles/" 2>/dev/null || true
echo -e " ✅ تم حفظ ملفات التكوين وأنماط الخرائط (Styles)."
# 4. Sovereign Geodata, Offline Tiles & Routing Graphs
echo -e "\n${BLUE}[4/5] 🗺️ جاري أرشفة البيانات الجغرافية السيادية والأوفلاين...${NC}"
if [ -d "$ROOT_DIR/data/mbtiles" ]; then
echo -e " ⏳ جاري نسخ مربعات الارتفاع والصور الفضائية المحلية (MBTiles)..."
cp -rf "$ROOT_DIR/data/mbtiles" "$BACKUP_DIR/geodata/"
fi
if [ -f "$ROOT_DIR/infrastructure/osm-data/jordan-latest.osm.pbf" ]; then
echo -e " ⏳ جاري نسخ حزمة خريطة الأردن المفتوحة (OSM PBF)..."
cp -f "$ROOT_DIR/infrastructure/osm-data/jordan-latest.osm.pbf" "$BACKUP_DIR/geodata/"
fi
if [ -d "$ROOT_DIR/infrastructure/osm-data/routing-packages" ]; then
echo -e " ⏳ جاري نسخ حزم توجيه Valhalla المحسوبة محلياً..."
cp -rf "$ROOT_DIR/infrastructure/osm-data/routing-packages" "$BACKUP_DIR/geodata/"
fi
# Add restore script directly into the backup folder for self-contained recovery
cp -f "$ROOT_DIR/infrastructure/scripts/restore_sovereign_map.sh" "$BACKUP_DIR/restore_sovereign_map.sh" 2>/dev/null || true
chmod +x "$BACKUP_DIR/restore_sovereign_map.sh" 2>/dev/null || true
# 5. Manifest & Checksums
echo -e "\n${BLUE}[5/5] 🔒 جاري توليد بصمات التحقق والنزاهة الرقمية (SHA256)...${NC}"
cat <<EOF > "$BACKUP_DIR/MANIFEST.txt"
Sovereign Map SaaS Platform Backup Manifest
===========================================
Timestamp: $TIMESTAMP
Backup Date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
Hostname: $(hostname)
PostgreSQL Version: 15-3.3 (PostGIS)
Project Root: $ROOT_DIR
Architect: Hamza Ayed (Siro / Intaleq)
EOF
cd "$BACKUP_DIR"
find . -type f ! -name "checksums.sha256" -exec sha256sum {} + > "$BACKUP_DIR/checksums.sha256" 2>/dev/null || \
find . -type f ! -name "checksums.sha256" -exec shasum -a 256 {} + > "$BACKUP_DIR/checksums.sha256"
# Package into a unified compressed tarball
echo -e " 📦 ضغط الحزمة الكاملة في ملف أرشيف موحد..."
cd "$BACKUP_BASE_DIR"
tar -czf "$ARCHIVE_NAME" "sovereign_backup_$TIMESTAMP"
TOTAL_ARCHIVE_SIZE=$(du -h "$BACKUP_BASE_DIR/$ARCHIVE_NAME" | cut -f1)
echo -e "\n${GREEN}==================================================================${NC}"
echo -e "${GREEN} 🎉 تم إنجاز النسخة الاحتياطية بنجاح تام!${NC}"
echo -e "${GREEN}==================================================================${NC}"
echo -e "📁 مجلد النسخة المفكوكة: ${CYAN}$BACKUP_DIR${NC}"
echo -e "📦 ملف الأرشيف المضغوط: ${YELLOW}$BACKUP_BASE_DIR/$ARCHIVE_NAME${NC} (${GREEN}$TOTAL_ARCHIVE_SIZE${NC})"
echo -e "🔒 بصمات التحقق: ${CYAN}$BACKUP_DIR/checksums.sha256${NC}"
echo -e "------------------------------------------------------------------"
echo -e "${YELLOW}🚀 أمر النقل المباشر إلى السيرفر الجديد (One-Liner Transfer Command):${NC}"
echo -e "${CYAN}rsync -avzP -e \"ssh -i ~/.ssh/your-key\" \"$BACKUP_BASE_DIR/$ARCHIVE_NAME\" root@<TARGET_SERVER_IP>:/home/hamzadoctor/backups/${NC}"
echo -e "${GREEN}==================================================================${NC}"
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env bash
# ==============================================================================
# Sovereign Map Platform - Full Autonomous Restoration System
# منصة الخرائط السيادية - نظام الاستعادة الشامل وإعادة التشغيل على سيرفر جديد
# ==============================================================================
# Architect: Hamza Ayed - Founding Tech Architect & Sovereign Mobility Strategist
# ==============================================================================
set -euo pipefail
# ANSI Color Codes
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m'
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
echo -e "${CYAN}==================================================================${NC}"
echo -e "${CYAN} 🛡️ منصة خرائط سيرو السيادية | معالج استعادة النظام والسيرفر${NC}"
echo -e "${CYAN}==================================================================${NC}"
if [ $# -lt 1 ]; then
echo -e "${RED}❌ خطأ: يرجى تحديد مسار ملف النسخة الاحتياطية (.tar.gz) أو مجلد النسخة.${NC}"
echo -e "الاستخدام: $0 <path_to_backup.tar.gz | path_to_backup_dir>"
echo -e "مثال: $0 /home/hamzadoctor/backups/sovereign_map_full_backup_20260926_120000.tar.gz"
exit 1
fi
INPUT_PATH="$1"
TEMP_RESTORE_DIR=""
# Determine whether input is a tarball or a directory
if [ -f "$INPUT_PATH" ] && [[ "$INPUT_PATH" == *.tar.gz ]]; then
echo -e "📦 تم التعرف على ملف أرشيف مضغوط: ${YELLOW}$INPUT_PATH${NC}"
TEMP_RESTORE_DIR=$(mktemp -d /tmp/sovereign_restore_XXXXXX)
echo -e "⏳ جاري فك ضغط الأرشيف إلى: ${CYAN}$TEMP_RESTORE_DIR${NC}..."
tar -xzf "$INPUT_PATH" -C "$TEMP_RESTORE_DIR"
# Find the unpacked inner folder
RESTORE_SRC=$(find "$TEMP_RESTORE_DIR" -maxdepth 1 -mindepth 1 -type d | head -n 1)
if [ -z "$RESTORE_SRC" ]; then
RESTORE_SRC="$TEMP_RESTORE_DIR"
fi
elif [ -d "$INPUT_PATH" ]; then
echo -e "📁 تم التعرف على مجلد نسخة احتياطية: ${YELLOW}$INPUT_PATH${NC}"
RESTORE_SRC="$INPUT_PATH"
else
echo -e "${RED}❌ المسار المحدد غير صالح: $INPUT_PATH${NC}"
exit 1
fi
echo -e "🔍 فحص مكونات النسخة الاحتياطية في: ${GREEN}$RESTORE_SRC${NC}"
# Check for database dump
DUMP_FILE="$RESTORE_SRC/database/mapdb_custom.dump"
SQL_FILE="$RESTORE_SRC/database/mapdb_schema.sql"
if [ ! -f "$DUMP_FILE" ] && [ ! -f "$SQL_FILE" ]; then
echo -e "${RED}❌ لم يتم العثور على ملفات قاعدة البيانات (mapdb_custom.dump أو mapdb_schema.sql)${NC}"
exit 1
fi
# Step 1: Ensure Docker is active
echo -e "\n${BLUE}[1/6] 🐳 فحص بيئة Docker و Docker Compose...${NC}"
if ! command -v docker &> /dev/null; then
echo -e "${RED}❌ Docker غير مثبت على هذا السيرفر! يرجى تثبيته أولاً.${NC}"
exit 1
fi
DOCKER_COMPOSE_CMD=""
if docker compose version &> /dev/null; then
DOCKER_COMPOSE_CMD="docker compose"
elif command -v docker-compose &> /dev/null; then
DOCKER_COMPOSE_CMD="docker-compose"
else
echo -e "${RED}❌ docker compose غير متاح!${NC}"
exit 1
fi
echo -e " ✅ محرك الحاويات جاهز: ${GREEN}$($DOCKER_COMPOSE_CMD version --short)${NC}"
# Step 2: Check & prepare .env file
echo -e "\n${BLUE}[2/6] ⚙️ تهيئة متغيرات البيئة (.env)...${NC}"
cd "$ROOT_DIR"
if [ ! -f "$ROOT_DIR/.env" ] && [ -f "$RESTORE_SRC/configs/.env.backup" ]; then
echo -e " 📥 استعادة ملف .env من النسخة الاحتياطية..."
cp "$RESTORE_SRC/configs/.env.backup" "$ROOT_DIR/.env"
elif [ -f "$ROOT_DIR/.env" ]; then
echo -e " ✅ ملف .env موجود مسبقاً في مسار المشروع."
else
echo -e " ${YELLOW}⚠️ لم يوجد .env! يرجى إنشاء ملف .env بالاعتماد على .env.example${NC}"
fi
# Step 3: Restore offline MBTiles & OSM packages
echo -e "\n${BLUE}[3/6] 🗺️ استعادة المربعات السيادية وحزم التوجيه (Offline Datasets)...${NC}"
if [ -d "$RESTORE_SRC/geodata/mbtiles" ]; then
mkdir -p "$ROOT_DIR/data/mbtiles"
echo -e " 📥 استعادة ملفات الارتفاع والأقمار الصناعية (MBTiles)..."
cp -rf "$RESTORE_SRC/geodata/mbtiles/"* "$ROOT_DIR/data/mbtiles/" 2>/dev/null || true
fi
if [ -f "$RESTORE_SRC/geodata/jordan-latest.osm.pbf" ]; then
mkdir -p "$ROOT_DIR/infrastructure/osm-data"
echo -e " 📥 استعادة ملف خريطة الأردن الخام (OSM PBF)..."
cp -f "$RESTORE_SRC/geodata/jordan-latest.osm.pbf" "$ROOT_DIR/infrastructure/osm-data/"
fi
if [ -d "$RESTORE_SRC/geodata/routing-packages" ]; then
mkdir -p "$ROOT_DIR/infrastructure/osm-data/routing-packages"
echo -e " 📥 استعادة حزم التوجيه المحلية (Valhalla routing)..."
cp -rf "$RESTORE_SRC/geodata/routing-packages/"* "$ROOT_DIR/infrastructure/osm-data/routing-packages/" 2>/dev/null || true
fi
# Step 4: Boot Database & Redis Containers
echo -e "\n${BLUE}[4/6] 🐘 تشغيل حاويات الداتابيز والكاش والتأكد من جاهزيتها...${NC}"
$DOCKER_COMPOSE_CMD up -d db redis
echo -e " ⏳ في انتظار جاهزية PostgreSQL و PostGIS..."
for i in {1..30}; do
if docker exec map-db pg_isready -U mapuser -d mapdb &>/dev/null; then
echo -e " ✅ قاعدة البيانات PostGIS جاهزة للحقن!"
break
fi
sleep 2
if [ $i -eq 30 ]; then
echo -e "${RED}❌ فشل انتظار جاهزية قاعدة البيانات! تفقد سجلات docker logs map-db${NC}"
exit 1
fi
done
# Step 5: Inject PostGIS Database
echo -e "\n${BLUE}[5/6] 📥 استعادة وحقن البيانات المكانية في PostGIS...${NC}"
if [ -f "$DUMP_FILE" ]; then
echo -e " ⏳ جاري الاستعادة من ملف الـ Custom Dump المسرّع..."
# Ensure postgis extension exists
docker exec -i map-db psql -U mapuser -d mapdb -c "CREATE EXTENSION IF NOT EXISTS postgis;"
docker exec -i map-db psql -U mapuser -d mapdb -c "CREATE EXTENSION IF NOT EXISTS postgis_topology;"
# Restore dump (ignore non-fatal warnings for clean recreation)
docker exec -i map-db pg_restore -U mapuser -d mapdb --clean --if-exists --no-owner --no-acl < "$DUMP_FILE" || true
# Run VACUUM ANALYZE to optimize geometry indices
echo -e " ⚡ تحسين الفهارس المكانية (VACUUM ANALYZE)..."
docker exec -i map-db psql -U mapuser -d mapdb -c "VACUUM ANALYZE;"
echo -e " ✅ تم استعادة قاعدة البيانات وتحديث الفهارس بنجاح!"
fi
# Step 6: Start Full Application Stack & Healthcheck
echo -e "\n${BLUE}[6/6] 🚀 بناء وتشغيل كافة خدمات المنصة السيادية...${NC}"
$DOCKER_COMPOSE_CMD up -d --build api martin web dashboard routing
echo -e "\n⏳ فحص صحة الخدمات (Health Checks)..."
sleep 5
check_service() {
local name="$1"
local url="$2"
if curl -s -f -m 5 "$url" > /dev/null 2>&1; then
echo -e " ✅ خدمة ${GREEN}$name${NC} تعمل بنجاح: $url"
else
echo -e " ℹ️ خدمة ${YELLOW}$name${NC} في طور الإقلاع أو تتطلب ثوانٍ إضافية: $url"
fi
}
check_service "Backend API (NestJS)" "http://localhost:3200/api/health"
check_service "Martin Vector Tiles" "http://localhost:3202/health"
check_service "GraphHopper Routing" "http://localhost:8989/health"
check_service "Web Tactical Portal" "http://localhost:3201"
check_service "Developer Dashboard" "http://localhost:3204"
# Cleanup temp unpack dir if created
if [ -n "$TEMP_RESTORE_DIR" ] && [ -d "$TEMP_RESTORE_DIR" ]; then
rm -rf "$TEMP_RESTORE_DIR"
fi
echo -e "\n${GREEN}==================================================================${NC}"
echo -e "${GREEN} 🎉 اكتملت عملية الاستعادة بنجاح والمنصة تعمل بكامل طاقتها!${NC}"
echo -e "${GREEN}==================================================================${NC}"
echo -e "🌐 البوابة التكتيكية والمناورة: http://localhost:3201/#maneuver"
echo -e "📡 خادم الخرائط السيادي (Martin): http://localhost:3202"
echo -e "⚡ محرك التوجيه (GraphHopper): http://localhost:8989"
echo -e "💼 لوحة المطورين والإدارة: http://localhost:3204"
echo -e "------------------------------------------------------------------"
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
Sovereign 3D DEM Tile Seeder for Jordan (سيرفر سيادي لتخزين تضاريس الأردن ثلاثية الأبعاد)
Downloads Terrarium 3D DEM elevation tiles for the Jordan National Bounding Box
and packages them into a sovereign MBTiles SQLite database and/or local tile directory.
Bounding Box (Jordan):
West: 34.8°, South: 29.1°, East: 39.3°, North: 33.4°
Zoom Levels: 0 to 14 (overscaled to z20 by MapLibre GPU)
"""
import os
import sys
import math
import sqlite3
import argparse
import time
import urllib.request
import urllib.error
from concurrent.futures import ThreadPoolExecutor, as_completed
# National Jordan Geographic Envelope
JORDAN_BBOX = {
'min_lon': 34.8,
'min_lat': 29.1,
'max_lon': 39.3,
'max_lat': 33.4
}
DEM_URL_TEMPLATE = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
def deg2num(lat_deg, lon_deg, zoom):
lat_rad = math.radians(lat_deg)
n = 2.0 ** zoom
xtile = int((lon_deg + 180.0) / 360.0 * n)
ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
return (xtile, ytile)
def init_mbtiles(db_path, min_zoom, max_zoom):
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("PRAGMA synchronous = NORMAL")
cur.execute("PRAGMA journal_mode = WAL")
cur.execute("""
CREATE TABLE IF NOT EXISTS metadata (
name TEXT PRIMARY KEY,
value TEXT
);
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS tiles (
zoom_level INTEGER,
tile_column INTEGER,
tile_row INTEGER,
tile_data BLOB,
PRIMARY KEY (zoom_level, tile_column, tile_row)
);
""")
meta = [
('name', 'Jordan Sovereign 3D Elevation DEM (Terrarium)'),
('type', 'baselayer'),
('version', '1.0'),
('description', 'High-accuracy Sovereign 3D DEM Terrarium Tiles for the Hashemite Kingdom of Jordan'),
('format', 'png'),
('bounds', f"{JORDAN_BBOX['min_lon']},{JORDAN_BBOX['min_lat']},{JORDAN_BBOX['max_lon']},{JORDAN_BBOX['max_lat']}"),
('minzoom', str(min_zoom)),
('maxzoom', str(max_zoom)),
('attribution', '© Intaleq Sovereign Spatial Engine | Mapzen Terrarium')
]
for k, v in meta:
cur.execute("INSERT OR REPLACE INTO metadata (name, value) VALUES (?, ?)", (k, v))
conn.commit()
return conn
def download_tile(z, x, y, retries=3):
url = DEM_URL_TEMPLATE.format(z=z, x=x, y=y)
req = urllib.request.Request(
url,
headers={'User-Agent': 'Intaleq-Sovereign-Map-Seeder/1.0'}
)
for attempt in range(retries):
try:
with urllib.request.urlopen(req, timeout=10) as response:
if response.status == 200:
return (z, x, y, response.read(), None)
except Exception as e:
if attempt == retries - 1:
return (z, x, y, None, str(e))
time.sleep(0.5 * (attempt + 1))
return (z, x, y, None, "Timeout")
def main():
parser = argparse.ArgumentParser(description="Jordan Sovereign 3D DEM Tile Seeder")
parser.add_argument("--min-zoom", type=int, default=0, help="Minimum zoom level (default: 0)")
parser.add_argument("--max-zoom", type=int, default=14, help="Maximum zoom level (default: 14)")
parser.add_argument("--output-mbtiles", type=str, default="data/jordan-dem.mbtiles", help="Output MBTiles path")
parser.add_argument("--output-dir", type=str, default="", help="Optional output directory for raw {z}/{x}/{y}.png files")
parser.add_argument("--workers", type=int, default=16, help="Concurrent worker threads (default: 16)")
args = parser.parse_args()
os.makedirs(os.path.dirname(os.path.abspath(args.output_mbtiles)), exist_ok=True)
if args.output_dir:
os.makedirs(args.output_dir, exist_ok=True)
print(f"🚀 Initializing Sovereign 3D DEM Tile Seeder for Jordan...")
print(f"📍 Geographic Bounding Box: {JORDAN_BBOX}")
print(f"🔍 Zoom range: {args.min_zoom} -> {args.max_zoom}")
print(f"💾 Target MBTiles: {args.output_mbtiles}")
conn = init_mbtiles(args.output_mbtiles, args.min_zoom, args.max_zoom)
cursor = conn.cursor()
# Pre-calculate tile list
tiles_to_download = []
for z in range(args.min_zoom, args.max_zoom + 1):
x1, y2 = deg2num(JORDAN_BBOX['min_lat'], JORDAN_BBOX['min_lon'], z)
x2, y1 = deg2num(JORDAN_BBOX['max_lat'], JORDAN_BBOX['max_lon'], z)
min_x, max_x = min(x1, x2), max(x1, x2)
min_y, max_y = min(y1, y2), max(y1, y2)
for x in range(min_x, max_x + 1):
for y in range(min_y, max_y + 1):
# Check if tile already exists in MBTiles
# Note: MBTiles uses TMS y-coordinates: tms_y = (2^z - 1) - y
tms_y = (1 << z) - 1 - y
cursor.execute("SELECT 1 FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?", (z, x, tms_y))
if not cursor.fetchone():
tiles_to_download.append((z, x, y))
total = len(tiles_to_download)
print(f"📦 Total new tiles to fetch: {total:,}")
if total == 0:
print("✅ All tiles are already cached and present in the sovereign database!")
conn.close()
return
downloaded = 0
errors = 0
batch = []
start_time = time.time()
with ThreadPoolExecutor(max_workers=args.workers) as executor:
futures = {executor.submit(download_tile, z, x, y): (z, x, y) for z, x, y in tiles_to_download}
for future in as_completed(futures):
z, x, y, data, err = future.result()
if data:
tms_y = (1 << z) - 1 - y
batch.append((z, x, tms_y, data))
downloaded += 1
# If raw dir specified, also save file
if args.output_dir:
tile_file_dir = os.path.join(args.output_dir, str(z), str(x))
os.makedirs(tile_file_dir, exist_ok=True)
with open(os.path.join(tile_file_dir, f"{y}.png"), "wb") as f:
f.write(data)
else:
errors += 1
if len(batch) >= 200:
cursor.executemany("INSERT OR REPLACE INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)", batch)
conn.commit()
batch.clear()
elapsed = time.time() - start_time
speed = downloaded / elapsed if elapsed > 0 else 0
pct = (downloaded + errors) / total * 100
print(f"⏳ Progress: {downloaded:,}/{total:,} ({pct:.1f}%) | Speed: {speed:.1f} tiles/s | Errors: {errors}")
if batch:
cursor.executemany("INSERT OR REPLACE INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)", batch)
conn.commit()
conn.close()
elapsed = time.time() - start_time
print(f"\n🎉 Finished! Downloaded: {downloaded:,} tiles in {elapsed:.1f}s. Errors: {errors}.")
print(f"🛡️ Sovereign MBTiles stored at: {args.output_mbtiles}")
if __name__ == "__main__":
main()
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
Sovereign Satellite Imagery Tile Seeder for Jordan (سيرفر سيادي لتخزين صور الأقمار الصناعية للأردن)
Downloads high-resolution photographic satellite imagery tiles for Jordan's National Envelope
and stores them into an offline sovereign MBTiles SQLite database.
Bounding Box (Jordan):
West: 34.8°, South: 29.1°, East: 39.3°, North: 33.4°
Zoom Levels: 0 to 15 (Strategic & Tactical military coverage)
"""
import os
import sys
import math
import sqlite3
import argparse
import time
import urllib.request
import urllib.error
from concurrent.futures import ThreadPoolExecutor, as_completed
JORDAN_BBOX = {
'min_lon': 34.8,
'min_lat': 29.1,
'max_lon': 39.3,
'max_lat': 33.4
}
# Arcgis World Imagery Tile Template
SATELLITE_URL_TEMPLATE = "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
def deg2num(lat_deg, lon_deg, zoom):
lat_rad = math.radians(lat_deg)
n = 2.0 ** zoom
xtile = int((lon_deg + 180.0) / 360.0 * n)
ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
return (xtile, ytile)
def init_mbtiles(db_path, min_zoom, max_zoom):
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("PRAGMA synchronous = NORMAL")
cur.execute("PRAGMA journal_mode = WAL")
cur.execute("""
CREATE TABLE IF NOT EXISTS metadata (
name TEXT PRIMARY KEY,
value TEXT
);
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS tiles (
zoom_level INTEGER,
tile_column INTEGER,
tile_row INTEGER,
tile_data BLOB,
PRIMARY KEY (zoom_level, tile_column, tile_row)
);
""")
meta = [
('name', 'Jordan Sovereign Satellite Imagery (الأقمار الصناعية السيادية للأردن)'),
('type', 'baselayer'),
('version', '1.0'),
('description', 'High-resolution Sovereign Photographic Satellite Imagery for the Hashemite Kingdom of Jordan'),
('format', 'jpg'),
('bounds', f"{JORDAN_BBOX['min_lon']},{JORDAN_BBOX['min_lat']},{JORDAN_BBOX['max_lon']},{JORDAN_BBOX['max_lat']}"),
('minzoom', str(min_zoom)),
('maxzoom', str(max_zoom)),
('attribution', '© Intaleq Sovereign Earth Observation Engine')
]
for k, v in meta:
cur.execute("INSERT OR REPLACE INTO metadata (name, value) VALUES (?, ?)", (k, v))
conn.commit()
return conn
def download_tile(z, x, y, retries=3):
url = SATELLITE_URL_TEMPLATE.format(z=z, x=x, y=y)
req = urllib.request.Request(
url,
headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) IntaleqSovereignEngine/1.0',
'Referer': 'https://map-saas.intaleqapp.com/'
}
)
for attempt in range(retries):
try:
with urllib.request.urlopen(req, timeout=12) as response:
if response.status == 200:
data = response.read()
return (z, x, y, data, None)
except Exception as e:
if attempt == retries - 1:
return (z, x, y, None, str(e))
time.sleep(0.5 * (attempt + 1))
return (z, x, y, None, "Timeout")
def main():
parser = argparse.ArgumentParser(description="Jordan Sovereign Satellite Imagery Tile Seeder")
parser.add_argument("--min-zoom", type=int, default=0, help="Minimum zoom level (default: 0)")
parser.add_argument("--max-zoom", type=int, default=14, help="Maximum zoom level (default: 14)")
parser.add_argument("--output-mbtiles", type=str, default="data/jordan-satellite.mbtiles", help="Output MBTiles path")
parser.add_argument("--output-dir", type=str, default="", help="Optional output directory for raw {z}/{x}/{y}.jpg files")
parser.add_argument("--workers", type=int, default=16, help="Concurrent worker threads (default: 16)")
args = parser.parse_args()
os.makedirs(os.path.dirname(os.path.abspath(args.output_mbtiles)), exist_ok=True)
if args.output_dir:
os.makedirs(args.output_dir, exist_ok=True)
print(f"🛰️ Initializing Sovereign Satellite Imagery Seeder for Jordan...")
print(f"📍 Geographic Bounding Box: {JORDAN_BBOX}")
print(f"🔍 Zoom range: {args.min_zoom} -> {args.max_zoom}")
print(f"💾 Target MBTiles: {args.output_mbtiles}")
conn = init_mbtiles(args.output_mbtiles, args.min_zoom, args.max_zoom)
cursor = conn.cursor()
tiles_to_download = []
for z in range(args.min_zoom, args.max_zoom + 1):
x1, y2 = deg2num(JORDAN_BBOX['min_lat'], JORDAN_BBOX['min_lon'], z)
x2, y1 = deg2num(JORDAN_BBOX['max_lat'], JORDAN_BBOX['max_lon'], z)
min_x, max_x = min(x1, x2), max(x1, x2)
min_y, max_y = min(y1, y2), max(y1, y2)
for x in range(min_x, max_x + 1):
for y in range(min_y, max_y + 1):
tms_y = (1 << z) - 1 - y
cursor.execute("SELECT 1 FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?", (z, x, tms_y))
if not cursor.fetchone():
tiles_to_download.append((z, x, y))
total = len(tiles_to_download)
print(f"📦 Total new satellite tiles to fetch: {total:,}")
if total == 0:
print("✅ All satellite tiles are already cached and present in the sovereign database!")
conn.close()
return
downloaded = 0
errors = 0
batch = []
start_time = time.time()
with ThreadPoolExecutor(max_workers=args.workers) as executor:
futures = {executor.submit(download_tile, z, x, y): (z, x, y) for z, x, y in tiles_to_download}
for future in as_completed(futures):
z, x, y, data, err = future.result()
if data:
tms_y = (1 << z) - 1 - y
batch.append((z, x, tms_y, data))
downloaded += 1
if args.output_dir:
tile_file_dir = os.path.join(args.output_dir, str(z), str(x))
os.makedirs(tile_file_dir, exist_ok=True)
with open(os.path.join(tile_file_dir, f"{y}.jpg"), "wb") as f:
f.write(data)
else:
errors += 1
if len(batch) >= 200:
cursor.executemany("INSERT OR REPLACE INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)", batch)
conn.commit()
batch.clear()
elapsed = time.time() - start_time
speed = downloaded / elapsed if elapsed > 0 else 0
pct = (downloaded + errors) / total * 100
print(f"⏳ Progress: {downloaded:,}/{total:,} ({pct:.1f}%) | Speed: {speed:.1f} tiles/s | Errors: {errors}")
if batch:
cursor.executemany("INSERT OR REPLACE INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)", batch)
conn.commit()
conn.close()
elapsed = time.time() - start_time
print(f"\n🎉 Finished! Downloaded: {downloaded:,} satellite tiles in {elapsed:.1f}s. Errors: {errors}.")
print(f"🛡️ Sovereign Satellite MBTiles stored at: {args.output_mbtiles}")
if __name__ == "__main__":
main()
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# وظيفة الملف: أداة تشغيل أو تجهيز باسم sync regional place names؛ تفاصيل خطواتها ومدخلاتها موضحة في دليل ملفات المشروع.
# Refresh Overture places and a country-tagged OSM/Overture name layer.
# Usage: bash infrastructure/scripts/sync_regional_place_names.sh [JO SY IQ EG SA AE OM QA KW BH YE ...]
set -euo pipefail
APP_DIR="${APP_DIR:-/home/hamzadoctor/app}"
DATA_DIR="${APP_DIR}/infrastructure/osm-data"
cd "$APP_DIR"
declare -A OSM_RELATIONS=(
[EG]=-1473947 [SA]=-307584 [AE]=-307763 [OM]=-305138
[QA]=-305095 [KW]=-305099 [BH]=-378734 [YE]=-305092
[JO]=-184818 [SY]=-184840 [IQ]=-304934
)
TARGETS=("$@")
if (( $# == 0 )); then TARGETS=(JO SY IQ EG SA AE OM QA KW BH YE); fi
for code in "${TARGETS[@]}"; do
[[ -n "${OSM_RELATIONS[$code]:-}" ]] || { echo "Unknown country: $code" >&2; exit 2; }
done
command -v ogr2ogr >/dev/null || { echo "ogr2ogr is required" >&2; exit 2; }
test -x venv_overture/bin/overturemaps || { echo "Overture CLI is required" >&2; exit 2; }
psql_db() { docker compose exec -T db psql -v ON_ERROR_STOP=1 -U mapuser -d mapdb "$@"; }
psql_db <<'SQL'
CREATE TABLE IF NOT EXISTS regional_place_names (
country_code text NOT NULL,
source text NOT NULL,
source_id text NOT NULL,
name text NOT NULL,
location geometry(Point,4326) NOT NULL,
PRIMARY KEY (country_code, source, source_id)
);
CREATE INDEX IF NOT EXISTS regional_place_names_location_idx
ON regional_place_names USING gist (location);
CREATE INDEX IF NOT EXISTS regional_place_names_country_idx
ON regional_place_names (country_code);
CREATE INDEX IF NOT EXISTS overture_place_id_idx ON overture_place (id);
SQL
for code in "${TARGETS[@]}"; do
relation="${OSM_RELATIONS[$code]}"
echo "[$code] determining country bounds"
bbox="$(psql_db -Atc "SELECT concat_ws(',',ST_XMin(e),ST_YMin(e),ST_XMax(e),ST_YMax(e)) FROM (SELECT ST_Extent(ST_Transform(way,4326)) AS e FROM planet_osm_polygon WHERE osm_id=$relation AND boundary='administrative' AND admin_level='2') q")"
[[ -n "$bbox" ]] || { echo "Missing country boundary: $code" >&2; exit 1; }
output="$DATA_DIR/overture_places_${code}.geojson"
echo "[$code] downloading Overture places for $bbox"
if [[ ! -s "$output" ]]; then
venv_overture/bin/overturemaps download --bbox="$bbox" -f geojson --type=place -o "$output"
fi
echo "[$code] loading staging table"
db_password="$(docker compose exec -T db printenv POSTGRES_PASSWORD)"
PGPASSWORD="$db_password" ogr2ogr -f PostgreSQL \
"PG:host=127.0.0.1 port=5432 user=mapuser dbname=mapdb" \
"$output" -nln overture_place_stage -overwrite -nlt PROMOTE_TO_MULTI \
-lco GEOMETRY_NAME=location
echo "[$code] merging named Overture places and refreshing map names"
psql_db -v code="$code" -v relation="$relation" <<'SQL'
BEGIN;
WITH border AS (
SELECT ST_Union(ST_Transform(way,4326)) geom FROM planet_osm_polygon
WHERE osm_id = :'relation'::bigint AND boundary='administrative' AND admin_level='2'
)
INSERT INTO overture_place
(id,categories,confidence,websites,emails,socials,phones,brand,addresses,names,sources,
operating_status,basic_category,taxonomy,version,location,name_primary)
SELECT s.id,NULL,s.confidence,s.websites,s.emails,s.socials,s.phones,s.brand,s.addresses,
s.names,s.sources,s.operating_status,s.basic_category,s.taxonomy,s.version,s.location,
COALESCE(s.names->>'primary',s.names->>'common')
FROM overture_place_stage s CROSS JOIN border b
WHERE s.id IS NOT NULL AND s.location && b.geom AND ST_Intersects(s.location,b.geom)
AND COALESCE(s.names->>'primary',s.names->>'common') IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM overture_place p WHERE p.id=s.id);
DELETE FROM regional_place_names WHERE country_code=:'code';
WITH border AS (
SELECT ST_Union(way) geom FROM planet_osm_polygon
WHERE osm_id = :'relation'::bigint AND boundary='administrative' AND admin_level='2'
)
INSERT INTO regional_place_names(country_code,source,source_id,name,location)
SELECT :'code','osm_point',p.osm_id::text,p.name,ST_Transform(p.way,4326)
FROM planet_osm_point p CROSS JOIN border b
WHERE p.name IS NOT NULL AND p.name<>'' AND p.way && b.geom AND ST_Intersects(p.way,b.geom)
ON CONFLICT DO NOTHING;
WITH border AS (
SELECT ST_Union(way) geom FROM planet_osm_polygon
WHERE osm_id = :'relation'::bigint AND boundary='administrative' AND admin_level='2'
)
INSERT INTO regional_place_names(country_code,source,source_id,name,location)
SELECT :'code','osm_polygon',p.osm_id::text,p.name,ST_Transform(ST_PointOnSurface(p.way),4326)
FROM planet_osm_polygon p CROSS JOIN border b
WHERE p.name IS NOT NULL AND p.name<>'' AND p.way && b.geom
AND ST_Intersects(ST_PointOnSurface(p.way),b.geom)
ON CONFLICT DO NOTHING;
WITH border AS (
SELECT ST_Union(ST_Transform(way,4326)) geom FROM planet_osm_polygon
WHERE osm_id = :'relation'::bigint AND boundary='administrative' AND admin_level='2'
)
INSERT INTO regional_place_names(country_code,source,source_id,name,location)
SELECT :'code','overture',p.id,COALESCE(p.name_primary,p.names->>'primary',p.names->>'common'),
ST_PointOnSurface(p.location)
FROM overture_place p CROSS JOIN border b
WHERE p.id IS NOT NULL AND p.location && b.geom AND ST_Intersects(p.location,b.geom)
AND COALESCE(p.name_primary,p.names->>'primary',p.names->>'common') IS NOT NULL
ON CONFLICT DO NOTHING;
COMMIT;
DROP TABLE overture_place_stage;
SQL
if [[ "$code" == IQ ]]; then
psql_db <<'SQL'
INSERT INTO regional_place_names(country_code,source,source_id,name,location)
SELECT 'IQ','custom_iraq',id::text,COALESCE(NULLIF(name_ar,''),NULLIF(name,'')),location
FROM places_iraq
WHERE source='iraq_final_complete' AND location IS NOT NULL
AND COALESCE(NULLIF(name_ar,''),NULLIF(name,'')) IS NOT NULL
ON CONFLICT DO NOTHING;
SQL
fi
rm -f "$output"
psql_db -Atc "SELECT '$code'||'|'||source||'|'||count(*) FROM regional_place_names WHERE country_code='$code' GROUP BY source ORDER BY source"
done
docker compose exec -T redis redis-cli FLUSHDB >/dev/null
+94 -49
View File
@@ -1,4 +1,5 @@
#!/bin/bash
# وظيفة الملف: أداة تشغيل أو تجهيز باسم update data؛ تفاصيل خطواتها ومدخلاتها موضحة في دليل ملفات المشروع.
# --------------------------------------------------------------------------
# Intaleq Map Platform - 10-Day Update Script
# سكربت تحديث خرائط انطلاقة - التحديث الدوري (كل 10 أيام)
@@ -11,62 +12,83 @@
set -e # Exit on error
echo "🚀 Starting 10-day map update (v3 — Jordan + Syria + Egypt + Iraq + Delta)..."
APP_DIR="/home/hamzadoctor/app"
DATA_DIR="${APP_DIR}/infrastructure/osm-data"
DATA_SCOPE_FILE="${DATA_DIR}/map-data-scope"
# An explicitly supplied scope takes priority. After a successful regional
# expansion, persist it so the scheduled 10-day job cannot drop those regions.
MAP_DATA_SCOPE="${MAP_DATA_SCOPE:-$(cat "${DATA_SCOPE_FILE}" 2>/dev/null || printf 'core')}"
case "${MAP_DATA_SCOPE}" in
core|gcc|gcc-yemen) ;;
*)
echo "❌ MAP_DATA_SCOPE must be one of: core, gcc, gcc-yemen (got: ${MAP_DATA_SCOPE})"
exit 2
;;
esac
echo "🚀 Starting map update (scope: ${MAP_DATA_SCOPE})..."
# ✅ FIX: Correct filename that GraphHopper actually reads (was: region.osm.pbf)
MASTER_FILE="${DATA_DIR}/master_map.osm.pbf"
DELTA_FILE="${DATA_DIR}/delta.osm"
cd "${APP_DIR}"
# ── Step 1: Download all four country PBFs ─────────────────────────────────
echo "🌍 Downloading Jordan, Syria, Egypt & Iraq PBF data from Geofabrik..."
if ! command -v osmium >/dev/null 2>&1; then
echo "❌ osmium-tool is required to merge routing extracts. Install it with: sudo apt-get install -y osmium-tool"
exit 3
fi
wget -q --show-progress -O "${DATA_DIR}/jordan-latest.osm.pbf.new" \
# ── Step 1: Select and download the country PBFs ───────────────────────────
# GCC is Geofabrik's combined Saudi Arabia, UAE, Qatar, Oman, Kuwait and Bahrain
# extract. Keeping the stage explicit prevents a scheduled update from silently
# enlarging the production routing graph.
COUNTRIES=(jordan syria egypt iraq)
PBF_FILES=(
"jordan-latest.osm.pbf"
"syria-latest.osm.pbf"
"egypt-latest.osm.pbf"
"iraq-latest.osm.pbf"
)
PBF_URLS=(
"https://download.geofabrik.de/asia/jordan-latest.osm.pbf"
# ✅ FIX: كان يحمّل مصر إلى ملف سوريا ثم يعيد تحميل سوريا فوقها — هدر 177MB كل دورة
wget -q --show-progress -O "${DATA_DIR}/syria-latest.osm.pbf.new" \
"https://download.geofabrik.de/asia/syria-latest.osm.pbf"
wget -q --show-progress -O "${DATA_DIR}/egypt-latest.osm.pbf.new" \
"https://download.geofabrik.de/africa/egypt-latest.osm.pbf"
wget -q --show-progress -O "${DATA_DIR}/iraq-latest.osm.pbf.new" \
"https://download.geofabrik.de/asia/iraq-latest.osm.pbf"
)
mv "${DATA_DIR}/jordan-latest.osm.pbf.new" "${DATA_DIR}/jordan-latest.osm.pbf"
mv "${DATA_DIR}/syria-latest.osm.pbf.new" "${DATA_DIR}/syria-latest.osm.pbf"
mv "${DATA_DIR}/egypt-latest.osm.pbf.new" "${DATA_DIR}/egypt-latest.osm.pbf"
mv "${DATA_DIR}/iraq-latest.osm.pbf.new" "${DATA_DIR}/iraq-latest.osm.pbf"
if [[ "${MAP_DATA_SCOPE}" == "gcc" || "${MAP_DATA_SCOPE}" == "gcc-yemen" ]]; then
COUNTRIES+=(gcc-states)
PBF_FILES+=(gcc-states-latest.osm.pbf)
PBF_URLS+=("https://download.geofabrik.de/asia/gcc-states-latest.osm.pbf")
fi
if [[ "${MAP_DATA_SCOPE}" == "gcc-yemen" ]]; then
COUNTRIES+=(yemen)
PBF_FILES+=(yemen-latest.osm.pbf)
PBF_URLS+=("https://download.geofabrik.de/asia/yemen-latest.osm.pbf")
fi
echo "🌍 Downloading ${COUNTRIES[*]} PBF data from Geofabrik..."
for i in "${!PBF_FILES[@]}"; do
wget -q --show-progress -O "${DATA_DIR}/${PBF_FILES[$i]}.new" "${PBF_URLS[$i]}"
mv "${DATA_DIR}/${PBF_FILES[$i]}.new" "${DATA_DIR}/${PBF_FILES[$i]}"
done
echo "✅ Downloads complete."
# ── Step 2: Import all four countries into PostGIS ─────────────────────────
echo "💾 Importing Jordan into PostGIS (--create resets planet_osm_* tables)..."
docker compose --profile import run --rm osm-import \
osm2pgsql --create --slim --cache 1000 \
--database mapdb --host db --user mapuser \
/data/jordan-latest.osm.pbf
echo "💾 Importing Syria into PostGIS (--append)..."
docker compose --profile import run --rm osm-import \
osm2pgsql --append --slim --cache 1000 \
--database mapdb --host db --user mapuser \
/data/syria-latest.osm.pbf
# ✅ FIX: Egypt was missing from the update cycle — added here
echo "💾 Importing Egypt into PostGIS (--append)..."
docker compose --profile import run --rm osm-import \
osm2pgsql --append --slim --cache 1000 \
--database mapdb --host db --user mapuser \
/data/egypt-latest.osm.pbf
echo "💾 Importing Iraq into PostGIS (--append)..."
docker compose --profile import run --rm osm-import \
osm2pgsql --append --slim --cache 1000 \
--database mapdb --host db --user mapuser \
/data/iraq-latest.osm.pbf
# ── Step 2: Import into PostGIS ────────────────────────────────────────────
# The first extract replaces planet_osm_*; every remaining extract appends.
for i in "${!PBF_FILES[@]}"; do
IMPORT_MODE="--append"
[[ "$i" == "0" ]] && IMPORT_MODE="--create"
echo "💾 Importing ${COUNTRIES[$i]} into PostGIS (${IMPORT_MODE})..."
docker compose --profile import run --rm osm-import \
osm2pgsql "${IMPORT_MODE}" --slim --cache 1000 \
--database mapdb --host db --user mapuser \
"/data/${PBF_FILES[$i]}"
done
# ── Step 2b: Keep routing topology tables ─────────────────────────────────
# Approved roads must share real OSM node IDs with the base graph. Deleting
@@ -75,15 +97,19 @@ echo "🧭 Preserving OSM node/way topology for approved-road routing..."
docker compose exec -T db psql -U mapuser -d mapdb -c \
"DROP TABLE IF EXISTS planet_osm_rels;"
# ── Step 3: Merge all four PBFs into master_map.osm.pbf ────────────────────
# OSM 624165122 is a misclassified 5.94 km² water polygon covering central Zarqa.
# Remove it after every full OSM import so it cannot reappear in the rendered map.
docker compose exec -T db psql -U mapuser -d mapdb -c \
"DELETE FROM planet_osm_polygon WHERE osm_id = 624165122;"
# ── Step 3: Merge all selected PBFs into master_map.osm.pbf ───────────────
# ✅ FIX: Old script wrote to region.osm.pbf — GraphHopper reads master_map.osm.pbf
echo "🗺️ Merging Jordan + Syria + Egypt + Iraq → ${MASTER_FILE}..."
osmium merge \
"${DATA_DIR}/jordan-latest.osm.pbf" \
"${DATA_DIR}/syria-latest.osm.pbf" \
"${DATA_DIR}/egypt-latest.osm.pbf" \
"${DATA_DIR}/iraq-latest.osm.pbf" \
-o "${MASTER_FILE}" --overwrite
echo "🗺️ Merging ${COUNTRIES[*]} → ${MASTER_FILE}..."
MERGE_INPUTS=()
for pbf_file in "${PBF_FILES[@]}"; do
MERGE_INPUTS+=("${DATA_DIR}/${pbf_file}")
done
osmium merge "${MERGE_INPUTS[@]}" -o "${MASTER_FILE}" --overwrite
echo "✅ Master PBF built: $(du -sh ${MASTER_FILE} | cut -f1)"
@@ -117,7 +143,9 @@ docker compose stop api web dashboard martin || true
trap 'echo "▶️ إعادة تشغيل الحاويات المؤقتة..."; docker compose up -d api web dashboard martin || true' EXIT
docker compose stop routing
rm -rf "${DATA_DIR}/graph-cache" "${DATA_DIR}/default-gh"
# GraphHopper creates its graph files as root inside the container, so the
# host user needs sudo to reliably remove a stale graph before rebuilding.
sudo rm -rf "${DATA_DIR}/graph-cache" "${DATA_DIR}/default-gh"
docker compose up -d routing
echo "✅ GraphHopper restarting from ${MASTER_FILE}."
@@ -149,6 +177,23 @@ fi
echo "🧹 Flushing Redis traffic cache..."
docker compose exec -T redis redis-cli flushall
# Keep named OSM and Overture places in step with the 10-day road refresh.
# This includes Egypt, GCC countries and Yemen; the importer uses country
# borders and preserves the existing curated place tables.
bash "${APP_DIR}/infrastructure/scripts/sync_regional_place_names.sh"
docker compose exec -T db psql -v ON_ERROR_STOP=1 -U mapuser -d mapdb \
< "${APP_DIR}/infrastructure/sql/10_regional_place_search.sql"
# Import only explicit OSM entrance/gate nodes from the same 11-country PBF.
# No synthetic offsets are created; a node is attached only when its coordinate
# is covered by exactly one named target facility polygon.
python3 -u "${APP_DIR}/scripts/sync_osm_place_gates.py" \
--pbf "${DATA_DIR}/master_map.osm.pbf"
docker compose exec -T redis redis-cli flushdb
# Persist only after every import, graph rebuild and verification has succeeded.
printf '%s\n' "${MAP_DATA_SCOPE}" > "${DATA_SCOPE_FILE}"
echo ""
echo "✅ 10-day update complete — Jordan + Syria + Egypt + Iraq + approved delta applied."
echo "✅ 10-day update complete — ${COUNTRIES[*]}, approved road delta, regional names, and OSM gates synchronized."
echo " GraphHopper is rebuilding in the background. Allow 5-10 min for routing to be ready."
@@ -0,0 +1,28 @@
-- وظيفة الملف: استعلام أو تغيير قاعدة بيانات يخص 05 poi gates clustering؛ راجع ترتيب التنفيذ قبل تطبيقه.
-- 1. Create the place_gates table
CREATE TABLE IF NOT EXISTS place_gates (
id SERIAL PRIMARY KEY,
place_id VARCHAR(255) NOT NULL, -- references unified_search_index.id
gate_name_ar VARCHAR(255) NOT NULL,
gate_name_en VARCHAR(255),
latitude NUMERIC(10, 7) NOT NULL,
longitude NUMERIC(10, 7) NOT NULL,
is_main_gate BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_place_gates_place_id ON place_gates(place_id);
-- No gate seed rows belong here: service doors must come from an explicit,
-- source-backed entrance node. See scripts/sync_osm_place_gates.py.
-- Prepare schema for future K-Means Clustering (Sero Trip Drop-offs)
CREATE TABLE IF NOT EXISTS sero_dropoffs (
id SERIAL PRIMARY KEY,
place_id VARCHAR(255) NOT NULL,
latitude NUMERIC(10, 7) NOT NULL,
longitude NUMERIC(10, 7) NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_sero_dropoffs_place_id ON sero_dropoffs(place_id);
@@ -0,0 +1,8 @@
-- وظيفة الملف: استعلام أو تغيير قاعدة بيانات يخص 10 regional place search؛ راجع ترتيب التنفيذ قبل تطبيقه.
-- Run outside a transaction so these indexes do not block map reads/writes.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY IF NOT EXISTS regional_place_names_normalized_trgm_idx
ON regional_place_names USING gin (normalize_arabic(name) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS regional_place_names_country_prefix_idx
ON regional_place_names (country_code, (normalize_arabic(name)) text_pattern_ops);
ANALYZE regional_place_names;
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
CMD="$@"
expect -c "
set timeout 60
spawn ssh -o StrictHostKeyChecking=no root@194.163.173.157 \"$CMD\"
expect {
\"*password:*\" {
send \"mehmetDev@2101\r\"
exp_continue
}
eof
}
"
+3 -1
View File
@@ -1,4 +1,5 @@
#!/bin/bash
# وظيفة الملف: أداة تشغيل أو تجهيز باسم backup db؛ تفاصيل خطواتها ومدخلاتها موضحة في دليل ملفات المشروع.
# ==============================================================================
# SIRO Maps — Daily Database Backup Script
# ==============================================================================
@@ -14,10 +15,11 @@ mkdir -p "$BACKUP_DIR"
echo "💾 [$(date '+%Y-%m-%d %H:%M:%S')] Starting SIRO Maps custom tables backup..."
# Dump custom tables: places_*, place_gates, billing, users, telemetry
# Dump custom tables, including archived legacy gate rows for rollback.
docker compose -f "${APP_DIR}/docker-compose.yml" exec -T db pg_dump -U mapuser -d mapdb \
-t 'places_*' \
-t 'place_gates' \
-t 'place_gates_unverified_archive_20260929' \
-t 'users' \
-t 'api_keys' \
-t 'billing_*' \
+3 -6
View File
@@ -1,11 +1,12 @@
#!/bin/bash
# وظيفة الملف: أداة تشغيل أو تجهيز باسم master cron sync؛ تفاصيل خطواتها ومدخلاتها موضحة في دليل ملفات المشروع.
# ==============================================================================
# SIRO Maps — Master Bi-Monthly Geospatial Synchronization (Every 15 Days)
# ==============================================================================
# This script runs automatically via crontab on the 1st and 15th of every month.
# It checks and syncs:
# 1. OpenStreetMap & Overture Maps for Jordan, Iraq, Syria, and Egypt.
# 2. Gate & Entrance mapping (Hospitals, Malls, Universities, Hotels, Parks).
# 2. Gate & Entrance mapping is handled by the 10-day OSM source sync.
# 3. Administrative boundary resolution (Governorates & Districts).
# 4. Materialized view concurrent refresh (unified_search_index).
# 5. Redis search cache flushing.
@@ -33,11 +34,7 @@ python3 -u scripts/enrich_iraq_comprehensive.py >> "$LOG_FILE" 2>&1 || echo "⚠
echo "🇪🇬 Syncing Egypt places..." >> "$LOG_FILE"
python3 -u scripts/enrich_egypt_places.py >> "$LOG_FILE" 2>&1 || echo "⚠️ Egypt sync had warnings" >> "$LOG_FILE"
# 3. Populate Gates & Entrances (Hospitals, Malls, Universities, Hotels, Parks)
echo "🚪 Updating Place Gates & Entrances..." >> "$LOG_FILE"
python3 -u scripts/populate_place_gates.py --country all >> "$LOG_FILE" 2>&1 || echo "⚠️ Gates update had warnings" >> "$LOG_FILE"
# 4. Flush Redis Search Cache via Python socket
# 3. Flush Redis Search Cache via Python socket
echo "⚡ Flushing Redis search cache..." >> "$LOG_FILE"
python3 -c "
import socket
+13 -295
View File
@@ -1,305 +1,23 @@
#!/usr/bin/env python3
"""
Populate and enrich `place_gates` table for major complexes:
- Hospitals (Main Gate, Emergency & Ambulance Gate, Outpatient/Service Gate)
- Malls & Shopping Centers (Main Entrance, Parking Entrance, Delivery/Service Gate)
- Universities & Colleges (Main Gate, North Gate, South/Student Gate)
- Hotels & Resorts (Main Entrance, Valet/Parking Gate, Service Gate)
- Parks & Public Gardens (Main Entrance, Family Entrance, Secondary Gate)
"""Compatibility entry point for source-backed OSM gate synchronization.
Harvests from:
1. Real OSM gate/entrance nodes (`planet_osm_point` where barrier in ('gate', 'entrance') or entrance is not null)
2. Complex boundary polygons and perimeter road-facing access points
The previous implementation generated guessed gate coordinates around POI
centroids. That behavior has been removed. Use --pbf to import real OSM nodes.
"""
import os
import sys
import math
import time
import argparse
import subprocess
import sys
from pathlib import Path
def haversine(lat1, lon1, lat2, lon2):
R = 6371000 # meters
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
delta_phi = math.radians(lat2 - lat1)
delta_lambda = math.radians(lon2 - lon1)
a = math.sin(delta_phi / 2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2)**2
return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def clean_sql_str(val):
if val is None:
return 'NULL'
s = str(val).strip().replace("'", "''")
return f"'{s}'"
def main():
parser = argparse.ArgumentParser(description="Populate place_gates for major complexes.")
parser.add_argument("--db-host", default="127.0.0.1")
parser.add_argument("--db-port", type=int, default=5432)
parser.add_argument("--db-user", default="mapuser")
parser.add_argument("--db-pass", default="mappass")
parser.add_argument("--db-name", default="mapdb")
parser.add_argument("--country", default="all", choices=["jordan", "iraq", "syria", "egypt", "all"])
parser.add_argument("--dry-run", action="store_true")
def main() -> int:
parser = argparse.ArgumentParser(description="Import real OSM gate/entrance nodes; no coordinates are generated.")
parser.add_argument("--pbf", required=True, help="Merged OSM PBF path for the current map-data scope")
args = parser.parse_args()
target = Path(__file__).with_name("sync_osm_place_gates.py")
return subprocess.call([sys.executable, str(target), "--pbf", args.pbf])
import pg8000.native
print("=" * 70)
print("🚪 SIRO Maps — Intelligent Place Gates & Entrances Ingestion")
print("=" * 70)
con = pg8000.native.Connection(
user=args.db_user,
password=args.db_pass,
host=args.db_host,
port=args.db_port,
database=args.db_name
)
countries = ["jordan", "iraq", "syria", "egypt"] if args.country == "all" else [args.country]
# 1. Ensure place_gates schema and indexes
con.run("""
CREATE TABLE IF NOT EXISTS place_gates (
id SERIAL PRIMARY KEY,
place_id VARCHAR(64) NOT NULL,
gate_name_ar VARCHAR(255) NOT NULL,
gate_name_en VARCHAR(255),
latitude NUMERIC(10,7) NOT NULL,
longitude NUMERIC(10,7) NOT NULL,
is_main_gate BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_place_gates_place_id ON place_gates(place_id);
""")
# Get already existing place_ids in place_gates to avoid duplicate inserts
existing_place_ids = set(r[0] for r in con.run("SELECT DISTINCT place_id FROM place_gates;"))
print(f"📌 Already configured places in place_gates: {len(existing_place_ids):,}")
total_gates_to_insert = []
for country in countries:
table_name = f"places_{country}"
prefix = f"places_{country}_"
print(f"\n🔍 Processing {country.upper()} ({table_name})...")
# Select major complexes that benefit from gates
rows = con.run(f"""
SELECT id, name, name_ar, category, latitude, longitude
FROM {table_name}
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
AND (
category ILIKE '%مستشف%' OR category ILIKE '%hospital%'
OR category ILIKE '%مول%' OR category ILIKE '%mall%' OR category ILIKE '%مركز تسوق%'
OR category ILIKE '%جامع%' OR category ILIKE '%university%' OR category ILIKE '%college%'
OR category ILIKE '%فندق%' OR category ILIKE '%hotel%'
OR category ILIKE '%حديق%' OR category ILIKE '%منتزه%' OR category ILIKE '%park%'
OR category ILIKE '%مطار%' OR category ILIKE '%airport%'
OR category ILIKE '%ملعب%' OR category ILIKE '%استاد%' OR category ILIKE '%stadium%'
);
""")
print(f" -> Found {len(rows):,} major complexes.")
count_added = 0
for r in rows:
p_id, p_name, p_name_ar, p_cat, p_lat, p_lon = r
full_place_id = f"{prefix}{p_id}"
if full_place_id in existing_place_ids:
continue
lat = float(p_lat)
lon = float(p_lon)
cat = str(p_cat or '').lower()
gates_for_place = []
# 1. Is it a Hospital? (Emergency Gate + Main Gate + Service Gate)
if any(k in cat for k in ('مستشف', 'hospital', 'طبي')):
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'البوابة الرئيسية',
'gate_name_en': 'Main Entrance',
'latitude': lat + 0.00035,
'longitude': lon,
'is_main_gate': True
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'بوابة الطوارئ والإسعاف',
'gate_name_en': 'Emergency & Ambulance Gate',
'latitude': lat - 0.00025,
'longitude': lon + 0.00040,
'is_main_gate': False
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'بوابة العيادات الخارجية والخدمات',
'gate_name_en': 'Outpatient Clinics & Service Gate',
'latitude': lat,
'longitude': lon - 0.00040,
'is_main_gate': False
})
# 2. Is it a Mall / Shopping Center?
elif any(k in cat for k in ('مول', 'mall', 'مركز تسوق')):
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'البوابة الرئيسية',
'gate_name_en': 'Main Entrance',
'latitude': lat + 0.00030,
'longitude': lon,
'is_main_gate': True
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'بوابة مواقف السيارات',
'gate_name_en': 'Parking Entrance',
'latitude': lat - 0.00030,
'longitude': lon + 0.00030,
'is_main_gate': False
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'بوابة الخدمات والشحن',
'gate_name_en': 'Service & Delivery Gate',
'latitude': lat,
'longitude': lon - 0.00035,
'is_main_gate': False
})
# 3. Is it a University / College / Education Campus?
elif any(k in cat for k in ('جامع', 'university', 'college', 'معهد')):
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'البوابة الرئيسية',
'gate_name_en': 'Main Campus Gate',
'latitude': lat + 0.00040,
'longitude': lon,
'is_main_gate': True
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'البوابة الشمالية (بوابة الطلاب)',
'gate_name_en': 'North Student Gate',
'latitude': lat + 0.00060,
'longitude': lon + 0.00030,
'is_main_gate': False
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'البوابة الجنوبية / الكليات الطبية',
'gate_name_en': 'South / Medical Gate',
'latitude': lat - 0.00050,
'longitude': lon - 0.00030,
'is_main_gate': False
})
# 4. Is it a Hotel / Resort?
elif any(k in cat for k in ('فندق', 'hotel', 'منتجع')):
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'مدخل الفندق الرئيسي (الاستقبال)',
'gate_name_en': 'Main Hotel Entrance / Lobby',
'latitude': lat + 0.00020,
'longitude': lon,
'is_main_gate': True
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'مدخل مواقف النزلاء (Valet)',
'gate_name_en': 'Valet & Guest Parking Gate',
'latitude': lat - 0.00025,
'longitude': lon + 0.00025,
'is_main_gate': False
})
# 5. Is it a Park / Stadium / Garden?
elif any(k in cat for k in ('حديق', 'منتزه', 'park', 'ملعب', 'استاد', 'stadium')):
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'البوابة الرئيسية',
'gate_name_en': 'Main Entrance',
'latitude': lat + 0.00040,
'longitude': lon,
'is_main_gate': True
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'بوابة العائلات / البوابة الشرقية',
'gate_name_en': 'Family / East Entrance',
'latitude': lat - 0.00040,
'longitude': lon + 0.00030,
'is_main_gate': False
})
if gates_for_place:
total_gates_to_insert.extend(gates_for_place)
existing_place_ids.add(full_place_id)
count_added += 1
print(f" ✓ Generated gates for {count_added:,} complexes in {country.upper()}.")
print(f"\n🌟 Total Gates to Insert: {len(total_gates_to_insert):,}")
if args.dry_run:
print("💡 Dry run complete. No database changes made.")
con.close()
return
# Insert into place_gates
print(f"\n🚀 Inserting {len(total_gates_to_insert):,} gates into place_gates...")
t_ins = time.time()
batch_size = 2000
total_ins = 0
con.run("BEGIN;")
for i in range(0, len(total_gates_to_insert), batch_size):
batch = total_gates_to_insert[i:i + batch_size]
values = []
for g in batch:
p_id = clean_sql_str(g['place_id'])
g_ar = clean_sql_str(g['gate_name_ar'])
g_en = clean_sql_str(g['gate_name_en'])
g_lat = g['latitude']
g_lon = g['longitude']
g_main = 'TRUE' if g['is_main_gate'] else 'FALSE'
line = f"({p_id}, {g_ar}, {g_en}, {g_lat:.7f}, {g_lon:.7f}, {g_main})"
values.append(line)
sql = (
"INSERT INTO place_gates ("
" place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate"
") VALUES " + ",\n".join(values) + ";"
)
con.run(sql)
total_ins += len(batch)
print(f" -> Progress: {total_ins:,} / {len(total_gates_to_insert):,} gates inserted...")
con.run("COMMIT;")
print(f"✅ Gates insertion committed in {time.time() - t_ins:.2f}s!")
con.run("ANALYZE place_gates;")
total_count = con.run("SELECT count(*) FROM place_gates;")[0][0]
print(f"\n🎯 Total Gates in place_gates: {total_count:,}")
# Sample verification
sample = con.run("""
SELECT place_id, gate_name_ar, gate_name_en, is_main_gate, latitude, longitude
FROM place_gates
ORDER BY id DESC
LIMIT 6;
""")
print("\n🔍 Sample New Gates:")
for s in sample:
print(f" - [{s[0]}] {s[1]} ({s[2]}) - Main: {s[3]} @ {s[4]}, {s[5]}")
con.close()
print("\n🎉 Place Gates enrichment completed successfully!")
if __name__ == '__main__':
main()
if __name__ == "__main__":
raise SystemExit(main())
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""Sync only explicitly mapped OpenStreetMap entrances into place_gates.
Entrances are extracted from the same OSM PBF used for the routing/map update.
The importer associates a node only when it is covered by exactly one named
target facility polygon (hospital/clinic, mall, or government facility).
No coordinates or gate types are inferred from facility centroids.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import subprocess
import sys
import tempfile
from collections import Counter
from pathlib import Path
APP_DIR = Path(__file__).resolve().parents[1]
COUNTRY_NAMES = {
"AE": "الإمارات", "BH": "البحرين", "EG": "مصر", "IQ": "العراق",
"JO": "الأردن", "KW": "الكويت", "OM": "عُمان", "QA": "قطر",
"SA": "السعودية", "SY": "سوريا", "YE": "اليمن",
}
USEFUL_ENTRANCES = {
"yes", "main", "secondary", "service", "emergency", "parking", "entrance"
}
def run(command: list[str], *, input_text: str | None = None) -> str:
proc = subprocess.run(
command, cwd=APP_DIR, input=input_text, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
if proc.returncode:
sys.stderr.write(proc.stderr)
raise RuntimeError(f"Command failed ({proc.returncode}): {' '.join(command)}")
return proc.stdout
def psql(sql: str) -> str:
return run([
"docker", "compose", "exec", "-T", "db", "psql", "-X", "-v",
"ON_ERROR_STOP=1", "-U", os.getenv("DB_USER", "mapuser"), "-d",
os.getenv("DB_NAME", "mapdb"), "-At", "-c", sql,
])
def label_for(tags: dict[str, str]) -> tuple[str, str, str, bool] | None:
entrance = tags.get("entrance", "").lower()
routing_entrance = tags.get("routing:entrance", "").lower()
barrier = tags.get("barrier", "").lower()
if (entrance not in USEFUL_ENTRANCES
and routing_entrance not in {"yes", "main"}
and barrier not in {"gate", "entrance"}):
return None
if tags.get("access", "").lower() in {"no", "private"}:
return None
labels = {
"main": ("المدخل الرئيسي", "Main entrance", "main", True),
"secondary": ("مدخل ثانوي", "Secondary entrance", "secondary", False),
"service": ("مدخل الخدمات", "Service entrance", "service", False),
"emergency": ("مدخل الطوارئ", "Emergency entrance", "emergency", False),
"parking": ("مدخل المواقف", "Parking entrance", "parking", False),
}
if entrance in labels:
fallback = labels[entrance]
elif routing_entrance in {"yes", "main"}:
fallback = ("مدخل", "Entrance", "routing", False)
else:
fallback = ("بوابة" if barrier == "gate" else "مدخل", "Gate" if barrier == "gate" else "Entrance", "gate" if barrier == "gate" else "entrance", False)
name_ar = tags.get("name:ar") or fallback[0]
name_en = tags.get("name:en") or tags.get("name") or fallback[1]
return name_ar, name_en, fallback[2], fallback[3]
def main() -> int:
parser = argparse.ArgumentParser(description="Import source-mapped OSM entrance nodes.")
parser.add_argument("--pbf", required=True, help="The current merged OSM PBF used by the map updater")
parser.add_argument("--dry-run", action="store_true", help="Extract and report exact matches without changing the live table")
args = parser.parse_args()
pbf_path = Path(args.pbf)
if not pbf_path.is_absolute():
pbf_path = APP_DIR / pbf_path
if not pbf_path.is_file():
parser.error(f"OSM source file does not exist: {pbf_path}")
run(["osmium", "--version"])
print(f"Extracting tagged OSM entrances from {pbf_path} …", flush=True)
with tempfile.TemporaryDirectory(prefix="siro-osm-gates-") as temp:
extracted = Path(temp) / "entrances.osm.pbf"
geojson_path = Path(temp) / "entrances.geojson"
run([
"osmium", "tags-filter", str(pbf_path),
"n/entrance", "n/barrier=gate", "n/barrier=entrance",
"n/routing:entrance", "-o", str(extracted),
])
run(["osmium", "export", "-u", "type_id", str(extracted), "-o", str(geojson_path)])
features = json.loads(geojson_path.read_text(encoding="utf-8")).get("features", [])
rows: list[tuple] = []
tag_counts: Counter[str] = Counter()
for feature in features:
osm_node_id = feature.get("id", "")
match = re.fullmatch(r"n(-?\d+)", str(osm_node_id))
geometry = feature.get("geometry") or {}
coordinates = geometry.get("coordinates") or []
tags = feature.get("properties") or {}
if not match or len(coordinates) < 2:
continue
classified = label_for(tags)
if classified is None:
continue
lon, lat = coordinates[:2]
rows.append((
int(match.group(1)), float(lon), float(lat),
tags.get("entrance"), tags.get("routing:entrance"), tags.get("barrier"),
tags.get("access"), tags.get("name"), tags.get("name:ar"),
tags.get("name:en"), *classified,
json.dumps(tags, ensure_ascii=False, separators=(",", ":")),
))
tag_counts[tags.get("entrance") or tags.get("barrier") or "other"] += 1
print(f"Source nodes selected: {len(rows):,}", flush=True)
if not rows:
raise RuntimeError("No usable entrance nodes found; keeping the live gate table unchanged.")
# Stage exact WGS84 source points. This table is not read by the API.
psql("""
CREATE TABLE IF NOT EXISTS place_gates_osm_stage (
osm_id bigint PRIMARY KEY, longitude double precision NOT NULL,
latitude double precision NOT NULL, entrance text, routing_entrance text,
barrier text, access text, name text, name_ar text, name_en text,
gate_name_ar text NOT NULL, gate_name_en text NOT NULL,
gate_type text NOT NULL, is_main_gate boolean NOT NULL,
source_tags jsonb NOT NULL
);
TRUNCATE place_gates_osm_stage;
""")
with tempfile.TemporaryFile(mode="w+t", encoding="utf-8", newline="") as tsv:
writer = csv.writer(tsv, delimiter="\t", lineterminator="\n", quoting=csv.QUOTE_MINIMAL)
writer.writerows(rows)
tsv.seek(0)
run([
"docker", "compose", "exec", "-T", "db", "psql", "-X", "-v",
"ON_ERROR_STOP=1", "-U", os.getenv("DB_USER", "mapuser"), "-d",
os.getenv("DB_NAME", "mapdb"), "-c",
"COPY place_gates_osm_stage (osm_id,longitude,latitude,entrance,routing_entrance,barrier,access,name,name_ar,name_en,gate_name_ar,gate_name_en,gate_type,is_main_gate,source_tags) FROM STDIN WITH (FORMAT csv, DELIMITER E'\\t', NULL '')",
], input_text=tsv.read())
psql("""
ALTER TABLE place_gates_osm_stage ADD COLUMN IF NOT EXISTS geom geometry(Point,3857);
UPDATE place_gates_osm_stage
SET geom=ST_Transform(ST_SetSRID(ST_MakePoint(longitude,latitude),4326),3857);
CREATE INDEX IF NOT EXISTS place_gates_osm_stage_geom_idx
ON place_gates_osm_stage USING gist (geom);
ANALYZE place_gates_osm_stage;
""")
print("Matching source nodes to OSM facility polygons by exact polygon coverage …", flush=True)
report_sql = """
WITH facilities AS (
SELECT p.osm_id, p.way, r.country_code,
'regional_' || r.country_code || '_osm_polygon_' || r.source_id AS place_id
FROM planet_osm_polygon p
JOIN regional_place_names r
ON r.source = 'osm_polygon' AND r.source_id = p.osm_id::text
WHERE p.name IS NOT NULL
AND (p.amenity IN ('hospital','clinic','townhall','courthouse')
OR p.shop = 'mall' OR p.office = 'government' OR p.building = 'government')
), matched AS (
SELECT g.osm_id AS gate_osm_id, f.country_code, f.place_id
FROM place_gates_osm_stage g
JOIN facilities f
ON ST_Covers(f.way, g.geom)
), unique_matches AS (
SELECT gate_osm_id, min(country_code) AS country_code, min(place_id) AS place_id
FROM matched GROUP BY gate_osm_id HAVING count(DISTINCT place_id) = 1
)
SELECT country_code || '|' || count(*) || '|' || count(DISTINCT place_id)
FROM unique_matches GROUP BY country_code ORDER BY country_code;
"""
report = psql(report_sql).strip().splitlines()
for line in report:
code, gates, places = line.split("|")
print(f"{COUNTRY_NAMES.get(code, code)} ({code}): {gates} مصدرًا موثقًا ↔ {places} منشأة", flush=True)
if not report:
raise RuntimeError("No source nodes matched named target facilities; the live gate table is unchanged.")
samples = psql("""
WITH matches AS (
SELECT g.osm_id,g.gate_name_ar,g.gate_type,r.country_code,r.name,
'regional_'||r.country_code||'_osm_polygon_'||r.source_id AS place_id
FROM place_gates_osm_stage g
JOIN planet_osm_polygon p
ON ST_Covers(p.way,g.geom)
JOIN regional_place_names r ON r.source='osm_polygon' AND r.source_id=p.osm_id::text
WHERE p.name IS NOT NULL
AND (p.amenity IN ('hospital','clinic','townhall','courthouse') OR p.shop='mall'
OR p.office='government' OR p.building='government')
), unique_ids AS (
SELECT osm_id,min(place_id) AS place_id FROM matches
GROUP BY osm_id HAVING count(DISTINCT place_id)=1
)
SELECT m.country_code||'|'||m.name||'|'||m.gate_name_ar||'|'||m.gate_type||'|'||m.osm_id
FROM matches m JOIN unique_ids u ON u.osm_id=m.osm_id AND u.place_id=m.place_id
ORDER BY m.country_code,m.name LIMIT 12;
""").strip()
if samples:
print("Source-matched examples (country|facility|gate label|OSM tag type|node id):", flush=True)
print(samples, flush=True)
if args.dry_run:
print("Dry run complete; live place_gates was not changed.", flush=True)
return 0
# Archive unverifiable legacy rows once, then expose only source-backed OSM rows.
sync_sql = """
BEGIN;
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS source text;
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS source_id text;
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS gate_type text;
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS access text;
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS source_tags jsonb;
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS last_seen_at timestamptz;
CREATE TABLE IF NOT EXISTS place_gates_unverified_archive_20260929 AS
SELECT * FROM place_gates WHERE source IS NULL AND false;
INSERT INTO place_gates_unverified_archive_20260929
SELECT * FROM place_gates WHERE source IS NULL;
DELETE FROM place_gates WHERE source IS NULL OR source = 'openstreetmap';
WITH facilities AS (
SELECT p.osm_id, p.way, r.country_code,
'regional_' || r.country_code || '_osm_polygon_' || r.source_id AS place_id
FROM planet_osm_polygon p
JOIN regional_place_names r
ON r.source = 'osm_polygon' AND r.source_id = p.osm_id::text
WHERE p.name IS NOT NULL
AND (p.amenity IN ('hospital','clinic','townhall','courthouse')
OR p.shop = 'mall' OR p.office = 'government' OR p.building = 'government')
), matches AS (
SELECT g.*, f.place_id
FROM place_gates_osm_stage g
JOIN facilities f
ON ST_Covers(f.way, g.geom)
), unique_matches AS (
SELECT osm_id, min(place_id) AS place_id FROM matches
GROUP BY osm_id HAVING count(DISTINCT place_id) = 1
), exact_matches AS (
SELECT DISTINCT ON (m.osm_id) m.*
FROM matches m JOIN unique_matches u ON u.osm_id=m.osm_id AND u.place_id=m.place_id
)
INSERT INTO place_gates
(place_id,gate_name_ar,gate_name_en,latitude,longitude,is_main_gate,source,source_id,gate_type,access,source_tags,last_seen_at)
SELECT place_id,gate_name_ar,gate_name_en,latitude,longitude,is_main_gate,
'openstreetmap',osm_id::text,gate_type,access,source_tags,now()
FROM exact_matches;
CREATE INDEX IF NOT EXISTS idx_place_gates_source_id ON place_gates(source,source_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_place_gates_osm_identity
ON place_gates(source,source_id,place_id) WHERE source='openstreetmap';
ANALYZE place_gates;
COMMIT;
"""
psql(sync_sql)
totals = psql("SELECT source || '|' || count(*) FROM place_gates GROUP BY source ORDER BY source;")
print("Live place_gates rows (source|count):", flush=True)
print(totals.strip(), flush=True)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc: # preserve the existing live table on any failed preflight/import
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)
+1
View File
@@ -33,6 +33,7 @@ rsync -avz --progress -e "ssh -o StrictHostKeyChecking=no" $KEY_FLAG \
style-satellite.json \
style-3d-terrain.json \
*.html \
*.md \
apps \
packages \
infrastructure \