Iapetus / frontend /src /utils /exportReport.js
EphAsad's picture
Upload 9972 files
01fdca8 verified
Raw
History Blame Contribute Delete
12.2 kB
/**
* PDF Export utilities for Iapetus reports.
* Uses jsPDF for document generation and html2canvas for chart capture.
*/
import { jsPDF } from "jspdf";
import html2canvas from "html2canvas";
import {
formatCategory,
formatPackaging,
formatOxygen,
formatPreservative,
formatAcidulant,
formatRiskClass,
formatConfidence,
formatFeature,
formatDirection,
} from "./formatters";
const APP_VERSION = "0.2.0";
// Color definitions matching the app theme
const COLORS = {
accent: [38, 70, 83], // #264653
accentLight: [51, 101, 138], // #33658a
riskLow: [42, 157, 143], // #2a9d8f
riskModerate: [233, 196, 106], // #e9c46a
riskHigh: [231, 111, 81], // #e76f51
riskCritical: [143, 29, 20], // #8f1d14
text: [27, 42, 47], // #1b2a2f
muted: [88, 101, 107], // #58656b
border: [216, 221, 212], // #d8ddd4
};
function getRiskColor(riskClass) {
const normalized = String(riskClass).toLowerCase();
if (normalized === "low") return COLORS.riskLow;
if (normalized === "moderate") return COLORS.riskModerate;
if (normalized === "high") return COLORS.riskHigh;
if (normalized === "critical") return COLORS.riskCritical;
return COLORS.muted;
}
function generateFilename() {
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
return `iapetus_report_${timestamp}.pdf`;
}
function formatTimestamp() {
return new Date().toLocaleString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Generate a PDF report from simulation results.
* @param {object} result - The simulation result from the API
* @param {object} scenario - The input scenario parameters
* @param {HTMLElement} chartElement - The chart DOM element to capture
*/
export async function exportReportAsPDF(result, scenario, chartElement) {
const doc = new jsPDF({
orientation: "portrait",
unit: "mm",
format: "a4",
});
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 15;
const contentWidth = pageWidth - (margin * 2);
let y = margin;
// Helper function for adding text
function addText(text, x, currentY, options = {}) {
const {
fontSize = 10,
fontStyle = "normal",
color = COLORS.text,
maxWidth = contentWidth,
} = options;
doc.setFontSize(fontSize);
doc.setFont("helvetica", fontStyle);
doc.setTextColor(...color);
const lines = doc.splitTextToSize(text, maxWidth);
doc.text(lines, x, currentY);
return currentY + (lines.length * fontSize * 0.4);
}
// Helper for section headers
function addSectionHeader(title, currentY) {
doc.setFillColor(...COLORS.accent);
doc.rect(margin, currentY - 4, contentWidth, 8, "F");
doc.setFontSize(11);
doc.setFont("helvetica", "bold");
doc.setTextColor(255, 255, 255);
doc.text(title, margin + 3, currentY + 1);
return currentY + 10;
}
// Helper for drawing a metric box
function addMetricBox(label, value, x, currentY, width, color = COLORS.accent) {
doc.setDrawColor(...COLORS.border);
doc.setLineWidth(0.3);
doc.rect(x, currentY, width, 18);
doc.setFontSize(8);
doc.setFont("helvetica", "normal");
doc.setTextColor(...COLORS.muted);
doc.text(label, x + 3, currentY + 5);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.setTextColor(...color);
doc.text(String(value), x + 3, currentY + 13);
}
// =====================
// HEADER
// =====================
doc.setFillColor(...COLORS.accent);
doc.rect(0, 0, pageWidth, 28, "F");
doc.setFontSize(18);
doc.setFont("helvetica", "bold");
doc.setTextColor(255, 255, 255);
doc.text("IAPETUS", margin, 12);
doc.setFontSize(11);
doc.setFont("helvetica", "normal");
doc.text("Shelf-Life Risk Assessment Report", margin, 19);
doc.setFontSize(8);
doc.text(`Generated: ${formatTimestamp()} | v${APP_VERSION}`, margin, 25);
y = 36;
// =====================
// EXECUTIVE SUMMARY
// =====================
y = addSectionHeader("EXECUTIVE SUMMARY", y);
const decision = result.decision || {};
const riskClass = decision.growth_risk_class || "unknown";
const riskColor = getRiskColor(riskClass);
// Metric boxes
const boxWidth = (contentWidth - 6) / 3;
addMetricBox("Risk Level", formatRiskClass(riskClass), margin, y, boxWidth, riskColor);
addMetricBox(
"Threshold Probability",
`${((decision.threshold_exceedance_probability || 0) * 100).toFixed(1)}%`,
margin + boxWidth + 3,
y,
boxWidth
);
addMetricBox(
"Recommended Shelf Life",
`${decision.recommended_max_shelf_life_days || "N/A"} days`,
margin + (boxWidth * 2) + 6,
y,
boxWidth
);
y += 24;
// Additional metrics row
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.setTextColor(...COLORS.text);
const challengeText = decision.challenge_test_recommended ? "Yes" : "No";
const confidenceText = formatConfidence(decision.confidence_label || "unknown");
doc.text(`Challenge Test Recommended: ${challengeText} | Confidence: ${confidenceText}`, margin, y);
y += 8;
// =====================
// SCENARIO PARAMETERS
// =====================
y = addSectionHeader("SCENARIO PARAMETERS", y);
const params = [
["Product Category", formatCategory(scenario.product_category)],
["Pathogen", scenario.pathogen || "Listeria monocytogenes"],
["Storage Temperature", `${scenario.storage_temperature_c}°C`],
["Target Shelf Life", `${scenario.target_shelf_life_days} days`],
["pH", scenario.ph],
["Water Activity (aw)", scenario.aw],
["Salt", `${scenario.salt_percent}%`],
["Sugar", `${scenario.sugar_percent}%`],
["Fat", `${scenario.fat_percent}%`],
["Preservative", scenario.preservative_flag ? formatPreservative(scenario.preservative_type) : "None"],
["Acidulant", formatAcidulant(scenario.acidulant_type)],
["Packaging", formatPackaging(scenario.packaging_type)],
["Oxygen Condition", formatOxygen(scenario.oxygen_condition)],
["Initial Inoculum", `${scenario.initial_inoculum_log_cfu_g} log CFU/g`],
];
// Two-column layout for parameters
const colWidth = contentWidth / 2;
const rowHeight = 5;
doc.setFontSize(8);
params.forEach((param, index) => {
const col = index % 2;
const row = Math.floor(index / 2);
const x = margin + (col * colWidth);
const rowY = y + (row * rowHeight);
doc.setFont("helvetica", "normal");
doc.setTextColor(...COLORS.muted);
doc.text(`${param[0]}:`, x, rowY);
doc.setFont("helvetica", "bold");
doc.setTextColor(...COLORS.text);
doc.text(String(param[1]), x + 40, rowY);
});
y += Math.ceil(params.length / 2) * rowHeight + 6;
// =====================
// GROWTH CHART
// =====================
if (chartElement) {
y = addSectionHeader("GROWTH PREDICTION", y);
try {
const canvas = await html2canvas(chartElement, {
scale: 2,
backgroundColor: "#ffffff",
logging: false,
});
const imgData = canvas.toDataURL("image/png");
const imgWidth = contentWidth;
const imgHeight = (canvas.height / canvas.width) * imgWidth;
// Check if we need a new page
if (y + imgHeight > pageHeight - margin) {
doc.addPage();
y = margin;
y = addSectionHeader("GROWTH PREDICTION (continued)", y);
}
doc.addImage(imgData, "PNG", margin, y, imgWidth, imgHeight);
y += imgHeight + 6;
} catch (error) {
console.error("Failed to capture chart:", error);
y = addText("Chart capture failed. See web application for visualization.", margin, y);
y += 6;
}
}
// Check page space for risk drivers
if (y > pageHeight - 80) {
doc.addPage();
y = margin;
}
// =====================
// KEY RISK FACTORS
// =====================
const drivers = result.sensitivity_analysis?.drivers || [];
if (drivers.length > 0) {
y = addSectionHeader("KEY RISK FACTORS", y);
// Table header
doc.setFillColor(245, 245, 245);
doc.rect(margin, y, contentWidth, 6, "F");
doc.setFontSize(8);
doc.setFont("helvetica", "bold");
doc.setTextColor(...COLORS.text);
doc.text("Factor", margin + 2, y + 4);
doc.text("Probability Impact", margin + 55, y + 4);
doc.text("Growth Change", margin + 95, y + 4);
doc.text("Shelf-Life Impact", margin + 135, y + 4);
y += 8;
// Table rows (top 6 drivers)
const topDrivers = drivers.slice(0, 6);
doc.setFontSize(8);
doc.setFont("helvetica", "normal");
topDrivers.forEach((driver) => {
const probImpact = (driver.impact_on_exceedance_probability * 100);
const logImpact = driver.impact_on_final_log_cfu_g;
const shelfImpact = driver.impact_on_recommended_shelf_life_days;
// Factor name with direction
doc.setTextColor(...COLORS.text);
doc.text(formatFeature(driver.feature), margin + 2, y);
// Probability impact (color-coded)
const probColor = probImpact >= 0 ? COLORS.riskHigh : COLORS.riskLow;
doc.setTextColor(...probColor);
doc.text(`${probImpact >= 0 ? "+" : ""}${probImpact.toFixed(1)}%`, margin + 55, y);
// Growth change (color-coded)
const logColor = logImpact >= 0 ? COLORS.riskHigh : COLORS.riskLow;
doc.setTextColor(...logColor);
doc.text(`${logImpact >= 0 ? "+" : ""}${logImpact.toFixed(2)} log CFU/g`, margin + 95, y);
// Shelf-life impact (color-coded)
const shelfColor = shelfImpact <= 0 ? COLORS.riskHigh : COLORS.riskLow;
doc.setTextColor(...shelfColor);
doc.text(`${shelfImpact > 0 ? "+" : ""}${shelfImpact} days`, margin + 135, y);
y += 5;
});
y += 4;
}
// Check page space for summary
if (y > pageHeight - 60) {
doc.addPage();
y = margin;
}
// =====================
// AI ANALYSIS
// =====================
if (result.summary) {
y = addSectionHeader("AI ANALYSIS", y);
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.setTextColor(...COLORS.text);
const summaryLines = doc.splitTextToSize(result.summary, contentWidth - 4);
doc.text(summaryLines, margin + 2, y);
y += summaryLines.length * 4;
if (result.summary_provider) {
doc.setFontSize(7);
doc.setTextColor(...COLORS.muted);
doc.text(`Source: ${result.summary_provider}`, margin + 2, y + 2);
y += 6;
}
}
// =====================
// DISCLAIMER
// =====================
// Ensure disclaimer fits on page
if (y > pageHeight - 30) {
doc.addPage();
y = margin;
}
y += 4;
doc.setDrawColor(...COLORS.border);
doc.setLineWidth(0.5);
doc.line(margin, y, margin + contentWidth, y);
y += 6;
doc.setFillColor(255, 250, 240);
doc.rect(margin, y, contentWidth, 20, "F");
doc.setDrawColor(...COLORS.riskModerate);
doc.setLineWidth(0.3);
doc.rect(margin, y, contentWidth, 20, "S");
doc.setFontSize(7);
doc.setFont("helvetica", "bold");
doc.setTextColor(...COLORS.riskHigh);
doc.text("IMPORTANT DISCLAIMER", margin + 3, y + 4);
doc.setFont("helvetica", "normal");
doc.setTextColor(...COLORS.text);
const disclaimer = "This report is generated by an exploratory simulation tool trained on synthetic data. It is not a regulatory-validated decision-making tool and should not be used as a substitute for formal challenge testing or shelf-life validation. Always consult with qualified food safety professionals.";
const disclaimerLines = doc.splitTextToSize(disclaimer, contentWidth - 6);
doc.text(disclaimerLines, margin + 3, y + 9);
// Save the PDF
doc.save(generateFilename());
}
/**
* Export the simulation report as PDF (main entry point).
* @param {object} result - The simulation result from the API
* @param {object} scenario - The input scenario parameters
* @param {HTMLElement} chartElement - The chart DOM element to capture
*/
export async function exportReport(result, scenario, chartElement) {
await exportReportAsPDF(result, scenario, chartElement);
}