317 lines
15 KiB
TypeScript
317 lines
15 KiB
TypeScript
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);
|
|
|
|
/**
|
|
* CompareView — side-by-side, pan/zoom-synced map comparison.
|
|
*
|
|
* Left = our map (the production Martin style).
|
|
* Right = a legally-usable reference: Esri World Imagery (satellite) / Esri Streets
|
|
* / OSM standard. Google, Bing and Apple tiles are NOT embedded here — their
|
|
* terms forbid unofficial tile use in a product — so instead there are buttons
|
|
* that open THEIR official sites at the exact same coordinates in a new tab,
|
|
* which is the legal way to eyeball against them.
|
|
*
|
|
* Purpose: visually confirm whether a street/place exists before adding or approving it.
|
|
*/
|
|
|
|
// Martin vector-tile host (serves planet_osm_*, approved_roads, …). This is a
|
|
// DIFFERENT host from where the app + its style.json are served, so it must be
|
|
// absolute — an empty default resolved to the app host, which serves no tiles.
|
|
const TILES = (import.meta as any).env.VITE_TILES_URL || 'https://tiles.intaleqapp.com';
|
|
|
|
type RefKey =
|
|
| 'google-hybrid'
|
|
| 'google-topo'
|
|
| 'soviet-military'
|
|
| 'opentopo'
|
|
| 'esri-clarity'
|
|
| 'google-sat'
|
|
| 'google-streets'
|
|
| 'esri-sat'
|
|
| 'esri-topo'
|
|
| 'esri-streets'
|
|
| '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 },
|
|
};
|
|
|
|
const rasterStyle = (ref: { tiles: string; attribution: string; maxzoom: number }): any => ({
|
|
version: 8,
|
|
sources: { ref: { type: 'raster', tiles: [ref.tiles], tileSize: 256, attribution: ref.attribution, maxzoom: ref.maxzoom } },
|
|
layers: [{ id: 'ref', type: 'raster', source: 'ref' }],
|
|
});
|
|
|
|
const CompareView: React.FC = () => {
|
|
const leftDiv = useRef<HTMLDivElement>(null);
|
|
const rightDiv = useRef<HTMLDivElement>(null);
|
|
const leftMap = useRef<maplibregl.Map | null>(null);
|
|
const rightMap = useRef<maplibregl.Map | null>(null);
|
|
const syncing = useRef(false);
|
|
|
|
const [refKey, setRefKey] = useState<RefKey>('google-hybrid');
|
|
const [center, setCenter] = useState<{ lng: number; lat: number; zoom: number }>({ lng: 35.91, lat: 31.95, zoom: 15 });
|
|
|
|
// Create both maps once.
|
|
useEffect(() => {
|
|
if (!leftDiv.current || !rightDiv.current || leftMap.current) return;
|
|
|
|
const start: [number, number] = [35.91, 31.95];
|
|
const startZoom = 15;
|
|
|
|
// Base style is served by the APP host as a static file (its internal sources
|
|
// already point at the absolute Martin host), so load it relatively — never
|
|
// prefixed with the tile host, which does not serve /style.json.
|
|
const lMap = new maplibregl.Map({ container: leftDiv.current, style: '/style.json', center: start, zoom: startZoom, attributionControl: false });
|
|
// آيقونات الـ POI تُحمّل عند الطلب — يجب الربط قبل أول رسم للطبقات
|
|
attachIconLoader(lMap);
|
|
const rMap = new maplibregl.Map({ container: rightDiv.current, style: rasterStyle(REFS['google-hybrid']), center: start, zoom: startZoom, attributionControl: false });
|
|
lMap.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left');
|
|
rMap.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
|
|
|
if ('geolocation' in navigator) {
|
|
navigator.geolocation.getCurrentPosition((pos) => {
|
|
const coords: [number, number] = [pos.coords.longitude, pos.coords.latitude];
|
|
lMap.flyTo({ center: coords, zoom: 14, essential: true });
|
|
rMap.flyTo({ center: coords, zoom: 14, essential: true });
|
|
setCenter({ lng: coords[0], lat: coords[1], zoom: 14 });
|
|
});
|
|
}
|
|
|
|
lMap.on('load', () => {
|
|
// Dynamic On-the-Fly Contours from Elevation DEM
|
|
const contourUrl = demSource.contourProtocolUrl({
|
|
thresholds: {
|
|
10: [100, 500],
|
|
11: [50, 250],
|
|
12: [25, 100],
|
|
13: [20, 100],
|
|
14: [10, 50],
|
|
15: [10, 50],
|
|
16: [10, 50],
|
|
17: [10, 50],
|
|
18: [10, 50],
|
|
},
|
|
elevationKey: 'ele',
|
|
levelKey: 'level',
|
|
contourLayer: 'contours',
|
|
});
|
|
|
|
if (!lMap.getSource('contour-source')) {
|
|
lMap.addSource('contour-source', {
|
|
type: 'vector',
|
|
tiles: [contourUrl],
|
|
maxzoom: 18,
|
|
});
|
|
|
|
lMap.addLayer({
|
|
id: 'contour-lines-minor',
|
|
type: 'line',
|
|
source: 'contour-source',
|
|
'source-layer': 'contours',
|
|
minzoom: 10,
|
|
filter: ['==', ['get', 'level'], 0],
|
|
paint: {
|
|
'line-color': '#a86324',
|
|
'line-width': 1.1,
|
|
'line-opacity': 0.9,
|
|
},
|
|
});
|
|
|
|
lMap.addLayer({
|
|
id: 'contour-lines-major',
|
|
type: 'line',
|
|
source: 'contour-source',
|
|
'source-layer': 'contours',
|
|
minzoom: 9,
|
|
filter: ['>', ['get', 'level'], 0],
|
|
paint: {
|
|
'line-color': '#703800',
|
|
'line-width': 2.0,
|
|
'line-opacity': 1.0,
|
|
},
|
|
});
|
|
|
|
lMap.addLayer({
|
|
id: 'contour-labels',
|
|
type: 'symbol',
|
|
source: 'contour-source',
|
|
'source-layer': 'contours',
|
|
minzoom: 12,
|
|
filter: ['>', ['get', 'level'], 0],
|
|
layout: {
|
|
'symbol-placement': 'line',
|
|
'text-field': ['concat', ['to-string', ['get', 'ele']], ' m'],
|
|
'text-size': 10,
|
|
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
|
|
},
|
|
paint: {
|
|
'text-color': '#5c2d00',
|
|
'text-halo-color': 'rgba(255, 255, 255, 0.95)',
|
|
'text-halo-width': 2,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
// Keep the two views locked together. The guard stops the move→jumpTo→move loop.
|
|
const link = (from: maplibregl.Map, to: maplibregl.Map) => {
|
|
if (syncing.current) return;
|
|
syncing.current = true;
|
|
to.jumpTo({ center: from.getCenter(), zoom: from.getZoom(), bearing: from.getBearing(), pitch: from.getPitch() });
|
|
syncing.current = false;
|
|
const c = from.getCenter();
|
|
setCenter({ lng: c.lng, lat: c.lat, zoom: from.getZoom() });
|
|
};
|
|
lMap.on('move', () => link(lMap, rMap));
|
|
rMap.on('move', () => link(rMap, lMap));
|
|
|
|
leftMap.current = lMap;
|
|
rightMap.current = rMap;
|
|
return () => { lMap.remove(); rMap.remove(); leftMap.current = null; rightMap.current = null; };
|
|
}, []);
|
|
|
|
// Swap the reference basemap without losing the synced position.
|
|
useEffect(() => {
|
|
const m = rightMap.current;
|
|
if (!m) return;
|
|
const c = m.getCenter(); const z = m.getZoom(); const b = m.getBearing(); const p = m.getPitch();
|
|
m.setStyle(rasterStyle(REFS[refKey]));
|
|
m.once('styledata', () => m.jumpTo({ center: c, zoom: z, bearing: b, pitch: p }));
|
|
}, [refKey]);
|
|
|
|
const [showTerrain, setShowTerrain] = useState(true);
|
|
const [showContours, setShowContours] = useState(true);
|
|
const [showAdmin, setShowAdmin] = useState(true);
|
|
const [show3D, setShow3D] = useState(false);
|
|
|
|
// Sync toggles with leftMap layers
|
|
useEffect(() => {
|
|
const m = leftMap.current;
|
|
if (!m) return;
|
|
|
|
// Wait if style is not loaded yet
|
|
const applyToggles = () => {
|
|
if (m.getLayer('hillshading')) {
|
|
m.setLayoutProperty('hillshading', 'visibility', showTerrain ? 'visible' : 'none');
|
|
}
|
|
['contour-lines-minor', 'contour-lines-major', 'contour-labels'].forEach(l => {
|
|
if (m.getLayer(l)) m.setLayoutProperty(l, 'visibility', showContours ? 'visible' : 'none');
|
|
});
|
|
['admin-boundary-national', 'admin-boundary-governorate-poly', 'admin-boundary-governorate', 'admin-boundary-district-poly', 'admin-boundary-district'].forEach(l => {
|
|
if (m.getLayer(l)) m.setLayoutProperty(l, 'visibility', showAdmin ? 'visible' : 'none');
|
|
});
|
|
['building-3d', 'building-3d-osm'].forEach(l => {
|
|
if (m.getLayer(l)) m.setLayoutProperty(l, 'visibility', show3D ? 'visible' : 'none');
|
|
});
|
|
|
|
// Tilt camera for 3D perspective
|
|
m.easeTo({ pitch: show3D ? 55 : 0, duration: 600 });
|
|
};
|
|
|
|
if (m.isStyleLoaded()) {
|
|
applyToggles();
|
|
} else {
|
|
m.once('styledata', applyToggles);
|
|
}
|
|
}, [showTerrain, showContours, showAdmin, show3D]);
|
|
|
|
const fmt = (n: number) => n.toFixed(6);
|
|
const z = Math.round(center.zoom);
|
|
const btn: React.CSSProperties = { background: '#1e293b', border: '1px solid #334155', color: '#cbd5e1', padding: '5px 8px', borderRadius: '8px', cursor: 'pointer', fontSize: '0.72rem', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '4px' };
|
|
const toggleBtn = (active: boolean): React.CSSProperties => ({
|
|
...btn,
|
|
borderColor: active ? '#10b981' : '#334155',
|
|
background: active ? 'rgba(16,185,129,0.2)' : '#1e293b',
|
|
color: active ? '#34d399' : '#94a3b8',
|
|
fontWeight: active ? 600 : 400
|
|
});
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: '#0f172a', color: '#e2e8f0', fontFamily: 'Inter, system-ui, sans-serif' }}>
|
|
{/* Toolbar */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px 14px', paddingRight: '280px', borderBottom: '1px solid #1e293b', background: '#0f172a', flexWrap: 'wrap' }}>
|
|
<strong style={{ fontSize: '0.85rem', whiteSpace: 'nowrap' }}>⚖️ Compare</strong>
|
|
|
|
{/* Left Map Layer Toggles */}
|
|
<div style={{ display: 'flex', gap: '5px', alignItems: 'center', background: 'rgba(30,41,59,0.7)', padding: '2px 6px', borderRadius: '8px', border: '1px solid #334155' }}>
|
|
<span style={{ fontSize: '0.68rem', color: '#94a3b8' }}>Our Map:</span>
|
|
<button onClick={() => setShowTerrain(!showTerrain)} style={toggleBtn(showTerrain)}>🏔️ Relief</button>
|
|
<button onClick={() => setShowContours(!showContours)} style={toggleBtn(showContours)}>〰️ Contours</button>
|
|
<button onClick={() => setShowAdmin(!showAdmin)} style={toggleBtn(showAdmin)}>🏛️ Admin</button>
|
|
<button onClick={() => setShow3D(!show3D)} style={toggleBtn(show3D)}>🏢 3D</button>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '5px', marginInlineStart: 'auto', overflowX: 'auto', paddingBottom: '2px' }}>
|
|
{(Object.keys(REFS) as RefKey[]).map(k => (
|
|
<button key={k} onClick={() => setRefKey(k)}
|
|
style={{ ...btn, whiteSpace: 'nowrap', flexShrink: 0, ...(refKey === k ? { borderColor: '#6366f1', color: '#fff', background: 'rgba(99,102,241,0.35)', fontWeight: 600 } : {}) }}>
|
|
{REFS[k].label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Maps */}
|
|
<div style={{ position: 'relative', flex: 1, display: 'flex' }}>
|
|
<div style={{ position: 'relative', flex: 1, borderInlineEnd: '2px solid #0f172a' }}>
|
|
<div ref={leftDiv} style={{ position: 'absolute', inset: 0 }} />
|
|
<Badge text="Our map (Intaleq)" />
|
|
<Crosshair />
|
|
</div>
|
|
<div style={{ position: 'relative', flex: 1 }}>
|
|
<div ref={rightDiv} style={{ position: 'absolute', inset: 0 }} />
|
|
<Badge text={REFS[refKey].label} />
|
|
<Crosshair />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Coordinate readout */}
|
|
<div style={{ padding: '6px 16px', borderTop: '1px solid #1e293b', fontSize: '0.75rem', color: '#94a3b8', display: 'flex', gap: '18px' }}>
|
|
<span>📍 center: <b style={{ color: '#e2e8f0' }}>{fmt(center.lat)}, {fmt(center.lng)}</b></span>
|
|
<span>zoom: <b style={{ color: '#e2e8f0' }}>{center.zoom.toFixed(1)}</b></span>
|
|
<span style={{ marginInlineStart: 'auto', color: '#64748b' }}>Center the crosshair on a street, then compare both sides / open Google.</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Badge: React.FC<{ text: string }> = ({ text }) => (
|
|
<div style={{ position: 'absolute', top: 10, insetInlineStart: 10, zIndex: 2, background: 'rgba(15,23,42,0.85)', border: '1px solid #334155', color: '#e2e8f0', padding: '4px 10px', borderRadius: '20px', fontSize: '0.72rem', fontWeight: 600, pointerEvents: 'none' }}>
|
|
{text}
|
|
</div>
|
|
);
|
|
|
|
const Crosshair: React.FC = () => (
|
|
<div style={{ position: 'absolute', inset: 0, zIndex: 2, pointerEvents: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<div style={{ position: 'relative', width: 26, height: 26 }}>
|
|
<div style={{ position: 'absolute', top: '50%', left: 0, right: 0, height: 2, background: 'rgba(239,68,68,0.9)', transform: 'translateY(-50%)' }} />
|
|
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: 2, background: 'rgba(239,68,68,0.9)', transform: 'translateX(-50%)' }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
export default CompareView;
|