Initial release v1.0.0: O-Tie bowtie diagram plugin for Obsidian.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andre482 2026-06-12 14:59:23 +03:00
commit 14241b0423
26 changed files with 6053 additions and 0 deletions

26
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: Release
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: |
main.js
manifest.json
styles.css

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
main.js
main.js.map
.DS_Store
*.log

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Andre482
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

102
README.md Normal file
View file

@ -0,0 +1,102 @@
# O-Tie
Build and edit **risk bowtie diagrams** in Obsidian with an interactive visual editor. O-Tie stores diagrams as `.bowtie` JSON files in your vault and auto-saves as you work.
Inspired by the layout and workflow of [Presight OpenRisk](https://openrisk.presight.com/). O-Tie is an independent project and is not affiliated with Presight.
## Features
- Interactive bowtie editor with fan-in/fan-out layout
- Threats, prevention barriers, top event, mitigation barriers, consequences, and hazard
- Escalation factors and escalation barriers
- Per-barrier analysis stacks (type, effectiveness, criticality, and custom rows)
- Toolbar: add elements, undo/redo, fit, zoom, PNG export, help
- Inspector panel for label, notes, and delete
- Pan and zoom on the canvas
- Auto-save to `.bowtie` files
## Bowtie structure
```
Threats → Prevention Barriers → Top Event → Mitigation Barriers → Consequences
Hazard
```
## Installation
### From Obsidian Community Plugins
1. Open **Settings → Community plugins**.
2. Turn off **Restricted mode** if needed.
3. Click **Browse**, search for **O-Tie**, and install.
4. Enable the plugin.
### Manual installation
1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/Andre482/O-tie/releases).
2. Copy them into `<vault>/.obsidian/plugins/o-tie/`.
3. Reload Obsidian and enable **O-Tie** under **Settings → Community plugins**.
## Usage
1. Click the **bowtie** ribbon icon or run **O-Tie: Create new bowtie**.
2. Enter a name — a `.bowtie` file opens in the editor.
3. Edit on the diagram:
- **Toolbar**: add threat, consequence, or barrier; fit; zoom; export; help
- **Double-click** a node or title to rename
- **Click** a node to inspect it in the bottom panel
- **Right-click** for context menus
- **Hover** nodes for quick add/delete buttons
- **Drag** empty canvas space to pan; **scroll** to zoom
- **Delete** removes the selected node
- **Ctrl+Z** / **Ctrl+Y** for undo and redo
4. Changes save automatically.
## Settings
Open **Settings → O-Tie** to configure:
- Default folder for new bowties
- Column gap, row gap, node width, and node height
## Commands
| Command | Description |
|---------|-------------|
| Create new bowtie | Create a new `.bowtie` file |
| Open bowtie file | Open the active `.bowtie` file in the editor |
| Export bowtie as image | Export the diagram as PNG |
## File format
`.bowtie` files are JSON. Example:
```json
{
"name": "Defective Steamcracker",
"hazard": "High pressure ethylene",
"topEvent": "Loss of containment",
"threats": [{ "label": "Corrosion", "preventionBarriers": [] }],
"consequences": [{ "label": "Fire", "mitigationBarriers": [] }],
"view": { "zoom": 1, "panX": 0, "panY": 0 }
}
```
See [examples/steamcracker.bowtie](examples/steamcracker.bowtie).
## Development
```bash
npm install
npm run dev # watch mode
npm run build # production build
```
## Third-party licenses
This plugin bundles [html-to-image](https://github.com/bubkoo/html-to-image) (MIT) for PNG export.
## License
MIT — see [LICENSE](LICENSE).

19
deploy.mjs Normal file
View file

@ -0,0 +1,19 @@
import { copyFileSync, existsSync, mkdirSync } from "fs";
import { join } from "path";
const files = ["main.js", "styles.css", "manifest.json"];
const vaults = [
"C:/Users/User/OneDrive/Obsidian/Andre482/.obsidian/plugins/o-tie",
"C:/Users/User/OneDrive/Obsidian/.obsidian/plugins/o-tie",
"C:/Users/User/OneDrive/Obsidian/Bowties/.obsidian/plugins/o-tie",
];
for (const vault of vaults) {
if (!existsSync(vault)) {
mkdirSync(vault, { recursive: true });
}
for (const file of files) {
copyFileSync(join(process.cwd(), file), join(vault, file));
}
console.log(`Deployed to ${vault}`);
}

28
deploy.ps1 Normal file
View file

@ -0,0 +1,28 @@
# Deploy O-Tie plugin to the active Obsidian vault
$vaultPath = "C:\Users\User\OneDrive\Obsidian"
$pluginDir = Join-Path $vaultPath ".obsidian\plugins\o-tie"
$sourceDir = $PSScriptRoot
Write-Host "Building plugin..."
Push-Location $sourceDir
npm run build
if ($LASTEXITCODE -ne 0) { Pop-Location; exit 1 }
Pop-Location
Write-Host "Deploying to $pluginDir ..."
New-Item -ItemType Directory -Force -Path $pluginDir | Out-Null
Copy-Item (Join-Path $sourceDir "main.js") -Destination $pluginDir -Force
Copy-Item (Join-Path $sourceDir "manifest.json") -Destination $pluginDir -Force
Copy-Item (Join-Path $sourceDir "styles.css") -Destination $pluginDir -Force
$communityPlugins = Join-Path $vaultPath ".obsidian\community-plugins.json"
$enabled = @("o-tie")
if (Test-Path $communityPlugins) {
$existing = Get-Content $communityPlugins -Raw | ConvertFrom-Json
foreach ($id in $existing) {
if ($id -ne "o-tie") { $enabled = @($id) + $enabled }
}
}
$enabled | ConvertTo-Json | Set-Content $communityPlugins -Encoding UTF8
Write-Host "Done. Reload Obsidian (Ctrl+R) to activate O-Tie."

48
esbuild.config.mjs Normal file
View file

@ -0,0 +1,48 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = process.argv[2] === "production";
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

474
examples/cl-contigo.bowtie Normal file
View file

@ -0,0 +1,474 @@
{
"id": "cl-contigo-me-hydraulic-pipe-failure",
"name": "CL Contigo - ME Hydraulic Pipe Failure",
"hazard": "High-pressure Main Engine hydraulic oil system (HPS & HCU piping inside the cam case) of the MAN B&W 5S50ME-B9.3 TII",
"topEvent": "Loss of containment of Main Engine hydraulic oil (failure/leakage of HPS & HCU pipes and securing arrangements in the cam case)",
"threats": [
{
"id": "threat-1",
"label": "Excessive 2nd-order vibration (RoT Compensator non-operational for a prolonged period)",
"notes": "5-cylinder MAN B&W 5S50ME-B9.3 TII is highly sensitive to 2nd-order vibration. RoT Compensator in steering gear room was found not working; axial compensator and two top bracings were operational but insufficient alone.",
"preventionBarriers": [
{
"id": "pb-1-1",
"label": "RoT Compensator / vibration damper operational",
"notes": "RoT Compensator was non-operational for a prolonged period. PMS recorded inspection on 23/02/2026 but did not note non-operational status. Equipment was not flagged as critical in PMS.",
"escalationFactors": [
{
"id": "ef-1-1-1",
"label": "RoT Compensator not flagged critical in PMS; no specific alarm / no HMI log",
"notes": "Alarm system only indicates common alarm at ME Operating Panel. HMI has no log functionality to review when system stopped.",
"escalationBarriers": [
{
"id": "eb-1-1-1",
"label": "Restore RoT Compensator; update PMS criticality and alarm/HMI",
"notes": "Preventive measure (Open, target 01-Jun-2026): restore RoT Compensator, verify during ME running, record in PMS. Restored to operational condition by 04/06/2026.",
"escalationFactors": [],
"stack": [
{
"id": "stack-eb-1-1-1-eff",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
}
],
"stackCollapsed": true
}
]
}
],
"stack": [
{
"id": "stack-pb-1-1-eff",
"field": "effectiveness",
"label": "Failed",
"color": "#c0392b"
}
],
"stackCollapsed": true
},
{
"id": "pb-1-2",
"label": "Axial compensator + two top bracings",
"notes": "Axial compensator and two port-side top bracings confirmed operational during investigation, but alone could not compensate for non-operational RoT Compensator.",
"escalationFactors": [],
"stack": [
{
"id": "stack-pb-1-2-eff",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
}
],
"stackCollapsed": true
},
{
"id": "pb-1-3",
"label": "PMS inspection of vibration-control equipment (4,000 hr)",
"notes": "Inspection job existed at 4,000-hour interval but failed to identify non-operational RoT Compensator.",
"escalationFactors": [
{
"id": "ef-1-3-1",
"label": "Repeated PSC findings with weak shore follow-up",
"notes": "Previous PSC findings and repeated technical deficiencies indicated pattern of weak follow-up and ineffective improvement of maintenance standards.",
"escalationBarriers": [
{
"id": "eb-1-3-1",
"label": "Shore management intervention and maker superintendent follow-up",
"notes": "Preventive measure (Open, target 15-Jun-2026): maker superintendent inspection covering vibration control, cam-case piping and PMS compliance.",
"escalationFactors": [],
"stack": [
{
"id": "stack-eb-1-3-1-eff",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
}
],
"stackCollapsed": true
}
]
},
{
"id": "ef-1-3-2",
"label": "VM inspections in port with ME not running",
"notes": "Three VM inspections since last dry dock (Apr 2025) could not detect RoT Comp issue because ME was not running and vibration-control equipment was not operational.",
"escalationBarriers": [
{
"id": "eb-1-3-2",
"label": "VM inspection scope includes ME-running vibration-control verification",
"notes": "Investigator note: quality of VM inspection for the period should be reviewed.",
"escalationFactors": [],
"stack": [
{
"id": "stack-eb-1-3-2-eff",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
}
],
"stackCollapsed": true
}
]
}
],
"stack": [
{
"id": "stack-pb-1-3-eff",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
}
],
"stackCollapsed": true
}
]
},
{
"id": "threat-2",
"label": "Low HPS nitrogen accumulator pressure (~40 bar vs 136 bar maker spec)",
"notes": "HPS nitrogen accumulator found with low pressure (~40 bar vs 136 bar maker requirement). Low accumulator pressure increases hydraulic pressure pulsations and contributed to pipe failure per Everllence.",
"preventionBarriers": [
{
"id": "pb-2-1",
"label": "Accumulator charge maintained per maker specification",
"notes": "Accumulator pre-charge was not maintained at maker-required level. Confirmed by manufacturer as contributing factor.",
"escalationFactors": [],
"stack": [
{
"id": "stack-pb-2-1-eff",
"field": "effectiveness",
"label": "Failed",
"color": "#c0392b"
}
],
"stackCollapsed": true
},
{
"id": "pb-2-2",
"label": "Periodic accumulator pressure check and recharge",
"notes": "No effective trending or monthly verification of HPS/HCU nitrogen accumulator pre-charge pressure in PMS prior to incident.",
"escalationFactors": [
{
"id": "ef-2-2-1",
"label": "No PMS trending of HPS/HCU N2 accumulator pre-charge pressure",
"notes": "Maker requires pre-charge per specification with temperature correction where applicable.",
"escalationBarriers": [
{
"id": "eb-2-2-1",
"label": "Monthly accumulator pre-charge check, trending and VM verification",
"notes": "Preventive measure (Open, target 01-Jun-2026): record results in PMS and send to VM for monthly verification.",
"escalationFactors": [],
"stack": [
{
"id": "stack-eb-2-2-1-eff",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
}
],
"stackCollapsed": true
}
]
}
],
"stack": [
{
"id": "stack-pb-2-2-eff",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
}
],
"stackCollapsed": true
}
]
},
{
"id": "threat-3",
"label": "Progressive deterioration / loosening of pipe supports and brackets",
"notes": "Increased vibration caused bolts of support brackets to loosen, further increasing vibration levels in hydraulic pipes until failure. Everllence identified vibration as main cause of pipe failures.",
"preventionBarriers": [
{
"id": "pb-3-1",
"label": "Cam-case inspection incl. pipe supports/brackets (PMS revised monthly)",
"notes": "Pre-incident 2,000-hour scope did not include torque verification of supports/brackets. PMS revised post-incident to monthly inspection with maker torque values (Closed 15-Apr-2026).",
"escalationFactors": [
{
"id": "ef-3-1-1",
"label": "Continuity weakness of ship/shore teams (manning, crew turnover)",
"notes": "No stable back-to-back crew arrangement; varying backgrounds reduced vessel-specific knowledge and ability to identify recurring defects.",
"escalationBarriers": [
{
"id": "eb-3-1-1",
"label": "Senior-officer screening and crew-continuity management",
"notes": "Fleet-wide manning review for technically sensitive vessels recommended.",
"escalationFactors": [],
"stack": [
{
"id": "stack-eb-3-1-1-eff",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
}
],
"stackCollapsed": true
}
]
}
],
"stack": [
{
"id": "stack-pb-3-1-eff",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
}
],
"stackCollapsed": true
},
{
"id": "pb-3-2",
"label": "Correct torque tightening of securing bolts (torque spanner)",
"notes": "Chief Engineer failed to ensure proper technical control during repairs. Torque values not applied; hand spanners used instead of torque spanners, leading to inadequately secured connections.",
"escalationFactors": [
{
"id": "ef-3-2-1",
"label": "Chief Engineer supervision / technical-control deficiency",
"notes": "CE had history of poor performance feedback. Concerns regarding supervision, quality of repairs, and control of pipe installation during incident.",
"escalationBarriers": [
{
"id": "eb-3-2-1",
"label": "Technical supervision capability and CE appraisal follow-up",
"notes": "Lessons learned: crew competence and supervision directly impact technical reliability.",
"escalationFactors": [],
"stack": [
{
"id": "stack-eb-3-2-1-eff",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
}
],
"stackCollapsed": true
}
]
}
],
"stack": [
{
"id": "stack-pb-3-2-eff",
"field": "effectiveness",
"label": "Failed",
"color": "#c0392b"
}
],
"stackCollapsed": true
},
{
"id": "pb-3-3",
"label": "Robust securing arrangements and material standards",
"notes": "Lock washers appeared installed on support bracket per manufacturer picture, but progressive deterioration of supports, brackets, retaining rings and securing arrangements continued.",
"escalationFactors": [],
"stack": [
{
"id": "stack-pb-3-3-eff",
"field": "effectiveness",
"label": "Poor",
"color": "#e67e22"
}
],
"stackCollapsed": true
}
]
}
],
"consequences": [
{
"id": "consequence-1",
"label": "Loss of propulsion and vessel drifting ~245 NM off the Brazilian coast (grounding/collision risk)",
"notes": "At 04:00 on 18/03/2026, vessel lost propulsion while drifting ~245 NM off Brazil. Calm weather and distance from land reduced immediate grounding/collision risk.",
"mitigationBarriers": [
{
"id": "mb-1-1",
"label": "AMS low-hydraulic-pressure alarm detection and engine-room crew response",
"notes": "Low hydraulic pressure alarms triggered repeated ME shutdowns. Crew responded to alarms and attempted repairs.",
"escalationFactors": [],
"stack": [
{
"id": "stack-mb-1-1-eff",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
}
],
"stackCollapsed": true
},
{
"id": "mb-1-2",
"label": "Temporary onboard repairs to restore short-term propulsion",
"notes": "Situational violation: crew used welding, fabricated collars and improvised gaskets due to loss of propulsion pressure and no genuine spares. Repairs failed under operating pressure and vibration.",
"escalationFactors": [
{
"id": "ef-mb-1-2-1",
"label": "No genuine spares on board (pipes, brackets, retaining rings, sealing parts not on critical spares list)",
"notes": "Investigation: spare pipes not required on maker/SMS minimum list, but operational risk justified carrying critical spares.",
"escalationBarriers": [
{
"id": "eb-mb-1-2-1",
"label": "Review and update critical spare-parts list (MAN ME/ME-B)",
"notes": "Fleet-wide review of hydraulic pipes, supports and sealing hardware for MAN ME/ME-B engines.",
"escalationFactors": [],
"stack": [
{
"id": "stack-eb-mb-1-2-1-eff",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
}
],
"stackCollapsed": true
}
]
}
],
"stack": [
{
"id": "stack-mb-1-2-eff",
"field": "effectiveness",
"label": "Poor",
"color": "#e67e22"
}
],
"stackCollapsed": true
},
{
"id": "mb-1-3",
"label": "ERT activation + shore/maker technical support (Everllence, Hitachi-MAN)",
"notes": "ERT activated at 04:30. Everllence and Hitachi-MAN service engineers attended. Shore management and technical department provided remote support.",
"escalationFactors": [],
"stack": [
{
"id": "stack-mb-1-3-eff",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
}
],
"stackCollapsed": true
},
{
"id": "mb-1-4",
"label": "Position/drift-rate monitoring, calm weather, distance from land",
"notes": "Investigation confirmed weather conditions did not contribute to incident. Good visibility, moderate sea state, vessel rolling but no precipitation.",
"escalationFactors": [],
"stack": [
{
"id": "stack-mb-1-4-eff",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
}
],
"stackCollapsed": true
}
]
},
{
"id": "consequence-2",
"label": "Repeated Main Engine shutdowns / prolonged unreliable propulsion",
"notes": "Hydraulic oil leakages caused low-pressure alarms, repeated ME shutdowns, and eventual total loss of propulsion on 18/03/2026.",
"mitigationBarriers": [
{
"id": "mb-2-1",
"label": "Operate on single HPS pump at reduced RPM and lowered pressure set point",
"notes": "Crew attempted to manage system by operating on single HPS pump with reduced RPM and lowered pressure set point. Temporary measure only.",
"escalationFactors": [],
"stack": [
{
"id": "stack-mb-2-1-eff",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
}
],
"stackCollapsed": true
},
{
"id": "mb-2-2",
"label": "Sea trials and hydraulic system verification after repair",
"notes": "Sea trials conducted after repairs at Sao Luis. NAV 024 risk assessment template applies to sea trials after main engine repair.",
"escalationFactors": [],
"stack": [
{
"id": "stack-mb-2-2-eff",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
}
],
"stackCollapsed": true
}
]
},
{
"id": "consequence-3",
"label": "External towage, operational disruption, ~USD 318,398 cost and reputational impact",
"notes": "Vessel towed to Sao Luis by tugs Svitzer Denise and Joaquim. Total estimated cost USD 318,398 (11 days delay, materials, miscellaneous). Reputational impact on company.",
"mitigationBarriers": [
{
"id": "mb-3-1",
"label": "Emergency Towing Booklet and tug arrangement (Svitzer Denise / Joaquim)",
"notes": "Towage commenced 19/03/2026. NAV 029 risk assessment template applies to towing operations.",
"escalationFactors": [],
"stack": [
{
"id": "stack-mb-3-1-eff",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
}
],
"stackCollapsed": true
},
{
"id": "mb-3-2",
"label": "Flag/Class notification, dispensation and surveyor attendance",
"notes": "Marshall Islands flag and Class notified. Dispensation granted for single voyage to Sao Luis with surveyor attendance.",
"escalationFactors": [],
"stack": [
{
"id": "stack-mb-3-2-eff",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
}
],
"stackCollapsed": true
},
{
"id": "mb-3-3",
"label": "Permanent repairs with genuine spares at Sao Luis",
"notes": "Permanent repairs completed at Sao Luis with genuine spare parts. RoT Compensator restored to operational condition by 04/06/2026.",
"escalationFactors": [],
"stack": [
{
"id": "stack-mb-3-3-eff",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
}
],
"stackCollapsed": true
}
]
}
],
"view": {
"zoom": 1,
"panX": 0,
"panY": 0
},
"createdAt": "2026-06-10T00:00:00.000Z",
"updatedAt": "2026-06-11T07:12:25.997Z"
}

View file

@ -0,0 +1,67 @@
{
"id": "example-steamcracker",
"name": "Defective Steamcracker",
"hazard": "High pressure ethylene cracking system",
"topEvent": "Loss of containment in steamcracker furnace",
"threats": [
{
"id": "threat-1",
"label": "Tube corrosion / fatigue",
"preventionBarriers": [
{
"id": "pb-1",
"label": "Periodic tube inspection program",
"escalationFactors": []
},
{
"id": "pb-2",
"label": "Material selection standards",
"escalationFactors": []
}
]
},
{
"id": "threat-2",
"label": "Operator error during startup",
"preventionBarriers": [
{
"id": "pb-3",
"label": "Standard operating procedures",
"escalationFactors": []
}
]
}
],
"consequences": [
{
"id": "consequence-1",
"label": "Fire / explosion",
"mitigationBarriers": [
{
"id": "mb-1",
"label": "Deluge and fire suppression systems",
"escalationFactors": []
}
]
},
{
"id": "consequence-2",
"label": "Toxic gas release",
"mitigationBarriers": [
{
"id": "mb-2",
"label": "Gas detection and isolation",
"escalationFactors": []
},
{
"id": "mb-3",
"label": "Emergency response plan",
"escalationFactors": []
}
]
}
],
"view": { "zoom": 1, "panX": 0, "panY": 0 },
"createdAt": "2026-06-09T00:00:00.000Z",
"updatedAt": "2026-06-09T00:00:00.000Z"
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "o-tie",
"name": "O-Tie",
"version": "1.0.0",
"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",
"authorUrl": "https://github.com/Andre482",
"isDesktopOnly": false
}

620
package-lock.json generated Normal file
View file

@ -0,0 +1,620 @@
{
"name": "o-tie",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "o-tie",
"version": "1.0.0",
"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",
"typescript": "^5.3.3"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/codemirror": {
"version": "5.60.8",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
"integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/tern": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.42",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz",
"integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@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",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/esbuild": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.20.2",
"@esbuild/android-arm": "0.20.2",
"@esbuild/android-arm64": "0.20.2",
"@esbuild/android-x64": "0.20.2",
"@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.20.2",
"@esbuild/freebsd-arm64": "0.20.2",
"@esbuild/freebsd-x64": "0.20.2",
"@esbuild/linux-arm": "0.20.2",
"@esbuild/linux-arm64": "0.20.2",
"@esbuild/linux-ia32": "0.20.2",
"@esbuild/linux-loong64": "0.20.2",
"@esbuild/linux-mips64el": "0.20.2",
"@esbuild/linux-ppc64": "0.20.2",
"@esbuild/linux-riscv64": "0.20.2",
"@esbuild/linux-s390x": "0.20.2",
"@esbuild/linux-x64": "0.20.2",
"@esbuild/netbsd-x64": "0.20.2",
"@esbuild/openbsd-x64": "0.20.2",
"@esbuild/sunos-x64": "0.20.2",
"@esbuild/win32-arm64": "0.20.2",
"@esbuild/win32-ia32": "0.20.2",
"@esbuild/win32-x64": "0.20.2"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/obsidian": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.0.tgz",
"integrity": "sha512-PHw5+SAPlJ0S3leFvJ0wgFg63Z3DavxL6+d1ll+8toXR2ZlYKc1rMWqdUv9LgUbTwPQUyY6yfhOMMivampRRiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/codemirror": "5.60.8",
"moment": "2.29.4"
},
"peerDependencies": {
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.38.6"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT",
"peer": true
}
}
}

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "o-tie",
"version": "1.0.0",
"description": "Obsidian plugin for building risk bowtie diagrams",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"deploy": "npm run build && node deploy.mjs",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
"obsidian",
"bowtie",
"risk"
],
"license": "MIT",
"devDependencies": {
"@types/node": "^20.11.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.20.0",
"obsidian": "latest",
"tslib": "^2.6.2",
"typescript": "^5.3.3"
},
"dependencies": {
"html-to-image": "^1.11.13"
}
}

