// Generates authentic O-Tie diagram screenshots for the README. // // It reuses the plugin's real layout engine (src/layout.ts) and stylesheet // (styles.css), reproduces the same node/edge DOM the plugin renders, and // captures it with headless Chromium. The output mirrors the plugin's "Export // as image" appearance (no editing chrome). // // Usage: npm run screenshots // Requires Chromium once: npx playwright install chromium import { chromium } from "playwright"; import { mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { loadEngine } from "./engine.mjs"; const scriptsDir = dirname(fileURLToPath(import.meta.url)); const root = join(scriptsDir, ".."); const assetsDir = join(root, "assets"); const stylesCss = readFileSync(join(root, "styles.css"), "utf8"); // Light-theme stand-ins for the Obsidian CSS variables the canvas relies on. const THEME_VARS = ` --background-primary: #ffffff; --background-secondary: #f4f6f8; --background-modifier-border: #d6dae0; --background-modifier-border-hover: #c2c8d0; --background-modifier-hover: #ecedf1; --text-normal: #1a1a1a; --text-muted: #5d6d7e; --text-error: #c0392b; --interactive-accent: #7b6cf6; --radius-s: 4px; --font-ui-smaller: 11px; --font-interface: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; `; const LIGHT_STACK_ROW_COLORS = new Set(["#ffffff", "#f4ecf7", "#eafaf1"]); function isLightStackColor(color) { if (LIGHT_STACK_ROW_COLORS.has(color)) return true; const match = /^#([0-9a-f]{6})$/i.exec(color); if (!match) return false; const n = parseInt(match[1], 16); const r = (n >> 16) & 0xff; const g = (n >> 8) & 0xff; const b = n & 0xff; return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.62; } function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function fieldColor(model, fieldKey, label) { const field = model.BARRIER_STACK_FIELDS.find((f) => f.key === fieldKey); const option = field?.options.find((o) => o.label === label); return option?.color; } function addStack(model, barrier, rows) { barrier.stack = rows.map(([field, label]) => model.createBarrierStackItem(label, field, fieldColor(model, field, label)) ); model.sortBarrierStack?.(barrier.stack); barrier.stackCollapsed = false; } // Representative cold-storage ammonia scenario (matches the docs example). function buildHeroBowtie(model) { const bt = model.createBowtie("Cold storage ammonia — LOPC"); bt.events[0].label = "Loss of primary containment"; bt.events[0].hazard = "Pressurised anhydrous ammonia"; const mi = model.createBarrier("Mechanical integrity inspection"); addStack(model, mi, [ ["type", "Active Hardware"], ["effectiveness", "Good"], ["criticality", "Very High"], ]); const chain = model.createDegradationChain("Deferred inspection"); chain.safeguards = [model.createEscalationNode("QA audit of MI backlog")]; mi.degradationChains = [chain]; const corrosion = model.createThreat("Corrosion / fatigue of piping"); corrosion.preventionBarriers = [mi, model.createBarrier("Material selection standards")]; const impact = model.createThreat("Forklift impact on pipework"); impact.preventionBarriers = [model.createBarrier("Physical protection & traffic segregation")]; const maint = model.createThreat("Maintenance isolation error"); maint.preventionBarriers = [model.createBarrier("Permit-to-work and LOTO")]; bt.threats = [corrosion, impact, maint]; const toxic = model.createConsequence("Toxic exposure to personnel"); const detect = model.createBarrier("Fixed gas detection & alarm"); addStack(model, detect, [ ["type", "Active Hardware+Human"], ["status", "Available"], ]); toxic.mitigationBarriers = [detect, model.createBarrier("Emergency ventilation & isolation")]; const env = model.createConsequence("Environmental release"); env.mitigationBarriers = [model.createBarrier("Containment sump & dilution")]; const biz = model.createConsequence("Business interruption"); biz.mitigationBarriers = [model.createBarrier("Emergency response plan")]; bt.consequences = [toxic, env, biz]; return bt; } // Minimal scenario that showcases a single barrier's analysis stack + escalation. function buildBarrierBowtie(model) { const bt = model.createBowtie("Barrier analysis"); bt.events[0].label = "Loss of containment"; bt.events[0].hazard = "Pressurised ammonia"; const mi = model.createBarrier("Mechanical integrity inspection"); addStack(model, mi, [ ["type", "Active Hardware"], ["effectiveness", "Good"], ["criticality", "Very High"], ["responsible", "Tech Department"], ["validation", "Inspection"], ["status", "Available"], ]); const chain = model.createDegradationChain("Deferred inspection"); chain.safeguards = [model.createEscalationNode("QA audit of MI backlog")]; mi.degradationChains = [chain]; const threat = model.createThreat("Corrosion / fatigue"); threat.preventionBarriers = [mi]; bt.threats = [threat]; bt.consequences = [model.createConsequence("Toxic release")]; return bt; } function renderNodeHtml(node, barriersById) { const wrapStyle = `left:${node.x}px;top:${node.y}px;width:${node.width}px;height:${node.height}px`; const inner = renderNodeInner(node, barriersById); return `
${inner}
`; } function renderNodeInner(node, barriersById) { const subtitle = escapeHtml(node.subtitle); const label = escapeHtml(node.label); if (node.kind === "topEvent") { return ( `
` + `
${label}
` + `
${subtitle}
` ); } const isBarrier = node.kind === "preventionBarrier" || node.kind === "mitigationBarrier" || node.kind === "transitionBarrier"; if (isBarrier) { const barrier = barriersById.get(node.ref.barrierId); const headerH = barrierHeaderHeight(barrier); let html = `
`; html += `
`; html += `
${subtitle}
`; html += `
${label}
`; html += `
`; const stack = barrier?.stack ?? []; if (stack.length > 0 && barrier?.stackCollapsed === false) { html += `
`; for (const item of stack) { const classes = ["o-tie-stack-row"]; if (item.field) classes.push("o-tie-stack-row-preset"); const style = [`height:${ROW_H}px`]; if (item.color) { style.push(`background-color:${item.color}`); if (isLightStackColor(item.color)) classes.push("o-tie-stack-row-light"); } html += `
`; html += `
${escapeHtml(item.label)}
`; } html += `
`; } html += `
`; return html; } return ( `
` + `
${subtitle}
` + `
${label}
` ); } function renderEdgesSvg(layout) { const size = 8; let svg = ``; for (const edge of layout.edges) { svg += ``; } for (const edge of layout.edges) { const { x, y, angleDeg } = edge.arrow; svg += ``; svg += ``; } svg += ``; return svg; } function pageHtml(layout, barriersById) { const nodes = layout.nodes.map((n) => renderNodeHtml(n, barriersById)).join(""); return `
${renderEdgesSvg(layout)}
${nodes}
`; } // Header height calculator captured from the engine at runtime. let barrierHeaderHeight = () => 52; let ROW_H = 24; function indexBarriers(bowtie) { const map = new Map(); const add = (b) => map.set(b.id, b); for (const e of bowtie.events) e.transitionBarriers.forEach(add); for (const t of bowtie.threats) t.preventionBarriers.forEach(add); for (const c of bowtie.consequences) c.mitigationBarriers.forEach(add); return map; } async function capture(page, engine, bowtie, outFile) { const layout = engine.layoutBowtie(bowtie, engine.DEFAULT_LAYOUT); ROW_H = engine.DEFAULT_LAYOUT.barrierStackRowHeight; barrierHeaderHeight = (barrier) => barrier ? engine.barrierHeaderHeightFor(barrier, engine.DEFAULT_LAYOUT) : engine.DEFAULT_LAYOUT.barrierHeaderHeight; const barriersById = indexBarriers(bowtie); await page.setContent(pageHtml(layout, barriersById), { waitUntil: "networkidle" }); const frame = page.locator(".shot-frame"); await frame.screenshot({ path: outFile }); console.log(`Wrote ${outFile}`); } async function main() { mkdirSync(assetsDir, { recursive: true }); const engine = await loadEngine(); const browser = await chromium.launch(); const page = await browser.newPage({ deviceScaleFactor: 2 }); try { await capture(page, engine, buildHeroBowtie(engine.model), join(assetsDir, "screenshot-diagram.png")); await capture(page, engine, buildBarrierBowtie(engine.model), join(assetsDir, "screenshot-barrier.png")); } finally { await browser.close(); } } main().catch((err) => { console.error(err); process.exit(1); });