|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>MSPO Deforestation</title> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css" /> |
|
|
<link rel="stylesheet" href="style2.css"> |
|
|
<style> |
|
|
|
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="header"> |
|
|
<div class="logo"></div> |
|
|
<img src="https://images.squarespace-cdn.com/content/v1/604db3a6dad32a12b2415387/1636475927545-PK57PVLIB7AEKX1AJQJ8/Logo_MSPO_2020.png" alt="MSPO Logo" style="height: 60px;"> |
|
|
<div class="logo" "textalign=left">MSPO Deforestation Mapping</div> |
|
|
<div class="logo"></div> |
|
|
<div class="controls"> |
|
|
<div class="control-group"> |
|
|
<label>Search Location</label> |
|
|
<input type="text" class="search-box" id="searchBox" placeholder="Enter city or address..."> |
|
|
</div> |
|
|
<div class="control-group"> |
|
|
<label>Left Panel</label> |
|
|
<select class="layer-select" id="leftLayer"> |
|
|
<option value="osm">OpenStreetMap</option> |
|
|
<option value="satellite">Satellite</option> |
|
|
<option value="terrain">Terrain</option> |
|
|
<option value="sentinel2">Sentinel-2</option> |
|
|
<option value="satelogic">satelogic</option> |
|
|
</select> |
|
|
</div> |
|
|
<div class="control-group"> |
|
|
<label>Right Panel</label> |
|
|
<select class="layer-select" id="rightLayer"> |
|
|
<option value="satellite" selected>Satellite</option> |
|
|
<option value="osm">OpenStreetMap</option> |
|
|
<option value="terrain">Terrain</option> |
|
|
<option value="sentinel2">SENTINEL-2</option> |
|
|
<option value="satelogic">Satelogic</option> |
|
|
</select> |
|
|
</div> |
|
|
<input type="file" class="file-input" id="importFile" accept=".geojson,.json,.kml,.gpx" style="display: none;"> |
|
|
<button class="search-btn" id="searchBtn">Search</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="map-container"> |
|
|
<div class="map-panel left-panel" id="leftPanel"> |
|
|
<div class="panel-label">OpenStreetMap</div> |
|
|
<div class="map" id="leftMap"></div> |
|
|
<div class="coordinates" id="leftCoords">Lat: 0.0000, Lng: 0.0000</div> |
|
|
</div> |
|
|
|
|
|
<div class="map-panel right-panel" id="rightPanel"> |
|
|
<div class="panel-label">Satellite</div> |
|
|
<div class="map" id="rightMap"></div> |
|
|
<div class="coordinates" id="rightCoords">Lat: 0.0000, Lng: 0.0000</div> |
|
|
</div> |
|
|
|
|
|
<div class="slider-container" id="sliderContainer"> |
|
|
<div class="slider-handle" id="sliderHandle"></div> |
|
|
</div> |
|
|
|
|
|
<button class="sync-toggle active" id="syncToggle" title="Toggle map synchronization"> |
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> |
|
|
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/> |
|
|
</svg> |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<div class="loading-indicator" id="loadingIndicator"> |
|
|
<div>Loading...</div> |
|
|
</div> |
|
|
|
|
|
<div class="error-message" id="errorMessage"> |
|
|
<div id="errorText"></div> |
|
|
</div> |
|
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script> |
|
|
<script> |
|
|
|
|
|
const DEFAULT_LOCATION = [40.7128, -74.0060]; |
|
|
const DEFAULT_ZOOM = 10; |
|
|
const SEARCH_ZOOM = 12; |
|
|
const MIN_SLIDER_POSITION = 15; |
|
|
const MAX_SLIDER_POSITION = 85; |
|
|
|
|
|
|
|
|
|
|
|
const tileLayers = { |
|
|
osm: { |
|
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', |
|
|
attribution: '© OpenStreetMap contributors', |
|
|
name: 'OpenStreetMap' |
|
|
}, |
|
|
satellite: { |
|
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', |
|
|
attribution: 'Tiles © Esri', |
|
|
name: 'Satellite' |
|
|
}, |
|
|
terrain: { |
|
|
url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', |
|
|
attribution: '© OpenTopoMap contributors', |
|
|
name: 'Terrain' |
|
|
}, |
|
|
sentinel2: { |
|
|
|
|
|
url: 'https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2020_3857/default/g/{z}/{y}/{x}.jpg', |
|
|
attribution: '© SENTINEL-2 Cloudless 2020, EOX IT Services GmbH', |
|
|
name: 'sentinel2 Mode' |
|
|
}, |
|
|
satelogic: { |
|
|
url: 'http://localhost:8080/data/forest/{z}/{x}/{y}.png', |
|
|
attribution: '© Satelogic', |
|
|
name: 'satelogic Mode' |
|
|
} |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
let leftMap, rightMap; |
|
|
let leftLayer, rightLayer; |
|
|
let isSynced = true; |
|
|
let mapSyncLock = false; |
|
|
let isSliderActive = false; |
|
|
let searchMarkers = []; |
|
|
|
|
|
|
|
|
const elements = { |
|
|
leftPanel: document.getElementById('leftPanel'), |
|
|
rightPanel: document.getElementById('rightPanel'), |
|
|
sliderContainer: document.getElementById('sliderContainer'), |
|
|
sliderHandle: document.getElementById('sliderHandle'), |
|
|
mapContainer: document.querySelector('.map-container'), |
|
|
searchBox: document.getElementById('searchBox'), |
|
|
searchBtn: document.getElementById('searchBtn'), |
|
|
leftLayer: document.getElementById('leftLayer'), |
|
|
rightLayer: document.getElementById('rightLayer'), |
|
|
syncToggle: document.getElementById('syncToggle'), |
|
|
leftCoords: document.getElementById('leftCoords'), |
|
|
rightCoords: document.getElementById('rightCoords'), |
|
|
loadingIndicator: document.getElementById('loadingIndicator'), |
|
|
errorMessage: document.getElementById('errorMessage'), |
|
|
errorText: document.getElementById('errorText') |
|
|
}; |
|
|
|
|
|
|
|
|
function showError(message) { |
|
|
elements.errorText.textContent = message; |
|
|
elements.errorMessage.style.display = 'block'; |
|
|
setTimeout(() => { |
|
|
elements.errorMessage.style.display = 'none'; |
|
|
}, 5000); |
|
|
} |
|
|
|
|
|
function showLoading() { |
|
|
elements.loadingIndicator.style.display = 'block'; |
|
|
} |
|
|
|
|
|
function hideLoading() { |
|
|
elements.loadingIndicator.style.display = 'none'; |
|
|
} |
|
|
|
|
|
function updatePanelLabels() { |
|
|
const leftLabel = document.querySelector('.left-panel .panel-label'); |
|
|
const rightLabel = document.querySelector('.right-panel .panel-label'); |
|
|
|
|
|
if (leftLabel) { |
|
|
leftLabel.textContent = tileLayers[elements.leftLayer.value].name; |
|
|
} |
|
|
if (rightLabel) { |
|
|
rightLabel.textContent = tileLayers[elements.rightLayer.value].name; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function initializeMaps() { |
|
|
try { |
|
|
|
|
|
leftMap = L.map('leftMap', { |
|
|
zoomControl: false, |
|
|
attributionControl: true |
|
|
}).setView(DEFAULT_LOCATION, DEFAULT_ZOOM); |
|
|
|
|
|
|
|
|
rightMap = L.map('rightMap', { |
|
|
zoomControl: false, |
|
|
attributionControl: true |
|
|
}).setView(DEFAULT_LOCATION, DEFAULT_ZOOM); |
|
|
|
|
|
|
|
|
L.control.zoom({ |
|
|
position: 'topright' |
|
|
}).addTo(leftMap); |
|
|
|
|
|
L.control.zoom({ |
|
|
position: 'topright' |
|
|
}).addTo(rightMap); |
|
|
|
|
|
|
|
|
leftLayer = L.tileLayer(tileLayers.osm.url, { |
|
|
attribution: tileLayers.osm.attribution, |
|
|
maxZoom: 18 |
|
|
}).addTo(leftMap); |
|
|
|
|
|
rightLayer = L.tileLayer(tileLayers.satellite.url, { |
|
|
attribution: tileLayers.satellite.attribution, |
|
|
maxZoom: 18 |
|
|
}).addTo(rightMap); |
|
|
|
|
|
|
|
|
setupMapEvents(); |
|
|
|
|
|
|
|
|
updateCoordinates(leftMap, elements.leftCoords); |
|
|
updateCoordinates(rightMap, elements.rightCoords); |
|
|
|
|
|
console.log('Maps initialized successfully'); |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Error initializing maps:', error); |
|
|
showError('Failed to initialize maps. Please refresh the page.'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function setupMapEvents() { |
|
|
leftMap.on('moveend', () => { |
|
|
updateCoordinates(leftMap, elements.leftCoords); |
|
|
if (isSynced && !mapSyncLock && !isSliderActive) { |
|
|
syncMaps(leftMap, rightMap); |
|
|
} |
|
|
}); |
|
|
|
|
|
rightMap.on('moveend', () => { |
|
|
updateCoordinates(rightMap, elements.rightCoords); |
|
|
if (isSynced && !mapSyncLock && !isSliderActive) { |
|
|
syncMaps(rightMap, leftMap); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
leftMap.on('tileerror', (e) => { |
|
|
console.warn('Left map tile error:', e); |
|
|
}); |
|
|
|
|
|
rightMap.on('tileerror', (e) => { |
|
|
console.warn('Right map tile error:', e); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function updateCoordinates(map, coordsElement) { |
|
|
if (!map || !coordsElement) return; |
|
|
|
|
|
const center = map.getCenter(); |
|
|
const zoom = map.getZoom(); |
|
|
coordsElement.textContent = `Lat: ${center.lat.toFixed(4)}, Lng: ${center.lng.toFixed(4)}, Zoom: ${zoom}`; |
|
|
} |
|
|
|
|
|
|
|
|
function syncMaps(sourceMap, targetMap) { |
|
|
if (!isSynced || !sourceMap || !targetMap || mapSyncLock) return; |
|
|
|
|
|
mapSyncLock = true; |
|
|
|
|
|
try { |
|
|
const center = sourceMap.getCenter(); |
|
|
const zoom = sourceMap.getZoom(); |
|
|
targetMap.setView(center, zoom); |
|
|
} catch (error) { |
|
|
console.error('Error syncing maps:', error); |
|
|
} |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
mapSyncLock = false; |
|
|
}, 100); |
|
|
} |
|
|
|
|
|
|
|
|
let isSliderDragging = false; |
|
|
let startMouseX = 0; |
|
|
let startSliderPosition = 50; |
|
|
|
|
|
function updateSliderPosition(positionPercentage) { |
|
|
|
|
|
positionPercentage = Math.max(MIN_SLIDER_POSITION, Math.min(MAX_SLIDER_POSITION, positionPercentage)); |
|
|
|
|
|
|
|
|
elements.sliderContainer.style.left = `${positionPercentage}%`; |
|
|
|
|
|
|
|
|
elements.leftPanel.style.clipPath = `polygon(0 0, ${positionPercentage}% 0, ${positionPercentage}% 100%, 0 100%)`; |
|
|
elements.rightPanel.style.clipPath = `polygon(${positionPercentage}% 0, 100% 0, 100% 100%, ${positionPercentage}% 100%)`; |
|
|
} |
|
|
|
|
|
function getMouseX(e) { |
|
|
return e.clientX || (e.touches && e.touches[0] ? e.touches[0].clientX : 0); |
|
|
} |
|
|
|
|
|
function startSliderDrag(e) { |
|
|
e.preventDefault(); |
|
|
e.stopPropagation(); |
|
|
|
|
|
isSliderDragging = true; |
|
|
isSliderActive = true; |
|
|
startMouseX = getMouseX(e); |
|
|
|
|
|
|
|
|
const containerRect = elements.mapContainer.getBoundingClientRect(); |
|
|
const sliderRect = elements.sliderContainer.getBoundingClientRect(); |
|
|
startSliderPosition = ((sliderRect.left - containerRect.left) / containerRect.width) * 100; |
|
|
|
|
|
|
|
|
elements.sliderContainer.classList.add('dragging'); |
|
|
elements.sliderHandle.classList.add('dragging'); |
|
|
|
|
|
|
|
|
document.addEventListener('mousemove', onSliderDrag, { passive: false }); |
|
|
document.addEventListener('mouseup', stopSliderDrag); |
|
|
document.addEventListener('touchmove', onSliderDrag, { passive: false }); |
|
|
document.addEventListener('touchend', stopSliderDrag); |
|
|
|
|
|
|
|
|
document.body.style.cursor = 'ew-resize'; |
|
|
document.body.style.userSelect = 'none'; |
|
|
} |
|
|
|
|
|
function onSliderDrag(e) { |
|
|
if (!isSliderDragging) return; |
|
|
|
|
|
e.preventDefault(); |
|
|
e.stopPropagation(); |
|
|
|
|
|
const currentMouseX = getMouseX(e); |
|
|
const deltaX = currentMouseX - startMouseX; |
|
|
const containerWidth = elements.mapContainer.offsetWidth; |
|
|
const deltaPercentage = (deltaX / containerWidth) * 100; |
|
|
|
|
|
const newPosition = startSliderPosition + deltaPercentage; |
|
|
updateSliderPosition(newPosition); |
|
|
} |
|
|
|
|
|
function stopSliderDrag(e) { |
|
|
if (!isSliderDragging) return; |
|
|
|
|
|
e.preventDefault(); |
|
|
e.stopPropagation(); |
|
|
|
|
|
isSliderDragging = false; |
|
|
|
|
|
|
|
|
elements.sliderContainer.classList.remove('dragging'); |
|
|
elements.sliderHandle.classList.remove('dragging'); |
|
|
|
|
|
|
|
|
document.removeEventListener('mousemove', onSliderDrag); |
|
|
document.removeEventListener('mouseup', stopSliderDrag); |
|
|
document.removeEventListener('touchmove', onSliderDrag); |
|
|
document.removeEventListener('touchend', stopSliderDrag); |
|
|
|
|
|
|
|
|
document.body.style.cursor = ''; |
|
|
document.body.style.userSelect = ''; |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
isSliderActive = false; |
|
|
}, 300); |
|
|
} |
|
|
|
|
|
|
|
|
function switchLayer(map, currentLayer, layerType) { |
|
|
if (!map || !currentLayer) return null; |
|
|
|
|
|
try { |
|
|
map.removeLayer(currentLayer); |
|
|
const selectedLayer = tileLayers[layerType]; |
|
|
const newLayer = L.tileLayer(selectedLayer.url, { |
|
|
attribution: selectedLayer.attribution, |
|
|
maxZoom: 18 |
|
|
}).addTo(map); |
|
|
|
|
|
return newLayer; |
|
|
} catch (error) { |
|
|
console.error('Error switching layer:', error); |
|
|
showError('Failed to switch map layer'); |
|
|
return currentLayer; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function clearSearchMarkers() { |
|
|
searchMarkers.forEach(marker => { |
|
|
if (leftMap && leftMap.hasLayer(marker.left)) { |
|
|
leftMap.removeLayer(marker.left); |
|
|
} |
|
|
if (rightMap && rightMap.hasLayer(marker.right)) { |
|
|
rightMap.removeLayer(marker.right); |
|
|
} |
|
|
}); |
|
|
searchMarkers = []; |
|
|
} |
|
|
|
|
|
async function searchLocation(query) { |
|
|
if (!query || query.trim() === '') { |
|
|
showError('Please enter a search term'); |
|
|
return false; |
|
|
} |
|
|
|
|
|
showLoading(); |
|
|
elements.searchBtn.disabled = true; |
|
|
|
|
|
try { |
|
|
const response = await fetch( |
|
|
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&addressdetails=1`, |
|
|
{ |
|
|
headers: { |
|
|
'User-Agent': 'Split Panel Maps' |
|
|
} |
|
|
} |
|
|
); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error('Search service unavailable'); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
if (data.length > 0) { |
|
|
const lat = parseFloat(data[0].lat); |
|
|
const lng = parseFloat(data[0].lon); |
|
|
|
|
|
if (isNaN(lat) || isNaN(lng)) { |
|
|
throw new Error('Invalid coordinates received'); |
|
|
} |
|
|
|
|
|
|
|
|
clearSearchMarkers(); |
|
|
|
|
|
|
|
|
const markerLeft = L.marker([lat, lng]).addTo(leftMap) |
|
|
.bindPopup(`<b>${data[0].display_name}</b>`) |
|
|
.openPopup(); |
|
|
|
|
|
const markerRight = L.marker([lat, lng]).addTo(rightMap) |
|
|
.bindPopup(`<b>${data[0].display_name}</b>`) |
|
|
.openPopup(); |
|
|
|
|
|
searchMarkers.push({ left: markerLeft, right: markerRight }); |
|
|
|
|
|
|
|
|
leftMap.setView([lat, lng], SEARCH_ZOOM); |
|
|
if (isSynced) { |
|
|
rightMap.setView([lat, lng], SEARCH_ZOOM); |
|
|
} |
|
|
|
|
|
return true; |
|
|
} else { |
|
|
showError('Location not found. Please try a different search term.'); |
|
|
return false; |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Search error:', error); |
|
|
showError('Search failed. Please try again.'); |
|
|
return false; |
|
|
} finally { |
|
|
hideLoading(); |
|
|
elements.searchBtn.disabled = false; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function setupEventListeners() { |
|
|
|
|
|
elements.sliderContainer.addEventListener('mousedown', startSliderDrag); |
|
|
elements.sliderHandle.addEventListener('mousedown', startSliderDrag); |
|
|
elements.sliderContainer.addEventListener('touchstart', startSliderDrag); |
|
|
elements.sliderHandle.addEventListener('touchstart', startSliderDrag); |
|
|
|
|
|
|
|
|
elements.sliderContainer.addEventListener('contextmenu', (e) => e.preventDefault()); |
|
|
elements.sliderHandle.addEventListener('contextmenu', (e) => e.preventDefault()); |
|
|
|
|
|
|
|
|
elements.syncToggle.addEventListener('click', () => { |
|
|
isSynced = !isSynced; |
|
|
elements.syncToggle.classList.toggle('active', isSynced); |
|
|
|
|
|
if (isSynced && leftMap && rightMap) { |
|
|
syncMaps(leftMap, rightMap); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
elements.leftLayer.addEventListener('change', (e) => { |
|
|
if (leftMap && leftLayer) { |
|
|
leftLayer = switchLayer(leftMap, leftLayer, e.target.value); |
|
|
updatePanelLabels(); |
|
|
} |
|
|
}); |
|
|
|
|
|
elements.rightLayer.addEventListener('change', (e) => { |
|
|
if (rightMap && rightLayer) { |
|
|
rightLayer = switchLayer(rightMap, rightLayer, e.target.value); |
|
|
updatePanelLabels(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
elements.searchBtn.addEventListener('click', async () => { |
|
|
const query = elements.searchBox.value.trim(); |
|
|
await searchLocation(query); |
|
|
}); |
|
|
|
|
|
elements.searchBox.addEventListener('keypress', async (e) => { |
|
|
if (e.key === 'Enter') { |
|
|
const query = e.target.value.trim(); |
|
|
await searchLocation(query); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
window.addEventListener('resize', () => { |
|
|
setTimeout(() => { |
|
|
if (leftMap) leftMap.invalidateSize(); |
|
|
if (rightMap) rightMap.invalidateSize(); |
|
|
}, 300); |
|
|
}); |
|
|
|
|
|
|
|
|
elements.errorMessage.addEventListener('click', () => { |
|
|
elements.errorMessage.style.display = 'none'; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
console.log('DOM loaded, initializing application...'); |
|
|
|
|
|
|
|
|
setupEventListeners(); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
initializeMaps(); |
|
|
updatePanelLabels(); |
|
|
}, 100); |
|
|
}); |
|
|
|
|
|
|
|
|
document.addEventListener('visibilitychange', () => { |
|
|
if (!document.hidden) { |
|
|
|
|
|
setTimeout(() => { |
|
|
if (leftMap) leftMap.invalidateSize(); |
|
|
if (rightMap) rightMap.invalidateSize(); |
|
|
}, 100); |
|
|
} |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |