Spaces:
Running
Running
| /** Leaflet map overlay: XYZ basemaps + georeferenced TIF layers (QGIS-style). */ | |
| const DDA_BASEMAPS = [ | |
| { | |
| id: 'osm', | |
| label: 'OpenStreetMap', | |
| url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', | |
| attribution: '© OpenStreetMap contributors', | |
| maxZoom: 19, | |
| }, | |
| { | |
| id: 'google-satellite', | |
| label: 'Google Satellite', | |
| url: 'https://mt{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', | |
| attribution: 'Imagery © Google', | |
| maxZoom: 21, | |
| subdomains: '0123', | |
| }, | |
| { | |
| id: 'google-hybrid', | |
| label: 'Google Hybrid', | |
| url: 'https://mt{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', | |
| attribution: 'Imagery © Google', | |
| maxZoom: 21, | |
| subdomains: '0123', | |
| }, | |
| { | |
| id: 'esri-imagery', | |
| label: 'Esri World Imagery', | |
| url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', | |
| attribution: 'Tiles © Esri', | |
| maxZoom: 19, | |
| }, | |
| { | |
| id: 'custom-xyz', | |
| label: 'Custom XYZ…', | |
| url: '', | |
| attribution: '', | |
| maxZoom: 22, | |
| }, | |
| ]; | |
| const DDA_MAP_LS_BASE = 'ddaMapBasemap'; | |
| const DDA_MAP_LS_XYZ = 'ddaMapCustomXyz'; | |
| const DDA_MAP_LS_OPACITY = 'ddaMapOverlayOpacity'; | |
| function ddaLeafletReady() { | |
| return typeof L !== 'undefined'; | |
| } | |
| function ddaBoundsToLatLng(bounds) { | |
| if (!bounds) return null; | |
| if (Array.isArray(bounds.latLng) && bounds.latLng.length === 2) return bounds.latLng; | |
| if (bounds.west != null) { | |
| return [[bounds.south, bounds.west], [bounds.north, bounds.east]]; | |
| } | |
| if (Array.isArray(bounds) && bounds.length === 4) { | |
| const [west, south, east, north] = bounds; | |
| return [[south, west], [north, east]]; | |
| } | |
| return null; | |
| } | |
| function DdaMapViewer(containerId, options) { | |
| this.containerId = containerId; | |
| this.options = options || {}; | |
| this.map = null; | |
| this.basemapLayer = null; | |
| this.rasterLayers = {}; | |
| this.layerEnabled = {}; | |
| this.rasterParts = {}; | |
| this.geojsonLayer = null; | |
| this.overlayOn = true; | |
| this.opacity = Number(localStorage.getItem(DDA_MAP_LS_OPACITY) || 0.85); | |
| if (Number.isNaN(this.opacity)) this.opacity = 0.75; | |
| this.basemapId = localStorage.getItem(DDA_MAP_LS_BASE) || 'google-satellite'; | |
| this.customXyz = localStorage.getItem(DDA_MAP_LS_XYZ) || ''; | |
| this.lastFitBounds = null; | |
| } | |
| DdaMapViewer.prototype.ensureMap = function ensureMap() { | |
| if (this.map) return this.map; | |
| if (!ddaLeafletReady()) { | |
| console.warn('Leaflet failed to load'); | |
| return null; | |
| } | |
| const el = document.getElementById(this.containerId); | |
| if (!el) return null; | |
| this.map = L.map(el, { | |
| zoomControl: true, | |
| attributionControl: true, | |
| maxZoom: 22, | |
| }); | |
| if (!this.map.getPane('ddaRaster')) { | |
| const pane = this.map.createPane('ddaRaster'); | |
| pane.style.zIndex = 450; | |
| pane.style.pointerEvents = 'none'; | |
| } | |
| this.map.setView([28.6139, 77.209], 12); | |
| this.map.on('zoomend', () => this._syncRasterVisibility()); | |
| this.applyBasemap(this.basemapId, this.customXyz); | |
| return this.map; | |
| }; | |
| DdaMapViewer.prototype.applyBasemap = function applyBasemap(id, customUrl) { | |
| const map = this.ensureMap(); | |
| if (!map) return; | |
| const preset = DDA_BASEMAPS.find((b) => b.id === id) || DDA_BASEMAPS[0]; | |
| this.basemapId = preset.id; | |
| if (preset.id === 'custom-xyz') { | |
| this.customXyz = (customUrl || this.customXyz || '').trim(); | |
| localStorage.setItem(DDA_MAP_LS_XYZ, this.customXyz); | |
| } | |
| localStorage.setItem(DDA_MAP_LS_BASE, this.basemapId); | |
| if (this.basemapLayer) { | |
| map.removeLayer(this.basemapLayer); | |
| this.basemapLayer = null; | |
| } | |
| let url = preset.url; | |
| if (preset.id === 'custom-xyz') url = this.customXyz; | |
| if (!url) return; | |
| const opts = { | |
| maxZoom: preset.maxZoom || 22, | |
| attribution: preset.attribution || '', | |
| updateWhenIdle: true, | |
| }; | |
| if (preset.subdomains) opts.subdomains = preset.subdomains.split(''); | |
| this.basemapLayer = L.tileLayer(url, opts); | |
| this.basemapLayer.addTo(map); | |
| }; | |
| DdaMapViewer.prototype.setLayerVisible = function setLayerVisible(key, on) { | |
| this.layerEnabled[key] = !!on; | |
| this._syncRasterVisibility(); | |
| }; | |
| DdaMapViewer.prototype.setOverlayOn = function setOverlayOn(on) { | |
| this.overlayOn = !!on; | |
| this._syncRasterVisibility(); | |
| }; | |
| DdaMapViewer.prototype.setGeoJsonVisible = function setGeoJsonVisible(on) { | |
| const map = this.map; | |
| if (!map || !this.geojsonLayer) return; | |
| if (on) { | |
| if (!map.hasLayer(this.geojsonLayer)) this.geojsonLayer.addTo(map); | |
| } else if (map.hasLayer(this.geojsonLayer)) { | |
| map.removeLayer(this.geojsonLayer); | |
| } | |
| }; | |
| DdaMapViewer.prototype.setOpacity = function setOpacity(value) { | |
| this.opacity = Math.max(0, Math.min(1, Number(value))); | |
| localStorage.setItem(DDA_MAP_LS_OPACITY, String(this.opacity)); | |
| const apply = (layer) => { | |
| if (!layer) return; | |
| if (typeof layer.setOpacity === 'function') layer.setOpacity(this.opacity); | |
| if (typeof layer.eachLayer === 'function') layer.eachLayer(apply); | |
| }; | |
| Object.values(this.rasterLayers).forEach(apply); | |
| }; | |
| DdaMapViewer.prototype.clearRasters = function clearRasters() { | |
| const map = this.map; | |
| Object.values(this.rasterParts || {}).forEach((parts) => { | |
| if (!parts || !map) return; | |
| if (parts.overview && map.hasLayer(parts.overview)) map.removeLayer(parts.overview); | |
| if (parts.tiles && map.hasLayer(parts.tiles)) map.removeLayer(parts.tiles); | |
| }); | |
| this.rasterLayers = {}; | |
| this.layerEnabled = {}; | |
| this.rasterParts = {}; | |
| }; | |
| DdaMapViewer.prototype.clearGeoJson = function clearGeoJson() { | |
| if (this.map && this.geojsonLayer) { | |
| this.map.removeLayer(this.geojsonLayer); | |
| } | |
| this.geojsonLayer = null; | |
| }; | |
| DdaMapViewer.prototype._syncRasterVisibility = function _syncRasterVisibility() { | |
| const map = this.map; | |
| if (!map) return; | |
| const zoom = map.getZoom(); | |
| Object.values(this.rasterParts || {}).forEach((parts) => { | |
| if (!parts) return; | |
| const parentOn = this.overlayOn && this.layerEnabled[parts.key] !== false; | |
| // Keep the mercator overview on at every zoom. Hiding it at z>=16 | |
| // (and relying on XYZ tiles) made the overlay vanish: Leaflet often | |
| // never requested interior tiles, or those tiles sat under Google. | |
| if (parts.overview) { | |
| if (parentOn && !map.hasLayer(parts.overview)) parts.overview.addTo(map); | |
| if (!parentOn && map.hasLayer(parts.overview)) map.removeLayer(parts.overview); | |
| } | |
| if (parts.tiles) { | |
| const showTiles = parentOn && (zoom >= 16 || !parts.overview || parts.overviewFailed); | |
| if (showTiles && !map.hasLayer(parts.tiles)) parts.tiles.addTo(map); | |
| if (!showTiles && map.hasLayer(parts.tiles)) map.removeLayer(parts.tiles); | |
| } | |
| if (parentOn && parts.overview && map.hasLayer(parts.overview)) { | |
| try { parts.overview.bringToFront(); } catch (_) { /* ignore */ } | |
| } | |
| if (parentOn && parts.tiles && map.hasLayer(parts.tiles)) { | |
| try { parts.tiles.bringToFront(); } catch (_) { /* ignore */ } | |
| } | |
| }); | |
| }; | |
| DdaMapViewer.prototype._addRasterFromInfo = function _addRasterFromInfo(key, info) { | |
| const map = this.ensureMap(); | |
| if (!map || !info || !info.hasGeoref) return null; | |
| const latLng = ddaBoundsToLatLng(info.bounds); | |
| if (!latLng) return null; | |
| const llBounds = L.latLngBounds(latLng); | |
| const group = L.layerGroup(); | |
| const parts = { key, overview: null, tiles: null, overviewFailed: false }; | |
| if (info.canTile && info.overviewUrl) { | |
| // Already warped to Web Mercator — same grid as Google/OSM tiles. | |
| parts.overview = L.imageOverlay(info.overviewUrl, llBounds, { | |
| opacity: this.opacity, | |
| pane: 'ddaRaster', | |
| interactive: false, | |
| className: 'dda-raster-overview', | |
| }); | |
| parts.overview.on('load', () => { | |
| try { parts.overview._reset(); } catch (_) { /* ignore */ } | |
| }); | |
| parts.overview.on('error', () => { | |
| parts.overviewFailed = true; | |
| this._syncRasterVisibility(); | |
| }); | |
| group.addLayer(parts.overview); | |
| } else if (!info.canTile && info.previewUrl) { | |
| parts.overview = L.imageOverlay(info.previewUrl, llBounds, { | |
| opacity: this.opacity, | |
| pane: 'ddaRaster', | |
| interactive: false, | |
| }); | |
| parts.overview.on('load', () => { | |
| try { parts.overview._reset(); } catch (_) { /* ignore */ } | |
| }); | |
| group.addLayer(parts.overview); | |
| } | |
| if (info.canTile && info.tileUrl) { | |
| // Dedicated pane above Google — same EPSG:3857 grid, not the same DOM pane | |
| // (sharing tilePane let opaque satellite tiles cover empty/late overlay tiles). | |
| parts.tiles = L.tileLayer(info.tileUrl, { | |
| opacity: this.opacity, | |
| pane: 'ddaRaster', | |
| maxZoom: 22, | |
| maxNativeZoom: info.maxNativeZoom || 20, | |
| tms: false, | |
| keepBuffer: 6, | |
| updateWhenIdle: false, | |
| updateWhenZooming: true, | |
| detectRetina: false, | |
| className: 'dda-raster-tiles', | |
| }); | |
| group.addLayer(parts.tiles); | |
| } | |
| if (!group.getLayers().length) return null; | |
| this.rasterLayers[key] = group; | |
| this.rasterParts[key] = parts; | |
| if (this.layerEnabled[key] === undefined) this.layerEnabled[key] = true; | |
| this._syncRasterVisibility(); | |
| return latLng; | |
| }; | |
| DdaMapViewer.prototype.loadRaster = async function loadRaster(path) { | |
| this.clearRasters(); | |
| this.clearGeoJson(); | |
| if (!path) return { ok: false, reason: 'no-path' }; | |
| const res = await fetch(`/api/dda/local/map-info?path=${encodeURIComponent(path)}`, { credentials: 'include' }); | |
| if (!res.ok) { | |
| const detail = await res.text(); | |
| throw new Error(detail || res.statusText); | |
| } | |
| const info = await res.json(); | |
| if (!info.hasGeoref) return { ok: false, reason: 'no-georef', info }; | |
| const latLng = this._addRasterFromInfo('tif', info); | |
| this.lastFitBounds = latLng; | |
| this.fit(latLng); | |
| this.invalidate(); | |
| return { ok: true, info }; | |
| }; | |
| DdaMapViewer.prototype.loadRasters = async function loadRasters(entries) { | |
| this.clearRasters(); | |
| const allBounds = []; | |
| for (const entry of entries || []) { | |
| if (!entry?.path) continue; | |
| try { | |
| const res = await fetch(`/api/dda/local/map-info?path=${encodeURIComponent(entry.path)}`, { credentials: 'include' }); | |
| if (!res.ok) continue; | |
| const info = await res.json(); | |
| const latLng = this._addRasterFromInfo(entry.id || entry.path, info); | |
| if (latLng) allBounds.push(latLng); | |
| } catch (err) { | |
| console.warn('Map overlay failed for', entry.path, err); | |
| } | |
| } | |
| const merged = this._mergeLatLngBounds(allBounds); | |
| this.lastFitBounds = merged; | |
| if (merged) this.fit(merged); | |
| this.invalidate(); | |
| return { ok: allBounds.length > 0, bounds: merged }; | |
| }; | |
| DdaMapViewer.prototype._mergeLatLngBounds = function _mergeLatLngBounds(list) { | |
| if (!list.length) return null; | |
| let south = Infinity; | |
| let west = Infinity; | |
| let north = -Infinity; | |
| let east = -Infinity; | |
| list.forEach((b) => { | |
| south = Math.min(south, b[0][0]); | |
| west = Math.min(west, b[0][1]); | |
| north = Math.max(north, b[1][0]); | |
| east = Math.max(east, b[1][1]); | |
| }); | |
| return [[south, west], [north, east]]; | |
| }; | |
| DdaMapViewer.prototype.setGeoJson = function setGeoJson(geojson) { | |
| const map = this.ensureMap(); | |
| if (!map) return; | |
| this.clearGeoJson(); | |
| if (!geojson || !geojson.features || !geojson.features.length) return; | |
| this.geojsonLayer = L.geoJSON(geojson, { | |
| style: () => ({ | |
| color: '#cf2040', | |
| weight: 2, | |
| fillColor: '#cf2040', | |
| fillOpacity: 0.25, | |
| }), | |
| pointToLayer: (_f, latlng) => L.circleMarker(latlng, { | |
| radius: 6, | |
| color: '#cf2040', | |
| weight: 2, | |
| fillColor: '#cf2040', | |
| fillOpacity: 0.6, | |
| }), | |
| onEachFeature: (feature, layer) => { | |
| const p = feature.properties || {}; | |
| const title = p.changeType || p.ddaType || p.id || 'Change'; | |
| layer.bindPopup(`<strong>${title}</strong>`); | |
| }, | |
| }); | |
| if (this.overlayOn) this.geojsonLayer.addTo(map); | |
| }; | |
| DdaMapViewer.prototype.fit = function fit(latLngBounds) { | |
| const map = this.ensureMap(); | |
| if (!map || !latLngBounds) return; | |
| this.lastFitBounds = latLngBounds; | |
| const run = () => { | |
| try { | |
| map.invalidateSize(); | |
| map.fitBounds(latLngBounds, { padding: [28, 28], maxZoom: 18 }); | |
| this._syncRasterVisibility(); | |
| } catch (_) { /* ignore invalid bounds */ } | |
| }; | |
| run(); | |
| setTimeout(run, 120); | |
| setTimeout(run, 400); | |
| }; | |
| DdaMapViewer.prototype.locate = function locate(lat, lng, zoom) { | |
| const map = this.ensureMap(); | |
| if (!map || lat == null || lng == null) return; | |
| map.setView([lat, lng], zoom || Math.max(map.getZoom(), 18)); | |
| }; | |
| DdaMapViewer.prototype.invalidate = function invalidate() { | |
| const map = this.map; | |
| if (!map) return; | |
| const refit = () => { | |
| map.invalidateSize(); | |
| if (this.lastFitBounds) { | |
| try { map.fitBounds(this.lastFitBounds, { padding: [28, 28], maxZoom: 18 }); } catch (_) { /* ignore */ } | |
| } | |
| this._syncRasterVisibility(); | |
| }; | |
| setTimeout(refit, 80); | |
| setTimeout(() => { | |
| map.invalidateSize(); | |
| this._syncRasterVisibility(); | |
| }, 320); | |
| }; | |
| DdaMapViewer.prototype.destroy = function destroy() { | |
| this.clearRasters(); | |
| this.clearGeoJson(); | |
| if (this.map) { | |
| this.map.remove(); | |
| this.map = null; | |
| this.basemapLayer = null; | |
| } | |
| }; | |
| function bindDdaMapToolbar(viewer, ids) { | |
| const basemap = document.getElementById(ids.basemap); | |
| const xyz = document.getElementById(ids.xyz); | |
| const overlay = document.getElementById(ids.overlay); | |
| const opacity = document.getElementById(ids.opacity); | |
| const opacityVal = document.getElementById(ids.opacityVal); | |
| const fit = document.getElementById(ids.fit); | |
| if (basemap && !basemap.dataset.ddaBound) { | |
| basemap.dataset.ddaBound = '1'; | |
| if (!basemap.options.length) { | |
| DDA_BASEMAPS.forEach((b) => { | |
| const opt = document.createElement('option'); | |
| opt.value = b.id; | |
| opt.textContent = b.label; | |
| basemap.appendChild(opt); | |
| }); | |
| } | |
| basemap.value = viewer.basemapId; | |
| const toggleXyz = () => { | |
| if (xyz) xyz.classList.toggle('hidden', basemap.value !== 'custom-xyz'); | |
| }; | |
| toggleXyz(); | |
| basemap.addEventListener('change', () => { | |
| viewer.applyBasemap(basemap.value, xyz?.value); | |
| toggleXyz(); | |
| }); | |
| } else if (basemap) { | |
| basemap.value = viewer.basemapId; | |
| } | |
| if (xyz && !xyz.dataset.ddaBound) { | |
| xyz.dataset.ddaBound = '1'; | |
| xyz.value = viewer.customXyz; | |
| xyz.addEventListener('change', () => { | |
| viewer.applyBasemap('custom-xyz', xyz.value); | |
| }); | |
| } | |
| if (overlay && !overlay.dataset.ddaBound) { | |
| overlay.dataset.ddaBound = '1'; | |
| overlay.checked = viewer.overlayOn; | |
| overlay.addEventListener('change', () => viewer.setOverlayOn(overlay.checked)); | |
| } | |
| if (opacity && !opacity.dataset.ddaBound) { | |
| opacity.dataset.ddaBound = '1'; | |
| opacity.value = String(Math.round(viewer.opacity * 100)); | |
| const syncLabel = () => { | |
| if (opacityVal) opacityVal.textContent = `${opacity.value}%`; | |
| }; | |
| syncLabel(); | |
| opacity.addEventListener('input', () => { | |
| viewer.setOpacity(Number(opacity.value) / 100); | |
| syncLabel(); | |
| }); | |
| } | |
| if (fit && !fit.dataset.ddaBound) { | |
| fit.dataset.ddaBound = '1'; | |
| fit.addEventListener('click', () => { | |
| if (viewer.lastFitBounds) viewer.fit(viewer.lastFitBounds); | |
| else if (viewer.geojsonLayer) viewer.fit(viewer.geojsonLayer.getBounds()); | |
| }); | |
| } | |
| } | |
| window.DdaMapViewer = DdaMapViewer; | |
| window.DDA_BASEMAPS = DDA_BASEMAPS; | |
| window.bindDdaMapToolbar = bindDdaMapToolbar; | |
| window.ddaBoundsToLatLng = ddaBoundsToLatLng; | |
| window.regionsToGeoJson = function regionsToGeoJson(regions) { | |
| const features = []; | |
| (regions || []).forEach((r) => { | |
| const ring = r.polygonGeo; | |
| if (ring && ring.length >= 3) { | |
| const coords = ring | |
| .filter((p) => p && p.lat != null && p.lng != null) | |
| .map((p) => [p.lng, p.lat]); | |
| if (coords.length >= 3) { | |
| if (coords[0][0] !== coords[coords.length - 1][0] || coords[0][1] !== coords[coords.length - 1][1]) { | |
| coords.push(coords[0]); | |
| } | |
| features.push({ | |
| type: 'Feature', | |
| properties: { | |
| id: r.id, | |
| changeType: r.changeType || r.change_type, | |
| ddaType: r.ddaType, | |
| }, | |
| geometry: { type: 'Polygon', coordinates: [coords] }, | |
| }); | |
| return; | |
| } | |
| } | |
| const ll = r.latLng; | |
| if (ll && ll.lat != null && ll.lng != null) { | |
| features.push({ | |
| type: 'Feature', | |
| properties: { id: r.id, changeType: r.changeType || r.change_type, ddaType: r.ddaType }, | |
| geometry: { type: 'Point', coordinates: [ll.lng, ll.lat] }, | |
| }); | |
| } | |
| }); | |
| return { type: 'FeatureCollection', features }; | |
| }; | |