import { COLORS, SUBTYPE_COLORS, } from "./globals.js"; const standardPrograms = ['SARI', 'ILI', 'LBM', 'AFI', 'NDS']; const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase(); let map; document.addEventListener("DOMContentLoaded", () => { if (!standardPrograms.includes(programCode)) return; new DashboardFilter((startYear, startWeek, endYear, endWeek) => { document.querySelectorAll(".report-period") .forEach(el => { el.textContent = `Week ${startWeek} of ${startYear} to Week ${endWeek} of ${endYear}`; }); fetch( `/api/dashboard/program?surveillance_id=${window.SURVEILLANCE_ID}` + `&start_year=${startYear}&start_week=${startWeek}` + `&end_year=${endYear}&end_week=${endWeek}` ) .then(res => res.json()) .then(programCode === 'AFI' ? renderAFIDashboard : renderDashboard ) .catch(err => console.error("Dashboard API error:", err)); }); }); function buildDistributionChart( id, type, rows, labelKey, valueKey = 'total', colorResolver = null ) { const labels = rows.map(r => r[labelKey]); buildChart( id, type, labels, rows.map(r => r[valueKey]) ); if (!charts[id]) return; charts[id].data.datasets[0].backgroundColor = labels.map( (label, index) => colorResolver ? colorResolver(label, index) : COLORS[index % COLORS.length] ); charts[id].update(); } function renderTrend(valueId, changeId, current, previous, suffix = '') { const valueEl = document.getElementById(valueId); const changeEl = document.getElementById(changeId); if (!valueEl || !changeEl) return; valueEl.textContent = current + suffix; if (!previous) { changeEl.innerHTML = "— No previous data"; changeEl.className = "text-muted"; return; } const diff = current - previous; const percent = ((diff / previous) * 100).toFixed(1); if (diff > 0) { changeEl.innerHTML = `↑ +${percent}% from previous week`; changeEl.className = "text-success"; } else if (diff < 0) { changeEl.innerHTML = `↓ ${percent}% from previous week`; changeEl.className = "text-danger"; } else { changeEl.innerHTML = "— No significant change"; changeEl.className = "text-muted"; } } function getSubtypeColor(label, index = 0) { const specialColors = { 'A/H5N1': '#dc2626', 'Influenza': '#b90c00' }; return specialColors[label] || COLORS[index % COLORS.length]; } function renderSummary(summary = {}) { const mappings = [ ['totalCases', 'casesChange', summary.cases], ['influenzaRate', 'influenzaChange', summary.influenza_rate, '%'], ['covidRate', 'covidChange', summary.covid_rate, '%'], ['hospitalRate', 'hospitalChange', summary.hospital_rate, '%'], ['icuRate', 'icuChange', summary.icu_rate, '%'], ['positivityRate', 'positivityChange', summary.positivity_rate, '%'] ]; mappings.forEach(([valueId, changeId, data = {}, suffix = '']) => { renderTrend( valueId, changeId, data.current || 0, data.previous || 0, suffix ); }); } function renderDashboard(data = {}) { renderProgramTrend(data.trend || []); renderSummary(data.summary); renderProvinceHeatmap(data.province_distribution || []); buildDistributionChart( 'pathogenChart', 'doughnut', (data.pathogen_distribution || []) .sort((a, b) => b.total - a.total), 'pathogen', 'total', getSubtypeColor ); buildDistributionChart( 'ageChart', 'doughnut', data.age_distribution || [], 'age_group' ); buildDistributionChart( 'sexChart', 'bar', data.sex_distribution || [], 'patient_sex' ); buildDistributionChart( 'subtypeChart', 'bar', data.subtype_distribution || [], 'subtype', 'total', getSubtypeColor ); buildDistributionChart( 'sentinelChart', 'pie', data.sentinel_sites || [], 'name' ); } function renderAFIDashboard(data = {}) { const trend = data.afi_trend || {}; const positivity = data.afi_case_trend || {}; renderAFITrend( data.afi_case_trend.section_1, 'afiSection1Trend', COLORS, 'trend' ); renderAFITrend( data.afi_case_trend.section_2, 'afiSection2Trend', COLORS, 'trend' ); renderAFITrend( data.afi_case_trend.section_3, 'afiSection3Trend', COLORS, 'trend' ); // DOUGHNUTS renderAFITrend( data.afi_case_trend.section_1, 'afiPcrChart', COLORS, 'donut' ); renderAFITrend( data.afi_case_trend.section_2, 'afiMultiplexChart', COLORS, 'donut' ); renderAFITrend( data.afi_case_trend.section_3, 'afiElisaChart', COLORS, 'donut' ); renderSummary(data.summary); renderProvinceHeatmap(data.province_distribution); renderDemographics(data); renderPathogenChart(data.pathogen_distribution || []); renderSentinel(data.sentinel_sites || []); renderSubtypeChart(data.subtype_distribution || []); } function normalizeProvince(name, validSet) { if (!name || !validSet) return null; const clean = str => str.toLowerCase().replace(/\s+/g, ''); const raw = name.trim(); const provinceMap = { "kepville": "Kep", "sihanoukville": "Preah Sihanouk", "sihanoukvillecity": "Preah Sihanouk", "krongpailin": "Pailin", "mondulkiri": "Mondulkiri", "odormeanchey": "Oddar Meanchey", "tbongkhmom": "Tboung Khmum", "tboungkhmum": "Tboung Khmum", "rattanakiri": "Ratanak Kiri" }; const key = clean(raw); if (provinceMap[key] && validSet.has(provinceMap[key])) { return provinceMap[key]; } const match = [...validSet] .find(p => clean(p) === key); return match || null; } window.normalizeProvince = normalizeProvince; function renderProvinceHeatmap(rows = []) { window.latestProvinceData = rows; if (map) { map.remove(); } map = L.map('provinceMap') .setView([12.7, 104.9], 7); window.map = map; L.tileLayer( 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' } ).addTo(map); addProvinceLegend(); fetch('/geo/cambodia_provinces.geojson') .then(r => r.json()) .then(geo => { const validProvinces = new Set( geo.features.map(f => f.properties.ADM1_EN) ); window.validProvinces = validProvinces; const totals = {}; rows.forEach(r => { const province = normalizeProvince( r.patient_province, validProvinces ); if (!province) return; totals[province] ??= { total: 0, positive: 0 }; totals[province].total += Number(r.total || 0); totals[province].positive += Number(r.positive || 0); }); const getColor = value => { if (value > 50) return "#b91c1c"; if (value >= 10) return "#ef4444"; if (value > 0) return "#fecaca"; return "#f3f4f600"; }; window.provinceLayer = L.geoJSON(geo, { style: feature => { const province = feature.properties.ADM1_EN; const value = totals[province]?.total || 0; return { color: "#444", weight: 1.5, fillColor: getColor(value), fillOpacity: 0.8 }; }, onEachFeature: (feature, layer) => { const province = feature.properties.ADM1_EN; const total = totals[province]?.total || 0; const positive = totals[province]?.positive || 0; const percent = total ? ((positive / total) * 100).toFixed(1) : 0; layer.bindTooltip(` ${province}
Total: ${total}
Positivity: ${percent}% `); } }).addTo(map); }); } function addProvinceLegend() { const legend = L.control({ position: "bottomright" }); legend.onAdd = function () { const div = L.DomUtil.create( "div", "map-legend" ); div.innerHTML = `
Cases
> 50
10 – 50
1 – 9
0
`; return div; }; legend.addTo(map); } function renderProgramTrend(rows = []) { if (!rows.length) { buildMixedTrendChart( 'trendChart', [], [], [] ); return; } /* |-------------------------------------------------------------------------- | YEARLY VIEW |-------------------------------------------------------------------------- */ const years = [...new Set( rows.map(r => Number(r.year)) )]; const totalYears = Math.max(...years) - Math.min(...years); const useYearlyView = totalYears >= 5; /* |-------------------------------------------------------------------------- | LABELS |-------------------------------------------------------------------------- */ let labels; if (useYearlyView) { labels = [...new Set( rows.map(r => String(r.year)) )].sort(); } else { labels = [...new Set( rows.map(r => `${r.year}-W${r.period}` ) )].sort((a, b) => { const [yearA, weekA] = a.split('-W').map(Number); const [yearB, weekB] = b.split('-W').map(Number); if (yearA !== yearB) { return yearA - yearB; } return weekA - weekB; }); } /* |-------------------------------------------------------------------------- | TOTAL SAMPLES |-------------------------------------------------------------------------- */ const samples = labels.map(label => { if (useYearlyView) { return rows .filter(r => String(r.year) === label ) .reduce( (sum, r) => sum + Number(r.total_samples || 0), 0 ); } const row = rows.find(r => `${r.year}-W${r.period}` === label ); return row?.total_samples || 0; }); /* |-------------------------------------------------------------------------- | LINES |-------------------------------------------------------------------------- */ const lines = [ { label: 'Influenza %', data: labels.map(label => { if (useYearlyView) { const filtered = rows.filter(r => String(r.year) === label ); const total = filtered.reduce( (sum, r) => sum + Number(r.influenza_rate || 0), 0 ); return filtered.length ? total / filtered.length : 0; } const row = rows.find(r => `${r.year}-W${r.period}` === label ); return row?.influenza_rate || 0; }), color: '#d21919' }, { label: 'COVID-19 %', data: labels.map(label => { if (useYearlyView) { const filtered = rows.filter(r => String(r.year) === label ); const total = filtered.reduce( (sum, r) => sum + Number(r.covid_rate || 0), 0 ); return filtered.length ? total / filtered.length : 0; } const row = rows.find(r => `${r.year}-W${r.period}` === label ); return row?.covid_rate || 0; }), color: '#10b981' } ]; /* |-------------------------------------------------------------------------- | NDS EXTRA LINES |-------------------------------------------------------------------------- */ if (programCode === 'NDS') { lines.push( { label: 'EV %', data: labels.map(label => { if (useYearlyView) { const filtered = rows.filter(r => String(r.year) === label ); const total = filtered.reduce( (sum, r) => sum + Number(r.ev_rate || 0), 0 ); return filtered.length ? total / filtered.length : 0; } const row = rows.find(r => `${r.year}-W${r.period}` === label ); return row?.ev_rate || 0; }), color: '#f59e0b' }, { label: 'Mpox %', data: labels.map(label => { if (useYearlyView) { const filtered = rows.filter(r => String(r.year) === label ); const total = filtered.reduce( (sum, r) => sum + Number(r.mpox_rate || 0), 0 ); return filtered.length ? total / filtered.length : 0; } const row = rows.find(r => `${r.year}-W${r.period}` === label ); return row?.mpox_rate || 0; }), color: '#8b5cf6' } ); } buildMixedTrendChart( 'trendChart', labels, samples, lines ); } function renderAFITrend( data = {}, canvasId, COLORS, type = 'trend' ) { /* |-------------------------------------------------------------------------- | DATA |-------------------------------------------------------------------------- */ const rows = data.rows || []; const totals = data.totals || []; /* |-------------------------------------------------------------------------- | EMPTY |-------------------------------------------------------------------------- */ if (!totals.length) { if (type === 'donut') { buildDistributionChart( canvasId, 'doughnut', [], 'pathogen', 'total' ); } else { buildMixedTrendChart( canvasId, [], [], [] ); } return; } /* |-------------------------------------------------------------------------- | LABELS |-------------------------------------------------------------------------- */ const labels = [...new Set( totals.map(r => `${r.year}-W${r.period}` ) )].sort((a, b) => { const [yearA, weekA] = a.split('-W').map(Number); const [yearB, weekB] = b.split('-W').map(Number); if (yearA !== yearB) { return yearA - yearB; } return weekA - weekB; }); /* |-------------------------------------------------------------------------- | TOTAL CASES (BLUE BARS) |-------------------------------------------------------------------------- */ const totalCases = labels.map(label => { const row = totals.find(r => `${r.year}-W${r.period}` === label ); return Number( row?.total_cases || 0 ); }); /* |-------------------------------------------------------------------------- | DONUT |-------------------------------------------------------------------------- */ if (type === 'donut') { const grouped = {}; rows.forEach(r => { const pathogen = r.pathogen || 'Unknown'; grouped[pathogen] ??= 0; grouped[pathogen] += Number(r.total_positive || 0); }); const donutRows = Object.entries(grouped) .map(([pathogen, total]) => ({ pathogen, total })); buildDistributionChart( canvasId, 'doughnut', donutRows, 'pathogen', 'total' ); /* |-------------------------------------------------------------------------- | DONUT CENTER TOTAL |-------------------------------------------------------------------------- | | MUST MATCH SUM OF BLUE BARS | */ const donutTotal = totalCases.reduce( (a, b) => a + b, 0 ); if (charts[canvasId]) { charts[canvasId].$afiTotalCases = donutTotal; charts[canvasId].update(); } return; } /* |-------------------------------------------------------------------------- | PATHOGENS |-------------------------------------------------------------------------- */ const pathogens = [ ...new Set( rows.map(r => r.pathogen) ) ]; /* |-------------------------------------------------------------------------- | LINES |-------------------------------------------------------------------------- */ const lines = pathogens.map( (pathogen, i) => ({ label: `${pathogen} %`, data: labels.map(label => { const row = rows.find(r => `${r.year}-W${r.period}` === label && r.pathogen === pathogen ); return Number( row?.positivity_rate || 0 ); }), color: COLORS[ i % COLORS.length ] }) ); /* |-------------------------------------------------------------------------- | BUILD |-------------------------------------------------------------------------- */ buildMixedTrendChart( canvasId, labels, totalCases, lines ); } function renderPathogenChart(rows = []) { buildDistributionChart( 'pathogenChart', 'doughnut', rows, 'pathogen' ); } function renderSentinel(rows = []) { buildDistributionChart( 'sentinelChart', 'pie', rows, 'name' ); } function renderSubtypeChart(rows = []) { console.log('renderSubtypeChart'); buildDistributionChart( 'subtypeChart', 'bar', rows, 'subtype', 'total', getSubtypeColor ); } function renderDemographics(data = {}) { buildDistributionChart( 'ageChart', 'doughnut', data.age_distribution || [], 'age_group' ); buildDistributionChart( 'sexChart', 'bar', data.sex_distribution || [], 'patient_sex' ); }