Compare commits

..
19 changed files with 5564 additions and 527 deletions
+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
@@ -11,7 +11,7 @@ export class AdminGuard implements CanActivate {
throw new UnauthorizedException('Tenant not found in request');
}
if (tenant.plan !== TenantPlan.ENTERPRISE && tenant.role !== TenantRole.ADMIN) {
if (tenant.plan !== TenantPlan.ENTERPRISE && tenant.role !== 'ADMIN') {
throw new ForbiddenException('Admin access required. Your tenant must have an ENTERPRISE plan or ADMIN clearance.');
}
@@ -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"
},
+3 -3
View File
@@ -198,7 +198,7 @@ const MapComponent: React.FC<MapProps> = ({
id: 'los-line',
type: 'line',
source: 'los-source',
filter: ['==', '$type', 'LineString'],
filter: ['==', ['geometry-type'], 'LineString'],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: {
'line-color': '#f59e0b',
@@ -211,7 +211,7 @@ const MapComponent: React.FC<MapProps> = ({
id: 'los-points',
type: 'circle',
source: 'los-source',
filter: ['==', '$type', 'Point'],
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': 8,
'circle-color': [
@@ -230,7 +230,7 @@ const MapComponent: React.FC<MapProps> = ({
id: 'los-labels',
type: 'symbol',
source: 'los-source',
filter: ['==', '$type', 'Point'],
filter: ['==', ['geometry-type'], 'Point'],
layout: {
'text-field': ['get', 'label'],
'text-size': 12,
+11 -2
View File
@@ -1,11 +1,12 @@
import React, { useEffect, useState } from 'react'
import ReactDOM from 'react-dom/client'
import maplibregl from 'maplibre-gl'
import maplibregl from './utils/maplibreWorker'
import App from './App.tsx'
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
+510 -26
View File
@@ -167,9 +167,10 @@ export async function sampleElevationsBatch(
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2500);
const res = await fetch(`${apiUrl}/tactical/elevations`, {
const apiKey = localStorage.getItem('map_admin_key') || localStorage.getItem('intaleq_api_key') || (import.meta as any).env.VITE_ADMIN_API_KEY || (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
const res = await fetch(`${apiUrl}/tactical/elevations?key=${encodeURIComponent(apiKey)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
body: JSON.stringify({ coordinates: coords }),
signal: controller.signal
});
@@ -303,13 +304,13 @@ export async function calculateLineOfSight(
// Try fetching high-precision result from Backend Tactical API
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const apiKey = (import.meta as any).env.VITE_API_KEY;
const apiKey = (import.meta as any).env.VITE_ADMIN_API_KEY || (import.meta as any).env.VITE_API_KEY || localStorage.getItem('map_admin_key') || localStorage.getItem('intaleq_api_key') || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const res = await fetch(`${apiUrl}/tactical/line-of-sight?observerLat=${startLat}&observerLng=${startLng}&targetLat=${endLat}&targetLng=${endLng}&observerHeight=${obsHeight}&targetHeight=${tgtHeight}&samples=${samples}`, {
headers: apiKey ? { 'x-api-key': apiKey } : {},
const res = await fetch(`${apiUrl}/tactical/line-of-sight?observerLat=${startLat}&observerLng=${startLng}&targetLat=${endLat}&targetLng=${endLng}&observerHeight=${obsHeight}&targetHeight=${tgtHeight}&samples=${samples}&key=${encodeURIComponent(apiKey)}`, {
headers: { 'x-api-key': apiKey },
signal: controller.signal
});
clearTimeout(timeoutId);
@@ -469,6 +470,7 @@ export interface Viewshed360Result {
centerElevation: number;
radiusMeters: number;
polygonGeoJson: any;
invisiblePolygonGeoJson?: any;
totalRays: number;
visibleAreaKm2: number;
visiblePercentage: number;
@@ -490,7 +492,9 @@ export async function calculateRadialViewshed(
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3500);
const res = await fetch(`${apiUrl}/tactical/viewshed?lat=${centerLat}&lng=${centerLng}&height=${obsHeight}&radius=${radiusMeters}&rays=${numRays}`, {
const apiKey = (import.meta as any).env.VITE_ADMIN_API_KEY || (import.meta as any).env.VITE_API_KEY || localStorage.getItem('map_admin_key') || localStorage.getItem('intaleq_api_key') || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
const res = await fetch(`${apiUrl}/tactical/viewshed?lat=${centerLat}&lng=${centerLng}&height=${obsHeight}&radius=${radiusMeters}&rays=${numRays}&key=${encodeURIComponent(apiKey)}`, {
headers: { 'x-api-key': apiKey },
signal: controller.signal
});
clearTimeout(timeoutId);
@@ -500,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
@@ -518,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++) {
@@ -543,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;
@@ -561,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 = {
@@ -580,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,
@@ -599,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;
@@ -1928,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
};
}
+10
View File
@@ -0,0 +1,10 @@
import maplibregl from 'maplibre-gl';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-csp-worker.js?url';
// MapLibre 5 serializes its embedded worker from functions. Vite's class-field
// transform injects __publicField outside those functions, leaving GeoJSON
// workers with an undefined helper. Serve the matching standalone worker as an
// untransformed asset in both development and production, before any map exists.
maplibregl.setWorkerUrl(workerUrl);
export default maplibregl;
+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"
+48
View File
@@ -0,0 +1,48 @@
# GeoJSON overlay rendering regression
The tactical calculations return geometry, but MapLibre's GeoJSON worker fails
with `__publicField is not defined`. HTML markers and vector basemap tiles still
render, so the UI appears functional while all GeoJSON overlays remain invisible.
## Cause
Commit `0bf1382` changed Vite 8.0.1 to 6.4.3 (and the React plugin to 4.7.0).
The current optimized MapLibre 5.20.2 bundle imports `__publicField` outside the
functions that MapLibre serializes into its embedded worker. The GeoJSON indexing
code references that helper inside the worker, where it does not exist.
Changing geometry filters, opacity, layer order, or camera bounds cannot repair
this worker failure.
## Fix
`src/utils/maplibreWorker.ts` loads the installed MapLibre 5 standalone CSP worker
using Vite's asset URL import and calls `setWorkerUrl` before React creates maps.
`src/main.tsx` imports this configured MapLibre instance. Other components share
the same underlying MapLibre module. No API or geometry calculation change is
required.
The `?url` import is intentional for this self-contained MapLibre 5 worker: it
preserves the vendor file without transpilation. Revisit the worker entry point
if upgrading MapLibre to a new major version.
## Verification (2026-09-24)
- Reproduced the missing overlays on the official tactical site, and confirmed
its Isochrone API returned three valid Polygon features.
- A minimal map with an empty base style and a fixed line/polygon reproduced the
same failure: zero source/rendered features and `__publicField is not defined`.
- With the standalone worker, that same map visibly drew both geometries, with
eight tile-level source/rendered feature entries and no worker error.
- Tested the modified tactical UI locally, proxying API requests to the official
service: terrain overlays, the 10.9 km² / 14% viewshed, and colored Isochrone
regions rendered. Temporary diagnostic geometry was removed afterward.
- `npm run build` succeeds. The emitted standalone worker is byte-identical to
the installed vendor worker.
## Deployment
Deploy `apps/web/src/main.tsx` and `apps/web/src/utils/maplibreWorker.ts` together.
The observed official site serves Vite development modules; restart its web
service and reload the browser after deploying. For a production build, deploy
the complete `dist` output, including the emitted worker asset. The live site
has not been changed by this local fix.
+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()
+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
}
"
+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 \