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
@@ -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>
);
};