KSvend commited on
Commit Β·
59bad34
1
Parent(s): c8318e6
feat: rewrite map.js with click-to-place AOI (no MapboxDraw)
Browse files- frontend/js/map.js +117 -273
frontend/js/map.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
/**
|
| 2 |
-
* Aperture β MapLibre GL
|
| 3 |
-
*
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 96 |
-
let
|
| 97 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
/**
|
| 100 |
-
*
|
| 101 |
-
* @param {string} containerId - DOM id for the map container
|
| 102 |
-
* @param {function} onAoiChange - called with [minLon,minLat,maxLon,maxLat] or null
|
| 103 |
*/
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 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 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
}
|
| 132 |
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
}
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 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
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
}
|
| 162 |
|
| 163 |
/**
|
| 164 |
-
*
|
| 165 |
-
*
|
| 166 |
-
*
|
| 167 |
*/
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 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 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
_aoiMap.off('click', _onRectClick);
|
| 209 |
-
|
| 210 |
-
// Remove corner marker
|
| 211 |
-
if (_firstCornerMarker) {
|
| 212 |
-
_firstCornerMarker.remove();
|
| 213 |
-
_firstCornerMarker = null;
|
| 214 |
-
}
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 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 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
_draw.add(feature);
|
| 240 |
-
_onDrawUpdate();
|
| 241 |
-
}
|
| 242 |
-
}
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
export function activateDrawPolygon() {
|
| 248 |
-
if (!_draw) return;
|
| 249 |
-
_draw.deleteAll();
|
| 250 |
-
_draw.changeMode('draw_polygon');
|
| 251 |
}
|
| 252 |
|
| 253 |
/**
|
| 254 |
-
*
|
| 255 |
-
* @param {
|
| 256 |
*/
|
| 257 |
-
export function
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 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
|
| 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
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 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:
|
| 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 |
-
|
| 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 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|