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 = { '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(null); const rightDiv = useRef(null); const leftMap = useRef(null); const rightMap = useRef(null); const syncing = useRef(false); const [refKey, setRefKey] = useState('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 (
{/* Toolbar */}
⚖️ Compare {/* Left Map Layer Toggles */}
Our Map:
{(Object.keys(REFS) as RefKey[]).map(k => ( ))}
{/* Maps */}
{/* Coordinate readout */}
📍 center: {fmt(center.lat)}, {fmt(center.lng)} zoom: {center.zoom.toFixed(1)} Center the crosshair on a street, then compare both sides / open Google.
); }; const Badge: React.FC<{ text: string }> = ({ text }) => (
{text}
); const Crosshair: React.FC = () => (
); export default CompareView;