486 lines
14 KiB
JavaScript
486 lines
14 KiB
JavaScript
const standardPrograms = ['SARI', 'ILI', 'LBM', 'AFI'];
|
||
const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase();
|
||
let map;
|
||
let provinceLayer;
|
||
|
||
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;
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
|
||
if (!standardPrograms.includes(programCode)) return;
|
||
|
||
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
|
||
|
||
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 renderAFIDashboard(data) {
|
||
const pathogenRows = (data.pathogen_distribution || [])
|
||
.sort((a, b) => b.total - a.total);
|
||
const colors = [
|
||
'#2563eb', '#10b981', '#f59e0b', '#ef4444',
|
||
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16', '#e11dba', '#f6f63b'
|
||
];
|
||
|
||
renderSummary(data.summary);
|
||
renderAFITrend(data.afi_trend);
|
||
renderProvinceHeatmap(data.province_distribution);
|
||
renderPathogenChart(data.pathogen_distribution);
|
||
renderDemographics(data);
|
||
renderSentinel(data.sentinel_sites || []);
|
||
renderSubtypeChart(data.subtype_distribution || []);
|
||
|
||
charts['pathogenChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['pathogenChart'].update();
|
||
charts['ageChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['ageChart'].update();
|
||
charts['sexChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['sexChart'].update();
|
||
charts['sentinelChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['sentinelChart'].update();
|
||
charts['subtypeChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['subtypeChart'].update();
|
||
}
|
||
function renderProvinceHeatmap(rows) {
|
||
|
||
if (map) map.remove();
|
||
|
||
map = L.map('provinceMap').setView([12.7, 104.9], 7);
|
||
|
||
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)
|
||
);
|
||
|
||
const totals = {};
|
||
|
||
rows.forEach(r => {
|
||
|
||
// ✅ FIX: use patient_province + validSet
|
||
const province = normalizeProvince(r.patient_province, validProvinces);
|
||
if (!province) return;
|
||
|
||
// ✅ FIX: accumulate instead of overwrite
|
||
if (!totals[province]) {
|
||
totals[province] = { total: 0, positive: 0 };
|
||
}
|
||
|
||
totals[province].total += Number(r.total);
|
||
totals[province].positive += Number(r.positive);
|
||
|
||
});
|
||
|
||
function getColor(value) {
|
||
if (value > 50) return "#b91c1c";
|
||
if (value >= 10) return "#ef4444";
|
||
if (value > 0) return "#fecaca";
|
||
return "#f3f4f600";
|
||
}
|
||
|
||
provinceLayer = L.geoJSON(geo, {
|
||
style: feature => {
|
||
|
||
const province = feature.properties.ADM1_EN;
|
||
const value = totals[province]?.total || 0;
|
||
|
||
return {
|
||
color: "#444",
|
||
weight: 1,
|
||
fillColor: getColor(value),
|
||
fillOpacity: 0.7
|
||
};
|
||
},
|
||
onEachFeature: (feature, layer) => {
|
||
|
||
const province = feature.properties.ADM1_EN;
|
||
const total = totals[province]?.total || 0;
|
||
const positive = totals[province]?.positive || 0;
|
||
|
||
// ✅ positivity kept
|
||
const percent = total
|
||
? ((positive / total) * 100).toFixed(1)
|
||
: 0;
|
||
|
||
layer.bindTooltip(`
|
||
${province}<br>
|
||
Total: ${total}<br>
|
||
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 = `
|
||
<div style="background:white;padding:10px 12px;border-radius:6px;
|
||
box-shadow:0 2px 6px rgba(0,0,0,0.2);font-size:12px;">
|
||
<div style="font-weight:600;margin-bottom:6px;">Cases</div>
|
||
|
||
<div style="display:flex;align-items:center;margin-bottom:4px;">
|
||
<span style="width:12px;height:12px;background:#b91c1c;
|
||
display:inline-block;margin-right:6px;"></span>
|
||
> 50
|
||
</div>
|
||
|
||
<div style="display:flex;align-items:center;margin-bottom:4px;">
|
||
<span style="width:12px;height:12px;background:#ef4444;
|
||
display:inline-block;margin-right:6px;"></span>
|
||
10 – 50
|
||
</div>
|
||
|
||
<div style="display:flex;align-items:center;margin-bottom:4px;">
|
||
<span style="width:12px;height:12px;background:#fecaca;
|
||
display:inline-block;margin-right:6px;"></span>
|
||
1 – 9
|
||
</div>
|
||
|
||
<div style="display:flex;align-items:center;">
|
||
<span style="width:12px;height:12px;background:#f3f4f6;
|
||
display:inline-block;margin-right:6px;"></span>
|
||
0
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return div;
|
||
};
|
||
|
||
legend.addTo(map);
|
||
}
|
||
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 renderProgramTrend(rows) {
|
||
|
||
rows = rows || [];
|
||
|
||
const labels = rows.map(r => `W${r.period}`);
|
||
const samples = rows.map(r => r.total_samples || 0);
|
||
const fluRate = rows.map(r => r.influenza_rate || 0);
|
||
const covidRate = rows.map(r => r.covid_rate || 0);
|
||
|
||
buildMixedTrendChart(
|
||
'trendChart',
|
||
labels,
|
||
samples,
|
||
fluRate,
|
||
covidRate
|
||
);
|
||
}
|
||
function renderSummary(summary) {
|
||
|
||
summary = summary || {};
|
||
|
||
const cases = summary.cases || {};
|
||
const hospital = summary.hospital_rate || {};
|
||
const icu = summary.icu_rate || {};
|
||
const positivity = summary.positivity_rate || {};
|
||
|
||
renderTrend(
|
||
"totalCases",
|
||
"casesChange",
|
||
cases.current || 0,
|
||
cases.previous || 0
|
||
);
|
||
renderTrend(
|
||
"influenzaRate",
|
||
"influenzaChange",
|
||
summary.influenza_rate.current,
|
||
summary.influenza_rate.previous,
|
||
"%"
|
||
);
|
||
|
||
renderTrend(
|
||
"covidRate",
|
||
"covidChange",
|
||
summary.covid_rate.current,
|
||
summary.covid_rate.previous,
|
||
"%"
|
||
);
|
||
|
||
renderTrend(
|
||
"hospitalRate",
|
||
"hospitalChange",
|
||
hospital.current || 0,
|
||
hospital.previous || 0,
|
||
"%"
|
||
);
|
||
|
||
renderTrend(
|
||
"icuRate",
|
||
"icuChange",
|
||
icu.current || 0,
|
||
icu.previous || 0,
|
||
"%"
|
||
);
|
||
|
||
renderTrend(
|
||
"positivityRate",
|
||
"positivityChange",
|
||
positivity.current || 0,
|
||
positivity.previous || 0,
|
||
"%"
|
||
);
|
||
}
|
||
function renderDashboard(data) {
|
||
|
||
data = data || {};
|
||
let virusRows = data.virus_trend || [];
|
||
if (!virusRows.length) {
|
||
virusRows = [
|
||
{ period: '', influenza: 0, covid: 0, total_samples: 0 }
|
||
];
|
||
}
|
||
renderProgramTrend(data.trend || []);
|
||
renderSummary(data.summary || {});
|
||
renderProvinceHeatmap(data.province_distribution || []);
|
||
|
||
|
||
const pathogenRows = (data.pathogen_distribution || [])
|
||
.sort((a, b) => b.total - a.total);
|
||
const colors = [
|
||
'#2563eb', '#10b981', '#f59e0b', '#ef4444',
|
||
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16', '#e11dba', '#f6f63b'
|
||
];
|
||
|
||
|
||
|
||
buildChart(
|
||
'pathogenChart',
|
||
'doughnut',
|
||
pathogenRows.map(r => r.pathogen),
|
||
pathogenRows.map(r => r.total)
|
||
);
|
||
charts['pathogenChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['pathogenChart'].update();
|
||
|
||
buildChart(
|
||
'ageChart',
|
||
'doughnut',
|
||
(data.age_distribution || []).map(r => r.age_group),
|
||
(data.age_distribution || []).map(r => r.total)
|
||
);
|
||
charts['ageChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['ageChart'].update();
|
||
|
||
buildChart(
|
||
'sexChart',
|
||
'bar',
|
||
(data.sex_distribution || []).map(r => r.patient_sex),
|
||
(data.sex_distribution || []).map(r => r.total)
|
||
);
|
||
charts['sexChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['sexChart'].update();
|
||
|
||
buildChart(
|
||
'subtypeChart',
|
||
'bar',
|
||
(data.subtype_distribution || []).map(r => r.subtype),
|
||
(data.subtype_distribution || []).map(r => r.total)
|
||
);
|
||
charts['subtypeChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['subtypeChart'].update();
|
||
buildChart(
|
||
'sentinelChart',
|
||
'pie',
|
||
(data.sentinel_sites || []).map(r => r.name),
|
||
(data.sentinel_sites || []).map(r => r.total)
|
||
);
|
||
charts['sentinelChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['sentinelChart'].update();
|
||
|
||
}
|
||
|
||
|
||
//AFI
|
||
function renderAFITrend(rows) {
|
||
|
||
if (!rows.length) {
|
||
buildStackedChart('trendChart', [], []);
|
||
return;
|
||
}
|
||
|
||
const { labels, datasets } = transformAFIData(rows);
|
||
|
||
buildStackedChart('trendChart', labels, datasets);
|
||
}
|
||
function transformAFIData(rows) {
|
||
|
||
const grouped = {};
|
||
const pathogensSet = new Set();
|
||
|
||
rows.forEach(r => {
|
||
|
||
const key = `${r.year}-W${r.period}`;
|
||
|
||
if (!grouped[key]) {
|
||
grouped[key] = {};
|
||
}
|
||
|
||
grouped[key][r.pathogen] = r.total;
|
||
pathogensSet.add(r.pathogen);
|
||
});
|
||
|
||
const labels = Object.keys(grouped).sort((a, b) => {
|
||
const [yA, wA] = a.split('-W').map(Number);
|
||
const [yB, wB] = b.split('-W').map(Number);
|
||
return yA === yB ? wA - wB : yA - yB;
|
||
});
|
||
|
||
const pathogenTotals = {};
|
||
|
||
rows.forEach(r => {
|
||
pathogenTotals[r.pathogen] = (pathogenTotals[r.pathogen] || 0) + r.total;
|
||
});
|
||
|
||
const pathogens = Object.keys(pathogenTotals)
|
||
.sort((a, b) => pathogenTotals[b] - pathogenTotals[a]);
|
||
const datasets = pathogens.map(p => ({
|
||
label: p,
|
||
data: labels.map(l => grouped[l][p] || 0),
|
||
backgroundColor: getColorForPathogen(p)
|
||
}));
|
||
|
||
return { labels: labels.map(l => l.split('-')[1]), datasets };
|
||
}
|
||
function getColorForPathogen(name) {
|
||
|
||
const colors = {
|
||
Dengue: '#2563eb',
|
||
Chikungunya: '#10b981',
|
||
Zika: '#f59e0b',
|
||
Leptospira: '#ef4444',
|
||
Rickettsia: '#8b5cf6',
|
||
Salmonella: '#f97316',
|
||
Plasmodium: '#14b8a6',
|
||
Influenza: '#84cc16'
|
||
};
|
||
|
||
|
||
if (colors[name]) return colors[name];
|
||
|
||
// fallback random color (for future pathogens)
|
||
return `hsl(${Math.floor(Math.random() * 360)}, 70%, 60%)`;
|
||
}
|
||
function renderPathogenChart(rows) {
|
||
buildChart(
|
||
'pathogenChart',
|
||
'doughnut',
|
||
rows.map(r => r.pathogen),
|
||
rows.map(r => r.total)
|
||
);
|
||
}
|
||
function renderSentinel(rows) {
|
||
buildChart(
|
||
'sentinelChart',
|
||
'pie',
|
||
rows.map(r => r.name),
|
||
rows.map(r => r.total)
|
||
);
|
||
}
|
||
function renderDemographics(data) {
|
||
buildChart(
|
||
'ageChart',
|
||
'doughnut',
|
||
(data.age_distribution || []).map(r => r.age_group),
|
||
(data.age_distribution || []).map(r => r.total)
|
||
);
|
||
|
||
buildChart(
|
||
'sexChart',
|
||
'bar',
|
||
(data.sex_distribution || []).map(r => r.patient_sex),
|
||
(data.sex_distribution || []).map(r => r.total)
|
||
);
|
||
}
|
||
function renderSubtypeChart(rows) {
|
||
buildChart(
|
||
'subtypeChart',
|
||
'bar',
|
||
rows.map(r => r.subtype),
|
||
rows.map(r => r.total)
|
||
);
|
||
}
|
||
|
||
//Seq
|