diff --git a/docs/website-text.txt b/docs/website-text.txt new file mode 100644 index 0000000..f8ecc84 --- /dev/null +++ b/docs/website-text.txt @@ -0,0 +1,99 @@ +[[ FO ]] +- Lýsing +Sum maðurin segði; tað frálíkt at vísa á, at ein dugur at heinta dátur úr einum API og vísa tað á einari heimasíðu har brúkarar kunna filtrera visualiseringina. +Her nýta vit fiskahagtøl sum verða dagførd einaferð um mánaðin. So kunna vit líkasum síggja hvat hendir har, ha? Eg kalli hesa applikatión 'hagfisk'. Altso hagtøl + fisk. Hagfisk. Hagfisk, havi eg funni útav, er eisini ein heilt ótrúligani ólekkur og slímutur fiskur. +[INSERT PHOTO]. +Men ja, eg fór við tí allíkavæl. +Anyways, her er ikki so nógv at siga: taka dátur frá hagstovu-API, skriva til ein lokalan DuckDB OLTP-dátugrunn á servara, servera API til web dátur, front end til at visualisera dáturnar. + +- Leiðbeining +Far inn á síðuna, hygg og filtrera. + +- Tøkni +Rust - axum, serde, tokio, tower-http, reqwest, rust-embed +DuckDB +html +js +css +echarts + +- Keldu kota +https://git.flo.fo/FLO/hagfisk + + + +- Leinkja +https://hagfisk.poc.fló.fo + + +[[ DA ]] + +## Beskrivelse + +Som den gamle mand sagde: Det er jo meget fint at vise, at man kan hente data fra et API og visualisere dem på en hjemmeside, hvor brugerne kan filtrere visningen. + +Her bruger vi fiskeristatistik, som bliver opdateret én gang om måneden. Så kan vi ligesom se, hvad der sker, ikke? Jeg kalder denne applikation 'hagfisk'. Altså statistik (FO = hagtøl) + fisk. Hagfisk. Jeg har fundet ud af, at hagfisk også er en helt utrolig modbydelig og slimet fisk. + +[INDSÆT FOTO]. + +Men ja, jeg gik med navnet alligevel. + +Anyways, der er ikke så meget mere at sige: Hent data fra Hagstova-API'et, gem dem i en lokal DuckDB-database på serveren, eksponér dem via et API til webfrontend'en, og visualisér dataene. + +## Vejledning + +Gå ind på siden, kig dig omkring og filtrér. + +## Teknologi + +Rust – axum, serde, tokio, tower-http, reqwest, rust-embed +DuckDB +HTML +JS +CSS +ECharts + +## Kildekode + +https://git.flo.fo/FLO/hagfisk + +## Link + +https://hagfisk.poc.fló.fo + + +[[ EN ]] + +## Description + +As the old man said: It’s pretty great to show that you can pull data from an API and visualise it on a website where users can filter the view. + +Here we’re using fisheries statistics, updated once a month. So we can kind of see what’s going on, right? I’m calling this application ‘hagfisk’. Basically, statistics + fish. Hagfisk. I’ve since discovered that hagfish is also an incredibly gross and slimy fish. + +[INSERT PHOTO]. + +But yeah, I went with the name anyway. + +Anyways, there’s not much more to say: fetch data from the Hagstova API, store it in a local DuckDB database on the server, expose it through an API to the web frontend, and visualise the data. + +## Guide + +Go to the page, have a look around, and filter. + +## Technology + +Rust – axum, serde, tokio, tower-http, reqwest, rust-embed +DuckDB +HTML +JS +CSS +ECharts + +## Source Code + +https://git.flo.fo/FLO/hagfisk + +## Link + +https://hagfisk.poc.fló.fo + diff --git a/src/api.rs b/src/api.rs index 66d0ffe..fcd9be3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -31,7 +31,9 @@ struct StaticAssets; #[derive(Debug, Clone, Deserialize)] pub struct LandingsQuery { - pub month: Option, + pub month: Option, // Kept for backward compatibility or single-month quick lookups + pub month_from: Option, + pub month_to: Option, pub species: Option, pub gear: Option, pub zone: Option, @@ -88,6 +90,8 @@ pub fn build_router(state: AppState) -> Router { Router::new() .route("/healthz", get(healthz)) .route("/api/species", get(get_species)) + .route("/api/zones", get(get_zones)) + .route("/api/gear", get(get_gear)) .route("/api/landings", get(get_landings)) .route("/api/summary", get(get_summary)) .route("/api/export.parquet", get(export_parquet)) @@ -139,11 +143,25 @@ async fn get_landings( let mut args: Vec> = Vec::new(); let mut idx = 1; + // Single month filter (legacy) if let Some(ref month) = params.month { sql.push_str(&format!(" AND month = ${idx}")); args.push(Box::new(month.clone())); idx += 1; } + + // Range filter (new) + if let Some(ref month_from) = params.month_from { + sql.push_str(&format!(" AND month >= ${idx}")); + args.push(Box::new(month_from.clone())); + idx += 1; + } + if let Some(ref month_to) = params.month_to { + sql.push_str(&format!(" AND month <= ${idx}")); + args.push(Box::new(month_to.clone())); + idx += 1; + } + if let Some(ref species) = params.species { sql.push_str(&format!(" AND species_code = ${idx}")); args.push(Box::new(species.clone())); @@ -403,11 +421,26 @@ async fn export_parquet(State(state): State) -> ApiResult { async fn static_handler(uri: axum::http::Uri) -> Response { let path = uri.path().trim_start_matches('/'); - let asset = StaticAssets::get(path).or_else(|| StaticAssets::get("index.html")); + + // Strip "static/" prefix if present + let asset_path = if let Some(stripped) = path.strip_prefix("static/") { + stripped + } else { + path + }; + + // If empty path (e.g., visiting "/"), serve index.html directly + let asset_path = if asset_path.is_empty() { + "index.html" + } else { + asset_path + }; + + let asset = StaticAssets::get(asset_path).or_else(|| StaticAssets::get("index.html")); match asset { Some(file) => { - let mime = mime_guess::from_path(path) + let mime = mime_guess::from_path(asset_path) .first_or_octet_stream() .as_ref() .to_string(); @@ -425,6 +458,50 @@ async fn static_handler(uri: axum::http::Uri) -> Response { } } +async fn get_zones(State(state): State) -> ApiResult>> { + let conn = state.conn.clone(); + let rows = tokio::task::spawn_blocking(move || -> db::Result> { + let conn = conn.blocking_lock(); + let mut stmt = conn.prepare("SELECT code, label FROM zone ORDER BY code")?; + let rows = stmt.query_map([], |row| { + Ok(SpeciesDto { + code: row.get(0)?, + label: row.get(1)?, + }) + })?; + Ok(rows.collect::, duckdb::Error>>()?) + }) + .await + .map_err(|e| ApiError { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: format!("Task join error: {e}"), + })??; + + Ok(Json(rows)) +} + +async fn get_gear(State(state): State) -> ApiResult>> { + let conn = state.conn.clone(); + let rows = tokio::task::spawn_blocking(move || -> db::Result> { + let conn = conn.blocking_lock(); + let mut stmt = conn.prepare("SELECT code, label FROM gear ORDER BY code")?; + let rows = stmt.query_map([], |row| { + Ok(SpeciesDto { + code: row.get(0)?, + label: row.get(1)?, + }) + })?; + Ok(rows.collect::, duckdb::Error>>()?) + }) + .await + .map_err(|e| ApiError { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: format!("Task join error: {e}"), + })??; + + Ok(Json(rows)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..9339193 --- /dev/null +++ b/static/app.js @@ -0,0 +1,335 @@ +let chartLine, chartBar, chartDonut; +let allMonths = []; + +function getThemeColors() { + const theme = document.documentElement.getAttribute('data-theme'); + const isDark = theme === 'tokyo'; + return { + isDark, + textColor: isDark ? '#e8f4ff' : '#1a1d22', + gridColor: isDark ? 'rgba(100, 220, 255, 0.1)' : 'rgba(26, 29, 34, 0.1)', + accent: isDark ? '#64dcff' : '#2c5a7a', + secondary: isDark ? '#2c5a7a' : '#64dcff', + palette: isDark + ? ['#64dcff', '#2c5a7a', '#a3b3cc', '#7886a0', '#d93d3d', '#dcad2b', '#4caf50', '#ff9800', '#9c27b0', '#00bcd4'] + : ['#2c5a7a', '#64dcff', '#595f68', '#9299a2', '#a03030', '#8a6a10', '#4caf50', '#ff9800', '#9c27b0', '#00bcd4'], + bgTransparent: 'transparent', + }; +} + +function getCommonOptions() { + const c = getThemeColors(); + return { + backgroundColor: c.bgTransparent, + textStyle: { fontFamily: 'JetBrains Mono', color: c.textColor }, + tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, + legend: { textStyle: { color: c.textColor }, top: 10 }, + grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, + xAxis: { + type: 'category', + axisLine: { lineStyle: { color: c.gridColor } }, + axisLabel: { color: c.textColor }, + data: [], + }, + yAxis: { + type: 'value', + splitLine: { lineStyle: { color: c.gridColor } }, + axisLabel: { color: c.textColor }, + }, + }; +} + +function initCharts() { + if (chartLine) chartLine.dispose(); + if (chartBar) chartBar.dispose(); + if (chartDonut) chartDonut.dispose(); + + chartLine = echarts.init(document.getElementById('chart-line')); + chartBar = echarts.init(document.getElementById('chart-bar')); + chartDonut = echarts.init(document.getElementById('chart-donut')); + + renderLineChart([], [], []); + renderBarChart([], []); + renderDonutChart([]); + + window.addEventListener('resize', () => { + chartLine.resize(); + chartBar.resize(); + chartDonut.resize(); + }); +} + +function renderLineChart(months, massData, valueData) { + const c = getThemeColors(); + chartLine.setOption({ + ...getCommonOptions(), + tooltip: { trigger: 'axis' }, + xAxis: { ...getCommonOptions().xAxis, data: months }, + series: [ + { + name: 'Mass (kg)', + type: 'line', + smooth: true, + itemStyle: { color: c.accent }, + areaStyle: { opacity: 0.08 }, + data: massData, + }, + { + name: 'Value (DKK)', + type: 'line', + smooth: true, + itemStyle: { color: c.secondary }, + areaStyle: { opacity: 0.08 }, + data: valueData, + }, + ], + }); +} + +function renderDonutChart(data) { + const c = getThemeColors(); + chartDonut.setOption({ + backgroundColor: c.bgTransparent, + textStyle: { fontFamily: 'JetBrains Mono', color: c.textColor }, + tooltip: { trigger: 'item' }, + legend: { orient: 'vertical', left: 'left', textStyle: { color: c.textColor } }, + series: [ + { + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 5, + borderColor: c.isDark ? '#07070d' : '#f4f1ec', + borderWidth: 2, + }, + label: { show: false, position: 'center' }, + emphasis: { + label: { show: true, fontSize: 16, fontWeight: 'bold', color: c.textColor }, + }, + data: data, + color: c.palette, + }, + ], + }); +} + +function renderBarChart(months, seriesData) { + const c = getThemeColors(); + chartBar.setOption({ + ...getCommonOptions(), + xAxis: { ...getCommonOptions().xAxis, data: months }, + series: seriesData, + color: c.palette, + }); +} + +async function fetchData(endpoint) { + try { + const res = await fetch(endpoint); + if (!res.ok) throw new Error(`API Error: ${res.status}`); + return await res.json(); + } catch (err) { + console.error(err); + return null; + } +} + +function populateSelect(id, items, codeKey, labelKey) { + const sel = document.getElementById(id); + sel.innerHTML = ''; + items.forEach((item) => { + const opt = document.createElement('option'); + opt.value = item[codeKey]; + opt.textContent = item[labelKey]; + sel.appendChild(opt); + }); +} + +async function populateDropdowns() { + showLoading(true); + const [speciesList, zonesList, gearList, summaryData] = await Promise.all([ + fetchData('/api/species'), + fetchData('/api/zones'), + fetchData('/api/gear'), + fetchData('/api/summary'), + ]); + + if (speciesList) { + populateSelect('species', speciesList, 'code', 'label'); + } + + if (zonesList) { + populateSelect('zone', zonesList, 'code', 'label'); + } + + if (gearList) { + populateSelect('gear', gearList, 'code', 'label'); + } + + if (summaryData && summaryData.monthly) { + allMonths = summaryData.monthly.map((m) => m.month).sort(); + const fillMonthSelect = (selectId) => { + const sel = document.getElementById(selectId); + sel.innerHTML = ''; + allMonths.forEach((m) => { + const opt = document.createElement('option'); + opt.value = m; + opt.textContent = m; + sel.appendChild(opt); + }); + }; + fillMonthSelect('month_from'); + fillMonthSelect('month_to'); + } + + showLoading(false); +} + +async function updateCharts() { + showLoading(true); + hideEmptyState(); + + const species = document.getElementById('species').value; + const zone = document.getElementById('zone').value; + const gear = document.getElementById('gear').value; + const monthFrom = document.getElementById('month_from').value; + const monthTo = document.getElementById('month_to').value; + + const params = new URLSearchParams(); + if (species) params.append('species', species); + if (zone) params.append('zone', zone); + if (gear) params.append('gear', gear); + if (monthFrom) params.append('month_from', monthFrom); + if (monthTo) params.append('month_to', monthTo); + params.append('limit', '10000'); + + const [landings, summary] = await Promise.all([ + fetchData(`/api/landings?${params.toString()}`), + fetchData(`/api/summary?${species ? `species=${species}` : ''}`), + ]); + + showLoading(false); + + if (!landings || landings.length === 0) { + showEmptyState(); + return; + } + + hideEmptyState(); + document.getElementById('charts-grid').style.display = 'grid'; + + // --- Line Chart --- + const months = [...new Set(landings.map((l) => l.month))].sort(); + const massMap = {}; + const valueMap = {}; + + landings.forEach((l) => { + if (!massMap[l.month]) massMap[l.month] = 0; + if (!valueMap[l.month]) valueMap[l.month] = 0; + if (l.measure_code === 'MASS' && l.value) massMap[l.month] += l.value; + if (l.measure_code === 'VALUE' && l.value) valueMap[l.month] += l.value; + }); + + renderLineChart( + months, + months.map((m) => massMap[m] || 0), + months.map((m) => valueMap[m] || 0) + ); + + // --- Donut Chart (Top Species by Value) --- + const speciesValueMap = {}; + landings.forEach((l) => { + if (l.measure_code === 'VALUE' && l.value) { + speciesValueMap[l.species_label] = + (speciesValueMap[l.species_label] || 0) + l.value; + } + }); + + const donutData = Object.entries(speciesValueMap) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([name, value]) => ({ name, value })); + + renderDonutChart(donutData); + + // --- Bar Chart (Stacked by Species) --- + const barSpeciesSet = new Set(); + landings.forEach((l) => { + if (l.measure_code === 'VALUE') barSpeciesSet.add(l.species_label); + }); + const barSpecies = [...barSpeciesSet].slice(0, 10); + + const barSeries = barSpecies.map((sp) => ({ + name: sp, + type: 'bar', + stack: 'total', + emphasis: { focus: 'series' }, + data: months.map((m) => { + const row = landings.find( + (l) => + l.month === m && + l.species_label === sp && + l.measure_code === 'VALUE' + ); + return row && row.value ? row.value : 0; + }), + })); + + renderBarChart(months, barSeries); +} + +function showLoading(show) { + const overlay = document.getElementById('loading-overlay'); + if (show) { + overlay.classList.remove('hidden'); + } else { + overlay.classList.add('hidden'); + } +} + +function showEmptyState() { + document.getElementById('charts-grid').style.display = 'none'; + document.getElementById('empty-state').style.display = 'flex'; +} + +function hideEmptyState() { + document.getElementById('empty-state').style.display = 'none'; +} + +document.getElementById('apply-filters').addEventListener('click', updateCharts); + +// Theme Logic +(() => { + function currentSystemTheme() { + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'tokyo' + : 'light'; + } + const btn = document.getElementById('theme-toggle'); + if (btn) { + btn.addEventListener('click', () => { + const current = + document.documentElement.getAttribute('data-theme') || 'light'; + const next = current === 'light' ? 'tokyo' : 'light'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('poc-theme', next); + + initCharts(); + updateCharts(); + }); + } + window + .matchMedia('(prefers-color-scheme: dark)') + .addEventListener('change', () => { + if (!localStorage.getItem('poc-theme')) { + document.documentElement.setAttribute('data-theme', currentSystemTheme()); + initCharts(); + updateCharts(); + } + }); +})(); + +// Init +initCharts(); +populateDropdowns().then(updateCharts); diff --git a/static/index.html b/static/index.html index 0fac637..18215f9 100644 --- a/static/index.html +++ b/static/index.html @@ -1 +1,88 @@ -hagfish

