finalize detail page except NDS and squencing
This commit is contained in:
@@ -1,3 +1,58 @@
|
||||
Chart.register({
|
||||
id: 'noDataText',
|
||||
afterDraw(chart) {
|
||||
|
||||
const datasets = chart.data.datasets || [];
|
||||
|
||||
const hasData = datasets.some(ds =>
|
||||
(ds.data || []).some(v => Number(v) > 0)
|
||||
);
|
||||
|
||||
chart.$noData = !hasData;
|
||||
|
||||
if (hasData) return;
|
||||
|
||||
const { ctx, width, height } = chart;
|
||||
|
||||
ctx.save();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.fillStyle = '#9ca3af';
|
||||
|
||||
ctx.fillText('No data available', width / 2, height / 2);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
Chart.register({
|
||||
id: 'centerText',
|
||||
afterDraw(chart) {
|
||||
|
||||
if (chart.config.type !== 'doughnut') return;
|
||||
if (chart.$noData) return;
|
||||
|
||||
const { ctx, width, height } = chart;
|
||||
|
||||
const data = chart.data.datasets[0].data;
|
||||
const total = data.reduce((a, b) => a + b, 0);
|
||||
|
||||
ctx.save();
|
||||
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
ctx.font = 'bold 18px sans-serif';
|
||||
ctx.fillStyle = '#111827';
|
||||
ctx.fillText(total, width / 2, height / 2 - 8);
|
||||
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.fillText('Total cases', width / 2, height / 2 + 12);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
Chart.register(ChartDataLabels);
|
||||
const charts = {};
|
||||
|
||||
@@ -80,10 +135,126 @@ function buildStackedChart(canvasId, labels, datasets) {
|
||||
function buildChart(id, type, labels, data) {
|
||||
|
||||
const ctx = document.getElementById(id);
|
||||
|
||||
if (!ctx) return;
|
||||
|
||||
if (charts[id]) charts[id].destroy();
|
||||
Chart.getChart(id)?.destroy();
|
||||
|
||||
const hasData = data && data.some(v => Number(v) > 0);
|
||||
|
||||
if (!hasData) {
|
||||
labels = [];
|
||||
data = [];
|
||||
}
|
||||
const isHorizontal = id === 'sexChart';
|
||||
const isAgeChart = id === 'ageChart';
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
|
||||
layout: {
|
||||
padding: 30
|
||||
},
|
||||
|
||||
|
||||
indexAxis: isHorizontal ? 'y' : 'x',
|
||||
plugins: {
|
||||
|
||||
legend: {
|
||||
position: isAgeChart ? 'left' : 'bottom',
|
||||
align: 'center',
|
||||
display: (ctx) => {
|
||||
const chart = ctx.chart;
|
||||
|
||||
if (!(chart.config.type === 'pie' || chart.config.type === 'doughnut')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !chart.$noData;
|
||||
},
|
||||
labels: {
|
||||
padding: 14,
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
datalabels: {
|
||||
color: "#282626",
|
||||
offset: 6,
|
||||
clip: false,
|
||||
display: (ctx) => {
|
||||
const chart = ctx.chart;
|
||||
if (chart.$noData) return false;
|
||||
if (chart.config.type === 'bar') return true;
|
||||
return !chart.$noData;
|
||||
},
|
||||
anchor: (ctx) => {
|
||||
const type = ctx.chart.config.type;
|
||||
|
||||
if (type === 'doughnut' || type === 'pie') {
|
||||
return 'center';
|
||||
}
|
||||
|
||||
return 'end';
|
||||
},
|
||||
|
||||
align: (ctx) => {
|
||||
const type = ctx.chart.config.type;
|
||||
if (type === 'doughnut' || type === 'pie') {
|
||||
return 'center';
|
||||
}
|
||||
if (type === 'bar') {
|
||||
return ctx.chart.options.indexAxis === 'y' ? 'right' : 'end';
|
||||
}
|
||||
return 'center';
|
||||
},
|
||||
font: {
|
||||
size: 10,
|
||||
weight: '600'
|
||||
},
|
||||
|
||||
formatter: (value, ctx) => {
|
||||
if (ctx.chart.$noData) return '';
|
||||
|
||||
const data = ctx.chart.data.datasets[0].data;
|
||||
const total = data.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (!total) return '';
|
||||
|
||||
return ((value / total) * 100).toFixed(1) + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if (type === 'bar') {
|
||||
options.scales = {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
display: false // cleaner look
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: '#f3f4f6' // subtle grid
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if (type === 'doughnut') {
|
||||
options.cutout = '70%';
|
||||
|
||||
options.elements = {
|
||||
arc: {
|
||||
borderWidth: (ctx) => ctx.chart.$noData ? 0 : 1
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
charts[id] = new Chart(ctx, {
|
||||
type: type,
|
||||
@@ -92,60 +263,24 @@ function buildChart(id, type, labels, data) {
|
||||
datasets: [{
|
||||
data: data,
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
tension: 0.3,
|
||||
barPercentage: 0.8,
|
||||
categoryPercentage: 0.6,
|
||||
maxBarThickness: 50
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
|
||||
layout: {
|
||||
padding: {
|
||||
top: 30,
|
||||
bottom: 30
|
||||
}
|
||||
},
|
||||
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
align: 'center',
|
||||
display: type === 'pie' || type === 'doughnut',
|
||||
labels: {
|
||||
padding: 10,
|
||||
boxWidth: 14,
|
||||
boxHeight: 14,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle'
|
||||
}
|
||||
},
|
||||
datalabels: {
|
||||
color: "#282626",
|
||||
anchor: type === "bar" ? "end" : "center",
|
||||
align: type === "bar" ? "top" : "center",
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
formatter: function(value, ctx) {
|
||||
const data = ctx.chart.data.datasets[0].data;
|
||||
const total = data.reduce((a, b) => a + b, 0);
|
||||
const percent = total ? (value / total * 100).toFixed(1) : 0;
|
||||
return percent + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
options: options
|
||||
});
|
||||
|
||||
}
|
||||
function buildMixedTrendChart(canvasId, labels, samples, fluRate, covidRate) {
|
||||
|
||||
const ctx = document.getElementById(canvasId);
|
||||
if (!ctx) return;
|
||||
if (!labels.length) labels = [''];
|
||||
|
||||
if (!fluRate.length) fluRate = [0];
|
||||
if (!covidRate.length) covidRate = [0];
|
||||
if (!samples.length) samples = [0];
|
||||
if (charts[canvasId]) charts[canvasId].destroy();
|
||||
|
||||
charts[canvasId] = new Chart(ctx, {
|
||||
|
||||
@@ -89,7 +89,6 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
|
||||
const [yearB, weekB] = b.split('-').map(Number);
|
||||
|
||||
if (yearA !== yearB) return yearA - yearB;
|
||||
|
||||
return weekA - weekB;
|
||||
|
||||
});
|
||||
@@ -109,11 +108,8 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
|
||||
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({
|
||||
@@ -135,14 +131,11 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
|
||||
});
|
||||
|
||||
trendChart = new Chart(document.getElementById('trendChart'), {
|
||||
|
||||
type: 'line',
|
||||
|
||||
data: {
|
||||
labels: displayLabels,
|
||||
datasets: datasets
|
||||
},
|
||||
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
@@ -160,11 +153,52 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
|
||||
x: { grid: { display: false } }
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Province Map Helpers
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -184,72 +218,81 @@ function loadProvinceMap(startYear, startWeek, endYear, endWeek) {
|
||||
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]) => {
|
||||
.then(([geojson, data]) => {
|
||||
|
||||
L.geoJSON(geojson, {
|
||||
style: {
|
||||
fillOpacity: 0,
|
||||
color: '#ccc',
|
||||
weight: 1,
|
||||
interactive: false
|
||||
},
|
||||
const validProvinces = new Set(
|
||||
geojson.features.map(f => f.properties.ADM1_EN)
|
||||
);
|
||||
|
||||
onEachFeature: function (feature, layer) {
|
||||
L.geoJSON(geojson, {
|
||||
style: {
|
||||
fillOpacity: 0,
|
||||
color: '#ccc',
|
||||
weight: 1,
|
||||
interactive: false
|
||||
},
|
||||
|
||||
const province = feature.properties.ADM1_EN;
|
||||
const center = layer.getBounds().getCenter();
|
||||
onEachFeature: function (feature, layer) {
|
||||
|
||||
const rows = data.filter(d => d.site_province_name === province);
|
||||
const province = feature.properties.ADM1_EN;
|
||||
const center = layer.getBounds().getCenter();
|
||||
|
||||
const offsets = {
|
||||
1: -0.15,
|
||||
2: 0,
|
||||
3: 0.15
|
||||
};
|
||||
const rows = data.filter(d => {
|
||||
if (![1, 2, 3].includes(d.surveillance_id)) return false;
|
||||
|
||||
rows.forEach(row => {
|
||||
const name = normalizeProvince(d.patient_province, validProvinces);
|
||||
return name === province;
|
||||
});
|
||||
|
||||
const lat = center.lat;
|
||||
const lng = center.lng + offsets[row.surveillance_id];
|
||||
const offsets = { 1: -0.15, 2: 0, 3: 0.15 };
|
||||
|
||||
const programName =
|
||||
row.surveillance_id === 1 ? 'SARI' :
|
||||
row.surveillance_id === 2 ? 'ILI' : 'LBM';
|
||||
const colors = {
|
||||
1: '#2563eb',
|
||||
2: '#10b981',
|
||||
3: '#9333ea'
|
||||
};
|
||||
|
||||
const colors = {
|
||||
1: '#2563eb',
|
||||
2: '#10b981',
|
||||
3: '#9333ea'
|
||||
};
|
||||
rows.forEach(row => {
|
||||
|
||||
L.circleMarker([lat, lng], {
|
||||
const percent = row.total
|
||||
? ((row.positive / row.total) * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
radius: 9,
|
||||
fillColor: colors[row.surveillance_id],
|
||||
color: '#fff',
|
||||
weight: 1,
|
||||
fillOpacity: 0.9
|
||||
const offset = offsets[row.surveillance_id] ?? 0;
|
||||
|
||||
})
|
||||
.bindTooltip(`
|
||||
<strong>${province}</strong><br>
|
||||
${programName}<br>
|
||||
Total: ${row.total}
|
||||
`)
|
||||
.addTo(map);
|
||||
const lat = center.lat;
|
||||
const lng = center.lng + offset;
|
||||
|
||||
});
|
||||
const programName =
|
||||
row.surveillance_id === 1 ? 'SARI' :
|
||||
row.surveillance_id === 2 ? 'ILI' : 'LBM';
|
||||
|
||||
}
|
||||
L.circleMarker([lat, lng], {
|
||||
radius: getRadius(row.total),
|
||||
fillColor: colors[row.surveillance_id],
|
||||
color: '#fff',
|
||||
weight: 1,
|
||||
fillOpacity: 0.9
|
||||
})
|
||||
.bindTooltip(`
|
||||
<strong>${province}</strong><br>
|
||||
${programName}<br>
|
||||
Cases: ${row.total}<br>
|
||||
Positivity: ${percent}%
|
||||
`)
|
||||
.addTo(map);
|
||||
|
||||
}).addTo(map);
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
}).addTo(map);
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -267,7 +310,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
|
||||
|
||||
loadTrend('week', startYear, startWeek, endYear, endWeek);
|
||||
|
||||
loadProvinceMap(startYear, startWeek, endYear, endWeek);
|
||||
|
||||
});
|
||||
|
||||
@@ -1,36 +1,84 @@
|
||||
const standardPrograms = ['SARI', 'ILI', 'LBM'];
|
||||
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(renderDashboard)
|
||||
.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) {
|
||||
|
||||
const totals = {};
|
||||
rows.forEach(r => {
|
||||
totals[r.site_province_name] = {
|
||||
total: Number(r.total),
|
||||
positive: Number(r.positive)
|
||||
};
|
||||
});
|
||||
|
||||
if (map) {
|
||||
map.remove();
|
||||
}
|
||||
if (map) map.remove();
|
||||
|
||||
map = L.map('provinceMap').setView([12.7, 104.9], 7);
|
||||
|
||||
@@ -44,6 +92,28 @@ function renderProvinceHeatmap(rows) {
|
||||
.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";
|
||||
@@ -65,11 +135,15 @@ function renderProvinceHeatmap(rows) {
|
||||
};
|
||||
},
|
||||
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;
|
||||
console.log(province, total, positive, percent);
|
||||
|
||||
// ✅ positivity kept
|
||||
const percent = total
|
||||
? ((positive / total) * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
layer.bindTooltip(`
|
||||
${province}<br>
|
||||
@@ -159,6 +233,7 @@ function renderTrend(valueId, changeId, current, previous, suffix = '') {
|
||||
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);
|
||||
@@ -230,32 +305,22 @@ function renderSummary(summary) {
|
||||
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 || []);
|
||||
// buildStackedChart(
|
||||
// "pathogenChart",
|
||||
// labels,
|
||||
// [
|
||||
// {
|
||||
// label: "Influenza",
|
||||
// data: influenza,
|
||||
// backgroundColor: "#2E7D32"
|
||||
// },
|
||||
// {
|
||||
// label: "SARS-CoV-2",
|
||||
// data: covid,
|
||||
// backgroundColor: "#A5D6A7"
|
||||
// }
|
||||
// ]
|
||||
// );
|
||||
|
||||
|
||||
const pathogenRows = (data.pathogen_distribution || [])
|
||||
.sort((a, b) => b.total - a.total);
|
||||
const colors = [
|
||||
'#2563eb', '#10b981', '#f59e0b', '#ef4444',
|
||||
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16'
|
||||
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16', '#e11dba', '#f6f63b'
|
||||
];
|
||||
|
||||
|
||||
@@ -305,3 +370,114 @@ function renderDashboard(data) {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user