sat-learn / index.html
thanthamky's picture
Upload index.html
dc33a73 verified
<!DOCTYPE html>
<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&#10;0,180&#10;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>