555 lines
15 KiB
JavaScript
555 lines
15 KiB
JavaScript
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)`;
|
||
} |