hydrological_cycle / wasa-map.html
ZoloKiala
Deploy static hydrological-cycle visualisation to HF Spaces
516783a
Raw
History Blame Contribute Delete
45.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>WASA on a real landscape β€” Malawi (CesiumJS)</title>
<link rel="stylesheet" href="style.css" />
<!--
CesiumJS is loaded from its official CDN. CESIUM_BASE_URL must be set
BEFORE Cesium.js so the library can locate its workers, shaders, and
assets at the same origin.
-->
<script>
window.CESIUM_BASE_URL = 'https://cesium.com/downloads/cesiumjs/releases/1.118/Build/Cesium/';
</script>
<link rel="stylesheet" href="https://cesium.com/downloads/cesiumjs/releases/1.118/Build/Cesium/Widgets/widgets.css" />
<script src="https://cesium.com/downloads/cesiumjs/releases/1.118/Build/Cesium/Cesium.js"></script>
<!-- Mapillary JS β€” free, open-source alternative to Google Street View.
The viewer is rendered inside the #mly-container modal when the user
clicks "Street View" on a card. Needs an access token from
mapillary.com/dashboard/developers (free, no billing). -->
<link rel="stylesheet" href="https://unpkg.com/mapillary-js@4.1.2/dist/mapillary.css" />
<script src="https://unpkg.com/mapillary-js@4.1.2/dist/mapillary.js"></script>
<style>
html, body { height: 100%; margin: 0; background: #0a1820; color: #e6f0f7;
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; overflow: hidden; }
#cesiumContainer { position: fixed; inset: 0; }
/* Cesium prints a small attribution bar / credit container in the
bottom-left; nudge it so it doesn't sit under the site badge. */
.cesium-viewer-bottom { left: 16px !important; bottom: 100px !important; }
#brand-bar { z-index: 4; }
#site-toggle { position: fixed; top: 14px; right: 16px; z-index: 5;
padding: 10px 16px; border-radius: 24px; border: 1px solid rgba(79,195,247,0.55);
background: rgba(79, 195, 247, 0.18); color: #fff; cursor: pointer;
font-size: 14px; font-weight: 600; letter-spacing: 0.02em;
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
text-decoration: none; display: inline-flex; align-items: center; gap: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,0.35); }
#site-toggle:hover { background: rgba(79, 195, 247, 0.32); }
/* Mirror link inside the WASA panel for users who don't notice the top
button. Styled like the other cards so it sits cleanly above them. */
#panel-3d-link { display: flex; align-items: center; justify-content: center;
gap: 8px; padding: 10px 12px; margin-bottom: 14px; border-radius: 10px;
border: 1px solid rgba(79,195,247,0.5); background: rgba(79,195,247,0.15);
color: #fff; text-decoration: none; font-weight: 600; font-size: 13px; }
#panel-3d-link:hover { background: rgba(79,195,247,0.28); }
/* On-screen camera nav cluster removed β€” Cesium's native mouse/touch
controls cover the same functionality. The north arrow stays as a
lightweight orientation cue. */
#north-arrow { position: fixed; top: 80px; left: 16px; z-index: 4;
width: 40px; height: 40px; border-radius: 50%;
background: rgba(7,28,41,0.78); border: 1px solid rgba(255,255,255,0.18);
cursor: pointer; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); padding: 0; }
#north-arrow svg { width: 32px; height: 32px; transition: transform 0.18s ease-out; }
#north-arrow:hover { background: rgba(7,28,41,0.95); }
@media (max-width: 720px) {
#north-arrow { top: 70px; left: 8px; }
}
/* ---------- WALK MODE (first-person) ---------- */
/* Hide other chrome so the FPS view is uncluttered. */
body.walk-mode #map-panel,
body.walk-mode #site-toggle,
body.walk-mode #brand-bar,
body.walk-mode #site-badge,
body.walk-mode #token-notice,
body.walk-mode #map-panel-show { display: none !important; }
body.walk-mode .cesium-infoBox { display: none !important; }
#walk-overlay { display: none; }
body.walk-mode #walk-overlay { display: block; }
.walk-reticle { position: fixed; top: 50%; left: 50%; width: 12px; height: 12px;
margin: -6px 0 0 -6px; border: 2px solid rgba(255,255,255,0.65);
border-radius: 50%; pointer-events: none; z-index: 6;
box-shadow: 0 0 0 1px rgba(0,0,0,0.5); }
.walk-hud { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
z-index: 7; display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
padding: 10px 16px; background: rgba(7,28,41,0.88); border-radius: 24px;
border: 1px solid rgba(255,255,255,0.2); color: #fff; font-size: 13px;
backdrop-filter: blur(6px); }
.walk-hint kbd { display: inline-block; padding: 2px 6px; margin: 0 1px;
background: rgba(255,255,255,0.12); border-radius: 4px;
border: 1px solid rgba(255,255,255,0.22); font-family: inherit;
font-size: 11px; line-height: 1; }
#walk-exit-btn { padding: 6px 14px; border-radius: 16px;
border: 1px solid #ff7043; background: rgba(255,112,67,0.20);
color: #fff; cursor: pointer; font-size: 12px; font-weight: 600; }
#walk-exit-btn:hover { background: rgba(255,112,67,0.34); }
.walk-title { position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
z-index: 7; padding: 8px 16px; border-radius: 20px;
background: rgba(7,28,41,0.88); color: #ffd54f; font-size: 13px;
border: 1px solid rgba(255,213,79,0.4); backdrop-filter: blur(6px); }
#site-badge { position: fixed; bottom: 16px; left: 16px; z-index: 4;
max-width: 380px; padding: 10px 14px; border-radius: 10px;
background: rgba(7, 28, 41, 0.82); color: #cfe4f1;
font-size: 12px; line-height: 1.45; backdrop-filter: blur(6px); }
#site-badge strong { color: #fff; font-size: 13px; }
#site-badge em { color: #ffd54f; font-style: normal; }
/* Token notice lives in the bottom-left column ABOVE the site badge so
it never overlaps the InfoBox (which is in the top-left column). */
#token-notice { position: fixed; bottom: 110px; left: 16px; z-index: 4;
max-width: 360px; padding: 10px 14px 10px 14px; border-radius: 10px;
background: rgba(7, 28, 41, 0.92); color: #ffd54f;
font-size: 12px; line-height: 1.45;
border: 1px solid rgba(255, 213, 79, 0.4); display: none; }
#token-notice a { color: #4fc3f7; }
#token-notice-close { position: absolute; top: 4px; right: 6px;
width: 22px; height: 22px; border-radius: 50%; border: 0;
background: transparent; color: #ffd54f; cursor: pointer;
font-size: 16px; line-height: 1; padding: 0; opacity: 0.8; }
#token-notice-close:hover { opacity: 1; background: rgba(255,213,79,0.18); }
#map-panel { position: fixed; top: 80px; right: 16px; bottom: 16px; width: 360px;
z-index: 3; padding: 16px; overflow-y: auto;
background: rgba(7, 28, 41, 0.88); color: #e6f0f7;
border: 1px solid rgba(255,255,255,0.12); border-radius: 14px;
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
box-shadow: 0 12px 40px rgba(0,0,0,0.45);
transition: transform 0.25s ease, opacity 0.25s ease; }
#map-panel.panel-hidden { transform: translateX(calc(100% + 32px)); opacity: 0; pointer-events: none; }
#map-panel-close { position: absolute; top: 10px; right: 10px;
width: 26px; height: 26px; border-radius: 50%; border: 0;
background: rgba(255,255,255,0.08); color: #fff; cursor: pointer;
font-size: 16px; line-height: 1; padding: 0; }
#map-panel-close:hover { background: rgba(255,255,255,0.18); }
/* Floating "Show panel" toggle that appears when the panel is hidden. */
#map-panel-show { position: fixed; top: 80px; right: 16px; z-index: 4;
padding: 10px 14px; border-radius: 24px; border: 1px solid rgba(255,255,255,0.4);
background: rgba(7, 28, 41, 0.88); color: #fff; cursor: pointer;
font-size: 13px; font-weight: 600; letter-spacing: 0.02em;
backdrop-filter: blur(6px); display: none; }
#map-panel-show:hover { background: rgba(7, 28, 41, 0.96); }
body.panel-hidden #map-panel-show { display: inline-block; }
/* When the panel is hidden, the InfoBox can use the full viewport width. */
body.panel-hidden .cesium-infoBox { max-width: calc(100vw - 32px) !important; }
#map-panel h2 { margin: 0 0 4px 0; font-size: 18px; padding-right: 28px; }
#map-panel .eyebrow { display: block; font-size: 11px; color: #9bc7e2;
letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 6px; }
#map-panel .intro { font-size: 13px; line-height: 1.5; color: #cfe4f1;
margin: 4px 0 14px 0; }
.map-card { border: 1px solid rgba(255,255,255,0.1); border-radius: 10px;
padding: 10px 12px; margin-bottom: 10px; background: rgba(255,255,255,0.04); }
.map-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.map-icon { display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 50%; font-weight: 700; font-size: 14px;
color: #0a1820; background: #ffd54f; }
.map-card h3 { margin: 0; font-size: 14px; color: #fff; }
.map-card p { margin: 6px 0; font-size: 12.5px; line-height: 1.45; color: #cfe4f1; }
.map-impact { font-size: 11.5px; color: #ffd54f; margin: 4px 0 8px 0; }
.map-focus-btn { display: inline-block; padding: 6px 12px; border-radius: 6px;
border: 1px solid #4fc3f7; background: rgba(79, 195, 247, 0.16);
color: #fff; cursor: pointer; font-size: 12px; margin-right: 6px; }
.map-focus-btn:hover { background: rgba(79, 195, 247, 0.28); }
.map-walk-btn { display: inline-block; padding: 6px 12px; border-radius: 6px;
border: 1px solid #ffb74d; background: rgba(255, 183, 77, 0.14);
color: #fff; cursor: pointer; font-size: 12px; margin-right: 6px; }
.map-walk-btn:hover { background: rgba(255, 183, 77, 0.26); }
.map-street-btn { display: inline-block; padding: 6px 12px; border-radius: 6px;
border: 1px solid #81c784; background: rgba(129, 199, 132, 0.16);
color: #fff; cursor: pointer; font-size: 12px; margin-right: 6px; }
.map-street-btn:hover { background: rgba(129, 199, 132, 0.28); }
.map-card-img { width: 100%; height: 110px; object-fit: cover;
border-radius: 6px; margin-bottom: 8px; display: block;
background: rgba(255,255,255,0.04); }
/* ---------- SCALE BAR ---------- */
#scale-bar { position: fixed; bottom: 16px; left: 50%;
transform: translateX(-50%); z-index: 4;
padding: 5px 10px; background: rgba(7,28,41,0.78);
color: #fff; font-size: 11px; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.18); pointer-events: none;
backdrop-filter: blur(4px); text-align: center; }
#scale-bar-bar { display: block; height: 4px; background: #fff;
border-radius: 1px; margin-top: 3px; transition: width 0.2s; }
/* ---------- NORTH ARROW ---------- */
/* (north-arrow CSS now lives next to the cam-cluster comment above) */
/* ---------- COORDINATES READOUT ---------- */
/* Live cursor lat/lng readout. Sits ABOVE the scale bar at bottom-center
(the scale bar's width changes with zoom, so they were colliding when
placed side-by-side). */
#cursor-coords { position: fixed; bottom: 48px; left: 50%;
transform: translateX(-50%); z-index: 4;
padding: 4px 10px; background: rgba(7,28,41,0.78);
color: #cfe4f1; font-size: 11px; font-family: ui-monospace, Menlo, Consolas, monospace;
border-radius: 6px; border: 1px solid rgba(255,255,255,0.18);
pointer-events: none; backdrop-filter: blur(4px); white-space: nowrap; }
#cursor-coords[hidden] { display: none; }
@media (max-width: 720px) {
#cursor-coords { display: none; } /* no hover on touch β€” hide on small screens */
}
/* ---------- FLOATING LEGEND (bottom-right, where cam-controls used to be) ---------- */
#map-legend { position: fixed; bottom: 16px; right: 392px; z-index: 4;
padding: 8px 12px; max-width: 240px;
background: rgba(7,28,41,0.78); color: #cfe4f1;
border: 1px solid rgba(255,255,255,0.14); border-radius: 8px;
font-size: 11.5px; backdrop-filter: blur(4px); }
body.panel-hidden #map-legend { right: 16px; }
#map-legend .row { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
#map-legend .swatch { width: 11px; height: 11px; border-radius: 2px; flex-shrink: 0; }
/* ---------- ADD-INTERVENTION WEBFORM ---------- */
#form-open { position: fixed; top: 14px; right: 200px; z-index: 5;
padding: 10px 16px; border-radius: 22px;
border: 1px solid #81c784; background: rgba(129,199,132,0.22);
color: #fff; cursor: pointer; font-size: 13px; font-weight: 600;
letter-spacing: 0.02em; backdrop-filter: blur(6px);
box-shadow: 0 4px 16px rgba(0,0,0,0.35); }
#form-open:hover { background: rgba(129,199,132,0.36); }
#form-overlay { position: fixed; inset: 0; z-index: 60;
background: rgba(0,0,0,0.7); display: flex;
align-items: center; justify-content: center; padding: 20px;
backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
#form-overlay[hidden] { display: none; }
#form-card { width: min(600px, 100%); max-height: 92vh; overflow-y: auto;
background: linear-gradient(180deg, #0a1820 0%, #0e2030 100%);
color: #e6f0f7; border-radius: 16px;
border: 1px solid rgba(255,255,255,0.14); padding: 24px;
box-shadow: 0 24px 80px rgba(0,0,0,0.6); }
#form-card header { display: flex; justify-content: space-between;
align-items: flex-start; margin-bottom: 6px; gap: 12px; }
.form-eyebrow { display: block; font-size: 11px; color: #9bc7e2;
letter-spacing: 0.14em; text-transform: uppercase; margin-bottom: 4px; }
#form-card h2 { margin: 0; font-size: 20px; color: #fff; font-weight: 600; }
#form-card h3 { margin: 0 0 10px 0; font-size: 15px; color: #ffd54f; }
.form-intro { margin: 14px 0 20px 0; padding: 14px 16px;
background: rgba(79,195,247,0.16); color: #e6f0f7;
border: 1px solid rgba(79,195,247,0.45);
border-left: 4px solid #4fc3f7; border-radius: 8px;
font-size: 13.5px; line-height: 1.55;
display: flex; gap: 12px; align-items: flex-start; }
.form-intro::before { content: "β„Ή"; flex-shrink: 0;
width: 26px; height: 26px; border-radius: 50%;
background: #4fc3f7; color: #0a1820;
display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 16px; }
.form-intro span { display: block; }
.form-intro strong { color: #fff; }
#form-close { width: 32px; height: 32px; border-radius: 50%; border: 0;
background: rgba(255,255,255,0.08); color: #fff; cursor: pointer;
font-size: 18px; line-height: 1; padding: 0; flex-shrink: 0; }
#form-close:hover { background: rgba(255,255,255,0.18); }
#wasa-form { display: flex; flex-direction: column; gap: 14px; }
#wasa-form label { display: block; font-size: 11.5px; font-weight: 600;
color: #9bc7e2; letter-spacing: 0.04em;
text-transform: uppercase; }
#wasa-form label > span.req { color: #ff8a65; }
#wasa-form input[type=text], #wasa-form input[type=file],
#wasa-form select, #wasa-form textarea { display: block; width: 100%;
margin-top: 6px; padding: 10px 12px;
background: rgba(255,255,255,0.05); color: #fff;
border: 1px solid rgba(255,255,255,0.16); border-radius: 8px;
font-size: 13.5px; font-family: inherit; box-sizing: border-box;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
text-transform: none; letter-spacing: normal; font-weight: normal; }
#wasa-form input[type=text]:focus, #wasa-form select:focus,
#wasa-form textarea:focus { outline: none;
border-color: #4fc3f7; background: rgba(255,255,255,0.08);
box-shadow: 0 0 0 2px rgba(79,195,247,0.25); }
#wasa-form input[readonly] { background: rgba(255,255,255,0.03);
cursor: default; color: #cfe4f1; font-family: ui-monospace, Menlo, Consolas, monospace; }
#wasa-form textarea { min-height: 70px; resize: vertical; line-height: 1.45; }
#wasa-form input[type=file] { padding: 8px 10px; cursor: pointer; }
#wasa-form .row { display: flex; gap: 8px; align-items: stretch; margin-top: 6px; }
#wasa-form .row > input[type=text] { flex: 1; margin-top: 0; }
#form-gps { padding: 8px 14px; border-radius: 8px;
border: 1px solid #4fc3f7; background: rgba(79,195,247,0.22);
color: #fff; cursor: pointer; font-size: 12px; font-weight: 600;
white-space: nowrap; align-self: stretch;
text-transform: none; letter-spacing: normal; }
#form-gps:hover { background: rgba(79,195,247,0.36); }
#form-gps:disabled { opacity: 0.5; cursor: not-allowed; }
.form-actions { margin-top: 8px; display: flex; gap: 8px; flex-wrap: wrap; }
.form-actions button { padding: 10px 18px; border-radius: 8px;
border: 1px solid #4fc3f7; background: rgba(79,195,247,0.24);
color: #fff; cursor: pointer; font-size: 13px; font-weight: 600;
transition: background 0.15s, transform 0.05s; }
.form-actions button[type="submit"] { background: #4fc3f7; color: #0a1820;
border-color: #4fc3f7; }
.form-actions button[type="submit"]:hover { background: #7ed1f7; }
.form-actions button.secondary { border-color: rgba(255,255,255,0.22);
background: rgba(255,255,255,0.06); }
.form-actions button:hover { background: rgba(79,195,247,0.38); }
.form-actions button.secondary:hover { background: rgba(255,255,255,0.14); }
.form-actions button:active { transform: scale(0.97); }
#form-status { font-size: 12px; min-height: 16px; padding: 4px 0; }
#form-status.ok { color: #81c784; }
#form-status.err { color: #ff8a65; }
#form-result { margin-top: 18px; padding-top: 18px;
border-top: 1px solid rgba(255,255,255,0.12); }
#form-result[hidden] { display: none; }
#form-json { background: rgba(0,0,0,0.45); color: #cfe4f1;
padding: 12px 14px; border-radius: 8px;
font-size: 11.5px; font-family: ui-monospace, Menlo, Consolas, monospace;
max-height: 240px; overflow: auto; white-space: pre-wrap;
word-break: break-all; line-height: 1.55;
border: 1px solid rgba(255,255,255,0.08); }
#photo-count { font-weight: 600; }
@media (max-width: 720px) {
#form-open { right: 116px; padding: 8px 12px; font-size: 12px; }
#form-card { padding: 18px; }
}
/* ---------- LOCATION SEARCH BAR ---------- */
#search-box { position: fixed; top: 80px; left: 50%;
transform: translateX(-50%); z-index: 5;
width: min(420px, calc(100vw - 32px));
display: flex; gap: 6px; align-items: stretch; }
#search-input { flex: 1; padding: 9px 14px; border-radius: 22px;
border: 1px solid rgba(255,255,255,0.22);
background: rgba(7,28,41,0.85); color: #fff;
font-size: 13px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
outline: none; }
#search-input:focus { border-color: #4fc3f7; box-shadow: 0 0 0 2px rgba(79,195,247,0.3); }
#search-input::placeholder { color: rgba(207,228,241,0.55); }
#search-go { padding: 8px 16px; border-radius: 22px;
border: 1px solid #4fc3f7; background: rgba(79,195,247,0.22);
color: #fff; cursor: pointer; font-weight: 600; font-size: 13px;
backdrop-filter: blur(6px); }
#search-go:hover { background: rgba(79,195,247,0.36); }
#search-results { position: absolute; top: 100%; left: 0; right: 0;
margin-top: 6px; padding: 4px 0; list-style: none;
background: rgba(7,28,41,0.94); border: 1px solid rgba(255,255,255,0.18);
border-radius: 10px; backdrop-filter: blur(6px); max-height: 320px;
overflow-y: auto; box-shadow: 0 8px 30px rgba(0,0,0,0.4); }
#search-results[hidden] { display: none; }
#search-results li { padding: 8px 14px; cursor: pointer; font-size: 12.5px;
color: #cfe4f1; line-height: 1.4;
border-bottom: 1px solid rgba(255,255,255,0.04); }
#search-results li:last-child { border-bottom: none; }
#search-results li:hover { background: rgba(79,195,247,0.15); color: #fff; }
#search-results li.search-empty { color: #9bc7e2; cursor: default; font-style: italic; }
@media (max-width: 720px) {
#search-box { top: 70px; }
#map-legend { bottom: 75px; max-width: 200px; padding: 6px 10px; font-size: 11px; }
}
/* ---------- MAPILLARY STREET VIEW MODAL ---------- */
#mly-overlay { position: fixed; inset: 0; z-index: 50; display: flex;
background: #000; }
#mly-overlay[hidden] { display: none; }
#mly-container { flex: 1; width: 100%; height: 100%; background: #000; }
.mly-hud { position: absolute; top: 16px; left: 16px; right: 16px;
display: flex; justify-content: space-between; align-items: center;
z-index: 51; pointer-events: none; }
.mly-hud > * { pointer-events: auto; }
#mly-title { color: #fff; font-weight: 600; padding: 8px 16px;
background: rgba(7,28,41,0.88); border-radius: 20px;
backdrop-filter: blur(6px); font-size: 13px; max-width: 60vw;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#mly-close { padding: 8px 16px; border-radius: 20px;
background: rgba(255,112,67,0.32); color: #fff; border: 1px solid #ff7043;
cursor: pointer; font-size: 13px; font-weight: 600; }
#mly-close:hover { background: rgba(255,112,67,0.5); }
#mly-empty { position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%); z-index: 51; padding: 24px 32px;
background: rgba(7,28,41,0.95); color: #cfe4f1; border-radius: 12px;
border: 1px solid rgba(255,255,255,0.18); text-align: center;
max-width: 480px; font-size: 14px; line-height: 1.5; }
#mly-empty[hidden] { display: none; }
#mly-empty h3 { margin: 0 0 8px 0; color: #ffd54f; font-size: 16px; }
#mly-empty a { color: #4fc3f7; }
/* Mapillary token notice β€” shown when no ?mapillary_token=… in URL. */
#mly-token-notice { position: fixed; bottom: 110px; right: 16px; z-index: 4;
max-width: 340px; padding: 10px 14px; border-radius: 10px;
background: rgba(7,28,41,0.92); color: #81c784; font-size: 12px;
line-height: 1.45; border: 1px solid rgba(129,199,132,0.4); display: none; }
#mly-token-notice a { color: #4fc3f7; }
body.panel-hidden #mly-token-notice { right: 16px; }
.map-legend { margin-top: 8px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.12);
font-size: 12px; color: #cfe4f1; }
.map-legend .row { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
.map-legend .swatch { width: 12px; height: 12px; border-radius: 2px; }
/* Cesium's built-in InfoBox sits top-right by default, which collides
with the WASA side panel. Move it to the top-LEFT below the brand bar,
give it a fixed width that fits next to (not under) the cards panel,
and restyle for the dark theme. */
.cesium-infoBox {
left: 16px !important;
right: auto !important;
top: 96px !important;
width: 340px !important;
max-width: calc(100vw - 392px) !important; /* never reach the right panel */
max-height: calc(100vh - 320px) !important; /* leave room for token notice + site badge below */
background: rgba(7, 28, 41, 0.94) !important;
color: #e6f0f7 !important;
border: 1px solid rgba(255,255,255,0.18) !important;
border-radius: 10px !important;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.cesium-infoBox-title { color: #fff !important; background: rgba(255,213,79,0.18) !important; }
.cesium-infoBox iframe { background: transparent !important; }
/* Static fallback photo inside the Street View modal β€” covers the same
area as the Mapillary viewer container. object-fit:contain so the
photo isn't cropped on weird aspect ratios. */
#mly-static { position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: contain; background: #000; }
#mly-static[hidden] { display: none; }
/* The token notice also lives at top-left; nudge it lower when the
InfoBox is visible so the two don't stack. */
.cesium-infoBox-visible ~ #token-notice { top: auto !important; bottom: 100px !important; }
/* On narrow viewports the side panel becomes a bottom sheet β€” give the
InfoBox back the full width on the left. */
@media (max-width: 720px) {
.cesium-infoBox {
max-width: calc(100vw - 32px) !important;
max-height: 40vh !important;
}
}
@media (max-width: 720px) {
#map-panel { top: auto; bottom: 0; left: 0; right: 0; width: auto;
max-height: 45vh; border-radius: 14px 14px 0 0; }
}
</style>
</head>
<body>
<header id="brand-bar">
<img id="iwmi-logo"
src="https://www.iwmi.org/wp-content/uploads/2020/02/iwmi-logo-full-trans-white-43h.png"
alt="IWMI"
onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'logo-fallback',textContent:'IWMI'}))" />
<div class="brand-text">
<h1>WASA on a real landscape</h1>
<p>CesiumJS 3D globe Β· Water and Soil Accelerator Β· Malawi pilot watershed</p>
</div>
</header>
<button id="form-open" type="button" title="Add a WASA intervention record">+ Add intervention</button>
<!-- Add-WASA-intervention webform (in-page; no backend, no upload). On submit
the form bundles the captured GPS + photo + free-text fields into a
JSON blob and offers it as a download / clipboard copy. -->
<div id="form-overlay" hidden>
<div id="form-card">
<header>
<div>
<span class="form-eyebrow">Water and Soil Accelerator</span>
<h2>Record an intervention site</h2>
</div>
<button id="form-close" type="button" aria-label="Close form">Γ—</button>
</header>
<p class="form-intro">
<span>
Capture a WASA intervention as you visit it. Pick the type, attach at
least three geotagged photos, and tag your GPS position. The record is
saved <strong>in your browser</strong> β€” no upload, no server β€” and
you download the JSON metadata plus the photo files to share or
import into KoBoToolbox later.
</span>
</p>
<!-- (KoBoToolbox XLSForm is tracked in the repo as
wasa-intervention-form.xlsx but is intentionally NOT deployed
to the Pages site.) -->
<form id="wasa-form">
<label>
WASA intervention <span class="req">*</span>
<select name="intervention" required>
<option value="">β€” select β€”</option>
<option value="a_afforestation">A β€” Afforestation &amp; Agroforestry</option>
<option value="b_conservation">B β€” Conservation Agriculture</option>
<option value="c_tied_ridges">C β€” Tied Ridges &amp; Soil Ripping</option>
<option value="d_erosion">D β€” Erosion Control &amp; Riparian Buffers</option>
<option value="e_rainwater">E β€” Rainwater Harvesting &amp; Farm Ponds</option>
<option value="f_green_infra">F β€” Green Infrastructure</option>
<option value="g_governance">G β€” Community Watershed Governance</option>
<option value="h_climate">H β€” Climate Information Services</option>
<option value="other">Other (describe in the Type field below)</option>
</select>
</label>
<label>
Type / sub-category <span class="req">*</span>
<input type="text" name="type" required placeholder="Replants tree cover, Minimum tillage, mulching, Contour bunds, mulch strips, grass tufts…" />
<small style="display:block;margin-top:4px;color:#9bc7e2;text-transform:none;letter-spacing:normal;font-weight:normal">
Examples: <em>Replants tree cover Β· Minimum tillage Β· Mulching Β· Contour bunds Β· Mulch strips Β· Grass tufts Β· Tied ridges Β· Soil ripping Β· Farm pond Β· Check dam Β· Cover crop Β· Live fencing</em>
</small>
</label>
<label>
Description <span class="req">*</span>
<textarea name="description" required placeholder="What was implemented? Size, materials, beneficiaries."></textarea>
</label>
<label>
Location (GPS β€” lat, lon) <span class="req">*</span>
<div class="row">
<input type="text" name="location" id="form-location" readonly required placeholder="click Get GPS" />
<button id="form-gps" type="button">πŸ“ Get GPS</button>
</div>
</label>
<label>
Photos (at least 3, must be geotagged) <span class="req">*</span>
<input type="file" name="photos" accept="image/*" capture="environment" multiple required />
<small style="display:block;margin-top:4px;color:#9bc7e2">
Pick or take at least 3 photos. Use a phone with location services
ON so each photo's EXIF carries GPS. The location field above is
also recorded as a fallback.
</small>
<small id="photo-count" style="display:block;margin-top:2px;color:#9bc7e2"></small>
</label>
<label>
Comment
<textarea name="comment" placeholder="Optional notes β€” issues, follow-ups, contacts, dates"></textarea>
</label>
<div id="form-status"></div>
<div class="form-actions">
<button type="submit">Save record</button>
<button type="button" class="secondary" id="form-reset">Clear</button>
</div>
</form>
<div id="form-result" hidden>
<h3>Record saved</h3>
<pre id="form-json"></pre>
<div class="form-actions">
<button type="button" id="form-copy">Copy JSON</button>
<button type="button" id="form-download-json">Download .json</button>
<button type="button" id="form-download-photo">Download photos</button>
<button type="button" class="secondary" id="form-new">+ New entry</button>
</div>
</div>
</div>
</div>
<a id="site-toggle" href="index.html" title="Open the Three.js water-cycle scene">
<span aria-hidden="true">&larr;</span> Back to 3D scene
</a>
<div id="cesiumContainer"></div>
<!-- Location search (OpenStreetMap Nominatim β€” free, no API key). -->
<div id="search-box">
<input type="search" id="search-input" placeholder="Search a place… (e.g., Lilongwe)" autocomplete="off" />
<button id="search-go" type="button">Go</button>
<ul id="search-results" hidden></ul>
</div>
<!-- Map legend, floated on the map (used to live inside the WASA panel). -->
<div id="map-legend">
<div class="row"><span class="swatch" style="background:#ffd54f"></span>WASA intervention</div>
<div class="row"><span class="swatch" style="background:#4fc3f7"></span>Stream / river</div>
<div class="row"><span class="swatch" style="background:rgba(255,213,79,0.18); border:1px dashed #ffd54f"></span>Watershed boundary</div>
</div>
<!-- Scale bar (updated by cesium-scene.js as the camera moves). -->
<div id="scale-bar">
<span id="scale-bar-label">--</span>
<span id="scale-bar-bar" style="width:0"></span>
</div>
<!-- North arrow β€” rotates with the camera heading; click to face north. -->
<button id="north-arrow" type="button" title="Click to face north" aria-label="Face north">
<svg viewBox="0 0 40 40" aria-hidden="true">
<polygon points="20,4 26,21 20,18 14,21" fill="#ff5252"/>
<polygon points="20,36 14,21 20,24 26,21" fill="#cfe4f1"/>
<text x="20" y="15" text-anchor="middle" font-size="9" font-weight="700" fill="#fff" stroke="#0a1820" stroke-width="0.5">N</text>
</svg>
</button>
<!-- Live lat/lng readout that follows the mouse cursor over the globe. -->
<div id="cursor-coords" hidden>lat β€”, lon β€”</div>
<!-- Mapillary Street View modal. Hidden by default; shown by clicking
the "Street View" button on any WASA card. -->
<div id="mly-overlay" hidden>
<div id="mly-container"></div>
<!-- Static topical photo shown as a fallback when Mapillary has no
relevant imagery for the selected site. -->
<img id="mly-static" alt="" hidden />
<div class="mly-hud">
<span id="mly-title">Street View</span>
<button id="mly-close" type="button">x Close</button>
</div>
<div id="mly-empty" hidden>
<h3>No Street View imagery near here</h3>
<p id="mly-empty-msg">Mapillary is crowd-sourced β€” no photos have been uploaded near this site yet.
Try the "Fly to site" or "Walk here" buttons instead.</p>
</div>
</div>
<!-- Mapillary token notice β€” shown when no token is set, or when the user
clicks Street View without one. Has a "Set token" button that opens
a prompt and saves the token to localStorage (no URL, no git). -->
<div id="mly-token-notice">
<strong>Street View needs a Mapillary token.</strong>
Free at
<a href="https://www.mapillary.com/dashboard/developers" target="_blank" rel="noopener">mapillary.com/dashboard/developers</a>
(no billing). The token is saved to <em>your browser only</em> β€” never to git or the URL.
<div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap">
<button id="mly-token-set" type="button"
style="padding:6px 10px;border-radius:6px;border:1px solid #4fc3f7;
background:rgba(79,195,247,0.18);color:#fff;cursor:pointer;
font-size:12px;font-weight:600">Set token</button>
<button id="mly-token-dismiss" type="button"
style="padding:6px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.22);
background:transparent;color:#cfe4f1;cursor:pointer;font-size:12px">Dismiss</button>
</div>
</div>
<!-- First-person walk-mode HUD. Hidden unless body has class `walk-mode`. -->
<div id="walk-overlay" aria-hidden="true">
<div class="walk-title" id="walk-title">Walking at <span id="walk-title-name">site</span></div>
<div class="walk-reticle"></div>
<div class="walk-hud">
<span class="walk-hint">
<kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> walk &middot;
<kbd>Q</kbd>/<kbd>E</kbd> down/up &middot;
drag mouse to look &middot;
<kbd>Shift</kbd> run
</span>
<button id="walk-exit-btn" type="button">Exit walk mode <kbd>Esc</kbd></button>
</div>
</div>
<div id="token-notice">
<button id="token-notice-close" type="button" aria-label="Dismiss"
onclick="document.getElementById('token-notice').style.display='none'">x</button>
<strong>Running without a Cesium Ion token.</strong>
Globe uses Esri satellite imagery on a flat ellipsoid (no real terrain elevation).
For real hills, get a free token at
<a href="https://ion.cesium.com/signup" target="_blank" rel="noopener">ion.cesium.com</a>
and reload with <code>?token=YOUR_TOKEN</code>.
</div>
<button id="map-panel-show" type="button" title="Show the WASA solutions panel">WASA Solutions</button>
<aside id="map-panel">
<button id="map-panel-close" type="button" aria-label="Hide WASA panel" title="Hide panel">x</button>
<span class="eyebrow">How WASA improves the water cycle</span>
<h2 id="watershed-name">Mudi River pilot watershed</h2>
<p class="intro">
A <em>fictional</em> 3&nbsp;km&times;2&nbsp;km demonstration watershed around Blantyre,
southern Malawi. Each marker is one of the eight WASA interventions on a real 3D globe.
Click <strong>Street&nbsp;View</strong> on any card to drop into real Mapillary photos at that spot.
</p>
<a id="panel-3d-link" href="index.html"
title="Compare this satellite view with the stylised Three.js scene">
<span aria-hidden="true">&larr;</span> Open the 3D water-cycle scene
</a>
<div id="map-cards"></div>
<!-- The legend used to live here. Moved to a floating widget on the
map (#map-legend) so it's visible even when the panel is hidden. -->
<div id="mly-token-status" style="margin-top:10px;padding-top:10px;
border-top:1px solid rgba(255,255,255,0.12);font-size:12px;color:#9bc7e2">
<span>Street View token: <span id="mly-token-state">not set</span></span>
<a href="#" id="mly-token-change" style="color:#4fc3f7;margin-left:8px;text-decoration:underline;cursor:pointer">change</a>
<a href="#" id="mly-token-clear" style="color:#ff7043;margin-left:8px;text-decoration:underline;cursor:pointer;display:none">clear</a>
</div>
</aside>
<!-- Cam-controls + north arrow removed β€” Cesium's native mouse/touch
controls (drag = pan, right-drag = tilt, scroll = zoom) cover this. -->
<div id="site-badge">
<strong>Demo location</strong> Β· centred on <em>Blantyre, Malawi</em>
(-15.786,&nbsp;35.006) so Mapillary Street View has crowdsourced photo coverage at every marker.
The 8 intervention sites are fictional, placed for visualisation.
</div>
<script>
// Toggle the WASA cards panel. Hiding it gives the map full width
// (including for the InfoBox via the body.panel-hidden CSS rules).
(function () {
const panel = document.getElementById('map-panel');
const closeBtn = document.getElementById('map-panel-close');
const showBtn = document.getElementById('map-panel-show');
const setHidden = (hidden) => {
panel.classList.toggle('panel-hidden', hidden);
document.body.classList.toggle('panel-hidden', hidden);
};
closeBtn.addEventListener('click', () => setHidden(true));
showBtn.addEventListener('click', () => setHidden(false));
})();
// ----- Add-WASA-intervention webform -----
// No backend: the form collects fields + GPS + a photo, then offers the
// result as a clipboard copy or as separate .json / image downloads. The
// user (or their data manager) can then email or upload the files
// wherever they like.
(function () {
const overlay = document.getElementById('form-overlay');
const form = document.getElementById('wasa-form');
const loc = document.getElementById('form-location');
const gpsBtn = document.getElementById('form-gps');
const status = document.getElementById('form-status');
const result = document.getElementById('form-result');
const jsonEl = document.getElementById('form-json');
let lastSubmission = null;
let lastPhotoFiles = []; // array of File objects from the picker
const MIN_PHOTOS = 3;
function setStatus(msg, kind) {
status.textContent = msg || '';
status.className = kind || '';
}
function openForm() { overlay.hidden = false; setStatus(''); }
function closeForm() { overlay.hidden = true; }
document.getElementById('form-open').addEventListener('click', openForm);
document.getElementById('form-close').addEventListener('click', closeForm);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeForm(); });
// GPS capture β€” uses the browser Geolocation API.
gpsBtn.addEventListener('click', () => {
if (!navigator.geolocation) {
setStatus('Geolocation not supported by this browser.', 'err');
return;
}
gpsBtn.disabled = true;
gpsBtn.textContent = '⏳ locating…';
setStatus('Asking the device for GPS β€” allow location access if prompted.', '');
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude: lat, longitude: lon, accuracy } = pos.coords;
loc.value = lat.toFixed(6) + ', ' + lon.toFixed(6);
setStatus('GPS captured (Β±' + Math.round(accuracy) + ' m).', 'ok');
gpsBtn.disabled = false;
gpsBtn.textContent = 'πŸ“ Re-capture';
},
(err) => {
setStatus('Could not get GPS: ' + err.message, 'err');
gpsBtn.disabled = false;
gpsBtn.textContent = 'πŸ“ Get GPS';
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
);
});
// Live photo-count hint as the user picks files.
const photosInput = form.querySelector('input[name="photos"]');
const photoCountEl = document.getElementById('photo-count');
photosInput.addEventListener('change', () => {
const n = photosInput.files.length;
photoCountEl.textContent = n
? n + ' photo' + (n === 1 ? '' : 's') + ' selected'
+ (n < MIN_PHOTOS ? ' β€” need ' + (MIN_PHOTOS - n) + ' more' : ' βœ“')
: '';
photoCountEl.style.color = (n >= MIN_PHOTOS) ? '#81c784' : '#ff8a65';
});
// Reset clears everything including the GPS button label.
document.getElementById('form-reset').addEventListener('click', () => {
form.reset();
loc.value = '';
gpsBtn.textContent = 'πŸ“ Get GPS';
photoCountEl.textContent = '';
setStatus('Form cleared.', '');
result.hidden = true;
});
// Submit: validate, build the JSON record, show the result panel.
form.addEventListener('submit', (e) => {
e.preventDefault();
const fd = new FormData(form);
const photos = Array.from(photosInput.files || []);
if (photos.length < MIN_PHOTOS) {
setStatus('Please attach at least ' + MIN_PHOTOS + ' photos (you have '
+ photos.length + ').', 'err');
return;
}
if (!loc.value) {
setStatus('Please capture GPS with the πŸ“ button.', 'err'); return;
}
const [latStr, lonStr] = loc.value.split(',').map(s => s.trim());
const now = new Date();
const record = {
id: 'wasa_' + Date.now(),
today: now.toISOString().slice(0, 10), // YYYY-MM-DD, mirrors XLSForm `today`
captured_at: now.toISOString(),
intervention: fd.get('intervention'),
type: fd.get('type'),
description: fd.get('description'),
location: { lat: parseFloat(latStr), lon: parseFloat(lonStr) },
photos: photos.map((p, i) => ({
index: i + 1,
filename: p.name,
size_bytes: p.size,
mime_type: p.type,
})),
comment: fd.get('comment') || '',
user_agent: navigator.userAgent,
};
lastSubmission = record;
lastPhotoFiles = photos;
jsonEl.textContent = JSON.stringify(record, null, 2);
result.hidden = false;
setStatus('Saved ' + photos.length + ' photos. Download or copy below.', 'ok');
});
document.getElementById('form-copy').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(lastSubmission, null, 2));
setStatus('JSON copied to clipboard.', 'ok');
} catch (e) { setStatus('Clipboard copy failed: ' + e.message, 'err'); }
});
document.getElementById('form-download-json').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(lastSubmission, null, 2)],
{ type: 'application/json' });
triggerDownload(blob, lastSubmission.id + '.json');
});
document.getElementById('form-download-photo').addEventListener('click', () => {
if (!lastPhotoFiles.length) return;
// Trigger one download per photo, spaced ~150 ms apart so the browser
// doesn't squash them together. Filenames: recordid_N.ext so the
// JSON's `photos[].index` aligns with the file stem.
lastPhotoFiles.forEach((p, i) => {
const ext = (p.name.match(/\.[^.]+$/) || ['.jpg'])[0];
const name = lastSubmission.id + '_' + (i + 1) + ext;
setTimeout(() => triggerDownload(p, name), i * 150);
});
});
document.getElementById('form-new').addEventListener('click', () => {
form.reset();
loc.value = '';
gpsBtn.textContent = 'πŸ“ Get GPS';
photoCountEl.textContent = '';
result.hidden = true;
setStatus('Ready for the next record.', '');
});
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
})();
</script>
<!-- type="module" so Vite tracks the file at build time and includes it
in ./dist; without it the file is invisible to the bundler and 404s
on GitHub Pages. The script doesn't use ES imports internally, so the
module shim is purely a build-time signal here. -->
<script type="module" src="cesium-scene.js"></script>
</body>
</html>