KSvend commited on
Commit
59bad34
Β·
1 Parent(s): c8318e6

feat: rewrite map.js with click-to-place AOI (no MapboxDraw)

Browse files
Files changed (1) hide show
  1. frontend/js/map.js +117 -273
frontend/js/map.js CHANGED
@@ -1,6 +1,6 @@
1
  /**
2
- * Aperture β€” MapLibre GL + Mapbox GL Draw tools
3
- * Manages the AOI definition map (Define Area page) and the results map.
4
  */
5
 
6
  const POSITRON_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
@@ -58,11 +58,14 @@ class BasemapToggle {
58
  const isSatellite = _currentStyle === 'satellite';
59
  const newStyle = isSatellite ? POSITRON_STYLE : SATELLITE_STYLE;
60
 
61
- // Just swap style β€” Draw handles its own re-initialization internally
62
- // via its own style.load listener. Features persist in Draw's internal store.
63
  _aoiMap.setStyle(newStyle);
64
 
65
- // Update state and button
 
 
 
 
 
66
  _currentStyle = isSatellite ? 'positron' : 'satellite';
67
  const nextLabel = _currentStyle === 'positron' ? 'Switch to Satellite' : 'Switch to Map';
68
  this._btn.title = nextLabel;
@@ -92,193 +95,125 @@ class BasemapToggle {
92
  /* ── AOI Map ─────────────────────────────────────────────── */
93
 
94
  let _aoiMap = null;
95
- let _draw = null;
96
- let _onAoiChange = null; // callback(bbox | null)
97
- let _rectFirstCorner = null; // [lon, lat] or null β€” for two-tap rectangle
 
 
 
 
 
98
 
99
  /**
100
- * Initialise the AOI draw map inside containerId.
101
- * @param {string} containerId - DOM id for the map container
102
- * @param {function} onAoiChange - called with [minLon,minLat,maxLon,maxLat] or null
103
  */
104
- export function initAoiMap(containerId, onAoiChange) {
105
- _onAoiChange = onAoiChange;
106
- _currentStyle = 'positron';
107
-
108
- _aoiMap = new maplibregl.Map({
109
- container: containerId,
110
- style: POSITRON_STYLE,
111
- center: [37.0, 3.0], // East Africa default
112
- zoom: 4,
113
- });
114
-
115
- // Add draw control
116
- _draw = new MapboxDraw({
117
- displayControlsDefault: false,
118
- controls: {},
119
- styles: drawStyles(),
120
- });
121
-
122
- _aoiMap.addControl(_draw);
123
-
124
- // Basemap layer toggle
125
- _aoiMap.addControl(new BasemapToggle(), 'top-right');
126
 
127
- // Propagate geometry changes
128
- _aoiMap.on('draw.create', _onDrawUpdate);
129
- _aoiMap.on('draw.update', _onDrawUpdate);
130
- _aoiMap.on('draw.delete', () => _onAoiChange && _onAoiChange(null));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  }
132
 
133
- function _onDrawUpdate() {
134
- const data = _draw.getAll();
135
- if (!data.features.length) {
136
- _onAoiChange && _onAoiChange(null);
137
- return;
138
- }
139
- const bbox = turf_bbox(data.features[0]);
140
- _onAoiChange && _onAoiChange(bbox);
 
 
 
 
 
 
 
 
 
141
  }
142
 
143
- // Simple bbox calculation without turf dependency
144
- function turf_bbox(feature) {
145
- const coords = getAllCoords(feature.geometry);
146
- let minLon = Infinity, minLat = Infinity, maxLon = -Infinity, maxLat = -Infinity;
147
- for (const [lon, lat] of coords) {
148
- if (lon < minLon) minLon = lon;
149
- if (lat < minLat) minLat = lat;
150
- if (lon > maxLon) maxLon = lon;
151
- if (lat > maxLat) maxLat = lat;
152
- }
153
- return [minLon, minLat, maxLon, maxLat];
154
  }
155
 
156
- function getAllCoords(geometry) {
157
- if (geometry.type === 'Point') return [geometry.coordinates];
158
- if (geometry.type === 'Polygon') return geometry.coordinates.flat();
159
- if (geometry.type === 'MultiPolygon') return geometry.coordinates.flat(2);
160
- return [];
161
  }
162
 
163
  /**
164
- * Activate two-tap rectangle mode.
165
- * First tap sets one corner, second tap sets the opposite corner.
166
- * Works on both touch (mobile) and click (desktop).
167
  */
168
- let _firstCornerMarker = null;
169
-
170
- export function activateDrawRect() {
171
- if (!_aoiMap) return;
172
- // Temporarily remove Draw so it doesn't intercept taps
173
- if (_draw) {
174
- _draw.deleteAll();
175
- _aoiMap.removeControl(_draw);
176
- }
177
- _rectFirstCorner = null;
178
- if (_firstCornerMarker) {
179
- _firstCornerMarker.remove();
180
- _firstCornerMarker = null;
181
- }
182
- _aoiMap.getCanvas().style.cursor = 'crosshair';
183
- // Remove previous listener if any, then add fresh
184
- _aoiMap.off('click', _onRectClick);
185
- _aoiMap.on('click', _onRectClick);
186
- }
187
-
188
- function _onRectClick(e) {
189
- const { lng, lat } = e.lngLat;
190
-
191
- if (!_rectFirstCorner) {
192
- // First tap β€” store corner and show marker
193
- _rectFirstCorner = [lng, lat];
194
- const el = document.createElement('div');
195
- el.style.cssText = 'width:12px;height:12px;border-radius:50%;background:#1A3A34;border:2px solid white;';
196
- _firstCornerMarker = new maplibregl.Marker({ element: el })
197
- .setLngLat([lng, lat])
198
- .addTo(_aoiMap);
199
- return;
200
- }
201
 
202
- // Second tap β€” build rectangle from two corners
203
- const [lon1, lat1] = _rectFirstCorner;
204
- const lon2 = lng;
205
- const lat2 = lat;
206
- _rectFirstCorner = null;
207
- _aoiMap.getCanvas().style.cursor = '';
208
- _aoiMap.off('click', _onRectClick);
209
-
210
- // Remove corner marker
211
- if (_firstCornerMarker) {
212
- _firstCornerMarker.remove();
213
- _firstCornerMarker = null;
214
- }
215
 
216
- const minLon = Math.min(lon1, lon2);
217
- const maxLon = Math.max(lon1, lon2);
218
- const minLat = Math.min(lat1, lat2);
219
- const maxLat = Math.max(lat1, lat2);
220
-
221
- const feature = {
222
- type: 'Feature',
223
- geometry: {
224
- type: 'Polygon',
225
- coordinates: [[
226
- [minLon, minLat],
227
- [maxLon, minLat],
228
- [maxLon, maxLat],
229
- [minLon, maxLat],
230
- [minLon, minLat],
231
- ]],
232
- },
233
- properties: {},
234
- };
235
 
236
- if (_draw) {
237
- // Re-add Draw control, insert the rectangle, and trigger update
238
- _aoiMap.addControl(_draw);
239
- _draw.add(feature);
240
- _onDrawUpdate();
241
- }
242
- }
243
 
244
- /**
245
- * Activate polygon draw mode.
246
- */
247
- export function activateDrawPolygon() {
248
- if (!_draw) return;
249
- _draw.deleteAll();
250
- _draw.changeMode('draw_polygon');
251
  }
252
 
253
  /**
254
- * Load a GeoJSON feature (polygon) as the AOI.
255
- * @param {object} geojson - GeoJSON FeatureCollection or Feature
256
  */
257
- export function loadGeoJSON(geojson) {
258
- if (!_draw) return;
259
- _draw.deleteAll();
260
-
261
- const feature = geojson.type === 'FeatureCollection'
262
- ? geojson.features[0]
263
- : geojson;
264
-
265
- if (!feature) return;
266
-
267
- const id = _draw.add(feature);
268
- if (id && id.length) {
269
- _onDrawUpdate();
270
  }
271
-
272
- // Fly to bounds
273
- const bbox = turf_bbox(feature);
274
- _aoiMap.fitBounds(
275
- [[bbox[0], bbox[1]], [bbox[2], bbox[3]]],
276
- { padding: 40, duration: 600 }
277
- );
278
  }
279
 
280
  /**
281
- * Search for a location via Nominatim and fly the map there.
282
  * @param {string} query
283
  */
284
  export async function geocode(query) {
@@ -286,14 +221,17 @@ export async function geocode(query) {
286
  const res = await fetch(url, { headers: { 'Accept-Language': 'en' } });
287
  const results = await res.json();
288
  if (!results.length) throw new Error('Location not found');
289
- const { lon, lat, boundingbox } = results[0];
290
- if (boundingbox) {
291
- // boundingbox: [minLat, maxLat, minLon, maxLon]
292
- const [minLat, maxLat, minLon, maxLon] = boundingbox.map(Number);
293
- _aoiMap.fitBounds([[minLon, minLat], [maxLon, maxLat]], { padding: 60, duration: 800 });
294
- } else {
295
- _aoiMap.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 10 });
296
- }
 
 
 
297
  }
298
 
299
  /* ── Results Map ─────────────────────────────────────────── */
@@ -316,48 +254,28 @@ export function initResultsMap(containerId, bbox) {
316
  _resultsMap.on('load', () => {
317
  _resultsMap.addSource('aoi', {
318
  type: 'geojson',
319
- data: bboxToPolygon(bbox),
320
  });
321
 
322
  _resultsMap.addLayer({
323
  id: 'aoi-fill',
324
  type: 'fill',
325
  source: 'aoi',
326
- paint: {
327
- 'fill-color': '#1A3A34',
328
- 'fill-opacity': 0.08,
329
- },
330
  });
331
 
332
  _resultsMap.addLayer({
333
  id: 'aoi-outline',
334
  type: 'line',
335
  source: 'aoi',
336
- paint: {
337
- 'line-color': '#1A3A34',
338
- 'line-width': 2,
339
- 'line-opacity': 0.7,
340
- },
341
  });
342
  });
343
  }
344
 
345
- const STATUS_COLORS = {
346
- green: '#3BAA7F',
347
- amber: '#CA5D0F',
348
- red: '#B83A2A',
349
- };
350
 
351
- const CONFIDENCE_COLORS = {
352
- high: '#B83A2A',
353
- nominal: '#CA5D0F',
354
- low: '#E8C547',
355
- };
356
-
357
- /**
358
- * Render spatial data on the results map based on type.
359
- * @param {object} spatialData - Parsed JSON from /spatial/ endpoint
360
- */
361
  export function renderSpatialOverlay(spatialData) {
362
  clearSpatialOverlay();
363
  if (!_resultsMap) return;
@@ -376,16 +294,12 @@ export function renderSpatialOverlay(spatialData) {
376
  }
377
  }
378
 
379
- /**
380
- * Remove any spatial overlay from the results map.
381
- */
382
  export function clearSpatialOverlay() {
383
  if (!_resultsMap) return;
384
  for (const id of ['spatial-points', 'spatial-choropleth', 'spatial-grid']) {
385
  if (_resultsMap.getLayer(id)) _resultsMap.removeLayer(id);
386
  if (_resultsMap.getSource(id)) _resultsMap.removeSource(id);
387
  }
388
- // Reset AOI fill to default
389
  if (_resultsMap.getLayer('aoi-fill')) {
390
  _resultsMap.setPaintProperty('aoi-fill', 'fill-color', '#1A3A34');
391
  _resultsMap.setPaintProperty('aoi-fill', 'fill-opacity', 0.08);
@@ -419,12 +333,10 @@ function _renderPoints(geojson) {
419
 
420
  function _renderChoropleth(geojson, colormap) {
421
  _resultsMap.addSource('spatial-choropleth', { type: 'geojson', data: geojson });
422
-
423
  const values = geojson.features.map(f => f.properties.value || 0);
424
  const vmin = Math.min(...values);
425
  const vmax = Math.max(...values);
426
  const mid = (vmin + vmax) / 2;
427
-
428
  _resultsMap.addLayer({
429
  id: 'spatial-choropleth',
430
  type: 'fill',
@@ -465,14 +377,11 @@ function _renderGrid(spatialData) {
465
  }
466
  }
467
  const geojson = { type: 'FeatureCollection', features };
468
-
469
  const values = features.map(f => f.properties.value);
470
  const vmin = Math.min(...values);
471
  const vmax = Math.max(...values);
472
  const mid = (vmin + vmax) / 2;
473
-
474
  const isTemp = spatialData.colormap === 'coolwarm';
475
-
476
  _resultsMap.addSource('spatial-grid', { type: 'geojson', data: geojson });
477
  _resultsMap.addLayer({
478
  id: 'spatial-grid',
@@ -500,68 +409,3 @@ function _renderStatusOverlay(status) {
500
  _resultsMap.setPaintProperty('aoi-outline', 'line-color', color);
501
  }
502
  }
503
-
504
- function bboxToPolygon(bbox) {
505
- const [minLon, minLat, maxLon, maxLat] = bbox;
506
- return {
507
- type: 'FeatureCollection',
508
- features: [{
509
- type: 'Feature',
510
- geometry: {
511
- type: 'Polygon',
512
- coordinates: [[
513
- [minLon, minLat],
514
- [maxLon, minLat],
515
- [maxLon, maxLat],
516
- [minLon, maxLat],
517
- [minLon, minLat],
518
- ]],
519
- },
520
- properties: {},
521
- }],
522
- };
523
- }
524
-
525
- /* ── Draw Styles ─────────────────────────────────────────── */
526
-
527
- function drawStyles() {
528
- return [
529
- {
530
- id: 'gl-draw-polygon-fill',
531
- type: 'fill',
532
- filter: ['all', ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
533
- paint: {
534
- 'fill-color': '#1A3A34',
535
- 'fill-opacity': 0.10,
536
- },
537
- },
538
- {
539
- id: 'gl-draw-polygon-stroke',
540
- type: 'line',
541
- filter: ['all', ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
542
- paint: {
543
- 'line-color': '#1A3A34',
544
- 'line-width': 2,
545
- },
546
- },
547
- {
548
- id: 'gl-draw-polygon-vertex',
549
- type: 'circle',
550
- filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point']],
551
- paint: {
552
- 'circle-radius': 4,
553
- 'circle-color': '#1A3A34',
554
- },
555
- },
556
- {
557
- id: 'gl-draw-line',
558
- type: 'line',
559
- filter: ['all', ['==', '$type', 'LineString'], ['!=', 'mode', 'static']],
560
- paint: {
561
- 'line-color': '#8071BC',
562
- 'line-width': 2,
563
- 'line-dasharray': [2, 2],
564
- },
565
- },
566
- ];
567
- }
 
1
  /**
2
+ * Aperture β€” MapLibre GL map tools
3
+ * AOI click-to-place on the Define Area map + results map rendering.
4
  */
5
 
6
  const POSITRON_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
 
58
  const isSatellite = _currentStyle === 'satellite';
59
  const newStyle = isSatellite ? POSITRON_STYLE : SATELLITE_STYLE;
60
 
 
 
61
  _aoiMap.setStyle(newStyle);
62
 
63
+ // Re-add AOI layer after style change
64
+ _aoiMap.once('style.load', () => {
65
+ _addAoiSource();
66
+ if (_currentBbox) _updateAoiLayer(_currentBbox);
67
+ });
68
+
69
  _currentStyle = isSatellite ? 'positron' : 'satellite';
70
  const nextLabel = _currentStyle === 'positron' ? 'Switch to Satellite' : 'Switch to Map';
71
  this._btn.title = nextLabel;
 
95
  /* ── AOI Map ─────────────────────────────────────────────── */
96
 
97
  let _aoiMap = null;
98
+ let _onAoiChange = null; // callback(bbox | null)
99
+ let _currentBbox = null; // [minLon, minLat, maxLon, maxLat]
100
+ let _currentCenter = null; // [lng, lat] β€” last click/geocode center
101
+ let _selectedKm2 = 500; // active preset size
102
+
103
+ const AOI_SOURCE_ID = 'aoi-draw';
104
+ const AOI_FILL_LAYER = 'aoi-draw-fill';
105
+ const AOI_OUTLINE_LAYER = 'aoi-draw-outline';
106
 
107
  /**
108
+ * Compute a square bbox centered on [lng, lat] for targetKm2.
 
 
109
  */
110
+ function computeBbox(centerLng, centerLat, targetKm2) {
111
+ const sideKm = Math.sqrt(targetKm2);
112
+ const dLat = sideKm / 111.32;
113
+ const dLon = sideKm / (111.32 * Math.cos(centerLat * Math.PI / 180));
114
+ return [
115
+ centerLng - dLon / 2,
116
+ centerLat - dLat / 2,
117
+ centerLng + dLon / 2,
118
+ centerLat + dLat / 2,
119
+ ];
120
+ }
 
 
 
 
 
 
 
 
 
 
 
121
 
122
+ function _bboxToGeoJSON(bbox) {
123
+ const [minLon, minLat, maxLon, maxLat] = bbox;
124
+ return {
125
+ type: 'FeatureCollection',
126
+ features: [{
127
+ type: 'Feature',
128
+ geometry: {
129
+ type: 'Polygon',
130
+ coordinates: [[
131
+ [minLon, minLat],
132
+ [maxLon, minLat],
133
+ [maxLon, maxLat],
134
+ [minLon, maxLat],
135
+ [minLon, minLat],
136
+ ]],
137
+ },
138
+ properties: {},
139
+ }],
140
+ };
141
  }
142
 
143
+ const _emptyGeoJSON = { type: 'FeatureCollection', features: [] };
144
+
145
+ function _addAoiSource() {
146
+ if (_aoiMap.getSource(AOI_SOURCE_ID)) return;
147
+ _aoiMap.addSource(AOI_SOURCE_ID, { type: 'geojson', data: _emptyGeoJSON });
148
+ _aoiMap.addLayer({
149
+ id: AOI_FILL_LAYER,
150
+ type: 'fill',
151
+ source: AOI_SOURCE_ID,
152
+ paint: { 'fill-color': '#1A3A34', 'fill-opacity': 0.10 },
153
+ });
154
+ _aoiMap.addLayer({
155
+ id: AOI_OUTLINE_LAYER,
156
+ type: 'line',
157
+ source: AOI_SOURCE_ID,
158
+ paint: { 'line-color': '#1A3A34', 'line-width': 2 },
159
+ });
160
  }
161
 
162
+ function _updateAoiLayer(bbox) {
163
+ const source = _aoiMap.getSource(AOI_SOURCE_ID);
164
+ if (source) source.setData(_bboxToGeoJSON(bbox));
 
 
 
 
 
 
 
 
165
  }
166
 
167
+ function _placeAoi(lng, lat) {
168
+ _currentCenter = [lng, lat];
169
+ _currentBbox = computeBbox(lng, lat, _selectedKm2);
170
+ _updateAoiLayer(_currentBbox);
171
+ if (_onAoiChange) _onAoiChange(_currentBbox);
172
  }
173
 
174
  /**
175
+ * Initialise the AOI map inside containerId.
176
+ * @param {string} containerId
177
+ * @param {function} onAoiChange - called with [minLon,minLat,maxLon,maxLat] or null
178
  */
179
+ export function initAoiMap(containerId, onAoiChange) {
180
+ _onAoiChange = onAoiChange;
181
+ _currentStyle = 'positron';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
+ _aoiMap = new maplibregl.Map({
184
+ container: containerId,
185
+ style: POSITRON_STYLE,
186
+ center: [37.0, 3.0],
187
+ zoom: 4,
188
+ });
 
 
 
 
 
 
 
189
 
190
+ _aoiMap.addControl(new BasemapToggle(), 'top-right');
191
+ _aoiMap.getCanvas().style.cursor = 'crosshair';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
+ _aoiMap.on('load', () => {
194
+ _addAoiSource();
195
+ });
 
 
 
 
196
 
197
+ _aoiMap.on('click', (e) => {
198
+ _placeAoi(e.lngLat.lng, e.lngLat.lat);
199
+ });
 
 
 
 
200
  }
201
 
202
  /**
203
+ * Change the AOI preset size. If an AOI is already placed, resize it.
204
+ * @param {number} km2 - target area in kmΒ²
205
  */
206
+ export function setAoiSize(km2) {
207
+ _selectedKm2 = km2;
208
+ if (_currentCenter) {
209
+ _currentBbox = computeBbox(_currentCenter[0], _currentCenter[1], km2);
210
+ _updateAoiLayer(_currentBbox);
211
+ if (_onAoiChange) _onAoiChange(_currentBbox);
 
 
 
 
 
 
 
212
  }
 
 
 
 
 
 
 
213
  }
214
 
215
  /**
216
+ * Search for a location via Nominatim, fly there, and auto-place the AOI.
217
  * @param {string} query
218
  */
219
  export async function geocode(query) {
 
221
  const res = await fetch(url, { headers: { 'Accept-Language': 'en' } });
222
  const results = await res.json();
223
  if (!results.length) throw new Error('Location not found');
224
+ const { lon, lat } = results[0];
225
+ const lng = parseFloat(lon);
226
+ const latNum = parseFloat(lat);
227
+
228
+ _placeAoi(lng, latNum);
229
+
230
+ // Fly to fit the placed bbox
231
+ _aoiMap.fitBounds(
232
+ [[_currentBbox[0], _currentBbox[1]], [_currentBbox[2], _currentBbox[3]]],
233
+ { padding: 60, duration: 800 }
234
+ );
235
  }
236
 
237
  /* ── Results Map ─────────────────────────────────────────── */
 
254
  _resultsMap.on('load', () => {
255
  _resultsMap.addSource('aoi', {
256
  type: 'geojson',
257
+ data: _bboxToGeoJSON(bbox),
258
  });
259
 
260
  _resultsMap.addLayer({
261
  id: 'aoi-fill',
262
  type: 'fill',
263
  source: 'aoi',
264
+ paint: { 'fill-color': '#1A3A34', 'fill-opacity': 0.08 },
 
 
 
265
  });
266
 
267
  _resultsMap.addLayer({
268
  id: 'aoi-outline',
269
  type: 'line',
270
  source: 'aoi',
271
+ paint: { 'line-color': '#1A3A34', 'line-width': 2, 'line-opacity': 0.7 },
 
 
 
 
272
  });
273
  });
274
  }
275
 
276
+ const STATUS_COLORS = { green: '#3BAA7F', amber: '#CA5D0F', red: '#B83A2A' };
277
+ const CONFIDENCE_COLORS = { high: '#B83A2A', nominal: '#CA5D0F', low: '#E8C547' };
 
 
 
278
 
 
 
 
 
 
 
 
 
 
 
279
  export function renderSpatialOverlay(spatialData) {
280
  clearSpatialOverlay();
281
  if (!_resultsMap) return;
 
294
  }
295
  }
296
 
 
 
 
297
  export function clearSpatialOverlay() {
298
  if (!_resultsMap) return;
299
  for (const id of ['spatial-points', 'spatial-choropleth', 'spatial-grid']) {
300
  if (_resultsMap.getLayer(id)) _resultsMap.removeLayer(id);
301
  if (_resultsMap.getSource(id)) _resultsMap.removeSource(id);
302
  }
 
303
  if (_resultsMap.getLayer('aoi-fill')) {
304
  _resultsMap.setPaintProperty('aoi-fill', 'fill-color', '#1A3A34');
305
  _resultsMap.setPaintProperty('aoi-fill', 'fill-opacity', 0.08);
 
333
 
334
  function _renderChoropleth(geojson, colormap) {
335
  _resultsMap.addSource('spatial-choropleth', { type: 'geojson', data: geojson });
 
336
  const values = geojson.features.map(f => f.properties.value || 0);
337
  const vmin = Math.min(...values);
338
  const vmax = Math.max(...values);
339
  const mid = (vmin + vmax) / 2;
 
340
  _resultsMap.addLayer({
341
  id: 'spatial-choropleth',
342
  type: 'fill',
 
377
  }
378
  }
379
  const geojson = { type: 'FeatureCollection', features };
 
380
  const values = features.map(f => f.properties.value);
381
  const vmin = Math.min(...values);
382
  const vmax = Math.max(...values);
383
  const mid = (vmin + vmax) / 2;
 
384
  const isTemp = spatialData.colormap === 'coolwarm';
 
385
  _resultsMap.addSource('spatial-grid', { type: 'geojson', data: geojson });
386
  _resultsMap.addLayer({
387
  id: 'spatial-grid',
 
409
  _resultsMap.setPaintProperty('aoi-outline', 'line-color', color);
410
  }
411
  }