mirror of
https://github.com/andre482/O-tie.git
synced 2026-07-22 07:44:11 +00:00
Initial release v1.0.0: O-Tie bowtie diagram plugin for Obsidian.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
14241b0423
26 changed files with 6053 additions and 0 deletions
26
.github/workflows/release.yml
vendored
Normal file
26
.github/workflows/release.yml
vendored
Normal 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
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
main.js
|
||||
main.js.map
|
||||
.DS_Store
|
||||
*.log
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
102
README.md
Normal 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
19
deploy.mjs
Normal 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
28
deploy.ps1
Normal 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
48
esbuild.config.mjs
Normal 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
474
examples/cl-contigo.bowtie
Normal 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"
|
||||
}
|
||||
67
examples/steamcracker.bowtie
Normal file
67
examples/steamcracker.bowtie
Normal 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
10
manifest.json
Normal 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
620
package-lock.json
generated
Normal 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
29
package.json
Normal 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
1719
src/bowtieView.ts
Normal file
File diff suppressed because it is too large
Load diff
51
src/editorModal.ts
Normal file
51
src/editorModal.ts
Normal 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
282
src/exportImage.ts
Normal 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
145
src/exportImageModal.ts
Normal 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
117
src/helpModal.ts
Normal 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
36
src/icons.ts
Normal 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
709
src/layout.ts
Normal 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
124
src/main.ts
Normal 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
305
src/model.ts
Normal 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
111
src/settingsTab.ts
Normal 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
973
styles.css
Normal 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
17
tsconfig.json
Normal 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
12
version-bump.mjs
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "1.4.0"
|
||||
}
|
||||
Loading…
Reference in a new issue