import React, { useEffect, useRef, useState } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; /** * 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 || 'http://188.68.36.205:3202'; type RefKey = 'google-sat' | 'esri-sat' | 'esri-streets' | 'osm'; const REFS: Record = { 'google-sat': { label: '🛰️ Google Satellite', tiles: 'https://mt1.google.com/vt/lyrs=s&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-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('esri-sat'); 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 }); const rMap = new maplibregl.Map({ container: rightDiv.current, style: rasterStyle(REFS['esri-sat']), 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', () => { lMap.addSource('approved', { type: 'vector', tiles: [`${TILES}/approved_roads/{z}/{x}/{y}`], minzoom: 8, maxzoom: 18 }); lMap.addLayer({ id: 'approved-casing', type: 'line', source: 'approved', 'source-layer': 'approved_roads', paint: { 'line-color': '#d4d4d4', 'line-width': ['interpolate', ['linear'], ['zoom'], 13, 1, 16, 8] } }); lMap.addLayer({ id: 'approved', type: 'line', source: 'approved', 'source-layer': 'approved_roads', paint: { 'line-color': '#ffffff', 'line-width': ['interpolate', ['linear'], ['zoom'], 13, 0.5, 16, 6] } }); }); // 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 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: '6px 10px', borderRadius: '8px', cursor: 'pointer', fontSize: '0.75rem', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '5px' }; return (
{/* Toolbar */}
← Map Compare Our map ⟷ reference — synced
{(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;