Spaces:
Running
Running
add building detection demo
Browse files- package-lock.json +0 -0
- package.json +3 -0
- public/favicon.ico +0 -0
- src/App.css +93 -28
- src/App.js +264 -17
- src/hooks/useGeoAIWorker.js +50 -0
- src/hooks/worker.js +30 -0
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -7,6 +7,9 @@
|
|
| 7 |
"@testing-library/jest-dom": "^6.6.3",
|
| 8 |
"@testing-library/react": "^16.3.0",
|
| 9 |
"@testing-library/user-event": "^13.5.0",
|
|
|
|
|
|
|
|
|
|
| 10 |
"react": "^19.1.0",
|
| 11 |
"react-dom": "^19.1.0",
|
| 12 |
"react-scripts": "5.0.1",
|
|
|
|
| 7 |
"@testing-library/jest-dom": "^6.6.3",
|
| 8 |
"@testing-library/react": "^16.3.0",
|
| 9 |
"@testing-library/user-event": "^13.5.0",
|
| 10 |
+
"geoai": "^1.0.0-rc.1",
|
| 11 |
+
"maplibre-gl": "^5.6.2",
|
| 12 |
+
"maplibre-gl-draw": "^1.6.9",
|
| 13 |
"react": "^19.1.0",
|
| 14 |
"react-dom": "^19.1.0",
|
| 15 |
"react-scripts": "5.0.1",
|
public/favicon.ico
CHANGED
|
|
|
|
src/App.css
CHANGED
|
@@ -1,38 +1,103 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
}
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
-
.
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
font-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
.
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
}
|
| 38 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root{
|
| 2 |
+
--bg: #ffffff;
|
| 3 |
+
--surface: #ffffff;
|
| 4 |
+
--card: #f6f8f7;
|
| 5 |
+
--accent: #247c53; /* main green */
|
| 6 |
+
--accent-2: #2fb36a; /* lighter green */
|
| 7 |
+
--text: #072021; /* dark text on white */
|
| 8 |
+
--muted: #5f6b67;
|
| 9 |
+
--danger: #ff4d4f;
|
| 10 |
}
|
| 11 |
|
| 12 |
+
*{box-sizing:border-box}
|
| 13 |
+
html,body,#root{height:100%;margin:0;background:var(--bg);color:var(--text);font-family:Inter, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial}
|
| 14 |
+
|
| 15 |
+
.app-root{display:flex;flex-direction:column;min-height:100vh;background:linear-gradient(180deg, var(--bg) 0%, #fbfdfb 100%);}
|
| 16 |
+
|
| 17 |
+
.app-header{display:flex;align-items:center;justify-content:space-between;padding:18px 28px;background:var(--surface);border-bottom:1px solid rgba(6,21,13,0.06)}
|
| 18 |
+
.brand{display:flex;align-items:center;gap:14px}
|
| 19 |
+
.brand-logo{width:150px;}
|
| 20 |
+
.brand-title{display:inline-flex;align-items:center;gap:8px;font-size:36px;margin:0}
|
| 21 |
+
.theme-dark .brand-title{color:#ffffff}
|
| 22 |
+
.theme-light .brand-title{color:#072021}
|
| 23 |
+
.brand-title img{filter:grayscale(0);}
|
| 24 |
+
.brand-text{display:flex;flex-direction:column;margin-left:100;}
|
| 25 |
+
.demo-title{margin:0;font-size:20px;font-weight:700;color:var(--text)}
|
| 26 |
+
.theme-dark .demo-title{color:#ffffff}
|
| 27 |
+
.theme-light .demo-title{color:var(--text)}
|
| 28 |
+
.tagline{margin:6px 0 0;color:var(--muted);font-size:13px}
|
| 29 |
+
|
| 30 |
+
.status{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px}
|
| 31 |
+
.ready-dot{width:10px;height:10px;border-radius:50%;background:#e6f3ed}
|
| 32 |
+
.ready-dot.on{background:var(--accent)}
|
| 33 |
+
|
| 34 |
+
.app-main{display:flex;flex:1;gap:20px;padding:20px}
|
| 35 |
+
.map-area{flex:1;border-radius:12px;overflow:hidden;box-shadow:0 6px 24px rgba(10,20,18,0.06);background:#f8fbfa;border:1px solid rgba(6,21,13,0.04)}
|
| 36 |
+
.map-container{width:100%;height:100%;min-height:420px}
|
| 37 |
+
|
| 38 |
+
.control-panel{width:360px;background:var(--card);border-radius:12px;padding:18px;border:1px solid rgba(6,21,13,0.04);display:flex;flex-direction:column;gap:12px;max-height:calc(100vh - 120px);overflow:hidden}
|
| 39 |
|
| 40 |
+
/* Ensure the geojson section takes remaining vertical space */
|
| 41 |
+
.geojson-section{margin-top:14px;display:flex;flex-direction:column;flex:1;min-height:0}
|
| 42 |
+
.geojson-header{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
| 43 |
+
.geojson-actions{display:flex;gap:8px}
|
| 44 |
+
.geojson-box{
|
| 45 |
+
/* allow box to expand to fill remaining space */
|
| 46 |
+
flex:1;min-height:0;overflow:auto;background:#ffffff;padding:12px;border-radius:8px;border:1px solid rgba(6,21,13,0.04);font-size:12px;color:var(--text);white-space:pre-wrap;word-break:break-word;margin-top:8px
|
| 47 |
}
|
| 48 |
|
| 49 |
+
.controls-row{display:flex;gap:12px;margin-top:12px}
|
| 50 |
+
.btn{
|
| 51 |
+
background:linear-gradient(90deg,var(--accent) 0%, var(--accent-2) 100%);
|
| 52 |
+
color:#ffffff;
|
| 53 |
+
border:0;
|
| 54 |
+
padding:8px 14px;
|
| 55 |
+
border-radius:10px;
|
| 56 |
+
font-weight:600;
|
| 57 |
+
cursor:pointer;
|
| 58 |
+
box-shadow:0 8px 20px rgba(36,124,83,0.12);
|
| 59 |
+
transition:transform .12s ease,box-shadow .12s ease,opacity .12s ease;
|
| 60 |
+
display:inline-flex;align-items:center;gap:8px;
|
| 61 |
}
|
| 62 |
+
.btn:hover{transform:translateY(-2px)}
|
| 63 |
+
.btn:disabled{opacity:.6;cursor:not-allowed;transform:none}
|
| 64 |
+
.btn.secondary{
|
| 65 |
+
background:transparent;
|
| 66 |
+
color:var(--accent);
|
| 67 |
+
border:1px solid rgba(36,124,83,0.12);
|
| 68 |
+
box-shadow:none;
|
| 69 |
+
}
|
| 70 |
+
.btn.small{padding:6px 10px;font-size:13px;border-radius:8px;background:transparent;color:var(--muted);border:1px solid rgba(6,21,13,0.04)}
|
| 71 |
+
|
| 72 |
+
.stats{display:flex;align-items:center;gap:12px;margin-top:12px}
|
| 73 |
+
.stats strong{font-size:32px;color:var(--accent-2)}
|
| 74 |
+
.stats span{color:var(--muted);font-size:13px}
|
| 75 |
|
| 76 |
+
.geojson-section{margin-top:14px;display:flex;flex-direction:column;flex:1;min-height:0}
|
| 77 |
+
.geojson-header{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
| 78 |
+
.geojson-actions{display:flex;gap:8px}
|
| 79 |
+
.geojson-box{
|
| 80 |
+
flex:1;min-height:0;overflow:auto;background:#ffffff;padding:12px;border-radius:8px;border:1px solid rgba(6,21,13,0.04);font-size:12px;color:var(--text);white-space:pre-wrap;word-break:break-word;margin-top:8px
|
| 81 |
}
|
| 82 |
|
| 83 |
+
.footer-note{margin-top:auto;color:var(--muted);font-size:13px;text-align:center}
|
| 84 |
+
|
| 85 |
+
/* responsive */
|
| 86 |
+
@media (max-width: 900px){
|
| 87 |
+
.app-main{flex-direction:column}
|
| 88 |
+
.control-panel{width:100%;max-height:none}
|
| 89 |
+
.map-container{min-height:320px}
|
| 90 |
}
|
| 91 |
+
|
| 92 |
+
/* Theme dark overrides */
|
| 93 |
+
.theme-dark{background:linear-gradient(180deg,#071022 0%, #0f1724 100%);color:#e6eef3}
|
| 94 |
+
.theme-dark .app-header{background:linear-gradient(90deg, rgba(255,255,255,0.01), rgba(255,255,255,0.005));border-bottom:1px solid rgba(255,255,255,0.03)}
|
| 95 |
+
.theme-dark .brand-logo{box-shadow:0 6px 18px rgba(0,0,0,0.6)}
|
| 96 |
+
.theme-dark .app-main{gap:20px;padding:20px}
|
| 97 |
+
.theme-dark .map-area{background:#071022;border:1px solid rgba(255,255,255,0.03);box-shadow:0 6px 30px rgba(2,6,23,0.6)}
|
| 98 |
+
.theme-dark .control-panel{background:rgba(3,6,12,0.45);border:1px solid rgba(255,255,255,0.03);color:#e6eef3}
|
| 99 |
+
.theme-dark .geojson-box{background:#021017;color:#cfeff6;border:1px solid rgba(255,255,255,0.03)}
|
| 100 |
+
.theme-dark .btn.small{background:transparent;color:#cfeff6;border:1px solid rgba(255,255,255,0.04)}
|
| 101 |
+
|
| 102 |
+
/* keep buttons accessible in dark */
|
| 103 |
+
.theme-dark .btn{box-shadow:0 8px 20px rgba(0,0,0,0.6)}
|
src/App.js
CHANGED
|
@@ -1,25 +1,272 @@
|
|
| 1 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import './App.css';
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
function App() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
return (
|
| 6 |
-
<div className=
|
| 7 |
-
<header className="
|
| 8 |
-
<
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
className="
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
>
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
</header>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
</div>
|
| 22 |
);
|
| 23 |
}
|
| 24 |
-
|
| 25 |
export default App;
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState } from 'react';
|
| 2 |
+
import maplibregl from 'maplibre-gl';
|
| 3 |
+
import 'maplibre-gl/dist/maplibre-gl.css';
|
| 4 |
+
import MaplibreDraw from 'maplibre-gl-draw';
|
| 5 |
+
import 'maplibre-gl-draw/dist/mapbox-gl-draw.css';
|
| 6 |
+
import { useGeoAIWorker } from './hooks/useGeoAIWorker';
|
| 7 |
import './App.css';
|
| 8 |
+
|
| 9 |
+
const config = {
|
| 10 |
+
provider: "esri",
|
| 11 |
+
serviceUrl: "https://server.arcgisonline.com/ArcGIS/rest/services",
|
| 12 |
+
serviceName: "World_Imagery",
|
| 13 |
+
tileSize: 256,
|
| 14 |
+
attribution: "ESRI World Imagery",
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
function App() {
|
| 18 |
+
const mapContainer = useRef(null);
|
| 19 |
+
const map = useRef(null);
|
| 20 |
+
const drawRef = useRef(null);
|
| 21 |
+
const [detections, setDetections] = useState([]);
|
| 22 |
+
const [currentPolygon, setCurrentPolygon] = useState(null);
|
| 23 |
+
const [isDrawing, setIsDrawing] = useState(false);
|
| 24 |
+
const [zoom, setZoom] = useState(null);
|
| 25 |
+
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light');
|
| 26 |
+
const { isReady, isProcessing, result, initialize, runInference } = useGeoAIWorker();
|
| 27 |
+
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
localStorage.setItem('theme', theme);
|
| 30 |
+
}, [theme]);
|
| 31 |
+
|
| 32 |
+
const toggleTheme = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));
|
| 33 |
+
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
initialize([{ task: "building-detection" }], config);
|
| 36 |
+
}, []);
|
| 37 |
+
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
if (!mapContainer.current) return;
|
| 40 |
+
|
| 41 |
+
map.current = new maplibregl.Map({
|
| 42 |
+
container: mapContainer.current,
|
| 43 |
+
style: {
|
| 44 |
+
version: 8,
|
| 45 |
+
sources: {
|
| 46 |
+
satellite: {
|
| 47 |
+
type: 'raster',
|
| 48 |
+
tiles: [
|
| 49 |
+
`https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}`,
|
| 50 |
+
],
|
| 51 |
+
tileSize: 256,
|
| 52 |
+
},
|
| 53 |
+
},
|
| 54 |
+
layers: [{ id: 'satellite', type: 'raster', source: 'satellite' }],
|
| 55 |
+
},
|
| 56 |
+
center: [-117.59, 47.653],
|
| 57 |
+
zoom: 18,
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
// set initial zoom state
|
| 61 |
+
setZoom(map.current.getZoom());
|
| 62 |
+
|
| 63 |
+
// update zoom on changes
|
| 64 |
+
const onZoom = () => setZoom(Number(map.current.getZoom().toFixed(2)));
|
| 65 |
+
map.current.on('zoom', onZoom);
|
| 66 |
+
|
| 67 |
+
const draw = new MaplibreDraw({
|
| 68 |
+
displayControlsDefault: false,
|
| 69 |
+
controls: { polygon: true, trash: true },
|
| 70 |
+
});
|
| 71 |
+
drawRef.current = draw;
|
| 72 |
+
|
| 73 |
+
// @ts-ignore
|
| 74 |
+
map.current.addControl(draw);
|
| 75 |
+
|
| 76 |
+
// cleanup map listeners on unmount
|
| 77 |
+
const cleanupMap = () => {
|
| 78 |
+
try {
|
| 79 |
+
map.current?.off('zoom', onZoom);
|
| 80 |
+
} catch (e) {}
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
map.current.on('draw.create', (e) => {
|
| 84 |
+
const polygon = e.features[0];
|
| 85 |
+
setCurrentPolygon(polygon);
|
| 86 |
+
// exit draw mode when a polygon is created
|
| 87 |
+
try { drawRef.current.changeMode('simple_select'); } catch (err) {}
|
| 88 |
+
setIsDrawing(false);
|
| 89 |
+
// DO NOT run inference automatically here. User must click Analyze.
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
map.current.on('draw.update', (e) => {
|
| 93 |
+
const polygon = e.features[0];
|
| 94 |
+
setCurrentPolygon(polygon);
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
map.current.on('draw.delete', (e) => {
|
| 98 |
+
setCurrentPolygon(null);
|
| 99 |
+
setDetections([]);
|
| 100 |
+
setIsDrawing(false);
|
| 101 |
+
// remove detections layer/source if present
|
| 102 |
+
if (map.current?.getSource('detections')) {
|
| 103 |
+
try {
|
| 104 |
+
map.current.removeLayer('detections');
|
| 105 |
+
map.current.removeSource('detections');
|
| 106 |
+
} catch (err) {}
|
| 107 |
+
}
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
return () => {
|
| 111 |
+
cleanupMap();
|
| 112 |
+
try { map.current?.remove(); } catch (e) {}
|
| 113 |
+
};
|
| 114 |
+
}, [isReady]);
|
| 115 |
+
|
| 116 |
+
useEffect(() => {
|
| 117 |
+
if (!result) return;
|
| 118 |
+
|
| 119 |
+
const features = result.detections?.features || [];
|
| 120 |
+
setDetections(features);
|
| 121 |
+
|
| 122 |
+
if (map.current?.getSource('detections')) {
|
| 123 |
+
map.current.removeLayer('detections');
|
| 124 |
+
map.current.removeSource('detections');
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// set detection fill color to yellow
|
| 128 |
+
const fillColor = '#fdb306ff';
|
| 129 |
+
|
| 130 |
+
map.current?.addSource('detections', { type: 'geojson', data: result.detections });
|
| 131 |
+
map.current?.addLayer({
|
| 132 |
+
id: 'detections',
|
| 133 |
+
type: 'fill',
|
| 134 |
+
source: 'detections',
|
| 135 |
+
paint: { 'fill-color': fillColor, 'fill-opacity': 0.7 },
|
| 136 |
+
});
|
| 137 |
+
}, [result, theme]);
|
| 138 |
+
|
| 139 |
+
// manual analyze handler
|
| 140 |
+
const handleAnalyze = () => {
|
| 141 |
+
if (!isReady || !currentPolygon) return;
|
| 142 |
+
runInference({
|
| 143 |
+
inputs: { polygon: currentPolygon },
|
| 144 |
+
mapSourceParams: { zoomLevel: map.current?.getZoom() || 18 },
|
| 145 |
+
});
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
// clear / reset everything on the map
|
| 149 |
+
const handleClear = () => {
|
| 150 |
+
// remove drawn shapes
|
| 151 |
+
if (drawRef.current) {
|
| 152 |
+
try { drawRef.current.deleteAll(); } catch (e) {}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// clear state
|
| 156 |
+
setCurrentPolygon(null);
|
| 157 |
+
setDetections([]);
|
| 158 |
+
setIsDrawing(false);
|
| 159 |
+
|
| 160 |
+
// remove detections layer/source if present
|
| 161 |
+
try {
|
| 162 |
+
if (map.current?.getLayer('detections')) map.current.removeLayer('detections');
|
| 163 |
+
} catch (e) {}
|
| 164 |
+
try {
|
| 165 |
+
if (map.current?.getSource('detections')) map.current.removeSource('detections');
|
| 166 |
+
} catch (e) {}
|
| 167 |
+
|
| 168 |
+
// optionally fly back to initial view
|
| 169 |
+
try { map.current?.flyTo({ center: [-117.59, 47.653], zoom: 18 }); } catch (e) {}
|
| 170 |
+
};
|
| 171 |
+
|
| 172 |
+
const getDetectionsGeoJSON = () => result?.detections || null;
|
| 173 |
+
|
| 174 |
+
const handleCopy = async () => {
|
| 175 |
+
const geojson = getDetectionsGeoJSON();
|
| 176 |
+
if (!geojson) return;
|
| 177 |
+
try {
|
| 178 |
+
await navigator.clipboard.writeText(JSON.stringify(geojson, null, 2));
|
| 179 |
+
// optionally give feedback
|
| 180 |
+
} catch (err) {
|
| 181 |
+
console.error('Copy failed', err);
|
| 182 |
+
}
|
| 183 |
+
};
|
| 184 |
+
|
| 185 |
+
const handleDownload = () => {
|
| 186 |
+
const geojson = getDetectionsGeoJSON();
|
| 187 |
+
if (!geojson) return;
|
| 188 |
+
const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/geo+json' });
|
| 189 |
+
const url = URL.createObjectURL(blob);
|
| 190 |
+
const a = document.createElement('a');
|
| 191 |
+
a.href = url;
|
| 192 |
+
a.download = 'detections.geojson';
|
| 193 |
+
document.body.appendChild(a);
|
| 194 |
+
a.click();
|
| 195 |
+
a.remove();
|
| 196 |
+
URL.revokeObjectURL(url);
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
return (
|
| 200 |
+
<div className={`app-root ${theme === 'dark' ? 'theme-dark' : 'theme-light'}`}>
|
| 201 |
+
<header className="app-header">
|
| 202 |
+
<div className="brand">
|
| 203 |
+
<h1 className="brand-title">
|
| 204 |
+
GeoAI
|
| 205 |
+
<img src="https://cdn-icons-png.flaticon.com/256/5968/5968292.png" alt="JS logo" height="24" style={{ verticalAlign: 'middle', marginLeft: 8 }} />
|
| 206 |
+
</h1>
|
| 207 |
+
<div className="brand-text">
|
| 208 |
+
<h4 className="demo-title">Building Detection (Demo)</h4>
|
| 209 |
+
<p className="tagline">Inspect satellite imagery and detect buildings with GeoAI</p>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
<div className="status">
|
| 213 |
+
<div className={`ready-dot ${isReady ? 'on' : 'off'}`}></div>
|
| 214 |
+
<span>{isReady ? 'Model ready' : 'Initializing...'}</span>
|
| 215 |
+
<button className="btn small" onClick={toggleTheme} style={{marginLeft:12}}>{theme === 'dark' ? 'Light' : 'Dark'}</button>
|
| 216 |
+
</div>
|
| 217 |
</header>
|
| 218 |
+
|
| 219 |
+
<main className="app-main">
|
| 220 |
+
<section className="map-area">
|
| 221 |
+
<div ref={mapContainer} className="map-container" />
|
| 222 |
+
</section>
|
| 223 |
+
|
| 224 |
+
<aside className="control-panel">
|
| 225 |
+
<h2>Controls</h2>
|
| 226 |
+
<p>Draw a polygon on the map to detect buildings in the selected area.</p>
|
| 227 |
+
|
| 228 |
+
<div className="controls-row">
|
| 229 |
+
<button className="btn" onClick={() => {
|
| 230 |
+
if (!drawRef.current) return;
|
| 231 |
+
try {
|
| 232 |
+
drawRef.current.changeMode('draw_polygon');
|
| 233 |
+
setIsDrawing(true);
|
| 234 |
+
} catch (err) { console.error(err); }
|
| 235 |
+
}} disabled={isDrawing || Boolean(currentPolygon)}>
|
| 236 |
+
{isDrawing ? 'Drawing…' : 'Draw Polygon'}
|
| 237 |
+
</button>
|
| 238 |
+
<button className="btn" onClick={handleAnalyze} disabled={!currentPolygon || !isReady || isProcessing}>
|
| 239 |
+
{isProcessing ? 'Analyzing…' : 'Analyze'}
|
| 240 |
+
</button>
|
| 241 |
+
<button className="btn secondary" onClick={handleClear}>
|
| 242 |
+
Clear
|
| 243 |
+
</button>
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
<div className="stats">
|
| 247 |
+
<strong>{detections.length}</strong>
|
| 248 |
+
<span>Buildings found</span>
|
| 249 |
+
<div style={{marginLeft:12,color:'var(--muted)'}}>Zoom: {zoom !== null ? zoom : '—'}</div>
|
| 250 |
+
</div>
|
| 251 |
+
|
| 252 |
+
<div className="geojson-section">
|
| 253 |
+
<div className="geojson-header">
|
| 254 |
+
<h3>Detections GeoJSON</h3>
|
| 255 |
+
<div className="geojson-actions">
|
| 256 |
+
<button className="btn small" onClick={handleCopy} disabled={!result}>Copy</button>
|
| 257 |
+
<button className="btn small" onClick={handleDownload} disabled={!result}>Download</button>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
<pre className="geojson-box" aria-live="polite">
|
| 261 |
+
{result ? JSON.stringify(result.detections, null, 2) : 'No detections yet.'}
|
| 262 |
+
</pre>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
<div className="footer-note">Powered by Geobase</div>
|
| 266 |
+
</aside>
|
| 267 |
+
</main>
|
| 268 |
</div>
|
| 269 |
);
|
| 270 |
}
|
| 271 |
+
|
| 272 |
export default App;
|
src/hooks/useGeoAIWorker.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from "react";
|
| 2 |
+
|
| 3 |
+
export function useGeoAIWorker() {
|
| 4 |
+
const workerRef = useRef(null);
|
| 5 |
+
const [isReady, setIsReady] = useState(false);
|
| 6 |
+
const [isProcessing, setIsProcessing] = useState(false);
|
| 7 |
+
const [result, setResult] = useState(null);
|
| 8 |
+
|
| 9 |
+
useEffect(() => {
|
| 10 |
+
workerRef.current = new Worker(new URL("./worker.js", import.meta.url));
|
| 11 |
+
|
| 12 |
+
workerRef.current.onmessage = e => {
|
| 13 |
+
const { type, payload } = e.data;
|
| 14 |
+
|
| 15 |
+
switch (type) {
|
| 16 |
+
case "ready":
|
| 17 |
+
setIsReady(true);
|
| 18 |
+
break;
|
| 19 |
+
case "result":
|
| 20 |
+
setResult(payload);
|
| 21 |
+
setIsProcessing(false);
|
| 22 |
+
break;
|
| 23 |
+
case "error":
|
| 24 |
+
console.error("Worker error:", payload);
|
| 25 |
+
setIsProcessing(false);
|
| 26 |
+
break;
|
| 27 |
+
}
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
return () => workerRef.current?.terminate();
|
| 31 |
+
}, []);
|
| 32 |
+
|
| 33 |
+
const initialize = (tasks, providerParams) => {
|
| 34 |
+
workerRef.current?.postMessage({
|
| 35 |
+
type: "init",
|
| 36 |
+
payload: { tasks, providerParams },
|
| 37 |
+
});
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const runInference = params => {
|
| 41 |
+
if (!isReady) return;
|
| 42 |
+
setIsProcessing(true);
|
| 43 |
+
workerRef.current?.postMessage({
|
| 44 |
+
type: "inference",
|
| 45 |
+
payload: params,
|
| 46 |
+
});
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
return { isReady, isProcessing, result, initialize, runInference };
|
| 50 |
+
}
|
src/hooks/worker.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { geoai } from "geoai";
|
| 2 |
+
|
| 3 |
+
let modelInstance = null;
|
| 4 |
+
|
| 5 |
+
// Use a Function to obtain the global object without referencing `globalThis` or `self`
|
| 6 |
+
const workerGlobal = Function("return this")();
|
| 7 |
+
|
| 8 |
+
workerGlobal.onmessage = async e => {
|
| 9 |
+
const { type, payload } = e.data;
|
| 10 |
+
|
| 11 |
+
try {
|
| 12 |
+
switch (type) {
|
| 13 |
+
case "init":
|
| 14 |
+
console.log({payload})
|
| 15 |
+
modelInstance = await geoai.pipeline(
|
| 16 |
+
payload.tasks,
|
| 17 |
+
payload.providerParams
|
| 18 |
+
);
|
| 19 |
+
workerGlobal.postMessage({ type: "ready" });
|
| 20 |
+
break;
|
| 21 |
+
|
| 22 |
+
case "inference":
|
| 23 |
+
const result = await modelInstance.inference(payload);
|
| 24 |
+
workerGlobal.postMessage({ type: "result", payload: result });
|
| 25 |
+
break;
|
| 26 |
+
}
|
| 27 |
+
} catch (error) {
|
| 28 |
+
workerGlobal.postMessage({ type: "error", payload: error.message });
|
| 29 |
+
}
|
| 30 |
+
};
|