working on detail page for sari, lil, amd lbm
This commit is contained in:
108
dashboard/public/js/dashboard/charts.js
Normal file
108
dashboard/public/js/dashboard/charts.js
Normal 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 + '%'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
132
dashboard/public/js/dashboard/filter.js
Normal file
132
dashboard/public/js/dashboard/filter.js
Normal 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
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
275
dashboard/public/js/overview.js
Normal file
275
dashboard/public/js/overview.js
Normal 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);
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
155
dashboard/public/js/program.js
Normal file
155
dashboard/public/js/program.js
Normal 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)
|
||||
);
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user