Release v1.0.1: fix lane add button placement on bowtie connectors.

Align + controls with bezier edge geometry, keep them behind nodes when paths cross, and document the release in CHANGELOG.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andre482 2026-06-18 22:18:37 +03:00
parent 6ea43bcfc6
commit 2f379a623f
19 changed files with 357 additions and 200 deletions

View file

@ -33,13 +33,7 @@ jobs:
- name: Create release
uses: softprops/action-gh-release@v2
with:
body: |
Initial release of O-Tie.
- Interactive risk bowtie diagram editor
- Barriers, escalation factors, and analysis stacks
- PNG export, undo/redo, help modal
- Auto-save to .bowtie JSON files
generate_release_notes: true
files: |
main.js
manifest.json

27
CHANGELOG.md Normal file
View file

@ -0,0 +1,27 @@
# Changelog
All notable changes to O-Tie are documented here.
## [1.0.1] — 2026-06-18
### Fixed
- **Lane add (+) button placement** — Buttons on threat and consequence connectors now sit on the same bezier curve as the diagram edges, so they no longer float above nodes or overlap barrier labels.
- **Layering** — Lane add controls render behind node boxes when paths cross, so a `+` from another row no longer covers mitigation or prevention text.
- **Overlap guard** — Lane add buttons are skipped when they would sit on top of an unrelated node (for example, a direct top-event path crossing another rows barrier column).
### Improved
- **Symmetric connectors** — Threat and consequence lanes both use the same placement logic, so `+` icons appear consistently on curved and straight segments.
- **PNG export** — Export rendering aligned with on-screen diagram styling.
---
## [1.0.0] — 2026-06-12
Initial public release.
- Interactive risk bowtie diagram editor for Obsidian
- Threats, prevention barriers, top event, mitigation barriers, consequences, and hazard
- Escalation factors, escalation barriers, and per-barrier analysis stacks
- Pan/zoom, undo/redo, inspector panel, PNG export, and auto-save to `.bowtie` files

View file