1719
src/bowtieView.ts Normal file

File diff suppressed because it is too large Load diff

51
src/editorModal.ts Normal file
View file

@ -0,0 +1,51 @@
import { App, Modal, Notice, Setting } from "obsidian";
export class NewBowtieNameModal extends Modal {
private onSubmit: (name: string) => void;
constructor(app: App, onSubmit: (name: string) => void) {
super(app);
this.onSubmit = onSubmit;
}
onOpen(): void {
const { contentEl, modalEl } = this;
modalEl.addClass("o-tie-modal");
contentEl.createEl("h2", { text: "Create New Bowtie" });
new Setting(contentEl)
.setName("Bowtie name")
.setDesc("Used for the file name and diagram title")
.addText((text) => {
text.setPlaceholder("e.g. Defective Steamcracker");
text.inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") this.submit(text.getValue());
});
setTimeout(() => text.inputEl.focus(), 50);
});
const actions = contentEl.createDiv({ cls: "o-tie-actions" });
const cancelBtn = actions.createEl("button", { text: "Cancel" });
cancelBtn.addEventListener("click", () => this.close());
const createBtn = actions.createEl("button", { text: "Create", cls: "mod-cta" });
createBtn.addEventListener("click", () => {
const input = contentEl.querySelector("input");
this.submit(input?.value ?? "");
});
}
private submit(name: string): void {
const trimmed = name.trim();
if (!trimmed) {
new Notice("Please enter a bowtie name.");
return;
}
this.onSubmit(trimmed);
this.close();
}
onClose(): void {
this.contentEl.empty();
}
}

