let chartLine, chartBar, chartDonut; 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 getThemeColors() { const theme = document.documentElement.getAttribute('data-theme'); const isDark = theme === 'macchiato'; 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 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([], []); renderTopSpeciesChart([]); window.addEventListener('resize', () => { chartLine.resize(); chartBar.resize(); chartDonut.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' } }, legend: { data: ['Mass (kg)', 'Value (DKK)'], textStyle: { color: c.textColor }, 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.textColor, 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.accent, formatter: (val) => (val >= 1e6 ? (val / 1e6).toFixed(1) + 'M' : val.toLocaleString()), margin: 8 }, axisLine: { show: true, lineStyle: { color: c.accent } }, nameTextStyle: { color: c.accent, padding: [0, 0, 0, 0] } }, { type: 'value', name: 'Value (DKK)', position: 'right', splitLine: { show: false }, axisLabel: { color: c.secondary, formatter: (val) => (val >= 1e6 ? (val / 1e6).toFixed(1) + 'M' : val.toLocaleString()), margin: 8 }, axisLine: { show: true, lineStyle: { color: c.secondary } }, nameTextStyle: { color: c.secondary, padding: [0, 10, 0, 0] } } ], series: [ { name: 'Mass (kg)', type: 'line', yAxisIndex: 0, smooth: true, symbol: 'none', lineStyle: { width: 2 }, itemStyle: { color: c.accent }, areaStyle: { opacity: 0.05 }, data: massData, }, { name: 'Value (DKK)', type: 'line', yAxisIndex: 1, smooth: true, symbol: 'none', lineStyle: { width: 2 }, itemStyle: { color: c.secondary }, areaStyle: { opacity: 0.05 }, data: valueData, }, ], }); } function renderTopSpeciesChart(data) { const c = getThemeColors(); const top5 = data.slice(0, 5); 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, textStyle: { fontFamily: 'Noto Sans', color: c.textColor }, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: (params) => { const p = params[0]; return `${p.name}
${formatDKK(p.value)}`; } }, grid: { left: '5%', right: '20%', bottom: 60, top: 10, containLabel: true, }, xAxis: { type: 'value', splitLine: { lineStyle: { color: c.gridColor, type: 'dashed' } }, axisLabel: { color: c.textColor, formatter: (val) => formatDKKShort(val), fontSize: 9, 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', }, }, series: [ { type: 'bar', data: values, barMaxWidth: 28, barGap: '30%', itemStyle: { borderRadius: [0, 4, 4, 0], }, label: { show: true, position: 'right', formatter: (params) => formatDKKShort(params.value), color: c.textColor, fontSize: 10, distance: 8, overflow: 'break', }, emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.3)', }, }, }, ], }); } function renderBarChart(months, seriesData) { const c = getThemeColors(); if (months.length === 0 || seriesData.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 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' }, formatter: (params) => { const month = params[0]?.axisValue || ''; let html = `${month}
`; params.forEach(p => { const val = p.value || 0; html += `${p.marker} ${p.seriesName}: ${formatDKK(val)}
`; }); return html; }, extraCssText: 'background: rgba(0,0,0,0.8); padding: 8px;' }, legend: { textStyle: { color: c.textColor }, top: 0, left: 'center', orient: 'horizontal', type: 'scroll', maxItems: 5, 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.textColor, 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.textColor, 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 } })), }); } 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 topSpecies = summary.top_species ? summary.top_species.slice(0, 5) : []; if (monthlyBreakdown && monthlyBreakdown.length > 0 && topSpecies.length > 0) { const speciesMonthlyMap = {}; monthlyBreakdown.forEach(row => { if (!speciesMonthlyMap[row.species_code]) { 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 { renderBarChart([], []); } const massData = summary.monthly.map((m) => m.total_mass || 0); const valueData = summary.monthly.map((m) => m.total_value || 0); renderLineChart(months, massData, valueData); if (summary.top_species && summary.top_species.length > 0) { const topData = summary.top_species.map((s) => ({ name: s.species_label, value: s.total_value || 0, })); renderTopSpeciesChart(topData); } else { renderTopSpeciesChart([]); } showLoading(false); hideEmptyState(); document.getElementById('charts-grid').style.display = 'grid'; setTimeout(() => { chartLine.resize(); chartBar.resize(); chartDonut.resize(); }, 0); } 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); // Close dropdowns when clicking outside 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(); // Setup click handlers for dropdown toggles ['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); }); } // Listen for changes inside the dropdown list 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); });