// 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; } // Two-event ("chained") bowtie: a loss of containment that can escalate to an // ignited release. Threats feed the first top event, between-event barriers sit // between the two top events, and consequences flow from the second top event. function buildMultiEventBowtie(model) { const bt = model.createBowtie("Flammable gas release escalating to fire"); bt.events[0].label = "Loss of containment of flammable gas"; bt.events[0].hazard = "Pressurised flammable hydrocarbon"; const ignited = model.createTopEvent( "Ignited release — jet fire / explosion", "Ignition energy & confinement" ); bt.events.push(ignited); const gasDetect = model.createBarrier("Gas detection & emergency shutdown (ESD)"); addStack(model, gasDetect, [ ["type", "Active Hardware+Human"], ["effectiveness", "Good"], ["criticality", "Very High"], ]); const ignitionControl = model.createBarrier("Ignition source control / hazardous area"); addStack(model, ignitionControl, [ ["type", "Passive Hardware"], ["status", "Available"], ]); bt.events[0].transitionBarriers = [gasDetect, ignitionControl]; const corrosion = model.createThreat("Corrosion / erosion of pipework"); corrosion.preventionBarriers = [ model.createBarrier("Mechanical integrity inspection"), model.createBarrier("Corrosion monitoring"), ]; const overpressure = model.createThreat("Process overpressure"); overpressure.preventionBarriers = [model.createBarrier("Pressure relief & high-pressure trip")]; const leak = model.createThreat("Flange / seal leak"); leak.preventionBarriers = [model.createBarrier("Flange management & leak testing")]; bt.threats = [corrosion, overpressure, leak]; const injury = model.createConsequence("Personnel burns / fatality"); const fireProt = model.createBarrier("Fireproofing & deluge protection"); addStack(model, fireProt, [ ["type", "Active Hardware"], ["status", "Available"], ]); injury.mitigationBarriers = [fireProt, model.createBarrier("Emergency response & evacuation")]; const escalation = model.createConsequence("Escalation to adjacent units"); escalation.mitigationBarriers = [model.createBarrier("Plant spacing & blast walls")]; bt.consequences = [injury, escalation]; 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 `