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
/**
* 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
}
// 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 };