KSvend Claude Happy commited on
Commit
27ce485
Β·
1 Parent(s): 504144a

docs: implementation plan for AOI click-to-place redesign

Browse files

5 tasks: HTML cleanup, CSS styles, map.js rewrite,
app.js wiring, and limit verification.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

docs/superpowers/plans/2026-04-06-aoi-click-to-place.md ADDED
@@ -0,0 +1,767 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AOI Click-to-Place Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Replace the two-tap rectangle + MapboxDraw AOI selection with a single-click, fixed-size square placement model.
6
+
7
+ **Architecture:** Remove MapboxDraw entirely. Render AOI as a plain MapLibre GeoJSON source with fill + outline layers. User clicks map β†’ compute square bbox of selected preset size (100/250/500 kmΒ²) β†’ update source. Geocoder auto-places AOI at geocoded center.
8
+
9
+ **Tech Stack:** MapLibre GL JS (already in use), vanilla JS, no new dependencies.
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ | File | Role | Action |
16
+ |------|------|--------|
17
+ | `frontend/index.html` | Page structure, CDN links | Modify: remove Draw CDN, remove GeoJSON upload & draw button HTML, add size toggle buttons |
18
+ | `frontend/js/map.js` | Map logic, AOI placement | Rewrite: remove all MapboxDraw code, add click-to-place with GeoJSON source rendering |
19
+ | `frontend/js/app.js` | SPA state, page wiring | Modify: remove Draw/upload handlers, wire size toggles, update geocode to auto-place |
20
+ | `frontend/css/merlx.css` | Styles | Modify: replace draw-btn/upload-area styles with size-toggle styles |
21
+
22
+ ---
23
+
24
+ ### Task 1: Remove MapboxDraw from HTML and add size toggle buttons
25
+
26
+ **Files:**
27
+ - Modify: `frontend/index.html:15-17` (CDN links)
28
+ - Modify: `frontend/index.html:209-225` (draw tools + upload sections)
29
+
30
+ - [ ] **Step 1: Remove MapboxDraw CDN links**
31
+
32
+ In `frontend/index.html`, delete lines 15-17:
33
+
34
+ ```html
35
+ <!-- Mapbox GL Draw 1.4.3 -->
36
+ <link rel="stylesheet" href="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.css" />
37
+ <script src="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.js"></script>
38
+ ```
39
+
40
+ - [ ] **Step 2: Replace draw tools and upload sections with size toggle**
41
+
42
+ Replace lines 209-225 (the "Draw tools" and "GeoJSON upload" form groups) with:
43
+
44
+ ```html
45
+ <!-- AOI size -->
46
+ <div class="form-group">
47
+ <label class="label">AOI size</label>
48
+ <div class="size-toggles">
49
+ <button class="size-toggle-btn" data-km2="100" type="button">100 kmΒ²</button>
50
+ <button class="size-toggle-btn" data-km2="250" type="button">250 kmΒ²</button>
51
+ <button class="size-toggle-btn active" data-km2="500" type="button">500 kmΒ²</button>
52
+ </div>
53
+ <div id="aoi-area-display" class="aoi-area-display" style="display:none;"></div>
54
+ </div>
55
+ ```
56
+
57
+ - [ ] **Step 3: Update the page subtitle**
58
+
59
+ On line 192, change:
60
+
61
+ ```html
62
+ <p style="font-size: var(--text-xs); color: var(--ink-muted); margin-top: var(--space-2);">Draw, upload, or search for your area of interest.</p>
63
+ ```
64
+
65
+ to:
66
+
67
+ ```html
68
+ <p style="font-size: var(--text-xs); color: var(--ink-muted); margin-top: var(--space-2);">Click the map or search to place your area of interest.</p>
69
+ ```
70
+
71
+ - [ ] **Step 4: Verify HTML loads without errors**
72
+
73
+ Open the app in a browser. Confirm:
74
+ - No console errors about missing MapboxDraw
75
+ - Size toggle buttons appear in the sidebar
76
+ - 500 kmΒ² button has the `active` class
77
+
78
+ - [ ] **Step 5: Commit**
79
+
80
+ ```bash
81
+ git add frontend/index.html
82
+ git commit -m "feat: replace MapboxDraw HTML with size toggle buttons"
83
+ ```
84
+
85
+ ---
86
+
87
+ ### Task 2: Replace CSS styles
88
+
89
+ **Files:**
90
+ - Modify: `frontend/css/merlx.css:549-639`
91
+
92
+ - [ ] **Step 1: Replace draw and upload styles with size toggle styles**
93
+
94
+ In `frontend/css/merlx.css`, replace the `.draw-tools` through `.upload-area input[type="file"]` blocks (lines 549-639) with:
95
+
96
+ ```css
97
+ /* AOI size toggle buttons */
98
+ .size-toggles {
99
+ display: flex;
100
+ gap: var(--space-2);
101
+ }
102
+
103
+ .size-toggle-btn {
104
+ flex: 1;
105
+ height: 32px;
106
+ padding: 0 var(--space-3);
107
+ font-size: var(--text-xs);
108
+ background-color: var(--surface);
109
+ border: 1px solid var(--border);
110
+ border-radius: var(--radius-sm);
111
+ cursor: pointer;
112
+ color: var(--ink-muted);
113
+ font-family: var(--font-data);
114
+ font-weight: 500;
115
+ transition: all var(--motion-default) var(--ease-default);
116
+ }
117
+
118
+ .size-toggle-btn:hover {
119
+ background-color: var(--shell-warm);
120
+ color: var(--ink);
121
+ }
122
+
123
+ .size-toggle-btn.active {
124
+ background-color: var(--iris-dim);
125
+ border-color: var(--iris);
126
+ color: var(--iris-dark);
127
+ font-weight: 600;
128
+ }
129
+
130
+ /* AOI area display */
131
+ .aoi-area-display {
132
+ margin-top: var(--space-3);
133
+ font-family: var(--font-data);
134
+ font-size: var(--text-xs);
135
+ color: var(--ink-muted);
136
+ }
137
+ ```
138
+
139
+ This removes: `.draw-tools`, `.draw-btn`, `.draw-btn:hover`, `.draw-btn.active`, `.aoi-area-over`, `.upload-area`, `.upload-area:hover`, `.upload-area input[type="file"]`.
140
+
141
+ - [ ] **Step 2: Commit**
142
+
143
+ ```bash
144
+ git add frontend/css/merlx.css
145
+ git commit -m "feat: replace draw/upload CSS with size toggle styles"
146
+ ```
147
+
148
+ ---
149
+
150
+ ### Task 3: Rewrite map.js β€” remove MapboxDraw, add click-to-place
151
+
152
+ **Files:**
153
+ - Rewrite: `frontend/js/map.js`
154
+
155
+ - [ ] **Step 1: Rewrite map.js**
156
+
157
+ Replace the entire contents of `frontend/js/map.js` with:
158
+
159
+ ```javascript
160
+ /**
161
+ * Aperture β€” MapLibre GL map tools
162
+ * AOI click-to-place on the Define Area map + results map rendering.
163
+ */
164
+
165
+ const POSITRON_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
166
+
167
+ const SATELLITE_STYLE = {
168
+ version: 8,
169
+ sources: {
170
+ 'esri-satellite': {
171
+ type: 'raster',
172
+ tiles: [
173
+ 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
174
+ ],
175
+ tileSize: 256,
176
+ attribution: '&copy; Esri',
177
+ },
178
+ },
179
+ layers: [
180
+ {
181
+ id: 'esri-satellite-layer',
182
+ type: 'raster',
183
+ source: 'esri-satellite',
184
+ minzoom: 0,
185
+ maxzoom: 19,
186
+ },
187
+ ],
188
+ };
189
+
190
+ /* ── Basemap Toggle Control ────────────────────────────── */
191
+
192
+ let _currentStyle = 'positron';
193
+
194
+ class BasemapToggle {
195
+ onAdd(map) {
196
+ this._map = map;
197
+ this._container = document.createElement('div');
198
+ this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group basemap-toggle';
199
+
200
+ this._btn = document.createElement('button');
201
+ this._btn.type = 'button';
202
+ this._btn.title = 'Switch to Satellite';
203
+ this._btn.setAttribute('aria-label', 'Switch to Satellite');
204
+ this._btn.innerHTML = BasemapToggle._satelliteIcon();
205
+ this._btn.addEventListener('click', () => this._toggle());
206
+
207
+ this._container.appendChild(this._btn);
208
+ return this._container;
209
+ }
210
+
211
+ onRemove() {
212
+ this._container.remove();
213
+ this._map = undefined;
214
+ }
215
+
216
+ _toggle() {
217
+ const isSatellite = _currentStyle === 'satellite';
218
+ const newStyle = isSatellite ? POSITRON_STYLE : SATELLITE_STYLE;
219
+
220
+ _aoiMap.setStyle(newStyle);
221
+
222
+ // Re-add AOI layer after style change
223
+ _aoiMap.once('style.load', () => {
224
+ _addAoiSource();
225
+ if (_currentBbox) _updateAoiLayer(_currentBbox);
226
+ });
227
+
228
+ _currentStyle = isSatellite ? 'positron' : 'satellite';
229
+ const nextLabel = _currentStyle === 'positron' ? 'Switch to Satellite' : 'Switch to Map';
230
+ this._btn.title = nextLabel;
231
+ this._btn.setAttribute('aria-label', nextLabel);
232
+ this._btn.innerHTML = _currentStyle === 'positron'
233
+ ? BasemapToggle._satelliteIcon()
234
+ : BasemapToggle._mapIcon();
235
+ }
236
+
237
+ static _satelliteIcon() {
238
+ return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
239
+ <circle cx="12" cy="12" r="10"/>
240
+ <path d="M2 12h20"/>
241
+ <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
242
+ </svg>`;
243
+ }
244
+
245
+ static _mapIcon() {
246
+ return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
247
+ <polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/>
248
+ <line x1="8" y1="2" x2="8" y2="18"/>
249
+ <line x1="16" y1="6" x2="16" y2="22"/>
250
+ </svg>`;
251
+ }
252
+ }
253
+
254
+ /* ── AOI Map ─────────────────────────────────────────────── */
255
+
256
+ let _aoiMap = null;
257
+ let _onAoiChange = null; // callback(bbox | null)
258
+ let _currentBbox = null; // [minLon, minLat, maxLon, maxLat]
259
+ let _currentCenter = null; // [lng, lat] β€” last click/geocode center
260
+ let _selectedKm2 = 500; // active preset size
261
+
262
+ const AOI_SOURCE_ID = 'aoi-draw';
263
+ const AOI_FILL_LAYER = 'aoi-draw-fill';
264
+ const AOI_OUTLINE_LAYER = 'aoi-draw-outline';
265
+
266
+ /**
267
+ * Compute a square bbox centered on [lng, lat] for targetKm2.
268
+ */
269
+ function computeBbox(centerLng, centerLat, targetKm2) {
270
+ const sideKm = Math.sqrt(targetKm2);
271
+ const dLat = sideKm / 111.32;
272
+ const dLon = sideKm / (111.32 * Math.cos(centerLat * Math.PI / 180));
273
+ return [
274
+ centerLng - dLon / 2,
275
+ centerLat - dLat / 2,
276
+ centerLng + dLon / 2,
277
+ centerLat + dLat / 2,
278
+ ];
279
+ }
280
+
281
+ function _bboxToGeoJSON(bbox) {
282
+ const [minLon, minLat, maxLon, maxLat] = bbox;
283
+ return {
284
+ type: 'FeatureCollection',
285
+ features: [{
286
+ type: 'Feature',
287
+ geometry: {
288
+ type: 'Polygon',
289
+ coordinates: [[
290
+ [minLon, minLat],
291
+ [maxLon, minLat],
292
+ [maxLon, maxLat],
293
+ [minLon, maxLat],
294
+ [minLon, minLat],
295
+ ]],
296
+ },
297
+ properties: {},
298
+ }],
299
+ };
300
+ }
301
+
302
+ const _emptyGeoJSON = { type: 'FeatureCollection', features: [] };
303
+
304
+ function _addAoiSource() {
305
+ if (_aoiMap.getSource(AOI_SOURCE_ID)) return;
306
+ _aoiMap.addSource(AOI_SOURCE_ID, { type: 'geojson', data: _emptyGeoJSON });
307
+ _aoiMap.addLayer({
308
+ id: AOI_FILL_LAYER,
309
+ type: 'fill',
310
+ source: AOI_SOURCE_ID,
311
+ paint: { 'fill-color': '#1A3A34', 'fill-opacity': 0.10 },
312
+ });
313
+ _aoiMap.addLayer({
314
+ id: AOI_OUTLINE_LAYER,
315
+ type: 'line',
316
+ source: AOI_SOURCE_ID,
317
+ paint: { 'line-color': '#1A3A34', 'line-width': 2 },
318
+ });
319
+ }
320
+
321
+ function _updateAoiLayer(bbox) {
322
+ const source = _aoiMap.getSource(AOI_SOURCE_ID);
323
+ if (source) source.setData(_bboxToGeoJSON(bbox));
324
+ }
325
+
326
+ function _placeAoi(lng, lat) {
327
+ _currentCenter = [lng, lat];
328
+ _currentBbox = computeBbox(lng, lat, _selectedKm2);
329
+ _updateAoiLayer(_currentBbox);
330
+ if (_onAoiChange) _onAoiChange(_currentBbox);
331
+ }
332
+
333
+ /**
334
+ * Initialise the AOI map inside containerId.
335
+ * @param {string} containerId
336
+ * @param {function} onAoiChange - called with [minLon,minLat,maxLon,maxLat] or null
337
+ */
338
+ export function initAoiMap(containerId, onAoiChange) {
339
+ _onAoiChange = onAoiChange;
340
+ _currentStyle = 'positron';
341
+
342
+ _aoiMap = new maplibregl.Map({
343
+ container: containerId,
344
+ style: POSITRON_STYLE,
345
+ center: [37.0, 3.0],
346
+ zoom: 4,
347
+ });
348
+
349
+ _aoiMap.addControl(new BasemapToggle(), 'top-right');
350
+ _aoiMap.getCanvas().style.cursor = 'crosshair';
351
+
352
+ _aoiMap.on('load', () => {
353
+ _addAoiSource();
354
+ });
355
+
356
+ _aoiMap.on('click', (e) => {
357
+ _placeAoi(e.lngLat.lng, e.lngLat.lat);
358
+ });
359
+ }
360
+
361
+ /**
362
+ * Change the AOI preset size. If an AOI is already placed, resize it.
363
+ * @param {number} km2 - target area in kmΒ²
364
+ */
365
+ export function setAoiSize(km2) {
366
+ _selectedKm2 = km2;
367
+ if (_currentCenter) {
368
+ _currentBbox = computeBbox(_currentCenter[0], _currentCenter[1], km2);
369
+ _updateAoiLayer(_currentBbox);
370
+ if (_onAoiChange) _onAoiChange(_currentBbox);
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Search for a location via Nominatim, fly there, and auto-place the AOI.
376
+ * @param {string} query
377
+ */
378
+ export async function geocode(query) {
379
+ const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1`;
380
+ const res = await fetch(url, { headers: { 'Accept-Language': 'en' } });
381
+ const results = await res.json();
382
+ if (!results.length) throw new Error('Location not found');
383
+ const { lon, lat } = results[0];
384
+ const lng = parseFloat(lon);
385
+ const latNum = parseFloat(lat);
386
+
387
+ _placeAoi(lng, latNum);
388
+
389
+ // Fly to fit the placed bbox
390
+ _aoiMap.fitBounds(
391
+ [[_currentBbox[0], _currentBbox[1]], [_currentBbox[2], _currentBbox[3]]],
392
+ { padding: 60, duration: 800 }
393
+ );
394
+ }
395
+
396
+ /* ── Results Map ─────────────────────────────────────────── */
397
+
398
+ let _resultsMap = null;
399
+
400
+ /**
401
+ * Initialise the results map inside containerId.
402
+ * @param {string} containerId
403
+ * @param {Array<number>} bbox - [minLon, minLat, maxLon, maxLat]
404
+ */
405
+ export function initResultsMap(containerId, bbox) {
406
+ _resultsMap = new maplibregl.Map({
407
+ container: containerId,
408
+ style: POSITRON_STYLE,
409
+ bounds: [[bbox[0], bbox[1]], [bbox[2], bbox[3]]],
410
+ fitBoundsOptions: { padding: 60 },
411
+ });
412
+
413
+ _resultsMap.on('load', () => {
414
+ _resultsMap.addSource('aoi', {
415
+ type: 'geojson',
416
+ data: _bboxToGeoJSON(bbox),
417
+ });
418
+
419
+ _resultsMap.addLayer({
420
+ id: 'aoi-fill',
421
+ type: 'fill',
422
+ source: 'aoi',
423
+ paint: { 'fill-color': '#1A3A34', 'fill-opacity': 0.08 },
424
+ });
425
+
426
+ _resultsMap.addLayer({
427
+ id: 'aoi-outline',
428
+ type: 'line',
429
+ source: 'aoi',
430
+ paint: { 'line-color': '#1A3A34', 'line-width': 2, 'line-opacity': 0.7 },
431
+ });
432
+ });
433
+ }
434
+
435
+ const STATUS_COLORS = { green: '#3BAA7F', amber: '#CA5D0F', red: '#B83A2A' };
436
+ const CONFIDENCE_COLORS = { high: '#B83A2A', nominal: '#CA5D0F', low: '#E8C547' };
437
+
438
+ export function renderSpatialOverlay(spatialData) {
439
+ clearSpatialOverlay();
440
+ if (!_resultsMap) return;
441
+
442
+ const mapType = spatialData.map_type;
443
+ const status = spatialData.status;
444
+
445
+ if (mapType === 'points' && spatialData.geojson) {
446
+ _renderPoints(spatialData.geojson);
447
+ } else if (mapType === 'choropleth' && spatialData.geojson) {
448
+ _renderChoropleth(spatialData.geojson, spatialData.colormap);
449
+ } else if (mapType === 'grid' && spatialData.data) {
450
+ _renderGrid(spatialData);
451
+ } else {
452
+ _renderStatusOverlay(status);
453
+ }
454
+ }
455
+
456
+ export function clearSpatialOverlay() {
457
+ if (!_resultsMap) return;
458
+ for (const id of ['spatial-points', 'spatial-choropleth', 'spatial-grid']) {
459
+ if (_resultsMap.getLayer(id)) _resultsMap.removeLayer(id);
460
+ if (_resultsMap.getSource(id)) _resultsMap.removeSource(id);
461
+ }
462
+ if (_resultsMap.getLayer('aoi-fill')) {
463
+ _resultsMap.setPaintProperty('aoi-fill', 'fill-color', '#1A3A34');
464
+ _resultsMap.setPaintProperty('aoi-fill', 'fill-opacity', 0.08);
465
+ }
466
+ if (_resultsMap.getLayer('aoi-outline')) {
467
+ _resultsMap.setPaintProperty('aoi-outline', 'line-color', '#1A3A34');
468
+ }
469
+ }
470
+
471
+ function _renderPoints(geojson) {
472
+ _resultsMap.addSource('spatial-points', { type: 'geojson', data: geojson });
473
+ _resultsMap.addLayer({
474
+ id: 'spatial-points',
475
+ type: 'circle',
476
+ source: 'spatial-points',
477
+ paint: {
478
+ 'circle-radius': 5,
479
+ 'circle-color': [
480
+ 'match', ['get', 'confidence'],
481
+ 'high', CONFIDENCE_COLORS.high,
482
+ 'nominal', CONFIDENCE_COLORS.nominal,
483
+ 'low', CONFIDENCE_COLORS.low,
484
+ CONFIDENCE_COLORS.nominal,
485
+ ],
486
+ 'circle-stroke-width': 1,
487
+ 'circle-stroke-color': '#111',
488
+ 'circle-opacity': 0.85,
489
+ },
490
+ }, 'aoi-fill');
491
+ }
492
+
493
+ function _renderChoropleth(geojson, colormap) {
494
+ _resultsMap.addSource('spatial-choropleth', { type: 'geojson', data: geojson });
495
+ const values = geojson.features.map(f => f.properties.value || 0);
496
+ const vmin = Math.min(...values);
497
+ const vmax = Math.max(...values);
498
+ const mid = (vmin + vmax) / 2;
499
+ _resultsMap.addLayer({
500
+ id: 'spatial-choropleth',
501
+ type: 'fill',
502
+ source: 'spatial-choropleth',
503
+ paint: {
504
+ 'fill-color': [
505
+ 'interpolate', ['linear'], ['get', 'value'],
506
+ vmin, colormap === 'Blues' ? '#deebf7' : '#f7fcb9',
507
+ mid, colormap === 'Blues' ? '#6baed6' : '#78c679',
508
+ vmax, colormap === 'Blues' ? '#08519c' : '#006837',
509
+ ],
510
+ 'fill-opacity': 0.55,
511
+ },
512
+ }, 'aoi-fill');
513
+ }
514
+
515
+ function _renderGrid(spatialData) {
516
+ const { data, lats, lons } = spatialData;
517
+ const features = [];
518
+ for (let r = 0; r < lats.length - 1; r++) {
519
+ for (let c = 0; c < lons.length - 1; c++) {
520
+ const val = data[r][c];
521
+ if (val == null) continue;
522
+ features.push({
523
+ type: 'Feature',
524
+ geometry: {
525
+ type: 'Polygon',
526
+ coordinates: [[
527
+ [lons[c], lats[r]],
528
+ [lons[c + 1], lats[r]],
529
+ [lons[c + 1], lats[r + 1]],
530
+ [lons[c], lats[r + 1]],
531
+ [lons[c], lats[r]],
532
+ ]],
533
+ },
534
+ properties: { value: val },
535
+ });
536
+ }
537
+ }
538
+ const geojson = { type: 'FeatureCollection', features };
539
+ const values = features.map(f => f.properties.value);
540
+ const vmin = Math.min(...values);
541
+ const vmax = Math.max(...values);
542
+ const mid = (vmin + vmax) / 2;
543
+ const isTemp = spatialData.colormap === 'coolwarm';
544
+ _resultsMap.addSource('spatial-grid', { type: 'geojson', data: geojson });
545
+ _resultsMap.addLayer({
546
+ id: 'spatial-grid',
547
+ type: 'fill',
548
+ source: 'spatial-grid',
549
+ paint: {
550
+ 'fill-color': [
551
+ 'interpolate', ['linear'], ['get', 'value'],
552
+ vmin, isTemp ? '#3b4cc0' : '#deebf7',
553
+ mid, isTemp ? '#f7f7f7' : '#6baed6',
554
+ vmax, isTemp ? '#b40426' : '#08519c',
555
+ ],
556
+ 'fill-opacity': 0.6,
557
+ },
558
+ }, 'aoi-fill');
559
+ }
560
+
561
+ function _renderStatusOverlay(status) {
562
+ const color = STATUS_COLORS[status] || '#1A3A34';
563
+ if (_resultsMap.getLayer('aoi-fill')) {
564
+ _resultsMap.setPaintProperty('aoi-fill', 'fill-color', color);
565
+ _resultsMap.setPaintProperty('aoi-fill', 'fill-opacity', 0.15);
566
+ }
567
+ if (_resultsMap.getLayer('aoi-outline')) {
568
+ _resultsMap.setPaintProperty('aoi-outline', 'line-color', color);
569
+ }
570
+ }
571
+ ```
572
+
573
+ - [ ] **Step 2: Verify the map loads**
574
+
575
+ Open the app, navigate to the Define Area page. Confirm:
576
+ - Map renders with crosshair cursor
577
+ - No console errors about MapboxDraw
578
+ - Basemap toggle still works
579
+
580
+ - [ ] **Step 3: Commit**
581
+
582
+ ```bash
583
+ git add frontend/js/map.js
584
+ git commit -m "feat: rewrite map.js with click-to-place AOI (no MapboxDraw)"
585
+ ```
586
+
587
+ ---
588
+
589
+ ### Task 4: Update app.js β€” wire size toggles, remove old handlers
590
+
591
+ **Files:**
592
+ - Modify: `frontend/js/app.js:7` (imports)
593
+ - Modify: `frontend/js/app.js:160-265` (setupDefineArea function)
594
+
595
+ - [ ] **Step 1: Update imports**
596
+
597
+ In `frontend/js/app.js`, change line 7 from:
598
+
599
+ ```javascript
600
+ import { initAoiMap, activateDrawRect, geocode, loadGeoJSON, initResultsMap } from './map.js';
601
+ ```
602
+
603
+ to:
604
+
605
+ ```javascript
606
+ import { initAoiMap, setAoiSize, geocode, initResultsMap } from './map.js';
607
+ ```
608
+
609
+ - [ ] **Step 2: Rewrite setupDefineArea function**
610
+
611
+ Replace the entire `setupDefineArea()` function (lines 160-266) with:
612
+
613
+ ```javascript
614
+ /* Define Area ─────────────────────────────────────────────── */
615
+
616
+ let _aoiMapInit = false;
617
+ let _currentBbox = null;
618
+
619
+ function setupDefineArea() {
620
+ updateSteps('define-area');
621
+
622
+ const continueBtn = document.getElementById('aoi-continue-btn');
623
+ const geocoderInput = document.getElementById('geocoder-input');
624
+ const areaDisplay = document.getElementById('aoi-area-display');
625
+ const sizeButtons = document.querySelectorAll('.size-toggle-btn');
626
+
627
+ continueBtn.disabled = true;
628
+
629
+ // Size toggle buttons
630
+ sizeButtons.forEach(btn => {
631
+ btn.addEventListener('click', () => {
632
+ sizeButtons.forEach(b => b.classList.remove('active'));
633
+ btn.classList.add('active');
634
+ setAoiSize(parseInt(btn.dataset.km2));
635
+ });
636
+ });
637
+
638
+ // Init map once
639
+ if (!_aoiMapInit) {
640
+ _aoiMapInit = true;
641
+ initAoiMap('map', (bbox) => {
642
+ _currentBbox = bbox;
643
+ if (bbox) {
644
+ const area = _bboxAreaKm2(bbox);
645
+ areaDisplay.style.display = '';
646
+ areaDisplay.textContent = `${Math.round(area).toLocaleString()} kmΒ²`;
647
+ areaDisplay.className = 'aoi-area-display';
648
+ continueBtn.disabled = false;
649
+ } else {
650
+ areaDisplay.style.display = 'none';
651
+ continueBtn.disabled = true;
652
+ }
653
+ });
654
+ }
655
+
656
+ // Geocoder β€” auto-places AOI
657
+ geocoderInput.addEventListener('keydown', async (e) => {
658
+ if (e.key !== 'Enter') return;
659
+ const query = geocoderInput.value.trim();
660
+ if (!query) return;
661
+ try {
662
+ await geocode(query);
663
+ } catch (err) {
664
+ showError('Location not found. Try a different search term.');
665
+ }
666
+ });
667
+
668
+ // Date defaults: last 12 months
669
+ const today = new Date();
670
+ const yearAgo = new Date(today);
671
+ yearAgo.setFullYear(today.getFullYear() - 1);
672
+ document.getElementById('date-start').value = _isoDate(yearAgo);
673
+ document.getElementById('date-end').value = _isoDate(today);
674
+
675
+ // Season wrap hint
676
+ const seasonStartEl = document.getElementById('season-start');
677
+ const seasonEndEl = document.getElementById('season-end');
678
+ const wrapHint = document.getElementById('season-wrap-hint');
679
+ function updateWrapHint() {
680
+ wrapHint.style.display = (parseInt(seasonStartEl.value) > parseInt(seasonEndEl.value)) ? '' : 'none';
681
+ }
682
+ seasonStartEl.addEventListener('change', updateWrapHint);
683
+ seasonEndEl.addEventListener('change', updateWrapHint);
684
+
685
+ // Continue
686
+ continueBtn.addEventListener('click', () => {
687
+ const name = document.getElementById('area-name').value.trim() || 'Unnamed area';
688
+ const startVal = document.getElementById('date-start').value;
689
+ const endVal = document.getElementById('date-end').value;
690
+
691
+ state.aoi = { name, bbox: _currentBbox };
692
+ state.timeRange = { start: startVal, end: endVal };
693
+ state.seasonStart = parseInt(document.getElementById('season-start').value);
694
+ state.seasonEnd = parseInt(document.getElementById('season-end').value);
695
+
696
+ navigate('indicators');
697
+ }, { once: true });
698
+ }
699
+ ```
700
+
701
+ - [ ] **Step 3: Remove unused imports from map.js**
702
+
703
+ The old imports `activateDrawRect`, `loadGeoJSON` are now removed from the import line (done in step 1). Verify no other references to these functions exist in app.js:
704
+
705
+ ```bash
706
+ grep -n "activateDrawRect\|loadGeoJSON\|draw-rect-btn\|geojson-upload\|upload-label\|uploadInput\|rectBtn" frontend/js/app.js
707
+ ```
708
+
709
+ Expected: no matches.
710
+
711
+ - [ ] **Step 4: Verify the full flow works**
712
+
713
+ 1. Open the app, log in, navigate to Define Area
714
+ 2. Click on the map β†’ square AOI appears, area displays in sidebar, Continue enabled
715
+ 3. Click elsewhere β†’ AOI moves to new location
716
+ 4. Toggle size buttons β†’ AOI resizes around same center
717
+ 5. Type a location in search β†’ AOI auto-places, map flies to it
718
+ 6. Click Continue β†’ proceeds to indicators page
719
+
720
+ - [ ] **Step 5: Commit**
721
+
722
+ ```bash
723
+ git add frontend/js/app.js
724
+ git commit -m "feat: wire size toggles and click-to-place in app.js"
725
+ ```
726
+
727
+ ---
728
+
729
+ ### Task 5: Sync frontend max with backend limit
730
+
731
+ **Files:**
732
+ - Modify: `frontend/js/app.js:175` (remove old 10,000 kmΒ² constant)
733
+
734
+ - [ ] **Step 1: Verify the old AOI_MAX_KM2 constant is gone**
735
+
736
+ The rewritten `setupDefineArea()` in Task 4 no longer references `AOI_MAX_KM2` or the `overLimit` check. Confirm by searching:
737
+
738
+ ```bash
739
+ grep -n "AOI_MAX_KM2\|overLimit\|aoi-area-over" frontend/js/app.js
740
+ ```
741
+
742
+ Expected: no matches.
743
+
744
+ - [ ] **Step 2: Update backend MAX_AOI_KM2 comment**
745
+
746
+ In `app/config.py`, the backend limit is `MAX_AOI_KM2 = 500` and the largest preset is 500 kmΒ². No code change needed, but verify they match:
747
+
748
+ ```bash
749
+ grep MAX_AOI_KM2 app/config.py
750
+ ```
751
+
752
+ Expected: `MAX_AOI_KM2: int = int(os.environ.get("APERTURE_MAX_AOI_KM2", "500"))`
753
+
754
+ - [ ] **Step 3: Final end-to-end check**
755
+
756
+ 1. Place AOI at each size (100, 250, 500)
757
+ 2. Verify area display shows correct value each time
758
+ 3. Search for "Khartoum" β†’ AOI auto-places
759
+ 4. Toggle basemap β†’ AOI persists
760
+ 5. Click Continue β†’ submit a job β†’ verify it reaches the backend without validation errors
761
+
762
+ - [ ] **Step 4: Commit all remaining changes**
763
+
764
+ ```bash
765
+ git add -A
766
+ git commit -m "chore: clean up AOI click-to-place β€” remove unused code and verify limits"
767
+ ```