update tile endpoints to the Martin host, refine map styling and snapping logic, and add geolocation-based map centering.
This commit is contained in:
@@ -15,7 +15,10 @@ import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
* 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';
|
||||
// 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<RefKey, { label: string; tiles: string; attribution: string; maxzoom: number }> = {
|
||||
@@ -48,14 +51,27 @@ const CompareView: React.FC = () => {
|
||||
const start: [number, number] = [35.91, 31.95];
|
||||
const startZoom = 15;
|
||||
|
||||
const lMap = new maplibregl.Map({ container: leftDiv.current, style: TILES + '/style.json', center: start, zoom: startZoom, attributionControl: false });
|
||||
// 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', type: 'line', source: 'approved', 'source-layer': 'approved_roads', paint: { 'line-color': '#22c55e', 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1.5, 16, 8] } });
|
||||
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.
|
||||
|
||||
@@ -59,15 +59,18 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
}, [drawing]);
|
||||
|
||||
const redrawDrawing = () => {
|
||||
const lm = leftMapRef.current;
|
||||
const rm = rightMapRef.current;
|
||||
const lMap = leftMapRef.current; const rMap = rightMapRef.current;
|
||||
if (!lMap || !rMap) return;
|
||||
const pts = pointsRef.current;
|
||||
const feats: any[] = [];
|
||||
pts.forEach(p => feats.push({ type: 'Feature', geometry: { type: 'Point', coordinates: p } }));
|
||||
if (pts.length > 1) feats.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: pts } });
|
||||
const col = { type: 'FeatureCollection', features: feats };
|
||||
if (lm && lm.isStyleLoaded()) (lm.getSource('draw') as maplibregl.GeoJSONSource)?.setData(col as any);
|
||||
if (rm && rm.isStyleLoaded()) (rm.getSource('draw') as maplibregl.GeoJSONSource)?.setData(col as any);
|
||||
const data = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
...(pts.length >= 2 ? [{ type: 'Feature' as const, geometry: { type: 'LineString' as const, coordinates: pts }, properties: {} }] : []),
|
||||
...pts.map(p => ({ type: 'Feature' as const, geometry: { type: 'Point' as const, coordinates: p }, properties: {} }))
|
||||
]
|
||||
};
|
||||
(lMap.getSource('draw') as maplibregl.GeoJSONSource)?.setData(data as any);
|
||||
(rMap.getSource('draw') as maplibregl.GeoJSONSource)?.setData(data as any);
|
||||
};
|
||||
|
||||
const pushMapData = () => {
|
||||
@@ -163,6 +166,15 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
// Sync logic
|
||||
const link = (from: maplibregl.Map, to: maplibregl.Map) => {
|
||||
@@ -178,6 +190,14 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
lMap.addSource('cands', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any);
|
||||
lMap.addSource('closures', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any);
|
||||
lMap.addSource('hl', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any);
|
||||
|
||||
// approved_roads is served by Martin on the tile host — NOT the app host. An
|
||||
// empty default made this resolve to /approved_roads on the app host (404), so
|
||||
// approved roads never drew. Point it at the real Martin host.
|
||||
const TILES = (import.meta as any).env.VITE_TILES_URL || 'https://tiles.intaleqapp.com';
|
||||
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] } });
|
||||
lMap.addSource('draw', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as any);
|
||||
|
||||
lMap.addLayer({ id: 'cl', type: 'line', source: 'closures', paint: { 'line-color': '#ef4444', 'line-width': 4, 'line-dasharray': [3, 2] } });
|
||||
@@ -208,18 +228,31 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
|
||||
const onCandidateClick = (e: any) => {
|
||||
const p = e.features?.[0]?.properties as any; if (!p) return;
|
||||
const l = e.features?.[0]?.layer?.id;
|
||||
setSel(p.id);
|
||||
const isOvt = p.source === 'overture';
|
||||
const title = isOvt ? '🗺️ Overture road (missing)' : '🛣️ Driver-traced road';
|
||||
const isAppr = l === 'approved';
|
||||
const title = isAppr ? '✅ Approved Road' : isOvt ? '🗺️ Overture road (missing)' : '🛣️ Driver-traced road';
|
||||
const nameLine = p.name ? `<b>${p.name}</b><br/>` : '';
|
||||
const evidence = isOvt ? `${p.uniqueDriverCount ?? 0} trace pts corroborate` : `${p.uniqueDriverCount} drivers`;
|
||||
const evidence = isOvt ? `${p.uniqueDriverCount ?? 0} trace pts corroborate` : `${p.uniqueDriverCount || 1} drivers`;
|
||||
if (popupRef.current) popupRef.current.remove();
|
||||
popupRef.current = new maplibregl.Popup({ offset: 12 }).setLngLat(e.lngLat)
|
||||
.setHTML(`<div style="font:12px sans-serif;color:#1e293b;line-height:1.6">${nameLine}<b>${title}</b><br/>Conf: <b style="color:${cc(p.confidence)}">${Math.round(p.confidence * 100)}%</b><br/>${Math.round(p.lengthMeters)}m | ${evidence}</div>`)
|
||||
.addTo(lMap);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
popupRef.current = new maplibregl.Popup({ offset: 12 }).setLngLat(e.lngLat).setDOMContent(popupDiv).addTo(lMap);
|
||||
};
|
||||
|
||||
['cp', 'cp-overture'].forEach(layer => {
|
||||
['cp', 'cp-overture', 'approved'].forEach(layer => {
|
||||
lMap.on('click', layer, onCandidateClick);
|
||||
lMap.on('mouseenter', layer, () => { lMap.getCanvas().style.cursor = 'pointer'; });
|
||||
lMap.on('mouseleave', layer, () => { lMap.getCanvas().style.cursor = ''; });
|
||||
@@ -278,6 +311,25 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
setTimeout(() => setMsg(''), 5000);
|
||||
};
|
||||
|
||||
const deleteRoad = async (id: string, type: 'approved' | 'candidate') => {
|
||||
try {
|
||||
const res = await fetch(`${API}/map-refinement/roads/${type}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-api-key': apiKey }
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete');
|
||||
setMsg(`🗑️ Deleted ${type} road successfully.`);
|
||||
setSel(null);
|
||||
(leftMapRef.current?.getSource('hl') as maplibregl.GeoJSONSource)?.setData({ type: 'FeatureCollection', features: [] });
|
||||
load();
|
||||
// Force MapLibre to drop cached vector tiles completely for approved roads
|
||||
if (type === 'approved') {
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
}
|
||||
} catch { setMsg('Delete failed.'); }
|
||||
setTimeout(() => setMsg(''), 5000);
|
||||
};
|
||||
|
||||
const runAI = async () => {
|
||||
setMsg('🧠 Deep analysis started...');
|
||||
try {
|
||||
@@ -298,18 +350,18 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
setTimeout(() => setMsg(''), 6000);
|
||||
};
|
||||
|
||||
const submitDrawing = async () => {
|
||||
const submitRoad = async () => {
|
||||
if (pointsRef.current.length < 2) return setMsg('Draw at least 2 points!');
|
||||
|
||||
const roadName = window.prompt('Enter a name for this new road:', 'Unnamed Road');
|
||||
if (roadName === null) return; // User cancelled
|
||||
|
||||
if (roadName === null) return;
|
||||
const highway = window.prompt('Enter road category (primary, secondary, residential, unclassified, service):', 'unclassified');
|
||||
if (highway === null) return;
|
||||
try {
|
||||
const geojson = { type: 'LineString', coordinates: pointsRef.current };
|
||||
const res = await fetch(`${API}/map-refinement/roads/candidates/manual`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
|
||||
body: JSON.stringify({ geojson, name: roadName }),
|
||||
body: JSON.stringify({ geojson, name: roadName, highway }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to submit road');
|
||||
setMsg('Road submitted for intelligence analysis!');
|
||||
@@ -400,7 +452,7 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
<div style={{ background: '#1e3a8a30', border: '1px solid #3b82f6', padding: '10px', borderRadius: '8px', fontSize: '0.75rem', color: '#bfdbfe' }}>
|
||||
Click on <b>either map</b> to trace the street.<br />
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||
<button onClick={submitDrawing} style={{ flex: 1, background: '#3b82f6', color: 'white', border: 'none', padding: '6px', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 }}>Submit Road</button>
|
||||
<button onClick={submitRoad} style={{ flex: 1, background: '#3b82f6', color: 'white', border: 'none', padding: '6px', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 }}>Submit Road</button>
|
||||
<button onClick={() => { pointsRef.current = []; redrawDrawing(); }} style={{ flex: 1, background: 'transparent', color: '#94a3b8', border: '1px solid #334155', padding: '6px', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 }}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user