frontend done

This commit is contained in:
2026-08-26 00:09:11 +01:00
parent f9cfb99916
commit 1c281c4dc8
3 changed files with 180 additions and 129 deletions
+148 -123
View File
@@ -1,4 +1,4 @@
let chartLine, chartBar, chartDonut; let chartLine, chartPrice, chartBar;
let allMonths = []; let allMonths = [];
const MONTHS_TO_SHOW_DEFAULT = 6; const MONTHS_TO_SHOW_DEFAULT = 6;
@@ -26,6 +26,13 @@ function formatDKKShort(value) {
return value.toString(); return value.toString();
} }
function formatPricePerKg(value) {
return new Intl.NumberFormat('da-DK', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value) + ' DKK/kg';
}
function getNextUpdateDate() { function getNextUpdateDate() {
const now = new Date(); const now = new Date();
const currentMonth = now.getMonth(); const currentMonth = now.getMonth();
@@ -99,13 +106,13 @@ function getThemeColors() {
'#89b4fa', '#89b4fa',
'#fab387', '#fab387',
'#a6e3a1', '#a6e3a1',
'#f9e2af', '#f9e2af',
'#f5c2e7', '#f5c2e7',
'#cba6f7', '#cba6f7',
'#f38ba8', '#f38ba8',
'#eba0ac', '#eba0ac',
'#94e2d5', '#94e2d5',
'#89dceb', '#89dceb',
], ],
tooltipBg: 'rgba(255, 255, 255, 0.98)', tooltipBg: 'rgba(255, 255, 255, 0.98)',
tooltipBorder: 'rgba(73, 77, 113, 0.15)', tooltipBorder: 'rgba(73, 77, 113, 0.15)',
@@ -115,21 +122,27 @@ function getThemeColors() {
function initCharts() { function initCharts() {
if (chartLine) chartLine.dispose(); if (chartLine) chartLine.dispose();
if (chartPrice) chartPrice.dispose();
if (chartBar) chartBar.dispose(); if (chartBar) chartBar.dispose();
if (chartDonut) chartDonut.dispose();
chartLine = echarts.init(document.getElementById('chart-line')); chartLine = echarts.init(document.getElementById('chart-line'));
chartPrice = echarts.init(document.getElementById('chart-price'));
chartBar = echarts.init(document.getElementById('chart-bar')); chartBar = echarts.init(document.getElementById('chart-bar'));
chartDonut = echarts.init(document.getElementById('chart-donut'));
renderLineChart([], [], []); chartLine.setOption({ series: [] });
renderBarChart([], []); chartPrice.setOption({ series: [] });
renderTopSpeciesChart([]); chartBar.setOption({ series: [] });
setTimeout(() => {
chartLine.resize();
chartPrice.resize();
chartBar.resize();
}, 0);
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
chartLine.resize(); chartLine.resize();
chartPrice.resize();
chartBar.resize(); chartBar.resize();
chartDonut.resize();
}); });
} }
@@ -260,103 +273,83 @@ function renderLineChart(months, massData, valueData) {
}); });
} }
function renderTopSpeciesChart(data) { function renderPriceChart(priceTrend) {
const c = getThemeColors(); const c = getThemeColors();
const top5 = data.slice(0, 5); chartPrice.setOption({
if (top5.length === 0) {
chartDonut.setOption({ series: [] });
return;
}
const sorted = [...top5].sort((a, b) => a.value - b.value);
const speciesLabels = sorted.map(d => d.name);
const values = sorted.map(d => d.value);
chartDonut.setOption({
backgroundColor: c.bgTransparent, backgroundColor: c.bgTransparent,
textStyle: { fontFamily: 'Noto Sans', color: c.textColor }, textStyle: { fontFamily: 'Noto Sans', color: c.textColor },
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' }, axisPointer: { type: 'cross' },
backgroundColor: c.tooltipBg, backgroundColor: c.tooltipBg,
borderColor: c.tooltipBorder, borderColor: c.tooltipBorder,
borderWidth: 1, borderWidth: 1,
textStyle: { color: c.textColor, fontFamily: 'Noto Sans' }, textStyle: { color: c.textColor, fontFamily: 'Noto Sans' },
formatter: (params) => { formatter: (params) => {
const p = params[0]; const p = params[0];
return `${p.name}<br/>${formatDKK(p.value)}`; return `<strong>${p.axisValue}</strong><br/>${formatPricePerKg(p.value)}`;
}, },
}, },
grid: { grid: {
left: '5%', left: '8%',
right: '20%', right: '10%',
bottom: 60, bottom: 50,
top: 10,
containLabel: true, containLabel: true,
top: '18%',
}, },
xAxis: { xAxis: {
type: 'category',
data: priceTrend.map(d => formatMonthDisplay(d.month)),
axisLine: { lineStyle: { color: c.gridColor } },
axisLabel: {
color: c.subTextColor,
interval: priceTrend.length > 24 ? 6 : (priceTrend.length > 12 ? 3 : 1),
fontSize: 10,
rotate: 0,
margin: 8,
},
},
yAxis: {
type: 'value', type: 'value',
name: 'Price per kg (DKK)',
splitLine: { lineStyle: { color: c.gridColor, type: 'dashed' } }, splitLine: { lineStyle: { color: c.gridColor, type: 'dashed' } },
axisLabel: { axisLabel: {
color: c.subTextColor, color: c.subTextColor,
formatter: (val) => formatDKKShort(val), formatter: (val) => formatPricePerKg(val),
fontSize: 9, margin: 8,
hideOverlap: true,
},
axisLine: { show: false },
axisTick: { show: false },
},
yAxis: {
type: 'category',
data: speciesLabels,
axisLine: { lineStyle: { color: c.gridColor } },
axisTick: { show: false },
axisLabel: {
color: c.textColor,
fontSize: 11,
fontWeight: 'bold',
width: 100,
overflow: 'truncate',
interval: 'auto',
}, },
axisLine: { show: true, lineStyle: { color: c.gridColor } },
nameTextStyle: { color: c.accent, padding: [0, 0, 0, 0] },
}, },
series: [ series: [
{ {
type: 'bar', name: 'Price per kg',
data: values.map((v, i) => ({ type: 'line',
value: v, smooth: true,
itemStyle: { symbol: 'none',
color: c.palette[i], lineStyle: { width: 3 },
borderRadius: [0, 4, 4, 0], itemStyle: { color: c.secondary },
}, areaStyle: {
})), color: {
barMaxWidth: 28, type: 'linear',
barGap: '30%', x: 0, y: 0, x2: 0, y2: 1,
label: { colorStops: [
show: true, { offset: 0, color: c.isDark ? 'rgba(148, 226, 213, 0.3)' : 'rgba(148, 226, 213, 0.2)' },
position: 'right', { offset: 1, color: 'rgba(148, 226, 213, 0)' },
formatter: (params) => formatDKKShort(params.value), ],
color: c.subTextColor,
fontSize: 10,
distance: 8,
overflow: 'break',
},
emphasis: {
itemStyle: {
shadowBlur: 0,
}, },
}, },
data: priceTrend.map(d => d.price_per_kg),
}, },
], ],
}); });
} }
function renderBarChart(months, seriesData) { function renderBarChart(months, breakdown, showOthers) {
const c = getThemeColors(); const c = getThemeColors();
if (months.length === 0 || seriesData.length === 0) { if (months.length === 0 || breakdown.length === 0) {
chartBar.setOption({ series: [] }); chartBar.setOption({ series: [] });
return; return;
} }
@@ -368,6 +361,49 @@ function renderBarChart(months, seriesData) {
idx % step === 0 ? label : '' idx % step === 0 ? label : ''
); );
const speciesMonthlyMap = {};
const speciesLabels = {};
const speciesTotals = {};
breakdown.forEach(row => {
if (!speciesMonthlyMap[row.species_code]) {
speciesMonthlyMap[row.species_code] = {};
speciesLabels[row.species_code] = row.species_label || row.species_code;
speciesTotals[row.species_code] = 0;
}
speciesMonthlyMap[row.species_code][row.month] = row.total_value || 0;
speciesTotals[row.species_code] += row.total_value || 0;
});
const rankedSpecies = Object.entries(speciesTotals)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([code]) => code);
const otherValues = months.map(m => {
return Object.keys(speciesMonthlyMap)
.filter(code => !rankedSpecies.includes(code))
.reduce((sum, code) => sum + (speciesMonthlyMap[code][m] || 0), 0);
});
let seriesData = rankedSpecies.map(code => ({
name: speciesLabels[code],
type: 'bar',
stack: 'total',
emphasis: { focus: 'series' },
data: months.map(m => speciesMonthlyMap[code][m] || 0),
}));
if (showOthers) {
seriesData.push({
name: 'Other species',
type: 'bar',
stack: 'total',
emphasis: { focus: 'series' },
data: otherValues,
});
}
const chartWidth = chartBar.getWidth(); const chartWidth = chartBar.getWidth();
const barMaxWidth = Math.max(15, Math.floor((chartWidth * 0.7) / (months.length * 1.5))); const barMaxWidth = Math.max(15, Math.floor((chartWidth * 0.7) / (months.length * 1.5)));
@@ -384,9 +420,11 @@ function renderBarChart(months, seriesData) {
formatter: (params) => { formatter: (params) => {
const month = params[0]?.axisValue || ''; const month = params[0]?.axisValue || '';
let html = `<strong>${month}</strong><br/>`; let html = `<strong>${month}</strong><br/>`;
params.forEach(p => { [...params].reverse().forEach(p => {
const val = p.value || 0; const val = p.value || 0;
html += `${p.marker} ${p.seriesName}: ${formatDKK(val)}<br/>`; if (val > 0) {
html += `${p.marker} ${p.seriesName}: ${formatDKK(val)}<br/>`;
}
}); });
return html; return html;
}, },
@@ -397,7 +435,7 @@ function renderBarChart(months, seriesData) {
left: 'center', left: 'center',
orient: 'horizontal', orient: 'horizontal',
type: 'scroll', type: 'scroll',
maxItems: 5, maxItems: 8,
padding: [0, 0, 0, 0], padding: [0, 0, 0, 0],
itemGap: 10, itemGap: 10,
}, },
@@ -678,69 +716,53 @@ async function updateCharts() {
} }
const months = summary.monthly.map(m => m.month).sort(); const months = summary.monthly.map(m => m.month).sort();
const topSpecies = summary.top_species ? summary.top_species.slice(0, 5) : [];
if (monthlyBreakdown && monthlyBreakdown.length > 0 && topSpecies.length > 0) { const showAllSpecies = document.getElementById('show-all-species')?.checked ?? false;
const speciesMonthlyMap = {};
monthlyBreakdown.forEach(row => { if (monthlyBreakdown && monthlyBreakdown.length > 0) {
if (!speciesMonthlyMap[row.species_code]) { renderBarChart(months, monthlyBreakdown, showAllSpecies);
speciesMonthlyMap[row.species_code] = {};
}
speciesMonthlyMap[row.species_code][row.month] = row.total_value || 0;
});
const topSpeciesCodes = topSpecies.map(s => s.species_code);
const filteredBreakdown = monthlyBreakdown.filter(row =>
topSpeciesCodes.includes(row.species_code)
);
const barSeries = topSpecies.map(sp => {
const data = months.map(month => {
const matching = filteredBreakdown.find(
row => row.month === month && row.species_code === sp.species_code
);
return matching ? matching.total_value || 0 : 0;
});
return {
name: sp.species_label,
type: 'bar',
stack: 'total',
emphasis: { focus: 'series' },
data: data,
};
});
renderBarChart(months, barSeries);
} else { } else {
renderBarChart([], []); renderBarChart([], [], false);
} }
const massData = summary.monthly.map((m) => m.total_mass || 0); const massData = summary.monthly.map((m) => m.total_mass || 0);
const valueData = summary.monthly.map((m) => m.total_value || 0); const valueData = summary.monthly.map((m) => m.total_value || 0);
renderLineChart(months, massData, valueData); renderLineChart(months, massData, valueData);
if (summary.top_species && summary.top_species.length > 0) { const priceTrend = summary.price_trend ?? [];
const topData = summary.top_species.map((s) => ({ if (priceTrend.length > 0) {
name: s.species_label, renderPriceChart(priceTrend);
value: s.total_value || 0,
}));
renderTopSpeciesChart(topData);
} else { } else {
renderTopSpeciesChart([]); chartPrice.setOption({ series: [] });
} }
showLoading(false); showLoading(false);
hideEmptyState(); hideEmptyState();
document.getElementById('charts-grid').style.display = 'grid'; document.getElementById('charts-grid').style.display = 'grid';
chartLine.resize();
chartPrice.resize();
chartBar.resize();
renderLineChart(months, massData, valueData);
if (priceTrend.length > 0) {
renderPriceChart(priceTrend);
} else {
chartPrice.setOption({ series: [] });
}
if (monthlyBreakdown && monthlyBreakdown.length > 0) {
renderBarChart(months, monthlyBreakdown, showAllSpecies);
} else {
renderBarChart([], [], false);
}
setTimeout(() => { setTimeout(() => {
chartLine.resize(); chartLine.resize();
chartPrice.resize();
chartBar.resize(); chartBar.resize();
chartDonut.resize(); }, 50);
}, 0);
} }
function showLoading(show) { function showLoading(show) {
@@ -758,11 +780,14 @@ function showEmptyState() {
} }
function hideEmptyState() { function hideEmptyState() {
document.getElementById('charts-grid').style.display = 'grid';
document.getElementById('empty-state').style.display = 'none'; document.getElementById('empty-state').style.display = 'none';
} }
document.getElementById('apply-filters').addEventListener('click', updateCharts); document.getElementById('apply-filters').addEventListener('click', updateCharts);
document.getElementById('show-all-species')?.addEventListener('change', updateCharts);
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!e.target.closest('.filter-group.multi-select')) { if (!e.target.closest('.filter-group.multi-select')) {
document.querySelectorAll('.checkbox-dropdown-list.open').forEach(el => { document.querySelectorAll('.checkbox-dropdown-list.open').forEach(el => {
+12 -6
View File
@@ -13,9 +13,9 @@
})(); })();
</script> </script>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@300;400;500;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans: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> <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
@@ -57,6 +57,12 @@
<label for="month_to">To</label> <label for="month_to">To</label>
<select id="month_to"><option value="">All</option></select> <select id="month_to"><option value="">All</option></select>
</div> </div>
<div class="filter-group">
<label class="toggle-label">
<input type="checkbox" id="show-all-species">
<span>Show "Other species" bucket</span>
</label>
</div>
<button class="apply-btn" id="apply-filters">Apply</button> <button class="apply-btn" id="apply-filters">Apply</button>
</div> </div>
</div> </div>
@@ -75,12 +81,12 @@
<div id="charts-grid" class="charts-grid" style="display:none;"> <div id="charts-grid" class="charts-grid" style="display:none;">
<div class="chart-container full-width"> <div class="chart-container full-width">
<div class="chart-title">Total Landings (Mass & Value)</div> <div class="chart-title">Total Landings (Mass &amp; Value)</div>
<div id="chart-line" style="width:100%; height:420px;"></div> <div id="chart-line" style="width:100%; height:420px;"></div>
</div> </div>
<div class="chart-container"> <div class="chart-container">
<div class="chart-title">Top Species by Value</div> <div class="chart-title">Price per kg Trend</div>
<div id="chart-donut" style="width:100%; height:380px;"></div> <div id="chart-price" style="width:100%; height:380px;"></div>
</div> </div>
<div class="chart-container"> <div class="chart-container">
<div class="chart-title">Monthly Value by Top Species</div> <div class="chart-title">Monthly Value by Top Species</div>
+20
View File
@@ -506,3 +506,23 @@ footer {
.nav-brand .brand-sep { display: none; } .nav-brand .brand-sep { display: none; }
.dashboard { padding: 1rem; } .dashboard { padding: 1rem; }
} }
.toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
cursor: pointer;
color: var(--ink-muted);
padding: 0.35rem 0;
}
.toggle-label input[type="checkbox"] {
accent-color: var(--accent);
cursor: pointer;
}
.toggle-label span {
line-height: 1.4;
}