282
src/exportImage.ts Normal file
View file

@ -0,0 +1,282 @@
import { toBlob } from "html-to-image";
export type BowtieExportArea = "full" | "viewport";
export interface BowtieExportViewport {
x: number;
y: number;
width: number;
height: number;
}
export interface BowtieExportOptions {
svgEl: SVGSVGElement;
nodesEl: HTMLElement;
viewRootEl: HTMLElement;
bounds: { width: number; height: number };
area: BowtieExportArea;
scale: number;
showGrid: boolean;
viewport?: BowtieExportViewport;
backgroundColor: string;
}
const EXPORT_PADDING = 24;
const MAX_EXPORT_DIMENSION = 16384;
const GRID_SIZE = 20;
const EXPORT_CHROME_SELECTORS = [
".o-tie-node-delete",
".o-tie-node-add-barrier",
".o-tie-node-add-escalation",
".o-tie-node-add-esc-barrier",
".o-tie-stack-add",
".o-tie-stack-chevron",
".o-tie-lane-add",
"button",
].join(", ");
function copyThemeVariables(from: HTMLElement, to: HTMLElement): void {
const styles = getComputedStyle(from);
for (let i = 0; i < styles.length; i++) {
const name = styles[i];
if (
name.startsWith("--o-tie") ||
name.startsWith("--font") ||
name.startsWith("--interactive") ||
name.startsWith("--radius")
) {
to.style.setProperty(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");
}
function prepareExportClone(clone: HTMLElement): void {
clone.querySelectorAll(EXPORT_CHROME_SELECTORS).forEach((el) => el.remove());
clone.querySelectorAll(".o-tie-node-selected").forEach((el) => {
el.removeClass("o-tie-node-selected");
});
clone.querySelectorAll(".o-tie-stack-empty").forEach((el) => el.remove());
}
function buildGridBackground(color: string, dotColor: string): string {
return `radial-gradient(circle, ${dotColor} 1px, transparent 1px), ${color}`;
}
function resolveSvgStyles(svg: SVGSVGElement): SVGSVGElement {
const clone = svg.cloneNode(true) as SVGSVGElement;
const sourceEls = [svg, ...Array.from(svg.querySelectorAll<SVGElement>("*"))];
const cloneEls = [clone, ...Array.from(clone.querySelectorAll<SVGElement>("*"))];
const shapeTags = new Set([
"path",
"line",
"polyline",
"polygon",
"rect",
"circle",
"ellipse",
]);
for (let i = 0; i < sourceEls.length && i < cloneEls.length; i++) {
const el = cloneEls[i];
if (!shapeTags.has(el.tagName.toLowerCase())) continue;
const computed = getComputedStyle(sourceEls[i]);
const stroke = computed.stroke;
const fill = computed.fill;
const strokeWidth = computed.strokeWidth;
const strokeDasharray = computed.strokeDasharray;
const strokeLinecap = computed.strokeLinecap;
const strokeLinejoin = computed.strokeLinejoin;
// Always write fill/stroke explicitly: the standalone export SVG has no
// stylesheet, so unset shapes would default to fill:black (filled blobs).
el.setAttribute("fill", fill || "none");
el.setAttribute("stroke", stroke || "none");
if (strokeWidth) el.setAttribute("stroke-width", strokeWidth);
if (strokeDasharray && strokeDasharray !== "none") {
el.setAttribute("stroke-dasharray", strokeDasharray);
}
if (strokeLinecap) el.setAttribute("stroke-linecap", strokeLinecap);
if (strokeLinejoin) el.setAttribute("stroke-linejoin", strokeLinejoin);
}
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
return clone;
}
function svgToDataUrl(svg: SVGSVGElement): string {
const resolved = resolveSvgStyles(svg);
const serialized = new XMLSerializer().serializeToString(resolved);
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}`;
}
function buildDiagramLayer(
svgEl: SVGSVGElement,
nodesEl: HTMLElement,
bounds: { width: number; height: number }
): HTMLElement {
const layer = document.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";
const edgeImg = document.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";
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";
layer.appendChild(nodesClone);
return layer;
}
async function domToPng(
element: HTMLElement,
width: number,
height: number,
scale: number,
backgroundColor: string
): Promise<Blob> {
const outW = Math.min(Math.round(width * scale), MAX_EXPORT_DIMENSION);
const outH = Math.min(Math.round(height * scale), MAX_EXPORT_DIMENSION);
if (outW <= 0 || outH <= 0) {
throw new Error("Export dimensions are too small.");
}
const blob = await toBlob(element, {
width,
height,
pixelRatio: scale,
cacheBust: true,
skipAutoScale: true,
includeQueryParams: false,
backgroundColor,
style: {
margin: "0",
padding: "0",
left: "0",
top: "0",
position: "relative",
transform: "none",
opacity: "1",
visibility: "visible",
},
});
if (!blob) {
throw new Error("PNG export failed.");
}
return blob;
}
export async function rasterizeBowtieForExport(options: BowtieExportOptions): Promise<Blob> {
const { svgEl, nodesEl, viewRootEl, bounds, area, scale, showGrid, viewport, backgroundColor } =
options;
const crop = area === "viewport" ? viewport : undefined;
const contentWidth = crop ? crop.width : bounds.width;
const contentHeight = crop ? crop.height : bounds.height;
const exportWidth = contentWidth + EXPORT_PADDING * 2;
const exportHeight = contentHeight + EXPORT_PADDING * 2;
// 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 root = document.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`;
copyThemeVariables(viewRootEl, root);
const bgStyles = getComputedStyle(viewRootEl);
const dotColor =
bgStyles.getPropertyValue("--background-modifier-border").trim() || "#d0d0d0";
root.style.backgroundColor = 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`;
}
const stage = document.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.appendChild(buildDiagramLayer(svgEl, nodesEl, bounds));
root.appendChild(stage);
wrapper.appendChild(root);
document.body.appendChild(wrapper);
try {
await document.fonts.ready;
await Promise.all(
Array.from(root.querySelectorAll<HTMLImageElement>("img.o-tie-export-edges")).map(
(img) => {
if (img.complete) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("Failed to render diagram edges."));
});
}
)
);
return await domToPng(root, exportWidth, exportHeight, scale, backgroundColor);
} finally {
wrapper.remove();
}
}
export function downloadPng(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename.endsWith(".png") ? filename : `${filename}.png`;
anchor.click();
URL.revokeObjectURL(url);
}
export function sanitizeExportFilename(name: string): string {
const trimmed = name.trim() || "bowtie";
return trimmed.replace(/[\\/:*?"<>|]/g, "-");
}

145
src/exportImageModal.ts Normal file
View file

@ -0,0 +1,145 @@
import { App, Modal, Notice, Setting } from "obsidian";
import {
downloadPng,
rasterizeBowtieForExport,
sanitizeExportFilename,
type BowtieExportArea,
type BowtieExportViewport,
} from "./exportImage";
export interface BowtieExportRequest {
svgEl: SVGSVGElement;
nodesEl: HTMLElement;
viewRootEl: HTMLElement;
bounds: { width: number; height: number };
viewport?: BowtieExportViewport;
bowtieName: string;
onExpandStacks?: () => Promise<void>;
onRestoreStacks?: () => void;
}
export class ExportImageModal extends Modal {
private readonly request: BowtieExportRequest;
private area: BowtieExportArea = "full";
private scale = 2;
private showGrid = true;
private expandStacks = true;
private isExporting = false;
constructor(app: App, request: BowtieExportRequest) {
super(app);
this.request = request;
}
onOpen(): void {
const { contentEl, modalEl } = this;
modalEl.addClass("o-tie-modal", "o-tie-export-modal");
contentEl.createEl("h2", { text: "Export as image" });
contentEl.createEl("p", {
cls: "o-tie-export-desc",
text: "Save the bowtie diagram as a high-resolution PNG. Increase zoom for sharper text and details.",
});
new Setting(contentEl)
.setName("Area")
.setDesc("Export the full diagram or only the visible canvas area")
.addDropdown((dropdown) =>
dropdown
.addOption("full", "Full diagram")
.addOption("viewport", "Visible area")
.setValue(this.area)
.onChange((value) => {
this.area = value as BowtieExportArea;
})
);
new Setting(contentEl)
.setName("Zoom")
.setDesc(`${this.scale}x — higher values produce a larger, sharper image`)
.addSlider((slider) =>
slider
.setLimits(1, 4, 0.5)
.setValue(this.scale)
.setDynamicTooltip()
.onChange((value) => {
this.scale = value;
})
);
new Setting(contentEl)
.setName("Show grid")
.setDesc("Include the dotted canvas background")
.addToggle((toggle) =>
toggle.setValue(this.showGrid).onChange((value) => {
this.showGrid = value;
})
);
new Setting(contentEl)
.setName("Expand barrier stacks")
.setDesc("Show all barrier analysis rows in the export")
.addToggle((toggle) =>
toggle.setValue(this.expandStacks).onChange((value) => {
this.expandStacks = value;
})
);
const actions = contentEl.createDiv({ cls: "o-tie-actions" });
const cancelBtn = actions.createEl("button", { text: "Cancel" });
cancelBtn.addEventListener("click", () => this.close());
const exportBtn = actions.createEl("button", {
text: "Export",
cls: "mod-cta",
});
exportBtn.addEventListener("click", () => void this.exportImage(exportBtn));
}
private async exportImage(exportBtn: HTMLButtonElement): Promise<void> {
if (this.isExporting) return;
this.isExporting = true;
exportBtn.disabled = true;
exportBtn.setText("Exporting…");
const backgroundColor =
getComputedStyle(this.request.viewRootEl).backgroundColor || "#ffffff";
try {
if (this.expandStacks && this.request.onExpandStacks) {
await this.request.onExpandStacks();
}
const blob = await rasterizeBowtieForExport({
svgEl: this.request.svgEl,
nodesEl: this.request.nodesEl,
viewRootEl: this.request.viewRootEl,
bounds: this.request.bounds,
area: this.area,
scale: this.scale,
showGrid: this.showGrid,
viewport: this.area === "viewport" ? this.request.viewport : undefined,
backgroundColor,
});
const filename = `${sanitizeExportFilename(this.request.bowtieName)}.png`;
downloadPng(blob, filename);
new Notice(`Exported ${filename}`);
this.close();
} catch (error) {
const message = error instanceof Error ? error.message : "Export failed.";
new Notice(`Export failed: ${message}`);
} finally {
if (this.expandStacks && this.request.onRestoreStacks) {
this.request.onRestoreStacks();
}
this.isExporting = false;
exportBtn.disabled = false;
exportBtn.setText("Export");
}
}
onClose(): void {
this.contentEl.empty();
}
}

117
src/helpModal.ts Normal file
View file

@ -0,0 +1,117 @@
import { App, Modal } from "obsidian";
const PLUGIN_VERSION = "1.0.0";
export class HelpModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen(): void {
const { contentEl, modalEl } = this;
modalEl.addClass("o-tie-modal", "o-tie-help-modal");
contentEl.createEl("h2", { text: "O-Tie Help" });
contentEl.createEl("p", {
cls: "o-tie-help-version",
text: `Version ${PLUGIN_VERSION}`,
});
const body = contentEl.createDiv({ cls: "o-tie-help-body" });
this.addSection(body, "About O-Tie", [
"O-Tie is an Obsidian plugin for building risk bowtie diagrams with an interactive visual editor.",
"Inspired by Presight OpenRisk. Diagrams are stored as .bowtie JSON files in your vault and auto-save as you edit.",
]);
this.addSection(body, "Getting started", [
"Click the ribbon icon or run O-Tie: Create new bowtie from the command palette.",
"Enter a name — a .bowtie file is created and opens in the editor.",
"Open any existing .bowtie file from your vault to continue editing.",
"All changes save automatically to the file.",
]);
body.createEl("h3", { text: "Bowtie structure" });
body.createEl("pre", {
cls: "o-tie-help-diagram",
text: [
"Threats → Prevention Barriers → Top Event → Mitigation Barriers → Consequences",
" ↑",
" Hazard",
].join("\n"),
});
this.addList(body, [
"Hazard (gold) — the source of risk, shown above the top event",
"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",
]);
this.addSection(body, "Editing", [
"Use the toolbar to add threats, consequences, and barriers.",
"Double-click any node or the diagram title to rename inline.",
"Click a node to select it — the inspector bar at the bottom shows label, notes, and actions.",
"Right-click nodes or the canvas for a context menu.",
"Hover nodes for quick buttons: add barrier, add escalation, or delete.",
"Press Delete to remove the selected node.",
"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.",
"Add custom rows with editable labels and colors.",
"Click stack rows to change preset values; expand or collapse the stack with the header toggle.",
]);
this.addSection(body, "Navigation", [
"Drag empty canvas space to pan.",
"Scroll the mouse wheel to zoom (hold Ctrl for finer control).",
"Use Fit to center and scale the diagram to the visible area.",
"Collapse the toolbar with the chevron button; click the floating button on the canvas to show it again.",
]);
this.addSection(body, "Undo and redo", [
"Use the ← and → toolbar buttons, or Ctrl+Z to undo and Ctrl+Y (or Ctrl+Shift+Z) to redo.",
]);
this.addSection(body, "Export", [
"Click the download icon in the toolbar, or right-click the canvas and choose Export as image.",
"Exports a high-resolution PNG with options for full diagram or visible area, zoom level, grid, and expanded barrier stacks.",
]);
this.addSection(body, "Settings", [
"Open Settings → O-Tie to configure the default folder for new bowties and layout spacing (column gap, row gap, node width, node height).",
]);
const actions = contentEl.createDiv({ cls: "o-tie-actions" });
const closeBtn = actions.createEl("button", { text: "Close", cls: "mod-cta" });
closeBtn.addEventListener("click", () => this.close());
}
private addSection(parent: HTMLElement, title: string, paragraphs: string[]): void {
parent.createEl("h3", { text: title });
for (const text of paragraphs) {
parent.createEl("p", { text });
}
}
private addList(parent: HTMLElement, items: string[]): void {
const ul = parent.createEl("ul");
for (const text of items) {
ul.createEl("li", { text });
}
}
onClose(): void {
this.contentEl.empty();
}
}

36
src/icons.ts Normal file
View file

@ -0,0 +1,36 @@
import { addIcon } from "obsidian";
/** Custom icon used for the ribbon, file tabs, and commands. */
export const PLUGIN_ICON = "o-tie-bowtie";
/**
* Bowtie risk diagram icon (100×100 viewBox per Obsidian addIcon spec).
*
* Design based on the industry-standard bowtie method (Wolters Kluwer / ICH):
* - Centre circle = top event (the "knot")
* - Left wing = threats & preventive barriers
* - Right wing = consequences & mitigating barriers
*
* Follows Lucide guidelines scaled to 100: ~8 stroke, round caps/joins, 4px padding.
* SVG content only no outer <svg> tag (Obsidian wraps it internally).
*/
const BOWTIE_ICON_SVG = `
<circle cx="50" cy="50" r="10" fill="currentColor" stroke="none"/>
<path d="M50 50 L12 26 L12 74 Z" fill="none" stroke="currentColor" stroke-width="8" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M50 50 L88 26 L88 74 Z" fill="none" stroke="currentColor" stroke-width="8" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M30 50 H38" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round"/>
<path d="M62 50 H70" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round"/>
`.trim();
let registered = false;
export function registerPluginIcon(): void {
if (registered) return;
addIcon(PLUGIN_ICON, BOWTIE_ICON_SVG);
registered = true;
}
/** Make the ribbon button icon slightly larger than default. */
export function styleRibbonIcon(el: HTMLElement): void {
el.style.setProperty("--icon-size", "var(--icon-size-l)");
}

709
src/layout.ts Normal file
View file

@ -0,0 +1,709 @@
import type { Barrier, Bowtie, Consequence, Threat } from "./model";
import type { NodeKind, NodeRef } from "./model";
export interface LayoutConfig {
nodeWidth: number;
nodeHeight: number;
hazardHeight: number;
barrierWidth: number;
barrierHeight: number;
barrierHeaderHeight: number;
barrierStackRowHeight: number;
escalationWidth: number;
escalationHeight: number;
columnGap: number;
rowGap: number;
barrierGap: number;
escalationOffsetY: number;
padding: number;
}
export const DEFAULT_LAYOUT: LayoutConfig = {
nodeWidth: 200,
nodeHeight: 72,
hazardHeight: 56,
barrierWidth: 140,
barrierHeight: 52,
barrierHeaderHeight: 52,
barrierStackRowHeight: 22,
escalationWidth: 130,
escalationHeight: 44,
columnGap: 100,
rowGap: 48,
barrierGap: 24,
escalationOffsetY: 70,
padding: 80,
};
const LABEL_LINE_HEIGHT = 16;
const LABEL_PAD_Y = 22;
const LABEL_PAD_X = 16;
const STRIPE_HEIGHT = 18;
const AVG_CHAR_WIDTH = 5.4;
const BARRIER_HEADER_BUFFER = 8;
const NODE_BOX_BUFFER = 8;
const ESCALATION_NODE_BUFFER = 6;
const ESCALATION_GAP_FACTOR = 0.5;
function estimateLabelLines(label: string, width: number): number {
const text = label?.trim() ?? "";
if (!text) return 1;
const usable = Math.max(20, width - LABEL_PAD_X);
const charsPerLine = Math.max(6, Math.floor(usable / AVG_CHAR_WIDTH));
const words = text.split(/\s+/).filter(Boolean);
let lines = 1;
let lineLen = 0;
for (const word of words) {
const wordLen = word.length;
if (wordLen > charsPerLine) {
if (lineLen > 0) {
lines++;
lineLen = 0;
}
lines += Math.ceil(wordLen / charsPerLine) - 1;
lineLen = wordLen % charsPerLine || charsPerLine;
continue;
}
const space = lineLen > 0 ? 1 : 0;
if (lineLen + space + wordLen > charsPerLine) {
lines++;
lineLen = wordLen;
} else {
lineLen += space + wordLen;
}
}
return Math.max(1, lines);
}
function labelBlockHeight(label: string, width: number): number {
return estimateLabelLines(label, width) * LABEL_LINE_HEIGHT + LABEL_PAD_Y;
}
export function nodeBoxHeight(
label: string,
width: number,
minHeight: number,
extraBuffer = 0
): number {
return Math.max(
minHeight,
STRIPE_HEIGHT + labelBlockHeight(label, width) + NODE_BOX_BUFFER + extraBuffer
);
}
export function escalationNodeHeight(
label: string,
width: number,
minHeight: number
): number {
return nodeBoxHeight(label, width, minHeight, ESCALATION_NODE_BUFFER);
}
export function barrierHeaderHeightFor(barrier: Barrier, layout: LayoutConfig): number {
return Math.max(
layout.barrierHeaderHeight,
STRIPE_HEIGHT +
labelBlockHeight(barrier.label || "Barrier", layout.barrierWidth) +
BARRIER_HEADER_BUFFER
);
}
export function barrierRenderHeight(barrier: Barrier, layout: LayoutConfig): number {
const stack = barrier.stack ?? [];
const collapsed = barrier.stackCollapsed ?? true;
const stackHeight =
!collapsed && stack.length > 0 ? stack.length * layout.barrierStackRowHeight : 0;
return barrierHeaderHeightFor(barrier, layout) + stackHeight;
}
export interface PositionedNode {
ref: NodeRef;
kind: NodeKind;
label: string;
subtitle: string;
x: number;
y: number;
width: number;
height: number;
}
export interface EdgeArrow {
x: number;
y: number;
angleDeg: number;
}
export interface EdgePath {
id: string;
path: string;
kind: "main" | "hazard" | "escalation";
fromRef: NodeRef;
toRef: NodeRef;
arrow: EdgeArrow;
}
export interface LayoutResult {
nodes: PositionedNode[];
edges: EdgePath[];
bounds: { width: number; height: number };
}
function placeBarriersInLane(
startX: number,
laneWidth: number,
count: number,
barrierWidth: number,
barrierGap: number
): number[] {
if (count === 0) return [];
const usedWidth = count * barrierWidth + Math.max(0, count - 1) * barrierGap;
const offset = (laneWidth - usedWidth) / 2;
return Array.from(
{ length: count },
(_, i) => startX + offset + i * (barrierWidth + barrierGap)
);
}
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 };
}
function nodeLeft(node: PositionedNode): { x: number; y: number } {
return { x: node.x, y: node.y + node.height / 2 };
}
function nodeBottom(node: PositionedNode): { x: number; y: number } {
return { x: node.x + node.width / 2, y: node.y + node.height };
}
function nodeTop(node: PositionedNode): { x: number; y: number } {
return { x: node.x + node.width / 2, y: node.y };
}
const ARROW_TIP_OVERLAP = 3;
const NODE_PORT_INSET = 1.5;
/** Tangent angle near the path end. */
function bezierEndTangentDeg(
x1: number,
y1: number,
x2: number,
y2: number,
curve = 0.35,
t = 0.96
): 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 dydt =
3 * mt * mt * (p1y - y1) + 6 * mt * t * (p2y - p1y) + 3 * t * t * (y2 - p2y);
const dxdt =
3 * mt * mt * (p1x - x1) + 6 * mt * t * (p2x - p1x) + 3 * t * t * (x2 - p2x);
return (Math.atan2(dydt, dxdt) * 180) / Math.PI;
}
function bezierStartTangentDeg(
x1: number,
y1: number,
x2: number,
y2: number,
curve = 0.35
): number {
const dx = (x2 - x1) * curve;
return (Math.atan2(0, dx) * 180) / Math.PI;
}
function insetPort(x: number, y: number, angleDeg: number, inset: number): { x: number; y: number } {
const rad = (angleDeg * Math.PI) / 180;
return {
x: x - Math.cos(rad) * inset,
y: y - Math.sin(rad) * inset,
};
}
function makeBezierEdge(
x1: number,
y1: number,
x2: number,
y2: number,
curve = 0.35
): { path: string; arrow: EdgeArrow } {
const endAngleDeg = bezierEndTangentDeg(x1, y1, x2, y2, curve);
const endRad = (endAngleDeg * Math.PI) / 180;
const start = insetPort(
x1,
y1,
bezierStartTangentDeg(x1, y1, x2, y2, curve),
NODE_PORT_INSET
);
const dx = (x2 - x1) * curve;
return {
path: `M ${start.x} ${start.y} C ${start.x + dx} ${start.y}, ${x2 - dx} ${y2}, ${x2} ${y2}`,
arrow: {
x: x2 + Math.cos(endRad) * ARROW_TIP_OVERLAP,
y: y2 + Math.sin(endRad) * ARROW_TIP_OVERLAP,
angleDeg: endAngleDeg,
},
};
}
function makeLineEdge(
x1: number,
y1: number,
x2: number,
y2: number
): { path: string; arrow: EdgeArrow } {
const angleDeg = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI;
const rad = (angleDeg * Math.PI) / 180;
const start = insetPort(x1, y1, angleDeg, NODE_PORT_INSET);
return {
path: `M ${start.x} ${start.y} L ${x2} ${y2}`,
arrow: {
x: x2 + Math.cos(rad) * ARROW_TIP_OVERLAP,
y: y2 + Math.sin(rad) * ARROW_TIP_OVERLAP,
angleDeg,
},
};
}
function escalationFactorGap(layout: LayoutConfig): number {
return layout.rowGap * 0.35;
}
function escalationColumnHeight(barrier: Barrier, layout: LayoutConfig): number {
const factors = barrier.escalationFactors;
if (factors.length === 0) return 0;
const factorGap = escalationFactorGap(layout);
const escGap = 12;
let height = 0;
factors.forEach((factor, factorIndex) => {
if (factorIndex > 0) height += factorGap;
height += escalationNodeHeight(
factor.label || "Escalation",
layout.escalationWidth,
layout.escalationHeight
);
factor.escalationBarriers.forEach((escBarrier, escIndex) => {
height +=
(escIndex === 0 ? layout.rowGap * 0.5 : escGap) +
escalationNodeHeight(
escBarrier.label || "Esc. Barrier",
layout.escalationWidth,
layout.escalationHeight
);
});
});
return layout.escalationOffsetY * ESCALATION_GAP_FACTOR + height;
}
function escalationExtraForBarriers(barriers: Barrier[], layout: LayoutConfig): number {
let maxExtra = 0;
for (const barrier of barriers) {
maxExtra = Math.max(maxExtra, escalationColumnHeight(barrier, layout));
}
return maxExtra;
}
function threatRowHeight(threat: Threat, layout: LayoutConfig): number {
let maxBarrierH = layout.barrierHeaderHeight;
for (const barrier of threat.preventionBarriers) {
maxBarrierH = Math.max(maxBarrierH, barrierRenderHeight(barrier, layout));
}
const threatH = nodeBoxHeight(
threat.label || "Threat",
layout.nodeWidth,
layout.nodeHeight
);
return Math.max(threatH, maxBarrierH) + escalationExtraForBarriers(threat.preventionBarriers, layout);
}
function consequenceRowHeight(consequence: Consequence, layout: LayoutConfig): number {
let maxBarrierH = layout.barrierHeaderHeight;
for (const barrier of consequence.mitigationBarriers) {
maxBarrierH = Math.max(maxBarrierH, barrierRenderHeight(barrier, layout));
}
const consequenceH = nodeBoxHeight(
consequence.label || "Consequence",
layout.nodeWidth,
layout.nodeHeight
);
return (
Math.max(consequenceH, maxBarrierH) +
escalationExtraForBarriers(consequence.mitigationBarriers, layout)
);
}
function sumRowHeights(heights: number[], rowGap: number): number {
if (heights.length === 0) return 0;
return heights.reduce((sum, h) => sum + h, 0) + Math.max(0, heights.length - 1) * rowGap;
}
function estimateLaneHeight(bowtie: Bowtie, layout: LayoutConfig): number {
const threatHeights = bowtie.threats.map((t) => threatRowHeight(t, layout));
const consequenceHeights = bowtie.consequences.map((c) => consequenceRowHeight(c, layout));
const leftHeight = sumRowHeights(threatHeights, layout.rowGap) || layout.nodeHeight;
const rightHeight = sumRowHeights(consequenceHeights, layout.rowGap) || layout.nodeHeight;
return Math.max(leftHeight, rightHeight);
}
function computeRowYPositions(heights: number[], contentTop: number, rowGap: number): number[] {
if (heights.length === 0) return [];
const positions: number[] = [];
let y = contentTop;
for (const height of heights) {
positions.push(y);
y += height + rowGap;
}
return positions;
}
export function layoutBowtie(bowtie: Bowtie, layout: LayoutConfig = DEFAULT_LAYOUT): LayoutResult {
const nodes: PositionedNode[] = [];
const edges: EdgePath[] = [];
const laneHeight = estimateLaneHeight(bowtie, layout);
const centerY = layout.padding + laneHeight / 2;
const maxBarriersLeft = Math.max(
0,
...bowtie.threats.map((t) => t.preventionBarriers.length)
);
const maxBarriersRight = Math.max(
0,
...bowtie.consequences.map((c) => c.mitigationBarriers.length)
);
const threatColX = layout.padding;
const preventionStartX =
threatColX + layout.nodeWidth + layout.columnGap * 0.4;
const preventionEndX =
layout.padding +
layout.nodeWidth +
layout.columnGap +
maxBarriersLeft * (layout.barrierWidth + layout.barrierGap);
const centerX = preventionEndX + layout.columnGap * 0.5;
const topEventSize = Math.max(layout.nodeWidth, layout.nodeHeight);
const mitigationStartX = centerX + topEventSize + layout.columnGap * 0.5;
const consequenceColX =
mitigationStartX +
maxBarriersRight * (layout.barrierWidth + layout.barrierGap) +
layout.columnGap;
const hazardH = nodeBoxHeight(
bowtie.hazard || "Hazard",
layout.nodeWidth,
layout.hazardHeight
);
const hazardNode: PositionedNode = {
ref: { kind: "hazard" },
kind: "hazard",
label: bowtie.hazard || "Hazard",
subtitle: "Hazard",
x: centerX,
y: centerY - topEventSize / 2 - hazardH - layout.rowGap,
width: layout.nodeWidth,
height: hazardH,
};
nodes.push(hazardNode);
const topEventNode: PositionedNode = {
ref: { kind: "topEvent" },
kind: "topEvent",
label: bowtie.topEvent || "Top Event",
subtitle: "Top Event",
x: centerX,
y: centerY - topEventSize / 2,
width: topEventSize,
height: topEventSize,
};
nodes.push(topEventNode);
{
const from = nodeBottom(hazardNode);
const to = nodeTop(topEventNode);
const edge = makeLineEdge(from.x, from.y, to.x, to.y);
edges.push({
id: "hazard-top",
path: edge.path,
kind: "hazard",
fromRef: hazardNode.ref,
toRef: topEventNode.ref,
arrow: edge.arrow,
});
}
const contentTop = layout.padding;
const threatRowHeights = bowtie.threats.map((t) => threatRowHeight(t, layout));
const consequenceRowHeights = bowtie.consequences.map((c) => consequenceRowHeight(c, layout));
const threatYs = computeRowYPositions(threatRowHeights, contentTop, layout.rowGap);
const consequenceYs = computeRowYPositions(consequenceRowHeights, contentTop, layout.rowGap);
bowtie.threats.forEach((threat, threatIndex) => {
const y = threatYs[threatIndex];
const threatNode: PositionedNode = {
ref: { kind: "threat", threatId: threat.id },
kind: "threat",
label: threat.label || "Threat",
subtitle: "Threat",
x: threatColX,
y,
width: layout.nodeWidth,
height: nodeBoxHeight(threat.label || "Threat", layout.nodeWidth, layout.nodeHeight),
};
nodes.push(threatNode);
const chain: PositionedNode[] = [threatNode];
const barrierCount = threat.preventionBarriers.length;
const laneWidth = preventionEndX - preventionStartX;
const preventionPositions = placeBarriersInLane(
preventionStartX,
laneWidth,
barrierCount,
layout.barrierWidth,
layout.barrierGap
);
threat.preventionBarriers.forEach((barrier, barrierIndex) => {
const bx = preventionPositions[barrierIndex];
const barrierHeight = barrierRenderHeight(barrier, layout);
const barrierNode: PositionedNode = {
ref: {
kind: "preventionBarrier",
threatId: threat.id,
barrierId: barrier.id,
},
kind: "preventionBarrier",
label: barrier.label || "Barrier",
subtitle: "Prevention",
x: bx,
y,
width: layout.barrierWidth,
height: barrierHeight,
};
nodes.push(barrierNode);
chain.push(barrierNode);
layoutEscalation(barrier, barrierNode, nodes, edges, layout);
});
chain.push(topEventNode);
for (let i = 0; i < chain.length - 1; i++) {
const from = chain[i];
const to = chain[i + 1];
const p1 = nodeRight(from);
const p2 = nodeLeft(to);
const edge = makeBezierEdge(p1.x, p1.y, p2.x, p2.y);
edges.push({
id: `edge-${from.ref.kind}-${to.ref.kind}-${i}`,
path: edge.path,
kind: "main",
fromRef: from.ref,
toRef: to.ref,
arrow: edge.arrow,
});
}
});
bowtie.consequences.forEach((consequence, consequenceIndex) => {
const y = consequenceYs[consequenceIndex];
const consequenceNode: PositionedNode = {
ref: { kind: "consequence", consequenceId: consequence.id },
kind: "consequence",
label: consequence.label || "Consequence",
subtitle: "Consequence",
x: consequenceColX,
y,
width: layout.nodeWidth,
height: nodeBoxHeight(
consequence.label || "Consequence",
layout.nodeWidth,
layout.nodeHeight
),
};
nodes.push(consequenceNode);
const chain: PositionedNode[] = [topEventNode];
const barrierCount = consequence.mitigationBarriers.length;
const laneWidth = consequenceColX - mitigationStartX;
const mitigationPositions = placeBarriersInLane(
mitigationStartX,
laneWidth,
barrierCount,
layout.barrierWidth,
layout.barrierGap
);
consequence.mitigationBarriers.forEach((barrier, barrierIndex) => {
const bx = mitigationPositions[barrierIndex];
const barrierHeight = barrierRenderHeight(barrier, layout);
const barrierNode: PositionedNode = {
ref: {
kind: "mitigationBarrier",
consequenceId: consequence.id,
barrierId: barrier.id,
},
kind: "mitigationBarrier",
label: barrier.label || "Barrier",
subtitle: "Mitigation",
x: bx,
y,
width: layout.barrierWidth,
height: barrierHeight,
};
nodes.push(barrierNode);
chain.push(barrierNode);
layoutEscalation(barrier, barrierNode, nodes, edges, layout, consequence.id);
});
chain.push(consequenceNode);
for (let i = 0; i < chain.length - 1; i++) {
const from = chain[i];
const to = chain[i + 1];
const p1 = nodeRight(from);
const p2 = nodeLeft(to);
const edge = makeBezierEdge(p1.x, p1.y, p2.x, p2.y);
edges.push({
id: `edge-${from.ref.kind}-${to.ref.kind}-${i}-r`,
path: edge.path,
kind: "main",
fromRef: from.ref,
toRef: to.ref,
arrow: edge.arrow,
});
}
});
let maxX = consequenceColX + layout.nodeWidth + layout.padding;
let maxY = layout.padding + laneHeight + layout.padding;
for (const node of nodes) {
maxX = Math.max(maxX, node.x + node.width + layout.padding);
maxY = Math.max(maxY, node.y + node.height + layout.padding);
}
return {
nodes,
edges,
bounds: { width: maxX, height: maxY },
};
}
function layoutEscalation(
barrier: import("./model").Barrier,
barrierNode: PositionedNode,
nodes: PositionedNode[],
edges: EdgePath[],
layout: LayoutConfig,
consequenceId?: string
): void {
const threatId = barrierNode.ref.threatId;
const factorGap = escalationFactorGap(layout);
const escGap = 12;
const baseY = barrierNode.y + barrierNode.height + layout.escalationOffsetY * ESCALATION_GAP_FACTOR;
const factorX = barrierNode.x + barrierNode.width / 2 - layout.escalationWidth / 2;
let attachFrom: PositionedNode = barrierNode;
let nextY = baseY;
barrier.escalationFactors.forEach((factor, factorIndex) => {
if (factorIndex > 0) nextY += factorGap;
const factorH = escalationNodeHeight(
factor.label || "Escalation",
layout.escalationWidth,
layout.escalationHeight
);
const factorNode: PositionedNode = {
ref: {
kind: "escalationFactor",
threatId,
consequenceId,
barrierId: barrier.id,
escalationId: factor.id,
},
kind: "escalationFactor",
label: factor.label || "Escalation",
subtitle: "Escalation Factor",
x: factorX,
y: nextY,
width: layout.escalationWidth,
height: factorH,
};
nodes.push(factorNode);
nextY += factorH;
const b1 = nodeBottom(attachFrom);
const b2 = nodeTop(factorNode);
const escEdge = makeLineEdge(b1.x, b1.y, b2.x, b2.y);
edges.push({
id: `esc-${barrier.id}-${factor.id}`,
path: escEdge.path,
kind: "escalation",
fromRef: attachFrom.ref,
toRef: factorNode.ref,
arrow: escEdge.arrow,
});
attachFrom = factorNode;
factor.escalationBarriers.forEach((escBarrier, escIndex) => {
nextY += escIndex === 0 ? layout.rowGap * 0.5 : escGap;
const escH = escalationNodeHeight(
escBarrier.label || "Esc. Barrier",
layout.escalationWidth,
layout.escalationHeight
);
const escNode: PositionedNode = {
ref: {
kind: "escalationBarrier",
threatId,
consequenceId,
barrierId: barrier.id,
escalationId: factor.id,
escalationBarrierId: escBarrier.id,
},
kind: "escalationBarrier",
label: escBarrier.label || "Esc. Barrier",
subtitle: "Escalation Barrier",
x: factorX,
y: nextY,
width: layout.escalationWidth,
height: escH,
};
nodes.push(escNode);
nextY += escH;
const e1 = nodeBottom(attachFrom);
const e2 = nodeTop(escNode);
const escEdge = makeLineEdge(e1.x, e1.y, e2.x, e2.y);
edges.push({
id: `escb-${factor.id}-${escBarrier.id}`,
path: escEdge.path,
kind: "escalation",
fromRef: attachFrom.ref,
toRef: escNode.ref,
arrow: escEdge.arrow,
});
attachFrom = escNode;
});
});
}

124
src/main.ts Normal file
View file

@ -0,0 +1,124 @@
import { Notice, Plugin, TFile, normalizePath } from "obsidian";
import { BowtieView } from "./bowtieView";
import { NewBowtieNameModal } from "./editorModal";
import { PLUGIN_ICON, registerPluginIcon, styleRibbonIcon } from "./icons";
import {
BOWTIE_EXTENSION,
BOWTIE_VIEW_TYPE,
createBowtie,
getBowtieFilePath,
serializeBowtie,
} from "./model";
import { DEFAULT_SETTINGS, OTieSettingTab, type OTieSettings } from "./settingsTab";
export default class OTiePlugin extends Plugin {
settings: OTieSettings = DEFAULT_SETTINGS;
async onload(): Promise<void> {
registerPluginIcon();
await this.loadSettings();
this.addSettingTab(new OTieSettingTab(this.app, this));
this.registerView(BOWTIE_VIEW_TYPE, (leaf) => new BowtieView(leaf, this));
this.registerExtensions(["bowtie"], BOWTIE_VIEW_TYPE);
const ribbonBtn = this.addRibbonIcon(PLUGIN_ICON, "Create bowtie", () => {
void this.createNewBowtie();
});
styleRibbonIcon(ribbonBtn);
this.addCommand({
id: "create-bowtie",
name: "Create new bowtie",
callback: () => void this.createNewBowtie(),
});
this.addCommand({
id: "open-bowtie",
name: "Open bowtie file",
callback: () => {
const file = this.app.workspace.getActiveFile();
if (file && file.extension === "bowtie") {
void this.openBowtieFile(file);
} else {
new Notice("Open a .bowtie file first.");
}
},
});
this.addCommand({
id: "export-bowtie-image",
name: "Export bowtie as image",
checkCallback: (checking) => {
const view = this.app.workspace.getActiveViewOfType(BowtieView);
if (view) {
if (!checking) view.openExportImageModal();
return true;
}
return false;
},
});
}
onunload(): void {}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
private async ensureFolder(folderPath: string): Promise<void> {
const normalized = normalizePath(folderPath);
if (!normalized || normalized === ".") return;
const parts = normalized.split("/");
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
const existing = this.app.vault.getAbstractFileByPath(current);
if (!existing) {
await this.app.vault.createFolder(current);
}
}
}
private sanitizeFileName(name: string): string {
return name.replace(/[\\/:*?"<>|]/g, "-").trim();
}
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);
}).open();
}
async openBowtieFile(file: TFile): Promise<void> {
const leaf = this.app.workspace.getLeaf(true);
await leaf.openFile(file);
}
isBowtieFile(file: TFile): boolean {
return file.path.endsWith(BOWTIE_EXTENSION) || file.extension === "bowtie";
}
}

305
src/model.ts Normal file
View file

@ -0,0 +1,305 @@
export type BarrierEffectiveness = "effective" | "degraded" | "failed" | "unknown";
export interface BarrierStackItem {
id: string;
field?: string;
label: string;
color?: string;
}
export interface BarrierStackFieldOption {
label: string;
color: string;
}
export interface BarrierStackField {
key: string;
name: string;
options: BarrierStackFieldOption[];
}
export const BARRIER_STACK_FIELDS: BarrierStackField[] = [
{
key: "type",
name: "Barrier type",
options: [
{ label: "Active Human", color: "#5dade2" },
{ label: "Reactive Human", color: "#3498db" },
{ label: "Active Hardware", color: "#48c9b0" },
{ label: "Reactive Hardware", color: "#1abc9c" },
{ label: "Passive Hardware", color: "#7f8c8d" },
],
},
{
key: "effectiveness",
name: "Effectiveness",
options: [
{ label: "Excellent", color: "#1e8449" },
{ label: "Good", color: "#27ae60" },
{ label: "Fair", color: "#f1c40f" },
{ label: "Poor", color: "#e67e22" },
{ label: "Failed", color: "#c0392b" },
],
},
{
key: "criticality",
name: "Criticality",
options: [
{ label: "Very High", color: "#2c3e50" },
{ label: "High", color: "#566573" },
{ label: "Medium", color: "#7f8c8d" },
{ label: "Low", color: "#aab7b8" },
],
},
{
key: "responsible",
name: "Responsible",
options: [
{ label: "HSE Department", color: "#ffffff" },
{ label: "Tech Department", color: "#ffffff" },
{ label: "Operations", color: "#ffffff" },
{ label: "Management", color: "#ffffff" },
],
},
{
key: "validation",
name: "Validation method",
options: [
{ label: "Drills and Exercise", color: "#ffffff" },
{ label: "Barrier Test", color: "#ffffff" },
{ label: "Inspection", color: "#ffffff" },
{ label: "Audit", color: "#ffffff" },
],
},
{
key: "status",
name: "Status",
options: [
{ label: "Available", color: "#1e8449" },
{ label: "Degraded", color: "#f39c12" },
{ label: "Unavailable", color: "#c0392b" },
{ label: "Unknown", color: "#95a5a6" },
],
},
];
export interface EscalationFactor {
id: string;
label: string;
notes?: string;
escalationBarriers: Barrier[];
}
export interface Barrier {
id: string;
label: string;
notes?: string;
color?: string;
effectiveness?: BarrierEffectiveness;
escalationFactors: EscalationFactor[];
stack?: BarrierStackItem[];
stackCollapsed?: boolean;
}
export interface Threat {
id: string;
label: string;
notes?: string;
preventionBarriers: Barrier[];
}
export interface Consequence {
id: string;
label: string;
notes?: string;
mitigationBarriers: Barrier[];
}
export interface BowtieViewState {
zoom: number;
panX: number;
panY: number;
}
export interface Bowtie {
id: string;
name: string;
hazard: string;
topEvent: string;
threats: Threat[];
consequences: Consequence[];
view?: BowtieViewState;
createdAt: string;
updatedAt: string;
}
export const BOWTIE_EXTENSION = ".bowtie";
export const BOWTIE_VIEW_TYPE = "o-tie-bowtie-view";
export function generateId(): string {
return crypto.randomUUID();
}
export function stackFieldOrder(fieldKey?: string): number {
if (!fieldKey) return BARRIER_STACK_FIELDS.length;
const index = BARRIER_STACK_FIELDS.findIndex((f) => f.key === fieldKey);
return index >= 0 ? index : BARRIER_STACK_FIELDS.length;
}
export function sortBarrierStack(stack: BarrierStackItem[]): void {
stack.sort((a, b) => stackFieldOrder(a.field) - stackFieldOrder(b.field));
}
export function createBarrierStackItem(
label = "",
field?: string,
color?: string
): BarrierStackItem {
return {
id: generateId(),
field,
label,
color,
};
}
export function createBarrier(label = ""): Barrier {
return {
id: generateId(),
label,
escalationFactors: [],
stack: [],
stackCollapsed: true,
};
}
export function createEscalationFactor(label = ""): EscalationFactor {
return {
id: generateId(),
label,
escalationBarriers: [],
};
}
export function createThreat(label = ""): Threat {
return {
id: generateId(),
label,
preventionBarriers: [],
};
}
export function createConsequence(label = ""): Consequence {
return {
id: generateId(),
label,
mitigationBarriers: [],
};
}
export function createBowtie(name: string): Bowtie {
const now = new Date().toISOString();
return {
id: generateId(),
name,
hazard: "",
topEvent: "",
threats: [],
consequences: [],
view: { zoom: 1, panX: 0, panY: 0 },
createdAt: now,
updatedAt: now,
};
}
export function serializeBowtie(bowtie: Bowtie): string {
return JSON.stringify(bowtie, null, 2);
}
export function deserializeBowtie(json: string): Bowtie {
const data = JSON.parse(json) as Bowtie;
validateBowtie(data);
if (!data.view) {
data.view = { zoom: 1, panX: 0, panY: 0 };
}
return data;
}
export function cloneBowtie(bowtie: Bowtie): Bowtie {
return deserializeBowtie(serializeBowtie(bowtie));
}
function normalizeBarrier(barrier: Barrier): void {
if (!Array.isArray(barrier.escalationFactors)) {
barrier.escalationFactors = [];
}
if (!Array.isArray(barrier.stack)) {
barrier.stack = [];
} else {
sortBarrierStack(barrier.stack);
}
if (barrier.stackCollapsed === undefined) {
barrier.stackCollapsed = true;
}
}
function validateBowtie(data: Bowtie): void {
if (!data.id || !data.name) {
throw new Error("Invalid bowtie: missing id or name");
}
if (!Array.isArray(data.threats) || !Array.isArray(data.consequences)) {
throw new Error("Invalid bowtie: threats and consequences must be arrays");
}
for (const threat of data.threats) {
for (const barrier of threat.preventionBarriers) {
normalizeBarrier(barrier);
}
}
for (const consequence of data.consequences) {
for (const barrier of consequence.mitigationBarriers) {
normalizeBarrier(barrier);
}
}
}
export function getBowtieFilePath(basePath: string): string {
return `${basePath}${BOWTIE_EXTENSION}`;
}
export function touchBowtie(bowtie: Bowtie): Bowtie {
return {
...bowtie,
updatedAt: new Date().toISOString(),
};
}
export type NodeKind =
| "hazard"
| "topEvent"
| "threat"
| "consequence"
| "preventionBarrier"
| "mitigationBarrier"
| "escalationFactor"
| "escalationBarrier";
export interface NodeRef {
kind: NodeKind;
threatId?: string;
consequenceId?: string;
barrierId?: string;
escalationId?: string;
escalationBarrierId?: string;
}
export function nodeRefKey(ref: NodeRef): string {
const parts = [
ref.kind,
ref.threatId ?? "",
ref.consequenceId ?? "",
ref.barrierId ?? "",
ref.escalationId ?? "",
ref.escalationBarrierId ?? "",
];
return parts.join(":");
}

111
src/settingsTab.ts Normal file
View file

@ -0,0 +1,111 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import type OTiePlugin from "./main";
export interface OTieSettings {
defaultFolder: string;
columnGap: number;
rowGap: number;
nodeWidth: number;
nodeHeight: number;
}
export const DEFAULT_SETTINGS: OTieSettings = {
defaultFolder: "Bowties",
columnGap: 120,
rowGap: 40,
nodeWidth: 220,
nodeHeight: 80,
};
export class OTieSettingTab extends PluginSettingTab {
plugin: OTiePlugin;
constructor(app: App, plugin: OTiePlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "O-Tie Settings" });
new Setting(containerEl)
.setName("Default folder")
.setDesc("Folder where new bowtie files are created")
.addText((text) =>
text
.setPlaceholder("Bowties")
.setValue(this.plugin.settings.defaultFolder)
.onChange(async (value) => {
this.plugin.settings.defaultFolder = value.trim() || "Bowties";
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Column gap")
.setDesc("Horizontal spacing between bowtie columns (pixels)")
.addText((text) =>
text
.setPlaceholder("120")
.setValue(String(this.plugin.settings.columnGap))
.onChange(async (value) => {
const parsed = parseInt(value, 10);
if (!isNaN(parsed) && parsed > 0) {
this.plugin.settings.columnGap = parsed;
await this.plugin.saveSettings();
}
})
);
new Setting(containerEl)
.setName("Row gap")
.setDesc("Vertical spacing between threats/consequences (pixels)")
.addText((text) =>
text
.setPlaceholder("40")
.setValue(String(this.plugin.settings.rowGap))
.onChange(async (value) => {
const parsed = parseInt(value, 10);
if (!isNaN(parsed) && parsed > 0) {
this.plugin.settings.rowGap = parsed;
await this.plugin.saveSettings();
}
})
);
new Setting(containerEl)
.setName("Node width")
.setDesc("Default width of bowtie nodes (pixels)")
.addText((text) =>
text
.setPlaceholder("220")
.setValue(String(this.plugin.settings.nodeWidth))
.onChange(async (value) => {
const parsed = parseInt(value, 10);
if (!isNaN(parsed) && parsed > 0) {
this.plugin.settings.nodeWidth = parsed;
await this.plugin.saveSettings();
}
})
);
new Setting(containerEl)
.setName("Node height")
.setDesc("Default height of bowtie nodes (pixels)")
.addText((text) =>
text
.setPlaceholder("80")
.setValue(String(this.plugin.settings.nodeHeight))
.onChange(async (value) => {
const parsed = parseInt(value, 10);
if (!isNaN(parsed) && parsed > 0) {
this.plugin.settings.nodeHeight = parsed;
await this.plugin.saveSettings();
}
})
);
}
}

973
styles.css Normal file
View file

@ -0,0 +1,973 @@
/* Modal (create bowtie) */
.o-tie-modal {
max-width: 480px;
width: 90vw;
}
.o-tie-modal .modal-content {
padding: 1.25rem;
}
.o-tie-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.o-tie-export-desc {
margin: 0 0 0.75rem;
color: var(--text-muted);
font-size: var(--font-ui-small);
line-height: 1.45;
}
.o-tie-color-menu-title {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.o-tie-color-swatch {
display: inline-block;
width: 1rem;
height: 1rem;
border-radius: var(--radius-s);
flex-shrink: 0;
}
.o-tie-color-swatch-light {
box-shadow: inset 0 0 0 1px var(--background-modifier-border);
}
.o-tie-color-menu-label {
font-size: var(--font-ui-small);
}
.o-tie-help-modal {
max-width: 560px;
max-height: 80vh;
}
.o-tie-help-modal .modal-content {
max-height: 80vh;
overflow-y: auto;
}
.o-tie-help-version {
margin: 0 0 0.75rem;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
.o-tie-help-body h3 {
margin: 1rem 0 0.4rem;
font-size: var(--font-ui-medium);
}
.o-tie-help-body h3:first-child {
margin-top: 0;
}
.o-tie-help-body p,
.o-tie-help-body li {
margin: 0 0 0.5rem;
color: var(--text-muted);
font-size: var(--font-ui-small);
line-height: 1.5;
}
.o-tie-help-body ul {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.o-tie-help-diagram {
margin: 0 0 0.5rem;
padding: 0.6rem 0.75rem;
background: var(--background-secondary);
border-radius: var(--radius-s);
font-family: var(--font-monospace);
font-size: var(--font-ui-smaller);
line-height: 1.4;
overflow-x: auto;
white-space: pre;
}
.o-tie-export-root {
font-family: var(--font-interface);
color: #1a1a1a;
--text-normal: #1a1a1a;
--text-muted: #5d6d7e;
--background-primary: #ffffff;
}
/* Root view */
.o-tie-view-root {
display: flex;
flex-direction: column;
height: 100%;
--o-tie-hazard: #d4a017;
--o-tie-hazard-bg: #fef9e7;
--o-tie-top-event: #c0392b;
--o-tie-top-event-bg: #fdecea;
--o-tie-threat: #5d6d7e;
--o-tie-threat-bg: #f4f6f7;
--o-tie-consequence: #7d3c98;
--o-tie-consequence-bg: #f5eef8;
--o-tie-barrier: #1e8449;
--o-tie-barrier-bg: #eafaf1;
--o-tie-escalation: #6c3483;
--o-tie-escalation-bg: #f4ecf7;
--o-tie-edge-color: #7f8c8d;
}
/* Toolbar */
.o-tie-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--background-modifier-border);
background: var(--background-primary);
flex-shrink: 0;
}
.o-tie-toolbar-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.o-tie-view-root.o-tie-toolbar-is-collapsed .o-tie-toolbar {
display: none;
}
.o-tie-toolbar-end {
display: flex;
align-items: center;
gap: 0.3rem;
flex-shrink: 0;
margin-left: auto;
}
.o-tie-toolbar-toggle {
flex-shrink: 0;
}
.o-tie-toolbar-toggle::before,
.o-tie-toolbar-reveal::before {
content: "";
display: block;
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 5px solid var(--text-muted);
}
.o-tie-toolbar-reveal::before {
transform: rotate(180deg);
}
.o-tie-toolbar-reveal {
position: absolute;
top: 0.45rem;
right: 0.6rem;
z-index: 30;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.o-tie-view-root:not(.o-tie-toolbar-is-collapsed) .o-tie-toolbar-reveal {
display: none;
}
.o-tie-view-root.o-tie-toolbar-is-collapsed .o-tie-toolbar-reveal {
display: grid;
}
.o-tie-toolbar-brand {
flex-shrink: 0;
min-width: 0;
max-width: 220px;
}
.o-tie-toolbar-title {
display: block;
font-weight: 600;
font-size: var(--font-ui-small);
cursor: text;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-s);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-normal);
}
.o-tie-toolbar-title:hover {
background: var(--background-modifier-hover);
}
.o-tie-toolbar-separator {
width: 1px;
height: 1.25rem;
background: var(--background-modifier-border);
flex-shrink: 0;
}
.o-tie-toolbar-group {
display: flex;
align-items: center;
gap: 0.3rem;
flex-shrink: 0;
}
.o-tie-toolbar-btn {
padding: 0.28rem 0.65rem;
font-size: var(--font-ui-smaller);
font-weight: 500;
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
color: var(--text-normal);
cursor: pointer;
line-height: 1.3;
transition: background 0.12s, border-color 0.12s;
}
.o-tie-toolbar-btn:hover {
background: var(--background-modifier-hover);
border-color: var(--background-modifier-border-hover);
}
.o-tie-toolbar-btn-primary {
background: var(--o-tie-barrier);
border-color: var(--o-tie-barrier);
color: #fff;
}
.o-tie-toolbar-btn-primary:hover {
filter: brightness(1.08);
background: var(--o-tie-barrier);
border-color: var(--o-tie-barrier);
}
.o-tie-toolbar-btn-icon {
width: 1.75rem;
height: 1.75rem;
padding: 0;
display: grid;
place-items: center;
font-size: 1rem;
font-weight: 600;
line-height: 1;
}
.o-tie-toolbar-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.o-tie-toolbar-btn:disabled:hover {
background: var(--background-secondary);
border-color: var(--background-modifier-border);
}
/* Canvas container */
.o-tie-canvas-container {
flex: 1;
position: relative;
overflow: hidden;
background-color: var(--background-primary);
background-image: radial-gradient(circle, var(--background-modifier-border) 1px, transparent 1px);
background-size: 20px 20px;
background-position: 0 0;
cursor: grab;
touch-action: none;
}
.o-tie-canvas-container.o-tie-panning {
cursor: grabbing;
}
.o-tie-viewport {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
.o-tie-stage {
position: relative;
will-change: zoom;
}
.o-tie-transform {
position: relative;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.o-tie-svg {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
overflow: visible;
}
.o-tie-edge {
fill: none;
stroke: var(--o-tie-edge-color);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
shape-rendering: geometricPrecision;
}
.o-tie-edge-hazard {
stroke: var(--o-tie-hazard);
}
.o-tie-edge-escalation {
stroke: var(--o-tie-escalation);
stroke-dasharray: 4 3;
stroke-width: 1.5;
shape-rendering: geometricPrecision;
}
.o-tie-edge-arrow {
pointer-events: none;
}
.o-tie-edge-arrow-main path {
fill: var(--o-tie-edge-color);
}
.o-tie-edge-arrow-hazard path {
fill: var(--o-tie-hazard);
}
.o-tie-edge-arrow-escalation path {
fill: var(--o-tie-escalation);
}
/* Nodes layer */
.o-tie-nodes {
position: relative;
overflow: visible;
}
.o-tie-node-wrap {
position: absolute;
overflow: visible;
cursor: pointer;
z-index: 1;
}
.o-tie-node-wrap:hover {
z-index: 2;
}
.o-tie-node-wrap.o-tie-node-selected {
z-index: 3;
}
.o-tie-node {
position: absolute;
inset: 0;
border-radius: 6px;
border: 2px solid;
background: var(--background-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
overflow: visible;
transition: box-shadow 0.15s;
}
.o-tie-node-preventionBarrier,
.o-tie-node-mitigationBarrier {
overflow: hidden;
}
.o-tie-node-wrap:hover .o-tie-node {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14);
}
.o-tie-node-wrap.o-tie-node-selected .o-tie-node {
box-shadow: 0 0 0 3px var(--interactive-accent);
}
.o-tie-node-stripe {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 8px;
flex-shrink: 0;
}
.o-tie-node-label {
flex: 1;
padding: 6px 8px 10px;
font-size: var(--font-ui-smaller);
line-height: 1.3;
word-break: break-word;
overflow-wrap: anywhere;
display: flex;
align-items: flex-start;
/* Node bodies use fixed light pastels — always use dark text for contrast */
color: #1a1a1a;
}
/* Node type colors */
.o-tie-node-hazard {
border-color: var(--o-tie-hazard);
background: var(--o-tie-hazard-bg);
}
.o-tie-node-hazard .o-tie-node-stripe {
background: var(--o-tie-hazard);
color: #fff;
}
.o-tie-node-topEvent {
border-color: var(--o-tie-top-event);
background: var(--o-tie-top-event-bg);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
}
.o-tie-top-event-badge {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
background: var(--o-tie-top-event);
color: #fff;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 10px;
border-radius: 4px;
white-space: nowrap;
z-index: 5;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
pointer-events: none;
}
.o-tie-top-event-label {
flex: 0 1 auto;
max-width: 78%;
justify-content: center;
text-align: center;
padding: 0 12px;
align-items: center;
color: #1a1a1a;
}
.o-tie-node-threat {
border-color: var(--o-tie-threat);
background: var(--o-tie-threat-bg);
}
.o-tie-node-threat .o-tie-node-stripe {
background: var(--o-tie-threat);
color: #fff;
}
.o-tie-node-consequence {
border-color: var(--o-tie-consequence);
background: var(--o-tie-consequence-bg);
}
.o-tie-node-consequence .o-tie-node-stripe {
background: var(--o-tie-consequence);
color: #fff;
}
.o-tie-node-preventionBarrier,
.o-tie-node-mitigationBarrier {
border-color: var(--o-tie-barrier);
background: var(--o-tie-barrier-bg);
border-radius: 4px;
display: flex;
flex-direction: column;
}
.o-tie-node-preventionBarrier .o-tie-node-stripe,
.o-tie-node-mitigationBarrier .o-tie-node-stripe {
background: var(--o-tie-barrier);
color: #fff;
}
.o-tie-barrier-header {
position: relative;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
.o-tie-barrier-header-body {
flex: 1;
display: flex;
align-items: flex-start;
min-height: 0;
overflow: hidden;
}
.o-tie-barrier-header .o-tie-node-label {
flex: 1;
padding: 4px 8px 14px;
overflow: hidden;
}
.o-tie-barrier-stack {
display: flex;
flex-direction: column;
flex: 0 0 auto;
min-height: 0;
}
.o-tie-stack-row {
display: flex;
align-items: center;
padding: 0 8px;
font-size: 10px;
font-weight: 600;
line-height: 1.2;
color: #fff;
border-top: 1px solid rgba(0, 0, 0, 0.08);
overflow: hidden;
flex-shrink: 0;
}
.o-tie-stack-row-light {
color: #1a1a1a;
border-top-color: rgba(0, 0, 0, 0.08);
}
.o-tie-stack-row-preset {
border-left: 3px solid var(--o-tie-barrier);
}
.o-tie-stack-row-clickable {
cursor: pointer;
}
.o-tie-stack-row-clickable:hover {
filter: brightness(0.95);
}
.o-tie-stack-row-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.o-tie-stack-chevron {
position: absolute;
left: 50%;
bottom: auto;
transform: translate(-50%, -50%);
width: 36px;
height: 14px;
padding: 0;
margin: 0;
border-radius: 7px;
border: 2px solid var(--o-tie-barrier);
background: var(--background-primary);
cursor: pointer;
z-index: 22;
pointer-events: auto;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
-webkit-appearance: none;
appearance: none;
}
.o-tie-stack-chevron::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
border-bottom: 4px solid var(--text-muted);
transform: translate(-50%, -65%);
transition: transform 0.15s ease;
}
.o-tie-stack-chevron.is-collapsed::before {
transform: translate(-50%, -35%) rotate(180deg);
}
.o-tie-stack-chevron:hover {
background: var(--background-modifier-hover);
}
.o-tie-stack-add {
position: absolute;
display: none;
place-items: center;
width: 20px;
height: 20px;
padding: 0;
margin: 0;
box-sizing: border-box;
border-radius: 50%;
border: 1px solid var(--o-tie-barrier);
background: #fff;
color: var(--o-tie-barrier);
cursor: pointer;
z-index: 12;
pointer-events: auto;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
-webkit-appearance: none;
appearance: none;
}
.o-tie-node-wrap-preventionBarrier .o-tie-stack-add,
.o-tie-node-wrap-mitigationBarrier .o-tie-stack-add {
top: auto;
right: 0;
bottom: 0;
transform: translate(40%, 40%);
z-index: 21;
}
.o-tie-stack-add.o-tie-plus-btn::before {
width: 8px;
height: 1.5px;
}
.o-tie-stack-add.o-tie-plus-btn::after {
width: 1.5px;
height: 8px;
}
.o-tie-stack-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
font-size: 9px;
font-style: italic;
color: var(--text-muted);
border-top: 1px dashed var(--background-modifier-border);
cursor: pointer;
text-align: center;
}
.o-tie-stack-empty:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.o-tie-node-escalationFactor,
.o-tie-node-escalationBarrier {
border-color: var(--o-tie-escalation);
background: var(--o-tie-escalation-bg);
border-style: dashed;
overflow: hidden;
}
.o-tie-node-escalationFactor .o-tie-node-stripe,
.o-tie-node-escalationBarrier .o-tie-node-stripe {
background: var(--o-tie-escalation);
color: #fff;
}
.o-tie-node-escalationFactor .o-tie-node-label,
.o-tie-node-escalationBarrier .o-tie-node-label {
padding: 6px 8px 14px;
min-height: 0;
overflow: hidden;
}
/* Node controls */
.o-tie-node-delete,
.o-tie-node-add-barrier,
.o-tie-node-add-escalation,
.o-tie-node-add-esc-barrier {
position: absolute;
display: none;
width: 22px;
height: 22px;
padding: 0;
margin: 0;
box-sizing: border-box;
font-size: 14px;
line-height: 1;
border-radius: 50%;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
cursor: pointer;
place-items: center;
z-index: 20;
pointer-events: auto;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
-webkit-appearance: none;
appearance: none;
}
.o-tie-node-wrap:hover .o-tie-node-delete,
.o-tie-node-wrap:hover .o-tie-node-add-barrier,
.o-tie-node-wrap:hover .o-tie-node-add-escalation,
.o-tie-node-wrap:hover .o-tie-node-add-esc-barrier,
.o-tie-node-wrap:hover .o-tie-stack-add,
.o-tie-node-wrap.o-tie-node-selected .o-tie-node-delete,
.o-tie-node-wrap.o-tie-node-selected .o-tie-node-add-barrier,
.o-tie-node-wrap.o-tie-node-selected .o-tie-node-add-escalation,
.o-tie-node-wrap.o-tie-node-selected .o-tie-node-add-esc-barrier,
.o-tie-node-wrap.o-tie-node-selected .o-tie-stack-add {
display: grid;
}
.o-tie-node-delete {
top: 0;
right: 0;
transform: translate(40%, -40%);
color: var(--text-error);
}
.o-tie-close-btn::before,
.o-tie-close-btn::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 9px;
height: 1.5px;
background: currentColor;
border-radius: 1px;
}
.o-tie-close-btn::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.o-tie-close-btn::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.o-tie-plus-btn::before,
.o-tie-plus-btn::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
background: currentColor;
border-radius: 1px;
transform: translate(-50%, -50%);
}
.o-tie-lane-add.o-tie-plus-btn::before {
width: 10px;
height: 2px;
}
.o-tie-lane-add.o-tie-plus-btn::after {
width: 2px;
height: 10px;
}
.o-tie-node-add-barrier.o-tie-plus-btn::before {
width: 8px;
height: 1.5px;
}
.o-tie-node-add-barrier.o-tie-plus-btn::after {
width: 1.5px;
height: 8px;
}
.o-tie-node-add-esc-barrier.o-tie-plus-btn::before {
width: 8px;
height: 1.5px;
}
.o-tie-node-add-esc-barrier.o-tie-plus-btn::after {
width: 1.5px;
height: 8px;
}
.o-tie-node-add-barrier {
bottom: 0;
right: 0;
transform: translate(40%, 40%);
font-weight: 600;
color: var(--o-tie-barrier);
border-color: var(--o-tie-barrier);
background: #fff;
}
.o-tie-node-wrap-threat .o-tie-node-add-barrier,
.o-tie-node-wrap-consequence .o-tie-node-add-barrier {
display: grid;
}
.o-tie-node-add-escalation {
bottom: 0;
left: 0;
transform: translate(-40%, 40%);
font-size: 11px;
background: #fff;
}
.o-tie-node-add-esc-barrier {
bottom: 0;
right: 0;
transform: translate(40%, 40%);
font-weight: 600;
color: var(--o-tie-escalation);
border-color: var(--o-tie-escalation);
background: #fff;
}
.o-tie-lane-add {
position: absolute;
width: 26px;
height: 26px;
padding: 0;
margin: 0;
box-sizing: border-box;
border-radius: 50%;
border: 2px dashed var(--o-tie-barrier);
background: var(--o-tie-barrier-bg);
color: var(--o-tie-barrier);
cursor: pointer;
z-index: 15;
pointer-events: auto;
opacity: 0.85;
-webkit-appearance: none;
appearance: none;
}
.o-tie-lane-add:hover {
opacity: 1;
background: var(--o-tie-barrier);
color: #fff;
}
.o-tie-inline-edit {
width: 100%;
border: none;
background: transparent;
font-size: inherit;
font-family: inherit;
outline: 2px solid var(--interactive-accent);
border-radius: 2px;
padding: 2px 4px;
}
/* Inspector */
.o-tie-inspector {
flex-shrink: 0;
padding: 0.55rem 0.75rem;
border-top: 1px solid var(--background-modifier-border);
background: var(--background-primary);
font-size: var(--font-ui-smaller);
min-height: 2.75rem;
}
.o-tie-inspector-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 2rem;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
.o-tie-inspector-row {
display: flex;
align-items: flex-start;
gap: 0.65rem;
min-width: 0;
}
.o-tie-inspector-kind {
flex-shrink: 0;
font-weight: 700;
text-transform: uppercase;
font-size: 9px;
letter-spacing: 0.05em;
padding: 0.3rem 0.55rem;
border-radius: 4px;
white-space: nowrap;
color: #fff;
}
.o-tie-inspector-kind-hazard { background: var(--o-tie-hazard); }
.o-tie-inspector-kind-topEvent { background: var(--o-tie-top-event); }
.o-tie-inspector-kind-threat { background: var(--o-tie-threat); }
.o-tie-inspector-kind-consequence { background: var(--o-tie-consequence); }
.o-tie-inspector-kind-preventionBarrier,
.o-tie-inspector-kind-mitigationBarrier { background: var(--o-tie-barrier); }
.o-tie-inspector-kind-escalationFactor,
.o-tie-inspector-kind-escalationBarrier { background: var(--o-tie-escalation); }
.o-tie-inspector-fields {
display: flex;
align-items: flex-start;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.o-tie-inspector-input {
flex: 2;
min-width: 0;
height: 1.85rem;
padding: 0 0.55rem;
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
font-size: var(--font-ui-smaller);
}
.o-tie-inspector-input:focus {
border-color: var(--interactive-accent);
outline: none;
}
.o-tie-inspector-notes {
flex: 1;
min-width: 0;
min-height: 1.85rem;
padding: 0.3rem 0.55rem;
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
font-size: var(--font-ui-smaller);
line-height: 1.35;
resize: vertical;
overflow-y: hidden;
box-sizing: border-box;
}
.o-tie-inspector-notes:focus {
border-color: var(--interactive-accent);
outline: none;
}
.o-tie-inspector-actions {
display: flex;
align-items: flex-start;
gap: 0.35rem;
flex-shrink: 0;
padding-top: 0.1rem;
}

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES6"]
},
"include": ["src/**/*.ts"]
}

12
version-bump.mjs Normal file
View file

@ -0,0 +1,12 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const versions = JSON.parse(readFileSync("versions.json", "utf8"));
manifest.version = targetVersion;
versions[targetVersion] = manifest.minAppVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

3
versions.json Normal file
View file

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