this repo has no description

docs: widget-leaflet extensions implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+298
+298
docs/plans/2026-03-06-widget-leaflet-extensions.md
··· 1 + # Widget-Leaflet Extensions Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add bounding box drawing, image overlay, and marker commands to the existing leaflet map widget adapter. 6 + 7 + **Architecture:** All changes are to the JS adapter code embedded in `widget_leaflet.ml`. The widget system's existing `Widget.command` and event handler mechanism is used — no OCaml API changes needed. New commands and events are added to the adapter's `command` function and `create` function respectively. 8 + 9 + **Tech Stack:** JavaScript (Leaflet API), OCaml (widget_leaflet.ml) 10 + 11 + --- 12 + 13 + ### Task 1: Bounding box drawing 14 + 15 + **Files:** 16 + - Modify: `js_top_worker/widget-leaflet/widget_leaflet.ml` 17 + 18 + **What to change:** 19 + 20 + In the JS adapter (the `{js|...|js}` string in `widget_leaflet.ml`), make these modifications: 21 + 22 + **Step 1: Add bbox state to the `create` function** 23 + 24 + In the `create` function, after `var state = { map: null, geojsonLayer: null };`, add bbox-related state: 25 + 26 + Change: 27 + ```javascript 28 + var state = { map: null, geojsonLayer: null }; 29 + ``` 30 + To: 31 + ```javascript 32 + var state = { map: null, geojsonLayer: null, bboxRect: null, bboxDrawing: false, imageOverlay: null, markers: [] }; 33 + ``` 34 + 35 + **Step 2: Add `enableBboxDraw` command** 36 + 37 + In the `command` function, after the `invalidateSize` handler, add: 38 + 39 + ```javascript 40 + else if (cmd === "enableBboxDraw") { 41 + if (state.bboxDrawing) return; 42 + state.bboxDrawing = true; 43 + state.map.getContainer().style.cursor = "crosshair"; 44 + var startLatLng = null; 45 + var tempRect = null; 46 + function onMouseDown(e) { 47 + startLatLng = e.latlng; 48 + L.DomEvent.preventDefault(e.originalEvent); 49 + } 50 + function onMouseMove(e) { 51 + if (!startLatLng) return; 52 + var bounds = L.latLngBounds(startLatLng, e.latlng); 53 + if (tempRect) state.map.removeLayer(tempRect); 54 + tempRect = L.rectangle(bounds, {color: "#ff0000", weight: 2, dashArray: "5,5", fillOpacity: 0.1}).addTo(state.map); 55 + } 56 + function onMouseUp(e) { 57 + if (!startLatLng) return; 58 + var bounds = L.latLngBounds(startLatLng, e.latlng); 59 + if (tempRect) state.map.removeLayer(tempRect); 60 + if (state.bboxRect) state.map.removeLayer(state.bboxRect); 61 + state.bboxRect = L.rectangle(bounds, {color: "#2f7d31", weight: 2, fillOpacity: 0.15}).addTo(state.map); 62 + var b = bounds; 63 + state._send("bbox_drawn", JSON.stringify({ 64 + south: b.getSouth(), west: b.getWest(), 65 + north: b.getNorth(), east: b.getEast() 66 + })); 67 + startLatLng = null; 68 + state.bboxDrawing = false; 69 + state.map.getContainer().style.cursor = ""; 70 + state.map.off("mousedown", onMouseDown); 71 + state.map.off("mousemove", onMouseMove); 72 + state.map.off("mouseup", onMouseUp); 73 + state.map.dragging.enable(); 74 + } 75 + state.map.dragging.disable(); 76 + state.map.on("mousedown", onMouseDown); 77 + state.map.on("mousemove", onMouseMove); 78 + state.map.on("mouseup", onMouseUp); 79 + } 80 + ``` 81 + 82 + **Step 3: Store the `send` function on state** 83 + 84 + In the `create` function, the `send` function is a local parameter. We need to store it on `state` so the `command` function can use it for the `bbox_drawn` event. After `state.map = map;`, add: 85 + 86 + ```javascript 87 + state._send = send; 88 + ``` 89 + 90 + **Step 4: Build and verify** 91 + 92 + Run: `cd ~/workspace/mono && opam exec -- dune build js_top_worker/widget-leaflet/` 93 + Expected: Build succeeds. 94 + 95 + **Step 5: Commit** 96 + 97 + ``` 98 + git add js_top_worker/widget-leaflet/widget_leaflet.ml 99 + git commit -m "widget-leaflet: add bounding box drawing command" 100 + ``` 101 + 102 + --- 103 + 104 + ### Task 2: Image overlay and markers 105 + 106 + **Files:** 107 + - Modify: `js_top_worker/widget-leaflet/widget_leaflet.ml` 108 + 109 + **Step 1: Add image overlay commands** 110 + 111 + In the `command` function, after the `enableBboxDraw` handler, add: 112 + 113 + ```javascript 114 + else if (cmd === "addImageOverlay") { 115 + if (state.imageOverlay) state.map.removeLayer(state.imageOverlay); 116 + state.imageOverlay = L.imageOverlay(d.url, d.bounds, { 117 + opacity: d.opacity || 0.7 118 + }).addTo(state.map); 119 + } 120 + else if (cmd === "removeImageOverlay") { 121 + if (state.imageOverlay) { state.map.removeLayer(state.imageOverlay); state.imageOverlay = null; } 122 + } 123 + ``` 124 + 125 + **Step 2: Add marker commands** 126 + 127 + After the image overlay commands, add: 128 + 129 + ```javascript 130 + else if (cmd === "addMarker") { 131 + var marker = L.circleMarker(L.latLng(d.lat, d.lng), { 132 + radius: 6, color: d.color || "#ff0000", fillColor: d.color || "#ff0000", 133 + fillOpacity: 0.8, weight: 2 134 + }).addTo(state.map); 135 + if (d.label) marker.bindTooltip(d.label, {permanent: true, direction: "right", offset: [8, 0]}); 136 + state.markers.push(marker); 137 + } 138 + else if (cmd === "clearMarkers") { 139 + state.markers.forEach(function(m) { state.map.removeLayer(m); }); 140 + state.markers = []; 141 + } 142 + ``` 143 + 144 + **Step 3: Handle empty data for commands that don't need it** 145 + 146 + The `command` function currently does `var d = JSON.parse(data);` unconditionally. Some commands (like `enableBboxDraw`, `removeImageOverlay`, `clearMarkers`, `invalidateSize`) may be called with empty string data. Change: 147 + 148 + ```javascript 149 + var d = JSON.parse(data); 150 + ``` 151 + To: 152 + ```javascript 153 + var d = data ? JSON.parse(data) : {}; 154 + ``` 155 + 156 + **Step 4: Build and verify** 157 + 158 + Run: `cd ~/workspace/mono && opam exec -- dune build js_top_worker/widget-leaflet/` 159 + Expected: Build succeeds. 160 + 161 + **Step 5: Commit** 162 + 163 + ``` 164 + git add js_top_worker/widget-leaflet/widget_leaflet.ml 165 + git commit -m "widget-leaflet: add image overlay and marker commands" 166 + ``` 167 + 168 + --- 169 + 170 + ### Task 3: Playwright test 171 + 172 + **Files:** 173 + - Create: `js_top_worker/widget-leaflet/test/test_adapter.html` 174 + 175 + **Context:** We test the JS adapter directly in a standalone HTML page (not through the full notebook widget system). The page loads Leaflet, evaluates the adapter IIFE, and exercises the commands. Playwright verifies DOM state. 176 + 177 + **Step 1: Create test HTML** 178 + 179 + ```html 180 + <!DOCTYPE html> 181 + <html> 182 + <head> 183 + <title>widget-leaflet adapter test</title> 184 + <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> 185 + <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> 186 + <style>#map { width: 600px; height: 400px; }</style> 187 + </head> 188 + <body> 189 + <h1>Widget-Leaflet Adapter Test</h1> 190 + <div id="map"></div> 191 + <p>Events: <span id="events">none</span></p> 192 + <p>Status: <span id="status">initializing...</span></p> 193 + <script> 194 + // Simulate the adapter IIFE (copy from widget_leaflet.ml, minus ensureCSS/ensureScript since we load directly) 195 + var events = []; 196 + function send(type, data) { 197 + events.push({type: type, data: data}); 198 + document.getElementById('events').textContent = JSON.stringify(events); 199 + } 200 + 201 + var container = document.getElementById('map'); 202 + var config = JSON.stringify({center: [51.505, -0.09], zoom: 13, height: "400px"}); 203 + 204 + // Create map directly (Leaflet already loaded) 205 + var div = document.createElement("div"); 206 + div.style.cssText = "width:100%;height:100%"; 207 + container.appendChild(div); 208 + var map = L.map(div).setView([51.505, -0.09], 13); 209 + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { 210 + maxZoom: 19, attribution: "&copy; OpenStreetMap" 211 + }).addTo(map); 212 + 213 + var state = { 214 + map: map, geojsonLayer: null, bboxRect: null, 215 + bboxDrawing: false, imageOverlay: null, markers: [], 216 + _send: send 217 + }; 218 + 219 + // Copy the command function from the adapter 220 + function command(cmd, data) { 221 + // This will be filled with the actual command implementation 222 + // For testing, we inline it 223 + if (!state.map) return; 224 + var d = data ? JSON.parse(data) : {}; 225 + if (cmd === "addMarker") { 226 + var marker = L.circleMarker(L.latLng(d.lat, d.lng), { 227 + radius: 6, color: d.color || "#ff0000", fillColor: d.color || "#ff0000", 228 + fillOpacity: 0.8, weight: 2 229 + }).addTo(state.map); 230 + if (d.label) marker.bindTooltip(d.label, {permanent: true, direction: "right", offset: [8, 0]}); 231 + state.markers.push(marker); 232 + } 233 + else if (cmd === "clearMarkers") { 234 + state.markers.forEach(function(m) { state.map.removeLayer(m); }); 235 + state.markers = []; 236 + } 237 + else if (cmd === "addImageOverlay") { 238 + if (state.imageOverlay) state.map.removeLayer(state.imageOverlay); 239 + state.imageOverlay = L.imageOverlay(d.url, d.bounds, { 240 + opacity: d.opacity || 0.7 241 + }).addTo(state.map); 242 + } 243 + else if (cmd === "removeImageOverlay") { 244 + if (state.imageOverlay) { state.map.removeLayer(state.imageOverlay); state.imageOverlay = null; } 245 + } 246 + } 247 + 248 + // Run tests 249 + try { 250 + // Test 1: Add markers 251 + command("addMarker", JSON.stringify({lat: 51.505, lng: -0.09, color: "blue", label: "Point A"})); 252 + command("addMarker", JSON.stringify({lat: 51.51, lng: -0.08, color: "red", label: "Point B"})); 253 + var markerCount = state.markers.length; 254 + 255 + // Test 2: Add image overlay (1x1 red PNG as data URL) 256 + var tinyPng = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; 257 + command("addImageOverlay", JSON.stringify({ 258 + url: tinyPng, 259 + bounds: [[51.5, -0.1], [51.51, -0.08]], 260 + opacity: 0.5 261 + })); 262 + var hasOverlay = state.imageOverlay !== null; 263 + 264 + // Test 3: Clear markers 265 + command("clearMarkers", ""); 266 + var markersAfterClear = state.markers.length; 267 + 268 + // Test 4: Remove overlay 269 + command("removeImageOverlay", ""); 270 + var overlayAfterRemove = state.imageOverlay; 271 + 272 + document.getElementById('status').textContent = 273 + "markers=" + markerCount + 274 + " hasOverlay=" + hasOverlay + 275 + " markersAfterClear=" + markersAfterClear + 276 + " overlayAfterRemove=" + (overlayAfterRemove === null ? "null" : "exists") + 277 + " ALL_PASSED"; 278 + } catch(e) { 279 + document.getElementById('status').textContent = "FAILED: " + e.message; 280 + } 281 + </script> 282 + </body> 283 + </html> 284 + ``` 285 + 286 + **Step 2: Test with Playwright** 287 + 288 + 1. Serve: `cd ~/workspace/mono/js_top_worker/widget-leaflet/test && python3 -m http.server 8767` 289 + 2. Navigate to `http://localhost:8767/test_adapter.html` 290 + 3. Wait for `#status` to contain "ALL_PASSED" 291 + 4. Verify status text contains: `markers=2 hasOverlay=true markersAfterClear=0 overlayAfterRemove=null ALL_PASSED` 292 + 293 + **Step 3: Commit** 294 + 295 + ``` 296 + git add js_top_worker/widget-leaflet/test/ 297 + git commit -m "widget-leaflet: add Playwright adapter test" 298 + ```