From 840b93a6513c62dc3cb1dcafcb6f4ad0f3820779 Mon Sep 17 00:00:00 2001 From: Khantey Long Date: Wed, 15 Apr 2026 15:45:34 +0700 Subject: [PATCH] export charts --- dashboard/public/js/dashboard/charts.js | 13 +- dashboard/public/js/dashboard/export.js | 300 ++++++++++++++++++ dashboard/public/js/dashboard/filter.js | 2 +- dashboard/public/js/program.js | 37 +-- .../resources/views/layouts/app.blade.php | 118 ++++++- 5 files changed, 426 insertions(+), 44 deletions(-) create mode 100644 dashboard/public/js/dashboard/export.js diff --git a/dashboard/public/js/dashboard/charts.js b/dashboard/public/js/dashboard/charts.js index 8838b86..aaa62d2 100644 --- a/dashboard/public/js/dashboard/charts.js +++ b/dashboard/public/js/dashboard/charts.js @@ -32,11 +32,16 @@ Chart.register({ if (chart.config.type !== 'doughnut') return; if (chart.$noData) return; - const { ctx, width, height } = chart; + const { ctx, chartArea } = chart; const data = chart.data.datasets[0].data; const total = data.reduce((a, b) => a + b, 0); + if (!chartArea) return; + + const centerX = (chartArea.left + chartArea.right) / 2; + const centerY = (chartArea.top + chartArea.bottom) / 2; + ctx.save(); ctx.textAlign = 'center'; @@ -44,11 +49,11 @@ Chart.register({ ctx.font = 'bold 18px sans-serif'; ctx.fillStyle = '#111827'; - ctx.fillText(total, width / 2, height / 2 - 8); + ctx.fillText(total, centerX, centerY - 8); ctx.font = '12px sans-serif'; ctx.fillStyle = '#6b7280'; - ctx.fillText('Total cases', width / 2, height / 2 + 12); + ctx.fillText('Total cases', centerX, centerY + 12); ctx.restore(); } @@ -247,7 +252,7 @@ function buildChart(id, type, labels, data) { } if (type === 'doughnut') { options.cutout = '70%'; - + options.maintainAspectRatio = false; options.elements = { arc: { borderWidth: (ctx) => ctx.chart.$noData ? 0 : 1 diff --git a/dashboard/public/js/dashboard/export.js b/dashboard/public/js/dashboard/export.js new file mode 100644 index 0000000..f3d5cb3 --- /dev/null +++ b/dashboard/public/js/dashboard/export.js @@ -0,0 +1,300 @@ +document.addEventListener("DOMContentLoaded", () => { + + const toggle = document.getElementById("exportToggle"); + const items = document.getElementById("exportItems"); + const close = document.getElementById("exportClose"); + + if (!toggle || !items) return; + + toggle.addEventListener("click", () => { + items.classList.toggle("show"); + + toggle.innerText = items.classList.contains("show") + ? "Export ▾" + : "Export ▸"; + }); + + if (close) { + close.addEventListener("click", () => { + items.classList.remove("show"); + toggle.innerText = "Export ▸"; + }); + } + +}); + +function openChartSelector() { + + if (typeof charts === "undefined" || Object.keys(charts).length === 0) { + alert("Charts are still loading. Please try again."); + return; + } + + const modal = document.getElementById("chartModal"); + const list = document.getElementById("chartList"); + + list.innerHTML = ""; + + Object.keys(charts).forEach(id => { + list.innerHTML += ` + + `; + }); + list.innerHTML += ` + + `; + + modal.style.display = "block"; +} + +function closeChartSelector() { + document.getElementById("chartModal").style.display = "none"; +} + +function formatChartName(id) { + const map = { + trendChart: "Case Trend & Positivity", + pathogenChart: "Pathogen Distribution", + provinceMap: "Geographic Distribution", + ageChart: "Age Distribution", + sexChart: "Sex Distribution", + subtypeChart: "Influenza Subtypes", + sentinelChart: "Sentinel Site Distribution" + }; + + return map[id] || id; +} + +async function exportSelectedCharts() { + + const { jsPDF } = window.jspdf; + const pdf = new jsPDF("p", "mm", "a4"); + + const selected = document.querySelectorAll("#chartList input:checked"); + + const margin = 10; + const pageWidth = 210; + const usableWidth = pageWidth - margin * 2; + + const gap = 5; + const chartWidth = (usableWidth - gap) / 2; + + let y = 35; + + // ===== HEADER ===== + pdf.setFontSize(18); + pdf.setFont(undefined, "bold"); + pdf.text(getReportTitle(), margin, 15); + + pdf.setFontSize(10); + pdf.setFont(undefined, "normal"); + pdf.text(`Generated: ${new Date().toLocaleString()}`, margin, 22); + + pdf.setDrawColor(200); + pdf.line(margin, 25, pageWidth - margin, 25); + + // ===== PREPARE ITEMS ===== + const items = []; + + for (const cb of selected) { + + if (cb.value === "provinceMap") { + const mapImg = await getMapImage(); + + if (mapImg) { + items.push({ + id: "provinceMap", + type: "map", + img: mapImg + }); + } + + continue; + } + + const chart = charts[cb.value]; + if (!chart) continue; + + items.push({ + id: cb.value, + type: "chart", + chart + }); + } + + let i = 0; + + const FIXED_HEIGHT = 70; + const FIXED_WIDTH = chartWidth; + + 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) { + pdf.addPage(); + y = 30; + } + } + + pdf.save("dashboard_report.pdf"); + closeChartSelector(); +} +function prepareMapForExport() { + if (!window.map) return; + + const bounds = L.featureGroup(Object.values(map._layers)).getBounds(); + + if (bounds.isValid()) { + map.setView([12.5, 104.9], 6); + } +} +function exportFullDashboard() { + + const el = document.querySelector(".content-area"); + + html2canvas(el, { scale: 2 }).then(canvas => { + + const img = canvas.toDataURL("image/png"); + + const { jsPDF } = window.jspdf; + const pdf = new jsPDF("p", "mm", "a4"); + + const width = 210; + const height = (canvas.height * width) / canvas.width; + + pdf.addImage(img, "PNG", 0, 0, width, height); + + pdf.save("full_dashboard.pdf"); + + }); + + closeChartSelector(); +} +async function getMapImage() { + + const mapEl = document.getElementById("provinceMap"); + if (!mapEl) return null; + + prepareMapForExport(); + + // wait for map tiles to re-render + await new Promise(resolve => setTimeout(resolve, 500)); + + const canvas = await html2canvas(mapEl, { + useCORS: true, + scale: 2 + }); + + return canvas.toDataURL("image/png"); +} +function getReportTitle() { + const program = window.PROGRAM_CODE || "Overview"; + + const rangeType = document.getElementById("trend_range")?.value; + + if (rangeType === "custom") { + const sy = document.getElementById("start_year")?.value; + const sw = document.getElementById("start_week")?.value; + const ey = document.getElementById("end_year")?.value; + const ew = document.getElementById("end_week")?.value; + + return `${program} Report (W${sw}/${sy} – W${ew}/${ey})`; + } + + return `${program} Report (Last ${rangeType} weeks)`; +} \ No newline at end of file diff --git a/dashboard/public/js/dashboard/filter.js b/dashboard/public/js/dashboard/filter.js index 2d12fa3..f6e9ddd 100644 --- a/dashboard/public/js/dashboard/filter.js +++ b/dashboard/public/js/dashboard/filter.js @@ -75,7 +75,7 @@ class DashboardFilter { const year = new Date().getFullYear(); - for (let y = year-10; y <= year; y++) { + for (let y = year-20; y <= year; y++) { this.startYear.innerHTML += ``; this.endYear.innerHTML += ``; diff --git a/dashboard/public/js/program.js b/dashboard/public/js/program.js index 90c97f1..633a563 100644 --- a/dashboard/public/js/program.js +++ b/dashboard/public/js/program.js @@ -2,7 +2,20 @@ const standardPrograms = ['SARI', 'ILI', 'LBM', 'AFI']; 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) => { + + 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 normalizeProvince(name, validSet) { if (!name || !validSet) return null; @@ -33,22 +46,6 @@ function normalizeProvince(name, validSet) { 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); @@ -100,11 +97,9 @@ function renderProvinceHeatmap(rows) { 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 }; } @@ -140,7 +135,6 @@ function renderProvinceHeatmap(rows) { const total = totals[province]?.total || 0; const positive = totals[province]?.positive || 0; - // ✅ positivity kept const percent = total ? ((positive / total) * 100).toFixed(1) : 0; @@ -323,8 +317,6 @@ function renderDashboard(data) { '#8b5cf6', '#14b8a6', '#f97316', '#84cc16', '#e11dba', '#f6f63b' ]; - - buildChart( 'pathogenChart', 'doughnut', @@ -439,7 +431,6 @@ function getColorForPathogen(name) { if (colors[name]) return colors[name]; - // fallback random color (for future pathogens) return `hsl(${Math.floor(Math.random() * 360)}, 70%, 60%)`; } function renderPathogenChart(rows) { @@ -482,4 +473,4 @@ function renderSubtypeChart(rows) { ); } -//Seq + diff --git a/dashboard/resources/views/layouts/app.blade.php b/dashboard/resources/views/layouts/app.blade.php index 3587178..a7d07fb 100644 --- a/dashboard/resources/views/layouts/app.blade.php +++ b/dashboard/resources/views/layouts/app.blade.php @@ -7,12 +7,14 @@ - + + + -
@@ -128,7 +180,6 @@
-