feat: add FastAPI web interface for GIS classification

This commit is contained in:
Andrew 2026-03-15 14:28:51 +07:00
parent 5a9b8469bd
commit 6815769d2b
5 changed files with 1458 additions and 15 deletions

994
static/index.html Normal file
View file

@ -0,0 +1,994 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GIS Classification</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
}
.container {
display: flex;
height: 100vh;
}
.sidebar {
width: 400px;
background: #16213e;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #0f3460;
}
.sidebar h1 {
font-size: 1.5rem;
margin-bottom: 10px;
color: #e94560;
}
.sidebar h2 {
font-size: 1.1rem;
margin: 20px 0 10px;
color: #0f3460;
border-bottom: 2px solid #e94560;
padding-bottom: 5px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-size: 0.9rem;
color: #aaa;
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px;
border: 1px solid #0f3460;
border-radius: 5px;
background: #1a1a2e;
color: #eee;
font-size: 0.9rem;
}
.form-group input[type="file"] {
padding: 8px;
}
.form-group small {
display: block;
margin-top: 3px;
color: #666;
font-size: 0.8rem;
}
.btn {
width: 100%;
padding: 12px;
border: none;
border-radius: 5px;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: #e94560;
color: white;
}
.btn-primary:hover {
background: #ff6b6b;
}
.btn-primary:disabled {
background: #555;
cursor: not-allowed;
}
.btn-secondary {
background: #0f3460;
color: white;
margin-top: 10px;
}
.btn-secondary:hover {
background: #1a4a7a;
}
.map-container {
flex: 1;
position: relative;
}
#map {
width: 100%;
height: 100%;
}
.status {
padding: 10px;
border-radius: 5px;
margin-top: 15px;
font-size: 0.9rem;
}
.status.info {
background: #0f3460;
border-left: 3px solid #4fc3f7;
}
.status.success {
background: #1b5e20;
border-left: 3px solid #4caf50;
}
.status.error {
background: #b71c1c;
border-left: 3px solid #f44336;
}
.status.loading {
background: #0f3460;
border-left: 3px solid #ff9800;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.metrics {
background: #0f3460;
padding: 15px;
border-radius: 5px;
margin-top: 15px;
}
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.metric {
text-align: center;
}
.metric-value {
font-size: 1.5rem;
font-weight: bold;
color: #e94560;
}
.metric-label {
font-size: 0.8rem;
color: #aaa;
}
.classes-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 10px;
}
.class-badge {
padding: 5px 10px;
border-radius: 15px;
font-size: 0.8rem;
background: #e94560;
color: white;
}
.hidden {
display: none;
}
.file-input-wrapper {
position: relative;
overflow: hidden;
display: inline-block;
width: 100%;
}
.legend {
position: absolute;
bottom: 30px;
right: 10px;
background: rgba(22, 33, 62, 0.9);
padding: 15px;
border-radius: 5px;
z-index: 1000;
max-width: 200px;
}
.legend h4 {
margin-bottom: 10px;
font-size: 0.9rem;
color: #e94560;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 5px;
font-size: 0.8rem;
}
.legend-color {
width: 20px;
height: 20px;
margin-right: 10px;
border-radius: 3px;
}
.class-editor-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
padding: 8px;
background: #1a1a2e;
border-radius: 5px;
}
.class-editor-item input[type="color"] {
width: 40px;
height: 30px;
border: none;
cursor: pointer;
}
.class-editor-item input[type="text"] {
flex: 1;
padding: 8px;
border: 1px solid #0f3460;
border-radius: 3px;
background: #16213e;
color: #eee;
}
.class-editor-item span {
min-width: 60px;
color: #aaa;
font-size: 0.85rem;
}
</style>
</head>
<body>
<div class="container">
<div class="sidebar">
<h1>🛰️ GIS Classification</h1>
<p style="color: #888; font-size: 0.9rem; margin-bottom: 20px;">
Land cover classification using machine learning
</p>
<h2>1. Upload Data</h2>
<div class="form-group">
<label for="raster">Raster File (GeoTIFF)</label>
<input type="file" id="raster" accept=".tif,.tiff">
<small>Select Landsat or similar multispectral imagery</small>
</div>
<div class="form-group">
<label for="vector">Shapefile Files (select all: .shp, .shx, .dbf, .prj)</label>
<input type="file" id="vector" accept=".shp,.shx,.dbf,.prj" multiple onchange="showSelectedFiles()">
<small>Hold Ctrl/Cmd to select multiple files. Required: .shp, .shx, .dbf</small>
<div id="fileList" style="margin-top: 8px; font-size: 0.85rem; color: #4fc3f7;"></div>
</div>
<h2>2. Configure</h2>
<div class="form-group">
<label for="strategy">Classification Strategy</label>
<select id="strategy">
<option value="random_forest">Random Forest (Recommended)</option>
<option value="svm">Support Vector Machine</option>
<option value="logistic_regression">Logistic Regression</option>
<option value="mle">Maximum Likelihood (MLE)</option>
</select>
</div>
<div class="form-group">
<label for="classColumn">Class Column</label>
<input type="text" id="classColumn" value="class" placeholder="Column name in shapefile">
</div>
<div class="form-group">
<label for="nEstimators">Estimators (Random Forest)</label>
<input type="number" id="nEstimators" value="100" min="1" max="500">
</div>
<div class="form-group">
<label for="kernel">SVM Kernel</label>
<select id="kernel">
<option value="linear">Linear (Fast)</option>
<option value="rbf">RBF (Accurate but slow)</option>
</select>
</div>
<h2>3. Train & Classify</h2>
<button class="btn btn-primary" id="trainBtn" onclick="train()">
Train Model
</button>
<div id="status" class="status hidden"></div>
<div id="metrics" class="metrics hidden">
<div class="metrics-grid">
<div class="metric">
<div class="metric-value" id="accuracyValue">-</div>
<div class="metric-label">Accuracy</div>
</div>
<div class="metric">
<div class="metric-value" id="kappaValue">-</div>
<div class="metric-label">Cohen's Kappa</div>
</div>
<div class="metric">
<div class="metric-value" id="trainSamplesValue">-</div>
<div class="metric-label">Train Samples</div>
</div>
<div class="metric">
<div class="metric-value" id="valSamplesValue">-</div>
<div class="metric-label">Validation Samples</div>
</div>
</div>
<div id="classesContainer" class="hidden">
<label style="font-size: 0.8rem; color: #aaa; margin-top: 10px; display: block;">Classes:</label>
<div class="classes-list" id="classesList"></div>
</div>
</div>
<h2>4. Class Templates</h2>
<div class="form-group">
<label for="templateSelect">Load Template</label>
<select id="templateSelect" onchange="loadTemplate()">
<option value="">-- Select Template --</option>
</select>
<small>Pre-defined class names and colors</small>
</div>
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
<button class="btn btn-secondary" onclick="saveTemplate()" style="flex: 1;">
Save Template
</button>
<button class="btn btn-secondary" onclick="exportTemplate()" style="flex: 1;">
Export
</button>
<label class="btn btn-secondary" style="flex: 1; text-align: center; cursor: pointer;">
Import
<input type="file" id="importTemplate" accept=".json" onchange="importTemplate(this)"
style="display: none;">
</label>
</div>
<div id="classEditor" class="hidden"
style="background: #0f3460; padding: 15px; border-radius: 5px; margin-bottom: 15px;">
<h4 style="margin-bottom: 10px; color: #e94560;">Edit Class Names & Colors</h4>
<div id="classEditorItems"></div>
<button class="btn btn-primary" onclick="applyClassTemplate()" style="margin-top: 15px;">
Apply to Map
</button>
</div>
<div class="form-group">
<label for="opacitySlider">Layer Opacity: <span id="opacityValue">70%</span></label>
<input type="range" id="opacitySlider" min="0" max="100" value="70" oninput="updateOpacity()">
<small>Adjust classification layer transparency</small>
</div>
<button class="btn btn-secondary hidden" id="predictBtn" onclick="predict()">
Classify Raster
</button>
<a id="downloadLink" class="btn btn-secondary hidden"
style="text-align: center; text-decoration: none; display: block;" download>
Download Result
</a>
</div>
<div class="map-container">
<div id="map"></div>
<div id="legend" class="legend hidden">
<h4>Classes</h4>
<div id="legendItems"></div>
</div>
</div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- GeoRasterLayer for Leaflet -->
<script src="https://unpkg.com/georaster"></script>
<script src="https://unpkg.com/georaster-layer-for-leaflet"></script>
<script>
// Initialize map
const map = L.map('map', {
attributionControl: false
}).setView([0, 0], 3);
// Add satellite base layer (Esri World Imagery)
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
maxZoom: 19
}).addTo(map);
// Add custom attribution control without country flags
L.control.attribution({
prefix: ''
}).addTo(map);
let sessionId = null;
let geoRasterLayer = null;
let currentClasses = [];
let currentPalette = [];
let classTemplates = {};
let customColorMapping = {};
let currentGeoRaster = null;
// Load templates from localStorage on init
loadTemplatesFromStorage();
function showSelectedFiles() {
const files = document.getElementById('vector').files;
const fileList = document.getElementById('fileList');
if (files.length === 0) {
fileList.innerHTML = '';
return;
}
const fileNames = Array.from(files).map(f => f.name).sort();
fileList.innerHTML = '<strong>Selected:</strong> ' + fileNames.join(', ');
}
function showStatus(message, type = 'info') {
const status = document.getElementById('status');
status.textContent = message;
status.className = `status ${type}`;
status.classList.remove('hidden');
}
function hideStatus() {
document.getElementById('status').classList.add('hidden');
}
async function train() {
const rasterFile = document.getElementById('raster').files[0];
const vectorFiles = document.getElementById('vector').files;
if (!rasterFile) {
showStatus('Please select a raster file', 'error');
return;
}
if (!vectorFiles || vectorFiles.length === 0) {
showStatus('Please select shapefile files (.shp, .shx, .dbf)', 'error');
return;
}
// Validate shapefile files
const extensions = Array.from(vectorFiles).map(f => f.name.toLowerCase().split('.').pop());
const required = ['shp', 'shx', 'dbf'];
const missing = required.filter(ext => !extensions.includes(ext));
if (missing.length > 0) {
showStatus(`Missing required shapefile files: ${missing.map(e => '.' + e).join(', ')}`, 'error');
return;
}
const formData = new FormData();
formData.append('raster', rasterFile);
// Append all shapefile files
for (let i = 0; i < vectorFiles.length; i++) {
formData.append('vector_files', vectorFiles[i]);
}
formData.append('strategy', document.getElementById('strategy').value);
formData.append('class_column', document.getElementById('classColumn').value);
formData.append('n_estimators', document.getElementById('nEstimators').value);
formData.append('kernel', document.getElementById('kernel').value);
formData.append('test_size', '0.2');
formData.append('random_state', '42');
const trainBtn = document.getElementById('trainBtn');
trainBtn.disabled = true;
trainBtn.textContent = 'Training...';
showStatus('Training classifier... This may take a few minutes.', 'loading');
try {
const response = await fetch('/train', {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Training failed');
}
sessionId = data.session_id;
// Display metrics
const metrics = data.metrics;
document.getElementById('accuracyValue').textContent = (metrics.accuracy * 100).toFixed(1) + '%';
document.getElementById('kappaValue').textContent = metrics.kappa.toFixed(4);
document.getElementById('trainSamplesValue').textContent = metrics.train_samples.toLocaleString();
document.getElementById('valSamplesValue').textContent = metrics.val_samples.toLocaleString();
// Display classes
const classesList = document.getElementById('classesList');
classesList.innerHTML = metrics.classes.map(c =>
`<span class="class-badge">Class ${c}</span>`
).join('');
document.getElementById('classesContainer').classList.remove('hidden');
document.getElementById('metrics').classList.remove('hidden');
document.getElementById('predictBtn').classList.remove('hidden');
showStatus(`Training completed! Accuracy: ${(metrics.accuracy * 100).toFixed(1)}%, Kappa: ${metrics.kappa.toFixed(4)}. You can classify raster and customize visualization with templates!`, 'success');
} catch (error) {
showStatus(`Error: ${error.message}`, 'error');
} finally {
trainBtn.disabled = false;
trainBtn.textContent = 'Train Model';
}
}
async function predict() {
if (!sessionId) {
showStatus('No trained model available', 'error');
return;
}
const predictBtn = document.getElementById('predictBtn');
predictBtn.disabled = true;
predictBtn.textContent = 'Classifying...';
showStatus('Classifying raster... This may take a moment.', 'loading');
const formData = new FormData();
formData.append('session_id', sessionId);
formData.append('output_format', 'geotiff');
try {
const response = await fetch('/predict', {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Classification failed');
}
// Update map view to show raster bounds
const bounds = [
[data.bounds.bottom, data.bounds.left],
[data.bounds.top, data.bounds.right]
];
map.fitBounds(bounds);
// Load and display classified raster
showStatus('Loading classification result...', 'loading');
// Download and display the classified raster
const downloadResponse = await fetch(`/result/${sessionId}/download`);
const blob = await downloadResponse.blob();
const arrayBuffer = await blob.arrayBuffer();
// Parse GeoTIFF
const geoRaster = await parseGeoraster(arrayBuffer);
currentGeoRaster = geoRaster;
// Remove existing layer
if (geoRasterLayer) {
map.removeLayer(geoRasterLayer);
}
// Create color palette for classes
const palette = generatePalette(data.classes.length);
// Add GeoRaster layer
geoRasterLayer = createGeoRasterLayer(geoRaster, palette);
geoRasterLayer.addTo(map);
// Apply initial opacity from slider
const initialOpacity = document.getElementById('opacitySlider').value / 100;
geoRasterLayer.setOpacity(initialOpacity);
// Zoom to layer bounds
if (geoRasterLayer.getBounds()) {
map.fitBounds(geoRasterLayer.getBounds());
}
// Update legend
currentClasses = data.classes;
currentPalette = palette;
updateLegend(data.classes, palette);
// Show class editor for customizing names
document.getElementById('classEditor').classList.remove('hidden');
const editorItems = document.getElementById('classEditorItems');
editorItems.innerHTML = '';
data.classes.forEach((cls, i) => {
const item = document.createElement('div');
item.className = 'class-editor-item';
item.innerHTML = `
<span>Class ${cls}</span>
<input type="color" value="${palette[i]}" data-class="${cls}" data-type="color">
<input type="text" value="Class ${cls}" data-class="${cls}" data-type="name" placeholder="Class name">
`;
editorItems.appendChild(item);
});
// Update download link
const downloadLink = document.getElementById('downloadLink');
downloadLink.href = URL.createObjectURL(blob);
downloadLink.download = 'classified_result.tif';
downloadLink.classList.remove('hidden');
showStatus('Classification complete!', 'success');
} catch (error) {
showStatus(`Error: ${error.message}`, 'error');
console.error(error);
} finally {
predictBtn.disabled = false;
predictBtn.textContent = 'Classify Raster';
}
}
function generatePalette(nClasses) {
// Generate distinct colors for classes
const colors = [
'#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00',
'#ffff33', '#a65628', '#f781bf', '#999999', '#66c2a5',
'#fc8d62', '#8da0cb', '#e78ac3', '#a6d854', '#ffd92f',
'#e5c494', '#b3b3b3', '#1b9e77', '#d95f02', '#7570b3'
];
const palette = [];
for (let i = 0; i < nClasses; i++) {
palette.push(colors[i % colors.length]);
}
return palette;
}
function updateLegend(classes, palette) {
const legend = document.getElementById('legend');
const legendItems = document.getElementById('legendItems');
legendItems.innerHTML = classes.map((cls, i) => `
<div class="legend-item">
<div class="legend-color" style="background: ${palette[i]}"></div>
<span>Class ${cls}</span>
</div>
`).join('');
legend.classList.remove('hidden');
}
// Helper function to parse GeoTIFF
async function parseGeoraster(arrayBuffer) {
return new Promise((resolve, reject) => {
parseGeorasterInternal(arrayBuffer).then(resolve).catch(reject);
});
}
function parseGeorasterInternal(arrayBuffer) {
return new Promise((resolve, reject) => {
try {
const georaster = new GeoRaster(arrayBuffer);
resolve(georaster);
} catch (e) {
// Fallback: use global parseGeoraster if available
if (typeof window.parseGeoraster === 'function') {
window.parseGeoraster(arrayBuffer).then(resolve).catch(reject);
} else {
reject(e);
}
}
});
}
// ==================== CLASS TEMPLATE FUNCTIONS ====================
function loadTemplatesFromStorage() {
const stored = localStorage.getItem('gis_class_templates');
if (stored) {
try {
classTemplates = JSON.parse(stored);
} catch (e) {
classTemplates = {};
}
}
// Add default templates if none exist
if (Object.keys(classTemplates).length === 0) {
classTemplates = {
'Rainbow': {
'1': { name: 'Red', color: '#FF0000' },
'2': { name: 'Orange', color: '#FF7F00' },
'3': { name: 'Yellow', color: '#FFFF00' },
'4': { name: 'Green', color: '#00FF00' },
'5': { name: 'Blue', color: '#0000FF' },
'6': { name: 'Violet', color: '#8B00FF' }
},
};
saveTemplatesToStorage();
}
updateTemplateSelect();
}
function saveTemplatesToStorage() {
localStorage.setItem('gis_class_templates', JSON.stringify(classTemplates));
}
function updateTemplateSelect() {
const select = document.getElementById('templateSelect');
select.innerHTML = '<option value="">-- Select Template --</option>';
Object.keys(classTemplates).forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
select.appendChild(option);
});
}
function loadTemplate() {
const select = document.getElementById('templateSelect');
const templateName = select.value;
if (!templateName || !classTemplates[templateName]) return;
const template = classTemplates[templateName];
const editorItems = document.getElementById('classEditorItems');
editorItems.innerHTML = '';
// Get current classes or use template keys
const classesToShow = currentClasses.length > 0 ? currentClasses : Object.keys(template).sort((a, b) => parseInt(a) - parseInt(b));
classesToShow.forEach(cls => {
const item = document.createElement('div');
item.className = 'class-editor-item';
const templateData = template[cls.toString()] || { name: `Class ${cls}`, color: '#808080' };
item.innerHTML = `
<span>Class ${cls}</span>
<input type="color" value="${templateData.color}" data-class="${cls}" data-type="color">
<input type="text" value="${templateData.name}" data-class="${cls}" data-type="name" placeholder="Class name">
`;
editorItems.appendChild(item);
});
document.getElementById('classEditor').classList.remove('hidden');
// Auto-apply the template
applyClassTemplate();
}
function saveTemplate() {
const name = prompt('Enter template name:');
if (!name) return;
if (!currentClasses.length) {
showStatus('No classes available. Run classification first.', 'error');
return;
}
const template = {};
const editorItems = document.querySelectorAll('.class-editor-item');
editorItems.forEach(item => {
const classId = item.querySelector('input[data-type="color"]').dataset.class;
const color = item.querySelector('input[data-type="color"]').value;
const className = item.querySelector('input[data-type="name"]').value;
template[classId] = { name: className, color: color };
});
classTemplates[name] = template;
saveTemplatesToStorage();
updateTemplateSelect();
document.getElementById('templateSelect').value = name;
showStatus(`Template "${name}" saved!`, 'success');
}
function exportTemplate() {
const select = document.getElementById('templateSelect');
const templateName = select.value;
if (!templateName || !classTemplates[templateName]) {
showStatus('Please select a template to export', 'error');
return;
}
const template = { name: templateName, classes: classTemplates[templateName] };
const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${templateName.replace(/\s+/g, '_')}_template.json`;
a.click();
URL.revokeObjectURL(url);
}
function importTemplate(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
try {
const template = JSON.parse(e.target.result);
if (!template.name || !template.classes) {
throw new Error('Invalid template format');
}
classTemplates[template.name] = template.classes;
saveTemplatesToStorage();
updateTemplateSelect();
document.getElementById('templateSelect').value = template.name;
showStatus(`Template "${template.name}" imported!`, 'success');
} catch (err) {
showStatus('Invalid template file: ' + err.message, 'error');
}
};
reader.readAsText(file);
input.value = '';
}
function applyClassTemplate() {
const editorItems = document.querySelectorAll('.class-editor-item');
const customMapping = {};
editorItems.forEach(item => {
const classId = item.querySelector('input[data-type="color"]').dataset.class;
const color = item.querySelector('input[data-type="color"]').value;
const className = item.querySelector('input[data-type="name"]').value;
customMapping[classId] = { name: className, color: color };
});
// Update legend with custom names
updateLegendWithCustomNames(currentClasses, currentPalette, customMapping);
showStatus('Class template applied!', 'success');
}
function updateLegendWithCustomNames(classes, palette, customMapping) {
const legend = document.getElementById('legend');
const legendItems = document.getElementById('legendItems');
legendItems.innerHTML = classes.map((cls, i) => {
const custom = customMapping[cls.toString()] || customMapping[i.toString()];
const name = custom ? custom.name : `Class ${cls}`;
const color = custom ? custom.color : palette[i];
return `
<div class="legend-item">
<div class="legend-color" style="background: ${color}"></div>
<span>${name}</span>
</div>
`;
}).join('');
legend.classList.remove('hidden');
}
function createGeoRasterLayer(geoRaster, palette, customMapping = {}) {
const layer = new GeoRasterLayer({
georaster: geoRaster,
opacity: 0.7,
pixelValuesToColorFn: function (pixelValues) {
const value = pixelValues[0];
if (value === null || value === undefined || value === geoRaster.nodata) {
return null;
}
const classIndex = Math.floor(value) - 1;
if (classIndex < 0 || classIndex >= palette.length) {
return null;
}
// Use custom color if available, otherwise use palette
const className = (classIndex + 1).toString();
if (customMapping[className] && customMapping[className].color) {
return customMapping[className].color;
}
return palette[classIndex];
},
resolution: Math.max(64, Math.min(256, geoRaster.height / 10))
});
// Force Leaflet to treat this as a new layer
layer._leaflet_id = 'georaster_' + Date.now();
return layer;
}
function applyClassTemplate() {
const editorItems = document.querySelectorAll('.class-editor-item');
const customMapping = {};
editorItems.forEach(item => {
const classId = item.querySelector('input[data-type="color"]').dataset.class;
const color = item.querySelector('input[data-type="color"]').value;
const className = item.querySelector('input[data-type="name"]').value;
customMapping[classId] = { name: className, color: color };
});
// Store custom mapping
window.currentCustomMapping = customMapping;
// Update legend first
updateLegendWithCustomNames(currentClasses, currentPalette, customMapping);
// Re-create GeoRaster layer with custom colors
if (currentGeoRaster && geoRasterLayer) {
// Remove old layer
map.removeLayer(geoRasterLayer);
// Create new layer with custom colors
geoRasterLayer = createGeoRasterLayer(currentGeoRaster, currentPalette, customMapping);
geoRasterLayer.addTo(map);
// Force complete redraw by triggering multiple refresh methods
setTimeout(() => {
// Clear cache of the layer
if (geoRasterLayer.clearCache) {
geoRasterLayer.clearCache();
}
// Try to redraw the layer
if (geoRasterLayer.redraw) {
geoRasterLayer.redraw();
}
// Trigger map move event to force tile refresh
map.fire('moveend');
}, 50);
}
showStatus('Class template applied! Zoom in/out if colors do not update immediately.', 'success');
}
function updateOpacity() {
const slider = document.getElementById('opacitySlider');
const valueDisplay = document.getElementById('opacityValue');
const opacity = slider.value / 100;
valueDisplay.textContent = slider.value + '%';
if (geoRasterLayer) {
geoRasterLayer.setOpacity(opacity);
}
}
</script>
</body>
</html>