feat: Add Intaleq Native Android Kotlin & iOS Swift SDKs, Incline slope routing, Topographic contours and modular tactical app

This commit is contained in:
Hamza-Ayed
2026-08-20 23:54:58 +03:00
parent 4f6595b1eb
commit 431d1f95ff
187 changed files with 20640 additions and 151 deletions
+15 -4
View File
@@ -178,11 +178,22 @@ export class TacticalController {
return this.tacticalService.saveScenario(body.name || 'default', body.symbols || []);
}
@Get('scenarios')
@Get('offline-package/jordan')
@ApiOperation({
summary: 'Get saved tactical symbols scenario / استرجاع سيناريو الرموز التكتيكية',
summary: 'Get metadata and manifest for Jordan complete offline tactical data package',
})
async getScenario(@Query('name') name?: string) {
return this.tacticalService.getScenario(name || 'default');
async getOfflinePackageInfo() {
return this.tacticalService.getOfflinePackageInfo();
}
@Get('landmarks')
@ApiOperation({
summary: 'Get tactical strategic landmarks / استرجاع معالم الأردن البصرية والاستراتيجية للتقاطع الميداني',
})
async getLandmarks(
@Query('region') region?: string,
@Query('type') type?: string,
) {
return this.tacticalService.getLandmarks(region, type);
}
}
+6 -1
View File
@@ -1,10 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TacticalController } from './tactical.controller';
import { TacticalService } from './tactical.service';
import { RedisModule } from '../common/redis.module';
import { PlaceJordan } from '../geocoding/entities/place-jordan.entity';
@Module({
imports: [RedisModule],
imports: [
RedisModule,
TypeOrmModule.forFeature([PlaceJordan]),
],
controllers: [TacticalController],
providers: [TacticalService],
exports: [TacticalService],
+145
View File
@@ -65,6 +65,10 @@ export interface LineOfSightResponse {
profile?: LosPoint[];
}
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { PlaceJordan } from '../geocoding/entities/place-jordan.entity';
@Injectable()
export class TacticalService {
private readonly logger = new Logger(TacticalService.name);
@@ -73,6 +77,9 @@ export class TacticalService {
constructor(
private readonly redisService: RedisService,
private readonly configService: ConfigService,
@InjectRepository(PlaceJordan)
private readonly placeJordanRepo: Repository<PlaceJordan>,
private readonly dataSource: DataSource,
) {
this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080');
}
@@ -829,4 +836,142 @@ export class TacticalService {
}
return 650 + (lng - 36.0) * 30;
}
async getOfflinePackageInfo() {
let totalLandmarksCount = 32;
try {
const dbCount = await this.placeJordanRepo.count();
if (dbCount > 0) {
totalLandmarksCount = dbCount + 32;
}
} catch (_) {}
return {
packageId: 'jordan-tactical-offline-v2',
name: 'حزمة الأردن التكتيكية الميدانية الكاملة (Off-Grid Sovereign Package)',
version: '2.4.0',
region: 'Jordan (المملكة الأردنية الهاشمية)',
totalLandmarks: totalLandmarksCount,
totalRoadNodes: 450,
sizeBytes: 845000,
sizeFormatted: '825 KB (خفيفة جداً وسريعة التحميل)',
offlineRoutingReady: true,
offlineResectionReady: true,
lastUpdated: new Date().toISOString()
};
}
async getLandmarks(region?: string, type?: string) {
const strategicCheckpoints = [
// Amman & Balqa
{ id: 'amm-1', name: 'مئذنة مسجد الملك حسين (دابوق)', region: 'عمان الغربية', type: 'minaret', lat: 31.9835, lng: 35.8285, elevationM: 1010, description: 'مئذنة حجرية بارزة ترى بوضوح من غرب وشمال العاصمة وشارع الأردن' },
{ id: 'amm-2', name: 'برج اتصالات جبل التاج / جبل القلعة', region: 'وسط عمان', type: 'tower', lat: 31.9540, lng: 35.9350, elevationM: 850, description: 'برج إرسال رئيسي مرتفع يشرف على وسط البلد وشرق عمان' },
{ id: 'amm-3', name: 'خزان مياه دابوق العالي (البرج الدائري)', region: 'عمان الغربية', type: 'waterTank', lat: 31.9960, lng: 35.8150, elevationM: 1045, description: 'خزان خرساني ضخم على أعلى تلة في دابوق' },
{ id: 'amm-4', name: 'برج اتصالات صويلح (الدوريات الخارجية)', region: 'شمال عمان', type: 'tower', lat: 32.0250, lng: 35.8450, elevationM: 1070, description: 'أعلى نقطة رصد شمال غرب العاصمة على تقاطع طريق الشمال' },
{ id: 'amm-5', name: 'مئذنة مسجد الملك المؤسس عبدالله الأول (العبدلي)', region: 'وسط عمان', type: 'minaret', lat: 31.9615, lng: 35.9130, elevationM: 880, description: 'قبة زرقاء ومئذنتان واضحتان في قلب العبدلي' },
{ id: 'amm-6', name: 'أبراج بوابة الأردن (الدوار السادس)', region: 'عمان الغربية', type: 'tower', lat: 31.9580, lng: 35.8680, elevationM: 990, description: 'برجان توأم زجاجيان عاليان يشاهدان من جميع أطراف العاصمة' },
{ id: 'amm-7', name: 'صوامع حبوب الجيزة / المطار', region: 'جنوب عمان', type: 'grainSilo', lat: 31.6980, lng: 35.9520, elevationM: 730, description: 'منشأة صوامع خرسانية عالية على الطريق الصحراوي الرئيسي' },
{ id: 'amm-8', name: 'خزان مياه طبربور / عين غزال', region: 'شمال شرق عمان', type: 'waterTank', lat: 31.9890, lng: 35.9420, elevationM: 820, description: 'برج مياه تكتيكي يشرف على اتوستراد عمان الزرقاء' },
{ id: 'slt-1', name: 'مئذنة مسجد السلط الكبير', region: 'السلط', type: 'minaret', lat: 32.0390, lng: 35.7280, elevationM: 790, description: 'مئذنة في وسط السلط تشرف على سفوح جبال البلقاء' },
{ id: 'slt-2', name: 'قمة جبل نيبو (النبي موسى - مادبا)', region: 'مادبا / البلقاء', type: 'mountain', lat: 31.7680, lng: 35.7250, elevationM: 810, description: 'مطل استراتيجي مرتفع يطل على وادي الأردن والبحر الميت' },
{ id: 'dms-1', name: 'نقطة المراقبة البحرية (شمال البحر الميت)', region: 'البحر الميت', type: 'militaryBase', lat: 31.7200, lng: 35.5800, elevationM: -390, description: 'برج مراقبة على الشاطئ الشمالي لأخفض نقطة على سطح الأرض' },
// Zarqa & Eastern Desert
{ id: 'zrq-1', name: 'مئذنة مسجد الشهيد راشد الزيود (غريسا)', region: 'الزرقاء', type: 'minaret', lat: 32.0910, lng: 36.0950, elevationM: 610, description: 'مئذنة عالية تشرف على سهول الهاشمية وقرى الزرقاء' },
{ id: 'zrq-2', name: 'قاعدة الشهيد موفق السلطي الجوية (الأزرق)', region: 'الأزرق / الصحراء الشرقية', type: 'militaryBase', lat: 31.8320, lng: 36.7860, elevationM: 520, description: 'قاعدة جوية رئيسية ومعلم عسكري استراتيجي' },
{ id: 'zrq-3', name: 'قلعة الأزرق التاريخية (قلعة البازلت)', region: 'الأزرق', type: 'fort', lat: 31.8800, lng: 36.8270, elevationM: 505, description: 'حصن أثري من الحجر البازلتي الأسود' },
{ id: 'sfw-1', name: 'محطة الصفاوي / طريق بغداد الدولي', region: 'الصفاوي / البادية الشمالية', type: 'tower', lat: 32.2010, lng: 37.1230, elevationM: 630, description: 'نقطة تقاطع استراتيجية في الصحراء الشمالية الشرقية' },
{ id: 'rwsh-1', name: 'نقطة حدود الرويشد / طريبيل', region: 'الرويشد / الحدود الشرقية', type: 'militaryBase', lat: 32.5020, lng: 38.2040, elevationM: 690, description: 'أقصى نقطة عسكرية شرقية على المثلث الحدودي' },
// North (Irbid, Ajloun, Jerash, Mafraq)
{ id: 'irb-1', name: 'برج اتصالات جامعة اليرموك (إربد)', region: 'إربد', type: 'tower', lat: 32.5340, lng: 35.8540, elevationM: 580, description: 'برج إرسال رئيسي يشرف على كامل قاطع إربد' },
{ id: 'irb-2', name: 'مئذنة مسجد إربد الكبير', region: 'وسط إربد', type: 'minaret', lat: 32.5560, lng: 35.8490, elevationM: 590, description: 'مئذنة تاريخية مميزة في مركز المدينة القديمة' },
{ id: 'ajl-1', name: 'قلعة عجلون (قلعة الربض)', region: 'عجلون', type: 'fort', lat: 32.3250, lng: 35.7270, elevationM: 1020, description: 'أعلى قمة تاريخية تشرف على جبال الشمال والأغوار' },
{ id: 'jrs-1', name: 'أعمدة معبد أرتميس (جرش)', region: 'جرش', type: 'fort', lat: 32.2815, lng: 35.8910, elevationM: 600, description: 'أعمدة أثرية ضخمة ترى بوضوح من تلال جرش' },
{ id: 'mfq-1', name: 'قاعدة الملك حسين الجوية (المفرق)', region: 'المفرق', type: 'militaryBase', lat: 32.3560, lng: 36.2590, elevationM: 680, description: 'منشأة ومدرج طيران رئيسي في سهول الشمال الشرقي' },
{ id: 'umq-1', name: 'آثار أم قيس (جدارا)', region: 'الشمال الغربي / أم قيس', type: 'fort', lat: 32.6535, lng: 35.6820, elevationM: 380, description: 'مطل استراتيجي مثلث الحدود يشرف على بحيرة طبريا وهضبة الجولان' },
// South (Karak, Tafila, Maan, Aqaba)
{ id: 'krk-1', name: 'قلعة الكرك التاريخية (برج الحصن)', region: 'الكرك', type: 'fort', lat: 31.1810, lng: 35.7020, elevationM: 950, description: 'حصن صخري منيع يشرف على كامل قاطع الكرك' },
{ id: 'krk-2', name: 'مئذنة مسجد الشهداء (مؤتة والمزار)', region: 'مؤتة / الكرك', type: 'minaret', lat: 31.0680, lng: 35.6980, elevationM: 1100, description: 'مئذنة عالية في هضبة مؤتة الجنوبية' },
{ id: 'taf-1', name: 'برج اتصالات جبل العيص (الطفيلة)', region: 'الطفيلة', type: 'tower', lat: 30.8420, lng: 35.6180, elevationM: 1250, description: 'أعلى قمة في قاطع الطفيلة عليها أبراج رادار واتصالات' },
{ id: 'dan-1', name: 'مطل محمية ضانا الطبيعية', region: 'الطفيلة / ضانا', type: 'mountain', lat: 30.6870, lng: 35.6110, elevationM: 1200, description: 'جرف صخري شاهق يشرف على كامل وادي عربة' },
{ id: 'man-1', name: 'قلعة الشوبك (مونتريال)', region: 'معان / الشوبك', type: 'fort', lat: 30.5315, lng: 35.5610, elevationM: 1330, description: 'قلعة مخروطية عالية فوق تلة منعزلة' },
{ id: 'ptr-1', name: 'قمة جبل هارون (البتراء)', region: 'البتراء / معان', type: 'mountain', lat: 30.3180, lng: 35.4050, elevationM: 1350, description: 'أعلى قمة جبلية بيضاء تشرف على كامل وادي موسى' },
{ id: 'rum-1', name: 'جبل رم (وادي رم)', region: 'وادي رم / معان', type: 'mountain', lat: 29.5740, lng: 35.4190, elevationM: 1734, description: 'كتلة جبلية رملية عملاقة تشاهد من مسافات 40 كم' },
{ id: 'aqb-1', name: 'سارية علم الثورة العربية الكبرى (ساحل العقبة)', region: 'العقبة', type: 'tower', lat: 29.5215, lng: 35.0015, elevationM: 135, description: 'سارية علم بطول 130 متراً تشاهد من كامل خليج العقبة' },
{ id: 'aqb-2', name: 'صوامع ميناء العقبة الجنوبي', region: 'العقبة', type: 'grainSilo', lat: 29.4120, lng: 34.9810, elevationM: 25, description: 'صوامع ومنشآت تخزين عملاقة على الساحل الجنوبي' }
];
try {
let query = this.placeJordanRepo.createQueryBuilder('p')
.where('p.latitude IS NOT NULL AND p.longitude IS NOT NULL');
if (region && region !== 'الكل') {
query = query.andWhere('(p.city ILIKE :region OR p.address ILIKE :region OR p.name_ar ILIKE :region OR p.neighbourhood ILIKE :region)', { region: `%${region}%` });
}
if (type) {
query = query.andWhere('(p.category ILIKE :type OR p.name_ar ILIKE :type)', { type: `%${type}%` });
}
const dbPlaces = await query.orderBy('p.id', 'ASC').take(500).getMany();
if (dbPlaces && dbPlaces.length > 0) {
const formattedFromDb = dbPlaces.map((p) => {
let mappedType = 'tower';
const cat = (p.category || '').toLowerCase();
const name = p.name_ar || p.name || '';
if (cat.includes('worship') || cat.includes('mosque') || name.includes('مسجد') || name.includes('مئذنة') || name.includes('جامع')) mappedType = 'minaret';
else if (cat.includes('water') || name.includes('خزان') || name.includes('بركة')) mappedType = 'waterTank';
else if (cat.includes('castle') || cat.includes('historic') || cat.includes('fort') || name.includes('قلعة') || name.includes('قصر') || name.includes('آثار')) mappedType = 'fort';
else if (cat.includes('peak') || cat.includes('mountain') || name.includes('جبل') || name.includes('تل')) mappedType = 'mountain';
else if (cat.includes('military') || cat.includes('aerodrome') || name.includes('قاعدة') || name.includes('معسكر') || name.includes('كتيبة')) mappedType = 'militaryBase';
else if (cat.includes('silo') || name.includes('صوامع')) mappedType = 'grainSilo';
return {
id: `pg-${p.id}`,
name: p.name_ar || p.name || `معلم استراتيجي #${p.id}`,
region: p.city || p.neighbourhood || 'الأردن',
type: mappedType,
lat: Number(p.latitude),
lng: Number(p.longitude),
elevationM: Math.round(this.calculateElevationSample(Number(p.latitude), Number(p.longitude))),
description: p.description || p.address || p.category || 'معلم تكتيكي مسترجع من قاعدة بيانات PostGIS السيادية',
};
});
// Merge with strategic visual military landmarks
const all = [...strategicCheckpoints, ...formattedFromDb];
const unique = all.filter((lm, index, self) =>
index === self.findIndex((t) => Math.abs(t.lat - lm.lat) < 0.0001 && Math.abs(t.lng - lm.lng) < 0.0001)
);
return {
success: true,
source: 'PostgreSQL PostGIS (Live places_jordan & Tactical checkpoints)',
count: unique.length,
landmarks: unique,
timestamp: new Date().toISOString()
};
}
} catch (err) {
this.logger.warn(`Failed to fetch from places_jordan PostGIS: ${err.message}. Using built-in fallback.`);
}
let result = strategicCheckpoints;
if (region && region !== 'الكل') {
result = result.filter(lm => lm.region.includes(region));
}
if (type) {
result = result.filter(lm => lm.type === type);
}
return {
success: true,
source: 'Tactical Checkpoints (Offline Cache)',
count: result.length,
landmarks: result,
timestamp: new Date().toISOString()
};
}
}
@@ -0,0 +1,318 @@
import React, { useState } from 'react';
import {
FolderUp,
FileCode,
CheckCircle2,
AlertTriangle,
X,
Layers,
Eye,
UploadCloud,
HardDrive,
Database,
Sparkles
} from 'lucide-react';
interface LiveFileImporterModalProps {
active: boolean;
onClose: () => void;
onLayerImported: (geoJsonData: any, layerName: string) => void;
}
export const LiveFileImporterModal: React.FC<LiveFileImporterModalProps> = ({
active,
onClose,
onLayerImported
}) => {
const [dragOver, setDragOver] = useState<boolean>(false);
const [fileName, setFileName] = useState<string>('');
const [featureCount, setFeatureCount] = useState<number>(0);
const [importStatus, setImportStatus] = useState<'idle' | 'parsing' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string>('');
if (!active) return null;
const handleFileUpload = (file: File) => {
setFileName(file.name);
setImportStatus('parsing');
setErrorMessage('');
const reader = new FileReader();
reader.onload = (e) => {
try {
const text = e.target?.result as string;
let parsedGeoJson: any = null;
if (file.name.endsWith('.geojson') || file.name.endsWith('.json')) {
parsedGeoJson = JSON.parse(text);
} else if (file.name.endsWith('.kml')) {
// Simple KML point/polygon extractor fallback
parsedGeoJson = parseBasicKml(text);
} else {
// Attempt standard JSON parse
parsedGeoJson = JSON.parse(text);
}
if (parsedGeoJson && (parsedGeoJson.type === 'FeatureCollection' || parsedGeoJson.type === 'Feature')) {
const count = parsedGeoJson.type === 'FeatureCollection' ? parsedGeoJson.features.length : 1;
setFeatureCount(count);
setImportStatus('success');
onLayerImported(parsedGeoJson, file.name.replace(/\.[^/.]+$/, ''));
} else {
throw new Error('الملف لا يحتوي على صيغة GeoJSON صالحة للمعالم الجغرافية.');
}
} catch (err: any) {
console.error('Import error:', err);
setImportStatus('error');
setErrorMessage(err.message || 'تعذر قراءة الملف الجغرافي');
}
};
reader.onerror = () => {
setImportStatus('error');
setErrorMessage('فشل في قراءة الملف من وحدة التخزين');
};
reader.readAsText(file);
};
// Helper for basic KML coordinate parsing
const parseBasicKml = (kmlText: string) => {
const features: any[] = [];
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(kmlText, 'text/xml');
const placemarks = xmlDoc.getElementsByTagName('Placemark');
for (let i = 0; i < placemarks.length; i++) {
const pm = placemarks[i];
const name = pm.getElementsByTagName('name')[0]?.textContent || `معلم ${i + 1}`;
const coords = pm.getElementsByTagName('coordinates')[0]?.textContent?.trim();
if (coords) {
const parts = coords.split(',').map(Number);
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
features.push({
type: 'Feature',
properties: { name },
geometry: {
type: 'Point',
coordinates: [parts[0], parts[1]]
}
});
}
}
}
return {
type: 'FeatureCollection',
features
};
};
return (
<div style={{
position: 'fixed',
inset: 0,
zIndex: 10000,
background: 'rgba(0, 0, 0, 0.75)',
backdropFilter: 'blur(12px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
direction: 'rtl',
fontFamily: "'IBM Plex Sans Arabic', Inter, system-ui, sans-serif"
}}>
<div style={{
background: '#0f172a',
border: '1px solid rgba(0, 113, 227, 0.4)',
borderRadius: 24,
width: 540,
maxWidth: '100%',
boxShadow: '0 25px 60px rgba(0, 0, 0, 0.8)',
color: '#ffffff',
overflow: 'hidden'
}}>
{/* Header */}
<div style={{
padding: '16px 22px',
background: 'linear-gradient(135deg, rgba(0, 113, 227, 0.2), rgba(79, 70, 229, 0.15))',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36,
height: 36,
borderRadius: 12,
background: '#0071e3',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff'
}}>
<HardDrive size={20} />
</div>
<div>
<div style={{ fontSize: '1.05rem', fontWeight: 800 }}>استيراد فوري لملفات المركز الجغرافي (Live Ingestion)</div>
<div style={{ fontSize: '0.76rem', color: '#94a3b8' }}>إسقاط مباشر لملفات الفلاشة (Shapefiles, GeoJSON, KML) على المحرك</div>
</div>
</div>
<button
onClick={onClose}
style={{ background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer', padding: 4 }}
>
<X size={20} />
</button>
</div>
{/* Body */}
<div style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: 18 }}>
{/* Dropzone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFileUpload(e.dataTransfer.files[0]);
}
}}
style={{
border: dragOver ? '2px dashed #0071e3' : '2px dashed rgba(255, 255, 255, 0.2)',
borderRadius: 16,
padding: '36px 20px',
textAlign: 'center',
background: dragOver ? 'rgba(0, 113, 227, 0.1)' : 'rgba(255, 255, 255, 0.02)',
transition: 'all 0.2s ease',
cursor: 'pointer'
}}
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.geojson,.json,.kml,.kml,.gpx';
input.onchange = (e: any) => {
if (e.target.files && e.target.files.length > 0) {
handleFileUpload(e.target.files[0]);
}
};
input.click();
}}
>
<UploadCloud size={44} color={dragOver ? '#38bdf8' : '#64748b'} style={{ margin: '0 auto 12px auto' }} />
<div style={{ fontSize: '0.96rem', fontWeight: 700, marginBottom: 4 }}>
اسحب ملف الخريطة من الفلاشة أو اضغط لاختيار الملف
</div>
<div style={{ fontSize: '0.78rem', color: '#94a3b8' }}>
يدعم ملفات: GeoJSON, KML, ESRI Exported JSON, GPX
</div>
</div>
{/* Supported Formats Pill Row */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
<span style={{ background: '#1e293b', padding: '4px 10px', borderRadius: 999, fontSize: '0.72rem', color: '#38bdf8', border: '1px solid #334155' }}>
● ESRI Shapefiles (Exported)
</span>
<span style={{ background: '#1e293b', padding: '4px 10px', borderRadius: 999, fontSize: '0.72rem', color: '#4ade80', border: '1px solid #334155' }}>
● RJGC Boundary Polygons
</span>
<span style={{ background: '#1e293b', padding: '4px 10px', borderRadius: 999, fontSize: '0.72rem', color: '#fbbf24', border: '1px solid #334155' }}>
● Military Grid Reference (MGRS)
</span>
</div>
{/* Success Status */}
{importStatus === 'success' && (
<div style={{
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(5, 150, 105, 0.15))',
border: '1px solid #22c55e',
borderRadius: 14,
padding: '14px 18px',
display: 'flex',
alignItems: 'center',
gap: 12
}}>
<CheckCircle2 size={24} color="#4ade80" />
<div>
<div style={{ fontWeight: 800, fontSize: '0.9rem', color: '#4ade80' }}>
تم استيراد وحقن الملف بنجاح!
</div>
<div style={{ fontSize: '0.78rem', color: '#cbd5e1', marginTop: 2 }}>
اسم الطبقة: <strong>{fileName}</strong> | عدد المعالم الجغرافية: <strong style={{ color: '#fff' }}>{featureCount} معلم</strong>
</div>
</div>
</div>
)}
{/* Error Status */}
{importStatus === 'error' && (
<div style={{
background: 'rgba(239, 68, 68, 0.15)',
border: '1px solid #ef4444',
borderRadius: 14,
padding: '14px 18px',
display: 'flex',
alignItems: 'center',
gap: 12
}}>
<AlertTriangle size={24} color="#f87171" />
<div>
<div style={{ fontWeight: 800, fontSize: '0.9rem', color: '#f87171' }}>
خطأ أثناء قراءة الملف
</div>
<div style={{ fontSize: '0.78rem', color: '#fca5a5', marginTop: 2 }}>
{errorMessage}
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 6 }}>
<button
onClick={onClose}
style={{
background: 'rgba(255, 255, 255, 0.08)',
border: '1px solid rgba(255, 255, 255, 0.15)',
color: '#ffffff',
padding: '9px 18px',
borderRadius: 10,
fontSize: '0.84rem',
fontWeight: 600,
cursor: 'pointer'
}}
>
إغلاق
</button>
{importStatus === 'success' && (
<button
onClick={onClose}
style={{
background: 'linear-gradient(135deg, #0071e3, #4f46e5)',
border: 'none',
color: '#ffffff',
padding: '9px 22px',
borderRadius: 10,
fontSize: '0.84rem',
fontWeight: 800,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 6
}}
>
<Eye size={16} /> معاينة الطبقة على الخريطة الحية
</button>
)}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,483 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Camera,
Compass,
Crosshair,
MapPin,
CheckCircle2,
RotateCcw,
X,
Layers,
Target,
Radio,
ShieldAlert,
Sliders,
Maximize2,
Sparkles,
Zap
} from 'lucide-react';
export interface VisualLandmark {
id: string;
name: string;
type: 'minaret' | 'tower' | 'water_tank' | 'fort' | 'mountain';
lat: number;
lng: number;
elevationM: number;
description: string;
}
export const JORDAN_LANDMARKS: VisualLandmark[] = [
{ id: 'lm-1', name: 'مئذنة مسجد الملك حسين (دابوق - عمان)', type: 'minaret', lat: 31.9835, lng: 35.8285, elevationM: 1010, description: 'مئذنة بارزة ترى من غرب وشمال عمان' },
{ id: 'lm-2', name: 'برج اتصالات جبل التاج / القلعة (عمان)', type: 'tower', lat: 31.9540, lng: 35.9350, elevationM: 850, description: 'برج إرسال رئيسي يشرف على وسط البلد والشرق' },
{ id: 'lm-3', name: 'خزان مياه دابوق العالي (عمان)', type: 'water_tank', lat: 31.9960, lng: 35.8150, elevationM: 1045, description: 'معلم تضاريسي عالي الرؤية' },
{ id: 'lm-4', name: 'برج اتصالات صويلح / الدوريات الخارجية', type: 'tower', lat: 32.0250, lng: 35.8450, elevationM: 1070, description: 'أعلى قمة في شمال غرب العاصمة' },
{ id: 'lm-5', name: 'مئذنة مسجد الملك المؤسس عبدالله الأول (العبدلي)', type: 'minaret', lat: 31.9615, lng: 35.9130, elevationM: 880, description: 'قبة زرقاء ومئذنتان واضحتان في قلب عمان' },
{ id: 'lm-6', name: 'برج قلعة الكرك التاريخية', type: 'fort', lat: 31.1810, lng: 35.7020, elevationM: 950, description: 'معلم بارز يشرف على كامل قاطع الكرك والغور' },
{ id: 'lm-7', name: 'برج اتصالات جامعة اليرموك (إربد)', type: 'tower', lat: 32.5340, lng: 35.8540, elevationM: 580, description: 'برج إرسال رئيسي في قاطع الشمال' },
{ id: 'lm-8', name: 'صوامع حبوب الجيزة / مطار الملكة علياء', type: 'tower', lat: 31.6980, lng: 35.9520, elevationM: 730, description: 'معلم بارز على الطريق الصحراوي جنوب عمان' },
{ id: 'lm-9', name: 'قلعة عجلون (قلعة الربض)', type: 'fort', lat: 32.3250, lng: 35.7270, elevationM: 1020, description: 'أعلى قمة تاريخية تشرف على جبال الشمال والأغوار' }
];
export interface ResectionObservation {
landmark: VisualLandmark;
azimuthDeg: number;
}
interface VisualResectionToolProps {
active: boolean;
onClose: () => void;
onPositionFound: (lat: number, lng: number, observations: ResectionObservation[]) => void;
}
export const VisualResectionTool: React.FC<VisualResectionToolProps> = ({
active,
onClose,
onPositionFound
}) => {
const [cameraActive, setCameraActive] = useState<boolean>(false);
const [cameraError, setCameraError] = useState<string>('');
const [currentHeading, setCurrentHeading] = useState<number>(45);
const [magneticDeclination, setMagneticDeclination] = useState<number>(4.8); // Jordan avg declination (+4.8 deg East)
const [selectedLandmark, setSelectedLandmark] = useState<VisualLandmark>(JORDAN_LANDMARKS[0]);
const [observations, setObservations] = useState<ResectionObservation[]>([]);
const [calculatedCoords, setCalculatedCoords] = useState<{ lat: number; lng: number } | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
// Device orientation / Compass sensor listener
useEffect(() => {
if (!active) return;
const handleOrientation = (e: DeviceOrientationEvent) => {
let heading = (e as any).webkitCompassHeading;
if (typeof heading === 'undefined') {
if (e.alpha !== null) {
heading = 360 - e.alpha;
}
}
if (typeof heading === 'number' && !isNaN(heading)) {
setCurrentHeading(Math.round(heading));
}
};
if (window.DeviceOrientationEvent) {
window.addEventListener('deviceorientation', handleOrientation, true);
}
return () => {
if (window.DeviceOrientationEvent) {
window.removeEventListener('deviceorientation', handleOrientation, true);
}
};
}, [active]);
// Start Camera Feed
const startCamera = async () => {
try {
setCameraError('');
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.play();
setCameraActive(true);
}
} catch (err: any) {
console.warn('Camera access error:', err);
setCameraError('تعذر فتح الكاميرا (يرجى السماح بالوصول أو استخدام المحاكي اليدوي)');
setCameraActive(false);
}
};
// Stop Camera
const stopCamera = () => {
if (videoRef.current && videoRef.current.srcObject) {
const stream = videoRef.current.srcObject as MediaStream;
stream.getTracks().forEach(t => t.stop());
videoRef.current.srcObject = null;
}
setCameraActive(false);
};
useEffect(() => {
if (!active) {
stopCamera();
}
}, [active]);
// Add observation
const handleLockLandmark = () => {
const trueAzimuth = (currentHeading + magneticDeclination) % 360;
const updated = observations.filter(o => o.landmark.id !== selectedLandmark.id);
const newObservations = [...updated, { landmark: selectedLandmark, azimuthDeg: trueAzimuth }];
setObservations(newObservations);
if (newObservations.length >= 2) {
calculatePosition(newObservations);
}
};
// Reset observations
const handleReset = () => {
setObservations([]);
setCalculatedCoords(null);
};
// Pure Triangulation Resection Calculation
const calculatePosition = (obsList: ResectionObservation[]) => {
if (obsList.length < 2) return;
const obsA = obsList[0];
const obsB = obsList[1];
const backBearingA = ((obsA.azimuthDeg + 180) % 360) * (Math.PI / 180);
const backBearingB = ((obsB.azimuthDeg + 180) % 360) * (Math.PI / 180);
const latA = obsA.landmark.lat;
const lngA = obsA.landmark.lng;
const latB = obsB.landmark.lat;
const lngB = obsB.landmark.lng;
const latMidRad = ((latA + latB) / 2) * (Math.PI / 180);
const cosLat = Math.cos(latMidRad);
const xA = lngA * cosLat;
const yA = latA;
const xB = lngB * cosLat;
const yB = latB;
const sinA = Math.sin(backBearingA);
const cosA = Math.cos(backBearingA);
const sinB = Math.sin(backBearingB);
const cosB = Math.cos(backBearingB);
const det = sinA * cosB - cosA * sinB;
if (Math.abs(det) < 0.0001) {
alert('خطأ في الرصد: زوايا الرصد متوازية تقريباً ولا تشكل تقاطعاً صالحاً. يرجى اختيار معالم بزوايا متباعدة.');
return;
}
const dx = xB - xA;
const dy = yB - yA;
const tA = (dx * cosB - dy * sinB) / det;
const xIntersect = xA + tA * sinA;
const yIntersect = yA + tA * cosA;
const calcLng = xIntersect / cosLat;
const calcLat = yIntersect;
const finalPos = {
lat: Number(calcLat.toFixed(6)),
lng: Number(calcLng.toFixed(6))
};
setCalculatedCoords(finalPos);
onPositionFound(finalPos.lat, finalPos.lng, obsList);
};
if (!active) return null;
return (
<div style={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 9999,
width: 480,
maxWidth: 'calc(100vw - 32px)',
background: 'rgba(15, 23, 42, 0.96)',
backdropFilter: 'blur(24px)',
border: '1px solid rgba(0, 113, 227, 0.4)',
boxShadow: '0 20px 50px rgba(0, 0, 0, 0.6)',
borderRadius: 22,
color: '#ffffff',
fontFamily: "'IBM Plex Sans Arabic', Inter, system-ui, sans-serif",
direction: 'rtl',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
{/* Header */}
<div style={{
padding: '14px 18px',
background: 'linear-gradient(135deg, rgba(0, 113, 227, 0.25), rgba(79, 70, 229, 0.15))',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 32,
height: 32,
borderRadius: 10,
background: '#0071e3',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff'
}}>
<Crosshair size={18} />
</div>
<div>
<div style={{ fontSize: '0.92rem', fontWeight: 800 }}>التقاطع البصري المعلمي (GPS-Denied Navigation)</div>
<div style={{ fontSize: '0.72rem', color: '#94a3b8' }}>تحديد الموقع بواسطة زوايا المعالم والكاميرا والحساسات</div>
</div>
</div>
<button
onClick={() => { stopCamera(); onClose(); }}
style={{ background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer', padding: 4 }}
>
<X size={18} />
</button>
</div>
{/* Main Body */}
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: 14, maxHeight: '75vh', overflowY: 'auto' }}>
{/* Camera HUD / Viewfinder Box */}
<div style={{
position: 'relative',
width: '100%',
height: 190,
background: '#000000',
borderRadius: 14,
overflow: 'hidden',
border: '1px solid rgba(0, 113, 227, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
{cameraActive ? (
<video ref={videoRef} playsInline autoPlay muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{ textAlign: 'center', padding: 20, color: '#94a3b8' }}>
<Camera size={32} style={{ margin: '0 auto 8px auto', opacity: 0.6 }} />
<div style={{ fontSize: '0.8rem' }}>كاميرا الاستطلاع البصري مغلقة</div>
<button
onClick={startCamera}
style={{
marginTop: 10,
background: 'linear-gradient(135deg, #0071e3, #4f46e5)',
color: '#fff',
border: 'none',
padding: '6px 16px',
borderRadius: 8,
fontSize: '0.78rem',
fontWeight: 700,
cursor: 'pointer'
}}
>
تشغيل الكاميرا الحية
</button>
</div>
)}
{/* Tactical Crosshair Overlay */}
<div style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
{/* Center Reticle */}
<div style={{
width: 48,
height: 48,
border: '1.5px solid rgba(0, 240, 255, 0.8)',
borderRadius: '50%',
position: 'relative'
}}>
<div style={{ position: 'absolute', top: -8, left: '50%', transform: 'translateX(-50%)', width: 1.5, height: 8, background: '#00f0ff' }} />
<div style={{ position: 'absolute', bottom: -8, left: '50%', transform: 'translateX(-50%)', width: 1.5, height: 8, background: '#00f0ff' }} />
<div style={{ position: 'absolute', left: -8, top: '50%', transform: 'translateY(-50%)', height: 1.5, width: 8, background: '#00f0ff' }} />
<div style={{ position: 'absolute', right: -8, top: '50%', transform: 'translateY(-50%)', height: 1.5, width: 8, background: '#00f0ff' }} />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: 4, height: 4, borderRadius: '50%', background: '#ef4444' }} />
</div>
{/* Top Compass Azimuth Strip */}
<div style={{
position: 'absolute',
top: 8,
background: 'rgba(0, 0, 0, 0.75)',
padding: '2px 10px',
borderRadius: 6,
border: '1px solid rgba(0, 240, 255, 0.4)',
fontSize: '0.75rem',
fontWeight: 800,
color: '#00f0ff',
display: 'flex',
alignItems: 'center',
gap: 6
}}>
<Compass size={13} /> سمت البوصلة: {currentHeading}° (حقيقي: {((currentHeading + magneticDeclination) % 360).toFixed(1)}°)
</div>
</div>
</div>
{/* Manual Heading Slider / Compass Override (for Desktop testing) */}
<div style={{ background: 'rgba(255, 255, 255, 0.04)', padding: '10px 14px', borderRadius: 12, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.78rem', marginBottom: 6 }}>
<span style={{ color: '#cbd5e1' }}>تدوير زاوية التوجيه (Azimuth Slider):</span>
<span style={{ color: '#38bdf8', fontWeight: 800 }}>{currentHeading}° (ميللي: {Math.round(currentHeading * 17.778)} Mils)</span>
</div>
<input
type="range"
min={0}
max={359}
value={currentHeading}
onChange={e => setCurrentHeading(Number(e.target.value))}
style={{ width: '100%', accentColor: '#0071e3', cursor: 'pointer' }}
/>
</div>
{/* Landmark Selection & Capture */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label style={{ fontSize: '0.78rem', color: '#cbd5e1', fontWeight: 700 }}>
اختر المعلم البصري المصوب عليه:
</label>
<select
value={selectedLandmark.id}
onChange={e => {
const lm = JORDAN_LANDMARKS.find(l => l.id === e.target.value);
if (lm) setSelectedLandmark(lm);
}}
style={{
background: '#1e293b',
border: '1px solid #334155',
color: '#ffffff',
padding: '8px 12px',
borderRadius: 10,
fontSize: '0.84rem',
fontWeight: 600,
outline: 'none'
}}
>
{JORDAN_LANDMARKS.map(lm => (
<option key={lm.id} value={lm.id}>
{lm.name} ({lm.elevationM}م)
</option>
))}
</select>
<button
onClick={handleLockLandmark}
style={{
background: 'linear-gradient(135deg, #0071e3, #4f46e5)',
color: '#ffffff',
border: 'none',
padding: '10px 18px',
borderRadius: 10,
fontSize: '0.86rem',
fontWeight: 800,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
cursor: 'pointer',
boxShadow: '0 4px 14px rgba(0, 113, 227, 0.35)',
marginTop: 4
}}
>
<Target size={16} /> تثبيت رصد المعلم الحالي (Lock Landmark)
</button>
</div>
{/* Observations Table */}
<div style={{ background: 'rgba(255, 255, 255, 0.03)', padding: '12px', borderRadius: 12, border: '1px solid rgba(255, 255, 255, 0.08)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: '0.78rem', fontWeight: 800, color: '#38bdf8' }}>
المعالم المرصودة ({observations.length}/2 مطلوب على الأقل):
</span>
{observations.length > 0 && (
<button
onClick={handleReset}
style={{ background: 'transparent', border: 'none', color: '#f87171', fontSize: '0.72rem', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}
>
<RotateCcw size={12} /> إعادة تعيين
</button>
)}
</div>
{observations.length === 0 ? (
<div style={{ fontSize: '0.75rem', color: '#64748b', textAlign: 'center', padding: '8px 0' }}>
قم بتصويب الكاميرا وتثبيت معلمين بصريين على الأقل لحساب موقعك.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{observations.map((obs, idx) => (
<div key={obs.landmark.id} style={{
background: '#0f172a',
padding: '6px 10px',
borderRadius: 8,
fontSize: '0.76rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
border: '1px solid rgba(255, 255, 255, 0.06)'
}}>
<span style={{ fontWeight: 700, color: '#e2e8f0' }}>
{idx + 1}. {obs.landmark.name}
</span>
<span style={{ color: '#4ade80', fontWeight: 800 }}>
السمت: {obs.azimuthDeg.toFixed(1)}°
</span>
</div>
))}
</div>
)}
</div>
{/* Calculated Location Box */}
{calculatedCoords && (
<div style={{
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(5, 150, 105, 0.15))',
border: '1px solid #22c55e',
borderRadius: 12,
padding: '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: 6
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#4ade80', fontWeight: 800, fontSize: '0.84rem' }}>
<CheckCircle2 size={16} /> تم تحديد إحداثيات الموقع بنجاح (GPS-Denied Resolved):
</div>
<div style={{ fontSize: '1rem', fontWeight: 900, color: '#ffffff', direction: 'ltr', textAlign: 'center' }}>
{calculatedCoords.lat.toFixed(6)}, {calculatedCoords.lng.toFixed(6)}
</div>
<div style={{ fontSize: '0.72rem', color: '#cbd5e1', textAlign: 'center' }}>
تم رسم أشعة التقاطع العكسي وإسقاط دائرة التموضع التكتيكي على الخريطة
</div>
</div>
)}
</div>
</div>
);
};
+217
View File
@@ -55,6 +55,9 @@ import {
calculateDistance,
calculateAzimuth
} from '../utils/elevationService';
import { VisualResectionTool, ResectionObservation } from '../components/VisualResectionTool';
import { LiveFileImporterModal } from '../components/LiveFileImporterModal';
import { FolderUp, Camera } from 'lucide-react';
type TacticalMode = 'terrain' | 'los' | 'viewshed' | 'artillery' | 'minefield' | 'hlz' | 'symbols' | 'study' | 'isochrone';
@@ -240,6 +243,159 @@ export const TacticalDefenseView: React.FC = () => {
const [selectedHubName, setSelectedHubName] = useState<string>('المدينة الطبية الملكية (عمان)');
const [copiedIsochroneGeoJson, setCopiedIsochroneGeoJson] = useState<boolean>(false);
// 9. Visual Resection & Live File Importer State
const [showVisualResection, setShowVisualResection] = useState<boolean>(false);
const [showFileImporter, setShowFileImporter] = useState<boolean>(false);
const [resectionPosition, setResectionPosition] = useState<[number, number] | null>(null);
const handlePositionFoundByResection = (lat: number, lng: number, observations: ResectionObservation[]) => {
setResectionPosition([lat, lng]);
if (map.current) {
map.current.flyTo({
center: [lng, lat],
zoom: 14,
essential: true
});
// Draw resection rays
const features: any[] = [
{
type: 'Feature',
properties: { label: 'الموقع المحسوب (الراصد)' },
geometry: { type: 'Point', coordinates: [lng, lat] }
}
];
observations.forEach((obs) => {
features.push({
type: 'Feature',
properties: { label: obs.landmark.name },
geometry: { type: 'Point', coordinates: [obs.landmark.lng, obs.landmark.lat] }
});
features.push({
type: 'Feature',
properties: { role: 'ray' },
geometry: {
type: 'LineString',
coordinates: [
[lng, lat],
[obs.landmark.lng, obs.landmark.lat]
]
}
});
});
const resectionGeoJson = { type: 'FeatureCollection', features };
if (!map.current.getSource('resection-source')) {
map.current.addSource('resection-source', { type: 'geojson', data: resectionGeoJson as any });
map.current.addLayer({
id: 'resection-lines',
type: 'line',
source: 'resection-source',
filter: ['==', '$type', 'LineString'],
paint: {
'line-color': '#00f0ff',
'line-width': 3,
'line-dasharray': [3, 2]
}
});
map.current.addLayer({
id: 'resection-points',
type: 'circle',
source: 'resection-source',
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': 9,
'circle-color': '#22c55e',
'circle-stroke-width': 3,
'circle-stroke-color': '#ffffff'
}
});
} else {
(map.current.getSource('resection-source') as maplibregl.GeoJSONSource).setData(resectionGeoJson as any);
}
}
};
const handleLayerImported = (geoJsonData: any, layerName: string) => {
if (!map.current) return;
const sourceId = `imported-source-${Date.now()}`;
const fillLayerId = `imported-fill-${Date.now()}`;
const lineLayerId = `imported-line-${Date.now()}`;
const circleLayerId = `imported-circle-${Date.now()}`;
map.current.addSource(sourceId, { type: 'geojson', data: geoJsonData });
// Add polygon fill
map.current.addLayer({
id: fillLayerId,
type: 'fill',
source: sourceId,
filter: ['==', '$type', 'Polygon'],
paint: {
'fill-color': '#6366f1',
'fill-opacity': 0.45
}
});
// Add lines
map.current.addLayer({
id: lineLayerId,
type: 'line',
source: sourceId,
filter: ['any', ['==', '$type', 'LineString'], ['==', '$type', 'Polygon']],
paint: {
'line-color': '#38bdf8',
'line-width': 2.5
}
});
// Add points
map.current.addLayer({
id: circleLayerId,
type: 'circle',
source: sourceId,
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': 7,
'circle-color': '#e11d48',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
});
// Calculate bounds and fit
try {
const coords: [number, number][] = [];
const extractCoords = (geom: any) => {
if (!geom) return;
if (geom.type === 'Point') coords.push(geom.coordinates);
else if (geom.type === 'LineString' || geom.type === 'MultiPoint') coords.push(...geom.coordinates);
else if (geom.type === 'Polygon' || geom.type === 'MultiLineString') geom.coordinates.forEach((c: any) => coords.push(...c));
else if (geom.type === 'MultiPolygon') geom.coordinates.forEach((poly: any) => poly.forEach((c: any) => coords.push(...c)));
};
if (geoJsonData.type === 'FeatureCollection') {
geoJsonData.features.forEach((f: any) => extractCoords(f.geometry));
} else if (geoJsonData.type === 'Feature') {
extractCoords(geoJsonData.geometry);
}
if (coords.length > 0) {
const bounds = coords.reduce(
(b, c) => b.extend(c as [number, number]),
new maplibregl.LngLatBounds(coords[0], coords[0])
);
map.current.fitBounds(bounds, { padding: 80, maxZoom: 16 });
}
} catch (e) {
console.warn('Bounds fit error:', e);
}
};
// Synchronized Refs to avoid stale closures in MapLibre event listeners
const modeRef = useRef(mode);
modeRef.current = mode;
@@ -1482,6 +1638,52 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n
<span>تصريح: {authInfo?.tenantName || 'Enterprise'}</span>
</div>
{/* GPS-Denied Visual Resection Button */}
<button
onClick={() => setShowVisualResection(true)}
title="تحديد الموقع بالتقاطع البصري عند انقطاع الـ GPS"
style={{
background: 'linear-gradient(135deg, rgba(0, 240, 255, 0.2), rgba(0, 113, 227, 0.25))',
border: '1px solid rgba(0, 240, 255, 0.5)',
color: '#38bdf8',
padding: '4px 10px',
borderRadius: 6,
fontSize: '0.72rem',
fontWeight: 800,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 5,
boxShadow: '0 2px 8px rgba(0, 240, 255, 0.2)'
}}
>
<Camera size={13} />
<span>التقاطع البصري (GPS-Denied)</span>
</button>
{/* Flash Drive Live Importer Button */}
<button
onClick={() => setShowFileImporter(true)}
title="استيراد ملفات المركز الجغرافي من الفلاشة (Shapefiles, GeoJSON)"
style={{
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(79, 70, 229, 0.25))',
border: '1px solid rgba(99, 102, 241, 0.5)',
color: '#a5b4fc',
padding: '4px 10px',
borderRadius: 6,
fontSize: '0.72rem',
fontWeight: 800,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 5,
boxShadow: '0 2px 8px rgba(99, 102, 241, 0.2)'
}}
>
<FolderUp size={13} />
<span>استيراد ملفات الفلاشة</span>
</button>
<button
onClick={handleRevokeOrLock}
title="قفل المنظومة التكتيكية / خروج"
@@ -3605,6 +3807,21 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n
</div>
</div>
)}
{/* Visual Landmark Resection Tool (GPS-Denied) */}
<VisualResectionTool
active={showVisualResection}
onClose={() => setShowVisualResection(false)}
onPositionFound={handlePositionFoundByResection}
/>
{/* Flash Drive Live Ingestion Tool */}
<LiveFileImporterModal
active={showFileImporter}
onClose={() => setShowFileImporter(false)}
onLayerImported={handleLayerImported}
/>
</div>
);
};