---
title: "Sizing Calculator"
id: "123293"
type: "page"
slug: "sizing-calculator"
published_at: "2026-04-29T20:19:59+00:00"
modified_at: "2026-04-29T21:07:32+00:00"
url: "https://stellarcyber.ai/sizing-calculator/"
markdown_url: "https://stellarcyber.ai/sizing-calculator.md"
excerpt: "Stellar Cyber’s Ingestion and Deployment Calculators Use the Stellar Cyber Ingestion Calculator to estimate projected entity or volume usage. Use the Stellar Cyber Deployment Calculator to generate virtual/hardware sizing estimates for on-prem deployments. Try Now Stellar Cyber | Open XDR..."
---

## Stellar Cyber's **Ingestion and Deployment Calculators**

## Use the Stellar Cyber Ingestion Calculator to estimate projected entity or volume usage. Use the Stellar Cyber Deployment Calculator to generate virtual/hardware sizing estimates for on-prem deployments.

[Try Now](#registerNow)

Stellar Cyber | Open XDR Sizing CalculatorSelect Calculator Mode

01 / USERS Users to Entity  02 / NETWORK Network Traffic  03 / EPS EPS to Volume  04 / ADVANCED Advanced Volume

  Users to Entity

Converts your user count into entity licensing estimates and daily ingestion volume. Three scenarios are modelled — conservative, typical, and maximum.

Number of Users

Events per User / Day// DEFAULT: 1000 — leave unchanged if unknown

 Calculate →

// OUTPUT

Awaiting input...

**Note —** Estimated accuracy ~80%. A full implementation is required for precise scoping.

  Network Traffic to Volume

Estimates daily SIEM ingestion volume from your network bandwidth and utilisation rate. Adjust utilisation for peak vs. average traffic.

Bandwidth (Gbps)

Network Utilisation — 100%// Set to average utilisation, not peak

 Calculate →

// OUTPUT

Awaiting input...

**Note —** Estimated accuracy ~80%. A full implementation is required for precise scoping.

  EPS to Volume

Converts Events Per Second (EPS) and average log message size into daily and monthly storage volume in GB.

Events Per Second (EPS)

Average Log Size (Bytes) — 525// Low ≈ 425B · Typical ≈ 525B · High ≈ 650B

 Calculate →

// OUTPUT

Awaiting input...

**Note —** Estimated accuracy ~80%. A full implementation is required for precise scoping.

  Advanced Volume Calculator

Enter the count of each data source in your environment. For AWS/GCP/Azure enter instance count; for EDR enter machine count; for AV enter the number of AV agents running.

 Calculate Total Volume →

// OUTPUT

Awaiting input...

**Note —** Estimated accuracy ~80%. A full implementation is required for precise scoping.

`; items.forEach((item) => { html += `${item.label}

`; }); html += `

`; } container.innerHTML = html; } // ══════════════════════════════════════════════════════════════ // EVIDENCE HTML BUILDER // ══════════════════════════════════════════════════════════════ function buildEvidenceHTML(evidence) { if (!evidence) return ''; const chevron = ''; let body = ''; if (evidence.type === 'table') { // Advanced entity breakdown table let rows = ''; evidence.rows.forEach((r) => { rows += `${r.label}${r.count}${r.lowRate}${r.medRate}${r.highRate}${r.lowTotal}${r.medTotal}${r.highTotal}`; }); rows += `TOTAL${evidence.totalLow}${evidence.totalMed}${evidence.totalHigh}`; body = `| Entity | Count | Low Rate | Med Rate | High Rate | Low Total | Med Total | High Total |
| --- | --- | --- | --- | --- | --- | --- | --- |

`; } else { // Step-by-step formula breakdown let steps = ''; evidence.steps.forEach((s) => { steps += `${s.label}${s.formula}

`; }); body = steps; } return `Calculation Evidence ${chevron}

${body}

`; } // ══════════════════════════════════════════════════════════════ // SHOW RESULTS HELPER // ══════════════════════════════════════════════════════════════ function showResults(prefix, htmlContent, emailData) { document.getElementById('empty-' + prefix).style.display = 'none'; const outEl = document.getElementById('output-' + prefix); outEl.innerHTML = htmlContent; outEl.style.display = 'block'; document.getElementById('disc-' + prefix).style.display = 'block'; const pdfEl = document.getElementById('pdf-' + prefix); pdfEl.innerHTML = buildPdfSection(prefix, emailData); pdfEl.style.display = 'block'; lastResults[prefix] = emailData; } function buildPdfSection(prefix, data) { return ` // Export Report

Customer / Organisation Name

     Download PDF Report  `; } // ══════════════════════════════════════════════════════════════ // CALC 1: USERS // ══════════════════════════════════════════════════════════════ function calcUsers() { const users = parseFloat(document.getElementById('user_count').value) || 0; const events = parseFloat(document.getElementById('events_per_user').value) || 0; if (!users) { showToast('⚠ Please enter a user count.'); return; } const totalEvents = users * events; const impliedEPS = totalEvents / CONFIG.time.secondsPerDay; const lowEnt = Math.ceil(users * CONFIG.userMultipliers.low); const medEnt = Math.ceil(users * CONFIG.userMultipliers.med); const highEnt = Math.ceil(users * CONFIG.userMultipliers.high); const evidence = [ { label: 'Total Events / Day', formula: `${users.toLocaleString()} users × ${events.toLocaleString()} events = ${totalEvents.toLocaleString()}`, }, { label: 'Implied EPS', formula: `${totalEvents.toLocaleString()} ÷ 86,400 sec = ${impliedEPS.toFixed(2)}`, }, { label: 'Low Entities', formula: `${users.toLocaleString()} × 2.2 = ${lowEnt.toLocaleString()}`, }, { label: 'Med Entities', formula: `${users.toLocaleString()} × 2.5 = ${medEnt.toLocaleString()}`, }, { label: 'High Entities', formula: `${users.toLocaleString()} × 2.8 = ${highEnt.toLocaleString()}`, }, ]; const html = ` Conservative (Low)

${lowEnt.toLocaleString()} Entities

Users × 2.2 multiplier

Typical (Medium)

${medEnt.toLocaleString()} Entities

Users × 2.5 multiplier

Maximum (High)

${highEnt.toLocaleString()} Entities

Users × 2.8 multiplier

User Count${users.toLocaleString()}

Total Events / Day${totalEvents.toLocaleString()}

Implied EPS${impliedEPS.toFixed(2)}

` + buildEvidenceHTML({ type: 'steps', steps: evidence }); const pdfData = { title: 'Users to Entity Sizing', inputs: `Users: ${users}\nEvents/User/Day: ${events}`, results: `LOW: ${lowEnt.toLocaleString()} entities\nMED: ${medEnt.toLocaleString()} entities\nHIGH: ${highEnt.toLocaleString()} entities\nUser Count: ${users.toLocaleString()}\nTotal Events/Day: ${totalEvents.toLocaleString()}\nImplied EPS: ${impliedEPS.toFixed(2)}`, evidence: { type: 'steps', steps: evidence }, }; showResults('users', html, pdfData); } // ══════════════════════════════════════════════════════════════ // CALC 2: NETWORK // ══════════════════════════════════════════════════════════════ function calcNetwork() { const gbps = parseFloat(document.getElementById('bandwidth_input').value) || 0; const util = parseFloat(document.getElementById('util_input').value) / 100; if (!gbps) { showToast('⚠ Please enter a bandwidth value.'); return; } const gbPerDay = (gbps / CONFIG.conversion.bitsPerByte) * util * CONFIG.conversion.networkDayFactor; const gbPerYear = gbPerDay * CONFIG.time.daysPerYear; const gbPerMonth = gbPerDay * CONFIG.time.daysPerMonth; const evidence = [ { label: 'Gbps → GB/s', formula: `${gbps} Gbps ÷ 8 = ${(gbps / 8).toFixed(4)} GB/s`, }, { label: 'Apply utilisation', formula: `${(gbps / 8).toFixed(4)} × ${(util * 100).toFixed(0)}% = ${((gbps / 8) * util).toFixed(4)} GB/s`, }, { label: 'GB / Day', formula: `${((gbps / 8) * util).toFixed(4)} × 86,400 sec ÷ 1,024 = ${gbPerDay.toFixed(2)} GB`, }, { label: 'GB / Month', formula: `${gbPerDay.toFixed(2)} × 30 = ${gbPerMonth.toFixed(2)} GB`, }, { label: 'GB / Year', formula: `${gbPerDay.toFixed(2)} × 365 = ${gbPerYear.toFixed(2)} GB`, }, { label: 'TB / Year', formula: `${gbPerYear.toFixed(2)} ÷ 1,024 = ${(gbPerYear / CONFIG.conversion.tbDivisorIG.conversion.tbDivisor).toFixed(2)} TB`, }, ]; const html = ` ${gbPerDay.toFixed(2)}

GB / Day

Monthly Volume${gbPerMonth.toFixed(2)} GB

Annual Volume${gbPerYear.toFixed(2)} GB

Annual Volume (TB)${(gbPerYear / CONFIG.conversion.tbDivisorIG.conversion.tbDivisor).toFixed(2)} TB

Bandwidth${gbps} Gbps

Utilisation${(util * 100).toFixed(0)}%

` + buildEvidenceHTML({ type: 'steps', steps: evidence }); const emailData = { title: 'Network Traffic Sizing', inputs: `Bandwidth: ${gbps} Gbps\nUtilisation: ${(util * 100).toFixed(0)}%`, results: `Daily: ${gbPerDay.toFixed(2)} GB\nMonthly: ${gbPerMonth.toFixed(2)} GB\nAnnual: ${gbPerYear.toFixed(2)} GB (${(gbPerYear / CONFIG.conversion.tbDivisorIG.conversion.tbDivisor).toFixed(2)} TB)`, evidence: { type: 'steps', steps: evidence }, }; showResults('network', html, emailData); } // ══════════════════════════════════════════════════════════════ // CALC 3: EPS // ══════════════════════════════════════════════════════════════ function calcEPS() { const eps = parseFloat(document.getElementById('eps_input').value) || 0; const size = parseFloat(document.getElementById('msg_size_input').value) || 0; if (!eps) { showToast('⚠ Please enter an EPS value.'); return; } const gbPerDay = (eps * size * CONFIG.time.secondsPerDay) / CONFIG.conversion.bytesToGB; const gbPerMonth = gbPerDay * CONFIG.time.daysPerMonth; const gbPerYear = gbPerDay * CONFIG.time.daysPerYear; const evidence = [ { label: 'Events / Day', formula: `${eps.toLocaleString()} EPS × 86,400 sec = ${(eps * CONFIG.time.secondsPerDay).toLocaleString()}`, }, { label: 'Bytes / Day', formula: `${(eps * CONFIG.time.secondsPerDay).toLocaleString()} × ${size} bytes = ${(eps * CONFIG.time.secondsPerDay * size).toLocaleString()} bytes`, }, { label: 'GB / Day', formula: `${(eps * CONFIG.time.secondsPerDay * size).toLocaleString()} ÷ 1,073,741,824 = ${gbPerDay.toFixed(2)} GB`, }, { label: 'GB / Month', formula: `${gbPerDay.toFixed(2)} × 30 = ${gbPerMonth.toFixed(2)} GB`, }, { label: 'GB / Year', formula: `${gbPerDay.toFixed(2)} × 365 = ${gbPerYear.toFixed(2)} GB`, }, ]; const html = ` ${gbPerDay.toFixed(2)}

GB / Day

Monthly Volume${gbPerMonth.toFixed(2)} GB

Annual Volume${gbPerYear.toFixed(2)} GB

Events Per Second${eps.toLocaleString()}

Avg Log Size${size} bytes

Events / Day${(eps * CONFIG.time.secondsPerDay).toLocaleString()}

` + buildEvidenceHTML({ type: 'steps', steps: evidence }); const emailData = { title: 'EPS to Volume Sizing', inputs: `EPS: ${eps}\nAvg Log Size: ${size} bytes`, results: `Daily: ${gbPerDay.toFixed(2)} GB\nMonthly: ${gbPerMonth.toFixed(2)} GB\nAnnual: ${gbPerYear.toFixed(2)} GB`, evidence: { type: 'steps', steps: evidence }, }; showResults('eps', html, emailData); } // ══════════════════════════════════════════════════════════════ // CALC 4: ADVANCED ENTITIES // ══════════════════════════════════════════════════════════════ function calcEntities() { let totalLow = 0, totalMed = 0, totalHigh = 0; let inputLines = []; let hasInput = false; let evidenceRows = []; CONFIG.entities.forEach((item) => { const val = parseFloat(document.getElementById('ent_' + item.id).value) || 0; if (val > 0) { hasInput = true; totalLow += val * item.low; totalMed += val * item.med; totalHigh += val * item.high; inputLines.push(`${item.label}: ${val}`); evidenceRows.push({ label: item.label, count: val, lowRate: item.low.toFixed(4), medRate: item.med.toFixed(4), highRate: item.high.toFixed(4), lowTotal: (val * item.low).toFixed(4), medTotal: (val * item.med).toFixed(4), highTotal: (val * item.high).toFixed(4), }); } }); if (!hasInput) { showToast('⚠ Please enter at least one entity count.'); return; } const html = ` Low Estimate

${totalLow.toFixed(2)} GB/Day

Medium Estimate

${totalMed.toFixed(2)} GB/Day

High Estimate

${totalHigh.toFixed(2)} GB/Day

Monthly Low${(totalLow * CONFIG.time.daysPerMonth).toFixed(2)} GB

Monthly Med${(totalMed * CONFIG.time.daysPerMonth).toFixed(2)} GB

Monthly High${(totalHigh * CONFIG.time.daysPerMonth).toFixed(2)} GB

` + buildEvidenceHTML({ type: 'table', rows: evidenceRows, totalLow: totalLow.toFixed(4), totalMed: totalMed.toFixed(4), totalHigh: totalHigh.toFixed(4), }); const emailData = { title: 'Advanced Volume Sizing', inputs: inputLines.join('\n'), results: `LOW: ${totalLow.toFixed(2)} GB/Day\nMED: ${totalMed.toFixed(2)} GB/Day\nHIGH: ${totalHigh.toFixed(2)} GB/Day\n\nMonthly LOW: ${(totalLow * CONFIG.time.daysPerMonth).toFixed(2)} GB\nMonthly MED: ${(totalMed * CONFIG.time.daysPerMonth).toFixed(2)} GB\nMonthly HIGH: ${(totalHigh * CONFIG.time.daysPerMonth).toFixed(2)} GB`, evidence: { type: 'table', rows: evidenceRows, totalLow: totalLow.toFixed(4), totalMed: totalMed.toFixed(4), totalHigh: totalHigh.toFixed(4), }, }; showResults('advanced', html, emailData); } // ══════════════════════════════════════════════════════════════ // PDF REPORT GENERATOR // ══════════════════════════════════════════════════════════════ function generatePDF(prefix) { const data = lastResults[prefix]; if (!data) return; const cname = ( document.getElementById('cname_pdf_' + prefix)?.value || '' ).trim() || 'Valued Customer'; const { jsPDF } = window.jspdf; const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4', }); const pageW = 210; const pageH = 297; const margin = 15; const cW = pageW - margin * 2; const footerH = 12; const bottomLimit = pageH - footerH - 6; // last safe y before footer let y = 0; let pageNum = 1; // ── COLOUR PALETTE ──────────────────────────────────────────── const C = { navy: [20, 44, 110], blue: [32, 89, 224], blueDim: [240, 244, 255], orange: [241, 101, 43], amber: [248, 168, 62], amberBg: [255, 249, 238], green: [0, 185, 130], white: [255, 255, 255], offWhite: [232, 239, 255], mid: [122, 159, 212], dark: [16, 29, 69], muted: [58, 90, 156], altRow: [245, 247, 255], }; // ── FOOTER (drawn on every page) ────────────────────────────── function drawFooter() { doc.setFillColor(...C.navy); doc.rect(0, pageH - footerH, pageW, footerH, 'F'); doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); doc.setTextColor(...C.mid); doc.text( 'Stellar Cyber · Open XDR Sizing Calculator · stellarcyber.ai', margin, pageH - 4.5, ); doc.setTextColor(...C.white); doc.text( 'Page ' + pageNum + ' CONFIDENTIAL', pageW - margin, pageH - 4.5, { align: 'right' }, ); } // ── PAGE BREAK HELPER ───────────────────────────────────────── // Call before drawing any block needing `needed` mm of vertical space. function checkPage(needed) { if (y + needed > bottomLimit) { drawFooter(); doc.addPage(); // doc.addImage(pdf_background_src, 'JPEG', 0, 0, pageW, pageH); pageNum++; // Slim continuation header doc.setFillColor(...C.navy); doc.rect(0, 0, pageW, 14, 'F'); doc.setFillColor(...C.blue); doc.triangle(pageW - 30, 0, pageW, 0, pageW, 14, 'F'); doc.setFillColor(...C.orange); doc.triangle(pageW - 15, 0, pageW, 0, pageW, 14, 'F'); doc.setFont('helvetica', 'bold'); doc.setFontSize(7); doc.setTextColor(...C.white); doc.text( 'STELLAR CYBER · ' + data.title.toUpperCase(), margin, 9, ); y = 22; } } // ── SECTION HEADER ──────────────────────────────────────────── function sectionHeader(title) { checkPage(20); doc.setFillColor(...C.blueDim); doc.rect(margin, y, cW, 7.5, 'F'); doc.setFillColor(...C.blue); doc.rect(margin, y, 3, 7.5, 'F'); doc.setFont('helvetica', 'bold'); doc.setFontSize(7.5); doc.setTextColor(...C.blue); doc.text(title, margin + 7, y + 5.2); y += 13; } // ── PAGE 1 HEADER BAND ──────────────────────────────────────── doc.setFillColor(...C.navy); doc.rect(0, 0, pageW, 38, 'F'); doc.setFillColor(...C.blue); doc.triangle(pageW - 55, 0, pageW, 0, pageW, 38, 'F'); doc.setFillColor(...C.orange); doc.triangle(pageW - 28, 0, pageW, 0, pageW, 28, 'F'); try { const logoEl = document.querySelector('.logo-area img'); // const logoEl = [...document.querySelectorAll('.logo-area img')].filter(el => el.offsetParent !== null).pop() if (logoEl && logoEl.src) { doc.addImage(logoEl.src, 'PNG', margin, 5, 60, 20); } } catch (e) { console.error('Error loading logo for PDF:', e); } doc.setFont('helvetica', 'bold'); doc.setFontSize(8); doc.setTextColor(...C.white); doc.text('OPEN XDR SIZING CALCULATOR', pageW - margin, 16, { align: 'right', }); doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(...C.mid); doc.text('stellarcyber.ai', pageW - margin, 23, { align: 'right' }); y = 46; // ── TITLE BLOCK ─────────────────────────────────────────────── doc.setFillColor(...C.blue); doc.rect(margin, y, 3, 18, 'F'); doc.setFont('helvetica', 'bold'); doc.setFontSize(17); doc.setTextColor(...C.dark); doc.text(data.title, margin + 7, y + 9); doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(...C.muted); doc.text('Prepared for: ' + cname, margin + 7, y + 15); const dateStr = new Date().toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', }); const timeStr = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', }); doc.setFontSize(7); doc.setTextColor(...C.muted); doc.text(dateStr + ' ' + timeStr, pageW - margin, y + 9, { align: 'right', }); doc.setFontSize(6.5); doc.text('DATE GENERATED', pageW - margin, y + 14, { align: 'right' }); y += 25; doc.setDrawColor(...C.blue); doc.setLineWidth(0.4); doc.line(margin, y, pageW - margin, y); y += 10; // ── INPUTS ──────────────────────────────────────────────────── sectionHeader('INPUT PARAMETERS'); const inputs = data.inputs.split('\n').filter((l) => l.trim()); const colW = (cW - 6) / 2; // Pre-check: entire inputs block at once (pairs of rows × 11mm) const inputBlockH = Math.ceil(inputs.length / 2) * 11; checkPage(inputBlockH + 4); const inputBaseY = y; inputs.forEach((line, i) => { const col = i % 2; const row = Math.floor(i / 2); const xPos = margin + col * (colW + 6); const yPos = inputBaseY + row * 11; if (i % 2 === 0) { doc.setFillColor(...C.altRow); doc.rect(margin, yPos - 1, cW, 10, 'F'); } const ci = line.indexOf(':'); const lbl = ci > -1 ? line.slice(0, ci).trim() : line; const val = ci > -1 ? line.slice(ci + 1).trim() : ''; doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); doc.setTextColor(...C.muted); doc.text(lbl.toUpperCase(), xPos + 3, yPos + 3.5); doc.setFont('helvetica', 'bold'); doc.setFontSize(9); doc.setTextColor(...C.dark); doc.text(val || lbl, xPos + 3, yPos + 8.5); }); y = inputBaseY + inputBlockH + 6; checkPage(4); doc.setDrawColor(...C.blueDim); doc.setLineWidth(0.3); doc.line(margin, y, pageW - margin, y); y += 10; // ── RESULTS ─────────────────────────────────────────────────── sectionHeader('RESULTS'); const results = data.results.split('\n'); const isScenario = results.some((r) => /^(LOW|MED|HIGH)\b/i.test(r)); if (isScenario) { const scenarios = [ { key: 'LOW', label: 'CONSERVATIVE', sub: 'LOW ESTIMATE', clr: C.green, }, { key: 'MED', label: 'TYPICAL', sub: 'MEDIUM ESTIMATE', clr: C.blue, }, { key: 'HIGH', label: 'MAXIMUM', sub: 'HIGH ESTIMATE', clr: C.amber, }, ]; const boxW = (cW - 8) / 3; const boxH = 32; // Scenario cards — keep all three on the same page checkPage(boxH + 8); scenarios.forEach((sc, i) => { const bx = margin + i * (boxW + 4); const line = results.find((r) => new RegExp('^' + sc.key + '\\b', 'i').test(r), ) || ''; const gbM = line.match(/([\d,.]+)\s*GB\/Day/i); const entM = line.match(/([\d,]+)\s*entities/i); const val = gbM ? gbM[1] + ' GB/Day' : entM ? entM[1] + ' Entities' : line.replace(/^[^:]+:\s*/i, '').trim(); doc.setFillColor(...C.dark); doc.rect(bx, y, boxW, boxH, 'F'); doc.setFillColor(...sc.clr); doc.rect(bx, y, boxW, 2.5, 'F'); doc.setFont('helvetica', 'bold'); doc.setFontSize(6.5); doc.setTextColor(...sc.clr); doc.text(sc.label, bx + boxW / 2, y + 9, { align: 'center' }); doc.setFont('helvetica', 'normal'); doc.setFontSize(5.5); doc.setTextColor(...C.mid); doc.text(sc.sub, bx + boxW / 2, y + 13.5, { align: 'center' }); doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.setTextColor(...C.offWhite); doc.text(val, bx + boxW / 2, y + 22, { align: 'center' }); }); y += boxH + 8; // Extra stat rows — check page before each one let rowIdx = 0; results .filter((r) => !/^(LOW|MED|HIGH)\b/i.test(r) && r.trim()) .forEach((line) => { const ci = line.indexOf(':'); if (ci < 0) return; const lbl = line.slice(0, ci).trim(); const val = line.slice(ci + 1).trim(); if (!val) return; checkPage(7); doc.setFillColor(...(rowIdx % 2 === 0 ? C.altRow : C.white)); doc.rect(margin, y, cW, 6.5, 'F'); doc.setFont('helvetica', 'normal'); doc.setFontSize(7.5); doc.setTextColor(...C.muted); doc.text(lbl.toUpperCase(), margin + 4, y + 4.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...C.dark); doc.text(val, pageW - margin - 4, y + 4.5, { align: 'right' }); y += 6.5; rowIdx++; }); y += 4; } else { // Hero single-result block checkPage(34); const mainLine = results.find((r) => /^Daily:/i.test(r)) || results[0] || ''; const ci = mainLine.indexOf(':'); const mainLbl = ci > -1 ? mainLine.slice(0, ci).trim() : 'Result'; const mainVal = ci > -1 ? mainLine.slice(ci + 1).trim() : mainLine; doc.setFillColor(...C.blue); doc.rect(margin, y, cW, 26, 'F'); doc.setFillColor(...C.orange); doc.rect(pageW - margin - 4, y, 4, 26, 'F'); doc.rect(pageW - margin - 7, y, 3, 26, 'F'); doc.setFont('helvetica', 'bold'); doc.setFontSize(28); doc.setTextColor(...C.white); doc.text(mainVal, pageW / 2, y + 15, { align: 'center' }); doc.setFont('helvetica', 'normal'); doc.setFontSize(7.5); doc.setTextColor(180, 210, 255); doc.text(mainLbl.toUpperCase(), pageW / 2, y + 22, { align: 'center', }); y += 32; let rowIdx = 0; results .filter((r, idx) => idx > 0 && r.trim()) .forEach((line) => { const ci = line.indexOf(':'); if (ci < 0) return; const lbl = line.slice(0, ci).trim(); const val = line.slice(ci + 1).trim(); if (!val) return; checkPage(7); doc.setFillColor(...(rowIdx % 2 === 0 ? C.altRow : C.white)); doc.rect(margin, y, cW, 6.5, 'F'); doc.setFont('helvetica', 'normal'); doc.setFontSize(7.5); doc.setTextColor(...C.muted); doc.text(lbl.toUpperCase(), margin + 4, y + 4.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...C.dark); doc.text(val, pageW - margin - 4, y + 4.5, { align: 'right' }); y += 6.5; rowIdx++; }); y += 4; } // ── CALCULATION EVIDENCE ────────────────────────────────────── if (data.evidence) { sectionHeader('CALCULATION EVIDENCE'); if (data.evidence.type === 'table') { // Table header const cols = [ 'ENTITY', 'QTY', 'LOW RATE', 'MED RATE', 'HIGH RATE', 'LOW TOTAL', 'MED TOTAL', 'HIGH TOTAL', ]; const colWidths = [ cW * 0.22, cW * 0.08, cW * 0.1, cW * 0.1, cW * 0.1, cW * 0.13, cW * 0.13, cW * 0.14, ]; const colAligns = [ 'left', 'center', 'right', 'right', 'right', 'right', 'right', 'right', ]; const colColors = [ C.muted, C.muted, C.green, C.blue, C.amber, C.green, C.blue, C.amber, ]; checkPage(10); // Header row let xOff = margin; doc.setFillColor(...C.dark); doc.rect(margin, y, cW, 7, 'F'); cols.forEach((col, ci) => { doc.setFont('helvetica', 'bold'); doc.setFontSize(5.5); doc.setTextColor(...colColors[ci]); const tx = colAligns[ci] === 'left' ? xOff + 2 : colAligns[ci] === 'center' ? xOff + colWidths[ci] / 2 : xOff + colWidths[ci] - 2; doc.text(col, tx, y + 4.8, { align: colAligns[ci] === 'left' ? 'left' : colAligns[ci] === 'center' ? 'center' : 'right', }); xOff += colWidths[ci]; }); y += 7; // Data rows data.evidence.rows.forEach((row, ri) => { checkPage(6); doc.setFillColor(...(ri % 2 === 0 ? C.altRow : C.white)); doc.rect(margin, y, cW, 5.5, 'F'); const vals = [ row.label, String(row.count), row.lowRate, row.medRate, row.highRate, row.lowTotal, row.medTotal, row.highTotal, ]; let xr = margin; vals.forEach((v, ci) => { doc.setFont('helvetica', ci === 0 ? 'normal' : 'normal'); doc.setFontSize(6); doc.setTextColor(...(ci === 0 ? C.muted : C.dark)); const tx = colAligns[ci] === 'left' ? xr + 2 : colAligns[ci] === 'center' ? xr + colWidths[ci] / 2 : xr + colWidths[ci] - 2; const displayVal = ci === 0 && v.length > 22 ? v.substring(0, 20) + '..' : v; doc.text(displayVal, tx, y + 3.8, { align: colAligns[ci] === 'left' ? 'left' : colAligns[ci] === 'center' ? 'center' : 'right', }); xr += colWidths[ci]; }); y += 5.5; }); // Totals row checkPage(7); doc.setFillColor(...C.dark); doc.rect(margin, y, cW, 6.5, 'F'); const totVals = [ 'TOTAL', '', '', '', '', data.evidence.totalLow, data.evidence.totalMed, data.evidence.totalHigh, ]; let xt = margin; totVals.forEach((v, ci) => { if (!v) { xt += colWidths[ci]; return; } doc.setFont('helvetica', 'bold'); doc.setFontSize(6.5); doc.setTextColor(...(ci === 0 ? C.offWhite : colColors[ci])); const tx = colAligns[ci] === 'left' ? xt + 2 : xt + colWidths[ci] - 2; doc.text(v, tx, y + 4.5, { align: colAligns[ci] === 'left' ? 'left' : 'right', }); xt += colWidths[ci]; }); y += 6.5; } else { // Step-by-step formula evidence data.evidence.steps.forEach((step, si) => { checkPage(7); doc.setFillColor(...(si % 2 === 0 ? C.altRow : C.white)); doc.rect(margin, y, cW, 6.5, 'F'); doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(...C.muted); doc.text(step.label.toUpperCase(), margin + 4, y + 4.5); doc.setFont('helvetica', 'bold'); doc.setFontSize(7); doc.setTextColor(...C.dark); doc.text(step.formula, pageW - margin - 4, y + 4.5, { align: 'right', }); y += 6.5; }); } y += 4; } // ── DISCLAIMER ──────────────────────────────────────────────── checkPage(28); y += 8; doc.setFillColor(...C.amberBg); doc.rect(margin, y, cW, 17, 'F'); doc.setFillColor(...C.amber); doc.rect(margin, y, 3, 17, 'F'); doc.setFont('helvetica', 'bold'); doc.setFontSize(7); doc.setTextColor(170, 100, 0); doc.text('ACCURACY DISCLAIMER', margin + 7, y + 5.5); doc.setFont('helvetica', 'normal'); doc.setFontSize(7.5); doc.setTextColor(100, 65, 10); const disc = 'This calculation is estimated to be ~80% accurate. For precise scoping, a full implementation ' + 'utilizing all available data sources is required.'; doc.text(doc.splitTextToSize(disc, cW - 12), margin + 7, y + 10.5); y += 17; // ── FOOTER ON FINAL PAGE ────────────────────────────────────── drawFooter(); // ── SAVE ────────────────────────────────────────────────────── const safeTitle = data.title.replace(/[^a-z0-9]/gi, '_'); const safeName = cname.replace(/[^a-z0-9]/gi, '_'); doc.save('StellarCyber_' + safeTitle + '_' + safeName + '.pdf'); showToast('✓ PDF report downloaded'); } // ══════════════════════════════════════════════════════════════ // TOAST // ══════════════════════════════════════════════════════════════ function showToast(msg) { const t = document.getElementById('toast'); t.textContent = msg; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 3000); } // ══════════════════════════════════════════════════════════════ // THEME TOGGLE // ══════════════════════════════════════════════════════════════ function toggleTheme() { const body = document.body; const current = body.getAttribute('data-theme'); const next = current === 'dark' ? 'light' : 'dark'; body.setAttribute('data-theme', next); try { localStorage.setItem('stellar-theme', next); } catch (e) {} } // Restore saved theme on load (function () { try { const savedTheme = localStorage.getItem('stellar-theme'); if (savedTheme === 'light' || savedTheme === 'dark') { document.body.setAttribute('data-theme', savedTheme); } } catch (e) {} })();