@ -89,6 +89,12 @@ Open **Settings → O-Tie** to configure:
See [examples/steamcracker.bowtie](examples/steamcracker.bowtie).
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for release history.
**Latest (1.0.1):** Fixes lane add (`+`) button placement on curved connectors, improves layering so buttons stay behind nodes, and aligns threat/consequence lane controls.
## Development
```bash

View file

@ -1,6 +1,6 @@
import esbuild from "esbuild";
import { builtinModules } from "node:module";
import process from "process";
import builtins from "builtin-modules";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
@ -30,7 +30,7 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
...builtinModules,
],
format: "cjs",
target: "es2018",

View file

@ -1,7 +1,7 @@
{
"id": "o-tie",
"name": "O-Tie",
"version": "1.0.0",
"version": "1.0.1",
"minAppVersion": "1.4.0",
"description": "Build and edit risk bowtie diagrams in a visual editor with barriers, escalation factors, analysis stacks, and PNG export.",
"author": "Andre482",

18
package-lock.json generated
View file

@ -1,19 +1,18 @@
{
"name": "o-tie",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "o-tie",
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"html-to-image": "^1.11.13"
},
"devDependencies": {
"@types/node": "^20.11.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.20.0",
"obsidian": "latest",
"tslib": "^2.6.2",
@ -481,19 +480,6 @@
"@types/estree": "*"
}
},
"node_modules/builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "o-tie",
"version": "1.0.0",
"version": "1.0.1",
"description": "Obsidian plugin for building risk bowtie diagrams",
"main": "main.js",
"scripts": {
@ -17,7 +17,6 @@
"license": "MIT",
"devDependencies": {
"@types/node": "^20.11.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.20.0",
"obsidian": "latest",
"tslib": "^2.6.2",

View file

@ -11,6 +11,8 @@ import {
type EdgePath,
type LayoutConfig,
type PositionedNode,
bezierPointAt,
connectionPorts,
} from "./layout";
import {
BARRIER_STACK_FIELDS,
@ -54,11 +56,23 @@ const STACK_ROW_COLOR_OPTIONS: { color: string; label: string }[] = [
const LIGHT_STACK_ROW_COLORS = new Set(["#ffffff", "#f4ecf7", "#eafaf1"]);
function isLightStackColor(color: string): boolean {
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;
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.62;
}
function createColorMenuTitle(color: string, label: string): DocumentFragment {
const frag = document.createDocumentFragment();
const frag = activeDocument.createDocumentFragment();
const wrap = frag.createEl("span", { cls: "o-tie-color-menu-title" });
const swatch = wrap.createEl("span", { cls: "o-tie-color-swatch" });
swatch.style.backgroundColor = color;
swatch.setCssStyles({ backgroundColor: color });
if (LIGHT_STACK_ROW_COLORS.has(color)) {
swatch.addClass("o-tie-color-swatch-light");
}
@ -279,7 +293,7 @@ export class BowtieView extends TextFileView {
this.transformEl = this.stageEl.createDiv({ cls: "o-tie-transform" });
const ns = "http://www.w3.org/2000/svg";
this.svgEl = document.createElementNS(ns, "svg");
this.svgEl = activeDocument.createElementNS(ns, "svg");
this.svgEl.classList.add("o-tie-svg");
this.transformEl.appendChild(this.svgEl);
@ -440,7 +454,7 @@ export class BowtieView extends TextFileView {
attr: { rows: "2" },
});
notesArea.value = notes;
requestAnimationFrame(() => this.fitInspectorNotesArea(notesArea));
window.requestAnimationFrame(() => this.fitInspectorNotesArea(notesArea));
notesArea.addEventListener("input", () => this.fitInspectorNotesArea(notesArea));
notesArea.addEventListener("change", () => {
if (notesArea.value === notes) return;
@ -497,21 +511,24 @@ export class BowtieView extends TextFileView {
}
private fitInspectorNotesArea(textarea: HTMLTextAreaElement): void {
textarea.style.height = "0";
textarea.style.height = `${textarea.scrollHeight}px`;
textarea.addClass("o-tie-inspector-notes-resize");
textarea.setCssStyles({ height: `${textarea.scrollHeight}px` });
textarea.removeClass("o-tie-inspector-notes-resize");
}
private renderDiagram(): void {
const layout = layoutBowtie(this.bowtie, this.getLayoutConfig());
this.transformEl.style.width = `${layout.bounds.width}px`;
this.transformEl.style.height = `${layout.bounds.height}px`;
this.transformEl.setCssStyles({
width: `${layout.bounds.width}px`,
height: `${layout.bounds.height}px`,
});
this.svgEl.setAttribute("width", String(layout.bounds.width));
this.svgEl.setAttribute("height", String(layout.bounds.height));
this.svgEl.innerHTML = "";
for (const edge of layout.edges) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const path = activeDocument.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", edge.path);
path.setAttribute("class", `o-tie-edge o-tie-edge-${edge.kind}`);
this.svgEl.appendChild(path);
@ -519,10 +536,10 @@ export class BowtieView extends TextFileView {
}
this.nodesEl.empty();
this.renderLaneAddButtons(layout);
for (const node of layout.nodes) {
this.renderNode(node);
}
this.renderLaneAddButtons(layout);
this.applyTransform();
@ -536,11 +553,11 @@ export class BowtieView extends TextFileView {
const { x, y, angleDeg } = edge.arrow;
const size = 8;
const group = document.createElementNS(ns, "g");
const group = activeDocument.createElementNS(ns, "g");
group.setAttribute("class", `o-tie-edge-arrow o-tie-edge-arrow-${edge.kind}`);
group.setAttribute("transform", `translate(${x} ${y}) rotate(${angleDeg})`);
const head = document.createElementNS(ns, "path");
const head = activeDocument.createElementNS(ns, "path");
head.setAttribute("d", `M0 0 L-${size} ${-size * 0.42} L-${size} ${size * 0.42} Z`);
group.appendChild(head);
this.svgEl.appendChild(group);
@ -551,10 +568,12 @@ export class BowtieView extends TextFileView {
cls: `o-tie-node-wrap o-tie-node-wrap-${node.kind}`,
attr: { "data-ref": nodeRefKey(node.ref) },
});
wrap.style.left = `${node.x}px`;
wrap.style.top = `${node.y}px`;
wrap.style.width = `${node.width}px`;
wrap.style.height = `${node.height}px`;
wrap.setCssStyles({
left: `${node.x}px`,
top: `${node.y}px`,
width: `${node.width}px`,
height: `${node.height}px`,
});
if (this.selectedRef && nodeRefKey(this.selectedRef) === nodeRefKey(node.ref)) {
wrap.addClass("o-tie-node-selected");
@ -665,8 +684,10 @@ export class BowtieView extends TextFileView {
: layout.barrierHeaderHeight;
const header = el.createDiv({ cls: "o-tie-barrier-header" });
header.style.height = `${headerH}px`;
header.style.minHeight = `${headerH}px`;
header.setCssStyles({
height: `${headerH}px`,
minHeight: `${headerH}px`,
});
const stripe = header.createDiv({ cls: "o-tie-node-stripe" });
stripe.setText(node.subtitle);
@ -696,7 +717,7 @@ export class BowtieView extends TextFileView {
const chevron = wrap.createEl("button", {
cls: `o-tie-stack-chevron${collapsed ? " is-collapsed" : ""}`,
});
chevron.style.top = `${headerH}px`;
chevron.setCssStyles({ top: `${headerH}px` });
chevron.setAttribute("aria-label", collapsed ? "Expand stack" : "Collapse stack");
chevron.addEventListener("mousedown", (e) => e.stopPropagation());
chevron.addEventListener("click", (e) => {
@ -710,7 +731,7 @@ export class BowtieView extends TextFileView {
if (stack.length === 0) {
const placeholder = stackEl.createDiv({ cls: "o-tie-stack-empty" });
placeholder.setText("Click + to add analysis row");
placeholder.style.height = `${layout.barrierStackRowHeight}px`;
placeholder.setCssStyles({ height: `${layout.barrierStackRowHeight}px` });
placeholder.addEventListener("click", (e) => {
e.stopPropagation();
this.showAddStackRowMenu(e, node.ref);
@ -739,19 +760,21 @@ export class BowtieView extends TextFileView {
cls: "o-tie-stack-row",
attr: { "data-stack-id": item.id },
});
row.style.height = `${layout.barrierStackRowHeight}px`;
const rowStyles: Partial<CSSStyleDeclaration> = {
height: `${layout.barrierStackRowHeight}px`,
};
if (item.color) {
row.style.backgroundColor = item.color;
if (item.color === "#ffffff" || item.color === "#f4ecf7" || item.color === "#eafaf1") {
rowStyles.backgroundColor = item.color;
if (isLightStackColor(item.color)) {
row.addClass("o-tie-stack-row-light");
}
}
row.setCssStyles(rowStyles);
const fieldDef = item.field
? BARRIER_STACK_FIELDS.find((f) => f.key === item.field)
: undefined;
if (fieldDef) {
row.style.borderLeftColor = "var(--o-tie-barrier)";
row.addClass("o-tie-stack-row-preset");
}
@ -872,9 +895,9 @@ export class BowtieView extends TextFileView {
if (!field) {
window.setTimeout(() => {
const rowEl = this.nodesEl.querySelector(
const rowEl = this.nodesEl.querySelector<HTMLElement>(
`[data-ref="${nodeRefKey(ref)}"] [data-stack-id="${item.id}"] .o-tie-stack-row-label`
) as HTMLElement | null;
);
if (rowEl) {
this.startInlineEdit(rowEl, label, (v) => {
this.updateStackRowLabel(ref, item.id, v);
@ -944,9 +967,9 @@ export class BowtieView extends TextFileView {
} else {
menu.addItem((item) =>
item.setTitle("Edit label").setIcon("pencil").onClick(() => {
const rowEl = this.nodesEl.querySelector(
const rowEl = this.nodesEl.querySelector<HTMLElement>(
`[data-ref="${nodeRefKey(ref)}"] [data-stack-id="${itemId}"] .o-tie-stack-row-label`
) as HTMLElement | null;
);
if (rowEl) {
this.startInlineEdit(rowEl, stackItem.label, (v) => {
this.updateStackRowLabel(ref, itemId, v);
@ -1039,7 +1062,7 @@ export class BowtieView extends TextFileView {
initial: string,
onCommit: (value: string) => void
): void {
const input = document.createElement("input");
const input = activeDocument.createElement("input");
input.type = "text";
input.className = "o-tie-inline-edit";
input.value = initial;
@ -1154,7 +1177,7 @@ export class BowtieView extends TextFileView {
this.nodesEl.querySelectorAll(".o-tie-node-wrap").forEach((n) => n.removeClass("o-tie-node-selected"));
});
this.registerDomEvent(document, "keydown", (e) => {
this.registerDomEvent(activeDocument, "keydown", (e) => {
if (this.app.workspace.getActiveViewOfType(BowtieView) !== this) return;
const target = e.target as HTMLElement;
if (target.closest("input, textarea, [contenteditable='true']")) return;
@ -1208,23 +1231,26 @@ export class BowtieView extends TextFileView {
const { panX, panY, zoom } = view;
// Pan and zoom on separate layers so zoom anchors correctly under the cursor.
this.viewportEl.style.transform = `translate3d(${panX}px, ${panY}px, 0)`;
this.viewportEl.style.transformOrigin = "0 0";
this.viewportEl.setCssStyles({
transform: `translate3d(${panX}px, ${panY}px, 0)`,
transformOrigin: "0 0",
});
if (BowtieView.useCssZoom) {
this.stageEl.style.zoom = String(zoom);
this.stageEl.style.transform = "";
this.stageEl.setCssStyles({ zoom: String(zoom), transform: "" });
} else {
this.stageEl.style.zoom = "1";
this.stageEl.style.transform = `scale(${zoom})`;
this.stageEl.style.transformOrigin = "0 0";
this.stageEl.setCssStyles({
zoom: "1",
transform: `scale(${zoom})`,
transformOrigin: "0 0",
});
}
if (this.containerEl_) {
const gs = BowtieView.GRID_SIZE;
const bgX = ((panX % gs) + gs) % gs;
const bgY = ((panY % gs) + gs) % gs;
this.containerEl_.style.backgroundPosition = `${bgX}px ${bgY}px`;
this.containerEl_.setCssStyles({ backgroundPosition: `${bgX}px ${bgY}px` });
}
}
@ -1420,25 +1446,16 @@ export class BowtieView extends TextFileView {
);
if (!threatNode) continue;
const lastBarrier = layout.nodes
.filter(
(n) =>
n.kind === "preventionBarrier" && n.ref.threatId === threat.id
)
.sort((a, b) => b.x - a.x)[0];
const lastBarrier = this.findLastBarrierInLane(
layout,
(n) => n.kind === "preventionBarrier" && n.ref.threatId === threat.id
);
const fromNode = lastBarrier ?? threatNode;
const fromX = fromNode.x + fromNode.width;
const toX = topEvent.x;
const fromY = fromNode.y + fromNode.height / 2;
const toY = topEvent.y + topEvent.height / 2;
const y = (fromY + toY) / 2;
const pos = this.laneAddPosition(fromNode, topEvent, layout);
if (!pos) continue;
this.createLaneAddButton(
(fromX + toX) / 2 - 13,
y - 13,
"Add prevention barrier",
() => this.addPreventionBarrier(threat.id)
this.createLaneAddButton(pos.x, pos.y, "Add prevention barrier", () =>
this.addPreventionBarrier(threat.id)
);
}
@ -1448,31 +1465,76 @@ export class BowtieView extends TextFileView {
);
if (!consNode) continue;
const mitigationBarriers = layout.nodes
.filter(
(n) =>
n.kind === "mitigationBarrier" &&
n.ref.consequenceId === consequence.id
)
.sort((a, b) => a.x - b.x);
const lastMitigation = mitigationBarriers[mitigationBarriers.length - 1];
const lastMitigation = this.findLastBarrierInLane(
layout,
(n) =>
n.kind === "mitigationBarrier" &&
n.ref.consequenceId === consequence.id
);
const fromNode = lastMitigation ?? topEvent;
const fromX = fromNode.x + fromNode.width;
const toX = consNode.x;
const fromY = fromNode.y + fromNode.height / 2;
const toY = consNode.y + consNode.height / 2;
const y = (fromY + toY) / 2;
const pos = this.laneAddPosition(fromNode, consNode, layout);
if (!pos) continue;
this.createLaneAddButton(
(fromX + toX) / 2 - 13,
y - 13,
"Add mitigation barrier",
() => this.addMitigationBarrier(consequence.id)
this.createLaneAddButton(pos.x, pos.y, "Add mitigation barrier", () =>
this.addMitigationBarrier(consequence.id)
);
}
}
private findLastBarrierInLane(
layout: import("./layout").LayoutResult,
match: (node: PositionedNode) => boolean
): PositionedNode | undefined {
return layout.nodes.filter(match).sort((a, b) => a.x - b.x).pop();
}
private laneAddPosition(
from: PositionedNode,
to: PositionedNode,
layout: import("./layout").LayoutResult
): { x: number; y: number } | null {
const LANE_ADD_SIZE = 26;
const LANE_ADD_HALF = 13;
const MIN_GAP = LANE_ADD_SIZE + 4;
const ports = connectionPorts(from, to);
const gap = ports.to.x - ports.from.x;
if (gap < MIN_GAP) return null;
const point = bezierPointAt(ports.from.x, ports.from.y, ports.to.x, ports.to.y, 0.5);
const cx = point.x;
const cy = point.y;
for (const node of layout.nodes) {
if (node === from || node === to) continue;
if (this.laneAddOverlapsButton(cx, cy, LANE_ADD_HALF, node)) {
return null;
}
}
return { x: cx - LANE_ADD_HALF, y: cy - LANE_ADD_HALF };
}
private laneAddOverlapsButton(
cx: number,
cy: number,
half: number,
node: PositionedNode
): boolean {
const margin = 2;
const left = cx - half;
const right = cx + half;
const top = cy - half;
const bottom = cy + half;
return (
right > node.x + margin &&
left < node.x + node.width - margin &&
bottom > node.y + margin &&
top < node.y + node.height - margin
);
}
private createLaneAddButton(
x: number,
y: number,
@ -1480,8 +1542,7 @@ export class BowtieView extends TextFileView {
onClick: () => void
): void {
const btn = this.nodesEl.createEl("button", { cls: "o-tie-lane-add o-tie-plus-btn" });
btn.style.left = `${x}px`;
btn.style.top = `${y}px`;
btn.setCssStyles({ left: `${x}px`, top: `${y}px` });
btn.setAttribute("aria-label", label);
btn.addEventListener("mousedown", (e) => e.stopPropagation());
btn.addEventListener("click", (e) => {

View file

@ -21,7 +21,7 @@ export class NewBowtieNameModal extends Modal {
text.inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") this.submit(text.getValue());
});
setTimeout(() => text.inputEl.focus(), 50);
window.setTimeout(() => text.inputEl.focus(), 50);
});
const actions = contentEl.createDiv({ cls: "o-tie-actions" });

View file

@ -38,6 +38,7 @@ const EXPORT_CHROME_SELECTORS = [
function copyThemeVariables(from: HTMLElement, to: HTMLElement): void {
const styles = getComputedStyle(from);
const props: Record<string, string> = {};
for (let i = 0; i < styles.length; i++) {
const name = styles[i];
if (
@ -46,14 +47,15 @@ function copyThemeVariables(from: HTMLElement, to: HTMLElement): void {
name.startsWith("--interactive") ||
name.startsWith("--radius")
) {
to.style.setProperty(name, styles.getPropertyValue(name));
props[name] = styles.getPropertyValue(name);
}
}
// Node cards always use light pastel fills — keep export text readable in dark mode.
to.style.setProperty("--text-normal", "#1a1a1a");
to.style.setProperty("--text-muted", "#5d6d7e");
to.style.setProperty("--background-primary", "#ffffff");
props["--text-normal"] = "#1a1a1a";
props["--text-muted"] = "#5d6d7e";
props["--background-primary"] = "#ffffff";
to.setCssProps(props);
}
function prepareExportClone(clone: HTMLElement): void {
@ -122,35 +124,32 @@ function buildDiagramLayer(
nodesEl: HTMLElement,
bounds: { width: number; height: number }
): HTMLElement {
const layer = document.createElement("div");
const layer = activeDocument.createElement("div");
layer.className = "o-tie-export-layer";
layer.style.position = "relative";
layer.style.width = `${bounds.width}px`;
layer.style.height = `${bounds.height}px`;
layer.style.overflow = "visible";
layer.setCssStyles({
width: `${bounds.width}px`,
height: `${bounds.height}px`,
});
const edgeImg = document.createElement("img");
const edgeImg = activeDocument.createElement("img");
edgeImg.className = "o-tie-export-edges";
edgeImg.alt = "";
edgeImg.src = svgToDataUrl(svgEl);
edgeImg.width = bounds.width;
edgeImg.height = bounds.height;
edgeImg.style.position = "absolute";
edgeImg.style.top = "0";
edgeImg.style.left = "0";
edgeImg.style.width = `${bounds.width}px`;
edgeImg.style.height = `${bounds.height}px`;
edgeImg.style.pointerEvents = "none";
edgeImg.setCssStyles({
width: `${bounds.width}px`,
height: `${bounds.height}px`,
});
layer.appendChild(edgeImg);
const nodesClone = nodesEl.cloneNode(true) as HTMLElement;
prepareExportClone(nodesClone);
nodesClone.style.position = "absolute";
nodesClone.style.top = "0";
nodesClone.style.left = "0";
nodesClone.style.width = `${bounds.width}px`;
nodesClone.style.height = `${bounds.height}px`;
nodesClone.style.overflow = "visible";
nodesClone.classList.add("o-tie-export-nodes");
nodesClone.setCssStyles({
width: `${bounds.width}px`,
height: `${bounds.height}px`,
});
layer.appendChild(nodesClone);
return layer;
@ -208,48 +207,43 @@ export async function rasterizeBowtieForExport(options: BowtieExportOptions): Pr
// Offscreen wrapper carries the positioning offset so it is NOT inlined
// onto the captured element (which would push content out of the frame).
const wrapper = document.createElement("div");
wrapper.style.cssText =
"position:fixed;left:-20000px;top:0;pointer-events:none;z-index:-1;";
const wrapper = activeDocument.createElement("div");
wrapper.className = "o-tie-export-wrapper";
const root = document.createElement("div");
const root = activeDocument.createElement("div");
root.className = "o-tie-view-root o-tie-export-root";
root.style.position = "relative";
root.style.left = "0";
root.style.top = "0";
root.style.overflow = "hidden";
root.style.opacity = "1";
root.style.visibility = "visible";
root.style.width = `${exportWidth}px`;
root.style.height = `${exportHeight}px`;
root.setCssStyles({
width: `${exportWidth}px`,
height: `${exportHeight}px`,
});
copyThemeVariables(viewRootEl, root);
const bgStyles = getComputedStyle(viewRootEl);
const dotColor =
bgStyles.getPropertyValue("--background-modifier-border").trim() || "#d0d0d0";
root.style.backgroundColor = backgroundColor;
const rootBg: Partial<CSSStyleDeclaration> = { backgroundColor };
if (showGrid) {
root.style.backgroundImage = buildGridBackground(backgroundColor, dotColor);
root.style.backgroundSize = `${GRID_SIZE}px ${GRID_SIZE}px`;
root.style.backgroundPosition = `${EXPORT_PADDING}px ${EXPORT_PADDING}px`;
rootBg.backgroundImage = buildGridBackground(backgroundColor, dotColor);
rootBg.backgroundSize = `${GRID_SIZE}px ${GRID_SIZE}px`;
rootBg.backgroundPosition = `${EXPORT_PADDING}px ${EXPORT_PADDING}px`;
}
root.setCssStyles(rootBg);
const stage = document.createElement("div");
const stage = activeDocument.createElement("div");
stage.className = "o-tie-export-stage";
stage.style.position = "absolute";
stage.style.width = `${bounds.width}px`;
stage.style.height = `${bounds.height}px`;
stage.style.left = `${EXPORT_PADDING - (crop?.x ?? 0)}px`;
stage.style.top = `${EXPORT_PADDING - (crop?.y ?? 0)}px`;
stage.style.overflow = "visible";
stage.setCssStyles({
width: `${bounds.width}px`,
height: `${bounds.height}px`,
left: `${EXPORT_PADDING - (crop?.x ?? 0)}px`,
top: `${EXPORT_PADDING - (crop?.y ?? 0)}px`,
});
stage.appendChild(buildDiagramLayer(svgEl, nodesEl, bounds));
root.appendChild(stage);
wrapper.appendChild(root);
document.body.appendChild(wrapper);
activeDocument.body.appendChild(wrapper);
try {
await document.fonts.ready;
await activeDocument.fonts.ready;
await Promise.all(
Array.from(root.querySelectorAll<HTMLImageElement>("img.o-tie-export-edges")).map(
(img) => {
@ -269,7 +263,7 @@ export async function rasterizeBowtieForExport(options: BowtieExportOptions): Pr
export function downloadPng(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
const anchor = activeDocument.createElement("a");
anchor.href = url;
anchor.download = filename.endsWith(".png") ? filename : `${filename}.png`;
anchor.click();

View file

@ -61,7 +61,6 @@ export class ExportImageModal extends Modal {
slider
.setLimits(1, 4, 0.5)
.setValue(this.scale)
.setDynamicTooltip()
.onChange((value) => {
this.scale = value;
})

View file

@ -1,6 +1,5 @@
import { App, Modal } from "obsidian";
const PLUGIN_VERSION = "1.0.0";
import * as manifest from "../manifest.json";
export class HelpModal extends Modal {
constructor(app: App) {
@ -14,7 +13,7 @@ export class HelpModal extends Modal {
contentEl.createEl("p", {
cls: "o-tie-help-version",
text: `Version ${PLUGIN_VERSION}`,
text: `Version ${manifest.version}`,
});
const body = contentEl.createDiv({ cls: "o-tie-help-body" });
@ -45,7 +44,24 @@ export class HelpModal extends Modal {
"Top Event (red) — the central loss-of-control event",
"Threats (gray) — left side; prevention barriers (green) sit on connectors to the top event",
"Consequences (purple) — right side; mitigation barriers (green) sit on connectors from the top event",
"Escalation factors (purple) — branch from barriers; can have escalation barriers",
"Escalation factors (purple, dashed) — branch downward from a barrier when something could weaken that barrier",
"Escalation barriers (purple, dashed) — sit on escalation factors and control those weakening influences",
]);
this.addSection(body, "Barriers", [
"A barrier is a control that stops or reduces risk along the main bowtie path. In O-Tie, barriers are green nodes on the connectors between threats/consequences and the top event.",
"Prevention barriers sit between a threat and the top event. They stop the threat from causing the top event — for example an alarm, an interlock, a procedure, or operator training.",
"Mitigation barriers sit between the top event and a consequence. They limit harm if the top event happens — for example emergency shutdown, fire suppression, or evacuation.",
"Add prevention barriers from a selected threat (+ Prevention Barrier in the inspector, the lane + button, or the barrier + on the threat node). Add mitigation barriers from a selected consequence the same way, or use + Barrier in the toolbar when a threat or consequence is selected.",
"Each barrier can carry an analysis stack (type, effectiveness, criticality, and more) — see Barrier analysis stacks below.",
]);
this.addSection(body, "Escalation factors and escalation barriers", [
"Barriers are not always reliable on their own. An escalation factor describes a condition that could weaken, bypass, or defeat a barrier — such as missed maintenance, fatigue, corrosion, or conflicting procedures.",
"In the diagram, escalation factors appear as purple dashed nodes branching below their parent barrier. Dashed connectors show that this is a secondary path: the factor does not replace the main threat-to-event or event-to-consequence flow, but explains how the barrier might fail.",
"An escalation barrier is a control targeted at the escalation factor itself — the measure that keeps the weakening influence in check. For example, if an escalation factor is “sensor not calibrated”, an escalation barrier might be “annual calibration program”.",
"To add an escalation factor, select a prevention or mitigation barrier, then click + Escalation factor in the inspector, the ⚡ button on the barrier, or use the context menu. To add an escalation barrier, select the escalation factor and use + Escalation barrier, the + button on the factor, or the context menu.",
"Name each node to match your risk assessment. Use notes in the inspector for evidence, assumptions, or actions. A complete bowtie often chains several barriers, each with its own escalation factors and escalation barriers where relevant.",
]);
this.addSection(body, "Editing", [
@ -58,13 +74,6 @@ export class HelpModal extends Modal {
"Use the + buttons in lanes between threats/consequences and the top event to add barriers quickly.",
]);
this.addSection(body, "Barriers and escalation", [
"Prevention barriers block threats from reaching the top event.",
"Mitigation barriers reduce consequences after the top event.",
"Select a barrier, then use + Barrier or the context menu to add escalation factors.",
"Escalation factors can have their own escalation barriers.",
]);
this.addSection(body, "Barrier analysis stacks", [
"Each barrier can have an expandable stack of analysis rows.",
"Preset fields include barrier type, effectiveness, criticality, responsible party, validation method, and status.",

View file

@ -32,5 +32,5 @@ export function registerPluginIcon(): void {
/** Make the ribbon button icon slightly larger than default. */
export function styleRibbonIcon(el: HTMLElement): void {
el.style.setProperty("--icon-size", "var(--icon-size-l)");
el.setCssProps({ "--icon-size": "var(--icon-size-l)" });
}

View file

@ -25,7 +25,7 @@ export const DEFAULT_LAYOUT: LayoutConfig = {
barrierWidth: 140,
barrierHeight: 52,
barrierHeaderHeight: 52,
barrierStackRowHeight: 22,
barrierStackRowHeight: 24,
escalationWidth: 130,
escalationHeight: 44,
columnGap: 100,
@ -168,10 +168,6 @@ function placeBarriersInLane(
);
}
function nodeCenter(node: PositionedNode): { x: number; y: number } {
return { x: node.x + node.width / 2, y: node.y + node.height / 2 };
}
function nodeRight(node: PositionedNode): { x: number; y: number } {
return { x: node.x + node.width, y: node.y + node.height / 2 };
}
@ -180,6 +176,37 @@ function nodeLeft(node: PositionedNode): { x: number; y: number } {
return { x: node.x, y: node.y + node.height / 2 };
}
/** Port points used for main-flow bezier edges between two nodes. */
export function connectionPorts(
from: PositionedNode,
to: PositionedNode
): { from: { x: number; y: number }; to: { x: number; y: number } } {
return { from: nodeRight(from), to: nodeLeft(to) };
}
/** Point on the same cubic bezier as {@link makeBezierEdge} at parameter t (01). */
export function bezierPointAt(
x1: number,
y1: number,
x2: number,
y2: number,
t: number,
curve = 0.35
): { x: number; y: number } {
const dx = (x2 - x1) * curve;
const p1x = x1 + dx;
const p1y = y1;
const p2x = x2 - dx;
const p2y = y2;
const mt = 1 - t;
const mt2 = mt * mt;
const t2 = t * t;
return {
x: mt2 * mt * x1 + 3 * mt2 * t * p1x + 3 * mt * t2 * p2x + t2 * t * x2,
y: mt2 * mt * y1 + 3 * mt2 * t * p1y + 3 * mt * t2 * p2y + t2 * t * y2,
};
}
function nodeBottom(node: PositionedNode): { x: number; y: number } {
return { x: node.x + node.width / 2, y: node.y + node.height };
}

View file

@ -63,7 +63,8 @@ export default class OTiePlugin extends Plugin {
onunload(): void {}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
const loaded = (await this.loadData()) as Partial<OTieSettings> | null;
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded ?? {});
}
async saveSettings(): Promise<void> {
@ -91,28 +92,32 @@ export default class OTiePlugin extends Plugin {
}
private async createNewBowtie(): Promise<void> {
new NewBowtieNameModal(this.app, async (name) => {
const safeName = this.sanitizeFileName(name);
const folder = normalizePath(this.settings.defaultFolder);
await this.ensureFolder(folder);
const basePath = folder ? `${folder}/${safeName}` : safeName;
const filePath = getBowtieFilePath(basePath);
if (this.app.vault.getAbstractFileByPath(filePath)) {
new Notice(`A bowtie named "${safeName}" already exists.`);
return;
}
const bowtie = createBowtie(name);
bowtie.hazard = "Hazard";
bowtie.topEvent = "Top Event";
const file = await this.app.vault.create(filePath, serializeBowtie(bowtie));
await this.openBowtieFile(file);
new NewBowtieNameModal(this.app, (name) => {
void this.createBowtieFromName(name);
}).open();
}
private async createBowtieFromName(name: string): Promise<void> {
const safeName = this.sanitizeFileName(name);
const folder = normalizePath(this.settings.defaultFolder);
await this.ensureFolder(folder);
const basePath = folder ? `${folder}/${safeName}` : safeName;
const filePath = getBowtieFilePath(basePath);
if (this.app.vault.getAbstractFileByPath(filePath)) {
new Notice(`A bowtie named "${safeName}" already exists.`);
return;
}
const bowtie = createBowtie(name);
bowtie.hazard = "Hazard";
bowtie.topEvent = "Top Event";
const file = await this.app.vault.create(filePath, serializeBowtie(bowtie));
await this.openBowtieFile(file);
}
async openBowtieFile(file: TFile): Promise<void> {
const leaf = this.app.workspace.getLeaf(true);
await leaf.openFile(file);

View file

@ -29,7 +29,7 @@ export class OTieSettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "O-Tie Settings" });
new Setting(containerEl).setName("O-Tie Settings").setHeading();
new Setting(containerEl)
.setName("Default folder")

View file

@ -100,6 +100,48 @@
--text-normal: #1a1a1a;
--text-muted: #5d6d7e;
--background-primary: #ffffff;
position: relative;
left: 0;
top: 0;
overflow: hidden;
opacity: 1;
visibility: visible;
}
.o-tie-export-wrapper {
position: fixed;
left: -20000px;
top: 0;
pointer-events: none;
z-index: -1;
}
.o-tie-export-layer {
position: relative;
overflow: visible;
}
.o-tie-export-edges {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
.o-tie-export-nodes {
position: absolute;
top: 0;
left: 0;
overflow: visible;
}
.o-tie-export-stage {
position: absolute;
overflow: visible;
}
.o-tie-inspector-notes-resize {
height: 0;
}
/* Root view */
@ -366,15 +408,16 @@
position: absolute;
overflow: visible;
cursor: pointer;
z-index: 1;
z-index: 2;
isolation: isolate;
}
.o-tie-node-wrap:hover {
z-index: 2;
z-index: 3;
}
.o-tie-node-wrap.o-tie-node-selected {
z-index: 3;
z-index: 4;
}
.o-tie-node {
@ -545,11 +588,12 @@
padding: 0 8px;
font-size: 10px;
font-weight: 600;
line-height: 1.2;
line-height: 1;
color: #fff;
border-top: 1px solid rgba(0, 0, 0, 0.08);
overflow: hidden;
flex-shrink: 0;
flex: 0 0 auto;
box-sizing: border-box;
}
.o-tie-stack-row-light {
@ -570,6 +614,10 @@
}
.o-tie-stack-row-label {
display: flex;
align-items: center;
align-self: stretch;
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -849,7 +897,7 @@
background: var(--o-tie-barrier-bg);
color: var(--o-tie-barrier);
cursor: pointer;
z-index: 15;
z-index: 1;
pointer-events: auto;
opacity: 0.85;
-webkit-appearance: none;

View file

@ -11,7 +11,8 @@
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"resolveJsonModule": true,
"lib": ["DOM", "ES6"]
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "manifest.json"]
}

View file

@ -1,3 +1,4 @@
{
"1.0.0": "1.4.0"
"1.0.0": "1.4.0",
"1.0.1": "1.4.0"
}