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 ed85032..760e7fc 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 fbae928..765fbeb 100644
--- a/dashboard/public/js/dashboard/export.js
+++ b/dashboard/public/js/dashboard/export.js
@@ -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 = ``;
+ }
+ }
+ 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 @@
+ (based on selected epiweek range) +
+ (based on selected epiweek range) +
+ (based on selected epiweek range) +
+ (based on selected epiweek range) +
+ (based on selected epiweek range) +
+ (based on selected epiweek range) +
+ (based on selected epiweek range) +