Spaces:
Running
Running
| 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; | |