phase 4 ready for review

This commit is contained in:
2026-08-17 16:57:38 +01:00
parent 5e80204e34
commit 1a6840d16c
5 changed files with 933 additions and 4 deletions
+99
View File
@@ -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
+80 -3
View File
@@ -31,7 +31,9 @@ struct StaticAssets;
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct LandingsQuery { pub struct LandingsQuery {
pub month: Option<String>, pub month: Option<String>, // Kept for backward compatibility or single-month quick lookups
pub month_from: Option<String>,
pub month_to: Option<String>,
pub species: Option<String>, pub species: Option<String>,
pub gear: Option<String>, pub gear: Option<String>,
pub zone: Option<String>, pub zone: Option<String>,
@@ -88,6 +90,8 @@ pub fn build_router(state: AppState) -> Router {
Router::new() Router::new()
.route("/healthz", get(healthz)) .route("/healthz", get(healthz))
.route("/api/species", get(get_species)) .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/landings", get(get_landings))
.route("/api/summary", get(get_summary)) .route("/api/summary", get(get_summary))
.route("/api/export.parquet", get(export_parquet)) .route("/api/export.parquet", get(export_parquet))
@@ -139,11 +143,25 @@ async fn get_landings(
let mut args: Vec<Box<dyn duckdb::ToSql>> = Vec::new(); let mut args: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
let mut idx = 1; let mut idx = 1;
// Single month filter (legacy)
if let Some(ref month) = params.month { if let Some(ref month) = params.month {
sql.push_str(&format!(" AND month = ${idx}")); sql.push_str(&format!(" AND month = ${idx}"));
args.push(Box::new(month.clone())); args.push(Box::new(month.clone()));
idx += 1; 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 { if let Some(ref species) = params.species {
sql.push_str(&format!(" AND species_code = ${idx}")); sql.push_str(&format!(" AND species_code = ${idx}"));
args.push(Box::new(species.clone())); args.push(Box::new(species.clone()));
@@ -403,11 +421,26 @@ async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
async fn static_handler(uri: axum::http::Uri) -> Response { async fn static_handler(uri: axum::http::Uri) -> Response {
let path = uri.path().trim_start_matches('/'); 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 { match asset {
Some(file) => { Some(file) => {
let mime = mime_guess::from_path(path) let mime = mime_guess::from_path(asset_path)
.first_or_octet_stream() .first_or_octet_stream()
.as_ref() .as_ref()
.to_string(); .to_string();
@@ -425,6 +458,50 @@ async fn static_handler(uri: axum::http::Uri) -> Response {
} }
} }
async fn get_zones(State(state): State<AppState>) -> ApiResult<Json<Vec<SpeciesDto>>> {
let conn = state.conn.clone();
let rows = tokio::task::spawn_blocking(move || -> db::Result<Vec<SpeciesDto>> {
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::<std::result::Result<Vec<_>, 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<AppState>) -> ApiResult<Json<Vec<SpeciesDto>>> {
let conn = state.conn.clone();
let rows = tokio::task::spawn_blocking(move || -> db::Result<Vec<SpeciesDto>> {
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::<std::result::Result<Vec<_>, duckdb::Error>>()?)
})
.await
.map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("Task join error: {e}"),
})??;
Ok(Json(rows))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+335
View File
@@ -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 = '<option value="">All</option>';
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 = '<option value="">All</option>';
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);
+88 -1
View File
@@ -1 +1,88 @@
<!DOCTYPE html><html><head><title>hagfish</title></head><body><h1>hagfish</h1><p>Frontend coming in Phase 4.</p></body></html> <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
<title>Fisheries Dashboard — FLÓ</title>
<script>
(function () {
var stored = localStorage.getItem('poc-theme');
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'tokyo' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav>
<a href="https://fló.fo" class="back-link">← FLÓ.FO</a>
<a href="/" class="nav-brand">
FLÓ <span class="brand-sep">/</span> <span class="brand-sub">HAGFISH</span>
</a>
</nav>
<main class="page-body">
<div class="dashboard">
<div id="loading-overlay" class="loading-overlay">
<span>Loading fisheries data…</span>
</div>
<div class="filters">
<div class="filter-group">
<label for="species">Species</label>
<select id="species"><option value="">All</option></select>
</div>
<div class="filter-group">
<label for="zone">Zone</label>
<select id="zone"><option value="">All</option></select>
</div>
<div class="filter-group">
<label for="gear">Gear</label>
<select id="gear"><option value="">All</option></select>
</div>
<div class="filter-group">
<label for="month_from">From</label>
<select id="month_from"><option value="">All</option></select>
</div>
<div class="filter-group">
<label for="month_to">To</label>
<select id="month_to"><option value="">All</option></select>
</div>
<button class="apply-btn" id="apply-filters">Apply</button>
</div>
<div id="empty-state" class="empty-state" style="display:none;">
<p>No data available for the selected filters.</p>
<p>Try adjusting your filters or running an ingestion.</p>
</div>
<div id="charts-grid" class="charts-grid" style="display:none;">
<div class="chart-container full-width">
<div class="chart-title">Total Landings (Mass & Value)</div>
<div id="chart-line" style="width:100%; height:300px;"></div>
</div>
<div class="chart-container">
<div class="chart-title">Top Species by Value</div>
<div id="chart-donut" style="width:100%; height:300px;"></div>
</div>
<div class="chart-container">
<div class="chart-title">Landings by Month (Stacked by Species)</div>
<div id="chart-bar" style="width:100%; height:300px;"></div>
</div>
</div>
</div>
</main>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
<script src="/static/app.js"></script>
</body>
</html>
+331
View File
@@ -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; }
}