From cdd8251b176b50dab40aa5241eb1f82424f9b46b Mon Sep 17 00:00:00 2001 From: Khantey Long Date: Wed, 29 Apr 2026 08:58:06 +0700 Subject: [PATCH] finalize export function --- dashboard/app/Services/DashboardService.php | 325 +++++++------ dashboard/public/js/dashboard/charts.js | 156 ++---- dashboard/public/js/dashboard/export.js | 460 ++++++++++++------ dashboard/public/js/overview.js | 9 +- dashboard/public/js/program.js | 127 +++-- .../views/dashboard/detail.blade.php | 36 +- .../resources/views/layouts/app.blade.php | 198 +++++--- 7 files changed, 748 insertions(+), 563 deletions(-) diff --git a/dashboard/app/Services/DashboardService.php b/dashboard/app/Services/DashboardService.php index 036e851..4577ea2 100644 --- a/dashboard/app/Services/DashboardService.php +++ b/dashboard/app/Services/DashboardService.php @@ -64,82 +64,13 @@ class DashboardService return $results; } - /* - |-------------------------------------------------------------------------- - | Fast SARI Summary (single query) - |-------------------------------------------------------------------------- - */ - - public function programSummaryFast($surveillanceId, $year, $week) - { - $row = SurveillanceCase::leftJoin( - 'case_lab_results', - 'surveillance_cases.lab_code', - '=', - 'case_lab_results.lab_code' - ) - - ->where('surveillance_cases.surveillance_id', $surveillanceId) - ->where('surveillance_cases.year_data', $year) - ->where('surveillance_cases.week_data', $week) - - ->selectRaw(" - COUNT(DISTINCT surveillance_cases.lab_code) as total_cases, - - COUNT(DISTINCT CASE - WHEN case_lab_results.is_positive = 1 - THEN surveillance_cases.lab_code - END) as overall_positive, - - COUNT(DISTINCT CASE - WHEN case_lab_results.is_positive = 1 - AND ( - LOWER(case_lab_results.pathogen_name) LIKE '%influenza%' - OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%' - ) - THEN surveillance_cases.lab_code - END) as influenza_positive, - - COUNT(DISTINCT CASE - WHEN case_lab_results.is_positive = 1 - AND ( - LOWER(case_lab_results.pathogen_name) LIKE '%covid%' - OR LOWER(case_lab_results.pathogen_name) LIKE '%sars%' - ) - THEN surveillance_cases.lab_code - END) as covid_positive - ") - - ->first(); - - - if (!$row || $row->total_cases == 0) { - return [ - 'cases' => 0, - 'overall_rate' => 0, - 'influenza_rate' => 0, - 'covid_rate' => 0 - ]; - } - - return [ - 'cases' => $row->total_cases, - - 'overall_rate' => round(($row->overall_positive / $row->total_cases) * 100, 1), - - 'influenza_rate' => round(($row->influenza_positive / $row->total_cases) * 100, 1), - - 'covid_rate' => round(($row->covid_positive / $row->total_cases) * 100, 1), - ]; - } - - /* |-------------------------------------------------------------------------- | Program Summary |-------------------------------------------------------------------------- */ + public function afiTrend($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) { return CaseLabResult::join( @@ -152,107 +83,177 @@ class DashboardService ->where('surveillance_cases.surveillance_id', $surveillanceId) ->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) { - $q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek]) - ->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]); + $q->whereRaw( + "(year_data > ? OR (year_data = ? AND week_data >= ?))", + [$startYear, $startYear, $startWeek] + ) + ->whereRaw( + "(year_data < ? OR (year_data = ? AND week_data <= ?))", + [$endYear, $endYear, $endWeek] + ); }) - ->where('case_lab_results.is_positive', 1) - - ->whereNotNull('case_lab_results.pathogen_name') - ->where('case_lab_results.pathogen_name', '!=', '') - ->where('case_lab_results.pathogen_name', '!=', 'Positive') - + ->where(function ($q) { + $q->whereNotNull('case_lab_results.pathogen_name') + ->orWhereRaw("LOWER(case_lab_results.indicator) LIKE '%serum%'"); + }) ->selectRaw(" - surveillance_cases.year_data as year, - surveillance_cases.week_data as period, + surveillance_cases.year_data as year, + surveillance_cases.week_data as period, - CASE - WHEN LOWER(case_lab_results.pathogen_name) LIKE '%influenza%' THEN 'Influenza' - ELSE case_lab_results.pathogen_name - END as pathogen, + CASE + WHEN LOWER(case_lab_results.pathogen_name) LIKE '%influenza%' + OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%' + THEN 'Influenza' + ELSE case_lab_results.pathogen_name + END as pathogen, - COUNT(DISTINCT surveillance_cases.lab_code) as total - ") + CASE + WHEN LOWER(case_lab_results.indicator) LIKE '%serum%' THEN 'Serum' + ELSE 'PCR' + END as test_type, + COUNT(case_lab_results.id) as total_tests, + COUNT(DISTINCT surveillance_cases.lab_code) as total_tested, + + COUNT(CASE + WHEN case_lab_results.is_positive = 1 + THEN surveillance_cases.lab_code + END) as total_positive + ") ->groupBy( 'surveillance_cases.year_data', 'surveillance_cases.week_data', - 'pathogen' + 'pathogen', + 'test_type' ) - + //->havingRaw("pathogen IS NOT NULL AND pathogen != ''") ->orderBy('surveillance_cases.year_data') ->orderBy('surveillance_cases.week_data') - ->get(); + ->get() + ->map(function ($r) { + + $r->positivity_rate = $r->total_tested > 0 + ? round(($r->total_positive / $r->total_tested) * 100, 1) + : 0; + + return $r; + }); + } + public function programSummaryFast($surveillanceId, $year = null, $week = null, $dateFrom = null, $dateTo = null) + { + $query = SurveillanceCase::leftJoin( + 'case_lab_results', + 'surveillance_cases.lab_code', + '=', + 'case_lab_results.lab_code' + ) + ->where('surveillance_cases.surveillance_id', $surveillanceId); + + if ($dateFrom && $dateTo) { + $query->whereBetween('surveillance_cases.case_date', [$dateFrom, $dateTo]); + } else { + $query->where('surveillance_cases.year_data', $year) + ->where('surveillance_cases.week_data', $week); + } + + $row = $query->selectRaw(" + COUNT(DISTINCT surveillance_cases.lab_code) as total_cases, + + COUNT(DISTINCT CASE + WHEN case_lab_results.is_positive = 1 + THEN surveillance_cases.lab_code + END) as overall_positive, + + COUNT(DISTINCT CASE + WHEN case_lab_results.is_positive = 1 + AND ( + LOWER(case_lab_results.pathogen_name) LIKE '%influenza%' + OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%' + ) + THEN surveillance_cases.lab_code + END) as influenza_positive, + + COUNT(DISTINCT CASE + WHEN case_lab_results.is_positive = 1 + AND ( + LOWER(case_lab_results.pathogen_name) LIKE '%covid%' + OR LOWER(case_lab_results.pathogen_name) LIKE '%sars%' + ) + THEN surveillance_cases.lab_code + END) as covid_positive + ")->first(); + + if (!$row || $row->total_cases == 0) { + return [ + 'cases' => 0, + 'overall_rate' => 0, + 'influenza_rate' => 0, + 'covid_rate' => 0 + ]; + } + + return [ + 'cases' => $row->total_cases, + 'overall_rate' => round(($row->overall_positive / $row->total_cases) * 100, 1), + 'influenza_rate' => round(($row->influenza_positive / $row->total_cases) * 100, 1), + 'covid_rate' => round(($row->covid_positive / $row->total_cases) * 100, 1), + ]; } public function programSummary($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) { + $dateTo = now()->toDateString(); + $dateFrom = now()->subDays(7)->toDateString(); - $prevWeek = $endWeek - 1; - $prevYear = $endYear; + $current = $this->programSummaryFast( + $surveillanceId, + null, + null, + $dateFrom, + $dateTo + ); - if ($prevWeek <= 0) { - $prevWeek = 52; - $prevYear--; - } + $prevFrom = date('Y-m-d', strtotime($dateFrom . ' -7 days')); + $prevTo = date('Y-m-d', strtotime($dateFrom . ' -1 day')); - $latest = SurveillanceCase::where('surveillance_id', $surveillanceId) - ->selectRaw("year_data, week_data") - ->orderByDesc('year_data') - ->orderByDesc('week_data') - ->first(); - - $year = $latest->year_data; - $week = $latest->week_data; - $current = $this->programSummaryFast($surveillanceId, $year, $week); - $previous = $this->programSummaryFast($surveillanceId, $prevYear, $prevWeek); - $prevWeek = $week - 1; - $prevYear = $year; - - if ($prevWeek <= 0) { - $prevWeek = 52; - $prevYear--; - } - - $previous = $this->programSummaryFast($surveillanceId, $prevYear, $prevWeek); + $previous = $this->programSummaryFast( + $surveillanceId, + null, + null, + $prevFrom, + $prevTo + ); return [ - 'cases' => [ 'current' => $current['cases'], 'previous' => $previous['cases'] ], - 'hospital_rate' => [ 'current' => 0, 'previous' => 0 ], - 'icu_rate' => [ 'current' => 0, 'previous' => 0 ], - 'positivity_rate' => [ 'current' => $current['overall_rate'], 'previous' => $previous['overall_rate'] ], - 'influenza_rate' => [ 'current' => $current['influenza_rate'], 'previous' => $previous['influenza_rate'] ], - 'covid_rate' => [ 'current' => $current['covid_rate'], 'previous' => $previous['covid_rate'] ], - ]; } - /* |-------------------------------------------------------------------------- | Overview Trend @@ -437,7 +438,7 @@ class DashboardService COUNT(DISTINCT surveillance_cases.lab_code) as total_samples, - -- Overall positivity rate + -- Overall positivity ROUND( COUNT(DISTINCT CASE WHEN case_lab_results.is_positive = 1 @@ -446,33 +447,52 @@ class DashboardService / NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100 ,1) as positivity_rate, - -- Influenza positivity rate + -- Influenza % ROUND( COUNT(DISTINCT CASE WHEN case_lab_results.is_positive = 1 AND ( LOWER(case_lab_results.pathogen_name) LIKE '%influenza%' - OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%' ) THEN surveillance_cases.lab_code END) / NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100 ,1) as influenza_rate, - -- COVID positivity rate + -- COVID % ROUND( COUNT(DISTINCT CASE WHEN case_lab_results.is_positive = 1 AND ( - case_lab_results.pathogen_name = 'Positive' + case_lab_results.pathogen_name = 'COVID-19' OR case_lab_results.pathogen_name = 'SARS-CoV-2' ) - AND case_lab_results.indicator LIKE '%Covid%' + AND case_lab_results.indicator LIKE '%COVID%' THEN surveillance_cases.lab_code END) / NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100 - ,1) as covid_rate - ") + ,1) as covid_rate, + + -- EV % + ROUND( + COUNT(DISTINCT CASE + WHEN case_lab_results.is_positive = 1 + AND LOWER(case_lab_results.pathogen_name) LIKE '%enterovirus%' + THEN surveillance_cases.lab_code + END) + / NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100 + ,1) as ev_rate, + + -- MPOX % + ROUND( + COUNT(DISTINCT CASE + WHEN case_lab_results.is_positive = 1 + AND LOWER(case_lab_results.pathogen_name) LIKE '%mpox%' + THEN surveillance_cases.lab_code + END) + / NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100 + ,1) as mpox_rate + ") ->groupBy( 'surveillance_cases.year_data', @@ -546,7 +566,6 @@ class DashboardService WHEN case_lab_results.is_positive = 1 AND ( LOWER(case_lab_results.pathogen_name) LIKE '%influenza%' - OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%' ) THEN surveillance_cases.lab_code END) as influenza, @@ -559,7 +578,19 @@ class DashboardService ) AND case_lab_results.indicator LIKE '%Covid%' THEN surveillance_cases.lab_code - END) as covid + END) as covid, + + COUNT(DISTINCT CASE + WHEN case_lab_results.is_positive = 1 + AND LOWER(case_lab_results.pathogen_name) LIKE '%enterovirus%' + THEN surveillance_cases.lab_code + END) as ev, + + COUNT(DISTINCT CASE + WHEN case_lab_results.is_positive = 1 + AND LOWER(case_lab_results.pathogen_name) LIKE '%mpox%' + THEN surveillance_cases.lab_code + END) as mpox ") ->groupBy('surveillance_cases.week_data') @@ -643,7 +674,7 @@ class DashboardService return SurveillanceCase::join('case_lab_results', function ($join) { $join->on('surveillance_cases.lab_code', '=', 'case_lab_results.lab_code') ->on('surveillance_cases.surveillance_id', '=', 'case_lab_results.surveillance_id'); - }) + }) ->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) { $q->whereRaw( "(surveillance_cases.year_data * 100 + surveillance_cases.week_data) BETWEEN ? AND ?", @@ -670,17 +701,17 @@ class DashboardService $join->on('surveillance_cases.lab_code', '=', 'case_lab_results.lab_code') ->on('surveillance_cases.surveillance_id', '=', 'case_lab_results.surveillance_id'); }) - ->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) { - $q->whereRaw( - "(surveillance_cases.year_data * 100 + surveillance_cases.week_data) BETWEEN ? AND ?", - [ - $startYear * 100 + $startWeek, - $endYear * 100 + $endWeek - ] - ); - }) - ->whereRaw('case_lab_results.is_positive = 1 and surveillance_cases.surveillance_id not in(6) and case_lab_results.indicator="Covid-19"') - ->selectRaw(" + ->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) { + $q->whereRaw( + "(surveillance_cases.year_data * 100 + surveillance_cases.week_data) BETWEEN ? AND ?", + [ + $startYear * 100 + $startWeek, + $endYear * 100 + $endWeek + ] + ); + }) + ->whereRaw('case_lab_results.is_positive = 1 and surveillance_cases.surveillance_id not in(6) and case_lab_results.indicator="Covid-19"') + ->selectRaw(" CASE WHEN patient_age_inday <= 28 THEN '0–28 days' WHEN patient_age_inday <= 364 THEN '29 days–11 months' @@ -703,9 +734,9 @@ class DashboardService END as age_order, COUNT(*) as total ") - ->groupBy('age_group', 'age_order') - ->orderBy('age_order') - ->get(); + ->groupBy('age_group', 'age_order') + ->orderBy('age_order') + ->get(); } diff --git a/dashboard/public/js/dashboard/charts.js b/dashboard/public/js/dashboard/charts.js index 7051692..9768c66 100644 --- a/dashboard/public/js/dashboard/charts.js +++ b/dashboard/public/js/dashboard/charts.js @@ -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' } } } } diff --git a/dashboard/public/js/dashboard/export.js b/dashboard/public/js/dashboard/export.js index f3d5cb3..765fbeb 100644 --- a/dashboard/public/js/dashboard/export.js +++ b/dashboard/public/js/dashboard/export.js @@ -72,214 +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); - // ===== HEADER ===== - 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(); - // ===== PREPARE ITEMS ===== 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 = ``; + } + } + 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() { diff --git a/dashboard/public/js/overview.js b/dashboard/public/js/overview.js index 88a7db1..cd17f49 100644 --- a/dashboard/public/js/overview.js +++ b/dashboard/public/js/overview.js @@ -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); diff --git a/dashboard/public/js/program.js b/dashboard/public/js/program.js index 633a563..65aba9a 100644 --- a/dashboard/public/js/program.js +++ b/dashboard/public/js/program.js @@ -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', diff --git a/dashboard/resources/views/dashboard/detail.blade.php b/dashboard/resources/views/dashboard/detail.blade.php index 9aba69e..b395903 100644 --- a/dashboard/resources/views/dashboard/detail.blade.php +++ b/dashboard/resources/views/dashboard/detail.blade.php @@ -77,12 +77,14 @@
-
+
Case Trends & Positivity Rate by Epiweek
- +