hagfish

Frontend coming in Phase 4.

+ + + + + + Fisheries Dashboard — FLÓ + + + + + + + + + + + + + + +
+
+
+ Loading fisheries data… +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + + + +
+
+ + + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..99793c9 --- /dev/null +++ b/static/style.css @@ -0,0 +1,331 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root, +[data-theme="light"] { + --bg: #f4f1ec; + --bg-panel: rgba(255, 253, 250, 0.72); + --nav-bg: rgba(244, 241, 236, 0.6); + --ink: #1a1d22; + --ink-muted: #595f68; + --ink-dim: #9299a2; + --accent: #2c5a7a; + --border: rgba(26, 29, 34, 0.1); + --danger: #a03030; + --danger-soft: rgba(160, 48, 48, 0.08); + --danger-text: #a03030; + --input-bg: rgba(26, 29, 34, 0.04); + --surface-hover: rgba(26, 29, 34, 0.04); +} + +[data-theme="tokyo"] { + --bg: #07070d; + --bg-panel: rgba(20, 22, 35, 0.85); + --nav-bg: rgba(7, 7, 13, 0.65); + --ink: #e8f4ff; + --ink-muted: #a3b3cc; + --ink-dim: #7886a0; + --accent: #64dcff; + --border: rgba(100, 220, 255, 0.25); + --danger: #d93d3d; + --danger-soft: rgba(217, 61, 61, 0.16); + --danger-text: #ffd1d1; + --input-bg: rgba(100, 220, 255, 0.08); + --surface-hover: rgba(100, 220, 255, 0.1); +} + +html { scroll-behavior: smooth; } + +body { + min-height: 100vh; + min-height: 100dvh; + background: var(--bg); + color: var(--ink); + font-family: 'JetBrains Mono', monospace; + font-size: 0.92rem; + font-weight: 300; + line-height: 1.65; + display: flex; + flex-direction: column; + transition: background 0.3s ease, color 0.3s ease; +} + +a { + color: var(--accent); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: all 0.2s ease; +} +a:hover { border-bottom-color: var(--accent); } + +/* ---- Nav ---- */ +nav { + position: fixed; top: 0; left: 0; + width: 100%; z-index: 20; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 1rem; + padding: 1.25rem 2.5rem; + background: var(--nav-bg); + backdrop-filter: blur(12px) saturate(120%); + -webkit-backdrop-filter: blur(12px) saturate(120%); + border-bottom: 1px solid var(--border); + transition: background 0.3s ease; +} + +.back-link { + grid-column: 1; + justify-self: start; + font-family: 'JetBrains Mono', monospace; + font-weight: 400; + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; + white-space: nowrap; + color: var(--ink-muted); + text-decoration: none; + padding: 0.35rem 0.6rem; + border: 1px solid var(--border); + border-radius: 2px; + transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease; +} +.back-link:hover { + color: var(--ink); + border-color: var(--ink-muted); + background: var(--bg-panel); + border-bottom-color: var(--ink-muted); +} + +.nav-brand { + grid-column: 2; + justify-self: center; + display: inline-flex; + align-items: baseline; + gap: 0.35rem; + white-space: nowrap; + font-family: 'JetBrains Mono', monospace; + font-weight: 700; + font-size: 1.1rem; + letter-spacing: -0.02em; + color: var(--ink); + text-decoration: none; +} +.nav-brand:hover { color: var(--accent); border-bottom: none; } + +.brand-sep { font-weight: 300; color: var(--ink-dim); } +.brand-sub { + font-weight: 400; + font-size: 0.78rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-muted); + align-self: center; + padding-top: 2px; +} + +[data-theme="tokyo"] .nav-brand { + color: #e8f4ff; + text-shadow: 0 0 8px rgba(100, 220, 255, 0.3); +} + +/* ---- Main ---- */ +.page-body { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: calc(65px + 2.5rem) 2rem 4rem; +} + +.dashboard { + width: min(1200px, 100%); + background: var(--bg-panel); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid var(--border); + border-radius: 3px; + padding: 2rem; + box-shadow: 0 8px 30px rgba(26, 29, 34, 0.06); + animation: panel-in 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards; + opacity: 0; + transform: translateY(10px); + transition: background 0.3s ease, border-color 0.3s ease; + position: relative; + min-height: 500px; +} + +@keyframes panel-in { + 0% { opacity: 0; transform: translateY(10px); } + 100% { opacity: 1; transform: translateY(0); } +} + +/* ---- Loading Overlay ---- */ +.loading-overlay { + position: absolute; + inset: 0; + background: var(--bg-panel); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + border-radius: 3px; + color: var(--ink-muted); + font-size: 0.88rem; + letter-spacing: 0.05em; +} + +.loading-overlay.hidden { + display: none; +} + +/* ---- Empty State ---- */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 3rem 1rem; + color: var(--ink-dim); + font-size: 0.85rem; + text-align: center; + line-height: 1.6; +} + +/* ---- Filters ---- */ +.filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.filter-group label { + font-size: 0.65rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--ink-dim); +} + +.filter-group select { + padding: 0.5rem 0.75rem; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 2px; + color: var(--ink); + font-family: 'JetBrains Mono', monospace; + font-size: 0.82rem; + outline: none; + transition: border-color 0.2s; + cursor: pointer; +} +.filter-group select:focus { border-color: var(--accent); } + +.apply-btn { + align-self: flex-end; + padding: 0.5rem 1.2rem; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 2px; + font-family: 'JetBrains Mono', monospace; + font-weight: 700; + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: opacity 0.2s; +} +.apply-btn:hover { opacity: 0.85; } + +/* ---- Charts ---- */ +.charts-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; +} + +.chart-container { + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 2px; + padding: 1rem; + height: 350px; + position: relative; +} + +.chart-container.full-width { + grid-column: span 2; +} + +.chart-title { + font-size: 0.75rem; + font-weight: 600; + margin-bottom: 0.8rem; + color: var(--ink); + letter-spacing: 0.02em; +} + +/* ---- Theme Toggle ---- */ +.theme-toggle { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--border); + cursor: pointer; + padding: 0; + z-index: 30; + transition: all 0.3s ease; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); +} +.theme-toggle:hover { transform: scale(1.08); } +[data-theme="light"] .theme-toggle { + background: linear-gradient(135deg, #07070d 0%, #64dcff 100%); + border-color: rgba(26, 29, 34, 0.2); +} +[data-theme="tokyo"] .theme-toggle { + background: #f4f1ec; + border-color: rgba(100, 220, 255, 0.4); + box-shadow: + 0 0 10px rgba(100, 220, 255, 0.5), + 0 0 25px rgba(100, 220, 255, 0.3); +} + +@media (max-width: 820px) { + nav { + gap: 0.5rem; + padding: 0.85rem 0.85rem; + } + .back-link { font-size: 0.58rem; padding: 0.25rem 0.4rem; letter-spacing: 0.08em; } + .nav-brand { font-size: 0.9rem; } + .brand-sub { font-size: 0.6rem; letter-spacing: 0.1em; } + .page-body { padding: calc(55px + 1.25rem) 1rem 3rem; } + .dashboard { padding: 1.5rem; } + .charts-grid { grid-template-columns: 1fr; } + .chart-container.full-width { grid-column: span 1; } + .filter-group { min-width: unset; } + .filters { grid-template-columns: 1fr 1fr; } + .apply-btn { grid-column: span 2; } + .theme-toggle { width: 20px; height: 20px; bottom: 1rem; right: 0.9rem; } +} + +@media (max-width: 460px) { + .nav-brand .brand-sub { display: none; } + .nav-brand .brand-sep { display: none; } + .dashboard { padding: 1rem; } + .filters { grid-template-columns: 1fr; } + .apply-btn { grid-column: span 1; } +}