Added world map to webapp.
This commit is contained in:
parent
5389776c8a
commit
4f839d32b6
1 changed files with 200 additions and 1 deletions
201
index.html
201
index.html
|
|
@ -4,7 +4,10 @@
|
|||
<meta charset="utf-8">
|
||||
<title>EFAST PhenoCam analysis</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/geotiff@2.0.7/dist-browser/geotiff.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/proj4@2.9.0/dist/proj4.js"></script>
|
||||
<script src="common.js"></script>
|
||||
|
|
@ -111,6 +114,41 @@ body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f5f5f5; col
|
|||
.inspector-table { width: 100%; border-collapse: collapse; font-size: 11px; font-family: monospace; }
|
||||
.inspector-table th, .inspector-table td { padding: 2px 8px; text-align: right; border-bottom: 1px solid #f0f0f0; }
|
||||
.inspector-table th:first-child, .inspector-table td:first-child { text-align: left; }
|
||||
|
||||
/* toolbar button */
|
||||
#worldMapBtn {
|
||||
font-size: 13px; padding: 4px 10px; border-radius: 4px;
|
||||
border: 1px solid #4a6fa5; background: #2a3f5f; color: #dceeff;
|
||||
cursor: pointer;
|
||||
}
|
||||
#worldMapBtn:hover { background: #3a5278; }
|
||||
|
||||
/* worldwide map overlay */
|
||||
#worldOverlay {
|
||||
display: none; position: fixed; inset: 0; z-index: 3000;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
#worldOverlay.open { display: flex; align-items: stretch; justify-content: center; }
|
||||
#worldPanel {
|
||||
flex: 1; margin: 12px; max-width: 1400px; display: flex; flex-direction: column;
|
||||
background: #fff; border-radius: 6px; overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
#worldHeader {
|
||||
display: flex; align-items: center; gap: 12px; padding: 10px 14px;
|
||||
background: #1a1a2e; color: #eee; flex-shrink: 0;
|
||||
}
|
||||
#worldHeader h2 { margin: 0; font-size: 14px; font-weight: 600; color: #7eb8f7; flex: 1; }
|
||||
#worldHeader .world-meta { font-size: 12px; color: #aaa; }
|
||||
#worldClose {
|
||||
font-size: 13px; padding: 4px 12px; border-radius: 4px;
|
||||
border: 1px solid #666; background: transparent; color: #ddd; cursor: pointer;
|
||||
}
|
||||
#worldClose:hover { background: rgba(255, 255, 255, 0.08); }
|
||||
#worldMap { flex: 1; min-height: 0; }
|
||||
.world-popup { font-size: 12px; line-height: 1.35; }
|
||||
.world-popup b { display: block; margin-bottom: 2px; }
|
||||
.world-popup .veg { color: #2e7d32; font-size: 11px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -118,6 +156,18 @@ body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f5f5f5; col
|
|||
<div id="toolbar">
|
||||
<h1>EFAST PhenoCam analysis</h1>
|
||||
<label>Year <select id="yearSel"></select></label>
|
||||
<button type="button" id="worldMapBtn" title="World map of all sites">World map</button>
|
||||
</div>
|
||||
|
||||
<div id="worldOverlay" aria-hidden="true">
|
||||
<div id="worldPanel">
|
||||
<div id="worldHeader">
|
||||
<h2>Worldwide sites</h2>
|
||||
<span class="world-meta" id="worldMeta"></span>
|
||||
<button type="button" id="worldClose">Close</button>
|
||||
</div>
|
||||
<div id="worldMap"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="layout">
|
||||
|
|
@ -207,6 +257,8 @@ let manifest = null, currentYear = null, currentSite = null;
|
|||
let ts = null, rasters = null;
|
||||
let fusionMode = "bti"; // bti | itb
|
||||
let miniMapInst = null, miniMarker = null;
|
||||
let worldMapInst = null, worldCluster = null;
|
||||
let worldOverlayOpen = false;
|
||||
const maps3 = {}; // { s2, fusion, s3 } Leaflet instances
|
||||
const overlays3 = {}; // current ImageOverlay per map
|
||||
const markers3 = {}; // site dot markers per map
|
||||
|
|
@ -454,7 +506,21 @@ async function init() {
|
|||
currentYear = years[years.length - 1] || null;
|
||||
if (currentYear) yearSel.value = currentYear;
|
||||
|
||||
yearSel.addEventListener("change", () => { currentYear = +yearSel.value; buildSiteList(); });
|
||||
yearSel.addEventListener("change", () => {
|
||||
currentYear = +yearSel.value;
|
||||
buildSiteList();
|
||||
if (worldOverlayOpen) buildWorldMap();
|
||||
});
|
||||
|
||||
qs("#worldMapBtn").addEventListener("click", () => openWorldOverlay());
|
||||
qs("#worldClose").addEventListener("click", () => closeWorldOverlay());
|
||||
qs("#worldOverlay").addEventListener("click", e => {
|
||||
if (e.target === qs("#worldOverlay")) closeWorldOverlay();
|
||||
});
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.key === "Escape" && worldOverlayOpen) closeWorldOverlay();
|
||||
});
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
|
||||
// global date slider
|
||||
qs("#dateSlider").addEventListener("input", onDateSlider);
|
||||
|
|
@ -471,6 +537,132 @@ async function init() {
|
|||
);
|
||||
|
||||
buildSiteList();
|
||||
onHashChange();
|
||||
}
|
||||
|
||||
// ── hash routing (#worldwide, #2025/sitename) ──
|
||||
function parseHash() {
|
||||
const raw = location.hash.replace(/^#/, "").trim();
|
||||
if (!raw) return { view: null, year: null, site: null };
|
||||
if (raw === "worldwide") return { view: "worldwide", year: null, site: null };
|
||||
const parts = raw.split("/");
|
||||
if (parts.length === 2 && /^\d{4}$/.test(parts[0]))
|
||||
return { view: "site", year: +parts[0], site: decodeURIComponent(parts[1]) };
|
||||
return { view: null, year: null, site: null };
|
||||
}
|
||||
|
||||
function setHash(view, year, site) {
|
||||
let hash = "";
|
||||
if (view === "worldwide") hash = "worldwide";
|
||||
else if (view === "site" && year && site)
|
||||
hash = `${year}/${encodeURIComponent(site)}`;
|
||||
const next = hash ? `#${hash}` : "";
|
||||
if (location.hash !== next) history.replaceState(null, "", location.pathname + location.search + next);
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
const { view, year, site } = parseHash();
|
||||
if (view === "worldwide") {
|
||||
openWorldOverlay(false);
|
||||
return;
|
||||
}
|
||||
if (view === "site" && year && site && manifest?.sites?.[year]?.[site]?.has_fusion) {
|
||||
closeWorldOverlay(false);
|
||||
if (year !== currentYear) {
|
||||
currentYear = year;
|
||||
qs("#yearSel").value = currentYear;
|
||||
buildSiteList();
|
||||
} else {
|
||||
selectSite(site);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (worldOverlayOpen) closeWorldOverlay(false);
|
||||
}
|
||||
|
||||
// ── worldwide map overlay ──
|
||||
function openWorldOverlay(updateHash = true) {
|
||||
if (!manifest) return;
|
||||
worldOverlayOpen = true;
|
||||
const overlay = qs("#worldOverlay");
|
||||
overlay.classList.add("open");
|
||||
overlay.setAttribute("aria-hidden", "false");
|
||||
if (updateHash) setHash("worldwide");
|
||||
buildWorldMap();
|
||||
requestAnimationFrame(() => worldMapInst?.invalidateSize());
|
||||
}
|
||||
|
||||
function closeWorldOverlay(updateHash = true) {
|
||||
worldOverlayOpen = false;
|
||||
const overlay = qs("#worldOverlay");
|
||||
overlay.classList.remove("open");
|
||||
overlay.setAttribute("aria-hidden", "true");
|
||||
if (updateHash && parseHash().view === "worldwide") {
|
||||
if (currentSite) setHash("site", currentYear, currentSite);
|
||||
else history.replaceState(null, "", location.pathname + location.search);
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorldMap() {
|
||||
if (!worldMapInst) {
|
||||
worldMapInst = L.map("worldMap", { zoomControl: true, attributionControl: false })
|
||||
.addLayer(L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"));
|
||||
worldCluster = L.markerClusterGroup({
|
||||
maxClusterRadius: 40,
|
||||
spiderfyOnMaxZoom: true,
|
||||
showCoverageOnHover: false,
|
||||
});
|
||||
worldMapInst.addLayer(worldCluster);
|
||||
}
|
||||
|
||||
worldCluster.clearLayers();
|
||||
const sites = manifest.sites[currentYear] || {};
|
||||
const entries = Object.entries(sites)
|
||||
.filter(([, m]) => m.has_fusion && m.lat != null && m.lon != null);
|
||||
|
||||
qs("#worldMeta").textContent =
|
||||
`${entries.length} fusion site${entries.length === 1 ? "" : "s"} · ${currentYear}`;
|
||||
|
||||
const bounds = [];
|
||||
for (const [site, meta] of entries) {
|
||||
const lat = meta.lat, lon = meta.lon;
|
||||
bounds.push([lat, lon]);
|
||||
const isActive = site === currentSite;
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: isActive ? 8 : 6,
|
||||
color: isActive ? "#fff" : "#1a1a2e",
|
||||
weight: isActive ? 2.5 : 1.5,
|
||||
fillColor: isActive ? "#7eb8f7" : "#e53935",
|
||||
fillOpacity: 0.95,
|
||||
});
|
||||
const label = meta.description || site;
|
||||
marker.bindPopup(
|
||||
`<div class="world-popup"><b>${label}</b>` +
|
||||
`<span>${site}</span><br>` +
|
||||
`<span class="veg">${meta.veg_label || meta.veg_type || ""}</span></div>`,
|
||||
{ closeButton: false }
|
||||
);
|
||||
marker.on("click", () => pickSiteFromWorldMap(site));
|
||||
worldCluster.addLayer(marker);
|
||||
}
|
||||
|
||||
if (bounds.length) {
|
||||
worldMapInst.fitBounds(bounds, { padding: [40, 40], maxZoom: 10 });
|
||||
if (currentSite) {
|
||||
const m = sites[currentSite];
|
||||
if (m?.lat != null) worldMapInst.setView([m.lat, m.lon], Math.max(worldMapInst.getZoom(), 6));
|
||||
}
|
||||
} else {
|
||||
worldMapInst.setView([20, 0], 2);
|
||||
}
|
||||
}
|
||||
|
||||
function pickSiteFromWorldMap(site) {
|
||||
selectSite(site);
|
||||
setHash("site", currentYear, site);
|
||||
closeWorldOverlay(false);
|
||||
const li = document.querySelector(`#siteList li[data-site="${CSS.escape(site)}"]`);
|
||||
li?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
// ── date helpers ──
|
||||
|
|
@ -507,6 +699,12 @@ function buildSiteList() {
|
|||
li.addEventListener("click", () => selectSite(site));
|
||||
list.appendChild(li);
|
||||
}
|
||||
const h = parseHash();
|
||||
if (h.view === "worldwide") return;
|
||||
if (h.view === "site" && h.year === currentYear && sites[h.site]?.has_fusion) {
|
||||
selectSite(h.site);
|
||||
return;
|
||||
}
|
||||
const keep = currentSite && sites[currentSite]?.has_fusion;
|
||||
selectSite(keep ? currentSite : entries[0][0]);
|
||||
}
|
||||
|
|
@ -516,6 +714,7 @@ async function selectSite(site) {
|
|||
currentSite = site;
|
||||
document.querySelectorAll("#siteList li").forEach(li =>
|
||||
li.classList.toggle("active", li.dataset.site === site));
|
||||
if (worldOverlayOpen) buildWorldMap();
|
||||
const meta = manifest.sites[currentYear][site];
|
||||
|
||||
qs("#empty").style.display = "none";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue