feat(dashboard): light/dark theme
CI / Format & lint (push) Successful in 36s
CI / Test suite (push) Successful in 27s
Docs / Validate documentation (push) Successful in 12s

Sun/moon button next to the language switch. Follows the OS preference
until the user picks one (persisted in localStorage, tracks OS changes
only while unpinned). Every colour that was a literal white / grey is now
a token with a dark counterpart; flood-verdict, live-pill and lang-toggle
states got their own bg/ink/border tokens. OSM tiles are inverted with
a hue rotate so roads and labels stay legible while the river network,
markers and rain dots (SVG, unfiltered) keep their data colours. Chart.js
reads grid/tick/legend colours from the tokens and the open chart is
rebuilt on toggle. Leaflet popups and controls follow the theme.
This commit is contained in:
2026-09-11 23:16:30 +02:00
parent 5ad8e4eac3
commit 97a6694ab2
3 changed files with 116 additions and 27 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

+116 -27
View File
@@ -33,6 +33,57 @@
--red: #cc4b37;
--border: #dce7e3;
--shadow: 0 16px 40px rgba(23, 57, 67, .10);
--surface: #ffffff; /* buttons, inputs, overlays */
--surface-2: #f1f7f5; /* hover rows, popup metrics */
--surface-3: #f7fbfa; /* outlook box */
--overlay: rgba(255,255,255,.93);
--overlay-border: rgba(207,224,218,.9);
--map-bg: #dcebea;
--glow: rgba(56, 180, 213, .12);
--map-filter: none;
--chart-grid: rgba(19,43,53,.08);
--mint-ink: #146644;
--mint-border: #b6dfc9;
--ok-bg: #e2f3ea; --ok-ink: #0c5138; --ok-border: #bfe3d2;
--watch-bg: #fdf1dc; --watch-ink: #6b4a05; --watch-border: #f0d9a8;
--danger-bg: #fbe3de; --danger-ink: #7c1d10; --danger-border: #f2c0b6;
--demo-bg: #fdeeda; --demo-ink: #9a6200;
color-scheme: light;
}
/* Dark theme: same hues, inverted lightness. Flow/rain/marker colours
on the map are data encodings and stay identical in both themes. */
[data-theme="dark"] {
--ink: #e6eef1;
--muted: #97a9b0;
--paper: #0f1a1f;
--card: #16252c;
--river: #3fb3d8;
--river-light: #5ccbe8;
--mint: #143b2c;
--green: #3fb07f;
--amber: #e6a83a;
--red: #e46a55;
--border: #27393f;
--shadow: 0 16px 40px rgba(0, 0, 0, .45);
--surface: #1c2c33;
--surface-2: #213238;
--surface-3: #1a2a30;
--overlay: rgba(22,37,44,.92);
--overlay-border: rgba(64,86,94,.9);
--map-bg: #1a262b;
--glow: rgba(63, 179, 216, .10);
/* OSM tiles are light; invert + rotate keeps roads/labels legible
while the water/river overlays (drawn in SVG, unfiltered) keep
their real colours. */
--map-filter: invert(1) hue-rotate(180deg) brightness(.92) contrast(.9) saturate(.75);
--chart-grid: rgba(230,238,241,.10);
--mint-ink: #7fd7ac;
--mint-border: #2a5c45;
--ok-bg: #143b2c; --ok-ink: #9fe3c3; --ok-border: #2a6b4d;
--watch-bg: #3d2f0d; --watch-ink: #f3d489; --watch-border: #6b4f14;
--danger-bg: #421c15; --danger-ink: #ffb3a6; --danger-border: #7a2f22;
--demo-bg: #3d2f0d; --demo-ink: #f3d489;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
@@ -43,7 +94,7 @@
color: var(--ink);
overflow-x: hidden;
background:
radial-gradient(circle at 8% 0%, rgba(56, 180, 213, .12), transparent 25rem),
radial-gradient(circle at 8% 0%, var(--glow), transparent 25rem),
var(--paper);
/* Thai families first so Thai text uses a proper Thai face where one
is installed (Android/iOS/Windows all ship one); Latin falls
@@ -60,7 +111,13 @@
html[lang="th"] body { line-break: normal; overflow-wrap: anywhere; }
html[lang="th"] .stat-value, html[lang="th"] .flow-value { overflow-wrap: normal; }
.lang-toggle { padding: 9px 12px; font-size: .78rem; font-weight: 800; white-space: nowrap; }
.lang-toggle[data-active-lang="th"] { background: var(--mint); border-color: #b6dfc9; color: #146644; }
.lang-toggle[data-active-lang="th"] { background: var(--mint); border-color: var(--mint-border); color: var(--mint-ink); }
.theme-toggle { padding: 9px 11px; font-size: .95rem; line-height: 1; }
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: var(--card); color: var(--ink); }
.leaflet-container a.leaflet-popup-close-button { color: var(--muted); }
.leaflet-bar a, .leaflet-control-attribution { background: var(--surface); color: var(--ink); border-color: var(--border); }
.leaflet-control-attribution a { color: var(--river); }
[data-theme="dark"] .leaflet-control-attribution { background: var(--overlay); }
.shell { max-width: 1500px; margin: 0 auto; padding: 24px; }
header {
display: flex; align-items: center; justify-content: space-between; gap: 20px;
@@ -78,16 +135,16 @@
.header-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.live-pill {
display: flex; gap: 8px; align-items: center; padding: 9px 13px; border-radius: 999px;
background: var(--mint); color: #146644; font-weight: 750; font-size: .8rem;
background: var(--mint); color: var(--mint-ink); font-weight: 750; font-size: .8rem;
}
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #22a66c; box-shadow: 0 0 0 5px rgba(34,166,108,.12); }
.live-pill.demo { background: #fdeeda; color: #9a6200; }
.live-pill.demo { background: var(--demo-bg); color: var(--demo-ink); }
.live-pill.demo .live-dot { background: #e6a23c; box-shadow: 0 0 0 5px rgba(230,162,60,.15); }
button {
border: 1px solid var(--border); border-radius: 11px; background: white; color: var(--ink);
border: 1px solid var(--border); border-radius: 11px; background: var(--surface); color: var(--ink);
padding: 10px 14px; cursor: pointer; font-weight: 700; box-shadow: 0 3px 10px rgba(22,52,62,.05);
}
button:hover { border-color: #a9c4bb; transform: translateY(-1px); }
button:hover { border-color: var(--river-light); transform: translateY(-1px); }
button:disabled { opacity: .55; cursor: wait; transform: none; }
.stats { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px; margin-bottom: 14px; }
.stat {
@@ -102,13 +159,14 @@
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 14px; min-height: 640px; }
.map-card, .side-card { background: var(--card); border: 1px solid var(--border); border-radius: 19px; box-shadow: var(--shadow); overflow: hidden; }
.map-card { position: relative; }
#station-map { height: 640px; width: 100%; background: #dcebea; }
#station-map { height: 640px; width: 100%; background: var(--map-bg); }
.leaflet-tile-pane { filter: var(--map-filter); }
.map-overlay {
position: absolute; z-index: 500; top: 16px; left: 52px; right: 16px;
display: flex; justify-content: space-between; align-items: flex-start; pointer-events: none;
}
.map-heading, .legend {
background: rgba(255,255,255,.93); backdrop-filter: blur(9px); border: 1px solid rgba(207,224,218,.9);
background: var(--overlay); backdrop-filter: blur(9px); border: 1px solid var(--overlay-border);
border-radius: 13px; padding: 11px 13px; box-shadow: 0 7px 20px rgba(22,58,68,.12);
pointer-events: auto; /* .map-overlay disables events; re-enable for the legend's rain toggle */
}
@@ -130,7 +188,7 @@
width: 100%; display: grid; grid-template-columns: 40px minmax(0,1fr) auto; gap: 10px; align-items: center;
padding: 11px; border: 0; border-radius: 12px; box-shadow: none; text-align: left; background: transparent;
}
.station-row:hover { background: #f1f7f5; transform: none; }
.station-row:hover { background: var(--surface-2); transform: none; }
.station-code { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 11px; color: white; font-size: .68rem; font-weight: 850; }
.station-code.long { font-size: .55rem; word-break: break-all; line-height: 1.15; padding: 2px; text-align: center; }
.station-name { overflow: hidden; }
@@ -139,9 +197,9 @@
.station-name span { color: var(--muted); font-size: .68rem; margin-top: 3px; }
.flow-value { text-align: right; font-size: .82rem; font-weight: 800; }
.flow-value span { display: block; color: var(--muted); font-size: .61rem; font-weight: 650; margin-top: 2px; }
.loading-panel, .error-panel { position: absolute; z-index: 600; inset: 0; display: grid; place-items: center; background: rgba(243,247,245,.88); }
.loading-card { background: white; padding: 18px 22px; border-radius: 14px; box-shadow: var(--shadow); font-weight: 750; }
.error-panel { display: none; color: #8d2f22; text-align: center; padding: 25px; }
.loading-panel, .error-panel { position: absolute; z-index: 600; inset: 0; display: grid; place-items: center; background: var(--overlay); }
.loading-card { background: var(--card); padding: 18px 22px; border-radius: 14px; box-shadow: var(--shadow); font-weight: 750; }
.error-panel { display: none; color: var(--red); text-align: center; padding: 25px; }
.marker-wrap { background: none; border: 0; }
.flow-marker {
--marker-color: #087da5; --marker-size: 26px;
@@ -181,7 +239,7 @@
.risk-chips { display: flex; gap: 6px; }
.risk-chip { flex: 1; text-align: center; border-radius: 8px; padding: 5px 4px; font-size: .64rem; font-weight: 800; color: white; }
.risk-chip span { display: block; font-weight: 650; font-size: .58rem; opacity: .85; }
.p1-outlook { border: 1px solid var(--border); border-left: 4px solid var(--river); border-radius: 12px; padding: 12px 14px; margin-top: 14px; background: #f7fbfa; }
.p1-outlook { border: 1px solid var(--border); border-left: 4px solid var(--river); border-radius: 12px; padding: 12px 14px; margin-top: 14px; background: var(--surface-3); }
.p1-outlook-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
.p1-outlook-head strong { font-size: .88rem; }
.p1-peak { color: var(--muted); font-size: .76rem; }
@@ -195,16 +253,16 @@
.popup h3 { margin: 4px 0 2px; font-size: 1rem; }
.popup-th { color: var(--muted); font-size: .74rem; }
.popup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
.popup-metric { background: #f1f7f5; padding: 8px; border-radius: 9px; }
.popup-metric { background: var(--surface-2); padding: 8px; border-radius: 9px; }
.popup-metric span { display: block; color: var(--muted); font-size: .62rem; }
.popup-metric strong { display: block; margin-top: 2px; font-size: .86rem; }
.popup-time { margin-top: 9px; color: var(--muted); font-size: .64rem; }
/* Verdict banner: light tinted backgrounds with dark ink (contrast-safe) */
#flood-verdict.ok { background: #e2f3ea; color: #0c5138; border-color: #bfe3d2; }
#flood-verdict.watch { background: #fdf1dc; color: #6b4a05; border-color: #f0d9a8; }
#flood-verdict.danger { background: #fbe3de; color: #7c1d10; border-color: #f2c0b6; }
#flood-verdict.ok { background: var(--ok-bg); color: var(--ok-ink); border-color: var(--ok-border); }
#flood-verdict.watch { background: var(--watch-bg); color: var(--watch-ink); border-color: var(--watch-border); }
#flood-verdict.danger { background: var(--danger-bg); color: var(--danger-ink); border-color: var(--danger-border); }
#legend-toggle {
display: none; pointer-events: auto; background: rgba(255,255,255,.93); border: 1px solid rgba(207,224,218,.9);
display: none; pointer-events: auto; background: var(--overlay); border: 1px solid var(--overlay-border);
border-radius: 11px; padding: 8px 12px; font-size: .72rem; font-weight: 800; color: var(--ink);
box-shadow: 0 7px 20px rgba(22,58,68,.12);
}
@@ -277,6 +335,7 @@
<div class="live-pill" id="live-pill"><span class="live-dot"></span> <span id="live-pill-text" data-i18n="pill.live">LIVE DATA</span></div>
<button id="refresh-button" type="button" data-i18n="action.refresh">↻ Refresh</button>
<button id="lang-toggle" class="lang-toggle" type="button" data-active-lang="en" aria-label="Switch to Thai">ไทย</button>
<button id="theme-toggle" class="theme-toggle" type="button" data-i18n-aria="theme.toggle" aria-label="Switch to dark mode" title="Switch to dark mode">🌙</button>
<button id="replay-2024" type="button" data-i18n="replay.start">▶ Replay Oct 2024 flood</button>
</div>
</header>
@@ -323,7 +382,7 @@
<div class="legend-row"><i class="swatch" style="background:#2a1668;border-radius:50%"></i> <span data-i18n="legend.rain.extreme">Extreme &gt; 150</span></div>
<div class="legend-title" style="margin-top:10px" data-i18n="legend.other">Other markers</div>
<div class="legend-row"><i class="swatch" style="background:#7b8f94"></i> <span data-i18n="legend.other.nodata">Gauge · no recent data</span></div>
<div class="legend-row"><span style="font-weight:800;color:#0f6844">+</span> <span data-i18n="legend.other.sensor">Water-level sensor · colour = % of bank height</span></div>
<div class="legend-row"><span style="font-weight:800;color:var(--green)">+</span> <span data-i18n="legend.other.sensor">Water-level sensor · colour = % of bank height</span></div>
</div>
</div>
</div>
@@ -334,7 +393,7 @@
<aside class="side-card">
<div class="side-head"><h2 data-i18n="side.title">Current station flow</h2><p data-i18n="side.subtitle">Select a station to locate it and load its history</p>
<input type="search" id="station-search" data-i18n-placeholder="search.placeholder" data-i18n-aria="search.aria" placeholder="Search stations · code, name, river…" aria-label="Search stations" style="margin-top:10px;width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:white;font-size:.8rem">
<input type="search" id="station-search" data-i18n-placeholder="search.placeholder" data-i18n-aria="search.aria" placeholder="Search stations · code, name, river…" aria-label="Search stations" style="margin-top:10px;width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:var(--surface);color:var(--ink);font-size:.8rem">
</div>
<div class="station-list" id="river-flow" aria-live="polite"></div>
<div class="side-head" id="sensors-head" style="cursor:pointer" role="button" tabindex="0" aria-expanded="false" aria-controls="thaiwater-sensors" data-i18n-title="sensors.tip" title="Show / hide the ThaiWater/HII station list"><h2><span data-i18n="sensors.title">Additional basin stations</span> <span id="sensors-arrow" style="color:var(--muted);font-size:.8rem"></span></h2><p id="thaiwater-count" data-i18n="sensors.loading">Loading ThaiWater/HII stations…</p></div>
@@ -365,10 +424,10 @@
<div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
<div><h2 id="history-title" style="margin:0;font-size:1rem" data-i18n="history.title">Station history</h2><p id="history-status" class="subtitle" data-i18n="history.status">Select a station to load the last 7 days</p></div>
<div class="history-controls" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<select id="history-range" style="padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:white"><option value="24" data-i18n="range.24h">Last 24 hours</option><option value="168" selected data-i18n="range.7d">Last 7 days</option><option value="720" data-i18n="range.30d">Last 30 days</option><option value="2160" data-i18n="range.90d">Last 90 days</option><option value="876000" data-i18n="range.all">All time</option><option value="custom" hidden data-i18n="range.custom">Custom range</option></select>
<input type="date" id="history-start" data-i18n-title="range.from" title="From date" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:white">
<select id="history-range" style="padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:var(--surface);color:var(--ink)"><option value="24" data-i18n="range.24h">Last 24 hours</option><option value="168" selected data-i18n="range.7d">Last 7 days</option><option value="720" data-i18n="range.30d">Last 30 days</option><option value="2160" data-i18n="range.90d">Last 90 days</option><option value="876000" data-i18n="range.all">All time</option><option value="custom" hidden data-i18n="range.custom">Custom range</option></select>
<input type="date" id="history-start" data-i18n-title="range.from" title="From date" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--surface);color:var(--ink)">
<span style="color:var(--muted)"></span>
<input type="date" id="history-end" data-i18n-title="range.to" title="To date" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:white">
<input type="date" id="history-end" data-i18n-title="range.to" title="To date" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--surface);color:var(--ink)">
</div>
</div>
<div style="height:260px;margin-top:14px;overflow:hidden;position:relative"><canvas id="history-chart" data-i18n-aria="aria.chart" aria-label="Historical water level and discharge chart" style="display:block"></canvas><div id="history-placeholder" data-i18n="history.placeholder" style="position:absolute;inset:0;display:grid;place-items:center;color:var(--muted);font-size:.85rem;text-align:center;padding:20px">Click any station on the map or in the list to see its history</div></div>
@@ -409,6 +468,7 @@
'app.title': 'Ping River Live Monitor',
'app.subtitle': 'Current water level and discharge across Northern Thailand',
'pill.live': 'LIVE DATA',
'theme.toggle': 'Toggle dark mode',
'pill.stale': '⚠ STALE FEED',
'pill.replay': '⏪ 2024 REPLAY',
'pill.sim': '⚠ SIMULATION',
@@ -584,6 +644,7 @@
'app.title': 'ติดตามระดับน้ำปิงแบบเรียลไทม์',
'app.subtitle': 'ระดับน้ำและอัตราการไหลปัจจุบันทั่วภาคเหนือของประเทศไทย',
'pill.live': 'ข้อมูลสด',
'theme.toggle': 'สลับโหมดมืด/สว่าง',
'pill.stale': '⚠ ข้อมูลไม่อัปเดต',
'pill.replay': '⏪ ย้อนเหตุการณ์ 2567',
'pill.sim': '⚠ การจำลอง',
@@ -809,6 +870,32 @@
}
}
// Theme: explicit choice persists; otherwise follow the OS and track its changes.
const THEME_KEY = 'ping-river-theme';
const osDark = window.matchMedia('(prefers-color-scheme: dark)');
function currentTheme() { return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'; }
function applyTheme(theme, persist) {
document.documentElement.dataset.theme = theme;
const button = $('theme-toggle');
if (button) {
button.textContent = theme === 'dark' ? '☀️' : '🌙';
}
if (persist) { try { localStorage.setItem(THEME_KEY, theme); } catch (e) { /* private mode */ } }
// Chart.js reads colours at construction: rebuild the open chart
if (state.historyChart && state.selectedStation) loadHistory(state.selectedStation);
}
(() => {
let saved = null;
try { saved = localStorage.getItem(THEME_KEY); } catch (e) { /* private mode */ }
applyTheme(saved === 'dark' || saved === 'light' ? saved : (osDark.matches ? 'dark' : 'light'), false);
osDark.addEventListener('change', (e) => {
let pinned = null;
try { pinned = localStorage.getItem(THEME_KEY); } catch (err) { /* ignore */ }
if (!pinned) applyTheme(e.matches ? 'dark' : 'light', false);
});
})();
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
function setLang(lang) {
state.lang = lang;
try { localStorage.setItem(LANG_KEY, lang); } catch (e) { /* private mode */ }
@@ -1208,13 +1295,14 @@
options: {
responsive: true, maintainAspectRatio: false, animation: { duration: 0 },
interaction: { mode: 'index', intersect: false },
color: cssVar('--ink'),
scales: {
flow: { type: 'linear', position: 'left' },
level: { type: 'linear', position: 'right', grid: { drawOnChartArea: false } },
x: { ticks: { maxTicksLimit: 12 } }
flow: { type: 'linear', position: 'left', grid: { color: cssVar('--chart-grid') }, ticks: { color: cssVar('--muted') } },
level: { type: 'linear', position: 'right', grid: { drawOnChartArea: false }, ticks: { color: cssVar('--muted') } },
x: { ticks: { maxTicksLimit: 12, color: cssVar('--muted') }, grid: { color: cssVar('--chart-grid') } }
},
plugins: {
legend: { display: true },
legend: { display: true, labels: { color: cssVar('--ink') } },
floodBands: { enabled: true }
}
},
@@ -1957,6 +2045,7 @@
: t('forecast.expand', count);
});
$('lang-toggle').addEventListener('click', () => setLang(state.lang === 'th' ? 'en' : 'th'));
$('theme-toggle').addEventListener('click', () => applyTheme(currentTheme() === 'dark' ? 'light' : 'dark', true));
$('station-search').addEventListener('input', applyStationSearch);
$('sensors-head').addEventListener('click', () => setSensorsOpen(!state.sensorsOpen));
$('sensors-head').addEventListener('keydown', (event) => {