Download frontend/js/map.js from MERLx/Aperture: direct link, hf CLI and curl.
- Browser
- Download file 17.1 kB
-
https://huggingface.co/spaces/MERLx/Aperture/resolve/main/frontend/js/map.js
- Command line
-
hf download hf://spaces/MERLx/Aperture/frontend/js/map.js
-
curl -L -o map.js https://huggingface.co/spaces/MERLx/Aperture/resolve/main/frontend/js/map.js
17.1 kB
| /** | |
| * Aperture β MapLibre GL map tools | |
| * AOI click-to-place on the Define Area map + results map rendering. | |
| */ | |
| const POSITRON_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'; | |
| const SATELLITE_STYLE = { | |
| version: 8, | |
| sources: { | |
| 'esri-satellite': { | |
| type: 'raster', | |
| tiles: [ | |
| 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', | |
| ], | |
| tileSize: 256, | |
| attribution: '© Esri', | |
| }, | |
| }, | |
| layers: [ | |
| { | |
| id: 'esri-satellite-layer', | |
| type: 'raster', | |
| source: 'esri-satellite', | |
| minzoom: 0, | |
| maxzoom: 19, | |
| }, | |
| ], | |
| }; | |
| /* ββ Basemap Toggle Control ββββββββββββββββββββββββββββββ */ | |
| let _currentStyle = 'positron'; | |
| class BasemapToggle { | |
| onAdd(map) { | |
| this._map = map; | |
| this._container = document.createElement('div'); | |
| this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group basemap-toggle'; | |
| this._btn = document.createElement('button'); | |
| this._btn.type = 'button'; | |
| this._btn.title = 'Switch to Satellite'; | |
| this._btn.setAttribute('aria-label', 'Switch to Satellite'); | |
| this._btn.innerHTML = BasemapToggle._satelliteIcon(); | |
| this._btn.addEventListener('click', () => this._toggle()); | |
| this._container.appendChild(this._btn); | |
| return this._container; | |
| } | |
| onRemove() { | |
| this._container.remove(); | |
| this._map = undefined; | |
| } | |
| _toggle() { | |
| const isSatellite = _currentStyle === 'satellite'; | |
| const newStyle = isSatellite ? POSITRON_STYLE : SATELLITE_STYLE; | |
| _aoiMap.setStyle(newStyle); | |
| // Re-add AOI layer + click handler after style change | |
| _aoiMap.once('style.load', () => { | |
| _addAoiSource(); | |
| if (_currentBbox) _updateAoiLayer(_currentBbox); | |
| _aoiMap.getCanvas().style.cursor = 'crosshair'; | |
| _aoiMap.on('click', (e) => { | |
| _placeAoi(e.lngLat.lng, e.lngLat.lat); | |
| }); | |
| }); | |
| _currentStyle = isSatellite ? 'positron' : 'satellite'; | |
| const nextLabel = _currentStyle === 'positron' ? 'Switch to Satellite' : 'Switch to Map'; | |
| this._btn.title = nextLabel; | |
| this._btn.setAttribute('aria-label', nextLabel); | |
| this._btn.innerHTML = _currentStyle === 'positron' | |
| ? BasemapToggle._satelliteIcon() | |
| : BasemapToggle._mapIcon(); | |
| } | |
| static _satelliteIcon() { | |
| return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="10"/> | |
| <path d="M2 12h20"/> | |
| <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"/> | |
| </svg>`; | |
| } | |
| static _mapIcon() { | |
| return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/> | |
| <line x1="8" y1="2" x2="8" y2="18"/> | |
| <line x1="16" y1="6" x2="16" y2="22"/> | |
| </svg>`; | |
| } | |
| } | |
| /* ββ AOI Map βββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| let _aoiMap = null; | |
| let _onAoiChange = null; // callback(bbox | null) | |
| let _currentBbox = null; // [minLon, minLat, maxLon, maxLat] | |
| let _currentCenter = null; // [lng, lat] β last click/geocode center | |
| let _selectedKm2 = 500; // active preset size | |
| const AOI_SOURCE_ID = 'aoi-draw'; | |
| const AOI_FILL_LAYER = 'aoi-draw-fill'; | |
| const AOI_OUTLINE_LAYER = 'aoi-draw-outline'; | |
| /** | |
| * Compute a square bbox centered on [lng, lat] for targetKm2. | |
| */ | |
| function computeBbox(centerLng, centerLat, targetKm2) { | |
| const sideKm = Math.sqrt(targetKm2); | |
| const dLat = sideKm / 111.32; | |
| const dLon = sideKm / (111.32 * Math.cos(centerLat * Math.PI / 180)); | |
| return [ | |
| centerLng - dLon / 2, | |
| centerLat - dLat / 2, | |
| centerLng + dLon / 2, | |
| centerLat + dLat / 2, | |
| ]; | |
| } | |
| function _bboxToGeoJSON(bbox) { | |
| const [minLon, minLat, maxLon, maxLat] = bbox; | |
| return { | |
| type: 'FeatureCollection', | |
| features: [{ | |
| type: 'Feature', | |
| geometry: { | |
| type: 'Polygon', | |
| coordinates: [[ | |
| [minLon, minLat], | |
| [maxLon, minLat], | |
| [maxLon, maxLat], | |
| [minLon, maxLat], | |
| [minLon, minLat], | |
| ]], | |
| }, | |
| properties: {}, | |
| }], | |
| }; | |
| } | |
| const _emptyGeoJSON = { type: 'FeatureCollection', features: [] }; | |
| function _addAoiSource() { | |
| if (_aoiMap.getSource(AOI_SOURCE_ID)) return; | |
| _aoiMap.addSource(AOI_SOURCE_ID, { type: 'geojson', data: _emptyGeoJSON }); | |
| _aoiMap.addLayer({ | |
| id: AOI_FILL_LAYER, | |
| type: 'fill', | |
| source: AOI_SOURCE_ID, | |
| paint: { 'fill-color': '#1A3A34', 'fill-opacity': 0.10 }, | |
| }); | |
| _aoiMap.addLayer({ | |
| id: AOI_OUTLINE_LAYER, | |
| type: 'line', | |
| source: AOI_SOURCE_ID, | |
| paint: { 'line-color': '#1A3A34', 'line-width': 2 }, | |
| }); | |
| } | |
| function _updateAoiLayer(bbox) { | |
| const source = _aoiMap.getSource(AOI_SOURCE_ID); | |
| if (source) source.setData(_bboxToGeoJSON(bbox)); | |
| } | |
| function _placeAoi(lng, lat) { | |
| _currentCenter = [lng, lat]; | |
| _currentBbox = computeBbox(lng, lat, _selectedKm2); | |
| _updateAoiLayer(_currentBbox); | |
| if (_onAoiChange) _onAoiChange(_currentBbox); | |
| } | |
| /** | |
| * Initialise the AOI map inside containerId. | |
| * @param {string} containerId | |
| * @param {function} onAoiChange - called with [minLon,minLat,maxLon,maxLat] or null | |
| */ | |
| export function initAoiMap(containerId, onAoiChange) { | |
| _onAoiChange = onAoiChange; | |
| _currentStyle = 'positron'; | |
| _aoiMap = new maplibregl.Map({ | |
| container: containerId, | |
| style: POSITRON_STYLE, | |
| center: [37.0, 3.0], | |
| zoom: 4, | |
| }); | |
| _aoiMap.addControl(new BasemapToggle(), 'top-right'); | |
| _aoiMap.getCanvas().style.cursor = 'crosshair'; | |
| _aoiMap.on('load', () => { | |
| _addAoiSource(); | |
| }); | |
| _aoiMap.on('click', (e) => { | |
| _placeAoi(e.lngLat.lng, e.lngLat.lat); | |
| }); | |
| } | |
| /** | |
| * Change the AOI preset size. If an AOI is already placed, resize it. | |
| * @param {number} km2 - target area in kmΒ² | |
| */ | |
| export function setAoiSize(km2) { | |
| _selectedKm2 = km2; | |
| if (_currentCenter) { | |
| _currentBbox = computeBbox(_currentCenter[0], _currentCenter[1], km2); | |
| _updateAoiLayer(_currentBbox); | |
| if (_onAoiChange) _onAoiChange(_currentBbox); | |
| } | |
| } | |
| /** | |
| * Search for a location via Nominatim, fly there, and auto-place the AOI. | |
| * @param {string} query | |
| */ | |
| export async function geocode(query) { | |
| const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1`; | |
| const res = await fetch(url, { headers: { 'Accept-Language': 'en' } }); | |
| const results = await res.json(); | |
| if (!results.length) throw new Error('Location not found'); | |
| const { lon, lat } = results[0]; | |
| const lng = parseFloat(lon); | |
| const latNum = parseFloat(lat); | |
| _placeAoi(lng, latNum); | |
| // Fly to fit the placed bbox | |
| _aoiMap.fitBounds( | |
| [[_currentBbox[0], _currentBbox[1]], [_currentBbox[2], _currentBbox[3]]], | |
| { padding: 60, duration: 800 } | |
| ); | |
| } | |
| /* ββ Results Map βββββββββββββββββββββββββββββββββββββββββββ */ | |
| let _resultsMap = null; | |
| /** | |
| * Initialise the results map inside containerId. | |
| * The AOI rectangle is intentionally NOT drawn here β the indicator | |
| * overlay itself defines the area, and the dark rectangle was clashing | |
| * with the data. A scale bar is added instead so distances are legible. | |
| * | |
| * @param {string} containerId | |
| * @param {Array<number>} bbox - [minLon, minLat, maxLon, maxLat] | |
| */ | |
| export function initResultsMap(containerId, bbox) { | |
| _resultsMap = new maplibregl.Map({ | |
| container: containerId, | |
| style: POSITRON_STYLE, | |
| bounds: [[bbox[0], bbox[1]], [bbox[2], bbox[3]]], | |
| fitBoundsOptions: { padding: 60 }, | |
| }); | |
| _resultsMap.addControl( | |
| new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), | |
| 'bottom-left', | |
| ); | |
| } | |
| const STATUS_COLORS = { green: '#3BAA7F', amber: '#CA5D0F', red: '#B83A2A' }; | |
| const CONFIDENCE_COLORS = { high: '#B83A2A', nominal: '#CA5D0F', low: '#E8C547' }; | |
| export function renderSpatialOverlay(spatialData) { | |
| clearSpatialOverlay(); | |
| if (!_resultsMap) return; | |
| const mapType = spatialData.map_type; | |
| const status = spatialData.status; | |
| if (mapType === 'points' && spatialData.geojson) { | |
| _renderPoints(spatialData.geojson); | |
| } else if (mapType === 'choropleth' && spatialData.geojson) { | |
| _renderChoropleth(spatialData.geojson, spatialData.colormap); | |
| } else if (mapType === 'grid' && spatialData.data) { | |
| _renderGrid(spatialData); | |
| } else { | |
| // Raster was not convertible to a grid (old pre-fix job, or the | |
| // backend couldn't read the raster file). Show the legend with a | |
| // plain-language explanation so clicking indicators doesn't just | |
| // blank the map with no feedback. | |
| _showLegend({ | |
| label: spatialData.label || '', | |
| productId: spatialData.product_id, | |
| stops: Array.isArray(spatialData.stops) && spatialData.stops.length | |
| ? spatialData.stops | |
| : null, | |
| vmin: spatialData.vmin, | |
| vmax: spatialData.vmax, | |
| unavailable: true, | |
| }); | |
| } | |
| } | |
| export function clearSpatialOverlay() { | |
| if (!_resultsMap) return; | |
| for (const id of ['spatial-points', 'spatial-choropleth', 'spatial-grid']) { | |
| if (_resultsMap.getLayer(id)) _resultsMap.removeLayer(id); | |
| if (_resultsMap.getSource(id)) _resultsMap.removeSource(id); | |
| } | |
| _hideLegend(); | |
| } | |
| function _renderPoints(geojson) { | |
| _resultsMap.addSource('spatial-points', { type: 'geojson', data: geojson }); | |
| _resultsMap.addLayer({ | |
| id: 'spatial-points', | |
| type: 'circle', | |
| source: 'spatial-points', | |
| paint: { | |
| 'circle-radius': 5, | |
| 'circle-color': [ | |
| 'match', ['get', 'confidence'], | |
| 'high', CONFIDENCE_COLORS.high, | |
| 'nominal', CONFIDENCE_COLORS.nominal, | |
| 'low', CONFIDENCE_COLORS.low, | |
| CONFIDENCE_COLORS.nominal, | |
| ], | |
| 'circle-stroke-width': 1, | |
| 'circle-stroke-color': '#111', | |
| 'circle-opacity': 0.85, | |
| }, | |
| }); | |
| } | |
| function _renderChoropleth(geojson, colormap) { | |
| _resultsMap.addSource('spatial-choropleth', { type: 'geojson', data: geojson }); | |
| const values = geojson.features.map(f => f.properties.value || 0); | |
| const vmin = Math.min(...values); | |
| const vmax = Math.max(...values); | |
| const mid = (vmin + vmax) / 2; | |
| _resultsMap.addLayer({ | |
| id: 'spatial-choropleth', | |
| type: 'fill', | |
| source: 'spatial-choropleth', | |
| paint: { | |
| 'fill-color': [ | |
| 'interpolate', ['linear'], ['get', 'value'], | |
| vmin, colormap === 'Blues' ? '#deebf7' : '#f7fcb9', | |
| mid, colormap === 'Blues' ? '#6baed6' : '#78c679', | |
| vmax, colormap === 'Blues' ? '#08519c' : '#006837', | |
| ], | |
| 'fill-opacity': 0.55, | |
| }, | |
| }); | |
| } | |
| function _renderGrid(spatialData) { | |
| const { data, lats, lons } = spatialData; | |
| if (!Array.isArray(data) || !Array.isArray(lats) || !Array.isArray(lons)) return; | |
| const features = []; | |
| for (let r = 0; r < lats.length - 1; r++) { | |
| const row = data[r]; | |
| if (!row) continue; | |
| for (let c = 0; c < lons.length - 1; c++) { | |
| const val = row[c]; | |
| if (val == null || !isFinite(val)) continue; | |
| features.push({ | |
| type: 'Feature', | |
| geometry: { | |
| type: 'Polygon', | |
| coordinates: [[ | |
| [lons[c], lats[r]], | |
| [lons[c + 1], lats[r]], | |
| [lons[c + 1], lats[r + 1]], | |
| [lons[c], lats[r + 1]], | |
| [lons[c], lats[r]], | |
| ]], | |
| }, | |
| properties: { value: val }, | |
| }); | |
| } | |
| } | |
| if (!features.length) return; | |
| const geojson = { type: 'FeatureCollection', features }; | |
| // Prefer backend-provided color stops; fall back to a two-colormap | |
| // heuristic for older payloads. | |
| const colorExpr = ['interpolate', ['linear'], ['get', 'value']]; | |
| if (Array.isArray(spatialData.stops) && spatialData.stops.length >= 2) { | |
| for (const stop of spatialData.stops) { | |
| colorExpr.push(Number(stop.value), String(stop.color)); | |
| } | |
| } else { | |
| const values = features.map(f => f.properties.value); | |
| const vmin = Number.isFinite(spatialData.vmin) ? spatialData.vmin : Math.min(...values); | |
| const vmax = Number.isFinite(spatialData.vmax) ? spatialData.vmax : Math.max(...values); | |
| const mid = (vmin + vmax) / 2; | |
| const isTemp = spatialData.colormap === 'coolwarm'; | |
| colorExpr.push( | |
| vmin, isTemp ? '#3b4cc0' : '#deebf7', | |
| mid, isTemp ? '#f7f7f7' : '#6baed6', | |
| vmax, isTemp ? '#b40426' : '#08519c', | |
| ); | |
| } | |
| try { | |
| _resultsMap.addSource('spatial-grid', { type: 'geojson', data: geojson }); | |
| _resultsMap.addLayer({ | |
| id: 'spatial-grid', | |
| type: 'fill', | |
| source: 'spatial-grid', | |
| paint: { | |
| 'fill-color': colorExpr, | |
| 'fill-opacity': 0.55, | |
| 'fill-outline-color': 'rgba(0,0,0,0)', | |
| }, | |
| }); | |
| } catch (err) { | |
| console.error('spatial-grid render failed', err); | |
| return; | |
| } | |
| // Fit the map to the grid extent so the overlay is always visible. | |
| const bounds = [ | |
| [Math.min(...lons), Math.min(...lats)], | |
| [Math.max(...lons), Math.max(...lats)], | |
| ]; | |
| _resultsMap.fitBounds(bounds, { padding: 40, duration: 400, maxZoom: 13 }); | |
| // Publish legend info so the overlay panel can update. | |
| _showLegend({ | |
| label: spatialData.label || '', | |
| productId: spatialData.product_id, | |
| stops: Array.isArray(spatialData.stops) && spatialData.stops.length | |
| ? spatialData.stops | |
| : null, | |
| vmin: spatialData.vmin, | |
| vmax: spatialData.vmax, | |
| unit: spatialData.unit || '', | |
| }); | |
| } | |
| function _renderStatusOverlay(_status) { | |
| // The AOI rectangle was removed from the results map. When an | |
| // indicator has no spatial data there is simply nothing to overlay, | |
| // which is the honest outcome for that case. | |
| _hideLegend(); | |
| } | |
| /* ββ Legend control ββββββββββββββββββββββββββββββββββββββββ */ | |
| let _legendEl = null; | |
| function _ensureLegend() { | |
| if (_legendEl) return _legendEl; | |
| const container = document.getElementById('results-map'); | |
| if (!container) return null; | |
| _legendEl = document.createElement('div'); | |
| _legendEl.className = 'map-legend'; | |
| _legendEl.style.display = 'none'; | |
| container.appendChild(_legendEl); | |
| return _legendEl; | |
| } | |
| const COLOR_HINTS = { | |
| ndvi: 'Darker green = healthier vegetation than the seasonal norm. Pink = less vegetation (stressed, cleared, or burnt).', | |
| water: 'Darker blue = more water present than normal. Pale = less water than normal.', | |
| sar: 'Blue = ground surface darker (wetter, smoother). Red = brighter (drier, rougher, new structures).', | |
| buildup: 'Magenta = likely built-up. Green = vegetated / non-built-up. The classification uses a persistence filter.', | |
| }; | |
| function _showLegend({ label, productId, stops, vmin, vmax, unavailable }) { | |
| const el = _ensureLegend(); | |
| if (!el) return; | |
| const hint = COLOR_HINTS[productId] || ''; | |
| const productName = PRODUCT_NAMES[productId] || label || 'Indicator'; | |
| if (unavailable) { | |
| el.innerHTML = ` | |
| <div class="map-legend-title">${_escHtml(productName)}</div> | |
| <div class="map-legend-unavailable"> | |
| Map overlay not available for this indicator in this job. | |
| Re-run the analysis to see the pixel-level layer. | |
| </div> | |
| ${hint ? `<div class="map-legend-hint">${_escHtml(hint)}</div>` : ''} | |
| `; | |
| el.style.display = 'block'; | |
| return; | |
| } | |
| let gradientCss = ''; | |
| if (Array.isArray(stops) && stops.length >= 2) { | |
| const parts = stops.map((s, i) => { | |
| const pct = (i / (stops.length - 1)) * 100; | |
| return `${s.color} ${pct.toFixed(1)}%`; | |
| }); | |
| gradientCss = `linear-gradient(to right, ${parts.join(', ')})`; | |
| } else { | |
| gradientCss = 'linear-gradient(to right, #e6e6e6, #6e6e6e)'; | |
| } | |
| const lo = Number.isFinite(vmin) ? _fmtNum(vmin) : ''; | |
| const hi = Number.isFinite(vmax) ? _fmtNum(vmax) : ''; | |
| el.innerHTML = ` | |
| <div class="map-legend-title">${_escHtml(productName)}</div> | |
| <div class="map-legend-bar" style="background: ${gradientCss}"></div> | |
| <div class="map-legend-scale"> | |
| <span>${_escHtml(lo)}</span> | |
| <span>${_escHtml(hi)}</span> | |
| </div> | |
| ${hint ? `<div class="map-legend-hint">${_escHtml(hint)}</div>` : ''} | |
| `; | |
| el.style.display = 'block'; | |
| } | |
| const PRODUCT_NAMES = { | |
| ndvi: 'Vegetation health', | |
| water: 'Water bodies', | |
| sar: 'Ground surface change', | |
| buildup: 'Built-up areas', | |
| }; | |
| function _hideLegend() { | |
| if (_legendEl) _legendEl.style.display = 'none'; | |
| } | |
| function _fmtNum(v) { | |
| const n = Number(v); | |
| if (!isFinite(n)) return ''; | |
| if (Math.abs(n) >= 1000) return n.toFixed(0); | |
| if (Math.abs(n) >= 10) return n.toFixed(1); | |
| return n.toFixed(2); | |
| } | |
| function _escHtml(str) { | |
| return String(str == null ? '' : str) | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"'); | |
| } | |