UNPKG

js-fault-tree-analyzer

Version:

A JavaScript library for parsing JSON fault tree descriptions and rendering them as interactive SVG graphics with customizable themes

1,030 lines (880 loc) 29.5 kB
/** * JavaScript Fault Tree Analyser * * A JavaScript implementation for parsing JSON fault tree descriptions * and rendering them as SVG graphics. */ // Color theme definitions const COLOR_THEMES = { light: { name: "Light Theme", // background: "#ffffff", borderWidth: "1.3", text: "#000000", labelBox: { normal: "#fdfbec", degraded: "#ffeb3b", critical: "#ff6969", }, labelBoxBorder: { normal: "#cccccc", degraded: "#e2be0a", critical: "#c62828", }, quantityBox: { normal: "#fdfbec", degraded: "#fff3c4", critical: "#ffcdd2", }, quantityBoxBorder: { normal: "#cccccc", degraded: "#e2be0a", critical: "#c62828", }, symbols: { normal: "#fdfbec", degraded: "#ffeb3b", critical: "#f44336", }, symbolsBorder: { normal: "#cccccc", degraded: "#e2be0a", critical: "#c62828", }, connections: "#cccccc", }, dark: { name: "Dark Theme", background: "#1a1a1a", borderWidth: "1.5", text: "#ffffff", labelBox: { normal: "#2d3748", degraded: "#d69e2e", critical: "#e53e3e", }, labelBoxBorder: { normal: "#cbd5e0", degraded: "#f6ad55", critical: "#fc8181", }, quantityBox: { normal: "#1a202c", degraded: "#744210", critical: "#742a2a", }, quantityBoxBorder: { normal: "#cbd5e0", degraded: "#f6ad55", critical: "#fc8181", }, symbols: { normal: "#4a5568", degraded: "#d69e2e", critical: "#e53e3e", }, symbolsBorder: { normal: "#cbd5e0", degraded: "#f6ad55", critical: "#fc8181", }, connections: "#cbd5e0", }, highContrast: { name: "High Contrast", background: "#ffffff", borderWidth: "2.0", text: "#000000", labelBox: { normal: "#ffffff", degraded: "#ffff00", critical: "#ff0000", }, labelBoxBorder: { normal: "#000000", degraded: "#000000", critical: "#000000", }, quantityBox: { normal: "#f5f5f5", degraded: "#ffffcc", critical: "#ffcccc", }, quantityBoxBorder: { normal: "#000000", degraded: "#000000", critical: "#000000", }, symbols: { normal: "#ffffff", degraded: "#ffff00", critical: "#ff0000", }, symbolsBorder: { normal: "#000000", degraded: "#000000", critical: "#000000", }, connections: "#000000", }, ocean: { name: "Ocean Theme", // background: "#f0f8ff", borderWidth: "1.4", text: "#1e3a8a", labelBox: { normal: "#dbeafe", degraded: "#fce8b7", critical: "#ffe2e2", }, labelBoxBorder: { normal: "#b1c0f3", degraded: "#f8bd78", critical: "#f2a8a8", }, quantityBox: { normal: "#eff6ff", degraded: "#fce8b7", critical: "#fee2e2", }, quantityBoxBorder: { normal: "#b1c0f3", degraded: "#f8bd78", critical: "#f2a8a8", }, symbols: { normal: "#bfdbfe", degraded: "#fddc87", critical: "#ffa0a0", }, symbolsBorder: { normal: "#b1c0f3", degraded: "#f5b56b", critical: "#ec7e7e", }, connections: "#b1c0f3", }, }; // Constants for SVG rendering const SVG_CONSTANTS = { PAGE_MARGIN: 10, DEFAULT_FONT_SIZE: 10, DEFAULT_LINE_SPACING: 1.3, TIME_HEADER_MARGIN: 20, TIME_HEADER_Y_OFFSET: -25, TIME_HEADER_FONT_SIZE: 16, EVENT_BOUNDING_WIDTH: 120, EVENT_BOUNDING_HEIGHT: 195, LABEL_BOX_Y_OFFSET: -65, LABEL_BOX_WIDTH: 108, LABEL_BOX_HEIGHT: 70, LABEL_BOX_TARGET_RATIO: 5.4, LABEL_MIN_LINE_LENGTH: 16, IDENTIFIER_BOX_Y_OFFSET: -13, IDENTIFIER_BOX_WIDTH: 108, IDENTIFIER_BOX_HEIGHT: 24, SYMBOL_Y_OFFSET: 20, // Compact spacing SYMBOL_SLOTS_HALF_WIDTH: 30, // OR Gate dimensions // OR Gate dimensions (reduced by ~25%) OR_GATE_APEX_HEIGHT: 28, OR_GATE_NECK_HEIGHT: -8, OR_GATE_BODY_HEIGHT: 27, OR_GATE_SLANT_DROP: 1.5, OR_GATE_SLANT_RUN: 4.5, OR_GATE_SLING_RISE: 26, OR_GATE_GROIN_RISE: 22, OR_GATE_HALF_WIDTH: 25, // AND Gate dimensions (reduced by ~25%) AND_GATE_NECK_HEIGHT: 8, AND_GATE_BODY_HEIGHT: 25, AND_GATE_SLING_RISE: 31, AND_GATE_HALF_WIDTH: 24, // Event dimensions (reduced by ~25%) BASIC_EVENT_RADIUS: 28, UNDEVELOPED_EVENT_HALF_HEIGHT: 28, UNDEVELOPED_EVENT_HALF_WIDTH: 40, HOUSE_EVENT_APEX_HEIGHT: 28, HOUSE_EVENT_SHOULDER_HEIGHT: 18, HOUSE_EVENT_BODY_HEIGHT: 20, HOUSE_EVENT_HALF_WIDTH: 27, QUANTITY_BOX_Y_OFFSET: -105, // Position above the symbol (negative value) QUANTITY_BOX_WIDTH: 108, QUANTITY_BOX_HEIGHT: 32, INPUT_CONNECTOR_BUS_Y_OFFSET: 70, // Reduced connection line length INPUT_CONNECTOR_BUS_HALF_HEIGHT: 10, }; // Enums const NodeType = { GATE: "GATE", EVENT: "EVENT", }; const GateType = { OR: "OR", AND: "AND", VOTE: "VOTE", }; const EventType = { BASIC: "BASIC", EXTERNAL: "EXTERNAL", UNDEVELOPED: "UNDEVELOPED", HOUSE: "HOUSE", }; const NodeStatus = { NORMAL: "NORMAL", DEGRADED: "DEGRADED", CRITICAL: "CRITICAL", }; class JSONFaultTreeParser { constructor() { this.nodeMap = new Map(); this.rootNode = null; } parse(jsonData) { // If jsonData is a string, parse it as JSON const faultTreeData = typeof jsonData === "string" ? JSON.parse(jsonData) : jsonData; // Clear previous data this.nodeMap.clear(); // Parse the tree structure this.rootNode = this.parseNode(faultTreeData); return { rootNode: this.rootNode, nodeMap: this.nodeMap, }; } parseNode(nodeData) { const node = { id: nodeData.id, label: nodeData.label, type: nodeData.type, children: [], metadata: nodeData.metadata || {}, }; // Add type-specific properties if (nodeData.type === NodeType.GATE) { node.gateType = nodeData.gateType || GateType.OR; } else if (nodeData.type === NodeType.EVENT) { node.eventType = nodeData.eventType || EventType.BASIC; } // Add probability and risk score if they exist if (nodeData.probability !== undefined) { node.probability = nodeData.probability; } if (nodeData.riskScore !== undefined) { node.riskScore = nodeData.riskScore; } // Add status (default to NORMAL if not specified) node.status = nodeData.status || NodeStatus.NORMAL; // Store in node map this.nodeMap.set(node.id, node); // Parse children recursively if (nodeData.children && nodeData.children.length > 0) { for (const childData of nodeData.children) { const childNode = this.parseNode(childData); node.children.push(childNode); } } return node; } } class JSONFaultTreeRenderer { constructor(parsedData, theme = "light") { this.rootNode = parsedData.rootNode; this.nodeMap = parsedData.nodeMap; this.layout = null; this.theme = COLOR_THEMES[theme] || COLOR_THEMES.light; } render() { this.computeLayout(); return this.generateSVG(); } computeLayout() { const layout = { nodes: new Map(), width: 0, height: 0 }; if (!this.rootNode) { this.layout = layout; return; } // Layout the tree starting from root const treeLayout = this.layoutSubtree(this.rootNode); // Copy positions to layout for (const [nodeId, position] of treeLayout.nodes) { layout.nodes.set(nodeId, position); } layout.width = treeLayout.width; layout.height = treeLayout.height; this.layout = layout; } layoutSubtree(node) { const positions = new Map(); const subtreeInfo = this.calculateSubtreeInfo(node); // Position the root at the top center const rootWidth = subtreeInfo.width; const rootX = rootWidth / 2; positions.set(node.id, { x: rootX, y: 50, id: node.id }); // Recursively position children this.positionChildren( node, rootX, 50 + SVG_CONSTANTS.EVENT_BOUNDING_HEIGHT, positions, ); return { nodes: positions, width: rootWidth, height: subtreeInfo.height * SVG_CONSTANTS.EVENT_BOUNDING_HEIGHT + 50, }; } calculateSubtreeInfo(node, visited = new Set()) { if (visited.has(node.id)) { return { width: SVG_CONSTANTS.EVENT_BOUNDING_WIDTH, height: 1 }; } visited.add(node.id); if (!node.children || node.children.length === 0) { // Leaf node return { width: SVG_CONSTANTS.EVENT_BOUNDING_WIDTH, height: 1 }; } // Calculate combined width and max height of children let totalWidth = 0; let maxHeight = 1; for (const child of node.children) { const childInfo = this.calculateSubtreeInfo(child, visited); totalWidth += childInfo.width; maxHeight = Math.max(maxHeight, childInfo.height + 1); } return { width: Math.max(SVG_CONSTANTS.EVENT_BOUNDING_WIDTH, totalWidth), height: maxHeight, }; } positionChildren(parentNode, parentX, currentY, positions) { if (!parentNode.children || parentNode.children.length === 0) { return; } // Calculate child widths const childWidths = parentNode.children.map((child) => { const childInfo = this.calculateSubtreeInfo(child); return childInfo.width; }); const totalChildWidth = childWidths.reduce((sum, width) => sum + width, 0); // Position children centered under parent let startX = parentX - totalChildWidth / 2; for (let i = 0; i < parentNode.children.length; i++) { const child = parentNode.children[i]; const childWidth = childWidths[i]; const childX = startX + childWidth / 2; positions.set(child.id, { x: childX, y: currentY, id: child.id }); // Recursively position grandchildren this.positionChildren( child, childX, currentY + SVG_CONSTANTS.EVENT_BOUNDING_HEIGHT, positions, ); startX += childWidth; } } generateSVG() { const margin = SVG_CONSTANTS.PAGE_MARGIN; const width = this.layout.width + 2 * margin; this.layout.height + 2 * margin; // Calculate proper viewBox to include all content const minY = Math.min( ...Array.from(this.layout.nodes.values()).map( (pos) => pos.y + SVG_CONSTANTS.LABEL_BOX_Y_OFFSET - SVG_CONSTANTS.LABEL_BOX_HEIGHT / 2, ), ); const maxY = Math.max( ...Array.from(this.layout.nodes.values()).map( (pos) => pos.y + SVG_CONSTANTS.SYMBOL_Y_OFFSET + SVG_CONSTANTS.BASIC_EVENT_RADIUS, ), ); const viewBoxTop = minY - margin; const viewBoxHeight = maxY - minY + 2 * margin; // Generate unique ID for this SVG to scope styles const svgId = `fault-tree-${Math.random().toString(36).substr(2, 9)}`; let svg = `<svg id="${svgId}" width="${width}" height="${viewBoxHeight}" viewBox="0 ${viewBoxTop} ${width} ${viewBoxHeight}" xmlns="http://www.w3.org/2000/svg">`; svg += this.generateStyles(svgId); // Render connections first (so they appear under text) svg += this.renderLabelConnectors(); svg += this.renderConnections(); // Render nodes last (so text appears on top) for (const [nodeId, position] of this.layout.nodes) { svg += this.renderNode(nodeId, position); } svg += "</svg>"; return svg; } generateStyles(svgId) { const theme = this.theme; return ` <style> #${svgId} {${theme.background ? ` background-color: ${theme.background};` : ''} } #${svgId} circle, #${svgId} path, #${svgId} polygon, #${svgId} rect { fill: ${theme.symbols.normal}; } #${svgId} circle, #${svgId} path, #${svgId} polygon, #${svgId} polyline, #${svgId} rect { stroke: ${theme.symbolsBorder.normal}; stroke-width: ${theme.borderWidth}; } #${svgId} polyline { fill: none; stroke: ${theme.connections}; } #${svgId} text { dominant-baseline: middle; font-family: Consolas, Cousine, "Courier New", monospace; font-size: ${SVG_CONSTANTS.DEFAULT_FONT_SIZE}px; text-anchor: middle; white-space: pre; fill: ${theme.text}; } #${svgId} .time-header { font-size: ${SVG_CONSTANTS.TIME_HEADER_FONT_SIZE}px; } /* Label box status colors and borders */ #${svgId} .label-box.status-normal { fill: ${theme.labelBox.normal}; stroke: ${theme.labelBoxBorder.normal}; } #${svgId} .label-box.status-degraded { fill: ${theme.labelBox.degraded}; stroke: ${theme.labelBoxBorder.degraded}; } #${svgId} .label-box.status-critical { fill: ${theme.labelBox.critical}; stroke: ${theme.labelBoxBorder.critical}; } /* Quantity box status colors and borders */ #${svgId} .quantity-box.status-normal { fill: ${theme.quantityBox.normal}; stroke: ${theme.quantityBoxBorder.normal}; } #${svgId} .quantity-box.status-degraded { fill: ${theme.quantityBox.degraded}; stroke: ${theme.quantityBoxBorder.degraded}; } #${svgId} .quantity-box.status-critical { fill: ${theme.quantityBox.critical}; stroke: ${theme.quantityBoxBorder.critical}; } /* Symbol status colors and borders */ #${svgId} .symbol.status-normal { fill: ${theme.symbols.normal}; stroke: ${theme.symbolsBorder.normal}; } #${svgId} .symbol.status-degraded { fill: ${theme.symbols.degraded}; stroke: ${theme.symbolsBorder.degraded}; } #${svgId} .symbol.status-critical { fill: ${theme.symbols.critical}; stroke: ${theme.symbolsBorder.critical}; } </style>`; } renderNode(nodeId, position) { const node = this.nodeMap.get(nodeId); let svg = ""; // Render label box and text svg += this.renderLabelBox(position, node.status); svg += this.renderLabelText(position, node.label); // Skip identifier text for cleaner appearance // Render symbol if (node.type === NodeType.EVENT) { svg += this.renderEventSymbol(position, node); } else if (node.type === NodeType.GATE) { svg += this.renderGateSymbol(position, node); } // Render quantity box if probability or risk score exists if (node.probability !== undefined || node.riskScore !== undefined) { console.log( `Rendering quantity box for ${node.id}: p=${node.probability}, r=${node.riskScore}`, ); svg += this.renderQuantityBox(position, node.status); svg += this.renderQuantityText(position, node); } return svg; } renderLabelBox(position, status) { const x = position.x - SVG_CONSTANTS.LABEL_BOX_WIDTH / 2; const y = position.y - SVG_CONSTANTS.LABEL_BOX_HEIGHT / 2 + SVG_CONSTANTS.LABEL_BOX_Y_OFFSET; const statusClass = this.getStatusClass(status); return `<rect x="${x}" y="${y}" width="${SVG_CONSTANTS.LABEL_BOX_WIDTH}" height="${SVG_CONSTANTS.LABEL_BOX_HEIGHT}" class="label-box ${statusClass}"/>`; } renderLabelText(position, label) { if (!label) return ""; const x = position.x; const y = position.y + SVG_CONSTANTS.LABEL_BOX_Y_OFFSET; // Simple text wrapping const words = label.split(" "); const lines = []; let currentLine = ""; for (const word of words) { if ( currentLine.length + word.length + 1 <= SVG_CONSTANTS.LABEL_MIN_LINE_LENGTH ) { currentLine += (currentLine ? " " : "") + word; } else { if (currentLine) lines.push(currentLine); currentLine = word; } } if (currentLine) lines.push(currentLine); let svg = ""; const lineHeight = SVG_CONSTANTS.DEFAULT_FONT_SIZE * SVG_CONSTANTS.DEFAULT_LINE_SPACING; const startY = y - ((lines.length - 1) * lineHeight) / 2; for (let i = 0; i < lines.length; i++) { const lineY = startY + i * lineHeight; svg += `<text x="${x}" y="${lineY}">${this.escapeXML(lines[i])}</text>`; } return svg; } renderQuantityBox(position, status) { const x = position.x - SVG_CONSTANTS.QUANTITY_BOX_WIDTH / 2 + 5; // 5 is a slight offset for x // Position directly over the label box, cover half const y = position.y + SVG_CONSTANTS.LABEL_BOX_Y_OFFSET + SVG_CONSTANTS.QUANTITY_BOX_HEIGHT / 2 + 2; // offset for border: 2 const statusClass = this.getStatusClass(status); return `<rect x="${x}" y="${y}" width="${SVG_CONSTANTS.QUANTITY_BOX_WIDTH}" height="${SVG_CONSTANTS.QUANTITY_BOX_HEIGHT}" class="quantity-box ${statusClass}"/>`; } renderQuantityText(position, node) { const x = position.x; // Match the same positioning as the quantity box - center over half of label box const y = position.y + SVG_CONSTANTS.LABEL_BOX_Y_OFFSET + SVG_CONSTANTS.QUANTITY_BOX_HEIGHT + 2; let lines = []; // Add probability if it exists if (node.probability !== undefined) { lines.push(`p = ${this.formatNumber(node.probability)}`); } // Add risk score if it exists if (node.riskScore !== undefined) { lines.push(`r = ${this.formatNumber(node.riskScore)}`); } if (lines.length === 0) return ""; let svg = ""; const lineHeight = SVG_CONSTANTS.DEFAULT_FONT_SIZE * SVG_CONSTANTS.DEFAULT_LINE_SPACING; const startY = y - ((lines.length - 1) * lineHeight) / 2; for (let i = 0; i < lines.length; i++) { const lineY = startY + i * lineHeight; svg += `<text x="${x}" y="${lineY}">${this.escapeXML(lines[i])}</text>`; } return svg; } formatNumber(value) { if (typeof value === "number") { if (value === 0) return "0"; if (value >= 0.01) return value.toFixed(3); return value.toExponential(2); } return value.toString(); } renderEventSymbol(position, node) { const x = position.x; const y = position.y + SVG_CONSTANTS.SYMBOL_Y_OFFSET; switch (node.eventType) { case EventType.UNDEVELOPED: return this.renderUndevelopedEvent(x, y, node.status); case EventType.HOUSE: return this.renderHouseEvent(x, y, node.status); case EventType.EXTERNAL: return this.renderUndevelopedEvent(x, y, node.status); // Use diamond for external events default: // BASIC return this.renderBasicEvent(x, y, node.status); } } renderGateSymbol(position, node) { const x = position.x; const y = position.y + SVG_CONSTANTS.SYMBOL_Y_OFFSET; switch (node.gateType) { case GateType.AND: return this.renderAndGate(x, y, node.status); case GateType.OR: default: return this.renderOrGate(x, y, node.status); } } renderBasicEvent(x, y, status = NodeStatus.NORMAL) { const statusClass = this.getStatusClass(status); return `<circle cx="${x}" cy="${y}" r="${SVG_CONSTANTS.BASIC_EVENT_RADIUS}" class="symbol ${statusClass}"/>`; } renderUndevelopedEvent(x, y, status = NodeStatus.NORMAL) { const statusClass = this.getStatusClass(status); const points = [ [x, y - SVG_CONSTANTS.UNDEVELOPED_EVENT_HALF_HEIGHT], [x - SVG_CONSTANTS.UNDEVELOPED_EVENT_HALF_WIDTH, y], [x, y + SVG_CONSTANTS.UNDEVELOPED_EVENT_HALF_HEIGHT], [x + SVG_CONSTANTS.UNDEVELOPED_EVENT_HALF_WIDTH, y], ] .map((p) => p.join(",")) .join(" "); return `<polygon points="${points}" class="symbol ${statusClass}"/>`; } renderHouseEvent(x, y, status = NodeStatus.NORMAL) { const statusClass = this.getStatusClass(status); const points = [ [x, y - SVG_CONSTANTS.HOUSE_EVENT_APEX_HEIGHT], [ x - SVG_CONSTANTS.HOUSE_EVENT_HALF_WIDTH, y - SVG_CONSTANTS.HOUSE_EVENT_SHOULDER_HEIGHT, ], [ x - SVG_CONSTANTS.HOUSE_EVENT_HALF_WIDTH, y + SVG_CONSTANTS.HOUSE_EVENT_BODY_HEIGHT, ], [ x + SVG_CONSTANTS.HOUSE_EVENT_HALF_WIDTH, y + SVG_CONSTANTS.HOUSE_EVENT_BODY_HEIGHT, ], [ x + SVG_CONSTANTS.HOUSE_EVENT_HALF_WIDTH, y - SVG_CONSTANTS.HOUSE_EVENT_SHOULDER_HEIGHT, ], ] .map((p) => p.join(",")) .join(" "); return `<polygon points="${points}" class="symbol ${statusClass}"/>`; } renderOrGate(x, y, status = NodeStatus.NORMAL) { const statusClass = this.getStatusClass(status); const apex_x = x; const apex_y = y - SVG_CONSTANTS.OR_GATE_APEX_HEIGHT; const left_x = x - SVG_CONSTANTS.OR_GATE_HALF_WIDTH; const right_x = x + SVG_CONSTANTS.OR_GATE_HALF_WIDTH; const ear_y = y - SVG_CONSTANTS.OR_GATE_NECK_HEIGHT; const toe_y = y + SVG_CONSTANTS.OR_GATE_BODY_HEIGHT; const left_slant_x = apex_x - SVG_CONSTANTS.OR_GATE_SLANT_RUN; const right_slant_x = apex_x + SVG_CONSTANTS.OR_GATE_SLANT_RUN; const slant_y = apex_y + SVG_CONSTANTS.OR_GATE_SLANT_DROP; const sling_y = ear_y - SVG_CONSTANTS.OR_GATE_SLING_RISE; const groin_x = x; const groin_y = toe_y - SVG_CONSTANTS.OR_GATE_GROIN_RISE; const path = [ `M${apex_x},${apex_y}`, `C${left_slant_x},${slant_y} ${left_x},${sling_y} ${left_x},${ear_y}`, `L${left_x},${toe_y}`, `Q${groin_x},${groin_y} ${right_x},${toe_y}`, `L${right_x},${ear_y}`, `C${right_x},${sling_y} ${right_slant_x},${slant_y} ${apex_x},${apex_y}`, "Z", ].join(" "); return `<path d="${path}" class="symbol ${statusClass}"/>`; } renderAndGate(x, y, status = NodeStatus.NORMAL) { const statusClass = this.getStatusClass(status); const left_x = x - SVG_CONSTANTS.AND_GATE_HALF_WIDTH; const right_x = x + SVG_CONSTANTS.AND_GATE_HALF_WIDTH; const ear_y = y - SVG_CONSTANTS.AND_GATE_NECK_HEIGHT; const toe_y = y + SVG_CONSTANTS.AND_GATE_BODY_HEIGHT; const sling_y = ear_y - SVG_CONSTANTS.AND_GATE_SLING_RISE; const path = [ `M${left_x},${toe_y}`, `L${right_x},${toe_y}`, `L${right_x},${ear_y}`, `C${right_x},${sling_y} ${left_x},${sling_y} ${left_x},${ear_y}`, `L${left_x},${toe_y}`, "Z", ].join(" "); return `<path d="${path}" class="symbol ${statusClass}"/>`; } getStatusClass(status) { switch (status) { case NodeStatus.CRITICAL: return "status-critical"; case NodeStatus.DEGRADED: return "status-degraded"; default: return "status-normal"; } } renderLabelConnectors() { let svg = ""; // Render connector from each symbol to its label box for (const [nodeId, position] of this.layout.nodes) { svg += this.renderLabelConnector(position); } return svg; } renderLabelConnector(position) { const x = position.x; const labelBottom = position.y + SVG_CONSTANTS.LABEL_BOX_Y_OFFSET + SVG_CONSTANTS.LABEL_BOX_HEIGHT / 2; // Determine symbol top based on node type const nodeId = position.id; const node = this.nodeMap.get(nodeId); let symbolTop; if (node.type === NodeType.EVENT) { // For events switch (node.eventType) { case EventType.UNDEVELOPED: case EventType.EXTERNAL: symbolTop = position.y + SVG_CONSTANTS.SYMBOL_Y_OFFSET - SVG_CONSTANTS.UNDEVELOPED_EVENT_HALF_HEIGHT; break; case EventType.HOUSE: symbolTop = position.y + SVG_CONSTANTS.SYMBOL_Y_OFFSET - SVG_CONSTANTS.HOUSE_EVENT_APEX_HEIGHT; break; default: // BASIC symbolTop = position.y + SVG_CONSTANTS.SYMBOL_Y_OFFSET - SVG_CONSTANTS.BASIC_EVENT_RADIUS; break; } } else if (node.type === NodeType.GATE) { // For gates switch (node.gateType) { case GateType.AND: symbolTop = position.y + SVG_CONSTANTS.SYMBOL_Y_OFFSET - SVG_CONSTANTS.AND_GATE_SLING_RISE; break; default: // OR gate symbolTop = position.y + SVG_CONSTANTS.SYMBOL_Y_OFFSET - SVG_CONSTANTS.OR_GATE_APEX_HEIGHT; break; } } else { // Fallback symbolTop = position.y + SVG_CONSTANTS.SYMBOL_Y_OFFSET - SVG_CONSTANTS.OR_GATE_APEX_HEIGHT; } return `<polyline points="${x},${labelBottom} ${x},${symbolTop}"/>`; } renderConnections() { let svg = ""; for (const [nodeId, node] of this.nodeMap) { if ( node.type !== NodeType.GATE || !node.children || node.children.length === 0 ) continue; const gatePosition = this.layout.nodes.get(nodeId); if (!gatePosition) continue; // Render connector from gate symbol down to horizontal bus svg += this.renderGateConnector(gatePosition); // Render horizontal bus and connections to inputs svg += this.renderInputConnectors(gatePosition, node.children); } return svg; } renderGateConnector(gatePosition) { const x = gatePosition.x; const nodeId = gatePosition.id; const node = this.nodeMap.get(nodeId); let symbolBottom; if (node && node.type === NodeType.GATE && node.gateType === GateType.OR) { // For OR gates, connect to the curved bottom (higher point due to the arc) symbolBottom = gatePosition.y + SVG_CONSTANTS.SYMBOL_Y_OFFSET + SVG_CONSTANTS.OR_GATE_BODY_HEIGHT - SVG_CONSTANTS.OR_GATE_GROIN_RISE; } else { // For AND gates and others, use the flat bottom symbolBottom = gatePosition.y + SVG_CONSTANTS.SYMBOL_Y_OFFSET + SVG_CONSTANTS.AND_GATE_BODY_HEIGHT; } const busY = gatePosition.y + SVG_CONSTANTS.INPUT_CONNECTOR_BUS_Y_OFFSET; return `<polyline points="${x},${symbolBottom} ${x},${busY}"/>`; } renderInputConnectors(gatePosition, children) { let svg = ""; if (!children || children.length === 0) return svg; const gateX = gatePosition.x; const busY = gatePosition.y + SVG_CONSTANTS.INPUT_CONNECTOR_BUS_Y_OFFSET; // Calculate positions for inputs const inputPositions = children .map((child) => this.layout.nodes.get(child.id)) .filter((pos) => pos); if (inputPositions.length === 0) return svg; // Draw horizontal bus const leftmostX = Math.min(...inputPositions.map((pos) => pos.x)); const rightmostX = Math.max(...inputPositions.map((pos) => pos.x)); const busLeft = Math.min( leftmostX, gateX - SVG_CONSTANTS.SYMBOL_SLOTS_HALF_WIDTH, ); const busRight = Math.max( rightmostX, gateX + SVG_CONSTANTS.SYMBOL_SLOTS_HALF_WIDTH, ); svg += `<polyline points="${busLeft},${busY} ${busRight},${busY}"/>`; // Draw vertical connections to each input for (const inputPosition of inputPositions) { const inputX = inputPosition.x; const inputTopY = inputPosition.y + SVG_CONSTANTS.LABEL_BOX_Y_OFFSET - SVG_CONSTANTS.LABEL_BOX_HEIGHT / 2; svg += `<polyline points="${inputX},${busY} ${inputX},${inputTopY}"/>`; } return svg; } escapeXML(text) { return text .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#39;"); } } // Main JSFTA-JSON class class JSFTA_JSON { static parse(jsonData) { const parser = new JSONFaultTreeParser(); return parser.parse(jsonData); } static render(parsedData, theme = "light") { const renderer = new JSONFaultTreeRenderer(parsedData, theme); return renderer.render(); } static parseAndRender(jsonData, theme = "light") { const parsedData = JSFTA_JSON.parse(jsonData); return JSFTA_JSON.render(parsedData, theme); } static getAvailableThemes() { return Object.keys(COLOR_THEMES).map((key) => ({ key: key, name: COLOR_THEMES[key].name, })); } } export { COLOR_THEMES, EventType, JSFTA_JSON as FaultTreeAnalyzer, GateType, JSONFaultTreeParser, JSONFaultTreeRenderer, NodeStatus, NodeType, SVG_CONSTANTS, JSFTA_JSON as default };