mhassanch commited on
Commit
1ed908c
·
1 Parent(s): 4141aa9

add building detection demo

Browse files
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
- .App {
2
- text-align: center;
 
 
 
 
 
 
 
3
  }
4
 
5
- .App-logo {
6
- height: 40vmin;
7
- pointer-events: none;
8
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
- @media (prefers-reduced-motion: no-preference) {
11
- .App-logo {
12
- animation: App-logo-spin infinite 20s linear;
13
- }
 
 
 
14
  }
15
 
16
- .App-header {
17
- background-color: #282c34;
18
- min-height: 100vh;
19
- display: flex;
20
- flex-direction: column;
21
- align-items: center;
22
- justify-content: center;
23
- font-size: calc(10px + 2vmin);
24
- color: white;
 
 
 
25
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
- .App-link {
28
- color: #61dafb;
 
 
 
29
  }
30
 
31
- @keyframes App-logo-spin {
32
- from {
33
- transform: rotate(0deg);
34
- }
35
- to {
36
- transform: rotate(360deg);
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 logo from './logo.svg';
 
 
 
 
 
2
  import './App.css';
3
-
 
 
 
 
 
 
 
 
4
  function App() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  return (
6
- <div className="App">
7
- <header className="App-header">
8
- <img src={logo} className="App-logo" alt="logo" />
9
- <p>
10
- Edit <code>src/App.js</code> and save to reload.
11
- </p>
12
- <a
13
- className="App-link"
14
- href="https://reactjs.org"
15
- target="_blank"
16
- rel="noopener noreferrer"
17
- >
18
- Learn React
19
- </a>
 
 
 
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
+ };