feat: Add Intaleq Native Android Kotlin & iOS Swift SDKs, Incline slope routing, Topographic contours and modular tactical app
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user