KSvend Claude Happy commited on
Commit Β·
27ce485
1
Parent(s): 504144a
docs: implementation plan for AOI click-to-place redesign
Browse files5 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: '© 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 |
+
```
|