feat: implement side-by-side synchronized map comparison tool and infrastructure scripts for connectivity and data updates

This commit is contained in:
Hamza-Ayed
2026-07-15 14:58:48 +03:00
parent 790bfcefc8
commit a39dfe1aaa
11 changed files with 1025 additions and 303 deletions
+173
View File
@@ -0,0 +1,173 @@
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.
*/
const TILES = (import.meta as any).env.VITE_TILES_URL || 'https://tiles.intaleqapp.com';
// "Our map" style, built inline from the public Martin vector tiles (same endpoints
// the main app uses — no API key needed). Roads-focused for clean street comparison,
// with our approved_roads drawn green. approved_roads tiles 404 until the table exists;
// MapLibre degrades gracefully (just no green lines yet).
const ourStyle = (): any => ({
version: 8,
glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
sources: {
osm_lines: { type: 'vector', tiles: [`${TILES}/planet_osm_line/{z}/{x}/{y}`], maxzoom: 14 },
osm_polys: { type: 'vector', tiles: [`${TILES}/planet_osm_polygon/{z}/{x}/{y}`], maxzoom: 14 },
approved: { type: 'vector', tiles: [`${TILES}/approved_roads/{z}/{x}/{y}`], minzoom: 8, maxzoom: 18 },
},
layers: [
{ id: 'bg', type: 'background', paint: { 'background-color': '#eef0f2' } },
{ id: 'water', type: 'fill', source: 'osm_polys', 'source-layer': 'planet_osm_polygon', filter: ['in', 'natural', 'water', 'bay'], paint: { 'fill-color': '#a3ccff' } },
{ id: 'roads-casing', type: 'line', source: 'osm_lines', 'source-layer': 'planet_osm_line', filter: ['has', 'highway'], paint: { 'line-color': '#c8ccd4', 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1.2, 16, 10] } },
{ id: 'roads-core', type: 'line', source: 'osm_lines', 'source-layer': 'planet_osm_line', filter: ['has', 'highway'], paint: { 'line-color': '#ffffff', 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 0.6, 16, 7] } },
{ id: 'approved', type: 'line', source: 'approved', 'source-layer': 'approved_roads', paint: { 'line-color': '#22c55e', 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1.5, 16, 8] } },
],
});
type RefKey = 'esri-sat' | 'esri-streets' | 'osm';
const REFS: Record<RefKey, { label: string; tiles: string; attribution: string; maxzoom: number }> = {
'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<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>('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;
const lMap = new maplibregl.Map({ container: leftDiv.current, style: ourStyle(), 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');
// 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 googleUrl = `https://www.google.com/maps/@${center.lat},${center.lng},${z}z/data=!3m1!1e3`; // 1e3 = satellite
const bingUrl = `https://www.bing.com/maps?cp=${center.lat}~${center.lng}&lvl=${z}&style=h`; // h = aerial+labels
const josmUrl = `https://www.openstreetmap.org/#map=${z}/${fmt(center.lat)}/${fmt(center.lng)}`;
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 (
<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: '12px', padding: '10px 16px', borderBottom: '1px solid #1e293b', flexWrap: 'wrap' }}>
<a href="#" style={{ ...btn, borderColor: '#6366f1', color: '#a5b4fc' }}>← Map</a>
<strong style={{ fontSize: '0.9rem' }}>Compare</strong>
<span style={{ color: '#64748b', fontSize: '0.78rem' }}>Our map ⟷ reference — synced</span>
<div style={{ display: 'flex', gap: '6px', marginInlineStart: 'auto' }}>
{(Object.keys(REFS) as RefKey[]).map(k => (
<button key={k} onClick={() => setRefKey(k)}
style={{ ...btn, ...(refKey === k ? { borderColor: '#6366f1', color: '#fff', background: 'rgba(99,102,241,0.2)' } : {}) }}>
{REFS[k].label}
</button>
))}
</div>
<div style={{ display: 'flex', gap: '6px' }}>
<a href={googleUrl} target="_blank" rel="noopener noreferrer" style={btn} title="Open Google Maps satellite at this point">Google ↗</a>
<a href={bingUrl} target="_blank" rel="noopener noreferrer" style={btn} title="Open Bing aerial at this point">Bing ↗</a>
<a href={josmUrl} target="_blank" rel="noopener noreferrer" style={btn} title="Open on openstreetmap.org">OSM ↗</a>
</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;