let chartLine, chartPrice, chartBar; let allMonths = []; const MONTHS_TO_SHOW_DEFAULT = 6; function formatMonthDisplay(monthCode) { const monthNames = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]; const year = monthCode.substring(0, 4); const monthIdx = parseInt(monthCode.substring(5, 7)) - 1; return `${monthNames[monthIdx]} ${year}`; } function formatDKK(value) { return new Intl.NumberFormat('da-DK', { minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(value) + ' DKK'; } function formatDKKShort(value) { if (value >= 1e9) return (value / 1e9).toFixed(1).replace('.', ',') + ' mia.'; if (value >= 1e6) return (value / 1e6).toFixed(1).replace('.', ',') + ' mio.'; if (value >= 1e3) return (value / 1e3).toFixed(0) + ' tus.'; return value.toString(); } function formatPricePerKg(value) { return new Intl.NumberFormat('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(value) + ' DKK/kg'; } function getNextUpdateDate() { const now = new Date(); const currentMonth = now.getMonth(); const currentYear = now.getFullYear(); const today = now.getDate(); let updateMonth = currentMonth; let updateYear = currentYear; if (today > 28) { updateMonth++; if (updateMonth > 11) { updateMonth = 0; updateYear++; } } const updateDate = new Date(updateYear, updateMonth, 28); const yyyy = updateDate.getFullYear(); const mm = String(updateDate.getMonth() + 1).padStart(2, '0'); const dd = String(updateDate.getDate()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}`; } function updateNavbarUpdateDate() { const container = document.getElementById('update-date-display'); if (container) { container.textContent = `Next update: ${getNextUpdateDate()}`; } } function getThemeColors() { const theme = document.documentElement.getAttribute('data-theme'); const isDark = theme === 'macchiato'; if (isDark) { return { isDark: true, textColor: '#cdd6f4', subTextColor: '#bac2de', gridColor: 'rgba(180, 185, 200, 0.12)', accent: '#89b4fa', secondary: '#94e2d5', palette: [ '#89b4fa', '#fab387', '#a6e3a1', '#f9e2af', '#f5c2e7', '#cba6f7', '#f38ba8', '#eba0ac', '#94e2d5', '#89dceb', ], tooltipBg: 'rgba(30, 32, 48, 0.95)', tooltipBorder: 'rgba(180, 185, 200, 0.2)', bgTransparent: 'transparent', }; } return { isDark: false, textColor: '#45475a', subTextColor: '#7f849c', gridColor: 'rgba(73, 77, 113, 0.08)', accent: '#89b4fa', secondary: '#94e2d5', palette: [ '#89b4fa', '#fab387', '#a6e3a1', '#f9e2af', '#f5c2e7', '#cba6f7', '#f38ba8', '#eba0ac', '#94e2d5', '#89dceb', ], tooltipBg: 'rgba(255, 255, 255, 0.98)', tooltipBorder: 'rgba(73, 77, 113, 0.15)', bgTransparent: 'transparent', }; } function initCharts() { if (chartLine) chartLine.dispose(); if (chartPrice) chartPrice.dispose(); if (chartBar) chartBar.dispose(); chartLine = echarts.init(document.getElementById('chart-line')); chartPrice = echarts.init(document.getElementById('chart-price')); chartBar = echarts.init(document.getElementById('chart-bar')); chartLine.setOption({ series: [] }); chartPrice.setOption({ series: [] }); chartBar.setOption({ series: [] }); setTimeout(() => { chartLine.resize(); chartPrice.resize(); chartBar.resize(); }, 0); window.addEventListener('resize', () => { chartLine.resize(); chartPrice.resize(); chartBar.resize(); }); } function renderLineChart(months, massData, valueData) { const c = getThemeColors(); const formattedMonths = months.map(m => formatMonthDisplay(m)); const step = months.length > 24 ? 6 : (months.length > 12 ? 3 : 1); const xLabels = formattedMonths.map((label, idx) => idx % step === 0 ? label : '' ); chartLine.setOption({ backgroundColor: c.bgTransparent, textStyle: { fontFamily: 'Noto Sans', color: c.textColor }, tooltip: { trigger: 'axis', axisPointer: { type: 'cross', lineStyle: { color: c.gridColor, type: 'dashed' }, crossStyle: { color: c.gridColor }, }, backgroundColor: c.tooltipBg, borderColor: c.tooltipBorder, borderWidth: 1, textStyle: { color: c.textColor, fontFamily: 'Noto Sans' }, }, legend: { data: ['Mass (kg)', 'Value (DKK)'], textStyle: { color: c.textColor, fontFamily: 'Noto Sans' }, top: 0, left: 'center', orient: 'horizontal', }, grid: { left: '8%', right: '10%', bottom: 50, containLabel: true, top: '18%', }, xAxis: { type: 'category', data: xLabels, axisLine: { lineStyle: { color: c.gridColor } }, axisLabel: { color: c.subTextColor, interval: 0, rotate: 0, fontSize: 10, margin: 8, overflow: 'break', width: 70, }, boundaryGap: false, }, yAxis: [ { type: 'value', name: 'Mass (kg)', position: 'left', splitLine: { lineStyle: { color: c.gridColor } }, axisLabel: { color: c.subTextColor, formatter: (val) => (val >= 1e6 ? (val / 1e6).toFixed(1) + 'M' : val.toLocaleString()), margin: 8, }, axisLine: { show: true, lineStyle: { color: c.gridColor } }, nameTextStyle: { color: c.accent, padding: [0, 0, 0, 0] }, }, { type: 'value', name: 'Value (DKK)', position: 'right', splitLine: { show: false }, axisLabel: { color: c.subTextColor, formatter: (val) => (val >= 1e6 ? (val / 1e6).toFixed(1) + 'M' : val.toLocaleString()), margin: 8, }, axisLine: { show: true, lineStyle: { color: c.gridColor } }, nameTextStyle: { color: c.secondary, padding: [0, 10, 0, 0] }, }, ], series: [ { name: 'Mass (kg)', type: 'line', yAxisIndex: 0, smooth: true, symbol: 'none', lineStyle: { width: 2, opacity: 0.9 }, itemStyle: { color: c.accent }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [ { offset: 0, color: c.isDark ? 'rgba(137, 180, 250, 0.22)' : 'rgba(137, 180, 250, 0.18)' }, { offset: 1, color: 'rgba(137, 180, 250, 0)' }, ], }, }, data: massData, }, { name: 'Value (DKK)', type: 'line', yAxisIndex: 1, smooth: true, symbol: 'none', lineStyle: { width: 2, opacity: 0.9 }, itemStyle: { color: c.secondary }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [ { offset: 0, color: c.isDark ? 'rgba(148, 226, 213, 0.22)' : 'rgba(148, 226, 213, 0.18)' }, { offset: 1, color: 'rgba(148, 226, 213, 0)' }, ], }, }, data: valueData, }, ], }); } function renderPriceChart(priceTrend) { const c = getThemeColors(); chartPrice.setOption({ backgroundColor: c.bgTransparent, textStyle: { fontFamily: 'Noto Sans', color: c.textColor }, tooltip: { trigger: 'axis', axisPointer: { type: 'cross' }, backgroundColor: c.tooltipBg, borderColor: c.tooltipBorder, borderWidth: 1, textStyle: { color: c.textColor, fontFamily: 'Noto Sans' }, formatter: (params) => { const p = params[0]; return `${p.axisValue}
${formatPricePerKg(p.value)}`; }, }, grid: { left: '8%', right: '10%', bottom: 50, containLabel: true, top: '18%', }, 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', name: 'Price per kg (DKK)', splitLine: { lineStyle: { color: c.gridColor, type: 'dashed' } }, axisLabel: { color: c.subTextColor, formatter: (val) => formatPricePerKg(val), margin: 8, }, axisLine: { show: true, lineStyle: { color: c.gridColor } }, nameTextStyle: { color: c.accent, padding: [0, 0, 0, 0] }, }, series: [ { name: 'Price per kg', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 3 }, itemStyle: { color: c.secondary }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [ { offset: 0, color: c.isDark ? 'rgba(148, 226, 213, 0.3)' : 'rgba(148, 226, 213, 0.2)' }, { offset: 1, color: 'rgba(148, 226, 213, 0)' }, ], }, }, data: priceTrend.map(d => d.price_per_kg), }, ], }); } function renderBarChart(months, breakdown, showOthers) { const c = getThemeColors(); if (months.length === 0 || breakdown.length === 0) { chartBar.setOption({ series: [] }); return; } const formattedMonths = months.map(m => formatMonthDisplay(m)); const step = months.length > 36 ? 12 : (months.length > 18 ? 6 : (months.length > 12 ? 3 : 1)); const xLabels = formattedMonths.map((label, idx) => 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 barMaxWidth = Math.max(15, Math.floor((chartWidth * 0.7) / (months.length * 1.5))); chartBar.setOption({ backgroundColor: c.bgTransparent, textStyle: { fontFamily: 'Noto Sans', color: c.textColor }, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, backgroundColor: c.tooltipBg, borderColor: c.tooltipBorder, borderWidth: 1, textStyle: { color: c.textColor, fontFamily: 'Noto Sans' }, formatter: (params) => { const month = params[0]?.axisValue || ''; let html = `${month}
`; [...params].reverse().forEach(p => { const val = p.value || 0; if (val > 0) { html += `${p.marker} ${p.seriesName}: ${formatDKK(val)}
`; } }); return html; }, }, legend: { textStyle: { color: c.textColor, fontFamily: 'Noto Sans' }, top: 0, left: 'center', orient: 'horizontal', type: 'scroll', maxItems: 8, padding: [0, 0, 0, 0], itemGap: 10, }, grid: { left: '8%', right: '5%', bottom: 65, containLabel: true, top: '25%', }, xAxis: { type: 'category', data: xLabels, axisLine: { lineStyle: { color: c.gridColor } }, axisLabel: { color: c.subTextColor, interval: 0, fontSize: 9, rotate: 0, margin: 8, overflow: 'break', width: 60, }, axisTick: { show: false }, }, yAxis: { type: 'value', splitLine: { lineStyle: { color: c.gridColor, type: 'dashed' } }, axisLabel: { color: c.subTextColor, formatter: (val) => formatDKKShort(val), margin: 8, }, nameTextStyle: { padding: [0, 0, 0, 10] }, }, series: seriesData.map((s, idx) => ({ ...s, barMaxWidth: barMaxWidth, itemStyle: { color: c.palette[idx % c.palette.length], }, label: { show: false, }, emphasis: { itemStyle: { shadowBlur: 0, }, }, })), }); } 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 populateDropdown(containerId, items, codeKey, labelKey) { const container = document.getElementById(containerId); if (!container) return; const checkboxList = container.querySelector('.checkbox-dropdown-list'); checkboxList.innerHTML = ''; const selectAllLabel = document.createElement('label'); selectAllLabel.className = 'checkbox-item select-all-wrapper'; selectAllLabel.innerHTML = ` All `; checkboxList.appendChild(selectAllLabel); const separator = document.createElement('div'); separator.className = 'dropdown-separator'; checkboxList.appendChild(separator); items.forEach((item) => { const label = document.createElement('label'); label.className = 'checkbox-item'; label.innerHTML = ` ${item[labelKey]} `; checkboxList.appendChild(label); }); const selectAllCb = selectAllLabel.querySelector('.select-all'); selectAllCb.addEventListener('change', (e) => { const cbs = checkboxList.querySelectorAll(`input[name="${containerId}"]`); cbs.forEach(cb => { if (!cb.disabled) { cb.checked = e.target.checked; } }); updateDropdownLabel(containerId); }); checkboxList.querySelectorAll(`input[name="${containerId}"]`).forEach(cb => { cb.addEventListener('change', () => updateDropdownLabel(containerId)); }); updateDropdownLabel(containerId); } function updateDropdownLabel(containerId) { const container = document.getElementById(containerId); if (!container) return; const cbs = container.querySelectorAll(`input[name="${containerId}"]`); const checked = Array.from(cbs).filter(cb => cb.checked && !cb.disabled); const labelEl = container.querySelector('.dropdown-label'); if (checked.length === 0) { labelEl.textContent = 'None selected'; } else if (checked.length === cbs.length) { labelEl.textContent = 'All selected'; } else { labelEl.textContent = `${checked.length} selected`; } } function toggleDropdown(containerId) { const container = document.getElementById(containerId); if (!container) return; const list = container.querySelector('.checkbox-dropdown-list'); const isOpen = list.classList.contains('open'); document.querySelectorAll('.checkbox-dropdown-list.open').forEach(el => { el.classList.remove('open'); }); if (!isOpen) { list.classList.add('open'); } } function getSelectedValues(groupName) { const checkboxes = document.querySelectorAll(`input[name="${groupName}"]:checked`); return Array.from(checkboxes).filter(cb => !cb.disabled).map(cb => cb.value); } function populateMonthSelect(selectId, months) { const sel = document.getElementById(selectId); sel.innerHTML = ''; months.forEach((m) => { const opt = document.createElement('option'); opt.value = m; opt.textContent = formatMonthDisplay(m); 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) { populateDropdown('species', speciesList, 'code', 'label'); } if (zonesList) { populateDropdown('zone', zonesList, 'code', 'label'); } if (gearList) { populateDropdown('gear', gearList, 'code', 'label'); } if (summaryData && summaryData.monthly) { allMonths = summaryData.monthly.map((m) => m.month).sort(); populateMonthSelect('month_from', allMonths); populateMonthSelect('month_to', allMonths); const fromIdx = Math.max(0, allMonths.length - MONTHS_TO_SHOW_DEFAULT); document.getElementById('month_from').value = allMonths[fromIdx]; document.getElementById('month_to').value = allMonths[allMonths.length - 1]; } await updateAvailableFilters(); showLoading(false); } async function updateAvailableFilters() { const species = getSelectedValues('species'); const zone = getSelectedValues('zone'); const gear = getSelectedValues('gear'); const monthFrom = document.getElementById('month_from').value; const monthTo = document.getElementById('month_to').value; const params = new URLSearchParams(); if (species.length > 0) params.append('species_multi', species.join(',')); if (zone.length > 0) params.append('zone_multi', zone.join(',')); if (gear.length > 0) params.append('gear_multi', gear.join(',')); if (monthFrom) params.append('month_from', monthFrom); if (monthTo) params.append('month_to', monthTo); const available = await fetchData(`/api/available-filters?${params.toString()}`); if (available) { disableUnavailableOptions('species', available.species); disableUnavailableOptions('zone', available.zones); disableUnavailableOptions('gear', available.gear); } } function disableUnavailableOptions(containerId, availableCodes) { const container = document.getElementById(containerId); if (!container) return; const checkboxes = container.querySelectorAll(`input[type="checkbox"]`); checkboxes.forEach(cb => { if (cb.classList.contains('select-all')) return; const isAvailable = availableCodes.includes(cb.value); cb.disabled = !isAvailable; cb.parentElement.style.opacity = isAvailable ? '1' : '0.4'; cb.parentElement.style.cursor = isAvailable ? 'pointer' : 'not-allowed'; if (!isAvailable && cb.checked) { cb.checked = false; } }); updateDropdownLabel(containerId); } async function updateCharts() { showLoading(true); hideEmptyState(); const species = getSelectedValues('species'); const zone = getSelectedValues('zone'); const gear = getSelectedValues('gear'); const monthFrom = document.getElementById('month_from').value; const monthTo = document.getElementById('month_to').value; if (monthFrom && monthTo && monthFrom > monthTo) { alert('From date cannot be greater than To date.'); showLoading(false); return; } const summaryParams = new URLSearchParams(); if (species.length > 0) summaryParams.append('species_multi', species.join(',')); if (zone.length > 0) summaryParams.append('zone_multi', zone.join(',')); if (gear.length > 0) summaryParams.append('gear_multi', gear.join(',')); if (monthFrom) summaryParams.append('month_from', monthFrom); if (monthTo) summaryParams.append('month_to', monthTo); const [summary, monthlyBreakdown] = await Promise.all([ fetchData(`/api/summary?${summaryParams.toString()}`), fetchData(`/api/summary/monthly-breakdown?${summaryParams.toString()}`), ]); if (!summary || !summary.monthly || summary.monthly.length === 0) { showLoading(false); showEmptyState(); return; } const months = summary.monthly.map(m => m.month).sort(); const showAllSpecies = document.getElementById('show-all-species')?.checked ?? false; if (monthlyBreakdown && monthlyBreakdown.length > 0) { renderBarChart(months, monthlyBreakdown, showAllSpecies); } else { renderBarChart([], [], false); } const massData = summary.monthly.map((m) => m.total_mass || 0); const valueData = summary.monthly.map((m) => m.total_value || 0); renderLineChart(months, massData, valueData); const priceTrend = summary.price_trend ?? []; if (priceTrend.length > 0) { renderPriceChart(priceTrend); } else { chartPrice.setOption({ series: [] }); } showLoading(false); hideEmptyState(); 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(() => { chartLine.resize(); chartPrice.resize(); chartBar.resize(); }, 50); } 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('charts-grid').style.display = 'grid'; document.getElementById('empty-state').style.display = 'none'; } document.getElementById('apply-filters').addEventListener('click', updateCharts); document.getElementById('show-all-species')?.addEventListener('change', updateCharts); document.addEventListener('click', (e) => { if (!e.target.closest('.filter-group.multi-select')) { document.querySelectorAll('.checkbox-dropdown-list.open').forEach(el => { el.classList.remove('open'); }); } }); (() => { function currentSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'macchiato' : 'light'; } const btn = document.getElementById('theme-toggle'); if (btn) { btn.addEventListener('click', () => { const current = document.documentElement.getAttribute('data-theme') || 'light'; const next = current === 'light' ? 'macchiato' : '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(); } }); })(); document.addEventListener('DOMContentLoaded', () => { initCharts(); updateNavbarUpdateDate(); ['species', 'zone', 'gear'].forEach(id => { const container = document.getElementById(id); if (container) { const label = container.querySelector('.dropdown-label'); if (label) { label.addEventListener('click', (e) => { e.stopPropagation(); toggleDropdown(id); }); } const list = container.querySelector('.checkbox-dropdown-list'); if (list) { list.addEventListener('change', () => { updateAvailableFilters(); }); } } }); document.getElementById('month_from').addEventListener('change', updateAvailableFilters); document.getElementById('month_to').addEventListener('change', updateAvailableFilters); populateDropdowns().then(updateCharts); });