feat(tactical): add tactical line of sight API, elevation engine, dynamic step resolution, weather module and executive showcase

This commit is contained in:
Hamza-Ayed
2026-08-17 19:25:39 +03:00
parent 03cdc6fe9c
commit a0a5a09135
29 changed files with 3896 additions and 190 deletions
+597
View File
@@ -0,0 +1,597 @@
import React, { useState, useEffect } from 'react';
import { Eye, Crosshair, AlertTriangle, CheckCircle2, XCircle, Mountain, Compass, Shield, ChevronDown, ChevronUp, Layers } from 'lucide-react';
import { LineOfSightResult, calculateLineOfSight } from '../utils/elevationService';
interface LineOfSightToolProps {
active: boolean;
pointA: [number, number] | null; // [lat, lng] Observer
pointB: [number, number] | null; // [lat, lng] Target
onClose: () => void;
onClear: () => void;
}
export const LineOfSightTool: React.FC<LineOfSightToolProps> = ({
active,
pointA,
pointB,
onClose,
onClear
}) => {
const [obsHeight, setObsHeight] = useState<number>(2); // 2m eye level
const [tgtHeight, setTgtHeight] = useState<number>(2); // 2m target level
const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<LineOfSightResult | null>(null);
const [hoverPoint, setHoverPoint] = useState<any | null>(null);
const [minimized, setMinimized] = useState<boolean>(false);
useEffect(() => {
if (pointA && pointB) {
setLoading(true);
calculateLineOfSight(pointA[0], pointA[1], pointB[0], pointB[1], obsHeight, tgtHeight, 80)
.then((res) => {
setResult(res);
setLoading(false);
})
.catch((err) => {
console.error('LOS Error:', err);
setLoading(false);
});
} else {
setResult(null);
}
}, [pointA, pointB, obsHeight, tgtHeight]);
if (!active) return null;
// Instructions overlay if points are not selected yet
if (!pointA || !pointB) {
return (
<div style={{
position: 'absolute',
top: 72,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
background: 'rgba(15, 23, 42, 0.95)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(59, 130, 246, 0.5)',
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.6)',
borderRadius: 14,
padding: '12px 22px',
color: '#f8fafc',
direction: 'rtl',
display: 'flex',
alignItems: 'center',
gap: 14,
fontFamily: "'IBM Plex Sans Arabic', Inter, system-ui, sans-serif"
}}>
<div style={{
width: 38,
height: 38,
borderRadius: '50%',
background: pointA ? 'rgba(34, 197, 94, 0.2)' : 'rgba(59, 130, 246, 0.2)',
border: pointA ? '1px solid #22c55e' : '1px solid #3b82f6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: pointA ? '#4ade80' : '#60a5fa'
}}>
{pointA ? <Crosshair size={20} /> : <Eye size={20} />}
</div>
<div>
<div style={{ fontWeight: 700, fontSize: '0.95rem', display: 'flex', alignItems: 'center', gap: 8 }}>
<span>أداة تبادل الرؤية وخط النظر التكتيكي (Line of Sight)</span>
<span style={{ fontSize: '0.72rem', background: '#3b82f630', color: '#60a5fa', padding: '2px 8px', borderRadius: 6 }}>مباشر</span>
</div>
<div style={{ fontSize: '0.82rem', color: pointA ? '#4ade80' : '#cbd5e1', marginTop: 2 }}>
{!pointA
? '📍 اضغط على الخريطة لتحديد موقع الراصد / الرامي (النقطة A)'
: `🎯 تم تحديد الراصد (${pointA[0].toFixed(4)}, ${pointA[1].toFixed(4)}) — اضغط الآن لتحديد موقع الهدف (النقطة B)`}
</div>
</div>
{pointA && (
<button
onClick={onClear}
style={{
background: 'rgba(239, 68, 68, 0.15)',
border: '1px solid rgba(239, 68, 68, 0.4)',
color: '#f87171',
borderRadius: 8,
padding: '4px 10px',
fontSize: '0.75rem',
cursor: 'pointer'
}}
>
إعادة
</button>
)}
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: '#64748b',
cursor: 'pointer',
padding: '4px 8px',
fontSize: '1.1rem'
}}
title="إغلاق"
>
✕
</button>
</div>
);
}
// Render SVG Chart for Elevation Profile
const renderProfileChart = () => {
if (!result || result.points.length === 0) return null;
const svgWidth = 620;
const svgHeight = 190;
const padding = { top: 20, right: 35, bottom: 30, left: 45 };
const chartWidth = svgWidth - padding.left - padding.right;
const chartHeight = svgHeight - padding.top - padding.bottom;
const maxDist = result.totalDistance;
// Dynamic elevation range with margin
const minElev = Math.floor(Math.min(result.minElevation, result.observerElevation, result.targetElevation) / 50) * 50 - 50;
const maxElev = Math.ceil(Math.max(result.maxElevation, result.observerElevation, result.targetElevation) / 50) * 50 + 50;
const elevRange = Math.max(100, maxElev - minElev);
const getX = (d: number) => padding.left + (d / maxDist) * chartWidth;
const getY = (h: number) => padding.top + chartHeight - ((h - minElev) / elevRange) * chartHeight;
// Build Terrain Polygon Path
const terrainPoints = result.points.map((p) => `${getX(p.distance)},${getY(p.elevation)}`).join(' ');
const terrainAreaPath = `M ${getX(0)},${padding.top + chartHeight} L ${terrainPoints} L ${getX(maxDist)},${padding.top + chartHeight} Z`;
const terrainLinePath = `M ${terrainPoints}`;
// Line of Sight Ray Path
const obsX = getX(0);
const obsY = getY(result.observerElevation);
const tgtX = getX(maxDist);
const tgtY = getY(result.targetElevation);
return (
<div style={{ position: 'relative', width: '100%', overflow: 'hidden' }}>
<svg
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
style={{ width: '100%', height: 'auto', display: 'block' }}
onMouseLeave={() => setHoverPoint(null)}
>
<defs>
{/* Terrain Gradient */}
<linearGradient id="terrainGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#475569" stopOpacity="0.85" />
<stop offset="100%" stopColor="#0f172a" stopOpacity="0.95" />
</linearGradient>
{/* Obstructed Ray Pattern */}
<linearGradient id="rayGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={result.isDirectlyVisible ? '#22c55e' : '#ef4444'} />
<stop offset="100%" stopColor={result.isDirectlyVisible ? '#10b981' : '#dc2626'} />
</linearGradient>
</defs>
{/* Grid Lines */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const hVal = minElev + ratio * elevRange;
const y = getY(hVal);
return (
<g key={ratio}>
<line x1={padding.left} y1={y} x2={svgWidth - padding.right} y2={y} stroke="rgba(255,255,255,0.08)" strokeDasharray="3 3" />
<text x={padding.left - 8} y={y + 3} fill="#64748b" fontSize="9" textAnchor="end" fontFamily="monospace">
{Math.round(hVal)}m
</text>
</g>
);
})}
{/* Distance Axis */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const dVal = ratio * maxDist;
const x = getX(dVal);
return (
<g key={ratio}>
<line x1={x} y1={padding.top} x2={x} y2={padding.top + chartHeight} stroke="rgba(255,255,255,0.06)" strokeDasharray="2 2" />
<text x={x} y={padding.top + chartHeight + 16} fill="#64748b" fontSize="9" textAnchor="middle" fontFamily="monospace">
{(dVal / 1000).toFixed(1)}km
</text>
</g>
);
})}
{/* Terrain Area & Line */}
<path d={terrainAreaPath} fill="url(#terrainGrad)" />
<path d={terrainLinePath} fill="none" stroke="#94a3b8" strokeWidth="2" strokeLinejoin="round" />
{/* Line of Sight Ray */}
<line
x1={obsX}
y1={obsY}
x2={tgtX}
y2={tgtY}
stroke="url(#rayGrad)"
strokeWidth="2.5"
strokeDasharray={result.isDirectlyVisible ? 'none' : '5 4'}
/>
{/* Dead Ground Regions (Highlighted on terrain) */}
{result.points.map((p, i) => {
if (!p.isVisible && i > 0) {
const x = getX(p.distance);
const y = getY(p.elevation);
return (
<circle key={i} cx={x} cy={y} r="1.5" fill="#f87171" opacity="0.6" />
);
}
return null;
})}
{/* Observer Marker */}
<circle cx={obsX} cy={obsY} r="5" fill="#3b82f6" stroke="#fff" strokeWidth="2" />
<text x={obsX} y={obsY - 10} fill="#60a5fa" fontSize="10" fontWeight="bold" textAnchor="middle">
الراصد (A)
</text>
{/* Target Marker */}
<circle cx={tgtX} cy={tgtY} r="5" fill="#f59e0b" stroke="#fff" strokeWidth="2" />
<text x={tgtX} y={tgtY - 10} fill="#fbbf24" fontSize="10" fontWeight="bold" textAnchor="middle">
الهدف (B)
</text>
{/* Critical Obstacle Marker (if blocked) */}
{result.highestObstacle && (
<g>
<circle
cx={getX(result.highestObstacle.distance)}
cy={getY(result.highestObstacle.elevation)}
r="6"
fill="#ef4444"
stroke="#fff"
strokeWidth="2"
/>
<path
d={`M ${getX(result.highestObstacle.distance)},${getY(result.highestObstacle.elevation) - 8} L ${getX(result.highestObstacle.distance) - 4},${getY(result.highestObstacle.elevation) - 14} L ${getX(result.highestObstacle.distance) + 4},${getY(result.highestObstacle.elevation) - 14} Z`}
fill="#ef4444"
/>
<text
x={getX(result.highestObstacle.distance)}
y={getY(result.highestObstacle.elevation) - 18}
fill="#f87171"
fontSize="9"
fontWeight="bold"
textAnchor="middle"
>
عائق الحجب (+{result.highestObstacle.excessHeight}m)
</text>
</g>
)}
{/* Interactive Hover Overlay Rect */}
{result.points.map((p, idx) => {
const x = getX(p.distance);
return (
<rect
key={idx}
x={x - (chartWidth / result.points.length) / 2}
y={padding.top}
width={chartWidth / result.points.length}
height={chartHeight}
fill="transparent"
style={{ cursor: 'crosshair' }}
onMouseEnter={() => setHoverPoint(p)}
/>
);
})}
{/* Active Hover Point Marker */}
{hoverPoint && (
<g>
<line
x1={getX(hoverPoint.distance)}
y1={padding.top}
x2={getX(hoverPoint.distance)}
y2={padding.top + chartHeight}
stroke="#38bdf8"
strokeWidth="1"
strokeDasharray="2 2"
/>
<circle cx={getX(hoverPoint.distance)} cy={getY(hoverPoint.elevation)} r="4" fill="#38bdf8" stroke="#fff" strokeWidth="1.5" />
</g>
)}
</svg>
{/* Hover Info Tooltip */}
{hoverPoint && (
<div style={{
position: 'absolute',
top: 10,
left: 20,
background: 'rgba(15, 23, 42, 0.95)',
border: '1px solid rgba(56, 189, 248, 0.4)',
borderRadius: 8,
padding: '6px 12px',
fontSize: '0.75rem',
color: '#f8fafc',
direction: 'rtl',
pointerEvents: 'none',
display: 'flex',
gap: 10
}}>
<span>المسافة: <strong>{(hoverPoint.distance / 1000).toFixed(2)} كم</strong></span>
<span>الارتفاع: <strong>{hoverPoint.elevation} م</strong></span>
<span>شعاع الرؤية: <strong>{hoverPoint.rayHeight} م</strong></span>
<span>الحالة: <strong style={{ color: hoverPoint.isVisible ? '#4ade80' : '#f87171' }}>
{hoverPoint.isVisible ? 'مكشوف' : 'أرض ميتة'}
</strong></span>
</div>
)}
</div>
);
};
return (
<div style={{
position: 'absolute',
bottom: 20,
left: '50%',
transform: 'translateX(-50%)',
width: 'min(94vw, 760px)',
zIndex: 1000,
background: 'rgba(15, 23, 42, 0.94)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.6)',
borderRadius: 16,
color: '#f8fafc',
direction: 'rtl',
fontFamily: 'system-ui, -apple-system, sans-serif',
overflow: 'hidden',
transition: 'all 0.3s ease'
}}>
{/* Header Bar */}
<div style={{
padding: '12px 18px',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: 'rgba(30, 41, 59, 0.5)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 32,
height: 32,
borderRadius: 8,
background: result?.isDirectlyVisible ? 'rgba(34, 197, 94, 0.2)' : 'rgba(239, 68, 68, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: result?.isDirectlyVisible ? '#4ade80' : '#f87171'
}}>
{result?.isDirectlyVisible ? <CheckCircle2 size={20} /> : <XCircle size={20} />}
</div>
<div>
<div style={{ fontWeight: 800, fontSize: '0.95rem', display: 'flex', alignItems: 'center', gap: 8 }}>
<span>تبادل الرؤية والمقطع التضاريسي (Line of Sight)</span>
{result && (
<span style={{
fontSize: '0.75rem',
padding: '2px 8px',
borderRadius: 6,
fontWeight: 700,
background: result.isDirectlyVisible ? '#15803d' : '#991b1b',
color: '#fff'
}}>
{result.isDirectlyVisible ? '✓ رؤية مباشرة مكشوفة' : '✕ الرؤية محجوبة بعائق'}
</span>
)}
</div>
<div style={{ fontSize: '0.75rem', color: '#94a3b8' }}>
تحليل خط النظر التكتيكي مع تصحيح انكسار الضوء الجوي وتقوس الأرض
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
onClick={onClear}
style={{
background: 'rgba(255, 255, 255, 0.08)',
border: '1px solid rgba(255, 255, 255, 0.15)',
borderRadius: 8,
padding: '6px 12px',
color: '#e2e8f0',
fontSize: '0.8rem',
cursor: 'pointer',
fontWeight: 600
}}
>
تحديد نقاط جديدة
</button>
<button
onClick={() => setMinimized(!minimized)}
style={{
background: 'transparent',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
padding: 4
}}
>
{minimized ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</button>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
padding: 4
}}
>
✕
</button>
</div>
</div>
{/* Main Content Area */}
{!minimized && (
<div style={{ padding: '14px 18px' }}>
{loading ? (
<div style={{ textAlign: 'center', padding: '30px', color: '#94a3b8' }}>
جاري حساب المقطع التضاريسي وشعاع الرؤية... ⏳
</div>
) : result ? (
<>
{/* Tactical Metrics Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
gap: 10,
marginBottom: 14
}}>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<Compass size={12} color="#38bdf8" /> المسافة المباشرة
</div>
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: '#f8fafc', marginTop: 2 }}>
{(result.totalDistance / 1000).toFixed(2)} <span style={{ fontSize: '0.75rem', fontWeight: 500 }}>كم</span>
</div>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<Crosshair size={12} color="#fbbf24" /> السمت / الاتجاه
</div>
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: '#f8fafc', marginTop: 2 }}>
{result.azimuthDegrees}° <span style={{ fontSize: '0.75rem', fontWeight: 500 }}>بوصلة</span>
</div>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<Shield size={12} color="#a78bfa" /> زاوية الموقع (رماية)
</div>
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: '#a78bfa', marginTop: 2 }}>
{result.angleMils > 0 ? `+${result.angleMils}` : result.angleMils} <span style={{ fontSize: '0.75rem', fontWeight: 500 }}>Mils ({result.angleDegrees}°)</span>
</div>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<Mountain size={12} color="#34d399" /> الراصد / الهدف
</div>
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#f8fafc', marginTop: 4 }}>
{result.observerElevation}m ➔ {result.targetElevation}m
</div>
</div>
<div style={{ background: 'rgba(255, 255, 255, 0.04)', borderRadius: 10, padding: '8px 12px', border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<Eye size={12} color="#f472b6" /> الأرض الميتة
</div>
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: result.deadGroundPercentage > 30 ? '#f87171' : '#f8fafc', marginTop: 2 }}>
{result.deadGroundPercentage}% <span style={{ fontSize: '0.75rem', fontWeight: 500 }}>محجوبة</span>
</div>
</div>
</div>
{/* Critical Obstacle Alert */}
{result.highestObstacle && (
<div style={{
background: 'rgba(239, 68, 68, 0.12)',
border: '1px solid rgba(239, 68, 68, 0.35)',
borderRadius: 10,
padding: '8px 14px',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: '0.82rem',
color: '#fca5a5'
}}>
<AlertTriangle size={18} color="#ef4444" style={{ flexShrink: 0 }} />
<div>
<strong>عائق الحجب الرئيسي:</strong> قمة جبلية/تضاريس على بعد <strong>{(result.highestObstacle.distance / 1000).toFixed(2)} كم</strong> بارتفاع <strong>{result.highestObstacle.elevation} م</strong>، تخترق خط الرؤية بمقدار <strong>+{result.highestObstacle.excessHeight} م</strong>.
</div>
</div>
)}
{/* Elevation Profile Chart */}
<div style={{ background: 'rgba(0,0,0,0.3)', borderRadius: 12, padding: '10px 10px 4px 10px', border: '1px solid rgba(255,255,255,0.08)' }}>
{renderProfileChart()}
</div>
{/* Observer / Target Height Adjusters */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
marginTop: 12,
paddingTop: 10,
borderTop: '1px solid rgba(255,255,255,0.08)',
fontSize: '0.8rem'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#94a3b8' }}>ارتفاع الراصد (A):</span>
{[
{ label: 'شخص (2م)', val: 2 },
{ label: 'آلية/برج (10م)', val: 10 },
{ label: 'سارية/درون (50م)', val: 50 },
].map((btn) => (
<button
key={btn.val}
onClick={() => setObsHeight(btn.val)}
style={{
background: obsHeight === btn.val ? 'rgba(59, 130, 246, 0.3)' : 'rgba(255,255,255,0.05)',
border: obsHeight === btn.val ? '1px solid #3b82f6' : '1px solid rgba(255,255,255,0.1)',
color: obsHeight === btn.val ? '#60a5fa' : '#cbd5e1',
borderRadius: 6,
padding: '3px 8px',
fontSize: '0.75rem',
cursor: 'pointer'
}}
>
{btn.label}
</button>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#94a3b8' }}>ارتفاع الهدف (B):</span>
{[
{ label: 'شخص (1.8م)', val: 1.8 },
{ label: 'مركبة (3م)', val: 3 },
{ label: 'مبنى/رادار (15م)', val: 15 },
].map((btn) => (
<button
key={btn.val}
onClick={() => setTgtHeight(btn.val)}
style={{
background: tgtHeight === btn.val ? 'rgba(245, 158, 11, 0.3)' : 'rgba(255,255,255,0.05)',
border: tgtHeight === btn.val ? '1px solid #f59e0b' : '1px solid rgba(255,255,255,0.1)',
color: tgtHeight === btn.val ? '#fbbf24' : '#cbd5e1',
borderRadius: 6,
padding: '3px 8px',
fontSize: '0.75rem',
cursor: 'pointer'
}}
>
{btn.label}
</button>
))}
</div>
</div>
</>
) : null}
</div>
)}
</div>
);
};
+368 -29
View File
@@ -4,6 +4,22 @@ import 'maplibre-gl/dist/maplibre-gl.css';
import mlcontour from 'maplibre-contour';
import { attachIconLoader } from '../utils/mapIcons';
interface MapProps {
onMapLoad: (map: maplibregl.Map) => void;
onMapClick?: (lat: number, lng: number) => void;
show3D?: boolean;
showPOIs?: boolean;
showTerrain?: boolean;
showContours?: boolean;
showAdminBoundaries?: boolean;
showWeather?: boolean;
currentRegion?: string;
onCityClick?: (city: any) => void;
losActive?: boolean;
losPointA?: [number, number] | null;
losPointB?: [number, number] | null;
}
const demSource = new mlcontour.DemSource({
url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png',
encoding: 'terrarium',
@@ -12,45 +28,136 @@ const demSource = new mlcontour.DemSource({
});
demSource.setupMaplibre(maplibregl);
interface MapComponentProps {
onMapLoad: (map: maplibregl.Map) => void;
onMapClick?: (lat: number, lng: number) => void;
show3D?: boolean;
showPOIs?: boolean;
showTerrain?: boolean;
showContours?: boolean;
showAdminBoundaries?: boolean;
}
const MapComponent: React.FC<MapComponentProps> = ({
const MapComponent: React.FC<MapProps> = ({
onMapLoad,
onMapClick,
show3D = false,
showPOIs = true,
showTerrain = true,
showTerrain = false,
showContours = false,
showAdminBoundaries = true
showAdminBoundaries = false,
showWeather = false,
currentRegion = 'Jordan',
onCityClick,
losActive = false,
losPointA = null,
losPointB = null,
}) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
// Sync toggles with map layers
// Synchronize Line of Sight (LOS) Tactical Layer
useEffect(() => {
if (!map.current || !map.current.isStyleLoaded()) return;
const features: any[] = [];
if (losActive) {
if (losPointA) {
features.push({
type: 'Feature',
properties: { role: 'obs', label: 'A (الراصد)' },
geometry: { type: 'Point', coordinates: [losPointA[1], losPointA[0]] }
});
}
if (losPointB) {
features.push({
type: 'Feature',
properties: { role: 'tgt', label: 'B (الهدف)' },
geometry: { type: 'Point', coordinates: [losPointB[1], losPointB[0]] }
});
}
if (losPointA && losPointB) {
features.push({
type: 'Feature',
properties: { role: 'ray' },
geometry: {
type: 'LineString',
coordinates: [
[losPointA[1], losPointA[0]],
[losPointB[1], losPointB[0]]
]
}
});
}
}
const losGeoJson = { type: 'FeatureCollection', features };
if (!map.current.getSource('los-source')) {
map.current.addSource('los-source', {
type: 'geojson',
data: losGeoJson as any
});
map.current.addLayer({
id: 'los-line',
type: 'line',
source: 'los-source',
filter: ['==', '$type', 'LineString'],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: {
'line-color': '#f59e0b',
'line-width': 3.5,
'line-dasharray': [2, 2]
}
});
map.current.addLayer({
id: 'los-points',
type: 'circle',
source: 'los-source',
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': 8,
'circle-color': [
'match',
['get', 'role'],
'obs', '#3b82f6',
'tgt', '#ef4444',
'#ffffff'
],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2.5
}
});
map.current.addLayer({
id: 'los-labels',
type: 'symbol',
source: 'los-source',
filter: ['==', '$type', 'Point'],
layout: {
'text-field': ['get', 'label'],
'text-size': 12,
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
'text-offset': [0, -1.6],
'text-anchor': 'bottom'
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#0f172a',
'text-halo-width': 2
}
});
} else {
(map.current.getSource('los-source') as maplibregl.GeoJSONSource).setData(losGeoJson as any);
map.current.setLayoutProperty('los-line', 'visibility', losActive ? 'visible' : 'none');
map.current.setLayoutProperty('los-points', 'visibility', losActive ? 'visible' : 'none');
map.current.setLayoutProperty('los-labels', 'visibility', losActive ? 'visible' : 'none');
}
}, [losActive, losPointA, losPointB]);
// Synchronize layers visibility
useEffect(() => {
if (!map.current || !map.current.isStyleLoaded()) return;
// Toggle 3D Buildings
['building-3d', 'building-3d-osm'].forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', show3D ? 'visible' : 'none');
}
});
if (map.current.getLayer('overture-building-footprint')) {
map.current.setLayoutProperty('overture-building-footprint', 'visibility', show3D ? 'none' : 'visible');
if (map.current.getLayer('3d-buildings')) {
map.current.setLayoutProperty('3d-buildings', 'visibility', show3D ? 'visible' : 'none');
}
map.current.easeTo({ pitch: show3D ? 55 : 0, duration: 600 });
// Toggle POI Layers
const poiLayers = ['poi-icons', 'place-labels', 'overture-building-names', 'places-jordan-labels'];
// Toggle POIs
const poiLayers = ['poi-level-1', 'poi-level-2', 'poi-level-3', 'poi-labels'];
poiLayers.forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', showPOIs ? 'visible' : 'none');
@@ -83,7 +190,192 @@ const MapComponent: React.FC<MapComponentProps> = ({
map.current!.setLayoutProperty(layerId, 'visibility', showAdminBoundaries ? 'visible' : 'none');
}
});
}, [show3D, showPOIs, showTerrain, showContours, showAdminBoundaries]);
const weatherLayers = ['weather-city-circles', 'weather-city-icons', 'weather-wind-arrows', 'weather-wind-speed'];
weatherLayers.forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', showWeather ? 'visible' : 'none');
}
});
}, [show3D, showPOIs, showTerrain, showContours, showAdminBoundaries, showWeather]);
// Weather data fetching and vector rendering
useEffect(() => {
if (!map.current || !map.current.isStyleLoaded()) return;
if (showWeather) {
const fetchWeather = async () => {
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const apiKey = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
const regionParam = currentRegion ? `?region=${encodeURIComponent(currentRegion)}` : '';
const citiesRes = await fetch(`${apiUrl}/weather/cities${regionParam}`, {
headers: { 'x-api-key': apiKey }
});
const rawCitiesData = await citiesRes.json();
// Convert cities array to GeoJSON FeatureCollection
const citiesGeoJson = {
type: 'FeatureCollection',
features: (Array.isArray(rawCitiesData) ? rawCitiesData : []).map((city: any) => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [city.lng, city.lat]
},
properties: {
name: city.name,
name_ar: city.name_ar,
temperature: Number(city.current?.temp ?? 20),
feelsLike: Number(city.current?.feelsLike ?? 20),
humidity: Number(city.current?.humidity ?? 50),
weatherCode: Number(city.current?.weatherCode ?? 0),
windSpeed: Number(city.current?.windSpeed ?? 10),
windDirection: Number(city.current?.windDirection ?? 0),
cloudCover: Number(city.current?.cloudCover ?? 0),
precipitation: Number(city.current?.precipitation ?? 0),
isDay: Number(city.current?.isDay ?? 1),
forecast: JSON.stringify(city.daily || [])
}
}))
};
if (!map.current!.getSource('weather-cities-source')) {
map.current!.addSource('weather-cities-source', { type: 'geojson', data: citiesGeoJson as any });
// 1. Soft temperature indicator circle badge
map.current!.addLayer({
id: 'weather-city-circles',
type: 'circle',
source: 'weather-cities-source',
paint: {
'circle-radius': 24,
'circle-color': [
'step', ['to-number', ['get', 'temperature'], 20],
'#3b82f6', 15,
'#10b981', 25,
'#f59e0b', 35,
'#ef4444'
],
'circle-opacity': 0.9,
'circle-stroke-width': 2.5,
'circle-stroke-color': '#ffffff'
}
});
// 2. City name and temperature text badge
map.current!.addLayer({
id: 'weather-city-icons',
type: 'symbol',
source: 'weather-cities-source',
layout: {
'text-field': ['concat', ['coalesce', ['get', 'name_ar'], ''], '\n', ['to-string', ['round', ['to-number', ['get', 'temperature'], 20]]], '°C'],
'text-size': 11,
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
'text-justify': 'center',
'text-anchor': 'center'
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(0, 0, 0, 0.6)',
'text-halo-width': 1.5
}
});
// 3. Wind indicator arrow (rotated by wind direction angle)
map.current!.addLayer({
id: 'weather-wind-arrows',
type: 'symbol',
source: 'weather-cities-source',
minzoom: 4,
layout: {
'text-field': '➤',
'text-rotation-alignment': 'map',
'text-rotate': ['to-number', ['get', 'windDirection'], 0],
'text-size': 14,
'text-offset': [2.2, 0],
'text-allow-overlap': true
},
paint: {
'text-color': '#38bdf8',
'text-halo-color': '#0f172a',
'text-halo-width': 2
}
});
// 4. Wind speed badge (e.g. 15 km/h)
map.current!.addLayer({
id: 'weather-wind-speed',
type: 'symbol',
source: 'weather-cities-source',
minzoom: 4,
layout: {
'text-field': ['concat', ['to-string', ['round', ['to-number', ['get', 'windSpeed'], 10]]], ' km/h'],
'text-size': 9,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-offset': [0, 2.6],
'text-anchor': 'top',
'text-allow-overlap': false
},
paint: {
'text-color': '#bae6fd',
'text-halo-color': 'rgba(15, 23, 42, 0.85)',
'text-halo-width': 2
}
});
const handleCityClick = (e: any) => {
if (e.originalEvent) {
e.originalEvent.stopPropagation();
}
if (onCityClick && e.features && e.features.length > 0) {
onCityClick(e.features[0].properties);
}
};
map.current!.on('click', 'weather-city-circles', handleCityClick);
map.current!.on('click', 'weather-city-icons', handleCityClick);
map.current!.on('mouseenter', 'weather-city-circles', () => {
if (map.current) map.current.getCanvas().style.cursor = 'pointer';
});
map.current!.on('mouseleave', 'weather-city-circles', () => {
if (map.current) map.current.getCanvas().style.cursor = '';
});
} else {
(map.current!.getSource('weather-cities-source') as maplibregl.GeoJSONSource).setData(citiesGeoJson as any);
map.current!.setLayoutProperty('weather-city-circles', 'visibility', 'visible');
map.current!.setLayoutProperty('weather-city-icons', 'visibility', 'visible');
map.current!.setLayoutProperty('weather-wind-arrows', 'visibility', 'visible');
map.current!.setLayoutProperty('weather-wind-speed', 'visibility', 'visible');
}
} catch (e) {
console.warn('Weather fetch failed', e);
}
};
fetchWeather();
} else {
const weatherLayers = ['weather-city-circles', 'weather-city-icons', 'weather-wind-arrows', 'weather-wind-speed'];
weatherLayers.forEach(layerId => {
if (map.current && map.current.getLayer(layerId)) {
map.current.setLayoutProperty(layerId, 'visibility', 'none');
}
});
}
}, [showWeather, currentRegion, onCityClick]);
const onMapClickRef = useRef(onMapClick);
useEffect(() => {
onMapClickRef.current = onMapClick;
}, [onMapClick]);
// Update canvas cursor when LOS is active
useEffect(() => {
if (map.current) {
map.current.getCanvas().style.cursor = losActive ? 'crosshair' : '';
}
}, [losActive]);
useEffect(() => {
if (map.current) return;
@@ -129,6 +421,53 @@ const MapComponent: React.FC<MapComponentProps> = ({
initialMap.on('load', () => {
console.log("MapComponent: Map Loaded Successfully");
// Initialize Route Source and Layers
if (!initialMap.getSource('route')) {
initialMap.addSource('route', {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: []
}
}
});
// Route outer shadow/glow
initialMap.addLayer({
id: 'route-casing',
type: 'line',
source: 'route',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': '#0369a1',
'line-width': 9,
'line-opacity': 0.8
}
});
// Route inner bright line
initialMap.addLayer({
id: 'route-line',
type: 'line',
source: 'route',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': '#38bdf8',
'line-width': 5,
'line-opacity': 1.0
}
});
}
const contourUrl = demSource.contourProtocolUrl({
thresholds: {
10: [100, 500],
@@ -211,13 +550,13 @@ const MapComponent: React.FC<MapComponentProps> = ({
onMapLoad(map.current!);
});
map.current.on('click', (e) => {
if (onMapClick) {
onMapClick(e.lngLat.lat, e.lngLat.lng);
initialMap.on('click', (e) => {
if (onMapClickRef.current) {
onMapClickRef.current(e.lngLat.lat, e.lngLat.lng);
}
});
map.current.on('error', (e) => {
initialMap.on('error', (e) => {
console.error("MapComponent: Map Error:", e);
});
+195
View File
@@ -0,0 +1,195 @@
import React, { useState } from 'react';
import { getWeatherDescription, getTemperatureColor } from '../utils/weatherIcons';
interface WeatherPanelProps {
visible: boolean;
selectedCity?: any;
alerts?: any[];
}
const WeatherPanel: React.FC<WeatherPanelProps> = ({ visible, selectedCity, alerts }) => {
const [minimized, setMinimized] = useState(false);
if (!visible) return null;
const panelStyle: React.CSSProperties = {
position: 'absolute',
bottom: '24px',
right: '24px',
width: '340px',
maxHeight: '440px',
overflowY: 'auto',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
border: '1px solid rgba(255, 255, 255, 0.12)',
borderRadius: '16px',
padding: '16px',
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.5)',
zIndex: 1000,
direction: 'rtl',
fontFamily: 'Inter, system-ui, sans-serif',
color: '#f8fafc'
};
if (minimized) {
return (
<div
style={{
...panelStyle,
width: 'auto',
cursor: 'pointer',
padding: '10px 18px',
fontWeight: 600,
background: 'rgba(30, 41, 59, 0.9)'
}}
onClick={() => setMinimized(false)}
>
🌤️ الطقس (Weather)
</div>
);
}
const renderCurrentWeather = () => {
if (!selectedCity) return null;
const code = Number(selectedCity.weatherCode ?? selectedCity.weather_code ?? 0);
const temp = Number(selectedCity.temperature ?? 0);
const desc = getWeatherDescription(code);
return (
<div style={{ marginBottom: '14px', padding: '14px', background: 'rgba(255,255,255,0.06)', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.08)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#fff' }}>{selectedCity.name_ar || selectedCity.name || 'المدينة'}</h3>
<span style={{ fontSize: '2rem' }}>{desc.icon}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginTop: '6px' }}>
<span style={{ fontSize: '2.2rem', fontWeight: 800, color: getTemperatureColor(temp) }}>
{Math.round(temp)}°C
</span>
<span style={{ fontSize: '1rem', color: '#94a3b8' }}>{desc.description_ar}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginTop: '12px', fontSize: '0.82rem', color: '#cbd5e1' }}>
<div>💧 الرطوبة: <b>{selectedCity.humidity || 0}%</b></div>
<div>💨 الرياح: <b>{selectedCity.windSpeed || 0} كم/س</b></div>
<div>☁️ الغطاء: <b>{selectedCity.cloudCover || 0}%</b></div>
<div>🌡️ الشعور: <b>{Math.round(Number(selectedCity.feelsLike ?? temp))}°C</b></div>
</div>
</div>
);
};
const renderForecast = () => {
if (!selectedCity || !selectedCity.forecast) return null;
let forecastArray: any[] = [];
try {
forecastArray = typeof selectedCity.forecast === 'string' ? JSON.parse(selectedCity.forecast) : selectedCity.forecast;
} catch(e) {
forecastArray = [];
}
if (!Array.isArray(forecastArray) || forecastArray.length === 0) return null;
const days = ['الأحد', 'الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت'];
return (
<div style={{ marginBottom: '14px' }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: '0.88rem', color: '#94a3b8' }}>📅 توقعات 7 أيام</h4>
<div style={{ display: 'flex', overflowX: 'auto', gap: '8px', paddingBottom: '6px' }}>
{forecastArray.map((f: any, idx: number) => {
const code = Number(f.weatherCode ?? f.weather_code ?? 0);
const desc = getWeatherDescription(code);
const dateObj = f.date ? new Date(f.date) : null;
const dayName = dateObj ? days[dateObj.getDay()] : `يوم ${idx + 1}`;
const maxTemp = Math.round(Number(f.tempMax ?? f.max_temp ?? 0));
const minTemp = Math.round(Number(f.tempMin ?? f.min_temp ?? 0));
return (
<div key={idx} style={{
minWidth: '65px',
padding: '8px 6px',
background: 'rgba(255,255,255,0.05)',
borderRadius: '10px',
textAlign: 'center',
border: '1px solid rgba(255,255,255,0.06)'
}}>
<div style={{ fontSize: '0.72rem', fontWeight: 600, color: '#94a3b8' }}>{dayName}</div>
<div style={{ fontSize: '1.3rem', margin: '4px 0' }}>{desc.icon}</div>
<div style={{ fontSize: '0.78rem' }}>
<span style={{ color: '#f87171', fontWeight: 600 }}>{maxTemp}°</span>
{' '}
<span style={{ color: '#60a5fa' }}>{minTemp}°</span>
</div>
</div>
);
})}
</div>
</div>
);
};
const renderAlerts = () => {
if (!alerts || alerts.length === 0) return null;
return (
<div>
<h4 style={{ margin: '0 0 8px 0', fontSize: '0.88rem', color: '#f59e0b' }}>⚠️ تنبيهات الطقس</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{alerts.map((alert, idx) => {
const severity = alert.severity || alert.level || 'info';
const colors: Record<string, string> = {
danger: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6'
};
const color = colors[severity] || colors.info;
return (
<div key={idx} style={{
padding: '8px 10px',
borderRadius: '8px',
backgroundColor: `${color}20`,
borderRight: `3px solid ${color}`,
fontSize: '0.82rem'
}}>
<div style={{ fontWeight: 700, color, marginBottom: '2px' }}>
📍 {alert.city_ar || alert.city || alert.city_name_ar || alert.city_name}: {alert.type || 'تنبيه'}
</div>
<div style={{ color: '#cbd5e1' }}>{alert.message_ar || alert.message}</div>
</div>
);
})}
</div>
</div>
);
};
return (
<div style={panelStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid rgba(255,255,255,0.1)', paddingBottom: '8px', marginBottom: '12px' }}>
<h2 style={{ margin: 0, fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '6px' }}>
🌤️ حالة الطقس المباشرة
</h2>
<button
onClick={() => setMinimized(true)}
style={{ background: 'transparent', border: 'none', cursor: 'pointer', fontSize: '0.9rem', color: '#94a3b8' }}
>
✕
</button>
</div>
{!selectedCity && (
<div style={{ textAlign: 'center', padding: '16px 8px', color: '#94a3b8', fontSize: '0.85rem', background: 'rgba(255,255,255,0.03)', borderRadius: '8px', marginBottom: '10px' }}>
💡 اضغط على أي مدينة على الخريطة لعرض تفاصيلها وتوقعات 7 أيام
</div>
)}
{renderCurrentWeather()}
{renderForecast()}
{renderAlerts()}
</div>
);
};
export default WeatherPanel;