mhassanch's picture
round zoom level when running inference
f609be6
raw
history blame
9.97 kB
import { useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import MaplibreDraw from 'maplibre-gl-draw';
import 'maplibre-gl-draw/dist/mapbox-gl-draw.css';
import { useGeoAIWorker } from './hooks/useGeoAIWorker';
import './App.css';
const config = {
provider: "esri",
serviceUrl: "https://server.arcgisonline.com/ArcGIS/rest/services",
serviceName: "World_Imagery",
tileSize: 256,
attribution: "ESRI World Imagery",
};
function App() {
const mapContainer = useRef(null);
const map = useRef(null);
const drawRef = useRef(null);
const [detections, setDetections] = useState([]);
const [currentPolygon, setCurrentPolygon] = useState(null);
const [isDrawing, setIsDrawing] = useState(false);
const [zoom, setZoom] = useState(null);
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light');
// when true we temporarily ignore any existing `result` from the worker (used after Clear)
const [suppressResult, setSuppressResult] = useState(false);
const { isReady, isProcessing, result, initialize, runInference } = useGeoAIWorker();
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));
useEffect(() => {
initialize([{ task: "building-detection" }], config);
}, []);
useEffect(() => {
if (!mapContainer.current) return;
map.current = new maplibregl.Map({
container: mapContainer.current,
style: {
version: 8,
sources: {
satellite: {
type: 'raster',
tiles: [
`https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}`,
],
tileSize: 256,
},
},
layers: [{ id: 'satellite', type: 'raster', source: 'satellite' }],
},
center: [-117.59, 47.653],
zoom: 18,
});
// set initial zoom state
setZoom(map.current.getZoom());
// update zoom on changes
const onZoom = () => setZoom(Number(map.current.getZoom().toFixed(2)));
map.current.on('zoom', onZoom);
const draw = new MaplibreDraw({
displayControlsDefault: false,
// don't render the default draw toolbar on the map; we'll control draw from the control panel
});
drawRef.current = draw;
// @ts-ignore
map.current.addControl(draw);
// cleanup map listeners on unmount
const cleanupMap = () => {
try {
map.current?.off('zoom', onZoom);
} catch (e) {}
};
map.current.on('draw.create', (e) => {
const polygon = e.features[0];
setCurrentPolygon(polygon);
// exit draw mode when a polygon is created
try { drawRef.current.changeMode('simple_select'); } catch (err) {}
setIsDrawing(false);
// DO NOT run inference automatically here. User must click Analyze.
});
map.current.on('draw.update', (e) => {
const polygon = e.features[0];
setCurrentPolygon(polygon);
});
map.current.on('draw.delete', (e) => {
setCurrentPolygon(null);
setDetections([]);
setIsDrawing(false);
// remove detections layer/source if present
if (map.current?.getSource('detections')) {
try {
map.current.removeLayer('detections');
map.current.removeSource('detections');
} catch (err) {}
}
});
return () => {
cleanupMap();
try {
// remove draw control if attached
if (map.current && drawRef.current) {
try { map.current.removeControl(drawRef.current); } catch (e) {}
}
map.current?.remove();
} catch (e) {}
// clear refs
map.current = null;
drawRef.current = null;
};
}, []); // initialize map once on mount; previously depended on isReady which caused the map to recreate when the worker became ready
useEffect(() => {
if (!result || suppressResult) return;
const features = result.detections?.features || [];
setDetections(features);
if (map.current?.getSource('detections')) {
map.current.removeLayer('detections');
map.current.removeSource('detections');
}
// set detection fill color to yellow
const fillColor = '#fdb306ff';
map.current?.addSource('detections', { type: 'geojson', data: result.detections });
map.current?.addLayer({
id: 'detections',
type: 'fill',
source: 'detections',
paint: { 'fill-color': fillColor, 'fill-opacity': 0.7 },
});
}, [result, theme]);
// manual analyze handler
const handleAnalyze = () => {
if (!isReady || !currentPolygon) return;
// allow the latest result to be shown again when user triggers analyze
setSuppressResult(false);
runInference({
inputs: { polygon: currentPolygon },
mapSourceParams: { zoomLevel: Math.round(map.current?.getZoom() || 18) },
});
};
// clear / reset everything on the map
const handleClear = () => {
// remove drawn shapes
if (drawRef.current) {
try { drawRef.current.deleteAll(); } catch (e) {}
}
// clear state
setCurrentPolygon(null);
setDetections([]);
// suppress previously computed result so it won't be re-applied to the map
setSuppressResult(true);
setIsDrawing(false);
// remove detections layer/source if present
try {
if (map.current?.getLayer('detections')) map.current.removeLayer('detections');
} catch (e) {}
try {
if (map.current?.getSource('detections')) map.current.removeSource('detections');
} catch (e) {}
// optionally fly back to initial view
try { map.current?.flyTo({ center: [-117.59, 47.653], zoom: 18 }); } catch (e) {}
};
const getDetectionsGeoJSON = () => result?.detections || null;
const handleCopy = async () => {
const geojson = getDetectionsGeoJSON();
if (!geojson) return;
try {
await navigator.clipboard.writeText(JSON.stringify(geojson, null, 2));
// optionally give feedback
} catch (err) {
console.error('Copy failed', err);
}
};
const handleDownload = () => {
const geojson = getDetectionsGeoJSON();
if (!geojson) return;
const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/geo+json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'detections.geojson';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
return (
<div className={`app-root ${theme === 'dark' ? 'theme-dark' : 'theme-light'}`}>
<header className="app-header">
<div className="brand">
<h1 className="brand-title">
GeoAI
<img src="https://cdn-icons-png.flaticon.com/256/5968/5968292.png" alt="JS logo" height="24" style={{ verticalAlign: 'middle', marginLeft: 8 }} />
</h1>
<div className="brand-text">
<h4 className="demo-title">Building Detection (Demo)</h4>
<p className="tagline">Inspect satellite imagery and detect buildings with GeoAI</p>
</div>
</div>
<div className="status">
<div className={`ready-dot ${isReady ? 'on' : 'off'}`}></div>
<span>{isReady ? 'Model ready' : 'Initializing...'}</span>
<button className="btn small" onClick={toggleTheme} style={{marginLeft:12}}>{theme === 'dark' ? 'Light' : 'Dark'}</button>
</div>
</header>
<main className="app-main">
<section className="map-area">
<div ref={mapContainer} className="map-container" />
</section>
<aside className="control-panel">
<h2>Controls</h2>
<p>Draw a polygon on the map to detect buildings in the selected area.</p>
<div className="controls-row">
<button className="btn" onClick={() => {
if (!drawRef.current) return;
try {
drawRef.current.changeMode('draw_polygon');
setIsDrawing(true);
} catch (err) { console.error(err); }
}} disabled={isDrawing || !isReady || isProcessing || Boolean(currentPolygon)}>
{isDrawing ? 'Drawing…' : 'Draw Polygon'}
</button>
<button className="btn" onClick={handleAnalyze} disabled={!currentPolygon || !isReady || isProcessing}>
{isProcessing ? 'Analyzing…' : 'Analyze'}
</button>
<button className="btn secondary" onClick={handleClear}>
Clear
</button>
</div>
<div className="stats">
<strong>{detections.length}</strong>
<span>Buildings found</span>
<div style={{marginLeft:12,color:'var(--muted)'}}>Zoom: {zoom !== null ? zoom : '—'}</div>
</div>
<div className="geojson-section">
<div className="geojson-header">
<h3>Detections GeoJSON</h3>
<div className="geojson-actions">
<button className="btn small" onClick={handleCopy} disabled={!result}>Copy</button>
<button className="btn small" onClick={handleDownload} disabled={!result}>Download</button>
</div>
</div>
<pre className="geojson-box" aria-live="polite">
{result ? JSON.stringify(result.detections, null, 2) : 'No detections yet.'}
</pre>
</div>
<div className="footer-note">
<a className="footer-link" href="https://geobase.app/" target="_blank" rel="noopener noreferrer">
<span><img width={15} src="/geobase-icon-tiny.svg" alt="Geobase small logo" className="footer-inline-logo" /> Geobase</span>
</a>
</div>
</aside>
</main>
</div>
);
}
export default App;