From 14241b042331b0e467179716cb5ced5577394256 Mon Sep 17 00:00:00 2001 From: Andre482 Date: Fri, 12 Jun 2026 14:59:23 +0300 Subject: [PATCH] Initial release v1.0.0: O-Tie bowtie diagram plugin for Obsidian. Co-authored-by: Cursor --- .github/workflows/release.yml | 26 + .gitignore | 5 + LICENSE | 21 + README.md | 102 ++ deploy.mjs | 19 + deploy.ps1 | 28 + esbuild.config.mjs | 48 + examples/cl-contigo.bowtie | 474 +++++++++ examples/steamcracker.bowtie | 67 ++ manifest.json | 10 + package-lock.json | 620 ++++++++++++ package.json | 29 + src/bowtieView.ts | 1719 +++++++++++++++++++++++++++++++++ src/editorModal.ts | 51 + src/exportImage.ts | 282 ++++++ src/exportImageModal.ts | 145 +++ src/helpModal.ts | 117 +++ src/icons.ts | 36 + src/layout.ts | 709 ++++++++++++++ src/main.ts | 124 +++ src/model.ts | 305 ++++++ src/settingsTab.ts | 111 +++ styles.css | 973 +++++++++++++++++++ tsconfig.json | 17 + version-bump.mjs | 12 + versions.json | 3 + 26 files changed, 6053 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 deploy.mjs create mode 100644 deploy.ps1 create mode 100644 esbuild.config.mjs create mode 100644 examples/cl-contigo.bowtie create mode 100644 examples/steamcracker.bowtie create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/bowtieView.ts create mode 100644 src/editorModal.ts create mode 100644 src/exportImage.ts create mode 100644 src/exportImageModal.ts create mode 100644 src/helpModal.ts create mode 100644 src/icons.ts create mode 100644 src/layout.ts create mode 100644 src/main.ts create mode 100644 src/model.ts create mode 100644 src/settingsTab.ts create mode 100644 styles.css create mode 100644 tsconfig.json create mode 100644 version-bump.mjs create mode 100644 versions.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..373efd9 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..193abb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +main.js +main.js.map +.DS_Store +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..71d6d3b --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..339a35c --- /dev/null +++ b/README.md @@ -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 `/.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). diff --git a/deploy.mjs b/deploy.mjs new file mode 100644 index 0000000..7663137 --- /dev/null +++ b/deploy.mjs @@ -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}`); +} diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..13c4222 --- /dev/null +++ b/deploy.ps1 @@ -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." diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..e4197e9 --- /dev/null +++ b/esbuild.config.mjs @@ -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(); +} diff --git a/examples/cl-contigo.bowtie b/examples/cl-contigo.bowtie new file mode 100644 index 0000000..c705d43 --- /dev/null +++ b/examples/cl-contigo.bowtie @@ -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" +} \ No newline at end of file diff --git a/examples/steamcracker.bowtie b/examples/steamcracker.bowtie new file mode 100644 index 0000000..4865971 --- /dev/null +++ b/examples/steamcracker.bowtie @@ -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" +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..4f5b93d --- /dev/null +++ b/manifest.json @@ -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 +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..de4765b --- /dev/null +++ b/package-lock.json @@ -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 + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..298887b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/bowtieView.ts b/src/bowtieView.ts new file mode 100644 index 0000000..cee5970 --- /dev/null +++ b/src/bowtieView.ts @@ -0,0 +1,1719 @@ +import { Menu, Notice, TextFileView, WorkspaceLeaf, setIcon } from "obsidian"; +import { ExportImageModal } from "./exportImageModal"; +import { HelpModal } from "./helpModal"; +import type { BowtieExportViewport } from "./exportImage"; +import { PLUGIN_ICON } from "./icons"; +import type OTiePlugin from "./main"; +import { + barrierHeaderHeightFor, + DEFAULT_LAYOUT, + layoutBowtie, + type EdgePath, + type LayoutConfig, + type PositionedNode, +} from "./layout"; +import { + BARRIER_STACK_FIELDS, + type Barrier, + type BarrierStackItem, + type Bowtie, + BOWTIE_VIEW_TYPE, + createBarrier, + createBarrierStackItem, + cloneBowtie, + createBowtie, + createConsequence, + createEscalationFactor, + createThreat, + deserializeBowtie, + type EscalationFactor, + nodeRefKey, + type NodeRef, + serializeBowtie, + sortBarrierStack, + touchBowtie, +} from "./model"; + +const STACK_ROW_COLOR_OPTIONS: { color: string; label: string }[] = [ + { color: "#5dade2", label: "Sky blue" }, + { color: "#3498db", label: "Blue" }, + { color: "#48c9b0", label: "Teal" }, + { color: "#1abc9c", label: "Mint" }, + { color: "#7f8c8d", label: "Gray" }, + { color: "#1e8449", label: "Dark green" }, + { color: "#27ae60", label: "Green" }, + { color: "#f1c40f", label: "Yellow" }, + { color: "#e67e22", label: "Orange" }, + { color: "#c0392b", label: "Red" }, + { color: "#2c3e50", label: "Navy" }, + { color: "#566573", label: "Slate" }, + { color: "#ffffff", label: "White" }, + { color: "#f4ecf7", label: "Light purple" }, + { color: "#eafaf1", label: "Light green" }, +]; + +const LIGHT_STACK_ROW_COLORS = new Set(["#ffffff", "#f4ecf7", "#eafaf1"]); + +function createColorMenuTitle(color: string, label: string): DocumentFragment { + const frag = document.createDocumentFragment(); + const wrap = frag.createEl("span", { cls: "o-tie-color-menu-title" }); + const swatch = wrap.createEl("span", { cls: "o-tie-color-swatch" }); + swatch.style.backgroundColor = color; + if (LIGHT_STACK_ROW_COLORS.has(color)) { + swatch.addClass("o-tie-color-swatch-light"); + } + wrap.createEl("span", { cls: "o-tie-color-menu-label", text: label }); + return frag; +} + +export { BOWTIE_VIEW_TYPE }; + +export class BowtieView extends TextFileView { + bowtie: Bowtie; + plugin: OTiePlugin; + private selectedRef: NodeRef | null = null; + private containerEl_: HTMLElement; + private viewportEl: HTMLElement; + private stageEl: HTMLElement; + private transformEl: HTMLElement; + private svgEl: SVGSVGElement; + private nodesEl: HTMLElement; + private toolbarEl: HTMLElement; + private inspectorEl: HTMLElement; + private isPanning = false; + private panStart = { x: 0, y: 0 }; + private panOrigin = { x: 0, y: 0 }; + private saveTimeout: number | null = null; + private viewSaveTimeout: number | null = null; + private viewShellReady = false; + private panZoomReady = false; + private wheelRaf: number | null = null; + private wheelDeltaAccum = 0; + private wheelClient = { x: 0, y: 0 }; + private wheelCtrlKey = false; + private toolbarTitleEl: HTMLElement | null = null; + private toolbarActionsEl: HTMLElement | null = null; + private toolbarToggleEl: HTMLButtonElement | null = null; + private toolbarRevealEl: HTMLButtonElement | null = null; + private toolbarCollapsed = false; + private undoBtn: HTMLButtonElement | null = null; + private redoBtn: HTMLButtonElement | null = null; + private undoStack: Bowtie[] = []; + private redoStack: Bowtie[] = []; + private undoSelectionStack: (NodeRef | null)[] = []; + private redoSelectionStack: (NodeRef | null)[] = []; + private isRestoringHistory = false; + private static readonly MAX_UNDO = 50; + private static readonly GRID_SIZE = 20; + private stackCollapseBackup: Map | null = null; + private static readonly useCssZoom = + typeof CSS !== "undefined" && CSS.supports("zoom", "1"); + + constructor(leaf: WorkspaceLeaf, plugin: OTiePlugin) { + super(leaf); + this.plugin = plugin; + this.bowtie = createBowtie("Untitled"); + } + + getViewType(): string { + return BOWTIE_VIEW_TYPE; + } + + getDisplayText(): string { + return this.bowtie.name || "Bowtie"; + } + + getIcon(): string { + return PLUGIN_ICON; + } + + getViewData(): string { + return serializeBowtie(this.bowtie); + } + + setViewData(data: string, clear: boolean): void { + if (clear) this.clear(); + try { + this.bowtie = deserializeBowtie(data); + } catch { + this.bowtie = createBowtie("Untitled"); + } + this.resetHistory(); + this.render(); + } + + clear(): void { + this.contentEl.empty(); + this.viewShellReady = false; + this.panZoomReady = false; + this.toolbarTitleEl = null; + this.undoBtn = null; + this.redoBtn = null; + this.resetHistory(); + } + + private getLayoutConfig(): LayoutConfig { + const s = this.plugin.settings; + return { + ...DEFAULT_LAYOUT, + columnGap: s.columnGap, + rowGap: s.rowGap, + nodeWidth: s.nodeWidth, + nodeHeight: s.nodeHeight, + }; + } + + private scheduleSave(): void { + if (this.saveTimeout !== null) window.clearTimeout(this.saveTimeout); + this.saveTimeout = window.setTimeout(() => { + this.bowtie = touchBowtie(this.bowtie); + this.requestSave(); + }, 400); + } + + private resetHistory(): void { + this.undoStack = []; + this.redoStack = []; + this.undoSelectionStack = []; + this.redoSelectionStack = []; + this.updateUndoRedoButtons(); + } + + private commitEdit(): void { + if (this.isRestoringHistory) return; + this.undoStack.push(cloneBowtie(this.bowtie)); + this.undoSelectionStack.push(this.selectedRef ? { ...this.selectedRef } : null); + if (this.undoStack.length > BowtieView.MAX_UNDO) { + this.undoStack.shift(); + this.undoSelectionStack.shift(); + } + this.redoStack = []; + this.redoSelectionStack = []; + this.updateUndoRedoButtons(); + } + + private updateUndoRedoButtons(): void { + if (this.undoBtn) this.undoBtn.disabled = this.undoStack.length === 0; + if (this.redoBtn) this.redoBtn.disabled = this.redoStack.length === 0; + } + + private nodeRefExists(ref: NodeRef): boolean { + switch (ref.kind) { + case "hazard": + case "topEvent": + return true; + case "threat": + return this.bowtie.threats.some((t) => t.id === ref.threatId); + case "consequence": + return this.bowtie.consequences.some((c) => c.id === ref.consequenceId); + case "preventionBarrier": + case "mitigationBarrier": + return this.findBarrier(ref) !== null; + case "escalationFactor": + return this.findEscalationFactor(ref) !== null; + case "escalationBarrier": { + const factor = this.findEscalationFactor(ref); + return factor?.escalationBarriers.some((eb) => eb.id === ref.escalationBarrierId) ?? false; + } + } + } + + private restoreHistorySnapshot(snapshot: Bowtie, selectedRef: NodeRef | null): void { + this.isRestoringHistory = true; + this.bowtie = snapshot; + this.selectedRef = selectedRef && this.nodeRefExists(selectedRef) ? { ...selectedRef } : null; + this.render(); + this.bowtie = touchBowtie(this.bowtie); + this.requestSave(); + this.isRestoringHistory = false; + this.updateUndoRedoButtons(); + } + + private undo(): void { + if (this.undoStack.length === 0) return; + this.redoStack.push(cloneBowtie(this.bowtie)); + this.redoSelectionStack.push(this.selectedRef ? { ...this.selectedRef } : null); + const snapshot = this.undoStack.pop()!; + const selectedRef = this.undoSelectionStack.pop() ?? null; + this.restoreHistorySnapshot(snapshot, selectedRef); + } + + private redo(): void { + if (this.redoStack.length === 0) return; + this.undoStack.push(cloneBowtie(this.bowtie)); + this.undoSelectionStack.push(this.selectedRef ? { ...this.selectedRef } : null); + const snapshot = this.redoStack.pop()!; + const selectedRef = this.redoSelectionStack.pop() ?? null; + this.restoreHistorySnapshot(snapshot, selectedRef); + } + + private render(): void { + this.ensureViewShell(); + this.updateToolbarTitle(); + this.renderDiagram(); + this.renderInspector(); + } + + private ensureViewShell(): void { + if (this.viewShellReady) return; + + this.contentEl.empty(); + this.contentEl.addClass("o-tie-view-root"); + + this.toolbarEl = this.contentEl.createDiv({ cls: "o-tie-toolbar" }); + this.renderToolbar(); + + this.containerEl_ = this.contentEl.createDiv({ cls: "o-tie-canvas-container" }); + this.toolbarRevealEl = this.containerEl_.createEl("button", { + cls: "o-tie-toolbar-btn o-tie-toolbar-btn-icon o-tie-toolbar-reveal", + }); + this.toolbarRevealEl.setAttribute("aria-label", "Show toolbar"); + this.toolbarRevealEl.addEventListener("mousedown", (e) => e.stopPropagation()); + this.toolbarRevealEl.addEventListener("click", (e) => { + e.stopPropagation(); + if (this.toolbarCollapsed) this.toggleToolbarCollapsed(); + }); + + this.viewportEl = this.containerEl_.createDiv({ cls: "o-tie-viewport" }); + this.stageEl = this.viewportEl.createDiv({ cls: "o-tie-stage" }); + + this.transformEl = this.stageEl.createDiv({ cls: "o-tie-transform" }); + const ns = "http://www.w3.org/2000/svg"; + this.svgEl = document.createElementNS(ns, "svg"); + this.svgEl.classList.add("o-tie-svg"); + this.transformEl.appendChild(this.svgEl); + + this.nodesEl = this.transformEl.createDiv({ cls: "o-tie-nodes" }); + + this.inspectorEl = this.contentEl.createDiv({ cls: "o-tie-inspector" }); + + this.setupPanZoom(); + this.viewShellReady = true; + } + + private updateToolbarTitle(): void { + this.toolbarTitleEl?.setText(this.bowtie.name); + } + + private renderToolbar(): void { + this.toolbarEl.empty(); + + const brand = this.toolbarEl.createDiv({ cls: "o-tie-toolbar-brand" }); + this.toolbarTitleEl = brand.createSpan({ + cls: "o-tie-toolbar-title", + text: this.bowtie.name, + }); + this.toolbarTitleEl.addEventListener("dblclick", () => + this.startInlineEdit(this.toolbarTitleEl!, this.bowtie.name, (v) => { + this.bowtie.name = v; + this.toolbarTitleEl!.setText(v); + this.scheduleSave(); + }) + ); + + this.toolbarActionsEl = this.toolbarEl.createDiv({ cls: "o-tie-toolbar-actions" }); + + const historyGroup = this.toolbarActionsEl.createDiv({ cls: "o-tie-toolbar-group" }); + this.undoBtn = this.createToolbarBtn(historyGroup, "←", () => this.undo(), { + icon: true, + title: "Undo", + }); + this.redoBtn = this.createToolbarBtn(historyGroup, "→", () => this.redo(), { + icon: true, + title: "Redo", + }); + this.toolbarActionsEl.createDiv({ cls: "o-tie-toolbar-separator" }); + + const addGroup = this.toolbarActionsEl.createDiv({ cls: "o-tie-toolbar-group" }); + this.createToolbarBtn(addGroup, "+ Threat", () => this.addThreat(), { primary: true }); + this.createToolbarBtn(addGroup, "+ Consequence", () => this.addConsequence(), { primary: true }); + this.createToolbarBtn(addGroup, "+ Barrier", () => this.addBarrierToSelection()); + + this.toolbarActionsEl.createDiv({ cls: "o-tie-toolbar-separator" }); + + const viewGroup = this.toolbarActionsEl.createDiv({ cls: "o-tie-toolbar-group" }); + this.createToolbarBtn(viewGroup, "Fit", () => this.fitToView(), { title: "Fit diagram to view" }); + this.createToolbarBtn(viewGroup, "−", () => this.adjustZoom(1 / 1.15), { + icon: true, + title: "Zoom out", + }); + this.createToolbarBtn(viewGroup, "+", () => this.adjustZoom(1.15), { + icon: true, + title: "Zoom in", + }); + this.createToolbarIconBtn(viewGroup, "download", () => this.openExportImageModal(), { + title: "Export as image", + }); + + const toolbarEnd = this.toolbarEl.createDiv({ cls: "o-tie-toolbar-end" }); + this.createToolbarIconBtn(toolbarEnd, "circle-help", () => this.openHelpModal(), { + title: "Help", + }); + this.toolbarToggleEl = toolbarEnd.createEl("button", { + cls: "o-tie-toolbar-btn o-tie-toolbar-btn-icon o-tie-toolbar-toggle", + }); + this.toolbarToggleEl.addEventListener("click", () => this.toggleToolbarCollapsed()); + this.updateToolbarCollapsedUi(); + this.updateUndoRedoButtons(); + } + + private toggleToolbarCollapsed(): void { + this.toolbarCollapsed = !this.toolbarCollapsed; + this.updateToolbarCollapsedUi(); + } + + private updateToolbarCollapsedUi(): void { + this.contentEl?.toggleClass("o-tie-toolbar-is-collapsed", this.toolbarCollapsed); + this.toolbarToggleEl?.setAttribute( + "aria-label", + this.toolbarCollapsed ? "Show toolbar" : "Hide toolbar" + ); + } + + private createToolbarBtn( + parent: HTMLElement, + label: string, + action: () => void, + opts: { primary?: boolean; icon?: boolean; title?: string } = {} + ): HTMLButtonElement { + const cls = ["o-tie-toolbar-btn"]; + if (opts.primary) cls.push("o-tie-toolbar-btn-primary"); + if (opts.icon) cls.push("o-tie-toolbar-btn-icon"); + const btn = parent.createEl("button", { cls: cls.join(" "), text: label }); + btn.setAttribute("aria-label", opts.title ?? label); + btn.addEventListener("click", action); + return btn; + } + + private createToolbarIconBtn( + parent: HTMLElement, + icon: string, + action: () => void, + opts: { title: string } + ): HTMLButtonElement { + const btn = parent.createEl("button", { + cls: "o-tie-toolbar-btn o-tie-toolbar-btn-icon", + }); + btn.setAttribute("aria-label", opts.title); + btn.setAttribute("title", opts.title); + setIcon(btn, icon); + btn.addEventListener("click", action); + return btn; + } + + private renderInspector(): void { + this.inspectorEl.empty(); + if (!this.selectedRef) { + const empty = this.inspectorEl.createDiv({ cls: "o-tie-inspector-empty" }); + empty.setText("Click a node to inspect and edit its properties"); + return; + } + + const label = this.getNodeLabel(this.selectedRef); + const subtitle = this.getNodeSubtitle(this.selectedRef); + + const row = this.inspectorEl.createDiv({ cls: "o-tie-inspector-row" }); + row.createSpan({ + cls: `o-tie-inspector-kind o-tie-inspector-kind-${this.selectedRef.kind}`, + text: subtitle, + }); + + const fields = row.createDiv({ cls: "o-tie-inspector-fields" }); + const input = fields.createEl("input", { + type: "text", + cls: "o-tie-inspector-input", + }); + input.value = label; + input.placeholder = "Label"; + input.addEventListener("change", () => { + if (input.value === label) return; + this.commitEdit(); + this.setNodeLabel(this.selectedRef!, input.value); + this.render(); + this.scheduleSave(); + }); + + const notes = this.getNodeNotes(this.selectedRef); + const notesArea = fields.createEl("textarea", { + cls: "o-tie-inspector-notes", + placeholder: "Notes", + attr: { rows: "2" }, + }); + notesArea.value = notes; + requestAnimationFrame(() => this.fitInspectorNotesArea(notesArea)); + notesArea.addEventListener("input", () => this.fitInspectorNotesArea(notesArea)); + notesArea.addEventListener("change", () => { + if (notesArea.value === notes) return; + this.commitEdit(); + this.setNodeNotes(this.selectedRef!, notesArea.value); + this.scheduleSave(); + }); + + const actions = row.createDiv({ cls: "o-tie-inspector-actions" }); + + if (this.selectedRef.kind === "preventionBarrier" || this.selectedRef.kind === "mitigationBarrier") { + const stackBtn = actions.createEl("button", { text: "+ Stack row", cls: "mod-small" }); + stackBtn.addEventListener("click", (e) => { + const rect = stackBtn.getBoundingClientRect(); + this.showAddStackRowMenu( + { clientX: rect.left, clientY: rect.bottom } as MouseEvent, + this.selectedRef! + ); + }); + const escBtn = actions.createEl("button", { text: "+ Escalation factor", cls: "mod-small" }); + escBtn.addEventListener("click", () => { + this.addEscalationFactor(this.selectedRef!); + }); + } + + if (this.selectedRef.kind === "escalationFactor") { + const escBarBtn = actions.createEl("button", { + text: "+ Escalation barrier", + cls: "mod-small", + }); + escBarBtn.addEventListener("click", () => { + this.addEscalationBarrier(this.selectedRef!); + }); + } + + if (this.selectedRef.kind === "threat") { + const barBtn = actions.createEl("button", { text: "+ Prevention Barrier", cls: "mod-cta mod-small" }); + barBtn.addEventListener("click", () => { + this.addPreventionBarrier(this.selectedRef!.threatId!); + }); + } + + if (this.selectedRef.kind === "consequence") { + const barBtn = actions.createEl("button", { text: "+ Mitigation Barrier", cls: "mod-cta mod-small" }); + barBtn.addEventListener("click", () => { + this.addMitigationBarrier(this.selectedRef!.consequenceId!); + }); + } + + const delBtn = actions.createEl("button", { text: "Delete", cls: "mod-warning mod-small" }); + delBtn.addEventListener("click", () => { + this.deleteNode(this.selectedRef!); + }); + } + + private fitInspectorNotesArea(textarea: HTMLTextAreaElement): void { + textarea.style.height = "0"; + textarea.style.height = `${textarea.scrollHeight}px`; + } + + private renderDiagram(): void { + const layout = layoutBowtie(this.bowtie, this.getLayoutConfig()); + + this.transformEl.style.width = `${layout.bounds.width}px`; + this.transformEl.style.height = `${layout.bounds.height}px`; + this.svgEl.setAttribute("width", String(layout.bounds.width)); + this.svgEl.setAttribute("height", String(layout.bounds.height)); + this.svgEl.innerHTML = ""; + + for (const edge of layout.edges) { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", edge.path); + path.setAttribute("class", `o-tie-edge o-tie-edge-${edge.kind}`); + this.svgEl.appendChild(path); + this.renderEdgeArrow(edge); + } + + this.nodesEl.empty(); + for (const node of layout.nodes) { + this.renderNode(node); + } + this.renderLaneAddButtons(layout); + + this.applyTransform(); + + if (!this.bowtie.view || (this.bowtie.view.panX === 0 && this.bowtie.view.panY === 0 && this.bowtie.view.zoom === 1)) { + window.setTimeout(() => this.fitToView(false), 0); + } + } + + private renderEdgeArrow(edge: EdgePath): void { + const ns = "http://www.w3.org/2000/svg"; + const { x, y, angleDeg } = edge.arrow; + const size = 8; + + const group = document.createElementNS(ns, "g"); + group.setAttribute("class", `o-tie-edge-arrow o-tie-edge-arrow-${edge.kind}`); + group.setAttribute("transform", `translate(${x} ${y}) rotate(${angleDeg})`); + + const head = document.createElementNS(ns, "path"); + head.setAttribute("d", `M0 0 L-${size} ${-size * 0.42} L-${size} ${size * 0.42} Z`); + group.appendChild(head); + this.svgEl.appendChild(group); + } + + private renderNode(node: PositionedNode): void { + const wrap = this.nodesEl.createDiv({ + cls: `o-tie-node-wrap o-tie-node-wrap-${node.kind}`, + attr: { "data-ref": nodeRefKey(node.ref) }, + }); + wrap.style.left = `${node.x}px`; + wrap.style.top = `${node.y}px`; + wrap.style.width = `${node.width}px`; + wrap.style.height = `${node.height}px`; + + if (this.selectedRef && nodeRefKey(this.selectedRef) === nodeRefKey(node.ref)) { + wrap.addClass("o-tie-node-selected"); + } + + const el = wrap.createDiv({ cls: `o-tie-node o-tie-node-${node.kind}` }); + + const isBarrier = + node.kind === "preventionBarrier" || node.kind === "mitigationBarrier"; + let labelEl: HTMLElement; + + if (isBarrier) { + labelEl = this.renderBarrierContent(wrap, el, node); + } else if (node.kind === "topEvent") { + wrap.createDiv({ cls: "o-tie-top-event-badge", text: node.subtitle }); + labelEl = el.createDiv({ + cls: "o-tie-node-label o-tie-top-event-label", + text: node.label, + }); + } else { + const stripe = el.createDiv({ cls: "o-tie-node-stripe" }); + stripe.setText(node.subtitle); + labelEl = el.createDiv({ cls: "o-tie-node-label", text: node.label }); + } + + const deleteBtn = wrap.createEl("button", { cls: "o-tie-node-delete o-tie-close-btn" }); + deleteBtn.setAttribute("aria-label", "Delete"); + deleteBtn.addEventListener("mousedown", (e) => e.stopPropagation()); + deleteBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.deleteNode(node.ref); + }); + + if (node.kind === "threat" || node.kind === "consequence") { + const addBar = wrap.createEl("button", { cls: "o-tie-node-add-barrier o-tie-plus-btn" }); + addBar.setAttribute("aria-label", "Add barrier"); + addBar.addEventListener("mousedown", (e) => e.stopPropagation()); + addBar.addEventListener("click", (e) => { + e.stopPropagation(); + if (node.kind === "threat" && node.ref.threatId) { + this.addPreventionBarrier(node.ref.threatId); + } else if (node.kind === "consequence" && node.ref.consequenceId) { + this.addMitigationBarrier(node.ref.consequenceId); + } + }); + } + + if (isBarrier) { + const addEsc = wrap.createEl("button", { cls: "o-tie-node-add-escalation", text: "⚡" }); + addEsc.setAttribute("aria-label", "Add escalation factor"); + addEsc.addEventListener("mousedown", (e) => e.stopPropagation()); + addEsc.addEventListener("click", (e) => { + e.stopPropagation(); + this.addEscalationFactor(node.ref); + }); + } + + if (node.kind === "escalationFactor") { + const addEscBar = wrap.createEl("button", { + cls: "o-tie-node-add-esc-barrier o-tie-plus-btn", + }); + addEscBar.setAttribute("aria-label", "Add escalation barrier"); + addEscBar.addEventListener("mousedown", (e) => e.stopPropagation()); + addEscBar.addEventListener("click", (e) => { + e.stopPropagation(); + this.addEscalationBarrier(node.ref); + }); + } + + wrap.addEventListener("click", (e) => { + if ((e.target as HTMLElement).closest("button")) return; + e.stopPropagation(); + this.selectedRef = node.ref; + this.renderInspector(); + this.nodesEl.querySelectorAll(".o-tie-node-wrap").forEach((n) => n.removeClass("o-tie-node-selected")); + wrap.addClass("o-tie-node-selected"); + }); + + if (!isBarrier) { + el.addEventListener("dblclick", (e) => { + e.stopPropagation(); + this.startInlineEdit(labelEl, node.label, (v) => { + this.setNodeLabel(node.ref, v); + this.render(); + this.scheduleSave(); + }); + }); + } + + wrap.addEventListener("contextmenu", (e) => { + e.preventDefault(); + this.showContextMenu(e, node.ref); + }); + } + + private renderBarrierContent( + wrap: HTMLElement, + el: HTMLElement, + node: PositionedNode + ): HTMLElement { + const barrier = this.findBarrier(node.ref); + const layout = this.getLayoutConfig(); + const stack = barrier?.stack ?? []; + const collapsed = barrier?.stackCollapsed ?? true; + + const headerH = barrier + ? barrierHeaderHeightFor(barrier, layout) + : layout.barrierHeaderHeight; + + const header = el.createDiv({ cls: "o-tie-barrier-header" }); + header.style.height = `${headerH}px`; + header.style.minHeight = `${headerH}px`; + + const stripe = header.createDiv({ cls: "o-tie-node-stripe" }); + stripe.setText(node.subtitle); + + const headerBody = header.createDiv({ cls: "o-tie-barrier-header-body" }); + const labelEl = headerBody.createDiv({ cls: "o-tie-node-label", text: node.label }); + labelEl.addEventListener("dblclick", (e) => { + e.stopPropagation(); + this.startInlineEdit(labelEl, node.label, (v) => { + this.setNodeLabel(node.ref, v); + this.render(); + this.scheduleSave(); + }); + }); + + const addStack = wrap.createEl("button", { + cls: "o-tie-stack-add o-tie-plus-btn", + }); + addStack.setAttribute("aria-label", "Add stack row"); + addStack.addEventListener("mousedown", (e) => e.stopPropagation()); + addStack.addEventListener("click", (e) => { + e.stopPropagation(); + this.showAddStackRowMenu(e, node.ref); + }); + + if (stack.length > 0) { + const chevron = wrap.createEl("button", { + cls: `o-tie-stack-chevron${collapsed ? " is-collapsed" : ""}`, + }); + chevron.style.top = `${headerH}px`; + chevron.setAttribute("aria-label", collapsed ? "Expand stack" : "Collapse stack"); + chevron.addEventListener("mousedown", (e) => e.stopPropagation()); + chevron.addEventListener("click", (e) => { + e.stopPropagation(); + this.toggleBarrierStackCollapsed(node.ref); + }); + } + + if (!collapsed) { + const stackEl = el.createDiv({ cls: "o-tie-barrier-stack" }); + if (stack.length === 0) { + const placeholder = stackEl.createDiv({ cls: "o-tie-stack-empty" }); + placeholder.setText("Click + to add analysis row"); + placeholder.style.height = `${layout.barrierStackRowHeight}px`; + placeholder.addEventListener("click", (e) => { + e.stopPropagation(); + this.showAddStackRowMenu(e, node.ref); + }); + } else { + for (const item of stack) { + this.renderStackRow(stackEl, node.ref, item, layout); + } + } + } + + if (collapsed) { + wrap.addClass("o-tie-barrier-stack-collapsed"); + } + + return labelEl; + } + + private renderStackRow( + stackEl: HTMLElement, + ref: NodeRef, + item: BarrierStackItem, + layout: import("./layout").LayoutConfig + ): void { + const row = stackEl.createDiv({ + cls: "o-tie-stack-row", + attr: { "data-stack-id": item.id }, + }); + row.style.height = `${layout.barrierStackRowHeight}px`; + if (item.color) { + row.style.backgroundColor = item.color; + if (item.color === "#ffffff" || item.color === "#f4ecf7" || item.color === "#eafaf1") { + row.addClass("o-tie-stack-row-light"); + } + } + + const fieldDef = item.field + ? BARRIER_STACK_FIELDS.find((f) => f.key === item.field) + : undefined; + if (fieldDef) { + row.style.borderLeftColor = "var(--o-tie-barrier)"; + row.addClass("o-tie-stack-row-preset"); + } + + const labelEl = row.createDiv({ cls: "o-tie-stack-row-label", text: item.label }); + if (fieldDef) { + row.setAttribute("aria-label", `Click to change ${fieldDef.name}`); + row.addClass("o-tie-stack-row-clickable"); + row.addEventListener("click", (e) => { + e.stopPropagation(); + this.showChangeStackRowMenu(e, ref, item.id); + }); + } else { + labelEl.addEventListener("dblclick", (e) => { + e.stopPropagation(); + this.startInlineEdit(labelEl, item.label, (v) => { + this.updateStackRowLabel(ref, item.id, v); + }); + }); + } + + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + this.showStackRowContextMenu(e, ref, item.id); + }); + } + + private toggleBarrierStackCollapsed(ref: NodeRef): void { + const barrier = this.findBarrier(ref); + if (!barrier) return; + this.commitEdit(); + barrier.stackCollapsed = !(barrier.stackCollapsed ?? false); + this.render(); + this.scheduleSave(); + } + + private barrierStackHasField(barrier: Barrier, fieldKey: string): boolean { + return (barrier.stack ?? []).some((item) => item.field === fieldKey); + } + + private showAddStackRowMenu(event: MouseEvent, ref: NodeRef): void { + const barrier = this.findBarrier(ref); + if (!barrier) return; + + const menu = new Menu(); + let hasPresetOptions = false; + for (const field of BARRIER_STACK_FIELDS) { + if (this.barrierStackHasField(barrier, field.key)) continue; + hasPresetOptions = true; + menu.addItem((item) => item.setTitle(field.name).setIsLabel(true)); + for (const option of field.options) { + menu.addItem((item) => + item.setTitle(option.label).onClick(() => { + this.addStackRow(ref, field.key, option.label, option.color); + }) + ); + } + menu.addSeparator(); + } + if (!hasPresetOptions) { + menu.addItem((item) => + item.setTitle("All standard fields are already on this barrier").setIsLabel(true) + ); + menu.addSeparator(); + } + menu.addItem((item) => + item.setTitle("Custom row").setIcon("plus").onClick(() => { + this.addStackRow(ref); + }) + ); + menu.showAtPosition({ x: event.clientX, y: event.clientY }); + } + + private showChangeStackRowMenu(event: MouseEvent, ref: NodeRef, itemId: string): void { + const barrier = this.findBarrier(ref); + const stackItem = barrier?.stack?.find((s) => s.id === itemId); + if (!stackItem?.field) return; + + const fieldDef = BARRIER_STACK_FIELDS.find((f) => f.key === stackItem.field); + if (!fieldDef) return; + + const menu = new Menu(); + menu.addItem((item) => item.setTitle(fieldDef.name).setIsLabel(true)); + for (const option of fieldDef.options) { + menu.addItem((item) => + item + .setTitle(option.label) + .setChecked(option.label === stackItem.label) + .onClick(() => { + this.setStackRowOption(ref, itemId, option.label, option.color); + }) + ); + } + menu.showAtPosition({ x: event.clientX, y: event.clientY }); + } + + private addStackRow( + ref: NodeRef, + field?: string, + label = "New row", + color?: string + ): void { + const barrier = this.findBarrier(ref); + if (!barrier) return; + if (field && this.barrierStackHasField(barrier, field)) { + const fieldName = BARRIER_STACK_FIELDS.find((f) => f.key === field)?.name ?? field; + new Notice(`${fieldName} is already on this barrier. Click the row to change it.`); + return; + } + this.commitEdit(); + if (!barrier.stack) barrier.stack = []; + const item = createBarrierStackItem(label, field, color); + barrier.stack.push(item); + sortBarrierStack(barrier.stack); + barrier.stackCollapsed = false; + this.render(); + this.scheduleSave(); + + if (!field) { + window.setTimeout(() => { + const rowEl = this.nodesEl.querySelector( + `[data-ref="${nodeRefKey(ref)}"] [data-stack-id="${item.id}"] .o-tie-stack-row-label` + ) as HTMLElement | null; + if (rowEl) { + this.startInlineEdit(rowEl, label, (v) => { + this.updateStackRowLabel(ref, item.id, v); + }); + } + }, 0); + } + } + + private setStackRowOption( + ref: NodeRef, + itemId: string, + label: string, + color: string + ): void { + const barrier = this.findBarrier(ref); + const item = barrier?.stack?.find((s) => s.id === itemId); + if (!item) return; + this.commitEdit(); + item.label = label; + item.color = color; + this.render(); + this.scheduleSave(); + } + + private updateStackRowLabel(ref: NodeRef, itemId: string, label: string): void { + const barrier = this.findBarrier(ref); + const item = barrier?.stack?.find((s) => s.id === itemId); + if (!item || item.field) return; + item.label = label.trim() || item.label; + this.render(); + this.scheduleSave(); + } + + private updateStackRowColor(ref: NodeRef, itemId: string, color: string): void { + const barrier = this.findBarrier(ref); + const item = barrier?.stack?.find((s) => s.id === itemId); + if (!item || item.color === color) return; + this.commitEdit(); + item.color = color; + this.render(); + this.scheduleSave(); + } + + private deleteStackRow(ref: NodeRef, itemId: string): void { + const barrier = this.findBarrier(ref); + if (!barrier?.stack) return; + this.commitEdit(); + barrier.stack = barrier.stack.filter((s) => s.id !== itemId); + this.render(); + this.scheduleSave(); + } + + private showStackRowContextMenu(event: MouseEvent, ref: NodeRef, itemId: string): void { + const barrier = this.findBarrier(ref); + const stackItem = barrier?.stack?.find((s) => s.id === itemId); + if (!stackItem) return; + + const menu = new Menu(); + if (stackItem.field) { + menu.addItem((item) => + item + .setTitle("Change value") + .setIcon("pencil") + .onClick(() => this.showChangeStackRowMenu(event, ref, itemId)) + ); + } else { + menu.addItem((item) => + item.setTitle("Edit label").setIcon("pencil").onClick(() => { + const rowEl = this.nodesEl.querySelector( + `[data-ref="${nodeRefKey(ref)}"] [data-stack-id="${itemId}"] .o-tie-stack-row-label` + ) as HTMLElement | null; + if (rowEl) { + this.startInlineEdit(rowEl, stackItem.label, (v) => { + this.updateStackRowLabel(ref, itemId, v); + }); + } + }) + ); + menu.addItem((item) => item.setTitle("Change color").setIsLabel(true)); + for (const option of STACK_ROW_COLOR_OPTIONS) { + menu.addItem((item) => + item + .setTitle(createColorMenuTitle(option.color, option.label)) + .setChecked(stackItem.color === option.color) + .onClick(() => { + this.updateStackRowColor(ref, itemId, option.color); + }) + ); + } + menu.addSeparator(); + } + + menu.addItem((item) => + item.setTitle("Delete row").setIcon("trash").onClick(() => { + this.deleteStackRow(ref, itemId); + }) + ); + menu.showAtPosition({ x: event.clientX, y: event.clientY }); + } + + private showContextMenu(event: MouseEvent, ref: NodeRef): void { + const menu = new Menu(); + menu.addItem((item) => + item.setTitle("Rename").setIcon("pencil").onClick(() => { + const label = this.getNodeLabel(ref); + const nodeEl = this.nodesEl.querySelector( + `[data-ref="${nodeRefKey(ref)}"] .o-tie-node-label` + ) as HTMLElement; + if (nodeEl) { + this.startInlineEdit(nodeEl, label, (v) => { + this.setNodeLabel(ref, v); + this.render(); + this.scheduleSave(); + }); + } + }) + ); + menu.addItem((item) => + item.setTitle("Delete").setIcon("trash").onClick(() => this.deleteNode(ref)) + ); + if (ref.kind === "threat" && ref.threatId) { + menu.addItem((item) => + item + .setTitle("Add prevention barrier") + .setIcon("shield") + .onClick(() => this.addPreventionBarrier(ref.threatId!)) + ); + } + if (ref.kind === "consequence" && ref.consequenceId) { + menu.addItem((item) => + item + .setTitle("Add mitigation barrier") + .setIcon("shield") + .onClick(() => this.addMitigationBarrier(ref.consequenceId!)) + ); + } + if (ref.kind === "preventionBarrier" || ref.kind === "mitigationBarrier") { + menu.addItem((item) => + item + .setTitle("Add stack row") + .setIcon("layers") + .onClick(() => this.showAddStackRowMenu(event, ref)) + ); + menu.addItem((item) => + item.setTitle("Add escalation factor").setIcon("zap").onClick(() => this.addEscalationFactor(ref)) + ); + } + if (ref.kind === "escalationFactor") { + menu.addItem((item) => + item + .setTitle("Add escalation barrier") + .setIcon("shield") + .onClick(() => this.addEscalationBarrier(ref)) + ); + } + menu.showAtPosition({ x: event.clientX, y: event.clientY }); + } + + private startInlineEdit( + el: HTMLElement, + initial: string, + onCommit: (value: string) => void + ): void { + const input = document.createElement("input"); + input.type = "text"; + input.className = "o-tie-inline-edit"; + input.value = initial; + el.empty(); + el.appendChild(input); + input.focus(); + input.select(); + + const commit = () => { + const v = input.value.trim(); + if (v && v !== initial) { + this.commitEdit(); + onCommit(v); + } else { + el.setText(initial); + } + }; + + input.addEventListener("blur", commit); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + commit(); + } + if (e.key === "Escape") { + el.setText(initial); + } + }); + } + + private setupPanZoom(): void { + if (this.panZoomReady) return; + this.panZoomReady = true; + + this.registerDomEvent(this.containerEl_, "contextmenu", (e) => { + const target = e.target as HTMLElement; + if (target.closest(".o-tie-node-wrap") || target.closest(".o-tie-lane-add")) { + return; + } + e.preventDefault(); + const menu = new Menu(); + menu.addItem((item) => + item + .setTitle("Export as image") + .setIcon("download") + .onClick(() => this.openExportImageModal()) + ); + menu.showAtMouseEvent(e); + }); + + this.registerDomEvent(this.containerEl_, "mousedown", (e) => { + const target = e.target as HTMLElement; + if ( + target.closest(".o-tie-node-wrap") || + target.closest(".o-tie-lane-add") || + target.closest("button") + ) { + return; + } + this.isPanning = true; + this.panStart = { x: e.clientX, y: e.clientY }; + this.panOrigin = { + x: this.bowtie.view?.panX ?? 0, + y: this.bowtie.view?.panY ?? 0, + }; + this.containerEl_.addClass("o-tie-panning"); + }); + + this.registerDomEvent(window, "mousemove", (e) => { + if (!this.isPanning) return; + const dx = e.clientX - this.panStart.x; + const dy = e.clientY - this.panStart.y; + if (!this.bowtie.view) this.bowtie.view = { zoom: 1, panX: 0, panY: 0 }; + this.bowtie.view.panX = this.panOrigin.x + dx; + this.bowtie.view.panY = this.panOrigin.y + dy; + this.applyTransform(); + }); + + this.registerDomEvent(window, "mouseup", () => { + if (this.isPanning) { + this.isPanning = false; + this.containerEl_.removeClass("o-tie-panning"); + this.scheduleViewSave(); + } + }); + + this.registerDomEvent( + this.containerEl_, + "wheel", + (e) => { + e.preventDefault(); + this.wheelDeltaAccum += this.normalizeWheelDelta(e); + this.wheelClient = { x: e.clientX, y: e.clientY }; + this.wheelCtrlKey = e.ctrlKey; + if (this.wheelRaf !== null) return; + this.wheelRaf = window.requestAnimationFrame(() => { + this.wheelRaf = null; + const dy = this.wheelDeltaAccum; + this.wheelDeltaAccum = 0; + if (Math.abs(dy) < 0.01) return; + const factor = this.wheelFactorFromDelta(dy, this.wheelCtrlKey); + this.zoomAt(this.wheelClient.x, this.wheelClient.y, factor); + }); + }, + { passive: false } + ); + + this.registerDomEvent(this.containerEl_, "click", (e) => { + if ((e.target as HTMLElement).closest(".o-tie-node-wrap")) return; + this.selectedRef = null; + this.renderInspector(); + this.nodesEl.querySelectorAll(".o-tie-node-wrap").forEach((n) => n.removeClass("o-tie-node-selected")); + }); + + this.registerDomEvent(document, "keydown", (e) => { + if (this.app.workspace.getActiveViewOfType(BowtieView) !== this) return; + const target = e.target as HTMLElement; + if (target.closest("input, textarea, [contenteditable='true']")) return; + + const mod = e.ctrlKey || e.metaKey; + if (mod && e.key.toLowerCase() === "z" && !e.shiftKey) { + e.preventDefault(); + this.undo(); + return; + } + if (mod && (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) { + e.preventDefault(); + this.redo(); + return; + } + if (e.key === "Delete" && this.selectedRef) { + e.preventDefault(); + this.deleteNode(this.selectedRef); + } + }); + + const resizeObserver = new ResizeObserver(() => { + this.applyTransform(); + }); + resizeObserver.observe(this.containerEl_); + this.register(() => resizeObserver.disconnect()); + } + + private normalizeWheelDelta(e: WheelEvent): number { + let dy = e.deltaY; + if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) { + dy *= 16; + } else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + dy *= this.containerEl_.clientHeight || window.innerHeight; + } + return dy; + } + + private wheelFactorFromDelta(deltaY: number, ctrlKey: boolean): number { + // Gentler zoom: ~5% per mouse-wheel notch, smooth trackpad pinch. + const sensitivity = ctrlKey ? 0.0012 : 0.00045; + const factor = Math.exp(-deltaY * sensitivity); + const maxStep = ctrlKey ? 1.08 : 1.06; + const minStep = 1 / maxStep; + return Math.max(minStep, Math.min(maxStep, factor)); + } + + private applyTransform(): void { + if (!this.viewportEl || !this.stageEl) return; + const view = this.bowtie.view ?? { zoom: 1, panX: 0, panY: 0 }; + const { panX, panY, zoom } = view; + + // Pan and zoom on separate layers so zoom anchors correctly under the cursor. + this.viewportEl.style.transform = `translate3d(${panX}px, ${panY}px, 0)`; + this.viewportEl.style.transformOrigin = "0 0"; + + if (BowtieView.useCssZoom) { + this.stageEl.style.zoom = String(zoom); + this.stageEl.style.transform = ""; + } else { + this.stageEl.style.zoom = "1"; + this.stageEl.style.transform = `scale(${zoom})`; + this.stageEl.style.transformOrigin = "0 0"; + } + + if (this.containerEl_) { + const gs = BowtieView.GRID_SIZE; + const bgX = ((panX % gs) + gs) % gs; + const bgY = ((panY % gs) + gs) % gs; + this.containerEl_.style.backgroundPosition = `${bgX}px ${bgY}px`; + } + } + + private scheduleViewSave(): void { + if (this.viewSaveTimeout !== null) window.clearTimeout(this.viewSaveTimeout); + this.viewSaveTimeout = window.setTimeout(() => { + this.scheduleSave(); + }, 500); + } + + private zoomAt(clientX: number, clientY: number, factor: number): void { + if (!this.bowtie.view) this.bowtie.view = { zoom: 1, panX: 0, panY: 0 }; + const rect = this.containerEl_.getBoundingClientRect(); + const mx = clientX - rect.left; + const my = clientY - rect.top; + const oldZoom = this.bowtie.view.zoom; + const newZoom = Math.min(3, Math.max(0.2, oldZoom * factor)); + if (newZoom === oldZoom) return; + + const worldX = (mx - this.bowtie.view.panX) / oldZoom; + const worldY = (my - this.bowtie.view.panY) / oldZoom; + this.bowtie.view.zoom = newZoom; + this.bowtie.view.panX = mx - worldX * newZoom; + this.bowtie.view.panY = my - worldY * newZoom; + this.applyTransform(); + this.scheduleViewSave(); + } + + private adjustZoom(factor: number): void { + const rect = this.containerEl_.getBoundingClientRect(); + this.zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, factor); + } + + openHelpModal(): void { + new HelpModal(this.app).open(); + } + + openExportImageModal(): void { + this.ensureViewShell(); + const layout = layoutBowtie(this.bowtie, this.getLayoutConfig()); + new ExportImageModal(this.app, { + svgEl: this.svgEl, + nodesEl: this.nodesEl, + viewRootEl: this.contentEl, + bounds: layout.bounds, + viewport: this.getExportViewport(layout.bounds), + bowtieName: this.bowtie.name, + onExpandStacks: async () => { + this.expandAllStacksForExport(); + }, + onRestoreStacks: () => { + this.restoreStackCollapseAfterExport(); + }, + }).open(); + } + + private getExportViewport(bounds: { + width: number; + height: number; + }): BowtieExportViewport { + const view = this.bowtie.view ?? { zoom: 1, panX: 0, panY: 0 }; + const rect = this.containerEl_.getBoundingClientRect(); + const x = Math.max(0, -view.panX / view.zoom); + const y = Math.max(0, -view.panY / view.zoom); + const width = Math.min(rect.width / view.zoom, Math.max(1, bounds.width - x)); + const height = Math.min(rect.height / view.zoom, Math.max(1, bounds.height - y)); + return { x, y, width, height }; + } + + private forEachBarrier(callback: (barrier: Barrier) => void): void { + for (const threat of this.bowtie.threats) { + for (const barrier of threat.preventionBarriers) { + callback(barrier); + } + } + for (const consequence of this.bowtie.consequences) { + for (const barrier of consequence.mitigationBarriers) { + callback(barrier); + for (const factor of barrier.escalationFactors) { + for (const escBarrier of factor.escalationBarriers) { + callback(escBarrier); + } + } + } + } + } + + private expandAllStacksForExport(): void { + this.stackCollapseBackup = new Map(); + this.forEachBarrier((barrier) => { + this.stackCollapseBackup!.set(barrier.id, barrier.stackCollapsed ?? true); + barrier.stackCollapsed = false; + }); + this.renderDiagram(); + } + + private restoreStackCollapseAfterExport(): void { + if (!this.stackCollapseBackup) return; + this.forEachBarrier((barrier) => { + const saved = this.stackCollapseBackup!.get(barrier.id); + if (saved !== undefined) { + barrier.stackCollapsed = saved; + } + }); + this.stackCollapseBackup = null; + this.renderDiagram(); + } + + private fitToView(animate = true): void { + const layout = layoutBowtie(this.bowtie, this.getLayoutConfig()); + const rect = this.containerEl_.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + const padding = 40; + const scaleX = (rect.width - padding * 2) / layout.bounds.width; + const scaleY = (rect.height - padding * 2) / layout.bounds.height; + const zoom = Math.min(1.2, Math.max(0.3, Math.min(scaleX, scaleY))); + + const panX = (rect.width - layout.bounds.width * zoom) / 2; + const panY = (rect.height - layout.bounds.height * zoom) / 2; + + if (!this.bowtie.view) this.bowtie.view = { zoom: 1, panX: 0, panY: 0 }; + this.bowtie.view.zoom = zoom; + this.bowtie.view.panX = panX; + this.bowtie.view.panY = panY; + this.applyTransform(); + if (animate) this.scheduleViewSave(); + } + + private addThreat(): void { + this.commitEdit(); + this.bowtie.threats.push(createThreat("New threat")); + this.render(); + this.scheduleSave(); + } + + private addConsequence(): void { + this.commitEdit(); + this.bowtie.consequences.push(createConsequence("New consequence")); + this.render(); + this.scheduleSave(); + } + + private addBarrierToSelection(): void { + if (this.selectedRef?.kind === "threat" && this.selectedRef.threatId) { + this.addPreventionBarrier(this.selectedRef.threatId); + return; + } + if (this.selectedRef?.kind === "consequence" && this.selectedRef.consequenceId) { + this.addMitigationBarrier(this.selectedRef.consequenceId); + return; + } + if (this.bowtie.threats.length > 0) { + this.addPreventionBarrier(this.bowtie.threats[0].id); + return; + } + if (this.bowtie.consequences.length > 0) { + this.addMitigationBarrier(this.bowtie.consequences[0].id); + return; + } + new Notice("Add a threat or consequence first, then add barriers."); + } + + private addPreventionBarrier(threatId: string): void { + const threat = this.bowtie.threats.find((t) => t.id === threatId); + if (!threat) return; + this.commitEdit(); + threat.preventionBarriers.push(createBarrier("New barrier")); + this.selectedRef = { kind: "threat", threatId }; + this.render(); + this.scheduleSave(); + new Notice("Prevention barrier added."); + } + + private addMitigationBarrier(consequenceId: string): void { + const cons = this.bowtie.consequences.find((c) => c.id === consequenceId); + if (!cons) return; + this.commitEdit(); + cons.mitigationBarriers.push(createBarrier("New barrier")); + this.selectedRef = { kind: "consequence", consequenceId }; + this.render(); + this.scheduleSave(); + new Notice("Mitigation barrier added."); + } + + private renderLaneAddButtons(layout: import("./layout").LayoutResult): void { + const topEvent = layout.nodes.find((n) => n.kind === "topEvent"); + if (!topEvent) return; + + for (const threat of this.bowtie.threats) { + const threatNode = layout.nodes.find( + (n) => n.kind === "threat" && n.ref.threatId === threat.id + ); + if (!threatNode) continue; + + const lastBarrier = layout.nodes + .filter( + (n) => + n.kind === "preventionBarrier" && n.ref.threatId === threat.id + ) + .sort((a, b) => b.x - a.x)[0]; + + const fromNode = lastBarrier ?? threatNode; + const fromX = fromNode.x + fromNode.width; + const toX = topEvent.x; + const fromY = fromNode.y + fromNode.height / 2; + const toY = topEvent.y + topEvent.height / 2; + const y = (fromY + toY) / 2; + + this.createLaneAddButton( + (fromX + toX) / 2 - 13, + y - 13, + "Add prevention barrier", + () => this.addPreventionBarrier(threat.id) + ); + } + + for (const consequence of this.bowtie.consequences) { + const consNode = layout.nodes.find( + (n) => n.kind === "consequence" && n.ref.consequenceId === consequence.id + ); + if (!consNode) continue; + + const mitigationBarriers = layout.nodes + .filter( + (n) => + n.kind === "mitigationBarrier" && + n.ref.consequenceId === consequence.id + ) + .sort((a, b) => a.x - b.x); + const lastMitigation = mitigationBarriers[mitigationBarriers.length - 1]; + + const fromNode = lastMitigation ?? topEvent; + const fromX = fromNode.x + fromNode.width; + const toX = consNode.x; + const fromY = fromNode.y + fromNode.height / 2; + const toY = consNode.y + consNode.height / 2; + const y = (fromY + toY) / 2; + + this.createLaneAddButton( + (fromX + toX) / 2 - 13, + y - 13, + "Add mitigation barrier", + () => this.addMitigationBarrier(consequence.id) + ); + } + } + + private createLaneAddButton( + x: number, + y: number, + label: string, + onClick: () => void + ): void { + const btn = this.nodesEl.createEl("button", { cls: "o-tie-lane-add o-tie-plus-btn" }); + btn.style.left = `${x}px`; + btn.style.top = `${y}px`; + btn.setAttribute("aria-label", label); + btn.addEventListener("mousedown", (e) => e.stopPropagation()); + btn.addEventListener("click", (e) => { + e.stopPropagation(); + onClick(); + }); + } + + private addEscalationFactor(ref: NodeRef): void { + const barrier = this.findBarrier(ref); + if (!barrier) return; + this.commitEdit(); + barrier.escalationFactors.push(createEscalationFactor("Escalation factor")); + this.render(); + this.scheduleSave(); + } + + private addEscalationBarrier(ref: NodeRef): void { + const factor = this.findEscalationFactor(ref); + if (!factor) return; + this.commitEdit(); + factor.escalationBarriers.push(createBarrier("Escalation barrier")); + this.render(); + this.scheduleSave(); + } + + private findEscalationFactor(ref: NodeRef): EscalationFactor | null { + if (!ref.escalationId) return null; + const barrier = this.findBarrier(ref); + return barrier?.escalationFactors.find((f) => f.id === ref.escalationId) ?? null; + } + + private findBarrier(ref: NodeRef): Barrier | null { + if (ref.threatId && ref.barrierId) { + const threat = this.bowtie.threats.find((t) => t.id === ref.threatId); + return threat?.preventionBarriers.find((b) => b.id === ref.barrierId) ?? null; + } + if (ref.consequenceId && ref.barrierId) { + const cons = this.bowtie.consequences.find((c) => c.id === ref.consequenceId); + return cons?.mitigationBarriers.find((b) => b.id === ref.barrierId) ?? null; + } + return null; + } + + private getNodeLabel(ref: NodeRef): string { + switch (ref.kind) { + case "hazard": + return this.bowtie.hazard; + case "topEvent": + return this.bowtie.topEvent; + case "threat": { + const t = this.bowtie.threats.find((x) => x.id === ref.threatId); + return t?.label ?? ""; + } + case "consequence": { + const c = this.bowtie.consequences.find((x) => x.id === ref.consequenceId); + return c?.label ?? ""; + } + case "preventionBarrier": + case "mitigationBarrier": { + const b = this.findBarrier(ref); + return b?.label ?? ""; + } + case "escalationFactor": { + const b = this.findBarrier(ref); + const f = b?.escalationFactors.find((x) => x.id === ref.escalationId); + return f?.label ?? ""; + } + case "escalationBarrier": { + const b = this.findBarrier(ref); + const f = b?.escalationFactors.find((x) => x.id === ref.escalationId); + const escB = f?.escalationBarriers.find((x) => x.id === ref.escalationBarrierId); + return escB?.label ?? ""; + } + } + } + + private getNodeSubtitle(ref: NodeRef): string { + const map: Record = { + hazard: "Hazard", + topEvent: "Top Event", + threat: "Threat", + consequence: "Consequence", + preventionBarrier: "Prevention Barrier", + mitigationBarrier: "Mitigation Barrier", + escalationFactor: "Escalation Factor", + escalationBarrier: "Escalation Barrier", + }; + return map[ref.kind] ?? ref.kind; + } + + private getNodeNotes(ref: NodeRef): string { + switch (ref.kind) { + case "threat": { + const t = this.bowtie.threats.find((x) => x.id === ref.threatId); + return t?.notes ?? ""; + } + case "consequence": { + const c = this.bowtie.consequences.find((x) => x.id === ref.consequenceId); + return c?.notes ?? ""; + } + case "preventionBarrier": + case "mitigationBarrier": { + const b = this.findBarrier(ref); + return b?.notes ?? ""; + } + case "escalationFactor": { + const b = this.findBarrier(ref); + const f = b?.escalationFactors.find((x) => x.id === ref.escalationId); + return f?.notes ?? ""; + } + } + return ""; + } + + private setNodeLabel(ref: NodeRef, label: string): void { + switch (ref.kind) { + case "hazard": + this.bowtie.hazard = label; + break; + case "topEvent": + this.bowtie.topEvent = label; + break; + case "threat": { + const t = this.bowtie.threats.find((x) => x.id === ref.threatId); + if (t) t.label = label; + break; + } + case "consequence": { + const c = this.bowtie.consequences.find((x) => x.id === ref.consequenceId); + if (c) c.label = label; + break; + } + case "preventionBarrier": + case "mitigationBarrier": { + const b = this.findBarrier(ref); + if (b) b.label = label; + break; + } + case "escalationFactor": { + const b = this.findBarrier(ref); + const f = b?.escalationFactors.find((x) => x.id === ref.escalationId); + if (f) f.label = label; + break; + } + case "escalationBarrier": { + const b = this.findBarrier(ref); + const f = b?.escalationFactors.find((x) => x.id === ref.escalationId); + const escB = f?.escalationBarriers.find((x) => x.id === ref.escalationBarrierId); + if (escB) escB.label = label; + break; + } + } + } + + private setNodeNotes(ref: NodeRef, notes: string): void { + switch (ref.kind) { + case "threat": { + const t = this.bowtie.threats.find((x) => x.id === ref.threatId); + if (t) t.notes = notes; + break; + } + case "consequence": { + const c = this.bowtie.consequences.find((x) => x.id === ref.consequenceId); + if (c) c.notes = notes; + break; + } + case "preventionBarrier": + case "mitigationBarrier": { + const b = this.findBarrier(ref); + if (b) b.notes = notes; + break; + } + case "escalationFactor": { + const b = this.findBarrier(ref); + const f = b?.escalationFactors.find((x) => x.id === ref.escalationId); + if (f) f.notes = notes; + break; + } + } + } + + private deleteNode(ref: NodeRef): void { + this.commitEdit(); + switch (ref.kind) { + case "hazard": + this.bowtie.hazard = ""; + break; + case "topEvent": + this.bowtie.topEvent = ""; + break; + case "threat": + this.bowtie.threats = this.bowtie.threats.filter((t) => t.id !== ref.threatId); + break; + case "consequence": + this.bowtie.consequences = this.bowtie.consequences.filter((c) => c.id !== ref.consequenceId); + break; + case "preventionBarrier": { + const threat = this.bowtie.threats.find((t) => t.id === ref.threatId); + if (threat) { + threat.preventionBarriers = threat.preventionBarriers.filter((b) => b.id !== ref.barrierId); + } + break; + } + case "mitigationBarrier": { + const cons = this.bowtie.consequences.find((c) => c.id === ref.consequenceId); + if (cons) { + cons.mitigationBarriers = cons.mitigationBarriers.filter((b) => b.id !== ref.barrierId); + } + break; + } + case "escalationFactor": { + const b = this.findBarrier(ref); + if (b) { + b.escalationFactors = b.escalationFactors.filter((f) => f.id !== ref.escalationId); + } + break; + } + case "escalationBarrier": { + const b = this.findBarrier(ref); + const f = b?.escalationFactors.find((x) => x.id === ref.escalationId); + if (f) { + f.escalationBarriers = f.escalationBarriers.filter( + (eb) => eb.id !== ref.escalationBarrierId + ); + } + break; + } + } + this.selectedRef = null; + this.render(); + this.scheduleSave(); + new Notice("Deleted."); + } +} diff --git a/src/editorModal.ts b/src/editorModal.ts new file mode 100644 index 0000000..d65fc47 --- /dev/null +++ b/src/editorModal.ts @@ -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(); + } +} diff --git a/src/exportImage.ts b/src/exportImage.ts new file mode 100644 index 0000000..9bbf075 --- /dev/null +++ b/src/exportImage.ts @@ -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("*"))]; + const cloneEls = [clone, ...Array.from(clone.querySelectorAll("*"))]; + + 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 { + 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 { + 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("img.o-tie-export-edges")).map( + (img) => { + if (img.complete) return Promise.resolve(); + return new Promise((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, "-"); +} diff --git a/src/exportImageModal.ts b/src/exportImageModal.ts new file mode 100644 index 0000000..e53068e --- /dev/null +++ b/src/exportImageModal.ts @@ -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; + 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 { + 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(); + } +} diff --git a/src/helpModal.ts b/src/helpModal.ts new file mode 100644 index 0000000..f34f005 --- /dev/null +++ b/src/helpModal.ts @@ -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(); + } +} diff --git a/src/icons.ts b/src/icons.ts new file mode 100644 index 0000000..1b49c61 --- /dev/null +++ b/src/icons.ts @@ -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 tag (Obsidian wraps it internally). + */ +const BOWTIE_ICON_SVG = ` + + + + + +`.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)"); +} diff --git a/src/layout.ts b/src/layout.ts new file mode 100644 index 0000000..52b331e --- /dev/null +++ b/src/layout.ts @@ -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; + }); + }); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..ea13406 --- /dev/null +++ b/src/main.ts @@ -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 { + 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 { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + private async ensureFolder(folderPath: string): Promise { + 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 { + 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 { + const leaf = this.app.workspace.getLeaf(true); + await leaf.openFile(file); + } + + isBowtieFile(file: TFile): boolean { + return file.path.endsWith(BOWTIE_EXTENSION) || file.extension === "bowtie"; + } +} diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..ee842fb --- /dev/null +++ b/src/model.ts @@ -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(":"); +} diff --git a/src/settingsTab.ts b/src/settingsTab.ts new file mode 100644 index 0000000..62ac8f3 --- /dev/null +++ b/src/settingsTab.ts @@ -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(); + } + }) + ); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..5ec8e0e --- /dev/null +++ b/styles.css @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..256d2f7 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/version-bump.mjs b/version-bump.mjs new file mode 100644 index 0000000..844b75b --- /dev/null +++ b/version-bump.mjs @@ -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")); diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..5f12a19 --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "1.0.0": "1.4.0" +}