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">
|
<meta charset="utf-8">
|
||||||
<title>EFAST PhenoCam analysis</title>
|
<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@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@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/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="https://cdn.jsdelivr.net/npm/proj4@2.9.0/dist/proj4.js"></script>
|
||||||
<script src="common.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 { 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, .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; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -118,6 +156,18 @@ body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f5f5f5; col
|
||||||
<div id="toolbar">
|
<div id="toolbar">
|
||||||
<h1>EFAST PhenoCam analysis</h1>
|
<h1>EFAST PhenoCam analysis</h1>
|
||||||
<label>Year <select id="yearSel"></select></label>
|
<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>
|
||||||
|
|
||||||
<div id="layout">
|
<div id="layout">
|
||||||
|
|
@ -207,6 +257,8 @@ let manifest = null, currentYear = null, currentSite = null;
|
||||||
let ts = null, rasters = null;
|
let ts = null, rasters = null;
|
||||||
let fusionMode = "bti"; // bti | itb
|
let fusionMode = "bti"; // bti | itb
|
||||||
let miniMapInst = null, miniMarker = null;
|
let miniMapInst = null, miniMarker = null;
|
||||||
|
let worldMapInst = null, worldCluster = null;
|
||||||
|
let worldOverlayOpen = false;
|
||||||
const maps3 = {}; // { s2, fusion, s3 } Leaflet instances
|
const maps3 = {}; // { s2, fusion, s3 } Leaflet instances
|
||||||
const overlays3 = {}; // current ImageOverlay per map
|
const overlays3 = {}; // current ImageOverlay per map
|
||||||
const markers3 = {}; // site dot markers per map
|
const markers3 = {}; // site dot markers per map
|
||||||
|
|
@ -454,7 +506,21 @@ async function init() {
|
||||||
currentYear = years[years.length - 1] || null;
|
currentYear = years[years.length - 1] || null;
|
||||||
if (currentYear) yearSel.value = currentYear;
|
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
|
// global date slider
|
||||||
qs("#dateSlider").addEventListener("input", onDateSlider);
|
qs("#dateSlider").addEventListener("input", onDateSlider);
|
||||||
|
|
@ -471,6 +537,132 @@ async function init() {
|
||||||
);
|
);
|
||||||
|
|
||||||
buildSiteList();
|
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 ──
|
// ── date helpers ──
|
||||||
|
|
@ -507,6 +699,12 @@ function buildSiteList() {
|
||||||
li.addEventListener("click", () => selectSite(site));
|
li.addEventListener("click", () => selectSite(site));
|
||||||
list.appendChild(li);
|
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;
|
const keep = currentSite && sites[currentSite]?.has_fusion;
|
||||||
selectSite(keep ? currentSite : entries[0][0]);
|
selectSite(keep ? currentSite : entries[0][0]);
|
||||||
}
|
}
|
||||||
|
|
@ -516,6 +714,7 @@ async function selectSite(site) {
|
||||||
currentSite = site;
|
currentSite = site;
|
||||||
document.querySelectorAll("#siteList li").forEach(li =>
|
document.querySelectorAll("#siteList li").forEach(li =>
|
||||||
li.classList.toggle("active", li.dataset.site === site));
|
li.classList.toggle("active", li.dataset.site === site));
|
||||||
|
if (worldOverlayOpen) buildWorldMap();
|
||||||
const meta = manifest.sites[currentYear][site];
|
const meta = manifest.sites[currentYear][site];
|
||||||
|
|
||||||
qs("#empty").style.display = "none";
|
qs("#empty").style.display = "none";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue