working on detail page for sari, lil, amd lbm

This commit is contained in:
2026-03-13 15:49:01 +07:00
parent 519d0924c8
commit c2b820fc6d
14 changed files with 1627 additions and 956 deletions

View File

@@ -0,0 +1,108 @@
const charts = {};
function buildChart(id, type, labels, data, label = 'Cases') {
const ctx = document.getElementById(id);
if (!ctx) return;
if (charts[id]) charts[id].destroy();
charts[id] = new Chart(ctx, {
type: type,
data: {
labels: labels,
datasets: [{
label: label,
data: data,
borderWidth: 2,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false
}
});
}
function buildMixedTrendChart(canvasId, labels, samples, positivity) {
const ctx = document.getElementById(canvasId);
if (!ctx) return;
if (charts[canvasId]) charts[canvasId].destroy();
charts[canvasId] = new Chart(ctx, {
data: {
labels: labels,
datasets: [
{
type: 'line',
label: '% Positive',
data: positivity,
borderColor: '#1e6ef2',
borderWidth: 2,
tension: 0.4,
fill: false,
pointRadius: 4,
pointStyle: 'line',
yAxisID: 'y1'
},
{
type: 'bar',
label: 'Total sample',
data: samples,
backgroundColor: '#2ecc71',
borderRadius: 6,
barPercentage: 0.6,
pointStyle: 'rect',
categoryPercentage: 0.7,
yAxisID: 'y',
},
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
align: 'center',
labels: {
usePointStyle: true,
padding: 20,
boxWidth: 30,
font: {
size: 12
}
}
}
},
layout: {
padding: {
bottom: 50
}
},
scales: {
y: {
position: 'left',
title: {
display: true,
text: 'Total sample'
}
},
y1: {
position: 'right',
grid: {
drawOnChartArea: false
},
title: {
display: true,
text: '% Positive'
},
ticks: {
callback: value => value + '%'
}
}
}
}
});
}

View File

@@ -0,0 +1,132 @@
class DashboardFilter {
constructor(onChange) {
this.onChange = onChange;
this.rangeSelect = document.getElementById("trend_range");
this.startYear = document.getElementById("start_year");
this.startWeek = document.getElementById("start_week");
this.endYear = document.getElementById("end_year");
this.endWeek = document.getElementById("end_week");
this.customContainer = document.getElementById("custom_range_container");
if (!this.rangeSelect) return;
this.init();
}
init() {
this.populateFilters();
this.rangeSelect.addEventListener("change", () => {
const val = this.rangeSelect.value;
if (val === "custom") {
this.customContainer.style.display = "flex";
return;
}
this.customContainer.style.display = "none";
const range = this.lastWeeks(parseInt(val));
this.apply(range);
this.trigger();
});
["start_year","start_week","end_year","end_week"].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener("change", ()=>this.trigger());
});
const defaultRange = this.lastWeeks(8);
this.apply(defaultRange);
this.trigger();
}
trigger() {
this.onChange(
this.startYear.value,
this.startWeek.value,
this.endYear.value,
this.endWeek.value
);
}
apply(range) {
this.startYear.value = range.startYear;
this.startWeek.value = range.startWeek;
this.endYear.value = range.endYear;
this.endWeek.value = range.endWeek;
}
populateFilters() {
const year = new Date().getFullYear();
for (let y = year-10; y <= year; y++) {
this.startYear.innerHTML += `<option value="${y}">${y}</option>`;
this.endYear.innerHTML += `<option value="${y}">${y}</option>`;
}
for (let w = 1; w <= 53; w++) {
this.startWeek.innerHTML += `<option value="${w}">W${w}</option>`;
this.endWeek.innerHTML += `<option value="${w}">W${w}</option>`;
}
}
getISOWeek(date) {
const d = new Date(Date.UTC(date.getFullYear(),date.getMonth(),date.getDate()));
d.setUTCDate(d.getUTCDate()+4-(d.getUTCDay()||7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(),0,1));
const week = Math.ceil((((d-yearStart)/86400000)+1)/7);
return {year:d.getUTCFullYear(),week};
}
lastWeeks(n) {
const end = this.getISOWeek(new Date());
let startWeek = end.week-n+1;
let startYear = end.year;
while(startWeek<=0){
startWeek += 52;
startYear--;
}
return {
startYear,
startWeek,
endYear:end.year,
endWeek:end.week
};
}
}

View File

@@ -0,0 +1,275 @@
let trendChart;
let map;
/*
|--------------------------------------------------------------------------
| Load Summary Cards
|--------------------------------------------------------------------------
*/
function loadSummary() {
fetch('/api/dashboard/summary')
.then(res => res.json())
.then(data => {
let html = '';
data.forEach(item => {
let trendColor = 'text-secondary';
if (item.percent_change > 0) trendColor = 'text-danger';
if (item.percent_change < 0) trendColor = 'text-success';
html += `
<div class="col-md-2 mb-3">
<div class="card shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="fw-bold">${item.code}</h6>
<h3 class="mb-1">${item.current_total}</h3>
<small class="text-muted">Last 7 days</small>
</div>
<div class="text-end">
<div class="${trendColor} fw-bold">
${item.percent_change > 0 ? '▲' : item.percent_change < 0 ? '▼' : ''}
${Math.abs(item.percent_change)}%
</div>
<small class="text-muted">
${item.previous_total ?? 0} last week
</small>
</div>
</div>
</div>
</div>
</div>
`;
});
document.getElementById('summary_cards').innerHTML = html;
});
}
/*
|--------------------------------------------------------------------------
| 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'
};
const datasets = [];
const allowedPrograms = ['SARI', 'ILI', 'LBM'];
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' }
},
interaction: {
mode: 'index',
intersect: false
},
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1 }
},
x: { grid: { display: false } }
}
}
});
});
}
/*
|--------------------------------------------------------------------------
| Province Map
|--------------------------------------------------------------------------
*/
function loadProvinceMap(startYear, startWeek, endYear, endWeek) {
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'
}).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]) => {
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 => d.site_province_name === province);
const offsets = {
1: -0.15,
2: 0,
3: 0.15
};
rows.forEach(row => {
const lat = center.lat;
const lng = center.lng + offsets[row.surveillance_id];
const programName =
row.surveillance_id === 1 ? 'SARI' :
row.surveillance_id === 2 ? 'ILI' : 'LBM';
const colors = {
1: '#2563eb',
2: '#10b981',
3: '#9333ea'
};
L.circleMarker([lat, lng], {
radius: 9,
fillColor: colors[row.surveillance_id],
color: '#fff',
weight: 1,
fillOpacity: 0.9
})
.bindTooltip(`
<strong>${province}</strong><br>
${programName}<br>
Total: ${row.total}
`)
.addTo(map);
});
}
}).addTo(map);
});
}
/*
|--------------------------------------------------------------------------
| Initialize Dashboard
|--------------------------------------------------------------------------
*/
document.addEventListener("DOMContentLoaded", () => {
loadSummary();
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
loadTrend('week', startYear, startWeek, endYear, endWeek);
loadProvinceMap(startYear, startWeek, endYear, endWeek);
});
});

View File

@@ -0,0 +1,155 @@
const standardPrograms = ['SARI', 'ILI', 'LBM'];
const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase();
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)
.catch(err => console.error("Dashboard API error:", err));
});
});
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 positivity = rows.map(r => r.positivity_rate || 0);
buildMixedTrendChart(
'trendChart',
labels,
samples,
positivity
);
}
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) {
console.log("SUMMARY:", data.summary);
data = data || {};
renderProgramTrend(data.trend || []);
renderSummary(data.summary || {});
buildChart(
'provinceChart',
'bar',
(data.province_distribution || []).map(r => r.site_province_name),
(data.province_distribution || []).map(r => r.total)
);
buildChart(
'pathogenChart',
'bar',
(data.pathogen_distribution || []).map(r => r.pathogen_name),
(data.pathogen_distribution || []).map(r => r.total),
'Positive'
);
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)
);
}