first commit
This commit is contained in:
25
dashboard/public/.htaccess
Normal file
25
dashboard/public/.htaccess
Normal file
@@ -0,0 +1,25 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Handle X-XSRF-Token Header
|
||||
RewriteCond %{HTTP:x-xsrf-token} .
|
||||
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
0
dashboard/public/favicon.ico
Normal file
0
dashboard/public/favicon.ico
Normal file
99971
dashboard/public/geo/cambodia_provinces.geojson
Normal file
99971
dashboard/public/geo/cambodia_provinces.geojson
Normal file
File diff suppressed because it is too large
Load Diff
BIN
dashboard/public/images/nrml-logo.png
Normal file
BIN
dashboard/public/images/nrml-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
20
dashboard/public/index.php
Normal file
20
dashboard/public/index.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->handleRequest(Request::capture());
|
||||
348
dashboard/public/js/dashboard/charts.js
Normal file
348
dashboard/public/js/dashboard/charts.js
Normal file
@@ -0,0 +1,348 @@
|
||||
Chart.register({
|
||||
id: 'noDataText',
|
||||
afterDraw(chart) {
|
||||
|
||||
const datasets = chart.data.datasets || [];
|
||||
|
||||
const hasData = datasets.some(ds =>
|
||||
(ds.data || []).some(v => Number(v) > 0)
|
||||
);
|
||||
|
||||
chart.$noData = !hasData;
|
||||
|
||||
if (hasData) return;
|
||||
|
||||
const { ctx, width, height } = chart;
|
||||
|
||||
ctx.save();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.fillStyle = '#9ca3af';
|
||||
|
||||
ctx.fillText('No data available', width / 2, height / 2);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
Chart.register({
|
||||
id: 'centerText',
|
||||
afterDraw(chart) {
|
||||
|
||||
if (chart.config.type !== 'doughnut') return;
|
||||
if (chart.$noData) return;
|
||||
|
||||
const { ctx, chartArea } = chart;
|
||||
|
||||
const data = chart.data.datasets[0].data;
|
||||
const total =
|
||||
chart.$totalTested ||
|
||||
chart.$afiTotalCases ||
|
||||
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';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
ctx.font = 'bold 18px sans-serif';
|
||||
ctx.fillStyle = '#111827';
|
||||
ctx.fillText(total, centerX, centerY - 8);
|
||||
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.fillText('Total cases', centerX, centerY + 12);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
Chart.register(ChartDataLabels);
|
||||
Chart.defaults.devicePixelRatio = 2;
|
||||
const charts = {};
|
||||
|
||||
|
||||
function buildStackedChart(canvasId, labels, data) {
|
||||
const ctx = document.getElementById(canvasId);
|
||||
|
||||
if (!ctx) return;
|
||||
|
||||
if (charts[canvasId]) {
|
||||
charts[canvasId].destroy();
|
||||
}
|
||||
|
||||
charts[canvasId] = new Chart(ctx, {
|
||||
|
||||
type: "bar",
|
||||
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: data.map(ds => ({
|
||||
...ds,
|
||||
borderWidth: 1,
|
||||
barPercentage: 0.9,
|
||||
categoryPercentage: 0.8,
|
||||
maxBarThickness: 60
|
||||
}))
|
||||
},
|
||||
|
||||
plugins: [ChartDataLabels],
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
|
||||
layout: {
|
||||
padding: {
|
||||
top: 20,
|
||||
bottom: 30
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
align: 'center',
|
||||
|
||||
labels: {
|
||||
padding: 20,
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
usePointStyle: true,
|
||||
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: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function buildChart(id, type, labels, data) {
|
||||
|
||||
const ctx = document.getElementById(id);
|
||||
if (!ctx) return;
|
||||
|
||||
Chart.getChart(id)?.destroy();
|
||||
|
||||
const hasData = data && data.some(v => Number(v) > 0);
|
||||
|
||||
if (!hasData) {
|
||||
labels = [];
|
||||
data = [];
|
||||
}
|
||||
const isHorizontal = id === 'sexChart';
|
||||
const isAgeChart = id === 'ageChart';
|
||||
const isSentinelChart = id === 'sentinelChart';
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
|
||||
layout: {
|
||||
padding: 30
|
||||
},
|
||||
|
||||
|
||||
indexAxis: isHorizontal ? 'y' : 'x',
|
||||
plugins: {
|
||||
|
||||
legend: {
|
||||
position: isAgeChart || isSentinelChart ? 'left' : 'bottom',
|
||||
align: 'center',
|
||||
display: (ctx) => {
|
||||
const chart = ctx.chart;
|
||||
|
||||
if (!(chart.config.type === 'pie' || chart.config.type === 'doughnut')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !chart.$noData;
|
||||
},
|
||||
labels: {
|
||||
padding: 14,
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
datalabels: {
|
||||
color: "#282626",
|
||||
offset: 6,
|
||||
clip: false,
|
||||
display: (ctx) => {
|
||||
const chart = ctx.chart;
|
||||
if (chart.$noData) return false;
|
||||
if (chart.config.type === 'bar') return true;
|
||||
return !chart.$noData;
|
||||
},
|
||||
anchor: (ctx) => {
|
||||
const type = ctx.chart.config.type;
|
||||
|
||||
if (type === 'doughnut' || type === 'pie') {
|
||||
return 'center';
|
||||
}
|
||||
|
||||
return 'end';
|
||||
},
|
||||
|
||||
align: (ctx) => {
|
||||
const type = ctx.chart.config.type;
|
||||
if (type === 'doughnut' || type === 'pie') {
|
||||
return 'center';
|
||||
}
|
||||
if (type === 'bar') {
|
||||
return ctx.chart.options.indexAxis === 'y' ? 'right' : 'end';
|
||||
}
|
||||
return 'center';
|
||||
},
|
||||
font: {
|
||||
size: 10,
|
||||
weight: '600'
|
||||
},
|
||||
|
||||
formatter: (value, ctx) => {
|
||||
if (ctx.chart.$noData) return '';
|
||||
|
||||
const data = ctx.chart.data.datasets[0].data;
|
||||
const total =
|
||||
ctx.chart.$totalTested ||
|
||||
data.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (!total) return '';
|
||||
|
||||
return ((value / total) * 100).toFixed(1) + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if (type === 'bar') {
|
||||
options.scales = {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: '#f3f4f6'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if (type === 'doughnut') {
|
||||
options.cutout = '70%';
|
||||
options.maintainAspectRatio = false;
|
||||
options.elements = {
|
||||
arc: {
|
||||
borderWidth: (ctx) => ctx.chart.$noData ? 0 : 1
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
charts[id] = new Chart(ctx, {
|
||||
|
||||
type: type,
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: data,
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
barPercentage: 0.8,
|
||||
categoryPercentage: 0.6,
|
||||
maxBarThickness: 50
|
||||
}]
|
||||
},
|
||||
options: options
|
||||
});
|
||||
charts[id].$totalTested = 0;
|
||||
}
|
||||
function buildMixedTrendChart(canvasId, labels, samples, lines) {
|
||||
|
||||
const ctx = document.getElementById(canvasId);
|
||||
if (!ctx) return;
|
||||
|
||||
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: '#007ce8',
|
||||
maxBarThickness: 60,
|
||||
yAxisID: 'y',
|
||||
|
||||
});
|
||||
|
||||
charts[canvasId] = new Chart(ctx, {
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
datalabels: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: { title: { display: true, text: 'Cases' } },
|
||||
y1: {
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
title: { display: true, text: '% Positivity' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
555
dashboard/public/js/dashboard/export.js
Normal file
555
dashboard/public/js/dashboard/export.js
Normal file
@@ -0,0 +1,555 @@
|
||||
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 += `
|
||||
<label style="display:block;margin-bottom:6px;">
|
||||
<input type="checkbox" value="${id}" checked>
|
||||
${formatChartName(id)}
|
||||
</label>
|
||||
`;
|
||||
});
|
||||
list.innerHTML += `
|
||||
<label>
|
||||
<input type="checkbox" value="provinceMap" checked>
|
||||
Geographic Distribution
|
||||
</label>
|
||||
`;
|
||||
|
||||
modal.style.display = "block";
|
||||
}
|
||||
|
||||
function closeChartSelector() {
|
||||
document.getElementById("chartModal").style.display = "none";
|
||||
}
|
||||
|
||||
function formatChartName(id) {
|
||||
const map = {
|
||||
trendChart: "Case Trend",
|
||||
pathogenChart: "Pathogen Distribution",
|
||||
provinceMap: "Geographic Distribution",
|
||||
ageChart: "Age Distribution",
|
||||
sexChart: "Sex Distribution",
|
||||
subtypeChart: "Influenza Subtypes",
|
||||
sentinelChart: "Sentinel Site Distribution",
|
||||
sequencingTotalChart: "Sequencing Total",
|
||||
covidLineageFrequency: "Covid Lineage Frequency",
|
||||
influenzaSubtypeDistribution: "Influenza Subtype Frequency",
|
||||
covidDistributedByAgeGroup: "Covid Cases by Age",
|
||||
influenzaSubtypeFrequency: "Influenza Cases by Age",
|
||||
|
||||
};
|
||||
|
||||
return map[id] || id;
|
||||
}
|
||||
|
||||
async function exportSelectedCharts() {
|
||||
|
||||
const { jsPDF } = window.jspdf;
|
||||
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 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);
|
||||
|
||||
function drawHeader() {
|
||||
pdf.setFillColor(255, 255, 255);
|
||||
pdf.rect(0, 0, pageWidth, 30, "F");
|
||||
|
||||
pdf.setFontSize(18);
|
||||
pdf.setFont(undefined, "bold");
|
||||
pdf.setTextColor(0);
|
||||
pdf.text(getReportTitle(), margin, 14);
|
||||
|
||||
pdf.setFontSize(9);
|
||||
pdf.setTextColor(100);
|
||||
pdf.text(`Generated: ${new Date().toLocaleString()}`, margin, 22);
|
||||
|
||||
pdf.setDrawColor(220);
|
||||
pdf.line(margin, 28, pageWidth - margin, 28);
|
||||
}
|
||||
|
||||
drawHeader();
|
||||
|
||||
const items = [];
|
||||
|
||||
for (const cb of selected) {
|
||||
if (cb.value === "provinceMap") {
|
||||
loadingDiv.innerHTML = "Processing map...";
|
||||
const img = await getMapImage();
|
||||
|
||||
if (img) {
|
||||
items.push({
|
||||
id: "provinceMap",
|
||||
type: "map",
|
||||
img
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const chart = charts[cb.value];
|
||||
if (!chart) continue;
|
||||
|
||||
items.push({
|
||||
id: cb.value,
|
||||
type: "chart",
|
||||
chart
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
loadingDiv.innerHTML = `Rendering ${formatChartName(item.id)} (${i + 1}/${items.length})`;
|
||||
|
||||
if (i !== 0) {
|
||||
pdf.addPage();
|
||||
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 scale = 4;
|
||||
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
|
||||
tempCanvas.width = canvas.width * scale;
|
||||
tempCanvas.height = canvas.height * scale;
|
||||
|
||||
const ctx = tempCanvas.getContext("2d");
|
||||
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
|
||||
img = tempCanvas.toDataURL("image/jpeg", 0.95);
|
||||
|
||||
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.latLngBounds([
|
||||
[10.3, 102.3],
|
||||
[14.7, 107.6]
|
||||
]);
|
||||
|
||||
window.map.fitBounds(bounds);
|
||||
|
||||
}
|
||||
async function exportFullDashboard() {
|
||||
const el = document.querySelector(".content-area");
|
||||
|
||||
const mapEl = document.getElementById("provinceMap");
|
||||
let originalMapHTML = null;
|
||||
|
||||
if (mapEl) {
|
||||
originalMapHTML = mapEl.innerHTML;
|
||||
|
||||
const mapImg = await getMapImage();
|
||||
|
||||
if (mapImg) {
|
||||
mapEl.innerHTML = `<img src="${mapImg}" style="width:100%;height:100%;object-fit:cover;" />`;
|
||||
}
|
||||
}
|
||||
prepareMapForExport();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
const canvas = await html2canvas(el, {
|
||||
scale: 4,
|
||||
useCORS: true,
|
||||
scrollY: -window.scrollY,
|
||||
backgroundColor: "#ffffff"
|
||||
});
|
||||
|
||||
if (mapEl && originalMapHTML !== null) {
|
||||
mapEl.innerHTML = originalMapHTML;
|
||||
window.map.invalidateSize();
|
||||
}
|
||||
|
||||
const img = canvas.toDataURL("image/jpeg", 0.95);
|
||||
|
||||
const { jsPDF } = window.jspdf;
|
||||
const pdf = new jsPDF({
|
||||
orientation: "landscape",
|
||||
unit: "mm",
|
||||
format: "a4",
|
||||
compress: true
|
||||
});
|
||||
|
||||
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, "JPEG", 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 width = original.offsetWidth;
|
||||
const height = original.offsetHeight;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width * 3;
|
||||
canvas.height = height * 3;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.scale(3, 3);
|
||||
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const zoom = window.map.getZoom();
|
||||
const projection = window.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 = window.normalizeProvince(r.patient_province, window.validProvinces);
|
||||
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";
|
||||
}
|
||||
|
||||
window.map.eachLayer(layer => {
|
||||
if (!layer.toGeoJSON) return;
|
||||
if (layer instanceof L.CircleMarker) {
|
||||
|
||||
const latlng = layer.getLatLng();
|
||||
|
||||
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);
|
||||
|
||||
projectedRings.push({
|
||||
type: "circle",
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
radius: layer.getRadius(),
|
||||
fillColor: layer.options.fillColor || "#000",
|
||||
strokeColor: layer.options.color || "#000"
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const geo = layer.toGeoJSON();
|
||||
|
||||
const features = geo.type === "FeatureCollection"
|
||||
? geo.features
|
||||
: [geo];
|
||||
|
||||
features.forEach(f => {
|
||||
if (
|
||||
!f.geometry ||
|
||||
!f.geometry.coordinates
|
||||
) return;
|
||||
|
||||
const coords = f.geometry.coordinates;
|
||||
|
||||
const polygons = f.geometry.type === "MultiPolygon"
|
||||
? coords
|
||||
: [coords];
|
||||
|
||||
polygons.forEach(poly => {
|
||||
|
||||
if (!Array.isArray(poly)) return;
|
||||
|
||||
poly.forEach(ring => {
|
||||
|
||||
if (
|
||||
!Array.isArray(ring) ||
|
||||
!Array.isArray(ring[0])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projected = ring.map(coord => {
|
||||
|
||||
if (!Array.isArray(coord) || coord.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [lng, lat] = coord;
|
||||
|
||||
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];
|
||||
}).filter(Boolean);
|
||||
|
||||
if (!projected.length) return;
|
||||
|
||||
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(item => {
|
||||
|
||||
// -------------------------
|
||||
// Draw circle markers
|
||||
// -------------------------
|
||||
if (item.type === "circle") {
|
||||
|
||||
const drawX = (item.x - minX) * scale + offsetX;
|
||||
const drawY = (item.y - minY) * scale + offsetY;
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
ctx.arc(
|
||||
drawX,
|
||||
drawY,
|
||||
item.radius,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
|
||||
ctx.fillStyle = item.fillColor;
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = item.strokeColor;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Draw polygons
|
||||
// -------------------------
|
||||
const { points, properties } = item;
|
||||
|
||||
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() {
|
||||
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)`;
|
||||
}
|
||||
131
dashboard/public/js/dashboard/filter.js
Normal file
131
dashboard/public/js/dashboard/filter.js
Normal file
@@ -0,0 +1,131 @@
|
||||
class DashboardFilter {
|
||||
|
||||
constructor(onChange) {
|
||||
this.onChange = onChange;
|
||||
|
||||
this.rangeSelect = document.getElementById("trend_range");
|
||||
this.startYear = document.getElementById("start_year");
|
||||
this.startWeek = document.getElementById("start_week");
|
||||
this.endYear = document.getElementById("end_year");
|
||||
this.endWeek = document.getElementById("end_week");
|
||||
this.customContainer = document.getElementById("custom_range_container");
|
||||
|
||||
if (!this.rangeSelect) return;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
this.populateFilters();
|
||||
|
||||
this.rangeSelect.addEventListener("change", () => {
|
||||
|
||||
const val = this.rangeSelect.value;
|
||||
|
||||
if (val === "custom") {
|
||||
this.customContainer.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
this.customContainer.style.display = "none";
|
||||
|
||||
const range = this.lastWeeks(parseInt(val));
|
||||
|
||||
this.apply(range);
|
||||
this.trigger();
|
||||
|
||||
});
|
||||
|
||||
["start_year","start_week","end_year","end_week"].forEach(id => {
|
||||
|
||||
const el = document.getElementById(id);
|
||||
|
||||
if (el) el.addEventListener("change", ()=>this.trigger());
|
||||
|
||||
});
|
||||
|
||||
const defaultRange = this.lastWeeks(8);
|
||||
|
||||
this.apply(defaultRange);
|
||||
this.trigger();
|
||||
}
|
||||
|
||||
trigger() {
|
||||
|
||||
this.onChange(
|
||||
this.startYear.value,
|
||||
this.startWeek.value,
|
||||
this.endYear.value,
|
||||
this.endWeek.value
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
apply(range) {
|
||||
|
||||
this.startYear.value = range.startYear;
|
||||
this.startWeek.value = range.startWeek;
|
||||
this.endYear.value = range.endYear;
|
||||
this.endWeek.value = range.endWeek;
|
||||
|
||||
}
|
||||
|
||||
populateFilters() {
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
|
||||
for (let y = year; y >= year-20; y--) {
|
||||
this.startYear.innerHTML += `<option value="${y}">${y}</option>`;
|
||||
this.endYear.innerHTML += `<option value="${y}">${y}</option>`;
|
||||
|
||||
}
|
||||
|
||||
for (let w = 1; w <= 53; w++) {
|
||||
|
||||
this.startWeek.innerHTML += `<option value="${w}">W${w}</option>`;
|
||||
this.endWeek.innerHTML += `<option value="${w}">W${w}</option>`;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
getISOWeek(date) {
|
||||
|
||||
const d = new Date(Date.UTC(date.getFullYear(),date.getMonth(),date.getDate()));
|
||||
|
||||
d.setUTCDate(d.getUTCDate()+4-(d.getUTCDay()||7));
|
||||
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(),0,1));
|
||||
|
||||
const week = Math.ceil((((d-yearStart)/86400000)+1)/7);
|
||||
|
||||
return {year:d.getUTCFullYear(),week};
|
||||
|
||||
}
|
||||
|
||||
lastWeeks(n) {
|
||||
|
||||
const end = this.getISOWeek(new Date());
|
||||
|
||||
let startWeek = end.week-n+1;
|
||||
let startYear = end.year;
|
||||
|
||||
while(startWeek<=0){
|
||||
|
||||
startWeek += 52;
|
||||
startYear--;
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
startYear,
|
||||
startWeek,
|
||||
endYear:end.year,
|
||||
endWeek:end.week
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
61
dashboard/public/js/globals.js
Normal file
61
dashboard/public/js/globals.js
Normal file
@@ -0,0 +1,61 @@
|
||||
export const COLORS = [
|
||||
|
||||
'#ef4444',
|
||||
'#10b981', // emerald
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // violet
|
||||
'#14b8a6', // teal
|
||||
'#f97316', // orange
|
||||
'#84cc16', // lime
|
||||
'#e11dba', // fuchsia
|
||||
'#f6f63b', // yellow
|
||||
|
||||
'#0ea5e9', // sky
|
||||
'#22c55e', // green
|
||||
'#a855f7', // purple
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
'#65a30d', // olive
|
||||
'#dc2626', // dark red
|
||||
'#1d4ed8', // strong blue
|
||||
'#7c3aed', // deep violet
|
||||
'#059669', // dark emerald
|
||||
'#c2410c', // burnt orange
|
||||
'#be123c', // rose
|
||||
'#4338ca', // indigo
|
||||
'#0f766e', // dark teal
|
||||
'#9333ea', // bright purple
|
||||
'#15803d', // forest green
|
||||
'#ea580c', // deep orange
|
||||
'#0284c7', // ocean blue
|
||||
'#ca8a04', // mustard
|
||||
'#db2777' // magenta
|
||||
];
|
||||
export const SUBTYPE_COLORS = {
|
||||
'A/H1N1pdm': '#f0d401',
|
||||
'A/H3N2': '#00ffff',
|
||||
'A/H9N2': '#2563eb',
|
||||
'A/H5N1': '#dc2626',
|
||||
'A/Unsubtypable': '#f455d7',
|
||||
'B/Yam': '#9333ea',
|
||||
'B/Vic': '#086037',
|
||||
'B/Unsubtypable': '#66ff00',
|
||||
'B/Victoria': '#9333ea',
|
||||
'H1N1pdm': '#f0d401',
|
||||
'H3N2': '#00ffff',
|
||||
'H9N2': '#2563eb',
|
||||
'J.2.4': '#8c6060',
|
||||
'K': '#55f49a',
|
||||
'Influenza': '#b90c00'
|
||||
};
|
||||
|
||||
export const SURVEILLANCE_COLORS = {
|
||||
'LBM': '#f0d401',
|
||||
'ILI': '#2563eb',
|
||||
'SARI': '#dc2626',
|
||||
'NDS': '#9333ea',
|
||||
'AFI': '#086037',
|
||||
'SEQ': '#66ff00'
|
||||
};
|
||||
|
||||
1530
dashboard/public/js/overview.js
Normal file
1530
dashboard/public/js/overview.js
Normal file
File diff suppressed because it is too large
Load Diff
1011
dashboard/public/js/program.js
Normal file
1011
dashboard/public/js/program.js
Normal file
File diff suppressed because it is too large
Load Diff
778
dashboard/public/js/sequencing.js
Normal file
778
dashboard/public/js/sequencing.js
Normal file
@@ -0,0 +1,778 @@
|
||||
import { COLORS, SUBTYPE_COLORS } from "./globals.js";
|
||||
|
||||
let sequencingTotalChart;
|
||||
let covidLineageFrequencyChart;
|
||||
let influenzaSubtypeFrequencyChart;
|
||||
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
|
||||
|
||||
fetch(`/api/dashboard/sequencing?surveillance_id=${window.SURVEILLANCE_ID}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
|
||||
renderSequencingTotalChart(data.trend || []);
|
||||
});
|
||||
|
||||
loadCovidLineageFrequency('week', startYear, startWeek, endYear, endWeek)
|
||||
loadInfluenzaSubtypeFrequency('week', startYear, startWeek, endYear, endWeek)
|
||||
|
||||
const elements = document.querySelectorAll(".report-period");
|
||||
elements.forEach(el => {
|
||||
el.textContent = 'Week ' + startWeek + ' of ' + startYear + ' to ' + 'Week ' + endWeek + ' of ' + endYear
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
function renderSequencingTotalChart(rows) {
|
||||
|
||||
const ctx = document.getElementById('sequencingTotalChart')?.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
if (sequencingTotalChart) sequencingTotalChart.destroy();
|
||||
|
||||
|
||||
const totals = {};
|
||||
|
||||
rows.forEach(r => {
|
||||
totals[r.period] = (totals[r.period] || 0) + Number(r.total);
|
||||
});
|
||||
|
||||
const weeks = Object.keys(totals);
|
||||
const values = Object.values(totals);
|
||||
|
||||
sequencingTotalChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: weeks.map(w => `${w}`),
|
||||
datasets: [{
|
||||
label: 'Total Samples',
|
||||
data: values,
|
||||
backgroundColor: '#0B8F3C'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
datalabels: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
}
|
||||
});
|
||||
charts['sequencingTotalChart'] = sequencingTotalChart;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function hexToRGBA(hex, alpha) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, endWeek) {
|
||||
|
||||
fetch(`/api/dashboard/covid-lineage-frequency?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
|
||||
// const weeks = [...new Set(data.map(item => item.week))].sort();
|
||||
const totalYears = endYear - startYear;
|
||||
const useYearlyView = totalYears >= 5;
|
||||
|
||||
let periods;
|
||||
|
||||
if (useYearlyView) {
|
||||
|
||||
periods = [...new Set(
|
||||
data.map(item => item.week.split('-')[0])
|
||||
)].sort((a, b) => a - b);
|
||||
|
||||
} else {
|
||||
|
||||
periods = [...new Set(
|
||||
data.map(item => item.week)
|
||||
)].sort();
|
||||
|
||||
}
|
||||
const lineages = [...new Set(data.map(item => item.lineage))];
|
||||
|
||||
|
||||
|
||||
const datasets = lineages.map((lineage, index) => {
|
||||
// const lineageData = weeks.map(week => {
|
||||
// const found = data.find(
|
||||
// item => item.week === week && item.lineage === lineage
|
||||
// );
|
||||
// return found ? found.total : 0;
|
||||
// });
|
||||
|
||||
const lineageData = periods.map(period => {
|
||||
|
||||
if (useYearlyView) {
|
||||
|
||||
return data
|
||||
.filter(item =>
|
||||
item.week.split('-')[0] == period &&
|
||||
item.lineage === lineage
|
||||
)
|
||||
.reduce((sum, item) => sum + item.total, 0);
|
||||
|
||||
}
|
||||
|
||||
const found = data.find(
|
||||
item =>
|
||||
item.week === period &&
|
||||
item.lineage === lineage
|
||||
);
|
||||
|
||||
return found ? found.total : 0;
|
||||
|
||||
});
|
||||
|
||||
return {
|
||||
label: lineage,
|
||||
data: lineageData,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
// borderColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
pointRadius: 0,
|
||||
backgroundColor: hexToRGBA(COLORS[index % COLORS.length], 0.3),
|
||||
stack: 'total'
|
||||
};
|
||||
});
|
||||
|
||||
if (covidLineageFrequencyChart) covidLineageFrequencyChart.destroy();
|
||||
|
||||
const ctx = document.getElementById('covidLineageFrequency').getContext('2d');
|
||||
|
||||
covidLineageFrequencyChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: periods,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
datalabels: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Relative Frequency'
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
charts['covidLineageFrequency'] = covidLineageFrequencyChart;
|
||||
|
||||
// -------------------------
|
||||
// Custom right-side scrollable legend
|
||||
// -------------------------
|
||||
const legendContainer = document.getElementById('legendContainer');
|
||||
legendContainer.innerHTML = '';
|
||||
datasets.forEach((dataset, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.style.display = 'flex';
|
||||
item.style.alignItems = 'center';
|
||||
item.style.marginBottom = '4px';
|
||||
item.style.fontSize = '11px';
|
||||
item.style.cursor = 'pointer';
|
||||
item.innerHTML = `
|
||||
<span style="width:15px;height:15px;background:${dataset.backgroundColor};display:inline-block;margin-right:8px;"></span>
|
||||
${dataset.label}
|
||||
`;
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
const meta = covidLineageFrequencyChart.getDatasetMeta(index);
|
||||
|
||||
const allHidden = datasets.every((d, i) => covidLineageFrequencyChart.getDatasetMeta(i).hidden || i === index);
|
||||
if (!allHidden) {
|
||||
datasets.forEach((d, i) => {
|
||||
covidLineageFrequencyChart.getDatasetMeta(i).hidden = true;
|
||||
});
|
||||
meta.hidden = false;
|
||||
} else {
|
||||
datasets.forEach((d, i) => {
|
||||
covidLineageFrequencyChart.getDatasetMeta(i).hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
covidLineageFrequencyChart.update();
|
||||
|
||||
Array.from(legendContainer.children).forEach((child, i) => {
|
||||
const metaItem = covidLineageFrequencyChart.getDatasetMeta(i);
|
||||
child.style.opacity = metaItem.hidden ? 0.5 : 1;
|
||||
});
|
||||
});
|
||||
|
||||
legendContainer.appendChild(item);
|
||||
});
|
||||
|
||||
legendContainer.style.maxHeight = '375px';
|
||||
legendContainer.style.overflowY = 'auto';
|
||||
legendContainer.style.padding = '8px';
|
||||
legendContainer.style.borderRadius = '0px';
|
||||
});
|
||||
}
|
||||
function loadInfluenzaSubtypeFrequency(periodType, startYear, startWeek, endYear, endWeek) {
|
||||
|
||||
fetch(`/api/dashboard/influenza-relative-frequency-sequencing?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
|
||||
const totalYears = endYear - startYear;
|
||||
const useYearlyView = totalYears >= 5;
|
||||
|
||||
let periods;
|
||||
|
||||
if (useYearlyView) {
|
||||
|
||||
periods = [...new Set(
|
||||
data.map(item => item.week.split('-')[0])
|
||||
)].sort((a, b) => a - b);
|
||||
|
||||
} else {
|
||||
|
||||
periods = [...new Set(
|
||||
data.map(item => item.week)
|
||||
)].sort();
|
||||
|
||||
}
|
||||
|
||||
const lineages = [...new Set(
|
||||
data.map(item => item.lineage)
|
||||
)];
|
||||
|
||||
const lineageColors = lineages.map(
|
||||
label => SUBTYPE_COLORS[label] || '#9ca3af'
|
||||
);
|
||||
|
||||
const datasets = lineages.map((lineage, index) => {
|
||||
|
||||
const lineageData = periods.map(period => {
|
||||
|
||||
if (useYearlyView) {
|
||||
|
||||
return data
|
||||
.filter(item =>
|
||||
item.week.split('-')[0] == period &&
|
||||
item.lineage === lineage
|
||||
)
|
||||
.reduce((sum, item) => sum + item.total, 0);
|
||||
|
||||
}
|
||||
|
||||
const found = data.find(
|
||||
item =>
|
||||
item.week === period &&
|
||||
item.lineage === lineage
|
||||
);
|
||||
|
||||
return found ? found.total : 0;
|
||||
|
||||
});
|
||||
|
||||
return {
|
||||
label: lineage,
|
||||
data: lineageData,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
borderWidth: 0,
|
||||
pointRadius: 0,
|
||||
backgroundColor: hexToRGBA(
|
||||
lineageColors[index % lineageColors.length],
|
||||
0.6
|
||||
),
|
||||
stack: 'total'
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
if (influenzaSubtypeFrequencyChart) {
|
||||
influenzaSubtypeFrequencyChart.destroy();
|
||||
}
|
||||
|
||||
const ctx = document
|
||||
.getElementById('influenzaSubtypeFrequency')
|
||||
.getContext('2d');
|
||||
|
||||
influenzaSubtypeFrequencyChart = new Chart(ctx, {
|
||||
|
||||
type: 'line',
|
||||
|
||||
data: {
|
||||
labels: periods,
|
||||
datasets: datasets
|
||||
},
|
||||
|
||||
options: {
|
||||
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
|
||||
plugins: {
|
||||
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
|
||||
datalabels: {
|
||||
display: false
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
scales: {
|
||||
|
||||
x: {
|
||||
|
||||
stacked: true,
|
||||
|
||||
|
||||
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
y: {
|
||||
|
||||
stacked: true,
|
||||
|
||||
beginAtZero: true,
|
||||
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Relative Frequency'
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
charts['influenzaSubtypeFrequency'] =
|
||||
influenzaSubtypeFrequencyChart;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CUSTOM LEGEND
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const legendContainer =
|
||||
document.getElementById(
|
||||
'legendContainerInfluenzaSubtypeFrequency'
|
||||
);
|
||||
|
||||
legendContainer.innerHTML = '';
|
||||
|
||||
datasets.forEach((dataset, index) => {
|
||||
|
||||
const item = document.createElement('div');
|
||||
|
||||
item.style.display = 'flex';
|
||||
item.style.alignItems = 'center';
|
||||
item.style.marginBottom = '4px';
|
||||
item.style.fontSize = '11px';
|
||||
item.style.cursor = 'pointer';
|
||||
|
||||
item.innerHTML = `
|
||||
<span style="
|
||||
width:15px;
|
||||
height:15px;
|
||||
background:${dataset.backgroundColor};
|
||||
display:inline-block;
|
||||
margin-right:8px;
|
||||
"></span>
|
||||
${dataset.label}
|
||||
`;
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
|
||||
const meta =
|
||||
influenzaSubtypeFrequencyChart
|
||||
.getDatasetMeta(index);
|
||||
|
||||
const allHidden = datasets.every(
|
||||
(d, i) =>
|
||||
influenzaSubtypeFrequencyChart
|
||||
.getDatasetMeta(i).hidden ||
|
||||
i === index
|
||||
);
|
||||
|
||||
if (!allHidden) {
|
||||
|
||||
datasets.forEach((d, i) => {
|
||||
|
||||
influenzaSubtypeFrequencyChart
|
||||
.getDatasetMeta(i)
|
||||
.hidden = true;
|
||||
|
||||
});
|
||||
|
||||
meta.hidden = false;
|
||||
|
||||
} else {
|
||||
|
||||
datasets.forEach((d, i) => {
|
||||
|
||||
influenzaSubtypeFrequencyChart
|
||||
.getDatasetMeta(i)
|
||||
.hidden = false;
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
influenzaSubtypeFrequencyChart.update();
|
||||
|
||||
Array.from(legendContainer.children)
|
||||
.forEach((child, i) => {
|
||||
|
||||
const metaItem =
|
||||
influenzaSubtypeFrequencyChart
|
||||
.getDatasetMeta(i);
|
||||
|
||||
child.style.opacity =
|
||||
metaItem.hidden ? 0.5 : 1;
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
legendContainer.appendChild(item);
|
||||
|
||||
});
|
||||
|
||||
legendContainer.style.maxHeight = '375px';
|
||||
legendContainer.style.overflowY = 'auto';
|
||||
legendContainer.style.padding = '8px';
|
||||
legendContainer.style.borderRadius = '0px';
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
// function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, endWeek) {
|
||||
|
||||
// fetch(`/api/dashboard/covid-lineage-frequency?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||||
// .then(res => res.json())
|
||||
// .then(data => {
|
||||
|
||||
// const weeks = [...new Set(data.map(item => item.week))].sort();
|
||||
|
||||
// const lineages = [...new Set(data.map(item => item.lineage))];
|
||||
|
||||
|
||||
|
||||
// const datasets = lineages.map((lineage, index) => {
|
||||
// const lineageData = weeks.map(week => {
|
||||
// const found = data.find(
|
||||
// item => item.week === week && item.lineage === lineage
|
||||
// );
|
||||
// return found ? found.total : 0;
|
||||
// });
|
||||
|
||||
// return {
|
||||
// label: lineage,
|
||||
// data: lineageData,
|
||||
// fill: true,
|
||||
// tension: 0.4,
|
||||
// borderColor: 'transparent',
|
||||
// borderWidth: 0,
|
||||
// pointRadius: 0,
|
||||
// backgroundColor: hexToRGBA(colors[index % colors.length], 0.3),
|
||||
// stack: 'total'
|
||||
// };
|
||||
// });
|
||||
|
||||
// if (covidLineageFrequencyChart) covidLineageFrequencyChart.destroy();
|
||||
|
||||
// const ctx = document.getElementById('covidLineageFrequency').getContext('2d');
|
||||
// if (!ctx) return;
|
||||
// covidLineageFrequencyChart = new Chart(ctx, {
|
||||
// type: 'line',
|
||||
// data: {
|
||||
// labels: weeks,
|
||||
// datasets: datasets
|
||||
// },
|
||||
// options: {
|
||||
// responsive: true,
|
||||
// maintainAspectRatio: false,
|
||||
// interaction: {
|
||||
// mode: 'index',
|
||||
// intersect: false
|
||||
// },
|
||||
// plugins: {
|
||||
// legend: {
|
||||
// display: false // hide default legend
|
||||
// },
|
||||
// tooltip: {
|
||||
// mode: 'index',
|
||||
// intersect: false
|
||||
// },
|
||||
// datalabels: {
|
||||
// display: false // hide labels
|
||||
// }
|
||||
// },
|
||||
// scales: {
|
||||
// x: {
|
||||
// stacked: true,
|
||||
// title: {
|
||||
// display: true,
|
||||
// text: 'Week'
|
||||
// },
|
||||
// grid: {
|
||||
// display: false
|
||||
// }
|
||||
// },
|
||||
// y: {
|
||||
// stacked: true,
|
||||
// beginAtZero: true,
|
||||
// title: {
|
||||
// display: true,
|
||||
// text: 'Relative Frequency'
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// charts['covidLineageFrequencyChart'] = covidLineageFrequencyChart;
|
||||
|
||||
// // -------------------------
|
||||
// // Custom right-side scrollable legend
|
||||
// // -------------------------
|
||||
// const legendContainer = document.getElementById('legendContainer');
|
||||
// legendContainer.innerHTML = '';
|
||||
|
||||
// datasets.forEach((dataset, index) => {
|
||||
// const item = document.createElement('div');
|
||||
// item.style.display = 'flex';
|
||||
// item.style.alignItems = 'center';
|
||||
// item.style.marginBottom = '4px';
|
||||
// item.style.fontSize = '11px';
|
||||
// item.style.cursor = 'pointer';
|
||||
// item.innerHTML = `
|
||||
// <span style="width:15px;height:15px;background:${dataset.backgroundColor};display:inline-block;margin-right:8px;"></span>
|
||||
// ${dataset.label}
|
||||
// `;
|
||||
|
||||
// item.addEventListener('click', () => {
|
||||
// const meta = covidLineageFrequencyChart.getDatasetMeta(index);
|
||||
|
||||
// const allHidden = datasets.every((d, i) => covidLineageFrequencyChart.getDatasetMeta(i).hidden || i === index);
|
||||
// if (!allHidden) {
|
||||
// datasets.forEach((d, i) => {
|
||||
// covidLineageFrequencyChart.getDatasetMeta(i).hidden = true;
|
||||
// });
|
||||
// meta.hidden = false;
|
||||
// } else {
|
||||
// datasets.forEach((d, i) => {
|
||||
// covidLineageFrequencyChart.getDatasetMeta(i).hidden = false;
|
||||
// });
|
||||
// }
|
||||
|
||||
// covidLineageFrequencyChart.update();
|
||||
|
||||
// Array.from(legendContainer.children).forEach((child, i) => {
|
||||
// const metaItem = covidLineageFrequencyChart.getDatasetMeta(i);
|
||||
// child.style.opacity = metaItem.hidden ? 0.5 : 1;
|
||||
// });
|
||||
// });
|
||||
|
||||
// legendContainer.appendChild(item);
|
||||
// });
|
||||
|
||||
// legendContainer.style.maxHeight = '375px';
|
||||
// legendContainer.style.overflowY = 'auto';
|
||||
// legendContainer.style.padding = '8px';
|
||||
// legendContainer.style.borderRadius = '0px';
|
||||
// });
|
||||
// }
|
||||
|
||||
// function loadInfluenzaSubtypeFrequency(periodType, startYear, startWeek, endYear, endWeek) {
|
||||
|
||||
// fetch(`/api/dashboard/influenza-relative-frequency-sequencing?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||||
// .then(res => res.json())
|
||||
// .then(data => {
|
||||
|
||||
// const weeks = [...new Set(data.map(item => item.week))].sort();
|
||||
|
||||
|
||||
|
||||
// const lineages = [...new Set(data.map(item => item.lineage))];
|
||||
// const datasets = lineages.map((lineage, index) => {
|
||||
// const lineageData = weeks.map(week => {
|
||||
// const found = data.find(
|
||||
// item => item.week === week && item.lineage === lineage
|
||||
// );
|
||||
// return found ? found.total : 0;
|
||||
// });
|
||||
|
||||
// return {
|
||||
// label: lineage,
|
||||
// data: lineageData,
|
||||
// fill: true,
|
||||
// tension: 0.4,
|
||||
// borderColor: 'transparent',
|
||||
// borderWidth: 0,
|
||||
// pointRadius: 0,
|
||||
// backgroundColor: hexToRGBA(colors[(index * 2) % colors.length], 0.8),
|
||||
// stack: 'total'
|
||||
// };
|
||||
// });
|
||||
|
||||
// if (influenzaSubtypeFrequencyChart) influenzaSubtypeFrequencyChart.destroy();
|
||||
|
||||
// const ctx = document.getElementById('influenzaSubtypeFrequency').getContext('2d');
|
||||
// if (!ctx) return;
|
||||
// influenzaSubtypeFrequencyChart = new Chart(ctx, {
|
||||
// type: 'line',
|
||||
// data: {
|
||||
// labels: weeks,
|
||||
// datasets: datasets
|
||||
// },
|
||||
// options: {
|
||||
// responsive: true,
|
||||
// maintainAspectRatio: false,
|
||||
// interaction: {
|
||||
// mode: 'index',
|
||||
// intersect: false
|
||||
// },
|
||||
// plugins: {
|
||||
// legend: {
|
||||
// display: false
|
||||
// },
|
||||
// tooltip: {
|
||||
// mode: 'index',
|
||||
// intersect: false
|
||||
// },
|
||||
// datalabels: {
|
||||
// display: false
|
||||
// }
|
||||
// },
|
||||
// scales: {
|
||||
// x: {
|
||||
// stacked: true,
|
||||
// title: {
|
||||
// display: true,
|
||||
// text: 'Week'
|
||||
// },
|
||||
// grid: {
|
||||
// display: false
|
||||
// }
|
||||
// },
|
||||
// y: {
|
||||
// stacked: true,
|
||||
// beginAtZero: true,
|
||||
// title: {
|
||||
// display: true,
|
||||
// text: 'Relative Frequency'
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// charts['influenzaSubtypeFrequencyChart'] = influenzaSubtypeFrequencyChart;
|
||||
|
||||
// const legendContainer = document.getElementById('legendContainerInfluenzaSubtypeFrequency');
|
||||
// legendContainer.innerHTML = '';
|
||||
|
||||
// datasets.forEach((dataset, index) => {
|
||||
// const item = document.createElement('div');
|
||||
// item.style.display = 'flex';
|
||||
// item.style.alignItems = 'center';
|
||||
// item.style.marginBottom = '4px';
|
||||
// item.style.fontSize = '11px';
|
||||
// item.style.cursor = 'pointer';
|
||||
// item.innerHTML = `
|
||||
// <span style="width:15px;height:15px;background:${dataset.backgroundColor};display:inline-block;margin-right:8px;"></span>
|
||||
// ${dataset.label}
|
||||
// `;
|
||||
|
||||
// item.addEventListener('click', () => {
|
||||
// const meta = influenzaSubtypeFrequencyChart.getDatasetMeta(index);
|
||||
|
||||
// const allHidden = datasets.every((d, i) => influenzaSubtypeFrequencyChart.getDatasetMeta(i).hidden || i === index);
|
||||
// if (!allHidden) {
|
||||
// datasets.forEach((d, i) => {
|
||||
// influenzaSubtypeFrequencyChart.getDatasetMeta(i).hidden = true;
|
||||
// });
|
||||
// meta.hidden = false;
|
||||
// } else {
|
||||
// datasets.forEach((d, i) => {
|
||||
// influenzaSubtypeFrequencyChart.getDatasetMeta(i).hidden = false;
|
||||
// });
|
||||
// }
|
||||
|
||||
// influenzaSubtypeFrequencyChart.update();
|
||||
|
||||
// Array.from(legendContainer.children).forEach((child, i) => {
|
||||
// const metaItem = influenzaSubtypeFrequencyChart.getDatasetMeta(i);
|
||||
// child.style.opacity = metaItem.hidden ? 0.5 : 1;
|
||||
// });
|
||||
// });
|
||||
|
||||
// legendContainer.appendChild(item);
|
||||
// });
|
||||
|
||||
// legendContainer.style.maxHeight = '375px';
|
||||
// legendContainer.style.overflowY = 'auto';
|
||||
// legendContainer.style.padding = '8px';
|
||||
// legendContainer.style.borderRadius = '0px';
|
||||
// });
|
||||
// }
|
||||
2
dashboard/public/robots.txt
Normal file
2
dashboard/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Reference in New Issue
Block a user