From 9aa45ad605b0c06cd6a3351144fc28bf82529b4a Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 26 Sep 2026 13:24:51 +0300 Subject: [PATCH] feat: add sovereign map server migration runbook, seeding scripts, and tactical maneuver compare view --- SOVEREIGN_MAP_SERVER_MIGRATION_RUNBOOK.md | 402 ++++++ apps/web/src/main.tsx | 11 +- .../src/pages/TacticalManeuverCompareView.tsx | 1123 +++++++++++++++++ docker-compose.yml | 16 +- .../scripts/backup_sovereign_map.sh | 139 ++ .../scripts/restore_sovereign_map.sh | 184 +++ infrastructure/scripts/seed_jordan_dem.py | 181 +++ .../scripts/seed_jordan_satellite.py | 181 +++ sync_to_server.sh | 1 + 9 files changed, 2232 insertions(+), 6 deletions(-) create mode 100644 SOVEREIGN_MAP_SERVER_MIGRATION_RUNBOOK.md create mode 100644 apps/web/src/pages/TacticalManeuverCompareView.tsx create mode 100755 infrastructure/scripts/backup_sovereign_map.sh create mode 100755 infrastructure/scripts/restore_sovereign_map.sh create mode 100644 infrastructure/scripts/seed_jordan_dem.py create mode 100644 infrastructure/scripts/seed_jordan_satellite.py diff --git a/SOVEREIGN_MAP_SERVER_MIGRATION_RUNBOOK.md b/SOVEREIGN_MAP_SERVER_MIGRATION_RUNBOOK.md new file mode 100644 index 0000000..295fee9 --- /dev/null +++ b/SOVEREIGN_MAP_SERVER_MIGRATION_RUNBOOK.md @@ -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@:/home/hamzadoctor/backups/ +``` + +### استنساخ مستودع الكود إلى السيرفر الجديد: +على السيرفر الجديد: +```bash +mkdir -p /home/hamzadoctor/app +cd /home/hamzadoctor/app + +# سحب الكود من Git أو نقله عبر rsync +git clone . +# أو نسخ ملفات المشروع عبر rsync من السيرفر القديم: +# rsync -avzP --exclude 'node_modules' --exclude '.git' root@:/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)* diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 1fa85b9..b91f773 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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 ( @@ -464,6 +467,12 @@ function Root() { )} + {isManeuver && ( +
+ +
+ )} + {isExecutive && (
diff --git a/apps/web/src/pages/TacticalManeuverCompareView.tsx b/apps/web/src/pages/TacticalManeuverCompareView.tsx new file mode 100644 index 0000000..cca028e --- /dev/null +++ b/apps/web/src/pages/TacticalManeuverCompareView.tsx @@ -0,0 +1,1123 @@ +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import { + Shield, + Crosshair, + Play, + Pause, + RotateCcw, + Target, + Navigation, + Compass, + Mountain, + Flame, + Clock, + Zap, + Info, + Sliders, + ChevronRight, + TrendingUp, + MapPin, + Eye, + Activity, + CheckCircle, + AlertTriangle, + Sun, + Layers, + Flag, + Radio, + ArrowRight, + Maximize2, + Satellite, + Map as MapIcon, + Sparkles +} from 'lucide-react'; +import { getSharedDemSource } from '../utils/demSource'; + +// ── MILITARY DOCTRINE & TACTICAL SPECIFICATIONS ────────────────────────────────────── + +export type DoctrineType = 'western' | 'eastern'; +export type BaseMapMode = 'satellite' | 'vector' | 'hybrid'; + +export interface MilitaryUnit { + id: string; + name: string; + echelon: 'battalion' | 'regiment' | 'company' | 'platoon'; + doctrine: DoctrineType; + category: 'armor' | 'mechanized' | 'artillery' | 'air_defense' | 'hq'; + speedRoadKmH: number; + speedCrossCountryKmH: number; + currentCoords: [number, number]; + corridorId: string; + status: 'holding' | 'advancing' | 'engaged' | 'secured'; +} + +export interface TacticalCorridor { + id: string; + nameAr: string; + doctrine: DoctrineType; + description: string; + terrainType: string; + passability: 'go' | 'slow_go' | 'no_go'; + maxSlopePercent: number; + lengthKm: number; + chokePoints: { name: string; coords: [number, number]; threat: string }[]; + pathCoords: [number, number][]; +} + +// Strategic Jordan 3D Benchmarks +const JORDAN_3D_SITES = [ + { + id: 'wadi-shuaib', + nameAr: 'وادي شعيب ومضيق السلط (انحدار حاد)', + coords: [35.6880, 31.9890] as [number, number], + elevation: 480, + zoom: 13.5, + pitch: 72, + bearing: -35, + desc: 'انحدار جبلي مهيب من جبال البلقاء نحو غور الأردن وممر تعبوي حرج' + }, + { + id: 'dead-sea-canyon', + nameAr: 'شواطئ البحر الميت والمنحدرات الملحية', + coords: [35.5720, 31.4660] as [number, number], + elevation: -425, + zoom: 13.5, + pitch: 68, + bearing: -75, + desc: 'أخفض نقطة على سطح الأرض مع جروف صخرية شاهقة تعلو البحر بـ 1200 متر' + }, + { + id: 'wadi-rum', + nameAr: 'وادي رم وجبال الغرانيت والرمال', + coords: [35.4337, 29.5763] as [number, number], + elevation: 1754, + zoom: 13.0, + pitch: 75, + bearing: -42, + desc: 'سلاسل جبال صخرية عملاقة تشبه أبراج القلاع في قلب الصحراء الجنوبية' + }, + { + id: 'ajloun-castle', + nameAr: 'قلعة عجلون ومرتفعات جبل عوف', + coords: [35.7330, 32.3255] as [number, number], + elevation: 1023, + zoom: 13.8, + pitch: 68, + bearing: 45, + desc: 'قمة استراتيجية حاكمة وغابات جبلية كثيفة تشرف على حوران وفلسطين' + }, + { + id: 'wadi-mujib', + nameAr: 'خانق وادي الموجب العظيم (جراند كانيون الأردن)', + coords: [35.5800, 31.4500] as [number, number], + elevation: -150, + zoom: 13.4, + pitch: 74, + bearing: -60, + desc: 'صدع جيولوجي سحيق بعمق يفوق 1000 متر يشكل أكبر مانع تضاريسي طبيعي' + } +]; + +const JORDAN_TACTICAL_CORRIDORS: TacticalCorridor[] = [ + { + id: 'corridor-w1', + nameAr: 'ممر وادي شعيب التعبوي (محور صويلح - السلط - الشونة)', + doctrine: 'western', + description: 'ممر جبلي يخترق جبال البلقاء هبوطاً من صويلح (+1060م) عبر السلط (+840م) ومضيق وادي شعيب وصولاً لغور الأردن (-250م).', + terrainType: 'تضاريس جبلية وعرة ذات انحدار شديد وأودية سحيقة', + passability: 'slow_go', + maxSlopePercent: 18, + lengthKm: 34.5, + chokePoints: [ + { name: 'مضيق وادي شعيب (عقدة اختناق)', coords: [35.6880, 31.9890], threat: 'عرض الطريق أقل من 12 متراً محصور بين جرفين صخريين' }, + { name: 'حافة انحدار سد وادي شعيب', coords: [35.6350, 31.9420], threat: 'انحدار حاد يزيد عن 15% يقلل سرعة المجنزرات' }, + { name: 'معبر الشونة الجنوبية والجسور', coords: [35.6120, 31.8950], threat: 'عقدة طرق مكشوفة تحت نيران المرتفعات الغربية' } + ], + pathCoords: [ + [35.8450, 32.0220], + [35.7950, 32.0310], + [35.7330, 32.0390], + [35.6880, 31.9890], + [35.6350, 31.9420], + [35.6120, 31.8950] + ] + }, + { + id: 'corridor-w2', + nameAr: 'محور الانفتاح الصحراوي السريع (المفرق - الزرقاء - الهاشمية)', + doctrine: 'western', + description: 'ممر أرضي شبه منبسط ذو تلال متموجة، يعتبر مثالياً للحركة السريعة للأرتال المدرعة وكتائب الدبابات الثقيلة.', + terrainType: 'هضبة بازلتية وصحراوية منبسطة مع رؤية واسعة', + passability: 'go', + maxSlopePercent: 6, + lengthKm: 42.0, + chokePoints: [ + { name: 'عقدة الهاشمية ومصافي البترول', coords: [36.1150, 32.1250], threat: 'تقاطع طرق سريع وعقدة أنابيب محروقات حيوية' }, + { name: 'تل بلعما المرتفع الحاكم', coords: [36.0300, 32.2050], threat: 'نقطة إشراف بصري وراداري على كامل المحور' } + ], + pathCoords: [ + [36.2590, 32.3560], + [36.1700, 32.2850], + [36.0850, 32.2100], + [36.1150, 32.1250], + [36.0880, 32.0720] + ] + } +]; + +export const TacticalManeuverCompareView: React.FC = () => { + // Navigation & Sub-Tabs + const [activeTab, setActiveTab] = useState<'elevation_compare' | 'maneuver'>('elevation_compare'); + + // Map references + const mapContainer = useRef(null); + const mapRef = useRef(null); + const markersRef = useRef([]); + + // 3D Terrain & Master Map Style Controls + const [baseMapMode, setBaseMapMode] = useState('satellite'); + const [terrain3DExaggeration, setTerrain3DExaggeration] = useState(1.5); + const terrainExaggerationRef = useRef(1.5); + const [esriHillshadeOpacity, setEsriHillshadeOpacity] = useState(0.55); + const [sunAzimuth, setSunAzimuth] = useState(315); + + // Keep terrainExaggerationRef synchronized for real-time elevation queries + useEffect(() => { + terrainExaggerationRef.current = terrain3DExaggeration; + }, [terrain3DExaggeration]); + + // Dynamic Vector Contours States + const [enableDynamicContours, setEnableDynamicContours] = useState(true); + const [contourInterval, setContourInterval] = useState(20); + + // Tactical States + const [selectedDoctrine, setSelectedDoctrine] = useState('western'); + const [selectedCorridorId, setSelectedCorridorId] = useState('corridor-w1'); + const [isSimulating, setIsSimulating] = useState(false); + const [simulationProgress, setSimulationProgress] = useState(0); + const [simulationSpeed, setSimulationSpeed] = useState(1); + + // Elevation Inspector + const [inspectedLocation, setInspectedLocation] = useState<{ + lng: number; + lat: number; + elevation: number; + terrainCategory: string; + } | null>(null); + + // Units list + const [units] = useState([ + { + id: 'w-armored-4', + name: 'كتيبة الدبابات الملكية 4 (خالد بن الوليد)', + echelon: 'battalion', + doctrine: 'western', + category: 'armor', + speedRoadKmH: 45, + speedCrossCountryKmH: 22, + currentCoords: [35.8450, 32.0220], + corridorId: 'corridor-w1', + status: 'advancing' + }, + { + id: 'w-mech-9', + name: 'كتيبة المشاة الآلية 9 (النسور)', + echelon: 'battalion', + doctrine: 'western', + category: 'mechanized', + speedRoadKmH: 60, + speedCrossCountryKmH: 28, + currentCoords: [35.7950, 32.0310], + corridorId: 'corridor-w1', + status: 'holding' + } + ]); + + // Unique source ID ref to avoid MapLibre caching collisions on interval changes + const currentContourSourceIdRef = useRef('contourSource'); + + // ── DYNAMIC VECTOR CONTOURS UPDATER (INSTANT REACTIVE) ───────────────────────────── + + const updateContourLayer = useCallback((interval: number, enabled: boolean) => { + const map = mapRef.current; + if (!map) return; + + // 1. Remove existing contour layers & source + if (map.getLayer('vectorContourLabels')) { + try { map.removeLayer('vectorContourLabels'); } catch (_) {} + } + if (map.getLayer('vectorContourLines')) { + try { map.removeLayer('vectorContourLines'); } catch (_) {} + } + if (map.getSource(currentContourSourceIdRef.current)) { + try { map.removeSource(currentContourSourceIdRef.current); } catch (_) {} + } + + if (!enabled) return; + + try { + const demSource = getSharedDemSource(); + if (!demSource) return; + + const newSourceId = `contourSource_${interval}_${Date.now()}`; + currentContourSourceIdRef.current = newSourceId; + + // Full zoom thresholds: minor line every interval, index line every (interval * 5) + const thresholds: Record = { + 8: [interval * 5, interval * 10], + 9: [interval * 4, interval * 10], + 10: [interval * 2, interval * 5], + 11: [interval * 2, interval * 5], + 12: [interval, interval * 5], + 13: [interval, interval * 5], + 14: [interval, interval * 5], + 15: [interval, interval * 5], + 16: [interval, interval * 5], + 17: [interval, interval * 5], + 18: [interval, interval * 5] + }; + + const url = demSource.contourProtocolUrl({ + thresholds, + elevationKey: 'ele', + levelKey: 'level', + contourLayer: 'contours' + }); + + map.addSource(newSourceId, { + type: 'vector', + tiles: [url], + maxzoom: 18 + }); + + map.addLayer({ + id: 'vectorContourLines', + type: 'line', + source: newSourceId, + 'source-layer': 'contours', + paint: { + 'line-color': [ + 'match', + ['get', 'level'], + 1, '#5c2c16', // Major Index Contour: Rich Dark Earth Brown (بني داكن واضح) + '#8a4b2d' // Minor Intermediate Contour: Topographic Earth Brown (بني طبيعي متناسق) + ], + 'line-width': [ + 'match', + ['get', 'level'], + 1, 2.0, + 1.0 + ], + 'line-opacity': [ + 'match', + ['get', 'level'], + 1, 0.85, // Major Index Contour: Distinct and legible + 0.40 // Minor Intermediate Contour: Soft transparency to prevent clash with satellite imagery + ] + } + }); + + map.addLayer({ + id: 'vectorContourLabels', + type: 'symbol', + source: newSourceId, + 'source-layer': 'contours', + filter: ['>', ['get', 'level'], 0], + layout: { + 'symbol-placement': 'line', + 'text-field': ['concat', ['to-string', ['get', 'ele']], ' م'], + 'text-size': 11, + 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'] + }, + paint: { + 'text-color': '#fffbeb', // Warm ivory for maximum contrast + 'text-halo-color': '#3e1a0b', // Deep brown halo + 'text-halo-width': 2.2 + } + }); + } catch (err) { + console.warn('Contour build error:', err); + } + }, []); + + // ── BASE MAP MODE SWITCHER (SOVEREIGN VECTOR / SATELLITE / HYBRID) ──────────────── + + const applyBaseMapMode = useCallback((map: maplibregl.Map, mode: BaseMapMode) => { + if (!map) return; + + // 1. Permanently eradicate pre-baked static raster overlays (OpenTopoMap beige raster) + ['contours-raster-overlay', 'contour-minor-lines', 'contour-major-lines', 'contour-labels'].forEach((layerId) => { + if (map.getLayer(layerId)) { + try { + map.removeLayer(layerId); + } catch (_) {} + } + }); + + const isSatellite = mode === 'satellite'; + const isHybrid = mode === 'hybrid'; + const isVector = mode === 'vector'; + + // 2. Control Dedicated Satellite Layer + if (map.getLayer('tactical-satellite-layer')) { + map.setLayoutProperty( + 'tactical-satellite-layer', + 'visibility', + isSatellite || isHybrid ? 'visible' : 'none' + ); + map.setPaintProperty('tactical-satellite-layer', 'raster-opacity', 1.0); + } + if (map.getLayer('satellite-base-layer')) { + map.setLayoutProperty( + 'satellite-base-layer', + 'visibility', + isSatellite || isHybrid ? 'visible' : 'none' + ); + } + + // 3. Control Hillshade: Only in vector mode (satellite photography already has natural illumination) + if (map.getLayer('esri-multidirectional-hillshade')) { + map.setLayoutProperty( + 'esri-multidirectional-hillshade', + 'visibility', + isVector ? 'visible' : 'none' + ); + } + if (map.getLayer('terrain-3d-hillshading')) { + map.setLayoutProperty( + 'terrain-3d-hillshading', + 'visibility', + isVector ? 'visible' : 'none' + ); + } + + // 4. In satellite or hybrid mode, hide opaque vector ground fills + const layers = map.getStyle()?.layers || []; + for (const l of layers) { + if ( + l.type === 'fill' && + (l.id.includes('natural-landcover') || + l.id.includes('sand') || + l.id.includes('forest') || + l.id.includes('residential') || + l.id.includes('commercial')) + ) { + map.setLayoutProperty(l.id, 'visibility', isVector ? 'visible' : 'none'); + } + } + + // 5. In pure satellite mode, hide roads for pure terrain; in hybrid/vector, show roads + const isRoadsVisible = isVector || isHybrid; + [ + 'roads-casing', + 'roads-core-highways', + 'roads-core-primary', + 'roads-core-minor', + 'road-labels-highways', + 'road-labels-major', + 'road-labels-minor', + 'approved-road-labels', + '3d-buildings-extrusion', + 'osm-buildings-extrusion', + '3d-buildings-ground-shadows', + 'osm-buildings-ground-shadows' + ].forEach((layerId) => { + if (map.getLayer(layerId)) { + map.setLayoutProperty(layerId, 'visibility', isRoadsVisible ? 'visible' : 'none'); + } + }); + + // 6. Mountain peaks always visible so heights are clear + ['mountain-peaks-points', 'mountain-peaks-labels'].forEach((layerId) => { + if (map.getLayer(layerId)) { + map.setLayoutProperty(layerId, 'visibility', 'visible'); + } + }); + }, []); + + // ── MAP INITIALIZATION ──────────────────────────────────────────────────────────── + + useEffect(() => { + if (!mapContainer.current) return; + + const map = new maplibregl.Map({ + container: mapContainer.current, + style: '/style-3d-terrain.json?v=' + Date.now(), + center: [35.6880, 31.9890], // Wadi Shuaib / Salt + zoom: 13.0, + pitch: 68, + bearing: -32, + maxPitch: 85 + }); + + map.addControl(new maplibregl.NavigationControl({ visualizePitch: true, showCompass: true }), 'top-left'); + map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-left'); + + map.on('load', () => { + // 1. Ensure DEM Source exists and activate GPU 3D Terrain + if (!map.getSource('terrain-dem')) { + map.addSource('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 + }); + } + + try { + map.setTerrain({ + source: 'terrain-dem', + exaggeration: terrain3DExaggeration + }); + } catch (e) { + console.warn('SetTerrain warning:', e); + } + + // 2. Add Dedicated Sovereign Satellite Source & Layer (with maxzoom: 17 to prevent high-zoom error tiles) + if (!map.getSource('esri-satellite-src')) { + map.addSource('esri-satellite-src', { + type: 'raster', + tiles: [ + 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' + ], + tileSize: 256, + maxzoom: 17, + attribution: '© Esri, DigitalGlobe, GeoEye' + }); + } + + // Insert satellite beneath vector layers + const layers = map.getStyle().layers || []; + const firstVectorLayer = layers.find(l => l.type === 'fill' || l.type === 'line' || l.type === 'symbol'); + const beforeId = firstVectorLayer ? firstVectorLayer.id : undefined; + + if (!map.getLayer('tactical-satellite-layer')) { + map.addLayer( + { + id: 'tactical-satellite-layer', + type: 'raster', + source: 'esri-satellite-src', + paint: { + 'raster-opacity': 1.0, + 'raster-resampling': 'linear' + } + }, + beforeId + ); + } + + // 3. Add Esri Multi-Directional Hillshade with STRICT maxzoom: 16 + // This completely solves the "Map data not yet available" error at close zooms! + if (!map.getSource('esri-hillshade-source')) { + map.addSource('esri-hillshade-source', { + type: 'raster', + tiles: [ + 'https://services.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer/tile/{z}/{y}/{x}' + ], + tileSize: 256, + maxzoom: 16, // STRICT MAXZOOM: Tells MapLibre to overscale instead of requesting z17+ error tiles! + attribution: '© Esri, Airbus, USGS' + }); + + map.addLayer( + { + id: 'esri-multidirectional-hillshade', + type: 'raster', + source: 'esri-hillshade-source', + paint: { + 'raster-opacity': esriHillshadeOpacity, + 'raster-resampling': 'linear' + } + }, + beforeId + ); + } + + // Apply initial base map mode + applyBaseMapMode(map, baseMapMode); + + // Render initial dynamic vector contours + updateContourLayer(contourInterval, enableDynamicContours); + + // Render initial corridors & military symbols + renderTacticalCorridors(map); + updateMilitaryUnitMarkers(map); + }); + + // Real-time Elevation Inspector on Click + map.on('click', (e) => { + let elev = 700; + if (typeof (map as any).queryTerrainElevation === 'function') { + const measured = (map as any).queryTerrainElevation(e.lngLat); + if (measured !== null && measured !== undefined && !isNaN(measured)) { + // Compensate for GPU 3D terrain exaggeration factor: + // MapLibre's queryTerrainElevation returns the height sampled from the GPU 3D mesh (measured = true_elevation * exaggeration). + // We divide by active exaggeration factor to obtain the true geographic AMSL elevation matching contour lines. + const terrainObj = (map as any).getTerrain(); + const activeExag = (terrainObj && typeof terrainObj.exaggeration === 'number') + ? terrainObj.exaggeration + : (terrainExaggerationRef.current || 1.0); + + elev = Math.round(measured / (activeExag > 0 ? activeExag : 1.0)); + } + } + + setInspectedLocation({ + lng: Number(e.lngLat.lng.toFixed(4)), + lat: Number(e.lngLat.lat.toFixed(4)), + elevation: elev, + terrainCategory: elev < 0 ? 'منخفض وادي الأردن الغوري' : elev > 1000 ? 'قمة جبلية شاهقة' : 'هضبة متموجة' + }); + }); + + mapRef.current = map; + + return () => { + markersRef.current.forEach((m) => m.remove()); + map.remove(); + }; + }, []); + + // Sync BaseMapMode changes + useEffect(() => { + const map = mapRef.current; + if (map && map.isStyleLoaded()) { + applyBaseMapMode(map, baseMapMode); + } + }, [baseMapMode, applyBaseMapMode]); + + // Sync Hillshade & Sun Azimuth + useEffect(() => { + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + if (map.getLayer('esri-multidirectional-hillshade')) { + map.setPaintProperty('esri-multidirectional-hillshade', 'raster-opacity', esriHillshadeOpacity); + } + + if (map.getLayer('terrain-3d-hillshading')) { + map.setPaintProperty('terrain-3d-hillshading', 'hillshade-illumination-direction', sunAzimuth); + } + }, [esriHillshadeOpacity, sunAzimuth]); + + // Sync 3D Exaggeration + useEffect(() => { + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + try { + map.setTerrain({ + source: 'terrain-dem', + exaggeration: terrain3DExaggeration + }); + } catch (e) { + console.warn(e); + } + }, [terrain3DExaggeration]); + + // Sync Dynamic Contours (triggers instant redraw on interval or toggle change) + useEffect(() => { + updateContourLayer(contourInterval, enableDynamicContours); + }, [contourInterval, enableDynamicContours, updateContourLayer]); + + // ── MOBILITY CORRIDORS RENDERER ─────────────────────────────────────────────────── + + const renderTacticalCorridors = (map: maplibregl.Map) => { + const corridorFeatures = JORDAN_TACTICAL_CORRIDORS.map((c) => ({ + type: 'Feature' as const, + properties: { + id: c.id, + name: c.nameAr, + doctrine: c.doctrine, + passability: c.passability, + color: c.doctrine === 'western' ? '#3b82f6' : '#ef4444' + }, + geometry: { + type: 'LineString' as const, + coordinates: c.pathCoords + } + })); + + const corridorsGeoJson = { + type: 'FeatureCollection' as const, + features: corridorFeatures + }; + + if (map.getSource('mobilityCorridors')) { + (map.getSource('mobilityCorridors') as maplibregl.GeoJSONSource).setData(corridorsGeoJson); + } else { + map.addSource('mobilityCorridors', { + type: 'geojson', + data: corridorsGeoJson + }); + + map.addLayer({ + id: 'mobilityCorridorGlow', + type: 'line', + source: 'mobilityCorridors', + paint: { + 'line-color': ['get', 'color'], + 'line-width': 14, + 'line-opacity': 0.35, + 'line-blur': 4 + } + }); + + map.addLayer({ + id: 'mobilityCorridorLine', + type: 'line', + source: 'mobilityCorridors', + paint: { + 'line-color': ['get', 'color'], + 'line-width': 4.5, + 'line-dasharray': [4, 2] + } + }); + } + }; + + // ── MILITARY SYMBOLS RENDERER ──────────────────────────────────────────────────── + + const updateMilitaryUnitMarkers = (map: maplibregl.Map) => { + markersRef.current.forEach((m) => m.remove()); + markersRef.current = []; + + units.forEach((unit) => { + const isWestern = unit.doctrine === 'western'; + const isArmor = unit.category === 'armor'; + + const el = document.createElement('div'); + el.className = 'tactical-mil-marker'; + el.style.cursor = 'pointer'; + el.style.display = 'flex'; + el.style.flexDirection = 'column'; + el.style.alignItems = 'center'; + + const svgSymbol = isWestern + ? ` + + || + + ${ + isArmor + ? '' + : '' + } + + ` + : ` + + ||| + + + + `; + + el.innerHTML = ` + ${svgSymbol} +
+ ${unit.name.split('(')[0]} +
+ `; + + const marker = new maplibregl.Marker({ + element: el, + anchor: 'center' + }) + .setLngLat(unit.currentCoords) + .addTo(map); + + markersRef.current.push(marker); + }); + }; + + const flyToSite = (site: typeof JORDAN_3D_SITES[0]) => { + if (!mapRef.current) return; + mapRef.current.flyTo({ + center: site.coords, + zoom: site.zoom, + pitch: site.pitch, + bearing: site.bearing, + duration: 2800, + essential: true + }); + }; + + return ( +
+ {/* ── TOP HUD CONTROLLER ───────────────────────────────────────────────────────── */} +
+ {/* Title */} +
+
+ +
+
+
+ + خريطة التضاريس ثلاثية الأبعاد السيادية ومختبر الارتفاعات + + + أقمار صناعية 3D + تضاريس مجسمة + كنتور فوري + +
+
+ معالجة حواف الجبال والأودية بانحناءات 3D حقيقية، بدون انقطاع عند التقريب الشديد +
+
+
+ + {/* Master Base Map Mode Segmented Controller */} +
+ + + + + +
+
+ + {/* ── WORKSPACE (MAP + SIDEBAR) ───────────────────────────────────────────────── */} +
+ {/* Map Canvas */} +
+ {/* Live In-Map Elevation HUD Card */} + {inspectedLocation && ( +
+
+ + قياس ارتفاع التضاريس الميداني + + + شبكة DEM 3D حية + +
+
+ {inspectedLocation.elevation} م {inspectedLocation.elevation < 0 ? '(تحت سطح البحر)' : '(فوق سطح البحر)'} +
+
+ طبيعة الأرض: {inspectedLocation.terrainCategory} +
+
+ {inspectedLocation.lat}°N, {inspectedLocation.lng}°E +
+
+ )} +
+ + {/* ── SIDEBAR CONTROLLER ────────────────────────────────────────────────────── */} +
+ {/* Quick 3D Panoramic Fly-to Sites */} +
+ +
+ {JORDAN_3D_SITES.map((site) => ( +
flyToSite(site)} + style={{ + padding: '8px 10px', + borderRadius: 6, + background: 'rgba(15, 23, 42, 0.6)', + border: '1px solid rgba(255, 255, 255, 0.08)', + cursor: 'pointer' + }} + > +
+ + {site.nameAr} + + + {site.elevation} م + +
+
+ {site.desc} +
+
+ ))} +
+
+ + {/* Esri Multi-Directional Hillshade Blending Controller */} +
+
+ إضاءة التضاريس المجسمة (Esri Multi-Directional Hillshade) +
+
+ تم ضبط حد التكبير (MaxZoom: 16) لمنع ظهور رسالة عدم توفر البيانات نهائياً عند التقريب الشديد. +
+ + {/* Opacity Slider */} +
+
+ شفافية طبقة الظلال والإضاءة (في نمط المتجهات): + {Math.round(esriHillshadeOpacity * 100)}% +
+ setEsriHillshadeOpacity(Number(e.target.value))} + style={{ width: '100%', cursor: 'pointer' }} + /> +
+ + {/* Sun Azimuth Rotation */} +
+
+ زاوية تحريك الظلال والإضاءة (Sun Azimuth): + {sunAzimuth}° +
+ setSunAzimuth(Number(e.target.value))} + style={{ width: '100%', cursor: 'pointer' }} + /> +
+
+ + {/* 3D Terrain GPU Elevation Mesh Exaggeration */} +
+
+ تجسيم شبكة الارتفاع ثلاثية الأبعاد (GPU Elevation Mesh) +
+
+ معامل تضخيم المرتفعات والأودية (Exaggeration): + {terrain3DExaggeration.toFixed(1)}x +
+ setTerrain3DExaggeration(Number(e.target.value))} + style={{ width: '100%', cursor: 'pointer' }} + /> +
+ + {/* Dynamic Vector Contours Controller (NOW WITH LIVE INSTANT UPDATE) */} +
+
+ + خطوط الكنتور المتجهة الفورية (Dynamic Contours) + + setEnableDynamicContours(e.target.checked)} + style={{ cursor: 'pointer' }} + /> +
+ +
+ يتم حسابها ورسمها فورياً من بلاطات الارتفاع في المتصفح. تغيير الفاصل أدناه يعيد رسم الشبكة بالكامل فوراً: +
+ + {enableDynamicContours && ( +
+
+ الفاصل الكنتوري النشط حالياً: + {contourInterval} متر +
+
+ {[10, 20, 50, 100].map((interval) => ( + + ))} +
+
+ • نسق طبوغرافي عسكري: خطوط رئيسية بنية داكنة واضحة + خطوط فرعية بنية بشفافية خفيفة لمنع التضارب مع مجاري المياه والستلايت. +
+
+ )} +
+ + {/* Technical Note on Military Elevation Accuracy */} +
+
+ 💡 ملحوظة تقنية حول جودة نماذج الارتفاع: +
+ في العرض البصري ثنائي الأبعاد، تبدو جميع نماذج 30م متشابهة لأن حجم البكسل واحد. + أما في **الارتفاع الرأسي ثلاثي الأبعاد وحسابات المدفعية والرادار**، فإن نموذج كوبرنيكوس GLO-30 يتفوق بدقة رأسية 4 أمتار مقارنة بـ 16 متراً في SRTM القديم، مما يمنع أخطاء التوجيه خلف سواتر التلال. +
+
+
+
+ ); +}; diff --git a/docker-compose.yml b/docker-compose.yml index e9d82ae..3defc38 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: db: image: postgis/postgis:15-3.3 container_name: map-db + restart: unless-stopped platform: linux/amd64 # Ensure compatibility on Mac Silicon environment: POSTGRES_USER: ${POSTGRES_USER} @@ -25,6 +26,7 @@ services: redis: image: redis:7-alpine container_name: map-redis + restart: unless-stopped platform: linux/amd64 ports: - "6381:6379" @@ -42,18 +44,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 +95,7 @@ services: context: . dockerfile: ./infrastructure/docker/api/Dockerfile container_name: map-api + restart: unless-stopped platform: linux/amd64 ports: - "3200:3200" @@ -132,6 +135,7 @@ services: martin: image: maplibre/martin:latest container_name: map-martin + restart: unless-stopped platform: linux/amd64 ports: - "3202:3000" @@ -181,6 +185,7 @@ services: context: . dockerfile: ./infrastructure/docker/web/Dockerfile container_name: map-web + restart: unless-stopped platform: linux/amd64 ports: - "3201:5173" @@ -201,6 +206,7 @@ services: context: . dockerfile: ./infrastructure/docker/dashboard/Dockerfile container_name: map-dashboard + restart: unless-stopped platform: linux/amd64 ports: - "3204:80" diff --git a/infrastructure/scripts/backup_sovereign_map.sh b/infrastructure/scripts/backup_sovereign_map.sh new file mode 100755 index 0000000..92d571a --- /dev/null +++ b/infrastructure/scripts/backup_sovereign_map.sh @@ -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 < "$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@:/home/hamzadoctor/backups/${NC}" +echo -e "${GREEN}==================================================================${NC}" diff --git a/infrastructure/scripts/restore_sovereign_map.sh b/infrastructure/scripts/restore_sovereign_map.sh new file mode 100755 index 0000000..391172a --- /dev/null +++ b/infrastructure/scripts/restore_sovereign_map.sh @@ -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 " + 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 "------------------------------------------------------------------" diff --git a/infrastructure/scripts/seed_jordan_dem.py b/infrastructure/scripts/seed_jordan_dem.py new file mode 100644 index 0000000..8b837cc --- /dev/null +++ b/infrastructure/scripts/seed_jordan_dem.py @@ -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() diff --git a/infrastructure/scripts/seed_jordan_satellite.py b/infrastructure/scripts/seed_jordan_satellite.py new file mode 100644 index 0000000..4873551 --- /dev/null +++ b/infrastructure/scripts/seed_jordan_satellite.py @@ -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() diff --git a/sync_to_server.sh b/sync_to_server.sh index e0e22a1..21112b2 100755 --- a/sync_to_server.sh +++ b/sync_to_server.sh @@ -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 \