Compare commits
6
Commits
ba4ae15eab
...
cb384765fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb384765fb | ||
|
|
8375773898 | ||
|
|
5d813f3a3f | ||
|
|
f1e31cb721 | ||
|
|
13ff137359 | ||
|
|
c9b3249aa5 |
@@ -4,7 +4,7 @@ import axios from 'axios';
|
||||
|
||||
// Constants
|
||||
const API_URL = process.env.TEST_API_URL || 'https://map-saas.intaleqapp.com/api/geocoding/search';
|
||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||
let DATA_FILE = path.join(__dirname, '../../../data/golden_tests/queries_syria.json');
|
||||
if (!fs.existsSync(DATA_FILE) && fs.existsSync('/data/data/golden_tests/queries_syria.json')) {
|
||||
DATA_FILE = '/data/data/golden_tests/queries_syria.json'; // Docker environment fallback
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||
const TOTAL_REQUESTS = 5000;
|
||||
const CONCURRENCY = 50; // Number of parallel requests
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/autocomplete';
|
||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||
|
||||
const testQueries = [
|
||||
"مستش",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||
|
||||
const testQueries = [
|
||||
"السمساني", // Should suggest "الشميساني"
|
||||
|
||||
@@ -2,7 +2,7 @@ import axios from 'axios';
|
||||
import { Client } from 'pg';
|
||||
|
||||
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||
const DB_URL = process.env.DATABASE_URL || 'postgresql://mapuser:TestMapPass123!@localhost:5432/mapdb';
|
||||
|
||||
async function testFailedLogging() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||
|
||||
const testQueries = [
|
||||
"سيتي مول",
|
||||
|
||||
@@ -2,7 +2,7 @@ import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||
const AUTO_URL = process.env.TEST_AUTO_URL || 'http://localhost:3200/api/geocoding/autocomplete';
|
||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||
|
||||
const testQueries = [
|
||||
"قرب مستشفى",
|
||||
|
||||
@@ -30,9 +30,10 @@ export class AuthModule implements OnModuleInit {
|
||||
* دمج مفتاح الأمان الافتراضي من الإعدادات لمنع توقف الرقابة الحالية
|
||||
*/
|
||||
async onModuleInit() {
|
||||
const defaultKey = this.configService.get<string>('MAP_API_KEY') || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
await this.authService.seedDefaultKey('Default System', 'admin@intaleq.xyz', defaultKey);
|
||||
await this.authService.seedDefaultKey('Default Fallback', 'support@intaleq.xyz', 'intaleq_secret_2026');
|
||||
console.log('✅ System API Keys seeded successfully');
|
||||
const defaultKey = this.configService.get<string>('MAP_API_KEY');
|
||||
if (defaultKey) {
|
||||
await this.authService.seedDefaultKey('Default System', 'admin@intaleq.xyz', defaultKey);
|
||||
console.log('✅ System API Key from environment seeded successfully');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, UnauthorizedException, Logger, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException, Logger, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { createHash } from 'crypto';
|
||||
@@ -126,6 +126,21 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete / Revoke an API key for a tenant with strict IDOR verification
|
||||
*/
|
||||
async deleteApiKey(tenantId: string, keyId: string): Promise<void> {
|
||||
const key = await this.apiKeyRepository.findOne({ where: { id: keyId } });
|
||||
if (!key) {
|
||||
throw new NotFoundException('API Key not found');
|
||||
}
|
||||
if (key.tenantId !== tenantId) {
|
||||
throw new ForbiddenException('Access denied: You cannot delete an API key belonging to another tenant');
|
||||
}
|
||||
await this.redisService.del(`auth:apikey:${key.key}`);
|
||||
await this.apiKeyRepository.delete(keyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all API keys for a specific tenant
|
||||
*/
|
||||
@@ -141,16 +156,16 @@ export class AuthService {
|
||||
*/
|
||||
async createApiKey(tenantId: string, name: string, rateLimit?: number, allowedOrigins?: string[]): Promise<ApiKey> {
|
||||
const existingKeysCount = await this.apiKeyRepository.count({ where: { tenantId } });
|
||||
if (existingKeysCount >= 1) {
|
||||
throw new ConflictException('Limit reached: Only 1 API key allowed per developer currently.');
|
||||
if (existingKeysCount >= 5) {
|
||||
throw new ConflictException('Limit reached: Maximum 5 API keys allowed per tenant.');
|
||||
}
|
||||
|
||||
const key = `in_${createHash('md5').update(Math.random().toString()).digest('hex').substring(0, 24)}`;
|
||||
const key = `in_${createHash('sha256').update(tenantId + Date.now().toString() + Math.random().toString()).digest('hex').substring(0, 28)}`;
|
||||
|
||||
const apiKey = this.apiKeyRepository.create({
|
||||
key,
|
||||
secretHash: this.hashSecret(key),
|
||||
name,
|
||||
name: name || 'Production Key',
|
||||
tenantId,
|
||||
rateLimit: rateLimit || 100,
|
||||
allowedOrigins: allowedOrigins || [],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Post, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Delete, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CreateKeyDto } from './dto/management/create-key.dto';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
@@ -30,6 +30,21 @@ export class TenantController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('keys/:tenantId')
|
||||
@ApiOperation({ summary: 'Create a new API key for specified tenant (IDOR Protected)' })
|
||||
async createKeyForTenant(
|
||||
@Req() req: any,
|
||||
@Param('tenantId') tenantId: string,
|
||||
@Body() dto: CreateKeyDto
|
||||
) {
|
||||
// IDOR Protection: Tenant can only create keys for their own tenant ID
|
||||
if (req.tenant.id !== tenantId) {
|
||||
throw new ForbiddenException('Access denied: Cannot create API keys for another tenant.');
|
||||
}
|
||||
|
||||
return this.createKey(req, dto);
|
||||
}
|
||||
|
||||
@Get('keys')
|
||||
@ApiOperation({ summary: 'Get all API keys for the authenticated tenant' })
|
||||
async getKeys(@Req() req: any) {
|
||||
@@ -37,9 +52,35 @@ export class TenantController {
|
||||
return this.authService.getApiKeys(tenantId);
|
||||
}
|
||||
|
||||
@Get('keys/:tenantId')
|
||||
@ApiOperation({ summary: 'Get all API keys for specified tenant (IDOR Protected)' })
|
||||
async getKeysForTenant(
|
||||
@Req() req: any,
|
||||
@Param('tenantId') tenantId: string
|
||||
) {
|
||||
// IDOR Protection: Tenant can only access their own keys
|
||||
if (req.tenant.id !== tenantId) {
|
||||
throw new ForbiddenException('Access denied: Cannot view API keys for another tenant.');
|
||||
}
|
||||
|
||||
return this.authService.getApiKeys(req.tenant.id);
|
||||
}
|
||||
|
||||
@Delete('keys/:keyId')
|
||||
@ApiOperation({ summary: 'Revoke / Delete an API key (IDOR Protected)' })
|
||||
async deleteKey(
|
||||
@Req() req: any,
|
||||
@Param('keyId') keyId: string
|
||||
) {
|
||||
const tenantId = req.tenant.id;
|
||||
await this.authService.deleteApiKey(tenantId, keyId);
|
||||
return { success: true, message: 'API key revoked successfully' };
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Get current authenticated tenant info' })
|
||||
async getMe(@Req() req: any) {
|
||||
return req.tenant;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
@@ -26,6 +27,21 @@ import { TacticalService } from './tactical.service';
|
||||
export class TacticalController {
|
||||
constructor(private readonly tacticalService: TacticalService) {}
|
||||
|
||||
@Get('verify-license')
|
||||
@ApiOperation({ summary: 'Verify tactical clearance and military license' })
|
||||
async verifyLicense(@Req() req: any) {
|
||||
const tenant = req.tenant;
|
||||
const apiKey = req.apiKey;
|
||||
return {
|
||||
valid: true,
|
||||
tenantName: tenant?.name || 'Authorized Tactical Operator',
|
||||
plan: tenant?.plan || 'ENTERPRISE',
|
||||
keyName: apiKey?.name || 'Tactical Defense Key',
|
||||
rateLimit: req.rateLimit || 1000,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('line-of-sight')
|
||||
@ApiOperation({
|
||||
summary: 'Calculate Tactical Line of Sight & Intervisibility (تبادل الرؤية العسكري)',
|
||||
|
||||
@@ -328,9 +328,14 @@ const app = {
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-right">
|
||||
<button class="p-2 text-slate-500 hover:text-white" onclick="app.fetchData()">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button class="p-2 text-slate-500 hover:text-white" title="Refresh" onclick="app.fetchData()">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<button class="p-2 text-slate-500 hover:text-red-400 transition-colors" title="Revoke Key" onclick="app.deleteKey('${key.id}', '${key.name}')">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -339,6 +344,29 @@ const app = {
|
||||
lucide.createIcons();
|
||||
},
|
||||
|
||||
deleteKey: async (keyId, keyName) => {
|
||||
if (!confirm(`Are you sure you want to revoke the API key "${keyName || 'Production Key'}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/auth/management/keys/${keyId}`, {
|
||||
method: 'DELETE',
|
||||
headers: auth.getAuthHeader()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await app.fetchData();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(data.message || 'Failed to revoke API key');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Delete key failed:', e);
|
||||
alert('Failed to revoke API key');
|
||||
}
|
||||
},
|
||||
|
||||
toggleKeyVisibility: (id) => {
|
||||
app.state.showKeys[id] = !app.state.showKeys[id];
|
||||
app.renderKeysTable();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
ShieldCheck,
|
||||
Globe,
|
||||
Zap,
|
||||
@@ -53,7 +54,7 @@ const DashboardHome = ({ tenant, keys, loading, onRefresh }: DashboardHomeProps)
|
||||
const createKey = async (name: string, limit: number) => {
|
||||
if (!tenant) return;
|
||||
try {
|
||||
await axios.post(`/api/auth/management/keys/${tenant.id}`, {
|
||||
await axios.post(`/api/auth/management/keys`, {
|
||||
name,
|
||||
rateLimit: limit
|
||||
});
|
||||
@@ -65,6 +66,18 @@ const DashboardHome = ({ tenant, keys, loading, onRefresh }: DashboardHomeProps)
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKey = async (keyId: string, keyName: string) => {
|
||||
if (!window.confirm(`Are you sure you want to revoke the API key "${keyName || 'Production Key'}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.delete(`/api/auth/management/keys/${keyId}`);
|
||||
onRefresh();
|
||||
} catch (e) {
|
||||
alert('Failed to revoke key');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* Hero Section */}
|
||||
@@ -264,9 +277,14 @@ const DashboardHome = ({ tenant, keys, loading, onRefresh }: DashboardHomeProps)
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-6 text-right">
|
||||
<button className="p-2 text-slate-500 hover:text-white" onClick={onRefresh}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button className="p-2 text-slate-500 hover:text-white" title="Refresh" onClick={onRefresh}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<button className="p-2 text-slate-500 hover:text-red-400 transition-colors" title="Revoke Key" onClick={() => deleteKey(apiKey.id, apiKey.name)}>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -346,8 +346,8 @@
|
||||
CONFIG
|
||||
─────────────────────────────────────────── */
|
||||
const API_BASE = window.location.origin; // Dynamically use the current host
|
||||
const API_KEY = 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
const HEADERS = { 'x-api-key': API_KEY };
|
||||
const API_KEY = new URLSearchParams(window.location.search).get('api_key') || localStorage.getItem('map_api_key') || '';
|
||||
const HEADERS = API_KEY ? { 'x-api-key': API_KEY } : {};
|
||||
|
||||
const ROUTES = {
|
||||
LEVANT: {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { decodePolyline } from './utils/polyline';
|
||||
import WeatherPanel from './components/WeatherPanel';
|
||||
import { LineOfSightTool } from './components/LineOfSightTool';
|
||||
|
||||
const DEFAULT_API_KEY = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
const DEFAULT_API_KEY = (import.meta as any).env.VITE_API_KEY || '';
|
||||
|
||||
function App() {
|
||||
const [map, setMap] = useState<any>(null);
|
||||
|
||||
@@ -1,32 +1,8 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
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',
|
||||
maxzoom: 14,
|
||||
worker: true,
|
||||
});
|
||||
demSource.setupMaplibre(maplibregl);
|
||||
import { getSharedDemSource } from '../utils/demSource';
|
||||
|
||||
const MapComponent: React.FC<MapProps> = ({
|
||||
onMapLoad,
|
||||
@@ -207,7 +183,7 @@ const MapComponent: React.FC<MapProps> = ({
|
||||
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 apiKey = (import.meta as any).env.VITE_API_KEY || '';
|
||||
|
||||
const regionParam = currentRegion ? `?region=${encodeURIComponent(currentRegion)}` : '';
|
||||
const citiesRes = await fetch(`${apiUrl}/weather/cities${regionParam}`, {
|
||||
@@ -468,6 +444,7 @@ const MapComponent: React.FC<MapProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
const demSource = getSharedDemSource();
|
||||
const contourUrl = demSource.contourProtocolUrl({
|
||||
thresholds: {
|
||||
10: [100, 500],
|
||||
|
||||
+86
-25
@@ -73,14 +73,70 @@ function ViewNav({ hash }: { hash: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean; error: any }> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: any) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: any, errorInfo: any) {
|
||||
console.error('App UI Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
background: '#0f172a',
|
||||
color: '#fff',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
direction: 'rtl',
|
||||
padding: 24,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '3rem', marginBottom: 16 }}>⚠️</div>
|
||||
<h2 style={{ fontSize: '1.4rem', fontWeight: 700, marginBottom: 8 }}>حدث خطأ أثناء تحميل الواجهة</h2>
|
||||
<p style={{ color: '#94a3b8', fontSize: '0.9rem', maxWidth: 450, marginBottom: 20 }}>
|
||||
{this.state.error?.message || 'تعذر تحميل أحد مكونات الخريطة'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { window.location.hash = '#map'; window.location.reload(); }}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
background: '#6366f1',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.95rem'
|
||||
}}
|
||||
>
|
||||
إعادة تحميل الخريطة الأساسية
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function Root() {
|
||||
const [hash, setHash] = useState(window.location.hash || '#tactical')
|
||||
const [hash, setHash] = useState(window.location.hash || '#tactical');
|
||||
|
||||
useEffect(() => {
|
||||
const on = () => {
|
||||
const h = window.location.hash || '#tactical';
|
||||
setHash(h);
|
||||
// Trigger a resize event to ensure MapLibre maps adapt to visibility changes instantly
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, 50);
|
||||
@@ -96,35 +152,40 @@ function Root() {
|
||||
const isMap = !isTactical && !isExecutive && !isCompare && !isReview;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<ViewNav hash={hash} />
|
||||
|
||||
{/* Keep-Alive Persistent Views: Preserves Map Instances and GPU Tile Caches across Tab switches */}
|
||||
<div style={{ display: isTactical ? 'block' : 'none', width: '100vw', height: '100vh', position: 'absolute', top: 0, left: 0 }}>
|
||||
<TacticalDefenseView />
|
||||
</div>
|
||||
{isTactical && (
|
||||
<div style={{ width: '100vw', height: '100vh', position: 'absolute', top: 0, left: 0 }}>
|
||||
<TacticalDefenseView />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: isExecutive ? 'block' : 'none', width: '100vw', height: '100vh', position: 'absolute', top: 0, left: 0 }}>
|
||||
<ExecutiveShowcase />
|
||||
</div>
|
||||
{isExecutive && (
|
||||
<div style={{ width: '100vw', height: '100vh', position: 'absolute', top: 0, left: 0 }}>
|
||||
<ExecutiveShowcase />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: isCompare ? 'block' : 'none', width: '100vw', height: '100vh', position: 'absolute', top: 0, left: 0 }}>
|
||||
<CompareView />
|
||||
</div>
|
||||
{isCompare && (
|
||||
<div style={{ width: '100vw', height: '100vh', position: 'absolute', top: 0, left: 0 }}>
|
||||
<CompareView />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: isReview ? 'block' : 'none', width: '100vw', height: '100vh', position: 'absolute', top: 0, left: 0 }}>
|
||||
<IntelligenceDashboard />
|
||||
</div>
|
||||
{isReview && (
|
||||
<div style={{ width: '100vw', height: '100vh', position: 'absolute', top: 0, left: 0 }}>
|
||||
<IntelligenceDashboard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: isMap ? 'block' : 'none', width: '100vw', height: '100vh', position: 'absolute', top: 0, left: 0 }}>
|
||||
<App />
|
||||
</div>
|
||||
</>
|
||||
{isMap && (
|
||||
<div style={{ width: '100vw', height: '100vh', position: 'absolute', top: 0, left: 0 }}>
|
||||
<App />
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Root />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(<Root />);
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import mlcontour from 'maplibre-contour';
|
||||
import { attachIconLoader } from '../utils/mapIcons';
|
||||
|
||||
// Initialize DEM source for on-the-fly global contours (Jordan, Syria, Iraq, Egypt, etc.)
|
||||
const demSource = new mlcontour.DemSource({
|
||||
url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png',
|
||||
encoding: 'terrarium',
|
||||
maxzoom: 14,
|
||||
worker: true,
|
||||
});
|
||||
demSource.setupMaplibre(maplibregl);
|
||||
import { getSharedDemSource } from '../utils/demSource';
|
||||
|
||||
/**
|
||||
* CompareView — side-by-side, pan/zoom-synced map comparison.
|
||||
@@ -102,6 +93,7 @@ const CompareView: React.FC = () => {
|
||||
|
||||
lMap.on('load', () => {
|
||||
// Dynamic On-the-Fly Contours from Elevation DEM
|
||||
const demSource = getSharedDemSource();
|
||||
const contourUrl = demSource.contourProtocolUrl({
|
||||
thresholds: {
|
||||
10: [100, 500],
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
Plane,
|
||||
Radar,
|
||||
Sparkles,
|
||||
ExternalLink
|
||||
ExternalLink,
|
||||
Flame,
|
||||
ShieldAlert
|
||||
} from 'lucide-react';
|
||||
|
||||
export const ExecutiveShowcase: React.FC = () => {
|
||||
|
||||
@@ -19,17 +19,17 @@ type RefKey =
|
||||
| 'osm';
|
||||
|
||||
const REFS: Record<RefKey, { label: string; tiles: string; attribution: string; maxzoom: number }> = {
|
||||
'google-hybrid': { label: '🛰️ Google Hybrid', tiles: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', attribution: '© Google', maxzoom: 20 },
|
||||
'google-topo': { label: '🏔️ Google Topo', tiles: 'https://mt1.google.com/vt/lyrs=p&x={x}&y={y}&z={z}', attribution: '© Google', maxzoom: 20 },
|
||||
'soviet-military': { label: '🇷🇺 Soviet Military Topo', tiles: 'https://maps.marshruty.ru/ml.ashx?x={x}&y={y}&z={z}&i=1&al=1', attribution: '© Soviet Military Topo 1:100k', maxzoom: 15 },
|
||||
'opentopo': { label: '🧭 OpenTopoMap', tiles: 'https://tile.opentopomap.org/{z}/{x}/{y}.png', attribution: '© OpenTopoMap', maxzoom: 17 },
|
||||
'esri-clarity': { label: '✨ Esri Clarity HD', tiles: 'https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri', maxzoom: 19 },
|
||||
'google-sat': { label: '🛰️ Google Sat', tiles: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', attribution: '© Google', maxzoom: 20 },
|
||||
'google-streets': { label: '🗺️ Google Streets', tiles: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', attribution: '© Google', maxzoom: 20 },
|
||||
'esri-sat': { label: '🛰️ Esri Satellite', tiles: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri, Maxar', maxzoom: 19 },
|
||||
'esri-topo': { label: '🏔️ Esri Topo', tiles: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri', maxzoom: 19 },
|
||||
'esri-streets': { label: '🗺️ Esri Streets', tiles: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri', maxzoom: 19 },
|
||||
'osm': { label: '🧭 OSM Standard', tiles: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap', maxzoom: 19 },
|
||||
'google-hybrid': { label: '🛰️ Google Hybrid', tiles: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', attribution: '© Google', maxzoom: 20 },
|
||||
'google-topo': { label: '🏔️ Google Topo', tiles: 'https://mt1.google.com/vt/lyrs=p&x={x}&y={y}&z={z}', attribution: '© Google', maxzoom: 20 },
|
||||
'soviet-military': { label: '🇷🇺 Soviet Military Topo', tiles: 'https://maps.marshruty.ru/ml.ashx?x={x}&y={y}&z={z}&i=1&al=1', attribution: '© Soviet Military Topo 1:100k', maxzoom: 15 },
|
||||
'opentopo': { label: '🧭 OpenTopoMap', tiles: 'https://tile.opentopomap.org/{z}/{x}/{y}.png', attribution: '© OpenTopoMap', maxzoom: 17 },
|
||||
'esri-clarity': { label: '✨ Esri Clarity HD', tiles: 'https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri', maxzoom: 19 },
|
||||
'google-sat': { label: '🛰️ Google Sat', tiles: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', attribution: '© Google', maxzoom: 20 },
|
||||
'google-streets': { label: '🗺️ Google Streets', tiles: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', attribution: '© Google', maxzoom: 20 },
|
||||
'esri-sat': { label: '🛰️ Esri Satellite', tiles: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri, Maxar', maxzoom: 19 },
|
||||
'esri-topo': { label: '🏔️ Esri Topo', tiles: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri', maxzoom: 19 },
|
||||
'esri-streets': { label: '🗺️ Esri Streets', tiles: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri', maxzoom: 19 },
|
||||
'osm': { label: '🧭 OSM Standard', tiles: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap', maxzoom: 19 },
|
||||
};
|
||||
|
||||
const API = (import.meta as any).env.VITE_API_URL || '/api';
|
||||
@@ -42,6 +42,8 @@ const rasterStyle = (ref: any): any => ({
|
||||
layers: [{ id: 'ref', type: 'raster', source: 'ref' }],
|
||||
});
|
||||
|
||||
const DEFAULT_SYSTEM_KEY = (import.meta as any).env.VITE_API_KEY;
|
||||
|
||||
const IntelligenceDashboard: React.FC = () => {
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [cands, setCands] = useState<CandidateRoad[]>([]);
|
||||
@@ -49,8 +51,8 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
const [msg, setMsg] = useState('');
|
||||
const [tab, setTab] = useState<'summary' | 'candidates' | 'closures'>('candidates');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('map_admin_key') || '');
|
||||
const [needsAuth, setNeedsAuth] = useState(!localStorage.getItem('map_admin_key'));
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('map_admin_key') || DEFAULT_SYSTEM_KEY);
|
||||
const [needsAuth, setNeedsAuth] = useState(false);
|
||||
const [sel, setSel] = useState<string | null>(null);
|
||||
|
||||
// Right Map State
|
||||
@@ -93,7 +95,7 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
[point.x - radius, point.y - radius],
|
||||
[point.x + radius, point.y + radius]
|
||||
];
|
||||
|
||||
|
||||
try {
|
||||
const features = map.queryRenderedFeatures(bbox).filter(f => {
|
||||
const id = f.layer?.id || '';
|
||||
@@ -107,22 +109,22 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
const geom = f.geometry;
|
||||
if (!geom) continue;
|
||||
|
||||
const lines: [number, number][][] = geom.type === 'LineString'
|
||||
const lines: [number, number][][] = geom.type === 'LineString'
|
||||
? [geom.coordinates as [number, number][]]
|
||||
: geom.type === 'MultiLineString'
|
||||
? (geom.coordinates as [number, number][][])
|
||||
: [];
|
||||
? (geom.coordinates as [number, number][][])
|
||||
: [];
|
||||
|
||||
for (const line of lines) {
|
||||
for (let i = 0; i < line.length - 1; i++) {
|
||||
const p1 = map.project(line[i] as any);
|
||||
const p2 = map.project(line[i + 1] as any);
|
||||
|
||||
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq === 0) continue;
|
||||
|
||||
|
||||
const t = Math.max(0, Math.min(1, ((point.x - p1.x) * dx + (point.y - p1.y) * dy) / lenSq));
|
||||
const closePx = { x: p1.x + t * dx, y: p1.y + t * dy };
|
||||
const distPx = Math.hypot(point.x - closePx.x, point.y - closePx.y);
|
||||
@@ -306,14 +308,14 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
const nameLine = p.name ? `<b>${p.name}</b><br/>` : '';
|
||||
const evidence = isOvt ? `${p.uniqueDriverCount ?? 0} trace pts corroborate` : `${p.uniqueDriverCount || 1} drivers`;
|
||||
if (popupRef.current) popupRef.current.remove();
|
||||
|
||||
|
||||
const popupDiv = document.createElement('div');
|
||||
popupDiv.style.font = '12px sans-serif';
|
||||
popupDiv.style.color = '#1e293b';
|
||||
popupDiv.style.lineHeight = '1.6';
|
||||
popupDiv.innerHTML = `${nameLine}<b>${title}</b><br/>Conf: <b style="color:${cc(p.confidence)}">${Math.round(p.confidence * 100)}%</b><br/>${Math.round(p.lengthMeters || 0)}m | ${evidence}
|
||||
<br/><button id="btn-del-${p.id}" style="margin-top:8px;background:#ef4444;color:white;border:none;border-radius:4px;padding:4px 8px;cursor:pointer;">🗑️ Delete Road</button>`;
|
||||
|
||||
|
||||
popupDiv.querySelector(`#btn-del-${p.id}`)?.addEventListener('click', () => {
|
||||
deleteRoad(p.id, isAppr ? 'approved' : 'candidate');
|
||||
popupRef.current?.remove();
|
||||
@@ -499,12 +501,12 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
setTimeout(() => setMsg(''), 5000);
|
||||
};
|
||||
|
||||
const cancelDrawing = () => {
|
||||
pointsRef.current = [];
|
||||
redrawDrawing();
|
||||
const cancelDrawing = () => {
|
||||
pointsRef.current = [];
|
||||
redrawDrawing();
|
||||
if (leftMapRef.current) updateSnapGuide(leftMapRef.current, null);
|
||||
if (rightMapRef.current) updateSnapGuide(rightMapRef.current, null);
|
||||
setDrawing(false);
|
||||
setDrawing(false);
|
||||
};
|
||||
|
||||
if (needsAuth) {
|
||||
@@ -689,8 +691,8 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
{/* Left Map: Our Data */}
|
||||
<div style={{ flex: 1, position: 'relative', borderRight: '2px solid #0f172a' }}>
|
||||
<div ref={leftDiv} style={{ width: '100%', height: '100%' }} />
|
||||
<div style={{ position: 'absolute', top: 10, left: 10, zIndex: 2, background: 'rgba(15,23,42,0.85)', border: '1px solid #334155', padding: '4px 10px', borderRadius: '20px', fontSize: '0.72rem', fontWeight: 600, pointerEvents: 'none' }}>
|
||||
Our Map (Intaleq)
|
||||
<div style={{ position: 'absolute', top: 62, left: 16, zIndex: 30, background: 'rgba(15,23,42,0.92)', backdropFilter: 'blur(8px)', border: '1px solid #334155', padding: '6px 14px', borderRadius: '20px', fontSize: '0.8rem', fontWeight: 700, color: '#38bdf8', boxShadow: '0 4px 12px rgba(0,0,0,0.4)', pointerEvents: 'none' }}>
|
||||
🗺️ خريطتنا السيادية (Intaleq Map)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -698,9 +700,9 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<div ref={rightDiv} style={{ width: '100%', height: '100%' }} />
|
||||
|
||||
<div style={{ position: 'absolute', top: 10, left: 10, right: 280, zIndex: 2, display: 'flex', gap: '6px', overflowX: 'auto', paddingBottom: '4px' }}>
|
||||
<div style={{ position: 'absolute', top: 62, left: 16, right: 20, zIndex: 30, display: 'flex', gap: '8px', overflowX: 'auto', paddingBottom: '4px' }}>
|
||||
{(Object.keys(REFS) as Array<keyof typeof REFS>).map(k => (
|
||||
<button key={k} onClick={() => setRefKey(k)} style={{ background: refKey === k ? 'rgba(99,102,241,0.95)' : 'rgba(15,23,42,0.85)', border: '1px solid #334155', color: refKey === k ? 'white' : '#e2e8f0', padding: '4px 10px', borderRadius: '20px', fontSize: '0.72rem', fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
<button key={k} onClick={() => setRefKey(k)} style={{ background: refKey === k ? 'linear-gradient(135deg, #6366f1, #3b82f6)' : 'rgba(15,23,42,0.92)', backdropFilter: 'blur(8px)', border: refKey === k ? '1px solid #818cf8' : '1px solid #334155', color: refKey === k ? 'white' : '#cbd5e1', padding: '6px 14px', borderRadius: '20px', fontSize: '0.78rem', fontWeight: 700, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0, boxShadow: '0 4px 12px rgba(0,0,0,0.3)', transition: 'all 0.15s ease' }}>
|
||||
{REFS[k].label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import mlcontour from 'maplibre-contour';
|
||||
|
||||
const demSource = new mlcontour.DemSource({
|
||||
url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png',
|
||||
encoding: 'terrarium',
|
||||
maxzoom: 15,
|
||||
worker: true,
|
||||
cacheSize: 100,
|
||||
});
|
||||
demSource.setupMaplibre(maplibregl);
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { getSharedDemSource } from '../utils/demSource';
|
||||
import {
|
||||
Shield,
|
||||
Crosshair,
|
||||
@@ -41,7 +32,13 @@ import {
|
||||
Compass,
|
||||
Check,
|
||||
EyeOff,
|
||||
Sliders
|
||||
Sliders,
|
||||
Lock,
|
||||
Unlock,
|
||||
Key,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
LogOut
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
calculateLineOfSight,
|
||||
@@ -87,6 +84,66 @@ export const TacticalDefenseView: React.FC = () => {
|
||||
const mapContainer = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<maplibregl.Map | null>(null);
|
||||
|
||||
// Tactical License & Access Clearance State
|
||||
const [authKey, setAuthKey] = useState<string>(() => {
|
||||
return new URLSearchParams(window.location.search).get('key') ||
|
||||
new URLSearchParams(window.location.search).get('api_key') ||
|
||||
sessionStorage.getItem('tactical_api_key') || '';
|
||||
});
|
||||
const [authStatus, setAuthStatus] = useState<'validating' | 'authorized' | 'unauthorized'>('validating');
|
||||
const [authInfo, setAuthInfo] = useState<{ tenantName?: string; plan?: string; keyName?: string } | null>(null);
|
||||
const [authError, setAuthError] = useState<string>('');
|
||||
const [inputKey, setInputKey] = useState<string>('');
|
||||
const [isVerifying, setIsVerifying] = useState<boolean>(false);
|
||||
|
||||
const verifyTacticalLicense = async (keyCandidate: string) => {
|
||||
if (!keyCandidate || !keyCandidate.trim()) {
|
||||
setAuthStatus('unauthorized');
|
||||
return;
|
||||
}
|
||||
setIsVerifying(true);
|
||||
setAuthError('');
|
||||
try {
|
||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||
const res = await fetch(`${apiUrl}/tactical/verify-license`, {
|
||||
headers: { 'x-api-key': keyCandidate.trim() }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAuthInfo(data);
|
||||
setAuthKey(keyCandidate.trim());
|
||||
sessionStorage.setItem('tactical_api_key', keyCandidate.trim());
|
||||
setAuthStatus('authorized');
|
||||
} else {
|
||||
sessionStorage.removeItem('tactical_api_key');
|
||||
setAuthStatus('unauthorized');
|
||||
setAuthError('مفتاح التصريح التكتيكي غير صالح، أو منتهي الصلاحية، أو تم إلغاؤه من قبل الإدارة.');
|
||||
}
|
||||
} catch (e) {
|
||||
sessionStorage.removeItem('tactical_api_key');
|
||||
setAuthStatus('unauthorized');
|
||||
setAuthError('تعذر التحقق من خادم التخويل التكتيكي. يرجى التأكد من الاتصال.');
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (authKey) {
|
||||
verifyTacticalLicense(authKey);
|
||||
} else {
|
||||
setAuthStatus('unauthorized');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRevokeOrLock = () => {
|
||||
sessionStorage.removeItem('tactical_api_key');
|
||||
setAuthKey('');
|
||||
setAuthStatus('unauthorized');
|
||||
setAuthInfo(null);
|
||||
setAuthError('');
|
||||
};
|
||||
|
||||
const [mode, setMode] = useState<TacticalMode>('terrain');
|
||||
const [cursorPos, setCursorPos] = useState({ lat: 31.95, lng: 35.93, elev: 850 });
|
||||
const [activePlacement, setActivePlacement] = useState<string | null>(null);
|
||||
@@ -456,6 +513,7 @@ export const TacticalDefenseView: React.FC = () => {
|
||||
});
|
||||
|
||||
// Dynamic On-the-Fly Contours from Elevation DEM
|
||||
const demSource = getSharedDemSource();
|
||||
const contourUrl = demSource.contourProtocolUrl({
|
||||
thresholds: {
|
||||
10: [100, 500],
|
||||
@@ -593,15 +651,17 @@ export const TacticalDefenseView: React.FC = () => {
|
||||
['get', 'layerType'],
|
||||
'cliff', 3.5,
|
||||
'road', 4.0,
|
||||
'wadi', 3.0,
|
||||
'wadi', 3.5,
|
||||
'ridge', 2.5,
|
||||
2.5
|
||||
],
|
||||
'line-dasharray': [
|
||||
'match',
|
||||
['get', 'layerType'],
|
||||
'cliff', ['literal', [2, 1]],
|
||||
'ridge', ['literal', [3, 1.5]],
|
||||
'road', ['literal', [1, 0]],
|
||||
'wadi', ['literal', [2, 1.5]],
|
||||
'wadi', ['literal', [1, 0]],
|
||||
['literal', [1, 0]]
|
||||
]
|
||||
}
|
||||
@@ -843,9 +903,9 @@ export const TacticalDefenseView: React.FC = () => {
|
||||
}
|
||||
|
||||
const filtered = terrainResult.spatialGeoJson.features.filter((f: any) => {
|
||||
if (f.properties.category === 'peak' || f.properties.category === 'valley') return showPeaksAndValleys;
|
||||
if (f.properties.category === 'peak' || f.properties.category === 'valley' || f.properties.layerType === 'ridge') return showPeaksAndValleys;
|
||||
if (f.properties.layerType === 'slope-sector') return showSlopeSectors;
|
||||
if (f.properties.layerType === 'wadi' || f.properties.layerType === 'ridge' || f.properties.layerType === 'cliff') return showNaturalObstacles;
|
||||
if (f.properties.layerType === 'wadi') return showNaturalObstacles;
|
||||
if (f.properties.layerType === 'road') return showRoadCorridors;
|
||||
if (f.properties.layerType === 'urban') return showUrbanZones;
|
||||
if (f.properties.layerType === 'hazard' || f.properties.layerType === 'cliff') return showHazardousGround;
|
||||
@@ -989,11 +1049,11 @@ export const TacticalDefenseView: React.FC = () => {
|
||||
setArtilleryLoading(true);
|
||||
try {
|
||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||
const apiKey = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
const keyToSend = authKey || (import.meta as any).env.VITE_API_KEY || '';
|
||||
|
||||
const res = await fetch(`${apiUrl}/tactical/artillery-fire-mission`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': keyToSend },
|
||||
body: JSON.stringify({
|
||||
gunLat: gunPos[0],
|
||||
gunLng: gunPos[1],
|
||||
@@ -1032,10 +1092,10 @@ export const TacticalDefenseView: React.FC = () => {
|
||||
setHlzLoading(true);
|
||||
try {
|
||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||
const apiKey = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
const keyToSend = authKey || (import.meta as any).env.VITE_API_KEY || '';
|
||||
|
||||
const res = await fetch(`${apiUrl}/tactical/hlz-assessment?lat=${hlzCenter[0]}&lng=${hlzCenter[1]}&radius=4000`, {
|
||||
headers: { 'x-api-key': apiKey }
|
||||
headers: { 'x-api-key': keyToSend }
|
||||
});
|
||||
const data = await res.json();
|
||||
setHlzResult(data);
|
||||
@@ -1205,11 +1265,57 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Realtime Cursor HUD */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.72rem', fontFamily: 'monospace', color: '#94a3b8' }}>
|
||||
<div>LAT: <span style={{ color: '#38bdf8' }}>{cursorPos.lat}</span></div>
|
||||
<div>LNG: <span style={{ color: '#38bdf8' }}>{cursorPos.lng}</span></div>
|
||||
<div>ELEV: <span style={{ color: '#4ade80' }}>{cursorPos.elev}m</span></div>
|
||||
{/* Realtime Cursor HUD & Clearance Status */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: '0.72rem', fontFamily: 'monospace', color: '#94a3b8' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div>LAT: <span style={{ color: '#38bdf8' }}>{cursorPos.lat}</span></div>
|
||||
<div>LNG: <span style={{ color: '#38bdf8' }}>{cursorPos.lng}</span></div>
|
||||
<div>ELEV: <span style={{ color: '#4ade80' }}>{cursorPos.elev}m</span></div>
|
||||
</div>
|
||||
|
||||
{authStatus === 'authorized' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, borderRight: '1px solid rgba(255,255,255,0.15)', paddingRight: 10 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
background: 'rgba(34, 197, 94, 0.15)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.35)',
|
||||
color: '#4ade80',
|
||||
padding: '3px 8px',
|
||||
borderRadius: 6,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'inherit'
|
||||
}}>
|
||||
<ShieldCheck size={13} />
|
||||
<span>تصريح: {authInfo?.tenantName || 'Enterprise'}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRevokeOrLock}
|
||||
title="قفل المنظومة التكتيكية / خروج"
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.15)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
color: '#f87171',
|
||||
padding: '3px 8px',
|
||||
borderRadius: 6,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
>
|
||||
<Lock size={12} />
|
||||
<span>قفل</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -2787,6 +2893,176 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n
|
||||
{/* Tactical Map Container */}
|
||||
<div ref={mapContainer} style={{ flex: 1, height: '100%', position: 'relative' }} />
|
||||
</div>
|
||||
|
||||
{/* Tactical License Authorization Modal Overlay */}
|
||||
{authStatus !== 'authorized' && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: 'rgba(5, 8, 15, 0.94)',
|
||||
backdropFilter: 'blur(25px)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
direction: 'rtl',
|
||||
padding: 20
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
maxWidth: 520,
|
||||
background: 'linear-gradient(180deg, rgba(15, 23, 42, 0.98), rgba(11, 19, 38, 0.98))',
|
||||
border: '1px solid rgba(56, 189, 248, 0.3)',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 0 50px rgba(14, 165, 233, 0.2), 0 25px 50px -12px rgba(0, 0, 0, 0.8)',
|
||||
padding: '32px 28px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Top Tactical Glowing Badge */}
|
||||
<div style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 20,
|
||||
background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.2), rgba(79, 70, 229, 0.3))',
|
||||
border: '1px solid rgba(56, 189, 248, 0.4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 18,
|
||||
boxShadow: '0 0 25px rgba(56, 189, 248, 0.35)'
|
||||
}}>
|
||||
{authStatus === 'validating' ? (
|
||||
<Radio size={32} color="#38bdf8" />
|
||||
) : (
|
||||
<ShieldAlert size={32} color="#38bdf8" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 style={{ fontSize: '1.35rem', fontWeight: 800, color: '#f8fafc', margin: '0 0 6px 0' }}>
|
||||
منظومة القيادة والسيطرة والتحليل التكتيكي
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.82rem', color: '#94a3b8', marginBottom: 20, lineHeight: 1.5 }}>
|
||||
بوابة التخويل الأمني العسكري — يتطلب استخدام أدوات التحليل الطبوغرافي ورمايات المدفعية وحقول الألغام مفتاح تصريح معتمد (<span style={{ color: '#38bdf8', fontFamily: 'monospace' }}>Enterprise Key</span>).
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
background: 'rgba(239, 68, 68, 0.12)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: 8,
|
||||
padding: '10px 14px',
|
||||
color: '#f87171',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
marginBottom: 18,
|
||||
textAlign: 'right',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
<AlertTriangle size={16} style={{ flexShrink: 0 }} />
|
||||
<span>{authError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
verifyTacticalLicense(inputKey);
|
||||
}}
|
||||
style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 14 }}
|
||||
>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.78rem', fontWeight: 700, color: '#cbd5e1', marginBottom: 6 }}>
|
||||
أدخل مفتاح الترخيص والتصريح التكتيكي:
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="password"
|
||||
value={inputKey}
|
||||
onChange={(e) => setInputKey(e.target.value)}
|
||||
placeholder="in_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 10,
|
||||
padding: '12px 14px',
|
||||
color: '#ffffff',
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'monospace',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
<Key size={16} color="#64748b" style={{ position: 'absolute', left: 14, top: 14 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isVerifying || !inputKey.trim()}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'linear-gradient(135deg, #0284c7, #4f46e5)',
|
||||
border: 'none',
|
||||
borderRadius: 10,
|
||||
padding: '12px',
|
||||
color: '#ffffff',
|
||||
fontSize: '0.88rem',
|
||||
fontWeight: 800,
|
||||
cursor: isVerifying || !inputKey.trim() ? 'not-allowed' : 'pointer',
|
||||
opacity: isVerifying || !inputKey.trim() ? 0.6 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
boxShadow: '0 4px 14px rgba(2, 132, 199, 0.4)',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{isVerifying ? (
|
||||
<>
|
||||
<Activity size={16} />
|
||||
<span>جاري التحقق من الصلاحيات والتصريح...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck size={16} />
|
||||
<span>تفعيل الدخول والتحقق من الصلاحية</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: 22, paddingTop: 16, borderTop: '1px solid rgba(255,255,255,0.08)', width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.75rem', color: '#64748b' }}>
|
||||
<span>منصة الخرائط والأنظمة الذكية</span>
|
||||
<a
|
||||
href="/"
|
||||
style={{
|
||||
color: '#38bdf8',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
>
|
||||
← العودة إلى الخريطة العامة
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import mlcontour from 'maplibre-contour';
|
||||
|
||||
let sharedDemSource: any = null;
|
||||
let isConfigured = false;
|
||||
|
||||
export function getSharedDemSource() {
|
||||
if (!sharedDemSource) {
|
||||
sharedDemSource = new mlcontour.DemSource({
|
||||
url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png',
|
||||
encoding: 'terrarium',
|
||||
maxzoom: 15,
|
||||
worker: true,
|
||||
cacheSize: 100,
|
||||
});
|
||||
|
||||
if (!isConfigured) {
|
||||
try {
|
||||
sharedDemSource.setupMaplibre(maplibregl);
|
||||
isConfigured = true;
|
||||
} catch (e) {
|
||||
console.warn('DemSource setup warning:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sharedDemSource;
|
||||
}
|
||||
@@ -733,8 +733,8 @@ export async function calculateTerrainStudy(
|
||||
const centerElev = await sampleElevationAt(centerLat, centerLng);
|
||||
const areaKm2 = Math.round(Math.PI * Math.pow(radiusMeters / 1000, 2) * 10) / 10;
|
||||
|
||||
// High-Density Sampling Matrix: 21x21 = 441 nodes (400 terrain cells)
|
||||
const gridSize = 21;
|
||||
// High-Density Sampling Matrix: 25x25 = 625 nodes (576 terrain cells)
|
||||
const gridSize = 25;
|
||||
const R_earth = 6371000;
|
||||
const dLatTotal = (radiusMeters / R_earth) * (180 / Math.PI);
|
||||
const dLngTotal = (radiusMeters / (R_earth * Math.cos((centerLat * Math.PI) / 180))) * (180 / Math.PI);
|
||||
@@ -963,74 +963,176 @@ export async function calculateTerrainStudy(
|
||||
const avgSlope = slopeValues.length > 0 ? Math.round((slopeValues.reduce((a, b) => a + b, 0) / slopeValues.length) * 10) / 10 : 2.5;
|
||||
const maxSlope = slopeValues.length > 0 ? Math.round(Math.max(...slopeValues) * 10) / 10 : 5.0;
|
||||
|
||||
// 6. Trace Drainage Defiles & Natural Wadis (Lowland Thalweg Corridors)
|
||||
const valleySegments: [number, number][] = [];
|
||||
// 6. Scientific Hydrological D8 Flow Routing & Natural Wadi (Thalweg) Extraction
|
||||
// Compute steepest descent neighbor for every grid cell
|
||||
interface FlowTarget {
|
||||
r: number;
|
||||
c: number;
|
||||
slope: number;
|
||||
}
|
||||
|
||||
const flowTarget: (FlowTarget | null)[][] = Array(gridSize).fill(null).map(() => Array(gridSize).fill(null));
|
||||
|
||||
for (let r = 0; r < gridSize; r++) {
|
||||
let minRowElev = 9999;
|
||||
let minColIdx = 0;
|
||||
for (let c = 0; c < gridSize; c++) {
|
||||
if (elevationGrid[r][c] < minRowElev) {
|
||||
minRowElev = elevationGrid[r][c];
|
||||
minColIdx = c;
|
||||
const zCurr = elevationGrid[r][c];
|
||||
let maxDropSlope = 0;
|
||||
let bestTarget: FlowTarget | null = null;
|
||||
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
if (dr === 0 && dc === 0) continue;
|
||||
const nr = r + dr;
|
||||
const nc = c + dc;
|
||||
if (nr < 0 || nr >= gridSize || nc < 0 || nc >= gridSize) continue;
|
||||
|
||||
const zNeighbor = elevationGrid[nr][nc];
|
||||
const distM = Math.hypot(dr * dyMeters, dc * dxMeters);
|
||||
const drop = zCurr - zNeighbor;
|
||||
const slope = drop / distM;
|
||||
|
||||
if (slope > maxDropSlope) {
|
||||
maxDropSlope = slope;
|
||||
bestTarget = { r: nr, c: nc, slope };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const pt = gridCoords[r * gridSize + minColIdx];
|
||||
const dist = calculateDistance(centerLat, centerLng, pt.lat, pt.lng);
|
||||
if (dist <= radiusMeters * 1.02) {
|
||||
valleySegments.push([pt.lng, pt.lat]);
|
||||
|
||||
flowTarget[r][c] = bestTarget;
|
||||
}
|
||||
}
|
||||
|
||||
if (valleySegments.length >= 3) {
|
||||
spatialFeatures.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
layerType: 'wadi',
|
||||
category: 'natural-obstacle',
|
||||
title: `🌊 بطن وادٍ ومصرف سيل طبيعي (${minElev}م)`,
|
||||
color: '#06b6d4'
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: valleySegments
|
||||
}
|
||||
});
|
||||
}
|
||||
// Calculate Flow Accumulation (Upslope contributing area)
|
||||
const flowAcc: number[][] = Array(gridSize).fill(1).map(() => Array(gridSize).fill(1));
|
||||
|
||||
// 7. Trace Dominant Mountain Ridges (Key Terrain Crest Lines)
|
||||
const ridgeSegments: [number, number][] = [];
|
||||
// Sort cells from highest elevation to lowest
|
||||
const sortedCells: Array<{ r: number; c: number; elev: number }> = [];
|
||||
for (let r = 0; r < gridSize; r++) {
|
||||
let maxRowElev = -9999;
|
||||
let maxColIdx = 0;
|
||||
for (let c = 0; c < gridSize; c++) {
|
||||
if (elevationGrid[r][c] > maxRowElev) {
|
||||
maxRowElev = elevationGrid[r][c];
|
||||
maxColIdx = c;
|
||||
}
|
||||
sortedCells.push({ r, c, elev: elevationGrid[r][c] });
|
||||
}
|
||||
const pt = gridCoords[r * gridSize + maxColIdx];
|
||||
const dist = calculateDistance(centerLat, centerLng, pt.lat, pt.lng);
|
||||
if (dist <= radiusMeters * 1.02 && maxRowElev >= minElev + relief * 0.5) {
|
||||
ridgeSegments.push([pt.lng, pt.lat]);
|
||||
}
|
||||
sortedCells.sort((a, b) => b.elev - a.elev);
|
||||
|
||||
for (const cell of sortedCells) {
|
||||
const target = flowTarget[cell.r][cell.c];
|
||||
if (target) {
|
||||
flowAcc[target.r][target.c] += flowAcc[cell.r][cell.c];
|
||||
}
|
||||
}
|
||||
|
||||
if (ridgeSegments.length >= 3) {
|
||||
spatialFeatures.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
layerType: 'ridge',
|
||||
category: 'key-terrain',
|
||||
title: `⛰️ سلسلة تلال حاكمة (Dominant Crest - ${maxElev}م)`,
|
||||
color: '#38bdf8'
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: ridgeSegments
|
||||
// Extract Thalweg Valley & Stream Channels
|
||||
// Threshold for channel initiation: cells where accumulated flow exceeds minimum threshold
|
||||
const minAccThreshold = Math.max(5, Math.floor(gridSize * 0.3));
|
||||
const visitedStream = Array(gridSize).fill(false).map(() => Array(gridSize).fill(false));
|
||||
|
||||
// Find all channel heads (cells where flowAcc >= threshold, but none of upstream neighbors >= threshold)
|
||||
const channelHeads: Array<{ r: number; c: number; acc: number }> = [];
|
||||
for (let r = 1; r < gridSize - 1; r++) {
|
||||
for (let c = 1; c < gridSize - 1; c++) {
|
||||
if (flowAcc[r][c] >= minAccThreshold) {
|
||||
// Check if any upstream neighbor that flows to (r, c) already had flowAcc >= minAccThreshold
|
||||
let hasUpstreamThreshold = false;
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
if (dr === 0 && dc === 0) continue;
|
||||
const nr = r + dr;
|
||||
const nc = c + dc;
|
||||
if (nr >= 0 && nr < gridSize && nc >= 0 && nc < gridSize) {
|
||||
const target = flowTarget[nr][nc];
|
||||
if (target && target.r === r && target.c === c && flowAcc[nr][nc] >= minAccThreshold) {
|
||||
hasUpstreamThreshold = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasUpstreamThreshold) break;
|
||||
}
|
||||
|
||||
if (!hasUpstreamThreshold) {
|
||||
channelHeads.push({ r, c, acc: flowAcc[r][c] });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Trace streams downstream from each channel head
|
||||
for (const head of channelHeads) {
|
||||
const rawPath: [number, number][] = [];
|
||||
const streamElevations: number[] = [];
|
||||
let currR = head.r;
|
||||
let currC = head.c;
|
||||
let maxStreamAcc = head.acc;
|
||||
let steps = 0;
|
||||
|
||||
while (currR >= 0 && currR < gridSize && currC >= 0 && currC < gridSize && steps < gridSize * 2) {
|
||||
steps++;
|
||||
const pt = gridCoords[currR * gridSize + currC];
|
||||
const distFromCenter = calculateDistance(centerLat, centerLng, pt.lat, pt.lng);
|
||||
|
||||
if (distFromCenter <= radiusMeters * 1.05) {
|
||||
rawPath.push([pt.lng, pt.lat]);
|
||||
streamElevations.push(elevationGrid[currR][currC]);
|
||||
if (flowAcc[currR][currC] > maxStreamAcc) {
|
||||
maxStreamAcc = flowAcc[currR][currC];
|
||||
}
|
||||
}
|
||||
|
||||
visitedStream[currR][currC] = true;
|
||||
const next = flowTarget[currR][currC];
|
||||
if (!next) break; // Reached sink or local depression
|
||||
if (visitedStream[next.r][next.c]) {
|
||||
// Confluence with existing river channel
|
||||
const confluencePt = gridCoords[next.r * gridSize + next.c];
|
||||
rawPath.push([confluencePt.lng, confluencePt.lat]);
|
||||
streamElevations.push(elevationGrid[next.r][next.c]);
|
||||
break;
|
||||
}
|
||||
currR = next.r;
|
||||
currC = next.c;
|
||||
}
|
||||
|
||||
// Only keep streams that have at least 3 points and actual length
|
||||
if (rawPath.length >= 3) {
|
||||
// Smooth the stream line using moving average
|
||||
const smoothedPath: [number, number][] = [];
|
||||
for (let i = 0; i < rawPath.length; i++) {
|
||||
if (i === 0 || i === rawPath.length - 1) {
|
||||
smoothedPath.push(rawPath[i]);
|
||||
} else {
|
||||
const pPrev = rawPath[i - 1];
|
||||
const pCurr = rawPath[i];
|
||||
const pNext = rawPath[i + 1];
|
||||
smoothedPath.push([
|
||||
Number((pPrev[0] * 0.25 + pCurr[0] * 0.5 + pNext[0] * 0.25).toFixed(6)),
|
||||
Number((pPrev[1] * 0.25 + pCurr[1] * 0.5 + pNext[1] * 0.25).toFixed(6))
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const streamMinZ = Math.min(...streamElevations);
|
||||
const streamMaxZ = Math.max(...streamElevations);
|
||||
|
||||
spatialFeatures.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
layerType: 'wadi',
|
||||
category: 'natural-obstacle',
|
||||
title: `🌊 مجرى وادٍ ومصرف سيل طبيعي (${Math.round(streamMinZ)}م - ${Math.round(streamMaxZ)}م)`,
|
||||
flowAcc: maxStreamAcc,
|
||||
color: '#0284c7',
|
||||
width: 3.5
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: smoothedPath
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 8. Regional Sector Intelligence Context
|
||||
let sectorName = 'قطاع عمليات ميداني';
|
||||
let terrainClassificationAr = 'هضاب وتلال متوسطة الوعورة';
|
||||
|
||||
Reference in New Issue
Block a user