Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Satellite Imagery Learning</title> | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> | |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; } | |
| #map { width: 100%; height: 100%; background: #e8f5e9; } | |
| .leaflet-control-attribution { font-size: 10px; } | |
| </style> | |
| </head> | |
| <body class="bg-white"> | |
| <!-- ── Alert Banner ─────────────────────────────────────────── --> | |
| <div id="alert-bar" | |
| class="hidden fixed top-4 left-1/2 -translate-x-1/2 z-[9999] w-full max-w-xl px-4 pointer-events-none"> | |
| <div class="bg-red-50 border border-red-200 rounded-xl shadow-lg p-4 flex items-start gap-3 pointer-events-auto"> | |
| <svg class="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"> | |
| <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> | |
| </svg> | |
| <div class="flex-1 min-w-0"> | |
| <p class="text-sm font-semibold text-red-800" id="alert-title">Error</p> | |
| <p class="text-sm text-red-700 mt-0.5" id="alert-msg"></p> | |
| </div> | |
| <button onclick="hideAlert()" class="text-red-400 hover:text-red-600 flex-shrink-0"> | |
| <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> | |
| <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- ── Preview Modal ─────────────────────────────────────────── --> | |
| <div id="preview-modal" | |
| class="hidden fixed inset-0 bg-black/50 z-[9998] flex items-center justify-center p-4"> | |
| <div class="bg-white rounded-2xl shadow-2xl max-w-2xl w-full overflow-hidden"> | |
| <div class="flex items-center justify-between px-5 py-4 border-b border-green-100"> | |
| <div> | |
| <h3 class="text-base font-semibold text-green-900">Preview</h3> | |
| <p class="text-xs text-gray-400 mt-0.5">Checking parameters before applying to map</p> | |
| </div> | |
| <button onclick="closePreview()" class="text-gray-400 hover:text-gray-600 p-1"> | |
| <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> | |
| <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="p-5"> | |
| <div id="preview-loading" class="flex flex-col items-center justify-center py-16 gap-3"> | |
| <div class="w-10 h-10 border-4 border-green-200 border-t-green-600 rounded-full animate-spin"></div> | |
| <p class="text-sm text-gray-500">Loading preview…</p> | |
| </div> | |
| <img id="preview-img" class="hidden w-full rounded-xl object-contain max-h-96" alt="Preview image" /> | |
| <div id="preview-url-box" class="hidden mt-3 bg-gray-50 rounded-lg px-3 py-2"> | |
| <p class="text-xs text-gray-400 font-mono break-all" id="preview-url-text"></p> | |
| </div> | |
| </div> | |
| <div class="flex justify-end gap-2 px-5 py-4 border-t border-green-100"> | |
| <button onclick="closePreview()" | |
| class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 rounded-lg hover:bg-gray-100 transition"> | |
| Close | |
| </button> | |
| <button id="apply-from-preview-btn" onclick="applyFromPreview()" | |
| class="hidden px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition font-medium"> | |
| Apply to Map | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ── Main Layout ───────────────────────────────────────────── --> | |
| <div class="flex h-screen"> | |
| <!-- Control Panel --> | |
| <div class="w-80 flex-shrink-0 bg-white border-r border-green-100 flex flex-col shadow-md z-10 overflow-hidden"> | |
| <!-- Header --> | |
| <div class="bg-green-600 px-5 py-4"> | |
| <h1 class="text-white font-bold text-lg leading-tight tracking-tight">Satellite Imagery</h1> | |
| <p class="text-green-200 text-xs mt-0.5 font-medium">Learning Visualization Demo</p> | |
| </div> | |
| <div class="flex-1 overflow-y-auto"> | |
| <div class="p-4 space-y-5"> | |
| <!-- Basemap --> | |
| <div> | |
| <p class="text-xs font-semibold text-green-800 uppercase tracking-wider mb-2">Basemap</p> | |
| <div class="flex gap-2"> | |
| <button id="btn-osm" onclick="setBasemap('osm')" | |
| class="flex-1 py-2 px-3 text-sm rounded-lg border-2 font-medium transition | |
| border-green-500 bg-green-50 text-green-700"> | |
| OSM | |
| </button> | |
| <button id="btn-google" onclick="setBasemap('google')" | |
| class="flex-1 py-2 px-3 text-sm rounded-lg border-2 font-medium transition | |
| border-gray-200 text-gray-500 hover:border-green-300"> | |
| Google Satellite | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Presets --> | |
| <div> | |
| <p class="text-xs font-semibold text-green-800 uppercase tracking-wider mb-2">Presets</p> | |
| <div id="presets-container" class="space-y-1.5"></div> | |
| </div> | |
| <div class="border-t border-green-100"></div> | |
| <!-- Parameters --> | |
| <div class="space-y-3"> | |
| <p class="text-xs font-semibold text-green-800 uppercase tracking-wider">Visualization Parameters</p> | |
| <!-- bidx --> | |
| <div> | |
| <label class="block text-sm text-gray-700 mb-1"> | |
| Band Indices | |
| <span class="text-gray-400 text-xs">(bidx, comma-separated)</span> | |
| </label> | |
| <input id="input-bidx" type="text" placeholder="e.g. 4,3,2" | |
| class="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg | |
| focus:outline-none focus:ring-2 focus:ring-green-300 focus:border-transparent" /> | |
| </div> | |
| <!-- expression --> | |
| <div> | |
| <label class="block text-sm text-gray-700 mb-1"> | |
| Expression | |
| <span class="text-gray-400 text-xs">(optional)</span> | |
| </label> | |
| <input id="input-expression" type="text" placeholder="e.g. (b4-b3)/(b4+b3)" | |
| class="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg | |
| focus:outline-none focus:ring-2 focus:ring-green-300 focus:border-transparent" /> | |
| </div> | |
| <!-- colormap_name --> | |
| <div> | |
| <label class="block text-sm text-gray-700 mb-1"> | |
| Colormap Name | |
| <span class="text-gray-400 text-xs">(optional)</span> | |
| </label> | |
| <input id="input-colormap" type="text" placeholder="e.g. viridis, tarn_r, rdylgn" | |
| class="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg | |
| focus:outline-none focus:ring-2 focus:ring-green-300 focus:border-transparent" /> | |
| </div> | |
| <!-- rescale --> | |
| <div> | |
| <label class="block text-sm text-gray-700 mb-1"> | |
| Rescale | |
| <span class="text-gray-400 text-xs">(one range per line)</span> | |
| </label> | |
| <textarea id="input-rescale" rows="3" | |
| placeholder="0,130 0,180 0,255" | |
| class="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none font-mono | |
| focus:outline-none focus:ring-2 focus:ring-green-300 focus:border-transparent"></textarea> | |
| <p class="text-xs text-gray-400 mt-1">Format: <code class="bg-gray-100 px-1 rounded">min,max</code> — one line per band</p> | |
| </div> | |
| </div> | |
| <!-- Action Buttons --> | |
| <div class="flex gap-2"> | |
| <button onclick="testPreview()" | |
| class="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 text-sm | |
| border-2 border-green-500 text-green-700 bg-white rounded-lg font-medium | |
| hover:bg-green-50 transition"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7 | |
| -1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/> | |
| </svg> | |
| Preview | |
| </button> | |
| <button onclick="applyToMap()" | |
| class="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 text-sm | |
| bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/> | |
| </svg> | |
| Apply | |
| </button> | |
| </div> | |
| <!-- Layer Toggle --> | |
| <div class="flex items-center justify-between"> | |
| <span class="text-sm text-gray-700">Satellite Layer</span> | |
| <label class="relative inline-flex items-center cursor-pointer"> | |
| <input type="checkbox" id="layer-toggle" class="sr-only peer" checked onchange="toggleLayer()"> | |
| <div class="w-11 h-6 bg-gray-200 rounded-full peer | |
| peer-focus:ring-2 peer-focus:ring-green-300 | |
| peer-checked:bg-green-600 | |
| after:content-[''] after:absolute after:top-[2px] after:left-[2px] | |
| after:bg-white after:border-gray-300 after:border after:rounded-full | |
| after:h-5 after:w-5 after:transition-all | |
| peer-checked:after:translate-x-full peer-checked:after:border-white"></div> | |
| </label> | |
| </div> | |
| <!-- Active File Info --> | |
| <div class="bg-green-50 border border-green-100 rounded-lg p-3"> | |
| <p class="text-xs font-semibold text-green-800 mb-1">Active File</p> | |
| <p class="text-xs text-green-700 font-mono break-all leading-relaxed" id="active-file"></p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Map --> | |
| <div id="map" class="flex-1"></div> | |
| </div> | |
| <!-- ── Script ────────────────────────────────────────────────── --> | |
| <script> | |
| // ================================================================ | |
| // PRESETS – edit this JSON to add / modify presets | |
| // ================================================================ | |
| const PRESETS = [ | |
| { | |
| name: "False Color", | |
| description: "NIR – Red – Green composite", | |
| icon: "🌿", | |
| bidx: [4, 3, 2], | |
| expression: "", | |
| colormap_name: "", | |
| rescale: ["0,130", "0,180", "0,255"] | |
| }, | |
| { | |
| name: "True Color", | |
| description: "Natural RGB composite", | |
| icon: "🗺️", | |
| bidx: [3, 2, 1], | |
| expression: "", | |
| colormap_name: "", | |
| rescale: ["30,90", "30,85", "30,70"] | |
| }, | |
| { | |
| name: "NDVI", | |
| description: "Vegetation index (tarn_r colormap)", | |
| icon: "🌱", | |
| bidx: [4, 3], | |
| expression: "(b4-b3)/(b4+b3)", | |
| colormap_name: "tarn_r", | |
| rescale: ["-0.5,0.7"] | |
| } | |
| ]; | |
| // ================================================================ | |
| // CONFIG | |
| // ================================================================ | |
| const COG_URL = "s2.tif"; | |
| const TITILER_BASE = "https://thanthamky-titiler.hf.space"; | |
| const TILE_BASE = `${TITILER_BASE}/cog/tiles/WebMercatorQuad/{z}/{x}/{y}`; | |
| const PREVIEW_BASE = `${TITILER_BASE}/cog/preview`; | |
| // ================================================================ | |
| // MAP INIT | |
| // ================================================================ | |
| const map = L.map("map", { center: [13.13, 101.374], zoom: 12 }); | |
| // Dedicated pane keeps the satellite layer permanently above any basemap | |
| map.createPane("satPane"); | |
| map.getPane("satPane").style.zIndex = 450; // above tilePane (200), below popups (600) | |
| const baseLayers = { | |
| osm: L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { | |
| attribution: "© OpenStreetMap contributors", | |
| maxZoom: 19 | |
| }), | |
| google: L.tileLayer("https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}", { | |
| attribution: "© Google", | |
| maxZoom: 21 | |
| }) | |
| }; | |
| baseLayers.osm.addTo(map); | |
| let activeBase = "osm"; | |
| let satLayer = null; | |
| // ================================================================ | |
| // PARAMS | |
| // ================================================================ | |
| function readForm() { | |
| const bidxRaw = document.getElementById("input-bidx").value.trim(); | |
| const expression = document.getElementById("input-expression").value.trim(); | |
| const colormap = document.getElementById("input-colormap").value.trim(); | |
| const rescaleRaw = document.getElementById("input-rescale").value.trim(); | |
| const bidx = bidxRaw ? bidxRaw.split(",").map(s => s.trim()).filter(Boolean) : []; | |
| const rescale = rescaleRaw ? rescaleRaw.split("\n").map(s => s.trim()).filter(Boolean) : []; | |
| return { bidx, expression, colormap, rescale }; | |
| } | |
| function validate({ bidx, expression }) { | |
| if (bidx.length === 0 && !expression) | |
| return "Specify at least one band index (bidx) or an expression."; | |
| for (const b of bidx) { | |
| if (!/^\d+$/.test(b) || parseInt(b) < 1) | |
| return `Invalid band index: "${b}" — must be a positive integer.`; | |
| } | |
| return null; | |
| } | |
| function validateRescale(rescale) { | |
| for (const r of rescale) { | |
| const parts = r.split(","); | |
| if (parts.length !== 2 || isNaN(parseFloat(parts[0])) || isNaN(parseFloat(parts[1]))) | |
| return `Invalid rescale: "${r}" — format must be min,max (e.g. 0,255).`; | |
| } | |
| return null; | |
| } | |
| function buildParams({ bidx, expression, colormap, rescale }, forPreview = false) { | |
| const p = []; | |
| p.push("url=" + encodeURIComponent(COG_URL)); | |
| bidx.forEach(b => p.push("bidx=" + encodeURIComponent(b))); | |
| if (expression) p.push("expression=" + encodeURIComponent(expression)); | |
| if (colormap) p.push("colormap_name=" + encodeURIComponent(colormap)); | |
| rescale.forEach(r => p.push("rescale=" + encodeURIComponent(r))); | |
| if (forPreview) { | |
| p.push("format=png"); | |
| p.push("max_size=1024"); | |
| } else { | |
| p.push("f=json"); | |
| } | |
| return p.join("&"); | |
| } | |
| // ================================================================ | |
| // APPLY TO MAP | |
| // ================================================================ | |
| function applyToMap() { | |
| const form = readForm(); | |
| const err = validate(form) || validateRescale(form.rescale); | |
| if (err) { showAlert("Invalid Parameters", err); return; } | |
| const qs = buildParams(form); | |
| const tileUrl = `${TILE_BASE}?${qs}`; | |
| replaceSatLayer(tileUrl); | |
| hideAlert(); | |
| } | |
| function replaceSatLayer(tileUrl) { | |
| if (satLayer) map.removeLayer(satLayer); | |
| satLayer = L.tileLayer(tileUrl, { | |
| pane: "satPane", | |
| opacity: 1, | |
| maxZoom: 21, | |
| tileSize: 256, | |
| attribution: "© Copernicus / ESA" | |
| }); | |
| satLayer.on("tileerror", () => { | |
| showAlert("Tile Load Error", | |
| "One or more tiles failed to load. Use Preview to test parameters first."); | |
| }); | |
| if (document.getElementById("layer-toggle").checked) { | |
| satLayer.addTo(map); | |
| } | |
| } | |
| function toggleLayer() { | |
| if (!satLayer) return; | |
| document.getElementById("layer-toggle").checked | |
| ? satLayer.addTo(map) | |
| : map.removeLayer(satLayer); | |
| } | |
| // ================================================================ | |
| // PREVIEW | |
| // ================================================================ | |
| async function testPreview() { | |
| const form = readForm(); | |
| const err = validate(form) || validateRescale(form.rescale); | |
| if (err) { showAlert("Invalid Parameters", err); return; } | |
| const qs = buildParams(form, true); | |
| const previewUrl = `${PREVIEW_BASE}?${qs}`; | |
| // Show modal in loading state | |
| const modal = document.getElementById("preview-modal"); | |
| const loading = document.getElementById("preview-loading"); | |
| const img = document.getElementById("preview-img"); | |
| const applyBtn = document.getElementById("apply-from-preview-btn"); | |
| const urlBox = document.getElementById("preview-url-box"); | |
| const urlText = document.getElementById("preview-url-text"); | |
| modal.classList.remove("hidden"); | |
| loading.classList.remove("hidden"); | |
| img.classList.add("hidden"); | |
| applyBtn.classList.add("hidden"); | |
| urlBox.classList.add("hidden"); | |
| urlText.textContent = previewUrl; | |
| try { | |
| const res = await fetch(previewUrl); | |
| loading.classList.add("hidden"); | |
| if (!res.ok) { | |
| let detail = `HTTP ${res.status}`; | |
| try { | |
| const body = await res.json(); | |
| if (body.detail !== undefined) { | |
| detail = typeof body.detail === "string" | |
| ? body.detail | |
| : JSON.stringify(body.detail, null, 2); | |
| } | |
| } catch {} | |
| closePreview(); | |
| showAlert(`Preview Failed (${res.status})`, detail); | |
| return; | |
| } | |
| const blob = await res.blob(); | |
| const objUrl = URL.createObjectURL(blob); | |
| img.onload = () => { img.classList.remove("hidden"); }; | |
| img.src = objUrl; | |
| applyBtn.classList.remove("hidden"); | |
| urlBox.classList.remove("hidden"); | |
| } catch (e) { | |
| loading.classList.add("hidden"); | |
| closePreview(); | |
| showAlert("Network Error", `Cannot reach server: ${e.message}`); | |
| } | |
| } | |
| function applyFromPreview() { | |
| closePreview(); | |
| applyToMap(); | |
| } | |
| function closePreview() { | |
| document.getElementById("preview-modal").classList.add("hidden"); | |
| const img = document.getElementById("preview-img"); | |
| if (img.src && img.src.startsWith("blob:")) URL.revokeObjectURL(img.src); | |
| img.src = ""; | |
| } | |
| // ================================================================ | |
| // BASEMAP | |
| // ================================================================ | |
| function setBasemap(type) { | |
| map.removeLayer(baseLayers[activeBase]); | |
| baseLayers[type].addTo(map); // always added to default tilePane (z-index 200) | |
| activeBase = type; | |
| // satPane (z-index 450) stays above automatically — no re-add needed | |
| const active = "flex-1 py-2 px-3 text-sm rounded-lg border-2 font-medium transition border-green-500 bg-green-50 text-green-700"; | |
| const passive = "flex-1 py-2 px-3 text-sm rounded-lg border-2 font-medium transition border-gray-200 text-gray-500 hover:border-green-300"; | |
| document.getElementById("btn-osm").className = type === "osm" ? active : passive; | |
| document.getElementById("btn-google").className = type === "google" ? active : passive; | |
| } | |
| // ================================================================ | |
| // PRESETS UI | |
| // ================================================================ | |
| function buildPresetUI() { | |
| const container = document.getElementById("presets-container"); | |
| PRESETS.forEach(preset => { | |
| const btn = document.createElement("button"); | |
| btn.className = | |
| "w-full flex items-center gap-3 px-3 py-2.5 rounded-xl border-2 border-gray-100 " + | |
| "hover:border-green-300 hover:bg-green-50 transition text-left group"; | |
| btn.innerHTML = ` | |
| <span class="text-xl leading-none flex-shrink-0">${preset.icon}</span> | |
| <div class="flex-1 min-w-0"> | |
| <p class="text-sm font-semibold text-gray-800 group-hover:text-green-800 leading-tight">${preset.name}</p> | |
| <p class="text-xs text-gray-400 leading-tight mt-0.5 truncate">${preset.description}</p> | |
| </div> | |
| <svg class="w-4 h-4 text-gray-300 group-hover:text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> | |
| </svg>`; | |
| btn.addEventListener("click", () => loadPreset(preset)); | |
| container.appendChild(btn); | |
| }); | |
| } | |
| function loadPreset(preset) { | |
| document.getElementById("input-bidx").value = preset.bidx.join(","); | |
| document.getElementById("input-expression").value = preset.expression || ""; | |
| document.getElementById("input-colormap").value = preset.colormap_name || ""; | |
| document.getElementById("input-rescale").value = preset.rescale.join("\n"); | |
| hideAlert(); | |
| } | |
| // ================================================================ | |
| // ALERTS | |
| // ================================================================ | |
| let alertTimer = null; | |
| function showAlert(title, msg) { | |
| document.getElementById("alert-title").textContent = title; | |
| document.getElementById("alert-msg").textContent = msg; | |
| document.getElementById("alert-bar").classList.remove("hidden"); | |
| clearTimeout(alertTimer); | |
| alertTimer = setTimeout(hideAlert, 8000); | |
| } | |
| function hideAlert() { | |
| document.getElementById("alert-bar").classList.add("hidden"); | |
| } | |
| // ================================================================ | |
| // INIT | |
| // ================================================================ | |
| buildPresetUI(); | |
| document.getElementById("active-file").textContent = COG_URL.split("/").pop(); | |
| // Load False Color preset by default | |
| loadPreset(PRESETS[0]); | |
| applyToMap(); | |
| </script> | |
| </body> | |
| </html> | |