let trendChart; let influenzaSubtypeChart; let covidDistributedByAgeChart; let covidLineageFrequencyChart; let influenzaSubtypeFrequencyChart; let map; /* |-------------------------------------------------------------------------- | Load Summary Cards |-------------------------------------------------------------------------- */ function loadSummary() { fetch('/api/dashboard/summary') .then(res => res.json()) .then(data => { let html = ''; const alerts = []; data.forEach(item => { let trendColor = 'text-secondary'; if (item.percent_change > 0) trendColor = 'text-danger'; if (item.percent_change < 0) trendColor = 'text-success'; html += `
${item.code} Cases

${item.current_total}

Last 7 days
${item.percent_change > 0 ? '▲' : item.percent_change < 0 ? '▼' : '–'} ${Math.abs(item.percent_change)}%
${item.previous_total ?? 0} last week
`; window._summaryData = data; updateAlerts(); }); document.getElementById('summary_cards').innerHTML = html; renderAlerts(alerts); }); } /* |-------------------------------------------------------------------------- | Load Trend Chart |-------------------------------------------------------------------------- */ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) { fetch(`/api/dashboard/trend?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`) .then(res => res.json()) .then(data => { if (trendChart) trendChart.destroy(); const labelsSet = new Set(); Object.values(data).forEach(program => { program.forEach(row => { labelsSet.add(`${row.year}-${row.period}`); }); }); const labels = Array.from(labelsSet).sort((a, b) => { const [yearA, weekA] = a.split('-').map(Number); const [yearB, weekB] = b.split('-').map(Number); if (yearA !== yearB) return yearA - yearB; return weekA - weekB; }); const colors = { SARI: '#2563eb', ILI: '#10b981', LBM: '#9333ea', AFI: '#fc5741', NDS: '#d59d01', SEQ: '#9ca3af' }; const datasets = []; const allowedPrograms = ['SARI', 'ILI', 'LBM', 'AFI', 'NDS']; Object.keys(data).forEach(code => { if (!allowedPrograms.includes(code)) return; const values = labels.map(label => { const found = data[code].find(row => `${row.year}-${row.period}` === label); return found ? found.total : 0; }); datasets.push({ label: code, data: values, borderColor: colors[code], backgroundColor: colors[code], borderWidth: 3, pointRadius: 4, fill: false, tension: 0.3 }); }); const displayLabels = labels.map(l => { const [year, week] = l.split('-'); return `W${String(week).padStart(2, '0')}`; }); trendChart = new Chart(document.getElementById('trendChart'), { type: 'line', data: { labels: displayLabels, datasets: datasets }, options: { responsive: true, plugins: { legend: { position: 'bottom' }, datalabels: { display: false } }, interaction: { mode: 'index', intersect: false }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 }, title: { display: true, text: 'Number of Cases' }, }, x: { grid: { display: false }, title: { display: true, text: 'Surveillance' }, } } } }); charts['trendChart'] = trendChart; }); } function loadInfluenzaSubtypeDistribution(periodType, startYear, startWeek, endYear, endWeek) { fetch(`/api/dashboard/influenza-subtype-distribution?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`) .then(res => res.json()) .then(data => { let displayLabels = data.map(item => item.subtype); let dataset = data.map(item => item.total); const colors = [ 'rgba(177,111,243,0.94)' ]; if (influenzaSubtypeChart) influenzaSubtypeChart.destroy(); influenzaSubtypeChart = new Chart(document.getElementById('influenzaSubtypeDistribution'), { type: 'bar', // you can change to 'pie', 'doughnut', etc. data: { labels: displayLabels, datasets: [{ data: dataset, backgroundColor: colors, }] }, options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false, position:'right' }, datalabels: { color: '#ffffff', backgroundColor: 'rgba(68,76,68,0.31)', borderRadius: 6, z: 1000, padding: { top: 6, bottom: 6, left: 10, right: 10 }, font: { weight: 'bold', size: 12 }, formatter: (value) => value, anchor: 'end', align: 'end', offset: 10, clamp: false } }, scales: { x: { stacked: true, beginAtZero: true, title: { display: true, text: 'Number of Positive Influenza Subtypes' } }, y: { stacked: true, beginAtZero: true, title: { display: true, text: 'Influenza Subtypes' }, grid:{ display: false } } } }, plugins: [ChartDataLabels] }); charts['influenzaSubtypeDistribution'] = influenzaSubtypeChart; }); } function loadCovidDistributedByAgeGroup(periodType, startYear, startWeek, endYear, endWeek) { fetch(`/api/dashboard/covid-distributed-by-age-group?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`) .then(res => res.json()) .then(data => { let displayLabels = data.map(item => item.age_group); let dataset = data.map(item => item.total); const colors = [ '#84cc16' ]; if (covidDistributedByAgeChart) covidDistributedByAgeChart.destroy(); covidDistributedByAgeChart = new Chart(document.getElementById('covidDistributedByAgeGroup'), { type: 'bar', // you can change to 'pie', 'doughnut', etc. data: { labels: displayLabels, datasets: [{ label: 'Total Covid-19 Detected', data: dataset, backgroundColor: colors, }] }, options: { responsive: true, plugins: { legend: { display: false, position:'bottom' }, datalabels: { color: '#fff', backgroundColor: 'rgba(68,76,68,0.7)', borderRadius: 6, z: 1000, padding: { top: 6, bottom: 6, left: 10, right: 10 }, font: { weight: 'bold', size: 12 }, formatter: (value) => value, anchor: 'end', align: 'end', offset: 10, clamp: false } }, scales: { x: { stacked: true, beginAtZero: true, title: { display: true, text: 'Patient Age Group' }, grid:{ display: false } }, y: { stacked: true, beginAtZero: true, title: { display: true, text: 'Number of Positive SARS-CoV-2' } } } } }); charts['covidDistributedByAgeGroup'] = covidDistributedByAgeChart; }); } function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, endWeek) { fetch(`/api/dashboard/covid-lineage-frequency?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`) .then(res => res.json()) .then(data => { // Extract unique weeks (X-axis) const weeks = [...new Set(data.map(item => item.week))].sort(); // Extract unique lineages const lineages = [...new Set(data.map(item => item.lineage))]; // Color palette const colors = [ '#84cc16','#22c55e','#06b6d4','#3b82f6', '#6366f1','#a855f7','#ec4899','#ef4444', '#f97316','#eab308' ]; // Build datasets const datasets = lineages.map((lineage, index) => { const lineageData = weeks.map(week => { const found = data.find( item => item.week === week && item.lineage === lineage ); return found ? found.total : 0; }); return { label: lineage, data: lineageData, fill: true, // area fill tension: 0.4, // smooth curve borderColor: 'transparent', // hide the line borderWidth: 0, pointRadius: 0, // hide points backgroundColor: hexToRGBA(colors[index % colors.length], 0.3), stack: 'total' }; }); // Destroy previous chart if exists if (covidLineageFrequencyChart) covidLineageFrequencyChart.destroy(); const ctx = document.getElementById('covidLineageFrequency').getContext('2d'); covidLineageFrequencyChart = new Chart(ctx, { type: 'line', data: { labels: weeks, datasets: datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false // hide default legend }, tooltip: { mode: 'index', intersect: false }, datalabels: { display: false // hide labels } }, scales: { x: { stacked: true, title: { display: true, text: 'Week' }, grid:{ display: false } }, y: { stacked: true, beginAtZero: true, title: { display: true, text: 'Relative Frequency' }, } } } }); charts['covidLineageFrequency'] = covidLineageFrequencyChart; // ------------------------- // Custom right-side scrollable legend // ------------------------- const legendContainer = document.getElementById('legendContainer'); legendContainer.innerHTML = ''; // clear old legend datasets.forEach((dataset, index) => { const item = document.createElement('div'); item.style.display = 'flex'; item.style.alignItems = 'center'; item.style.marginBottom = '4px'; item.style.fontSize = '11px'; item.style.cursor = 'pointer'; item.innerHTML = ` ${dataset.label} `; item.addEventListener('click', () => { const meta = covidLineageFrequencyChart.getDatasetMeta(index); // If the clicked dataset is already the only visible one, show all const allHidden = datasets.every((d, i) => covidLineageFrequencyChart.getDatasetMeta(i).hidden || i === index); if (!allHidden) { // Hide all datasets datasets.forEach((d, i) => { covidLineageFrequencyChart.getDatasetMeta(i).hidden = true; }); // Show only clicked meta.hidden = false; } else { // Show all datasets datasets.forEach((d, i) => { covidLineageFrequencyChart.getDatasetMeta(i).hidden = false; }); } covidLineageFrequencyChart.update(); // Update legend opacity Array.from(legendContainer.children).forEach((child, i) => { const metaItem = covidLineageFrequencyChart.getDatasetMeta(i); child.style.opacity = metaItem.hidden ? 0.5 : 1; }); }); legendContainer.appendChild(item); }); // Scrollable CSS (in case legend is long) legendContainer.style.maxHeight = '375px'; legendContainer.style.overflowY = 'auto'; legendContainer.style.padding = '8px'; legendContainer.style.borderRadius = '0px'; }); } function loadInfluenzaSubtypeFrequency(periodType, startYear, startWeek, endYear, endWeek) { fetch(`/api/dashboard/influenza-relative-frequency?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`) .then(res => res.json()) .then(data => { // Extract unique weeks (X-axis) const weeks = [...new Set(data.map(item => item.week))].sort(); const colors = [ '#84cc16','#22c55e','#06b6d4','#3b82f6', '#6366f1','#a855f7','#ec4899','#ef4444', '#f97316','#eab308' ]; // Extract unique lineages const lineages = [...new Set(data.map(item => item.lineage))]; // Build datasets const datasets = lineages.map((lineage, index) => { const lineageData = weeks.map(week => { const found = data.find( item => item.week === week && item.lineage === lineage ); return found ? found.total : 0; }); return { label: lineage, data: lineageData, fill: true, // area fill tension: 0.4, // smooth curve borderColor: 'transparent', // hide the line borderWidth: 0, pointRadius: 0, // hide points backgroundColor: hexToRGBA(colors[index % colors.length], 0.3), stack: 'total' }; }); // Destroy previous chart if exists if (influenzaSubtypeFrequencyChart) influenzaSubtypeFrequencyChart.destroy(); const ctx = document.getElementById('influenzaSubtypeFrequency').getContext('2d'); influenzaSubtypeFrequencyChart = new Chart(ctx, { type: 'line', data: { labels: weeks, datasets: datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false // hide default legend }, tooltip: { mode: 'index', intersect: false }, datalabels: { display: false // hide labels } }, scales: { x: { stacked: true, title: { display: true, text: 'Week' }, grid:{ display: false } }, y: { stacked: true, beginAtZero: true, title: { display: true, text: 'Relative Frequency' }, } } } }); const legendContainer = document.getElementById('legendContainerInfluenzaSubtypeFrequency'); legendContainer.innerHTML = ''; // clear old legend datasets.forEach((dataset, index) => { const item = document.createElement('div'); item.style.display = 'flex'; item.style.alignItems = 'center'; item.style.marginBottom = '4px'; item.style.fontSize = '11px'; item.style.cursor = 'pointer'; item.innerHTML = ` ${dataset.label} `; item.addEventListener('click', () => { const meta = influenzaSubtypeFrequencyChart.getDatasetMeta(index); // If the clicked dataset is already the only visible one, show all const allHidden = datasets.every((d, i) => influenzaSubtypeFrequencyChart.getDatasetMeta(i).hidden || i === index); if (!allHidden) { // Hide all datasets datasets.forEach((d, i) => { influenzaSubtypeFrequencyChart.getDatasetMeta(i).hidden = true; }); // Show only clicked meta.hidden = false; } else { // Show all datasets datasets.forEach((d, i) => { influenzaSubtypeFrequencyChart.getDatasetMeta(i).hidden = false; }); } influenzaSubtypeFrequencyChart.update(); // Update legend opacity Array.from(legendContainer.children).forEach((child, i) => { const metaItem = influenzaSubtypeFrequencyChart.getDatasetMeta(i); child.style.opacity = metaItem.hidden ? 0.5 : 1; }); }); legendContainer.appendChild(item); }); // Scrollable CSS (in case legend is long) legendContainer.style.maxHeight = '375px'; legendContainer.style.overflowY = 'auto'; legendContainer.style.padding = '8px'; legendContainer.style.borderRadius = '0px'; }); } // ------------------------- // Helper to convert hex to rgba // ------------------------- function hexToRGBA(hex, alpha) { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } function updateAlerts() { if (!window._summaryData || !window._provinceData) return; const raw = buildAlerts(window._summaryData, window._provinceData); const finalAlerts = processAlerts(raw); renderAlerts(finalAlerts); } function generateAlerts(data) { const alerts = []; // ------------------------- // 1. Program-level alerts // ------------------------- const summary = data.summary || {}; const programs = [ { key: 'influenza_rate', label: 'Influenza' }, { key: 'covid_rate', label: 'COVID-19' }, { key: 'positivity_rate', label: 'Overall positivity' } ]; programs.forEach(p => { const current = summary[p.key]?.current || 0; const previous = summary[p.key]?.previous || 0; const diff = previous ? ((current - previous) / previous) * 100 : 0; if (current >= 15) { alerts.push(`🔴 High ${p.label} (${current}%)`); } else if (current >= 10) { alerts.push(`🟠 Moderate ${p.label} (${current}%)`); } if (diff >= 10) { alerts.push(`🟡 Increasing ${p.label} (+${diff.toFixed(1)}%)`); } }); // ------------------------- // 2. Province-level alerts // ------------------------- const provinces = data.province_distribution || []; const top = [...provinces] .sort((a, b) => b.total - a.total) .slice(0, 3); top.forEach(p => { const percent = p.total ? ((p.positive / p.total) * 100) : 0; if (percent >= 15) { alerts.push(`🔴 High positivity in ${p.patient_province} (${percent.toFixed(1)}%)`); } else if (percent >= 10) { alerts.push(`🟠 Moderate positivity in ${p.patient_province}`); } if (p.total >= 50) { alerts.push(`🟡 High case volume in ${p.patient_province} (${p.total})`); } }); // ------------------------- // fallback // ------------------------- if (!alerts.length) { alerts.push("🟢 No unusual activity detected"); } return alerts; } function createAlert(type, message, priority) { return { type, message, priority }; } function buildAlerts(summaryData, provinceData) { const alerts = []; // ------------------------- // 1. Program alerts // ------------------------- summaryData.forEach(item => { // 🔴 High activity if (item.current_total >= 80) { alerts.push(createAlert( 'high', `High ${item.code} activity (${item.current_total} cases)`, 1 )); } // 🟠 Moderate else if (item.current_total >= 40) { alerts.push(createAlert( 'moderate', `${item.code} activity elevated (${item.current_total})`, 2 )); } // 🟡 Increasing trend if (item.percent_change >= 10) { alerts.push(createAlert( 'trend', `Increasing ${item.code} (+${item.percent_change}%)`, 3 )); } }); // ------------------------- // 2. Province alerts // ------------------------- const top = [...provinceData] .sort((a, b) => b.total - a.total) .slice(0, 5); top.forEach(p => { const percent = p.total ? ((p.positive / p.total) * 100) : 0; // 🔴 High positivity if (percent >= 15) { alerts.push(createAlert( 'high', `High positivity in ${p.patient_province} (${percent.toFixed(1)}%)`, 1 )); } // 🟠 Moderate positivity else if (percent >= 10) { alerts.push(createAlert( 'moderate', `Moderate positivity in ${p.patient_province}`, 2 )); } // 🟡 High volume if (p.total >= 50) { alerts.push(createAlert( 'volume', `High case volume in ${p.patient_province} (${p.total})`, 3 )); } }); return alerts; } function processAlerts(alerts) { const seen = new Set(); const unique = alerts.filter(a => { if (seen.has(a.message)) return false; seen.add(a.message); return true; }); // sort by priority unique.sort((a, b) => a.priority - b.priority); // limit to top 5 return unique.slice(0, 5); } function renderAlerts(alerts) { const container = document.getElementById('alertsList'); if (!container) return; if (!alerts.length) { container.innerHTML = `
  • 🟢 No unusual activity detected
  • `; return; } const colorMap = { high: 'text-danger', moderate: 'text-warning', trend: 'text-primary', volume: 'text-secondary' }; container.innerHTML = alerts.map(a => `
  • ${a.type === 'high' ? '🔴' : a.type === 'moderate' ? '🟠' : a.type === 'trend' ? '🟡' : '🔵'} ${a.message}
  • `).join(''); } /* |-------------------------------------------------------------------------- | Province Map Helpers |-------------------------------------------------------------------------- */ function getPositivityColor(p) { if (p == 'A/H1N1pdm') return "#2563eb"; if (p == 'A/H3N2') return "#11de9d"; if (p == 'B/Yam') return "#9333ea"; if (p == 'B/Vic') return "#1021b9"; if (p == 'A/H9N2') return "#b91081"; if (p == 'A/H5N1') return "#ad850d"; if (p == 'A/Unsubtypable') return "#047393"; if (p == 'B/Unsubtypable') return "#890512"; return "#9ca3af"; } function normalizeProvince(name, validSet) { if (!name || !validSet) return null; const clean = str => str.toLowerCase().replace(/\s+/g, ''); const raw = name.trim(); const map = { "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 (map[key] && validSet.has(map[key])) { return map[key]; } const match = [...validSet].find(p => clean(p) === key); return match || null; } function getRadius(total) { if (!total) return 0; const r = Math.sqrt(total); return Math.max(4, Math.min(r * 2, 22)); } function addPositivityLegend() { const legend = L.control({ position: "bottomleft" }); legend.onAdd = function () { const div = L.DomUtil.create("div", "map-legend"); div.innerHTML = `
    Influenza Subtypes
    A/H1N1pdm
    A/H3N2
    A/H9N2
    A/H5N1
    A/Unsubtypable
    B/Yam
    B/Vic
    B/Unsubtypable
    `; return div; }; legend.addTo(map); } /* |-------------------------------------------------------------------------- | Province Map |-------------------------------------------------------------------------- */ function loadProvinceMap(startYear, startWeek, endYear, endWeek) { if (map) map.remove(); map = L.map('provinceMap').setView([12.7, 104.9], 7); addPositivityLegend(); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap' }).addTo(map); Promise.all([ fetch('/geo/cambodia_provinces.geojson').then(r => r.json()), fetch(`/api/dashboard/province-circles?start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`).then(r => r.json()) ]) .then(([geojson, data]) => { window._provinceData = data; updateAlerts(); const validProvinces = new Set( geojson.features.map(f => f.properties.ADM1_EN) ); L.geoJSON(geojson, { style: { fillOpacity: 0, color: '#ccc', weight: 1, interactive: false }, onEachFeature: function (feature, layer) { const province = feature.properties.ADM1_EN; const center = layer.getBounds().getCenter(); const rows = data.filter(d => { const name = normalizeProvince(d.patient_province, validProvinces); return name === province; }); if (!rows.length) return; const pathogens = [...new Set(rows.map(r => r.pathogen_name))]; const spacing = 0.15; rows.forEach(row => { const index = pathogens.indexOf(row.pathogen_name); const offset = (index - (pathogens.length - 1) / 2) * spacing; const lat = center.lat; const lng = center.lng + offset; L.circleMarker([lat, lng], { radius: getRadius(row.total), fillColor: getColorByPathogen(row.pathogen_name), // 👈 new function color: getPositivityColor(row.pathogen_nam), weight: 2, fillOpacity: 0.9 }) .bindTooltip(` ${province}
    ${row.pathogen_name}
    Total Detected: ${row.total}
    `) .addTo(map); }); } }).addTo(map); }); } function getColorByPathogen(name) { const colors = { "A/H1N1pdm": "#ff0200", "A/H3N2": "#11de9d", "B/Yam": "#9333ea", "B/Vic" : "#086037", "A/H9N2": "#b91081", "A/H5N1": "#ad850d", "A/Unsubtypable": "#047393", "B/Unsubtypable": "#890512" }; return colors[name] || "#000000"; // fallback color } /* |-------------------------------------------------------------------------- | Initialize Dashboard |-------------------------------------------------------------------------- */ document.addEventListener("DOMContentLoaded", () => { loadSummary(); new DashboardFilter((startYear, startWeek, endYear, endWeek) => { loadTrend('week', startYear, startWeek, endYear, endWeek); loadInfluenzaSubtypeDistribution('week', startYear, startWeek, endYear, endWeek); loadCovidDistributedByAgeGroup('week', startYear, startWeek, endYear, endWeek); loadInfluenzaSubtypeFrequency('week', startYear, startWeek, endYear, endWeek); loadCovidLineageFrequency('week', startYear, startWeek, endYear, endWeek); loadProvinceMap(startYear, startWeek, endYear, endWeek); const elements = document.querySelectorAll(".report-period"); elements.forEach(el => { el.textContent = 'Week ' + startWeek + ' of '+startYear+' to ' + 'Week ' + endWeek + ' of ' + endYear }); }); }); document.addEventListener("DOMContentLoaded", function () { let currentSlide = 0; const slides = document.querySelectorAll('.slide'); const nextBtn = document.querySelector('.next-btn'); const prevBtn = document.querySelector('.prev-btn'); function showSlide(index) { slides.forEach((slide, i) => { slide.classList.remove('active', 'prev'); if (i === index) { slide.classList.add('active'); } else if (i === index - 1) { slide.classList.add('prev'); } }); } function nextSlide() { currentSlide = (currentSlide + 1) % slides.length; showSlide(currentSlide); } function prevSlide() { currentSlide = (currentSlide - 1 + slides.length) % slides.length; showSlide(currentSlide); } // Button events nextBtn.addEventListener('click', nextSlide); prevBtn.addEventListener('click', prevSlide); // Auto slide every 1 minute let slideInterval = 2 * (60 * 1000); setInterval(nextSlide, slideInterval); // Init showSlide(currentSlide); });