+ (based on selected epiweek range) +

@@ -94,10 +96,12 @@
-
+
Pathogen Distribution
- +

+ (based on selected epiweek range) +

@@ -112,10 +116,12 @@
-
+
Cases by Province
- +

+ (based on selected epiweek range) +

@@ -123,7 +129,7 @@
-
+
Sentinel Sites & Influenza Subtypes
@@ -132,6 +138,9 @@
Cases by Sentinel Site +

+ (based on selected epiweek range) +

@@ -140,6 +149,9 @@
Influenza Subtypes +

+ (based on selected epiweek range) +

@@ -156,8 +168,11 @@
-
+
Sex Distribution
+

+ (based on selected epiweek range) +

@@ -167,8 +182,11 @@
-
+
Age Distribution
+

+ (based on selected epiweek range) +

diff --git a/dashboard/resources/views/layouts/app.blade.php b/dashboard/resources/views/layouts/app.blade.php index 163044f..e086ead 100644 --- a/dashboard/resources/views/layouts/app.blade.php +++ b/dashboard/resources/views/layouts/app.blade.php @@ -8,7 +8,7 @@ - + @@ -20,7 +20,9 @@ -
-
- National Reference Medical Laboratory Surveillance Dashboard +
+
+ National Reference Medical Laboratory Surveillance Dashboard +
+
+ Last update: 12:05 | 2026-03-15 +
-
- Last update: 12:05 | 2026-03-15 -
-
-