| |
| |
| |
| |
|
|
| 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"; |
|
|
| |
| const COLORS = { |
| accent: [38, 70, 83], |
| accentLight: [51, 101, 138], |
| riskLow: [42, 157, 143], |
| riskModerate: [233, 196, 106], |
| riskHigh: [231, 111, 81], |
| riskCritical: [143, 29, 20], |
| text: [27, 42, 47], |
| muted: [88, 101, 107], |
| border: [216, 221, 212], |
| }; |
|
|
| 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", |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
|
|
| |
| 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); |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| |
| |
| 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; |
|
|
| |
| |
| |
| y = addSectionHeader("EXECUTIVE SUMMARY", y); |
|
|
| const decision = result.decision || {}; |
| const riskClass = decision.growth_risk_class || "unknown"; |
| const riskColor = getRiskColor(riskClass); |
|
|
| |
| 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; |
|
|
| |
| 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; |
|
|
| |
| |
| |
| 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`], |
| ]; |
|
|
| |
| 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; |
|
|
| |
| |
| |
| 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; |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| if (y > pageHeight - 80) { |
| doc.addPage(); |
| y = margin; |
| } |
|
|
| |
| |
| |
| const drivers = result.sensitivity_analysis?.drivers || []; |
| if (drivers.length > 0) { |
| y = addSectionHeader("KEY RISK FACTORS", y); |
|
|
| |
| 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; |
|
|
| |
| 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; |
|
|
| |
| doc.setTextColor(...COLORS.text); |
| doc.text(formatFeature(driver.feature), margin + 2, y); |
|
|
| |
| const probColor = probImpact >= 0 ? COLORS.riskHigh : COLORS.riskLow; |
| doc.setTextColor(...probColor); |
| doc.text(`${probImpact >= 0 ? "+" : ""}${probImpact.toFixed(1)}%`, margin + 55, y); |
|
|
| |
| const logColor = logImpact >= 0 ? COLORS.riskHigh : COLORS.riskLow; |
| doc.setTextColor(...logColor); |
| doc.text(`${logImpact >= 0 ? "+" : ""}${logImpact.toFixed(2)} log CFU/g`, margin + 95, y); |
|
|
| |
| const shelfColor = shelfImpact <= 0 ? COLORS.riskHigh : COLORS.riskLow; |
| doc.setTextColor(...shelfColor); |
| doc.text(`${shelfImpact > 0 ? "+" : ""}${shelfImpact} days`, margin + 135, y); |
|
|
| y += 5; |
| }); |
|
|
| y += 4; |
| } |
|
|
| |
| if (y > pageHeight - 60) { |
| doc.addPage(); |
| y = margin; |
| } |
|
|
| |
| |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| 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); |
|
|
| |
| doc.save(generateFilename()); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function exportReport(result, scenario, chartElement) { |
| await exportReportAsPDF(result, scenario, chartElement); |
| } |
|
|