Merge branch 'feature/exportcharts'
This commit is contained in:
@@ -78,9 +78,7 @@ function buildStackedChart(canvasId, labels, datasets) {
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets,
|
||||
datalabels: {
|
||||
display: true
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
plugins: [ChartDataLabels],
|
||||
@@ -109,19 +107,22 @@ function buildStackedChart(canvasId, labels, datasets) {
|
||||
pointStyle: 'circle'
|
||||
}
|
||||
},
|
||||
// datalabels: {
|
||||
// color: "#000",
|
||||
// anchor: "end",
|
||||
// align: "top",
|
||||
// clamp: true,
|
||||
// clip: false,
|
||||
// font: {
|
||||
// weight: "bold",
|
||||
// size: 10
|
||||
// },
|
||||
// formatter: function (value) {
|
||||
// return value > 0 ? value : null;
|
||||
// }
|
||||
// }
|
||||
datalabels: {
|
||||
color: "#000",
|
||||
anchor: "end",
|
||||
align: "top",
|
||||
clamp: true,
|
||||
clip: false,
|
||||
font: {
|
||||
weight: "bold",
|
||||
size: 10
|
||||
},
|
||||
formatter: function (value) {
|
||||
return value > 0 ? value : null;
|
||||
}
|
||||
display: false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -152,6 +153,7 @@ function buildChart(id, type, labels, data) {
|
||||
}
|
||||
const isHorizontal = id === 'sexChart';
|
||||
const isAgeChart = id === 'ageChart';
|
||||
const isSentinelChart = id === 'sentinelChart';
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -165,7 +167,7 @@ function buildChart(id, type, labels, data) {
|
||||
plugins: {
|
||||
|
||||
legend: {
|
||||
position: isAgeChart ? 'left' : 'bottom',
|
||||
position: isAgeChart || isSentinelChart ? 'left' : 'bottom',
|
||||
align: 'center',
|
||||
display: (ctx) => {
|
||||
const chart = ctx.chart;
|
||||
@@ -277,117 +279,53 @@ function buildChart(id, type, labels, data) {
|
||||
options: options
|
||||
});
|
||||
}
|
||||
function buildMixedTrendChart(canvasId, labels, samples, fluRate, covidRate) {
|
||||
function buildMixedTrendChart(canvasId, labels, samples, lines) {
|
||||
|
||||
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();
|
||||
|
||||
const datasets = [];
|
||||
|
||||
lines.forEach(line => {
|
||||
datasets.push({
|
||||
type: 'line',
|
||||
label: line.label,
|
||||
data: line.data,
|
||||
borderColor: line.color,
|
||||
backgroundColor: line.color,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1',
|
||||
pointStyle: 'line'
|
||||
});
|
||||
});
|
||||
|
||||
datasets.push({
|
||||
type: 'bar',
|
||||
label: 'Total Cases',
|
||||
data: samples,
|
||||
backgroundColor: '#0B8F3C',
|
||||
yAxisID: 'y'
|
||||
});
|
||||
|
||||
charts[canvasId] = new Chart(ctx, {
|
||||
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
|
||||
{
|
||||
type: 'line',
|
||||
label: 'Influenza %',
|
||||
data: fluRate,
|
||||
borderColor: '#fa2929',
|
||||
backgroundColor: '#fa2929',
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1',
|
||||
pointStyle: 'line',
|
||||
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
type: 'line',
|
||||
label: 'COVID-19 %',
|
||||
data: covidRate,
|
||||
borderColor: '#1976D2',
|
||||
backgroundColor: '#1976D2',
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1',
|
||||
pointStyle: 'line',
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
type: 'bar',
|
||||
label: 'Total Cases',
|
||||
data: samples,
|
||||
backgroundColor: '#0B8F3C',
|
||||
borderRadius: 2,
|
||||
barPercentage: 0.8,
|
||||
categoryPercentage: 0.7,
|
||||
yAxisID: 'y',
|
||||
}
|
||||
|
||||
]
|
||||
},
|
||||
|
||||
plugins: [ChartDataLabels],
|
||||
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20
|
||||
}
|
||||
},
|
||||
legend: { position: 'bottom' },
|
||||
datalabels: {
|
||||
align: "top",
|
||||
anchor: "end",
|
||||
color: "#555",
|
||||
display: false,
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
formatter: function (value, context) {
|
||||
if (Number(value) === 0) return null;
|
||||
|
||||
if (context.dataset.type === 'line') {
|
||||
return value + '%';
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
display: false
|
||||
}
|
||||
},
|
||||
layout: {
|
||||
padding: {
|
||||
top: 30,
|
||||
bottom: 20
|
||||
}
|
||||
},
|
||||
|
||||
scales: {
|
||||
y: {
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Cases'
|
||||
}
|
||||
},
|
||||
y: { title: { display: true, text: 'Cases' } },
|
||||
y1: {
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
title: {
|
||||
display: true,
|
||||
text: '% Positivity'
|
||||
},
|
||||
|
||||
title: { display: true, text: '% Positivity' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,212 +72,372 @@ function formatChartName(id) {
|
||||
}
|
||||
|
||||
async function exportSelectedCharts() {
|
||||
console.log("Exporting selected charts...");
|
||||
|
||||
const { jsPDF } = window.jspdf;
|
||||
const pdf = new jsPDF("p", "mm", "a4");
|
||||
const pdf = new jsPDF("l", "mm", "a4");
|
||||
|
||||
const margin = 15;
|
||||
const pageWidth = 297;
|
||||
const pageHeight = 210;
|
||||
|
||||
const contentTop = 35;
|
||||
const contentHeight = pageHeight - contentTop - 10;
|
||||
const contentWidth = pageWidth - margin * 2;
|
||||
|
||||
const selected = document.querySelectorAll("#chartList input:checked");
|
||||
|
||||
const margin = 10;
|
||||
const pageWidth = 210;
|
||||
const usableWidth = pageWidth - margin * 2;
|
||||
const loadingDiv = document.createElement("div");
|
||||
Object.assign(loadingDiv.style, {
|
||||
position: "fixed",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: "rgb(255, 255, 255)",
|
||||
color: "#fff",
|
||||
padding: "20px",
|
||||
borderRadius: "10px",
|
||||
zIndex: "10000"
|
||||
});
|
||||
loadingDiv.innerHTML = "Generating PDF...";
|
||||
document.body.appendChild(loadingDiv);
|
||||
|
||||
const gap = 5;
|
||||
const chartWidth = (usableWidth - gap) / 2;
|
||||
function drawHeader() {
|
||||
pdf.setFillColor(255, 255, 255);
|
||||
pdf.rect(0, 0, pageWidth, 30, "F");
|
||||
|
||||
let y = 35;
|
||||
pdf.setFontSize(18);
|
||||
pdf.setFont(undefined, "bold");
|
||||
pdf.setTextColor(0);
|
||||
pdf.text(getReportTitle(), margin, 14);
|
||||
|
||||
pdf.setFontSize(18);
|
||||
pdf.setFont(undefined, "bold");
|
||||
pdf.text(getReportTitle(), margin, 15);
|
||||
pdf.setFontSize(9);
|
||||
pdf.setTextColor(100);
|
||||
pdf.text(`Generated: ${new Date().toLocaleString()}`, margin, 22);
|
||||
|
||||
pdf.setFontSize(10);
|
||||
pdf.setFont(undefined, "normal");
|
||||
pdf.text(`Generated: ${new Date().toLocaleString()}`, margin, 22);
|
||||
pdf.setDrawColor(220);
|
||||
pdf.line(margin, 28, pageWidth - margin, 28);
|
||||
}
|
||||
|
||||
pdf.setDrawColor(200);
|
||||
pdf.line(margin, 25, pageWidth - margin, 25);
|
||||
drawHeader();
|
||||
|
||||
const items = [];
|
||||
|
||||
for (const cb of selected) {
|
||||
|
||||
if (cb.value === "provinceMap") {
|
||||
const mapImg = await getMapImage();
|
||||
loadingDiv.innerHTML = "Processing map...";
|
||||
const img = await getMapImage();
|
||||
|
||||
if (mapImg) {
|
||||
if (img) {
|
||||
items.push({
|
||||
id: "provinceMap",
|
||||
type: "map",
|
||||
img: mapImg
|
||||
img
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const chart = charts[cb.value];
|
||||
if (!chart) continue;
|
||||
|
||||
continue;
|
||||
items.push({
|
||||
id: cb.value,
|
||||
type: "chart",
|
||||
chart
|
||||
});
|
||||
}
|
||||
|
||||
const chart = charts[cb.value];
|
||||
if (!chart) continue;
|
||||
|
||||
items.push({
|
||||
id: cb.value,
|
||||
type: "chart",
|
||||
chart
|
||||
});
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
const FIXED_HEIGHT = 70;
|
||||
const FIXED_WIDTH = chartWidth;
|
||||
loadingDiv.innerHTML = `Rendering ${formatChartName(item.id)} (${i + 1}/${items.length})`;
|
||||
|
||||
while (i < items.length) {
|
||||
|
||||
const left = items[i];
|
||||
const right = items[i + 1] || null;
|
||||
|
||||
let leftHeight = FIXED_HEIGHT;
|
||||
let rightHeight = 0;
|
||||
|
||||
const xLeft = margin;
|
||||
|
||||
pdf.setFontSize(11);
|
||||
pdf.setFont(undefined, "bold");
|
||||
pdf.text(formatChartName(left.id), xLeft, y - 3);
|
||||
|
||||
let leftWidth = FIXED_WIDTH;
|
||||
let leftImg;
|
||||
|
||||
if (left.type === "map") {
|
||||
leftWidth = FIXED_WIDTH;
|
||||
leftHeight = 60;
|
||||
leftImg = left.img;
|
||||
} else {
|
||||
leftImg = left.chart.toBase64Image();
|
||||
|
||||
if (["doughnut", "pie"].includes(left.chart.config.type)) {
|
||||
const size = Math.min(FIXED_WIDTH, FIXED_HEIGHT);
|
||||
leftWidth = size;
|
||||
leftHeight = size;
|
||||
} else {
|
||||
leftHeight = FIXED_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
const xLeftAdjusted = xLeft + (FIXED_WIDTH - leftWidth) / 2;
|
||||
|
||||
pdf.addImage(
|
||||
leftImg,
|
||||
"PNG",
|
||||
xLeftAdjusted,
|
||||
y,
|
||||
leftWidth,
|
||||
leftHeight
|
||||
);
|
||||
|
||||
if (right) {
|
||||
|
||||
const xRight = margin + chartWidth + gap;
|
||||
|
||||
pdf.setFontSize(11);
|
||||
pdf.setFont(undefined, "bold");
|
||||
pdf.text(formatChartName(right.id), xRight, y - 3);
|
||||
|
||||
let rightWidth = FIXED_WIDTH;
|
||||
let rightImg;
|
||||
|
||||
if (right.type === "map") {
|
||||
rightWidth = FIXED_WIDTH;
|
||||
rightHeight = 60;
|
||||
rightImg = right.img;
|
||||
} else {
|
||||
rightImg = right.chart.toBase64Image();
|
||||
|
||||
if (["doughnut", "pie"].includes(right.chart.config.type)) {
|
||||
const size = Math.min(FIXED_WIDTH, FIXED_HEIGHT);
|
||||
rightWidth = size;
|
||||
rightHeight = size;
|
||||
} else {
|
||||
rightHeight = FIXED_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
const xRightAdjusted = xRight + (FIXED_WIDTH - rightWidth) / 2;
|
||||
|
||||
pdf.addImage(
|
||||
rightImg,
|
||||
"PNG",
|
||||
xRightAdjusted,
|
||||
y,
|
||||
rightWidth,
|
||||
rightHeight
|
||||
);
|
||||
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// ===== ROW HEIGHT =====
|
||||
const rowHeight = Math.max(leftHeight, rightHeight);
|
||||
y += rowHeight + 12;
|
||||
|
||||
// ===== PAGE BREAK =====
|
||||
if (y > 260) {
|
||||
if (i !== 0) {
|
||||
pdf.addPage();
|
||||
y = 30;
|
||||
drawHeader();
|
||||
}
|
||||
|
||||
const cardX = margin;
|
||||
const cardY = contentTop - 5;
|
||||
const cardWidth = contentWidth;
|
||||
const cardHeight = contentHeight;
|
||||
|
||||
pdf.setFillColor(255, 255, 255);
|
||||
pdf.roundedRect(cardX, cardY, cardWidth, cardHeight, 3, 3, "F");
|
||||
|
||||
pdf.setDrawColor(230);
|
||||
pdf.roundedRect(cardX, cardY, cardWidth, cardHeight, 3, 3);
|
||||
|
||||
pdf.setFontSize(13);
|
||||
pdf.setFont(undefined, "bold");
|
||||
pdf.setTextColor(0);
|
||||
pdf.text(formatChartName(item.id), cardX + 5, cardY + 8);
|
||||
|
||||
let img;
|
||||
let width;
|
||||
let height;
|
||||
|
||||
if (item.type === "map") {
|
||||
img = item.img;
|
||||
|
||||
width = cardWidth - 50;
|
||||
height = width * 0.65;
|
||||
|
||||
} else {
|
||||
const canvas = item.chart.canvas;
|
||||
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = canvas.width;
|
||||
tempCanvas.height = canvas.height;
|
||||
|
||||
const ctx = tempCanvas.getContext("2d", { willReadFrequently: true });
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
|
||||
img = tempCanvas.toDataURL("image/png");
|
||||
|
||||
const ratio = canvas.height / canvas.width;
|
||||
|
||||
width = cardWidth - 10;
|
||||
height = width * ratio;
|
||||
|
||||
if (height > cardHeight - 20) {
|
||||
height = cardHeight - 20;
|
||||
width = height / ratio;
|
||||
}
|
||||
}
|
||||
|
||||
const x = cardX + (cardWidth - width) / 2;
|
||||
const contentTopOffset = 14;
|
||||
const availableHeight = cardHeight - contentTopOffset;
|
||||
|
||||
const y = cardY + contentTopOffset + (availableHeight - height) / 2;
|
||||
|
||||
pdf.addImage(img, "PNG", x, y, width, height);
|
||||
}
|
||||
|
||||
document.body.removeChild(loadingDiv);
|
||||
|
||||
pdf.save("dashboard_report.pdf");
|
||||
closeChartSelector();
|
||||
}
|
||||
function prepareMapForExport() {
|
||||
if (!window.map) return;
|
||||
|
||||
const bounds = L.featureGroup(Object.values(map._layers)).getBounds();
|
||||
const bounds = L.latLngBounds([
|
||||
[10.3, 102.3],
|
||||
[14.7, 107.6]
|
||||
]);
|
||||
|
||||
map.fitBounds(bounds);
|
||||
|
||||
if (bounds.isValid()) {
|
||||
map.setView([12.5, 104.9], 6);
|
||||
}
|
||||
}
|
||||
function exportFullDashboard() {
|
||||
|
||||
async function exportFullDashboard() {
|
||||
console.log("Exporting full charts...");
|
||||
const el = document.querySelector(".content-area");
|
||||
|
||||
html2canvas(el, { scale: 2 }).then(canvas => {
|
||||
const mapEl = document.getElementById("provinceMap");
|
||||
let originalMapHTML = null;
|
||||
|
||||
const img = canvas.toDataURL("image/png");
|
||||
if (mapEl) {
|
||||
originalMapHTML = mapEl.innerHTML;
|
||||
|
||||
const { jsPDF } = window.jspdf;
|
||||
const pdf = new jsPDF("p", "mm", "a4");
|
||||
const mapImg = await getMapImage();
|
||||
|
||||
const width = 210;
|
||||
const height = (canvas.height * width) / canvas.width;
|
||||
if (mapImg) {
|
||||
mapEl.innerHTML = `<img src="${mapImg}" style="width:100%;height:100%;object-fit:cover;" />`;
|
||||
}
|
||||
}
|
||||
prepareMapForExport();
|
||||
|
||||
pdf.addImage(img, "PNG", 0, 0, width, height);
|
||||
|
||||
pdf.save("full_dashboard.pdf");
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
const canvas = await html2canvas(el, {
|
||||
scale: 3,
|
||||
useCORS: true,
|
||||
scrollY: -window.scrollY
|
||||
});
|
||||
|
||||
if (mapEl && originalMapHTML !== null) {
|
||||
mapEl.innerHTML = originalMapHTML;
|
||||
map.invalidateSize();
|
||||
}
|
||||
|
||||
const img = canvas.toDataURL("image/png");
|
||||
|
||||
const { jsPDF } = window.jspdf;
|
||||
const pdf = new jsPDF("l", "mm", "a4");
|
||||
|
||||
const pageWidth = 297;
|
||||
const pageHeight = 210;
|
||||
|
||||
const ratio = Math.min(
|
||||
pageWidth / canvas.width,
|
||||
pageHeight / canvas.height
|
||||
);
|
||||
|
||||
const imgWidth = canvas.width * ratio;
|
||||
const imgHeight = canvas.height * ratio;
|
||||
|
||||
const x = (pageWidth - imgWidth) / 2;
|
||||
const y = (pageHeight - imgHeight) / 2;
|
||||
|
||||
pdf.addImage(img, "PNG", x, y, imgWidth, imgHeight);
|
||||
pdf.save("full_dashboard.pdf");
|
||||
|
||||
closeChartSelector();
|
||||
}
|
||||
function getColor(value) {
|
||||
if (value > 50) return "#b91c1c";
|
||||
if (value >= 10) return "#ef4444";
|
||||
if (value > 0) return "#fecaca";
|
||||
return "#f3f4f600";
|
||||
}
|
||||
async function getMapImage() {
|
||||
const original = document.getElementById("provinceMap");
|
||||
if (!original) return null;
|
||||
|
||||
const mapEl = document.getElementById("provinceMap");
|
||||
if (!mapEl) return null;
|
||||
const width = original.offsetWidth;
|
||||
const height = original.offsetHeight;
|
||||
|
||||
prepareMapForExport();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width * 3;
|
||||
canvas.height = height * 3;
|
||||
|
||||
// wait for map tiles to re-render
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.scale(3, 3);
|
||||
|
||||
const canvas = await html2canvas(mapEl, {
|
||||
useCORS: true,
|
||||
scale: 2
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const zoom = map.getZoom();
|
||||
const projection = map.options.crs;
|
||||
|
||||
const projectedRings = [];
|
||||
|
||||
let minX = Infinity, minY = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
const totals = {};
|
||||
const rows = window.latestProvinceData || [];
|
||||
|
||||
rows.forEach(r => {
|
||||
const province = normalizeProvince(r.patient_province, window.validProvinces);
|
||||
console.log(province, totals[province]);
|
||||
if (!province) return;
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
map.eachLayer(layer => {
|
||||
if (!layer.toGeoJSON) return;
|
||||
|
||||
const geo = layer.toGeoJSON();
|
||||
|
||||
const features = geo.type === "FeatureCollection"
|
||||
? geo.features
|
||||
: [geo];
|
||||
|
||||
features.forEach(f => {
|
||||
if (!f.geometry) return;
|
||||
|
||||
const coords = f.geometry.coordinates;
|
||||
|
||||
const polygons = f.geometry.type === "MultiPolygon"
|
||||
? coords
|
||||
: [coords];
|
||||
|
||||
polygons.forEach(poly => {
|
||||
poly.forEach(ring => {
|
||||
|
||||
const projected = ring.map(([lng, lat]) => {
|
||||
const latlng = L.latLng(lat, lng);
|
||||
const point = projection.latLngToPoint(latlng, zoom);
|
||||
|
||||
minX = Math.min(minX, point.x);
|
||||
maxX = Math.max(maxX, point.x);
|
||||
minY = Math.min(minY, point.y);
|
||||
maxY = Math.max(maxY, point.y);
|
||||
|
||||
return [point.x, point.y];
|
||||
});
|
||||
|
||||
projectedRings.push({
|
||||
points: projected,
|
||||
properties: f.properties
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const padding = 10;
|
||||
|
||||
const scaleX = (width - padding * 2) / (maxX - minX);
|
||||
const scaleY = (height - padding * 2) / (maxY - minY);
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
const offsetX = (width - (maxX - minX) * scale) / 2;
|
||||
const offsetY = (height - (maxY - minY) * scale) / 2;
|
||||
|
||||
projectedRings.forEach(({ points, properties }) => {
|
||||
ctx.beginPath();
|
||||
|
||||
points.forEach(([x, y], i) => {
|
||||
const drawX = (x - minX) * scale + offsetX;
|
||||
const drawY = (y - minY) * scale + offsetY;
|
||||
|
||||
if (i === 0) ctx.moveTo(drawX, drawY);
|
||||
else ctx.lineTo(drawX, drawY);
|
||||
});
|
||||
|
||||
ctx.closePath();
|
||||
|
||||
const province = properties.ADM1_EN;
|
||||
const value = totals[province]?.total || 0;
|
||||
|
||||
ctx.fillStyle = getColor(value);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = "#444";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
const legend = document.querySelector(".map-legend");
|
||||
|
||||
if (legend) {
|
||||
const legendCanvas = await html2canvas(legend, {
|
||||
scale: 3,
|
||||
backgroundColor: null
|
||||
});
|
||||
|
||||
const lw = legend.offsetWidth;
|
||||
const lh = legend.offsetHeight;
|
||||
|
||||
ctx.drawImage(
|
||||
legendCanvas,
|
||||
width - lw - 10,
|
||||
height - lh - 10,
|
||||
lw,
|
||||
lh
|
||||
);
|
||||
}
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
function getReportTitle() {
|
||||
|
||||
@@ -114,7 +114,7 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
|
||||
|
||||
const datasets = [];
|
||||
|
||||
const allowedPrograms = ['SARI', 'ILI', 'LBM', 'AFI', 'NDS', 'SEQ'];
|
||||
const allowedPrograms = ['SARI', 'ILI', 'LBM', 'AFI', 'NDS'];
|
||||
|
||||
Object.keys(data).forEach(code => {
|
||||
|
||||
@@ -144,6 +144,7 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
|
||||
});
|
||||
|
||||
trendChart = new Chart(document.getElementById('trendChart'), {
|
||||
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: displayLabels,
|
||||
@@ -184,6 +185,7 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
|
||||
}
|
||||
}
|
||||
});
|
||||
charts['trendChart'] = trendChart;
|
||||
|
||||
});
|
||||
}
|
||||
@@ -266,6 +268,7 @@ function loadInfluenzaSubtypeDistribution(periodType, startYear, startWeek, endY
|
||||
},
|
||||
plugins: [ChartDataLabels]
|
||||
});
|
||||
charts['influenzaSubtypeDistribution'] = influenzaSubtypeChart;
|
||||
|
||||
});
|
||||
}
|
||||
@@ -346,6 +349,7 @@ function loadCovidDistributedByAgeGroup(periodType, startYear, startWeek, endYea
|
||||
}
|
||||
}
|
||||
});
|
||||
charts['covidDistributedByAgeGroup'] = covidDistributedByAgeChart;
|
||||
|
||||
});
|
||||
}
|
||||
@@ -443,6 +447,7 @@ function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, en
|
||||
}
|
||||
}
|
||||
});
|
||||
charts['covidLineageFrequency'] = covidLineageFrequencyChart;
|
||||
|
||||
// -------------------------
|
||||
// Custom right-side scrollable legend
|
||||
@@ -1059,7 +1064,7 @@ function getColorByPathogen(name) {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
loadSummary();
|
||||
|
||||
|
||||
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
|
||||
|
||||
loadTrend('week', startYear, startWeek, endYear, endWeek);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
const standardPrograms = ['SARI', 'ILI', 'LBM', 'AFI'];
|
||||
const standardPrograms = ['SARI', 'ILI', 'LBM', 'AFI', 'NDS'];
|
||||
const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase();
|
||||
let map;
|
||||
let provinceLayer;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
if (!standardPrograms.includes(programCode)) return;
|
||||
|
||||
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
|
||||
const elements = document.querySelectorAll(".report-period");
|
||||
elements.forEach(el => {
|
||||
el.textContent = 'Week ' + startWeek + ' of ' + startYear + ' to ' + 'Week ' + endWeek + ' of ' + endYear
|
||||
});
|
||||
|
||||
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())
|
||||
@@ -15,6 +19,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
function normalizeProvince(name, validSet) {
|
||||
if (!name || !validSet) return null;
|
||||
@@ -46,6 +51,7 @@ function normalizeProvince(name, validSet) {
|
||||
|
||||
return match || null;
|
||||
}
|
||||
|
||||
function renderAFIDashboard(data) {
|
||||
const pathogenRows = (data.pathogen_distribution || [])
|
||||
.sort((a, b) => b.total - a.total);
|
||||
@@ -54,16 +60,22 @@ function renderAFIDashboard(data) {
|
||||
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16', '#e11dba', '#f6f63b'
|
||||
];
|
||||
|
||||
const rows = data.afi_trend || [];
|
||||
|
||||
const pcr = rows.filter(r => r.test_type === 'PCR');
|
||||
const serum = rows.filter(r => r.test_type === 'Serum');
|
||||
|
||||
renderAFITrend(pcr, 'trendChart', colors);
|
||||
//renderAFITrend(serum, 'pathogenChart', colors);
|
||||
|
||||
renderSummary(data.summary);
|
||||
renderAFITrend(data.afi_trend);
|
||||
renderProvinceHeatmap(data.province_distribution);
|
||||
renderPathogenChart(data.pathogen_distribution);
|
||||
renderDemographics(data);
|
||||
renderPathogenChart(data.pathogen_distribution || []);
|
||||
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;
|
||||
@@ -72,9 +84,12 @@ function renderAFIDashboard(data) {
|
||||
charts['sentinelChart'].update();
|
||||
charts['subtypeChart'].data.datasets[0].backgroundColor = colors;
|
||||
charts['subtypeChart'].update();
|
||||
charts['pathogenChart'].data.datasets[0].backgroundColor = colors;
|
||||
charts['pathogenChart'].update();
|
||||
}
|
||||
function renderProvinceHeatmap(rows) {
|
||||
|
||||
window.latestProvinceData = rows;
|
||||
|
||||
if (map) map.remove();
|
||||
|
||||
map = L.map('provinceMap').setView([12.7, 104.9], 7);
|
||||
@@ -92,7 +107,7 @@ function renderProvinceHeatmap(rows) {
|
||||
const validProvinces = new Set(
|
||||
geo.features.map(f => f.properties.ADM1_EN)
|
||||
);
|
||||
|
||||
window.validProvinces = validProvinces;
|
||||
const totals = {};
|
||||
|
||||
rows.forEach(r => {
|
||||
@@ -116,7 +131,7 @@ function renderProvinceHeatmap(rows) {
|
||||
return "#f3f4f600";
|
||||
}
|
||||
|
||||
provinceLayer = L.geoJSON(geo, {
|
||||
window.provinceLayer = L.geoJSON(geo, {
|
||||
style: feature => {
|
||||
|
||||
const province = feature.properties.ADM1_EN;
|
||||
@@ -124,9 +139,9 @@ function renderProvinceHeatmap(rows) {
|
||||
|
||||
return {
|
||||
color: "#444",
|
||||
weight: 1,
|
||||
weight: 1.5,
|
||||
fillColor: getColor(value),
|
||||
fillOpacity: 0.7
|
||||
fillOpacity: 0.8
|
||||
};
|
||||
},
|
||||
onEachFeature: (feature, layer) => {
|
||||
@@ -233,12 +248,27 @@ function renderProgramTrend(rows) {
|
||||
const fluRate = rows.map(r => r.influenza_rate || 0);
|
||||
const covidRate = rows.map(r => r.covid_rate || 0);
|
||||
|
||||
const lines = [
|
||||
{ label: 'Influenza %', data: fluRate, color: '#fa2929' },
|
||||
{ label: 'COVID-19 %', data: covidRate, color: '#1976D2' }
|
||||
];
|
||||
|
||||
// ✅ ONLY NDS gets EV + Mpox
|
||||
if (programCode === 'NDS') {
|
||||
const evRate = rows.map(r => r.ev_rate || 0);
|
||||
const mpoxRate = rows.map(r => r.mpox_rate || 0);
|
||||
|
||||
lines.push(
|
||||
{ label: 'EV %', data: evRate, color: '#f59e0b' },
|
||||
{ label: 'Mpox %', data: mpoxRate, color: '#8b5cf6' }
|
||||
);
|
||||
}
|
||||
|
||||
buildMixedTrendChart(
|
||||
'trendChart',
|
||||
labels,
|
||||
samples,
|
||||
fluRate,
|
||||
covidRate
|
||||
lines
|
||||
);
|
||||
}
|
||||
function renderSummary(summary) {
|
||||
@@ -365,74 +395,43 @@ function renderDashboard(data) {
|
||||
|
||||
|
||||
//AFI
|
||||
function renderAFITrend(rows) {
|
||||
function renderAFITrend(rows, canvasId, colors) {
|
||||
|
||||
if (!rows.length) {
|
||||
buildStackedChart('trendChart', [], []);
|
||||
if (!rows || !rows.length) {
|
||||
buildStackedChart(canvasId, [], []);
|
||||
return;
|
||||
}
|
||||
|
||||
const { labels, datasets } = transformAFIData(rows);
|
||||
const cleanRows = rows.filter(r => r.pathogen);
|
||||
|
||||
buildStackedChart('trendChart', labels, datasets);
|
||||
}
|
||||
function transformAFIData(rows) {
|
||||
const keyFn = r => `${r.year}-${r.period}`;
|
||||
|
||||
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 map = {};
|
||||
cleanRows.forEach(r => {
|
||||
const key = keyFn(r);
|
||||
if (!map[key]) map[key] = {};
|
||||
map[key][r.pathogen] = Number(r.total_tests || r.total || 0);
|
||||
});
|
||||
|
||||
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 keys = Object.keys(map).sort((a, b) => {
|
||||
const [y1, w1] = a.split('-').map(Number);
|
||||
const [y2, w2] = b.split('-').map(Number);
|
||||
return y1 !== y2 ? y1 - y2 : w1 - w2;
|
||||
});
|
||||
|
||||
const pathogenTotals = {};
|
||||
const labels = keys.map(k => `W${k.split('-')[1]}`);
|
||||
|
||||
rows.forEach(r => {
|
||||
pathogenTotals[r.pathogen] = (pathogenTotals[r.pathogen] || 0) + r.total;
|
||||
});
|
||||
const pathogens = [...new Set(cleanRows.map(r => r.pathogen))];
|
||||
|
||||
const pathogens = Object.keys(pathogenTotals)
|
||||
.sort((a, b) => pathogenTotals[b] - pathogenTotals[a]);
|
||||
const datasets = pathogens.map(p => ({
|
||||
const datasets = pathogens.map((p, i) => ({
|
||||
label: p,
|
||||
data: labels.map(l => grouped[l][p] || 0),
|
||||
backgroundColor: getColorForPathogen(p)
|
||||
data: keys.map(k => map[k][p] || 0),
|
||||
backgroundColor: colors[i % colors.length]
|
||||
}));
|
||||
|
||||
return { labels: labels.map(l => l.split('-')[1]), datasets };
|
||||
buildStackedChart(canvasId, labels, 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];
|
||||
|
||||
return `hsl(${Math.floor(Math.random() * 360)}, 70%, 60%)`;
|
||||
}
|
||||
function renderPathogenChart(rows) {
|
||||
buildChart(
|
||||
'pathogenChart',
|
||||
|
||||
Reference in New Issue
Block a user