Release 1.0.8: Pan from anywhere on touch, keyboard selection, and quality audit.

- Single-finger touch panning now works even when starting on a node; a tap still selects.

- Add keyboard node selection (Tab/Enter/Space/Escape) and validated settings input.

- Guard against overwriting malformed .bowtie files; show a notice and keep original data.

- Add Vitest tests (model/layout/panZoom) and a GitHub Actions CI workflow.

- Split bowtieView into history/panZoom/stackRows/externalSync modules; refresh README, examples, and contributor docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andre482 2026-06-27 19:26:42 +03:00
parent d86fd3291c
commit 8a376a87a8
28 changed files with 3023 additions and 689 deletions

22
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,22 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npx tsc -noEmit -skipLibCheck
- run: npm test
- run: npm run build

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ main.js.map
*.log
deploy.local.mjs
deploy.local.ps1
*.code-workspace

View file

@ -2,6 +2,25 @@
All notable changes to O-Tie are documented here.
## [1.0.8] — 2026-06-27
### Added
- **Pan from anywhere on touch** — Single-finger drags now pan the canvas even when the finger starts on a node; a tap still selects the node.
- **Keyboard node selection** — Nodes are focusable (`Tab`), selectable with `Enter`/`Space`, and clearable with `Escape`.
- **Settings validation** — Numeric layout settings reject invalid input with clear visual feedback instead of silently ignoring it.
- **Unit tests and CI** — Vitest coverage for the model, layout, and pan/zoom logic, plus a GitHub Actions workflow running lint, type-check, tests, and build.
### Fixed
- **Data-loss guard** — A malformed `.bowtie` file no longer gets overwritten by an empty diagram; the original content is preserved and a notice is shown.
### Changed
- Internal: split the editor view into focused modules (history, pan/zoom, stack rows, external sync) and refreshed the README, examples, and contributor docs.
---
## [1.0.7] — 2026-06-23
### Fixed

100
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,100 @@
# Contributing to O-Tie
Thanks for your interest in improving O-Tie. This guide covers local setup, the
checks we run, how to regenerate documentation assets, and the release process.
## Prerequisites
- Node.js 20+ and npm
- Obsidian 1.4.0+ (for manual testing)
## Setup
```bash
npm install
```
## Develop
```bash
npm run dev # esbuild watch -> main.js
npm run build # type-check (tsc) + production bundle
npm run lint # ESLint with the Obsidian plugin guideline rules
npm test # unit tests (model + layout)
```
To test inside a real vault, point the deploy helper at your plugin folder:
```bash
OBSIDIAN_PLUGIN_DIR="/path/to/vault/.obsidian/plugins/o-tie" npm run deploy
```
`npm run lint`, `npm run build`, and `npm test` must all pass before a change is
merged or released — these mirror the automated checks Obsidian runs on the
community directory.
## Regenerating documentation assets
The README screenshots and the example `.bowtie` files are generated from the
plugin's real layout engine and stylesheet, so they always match the current
behaviour.
```bash
npx playwright install chromium # one time, for screenshots
npm run screenshots # -> assets/*.png
npm run examples # -> examples/*.bowtie
```
## Release process
Obsidian requires the GitHub release tag to match `manifest.json` exactly
(e.g. `1.0.8`, not `v1.0.8`).
1. Run `npm run lint`, `npm run build`, and `npm test`.
2. Complete the QA checklist below.
3. Bump the version: `npm version patch` (updates `manifest.json` and
`versions.json` via `version-bump.mjs`).
4. Update `CHANGELOG.md`.
5. Push the tag: `git push origin <version>`. The release workflow builds and
attaches `main.js`, `manifest.json`, and `styles.css`.
### Manual QA checklist
Mobile regressions have driven most past patch releases, so test on a phone or
tablet as well as desktop before every release.
Desktop:
- [ ] Create a new bowtie; rename it via the toolbar title.
- [ ] Add a threat, consequence, event, and barrier from the toolbar.
- [ ] Add a prevention/mitigation/between-events barrier via lane `+` buttons.
- [ ] Add a barrier analysis stack row; change a preset value; add a custom row.
- [ ] Add a degradation factor and a safeguard.
- [ ] Edit a label inline (double-click) and notes (inspector).
- [ ] Undo/redo (toolbar and Ctrl+Z / Ctrl+Y).
- [ ] Pan (drag), zoom (wheel), and Fit.
- [ ] Export as image (full diagram and visible area), with/without grid.
- [ ] Delete nodes; confirm autosave writes to the `.bowtie` file.
- [ ] Open a deliberately malformed `.bowtie` file: a notice appears and the
file is not overwritten.
Mobile (phone and tablet):
- [ ] One-finger pan and two-finger pinch zoom.
- [ ] Overlay `+` / `×` controls appear at node corners and stay circular after zoom.
- [ ] Add and delete nodes via overlay controls.
- [ ] App swipe gestures do not fire while interacting with the canvas.
## GitHub repository topics
For discoverability, the repository should carry these topics:
```
obsidian, obsidian-plugin, bowtie, risk-management, process-safety, hse, barrier-management, risk-analysis
```
A maintainer can set them with:
```bash
gh repo edit --add-topic obsidian,obsidian-plugin,bowtie,risk-management,process-safety,hse,barrier-management,risk-analysis
```

View file

@ -4,6 +4,18 @@ Build and edit **risk bowtie diagrams** in Obsidian with an interactive visual e
Diagrams are stored as `.bowtie` files in your vault and auto-save as you edit.
## Screenshots
![O-Tie editor showing a bowtie with threats, prevention and mitigation barriers, a central top event, consequences, an escalation chain, and a colour-coded barrier analysis stack](assets/screenshot-diagram.png)
*A bowtie for a cold-storage ammonia loss-of-primary-containment scenario.*
![A prevention barrier with its full analysis stack — type, effectiveness, criticality, responsible party, validation method, and status — plus a safeguard and degradation factor below it](assets/screenshot-barrier.png)
*Per-barrier analysis stacks and escalation factors (safeguards and degradation factors).*
> Regenerate these images with `npm run screenshots` (requires `npx playwright install chromium` once).
## Features
- Interactive bowtie editor with fan-in/fan-out layout
@ -82,20 +94,49 @@ After updating O-Tie on desktop, let Obsidian Sync finish, then reload Obsidian
## File format
`.bowtie` files are JSON. Example:
`.bowtie` files are JSON. The hazard and top event live inside `events`, and barriers can carry escalation chains and an analysis `stack`:
```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 }
"id": "b1f2…",
"name": "Defective steamcracker",
"events": [
{
"id": "ev1",
"label": "Loss of containment",
"hazard": "High-pressure ethylene",
"transitionBarriers": []
}
],
"threats": [
{
"id": "t1",
"label": "Tube corrosion / fatigue",
"preventionBarriers": [
{
"id": "pb1",
"label": "Mechanical integrity inspection",
"degradationChains": [],
"stack": [
{ "id": "s1", "field": "type", "label": "Active Hardware", "color": "#48c9b0" }
],
"stackCollapsed": false
}
]
}
],
"consequences": [
{ "id": "c1", "label": "Fire / explosion", "mitigationBarriers": [] }
],
"view": { "zoom": 1, "panX": 0, "panY": 0 },
"createdAt": "2026-06-12T00:00:00.000Z",
"updatedAt": "2026-06-12T00:00:00.000Z"
}
```
See [examples/steamcracker.bowtie](examples/steamcracker.bowtie) and [examples/cold-storage-ammonia.bowtie](examples/cold-storage-ammonia.bowtie) (LinkedIn promo scenario).
Files written by older versions (with top-level `hazard`/`topEvent` and barrier `escalationFactors`) are migrated automatically on open.
See [examples/steamcracker.bowtie](examples/steamcracker.bowtie) and [examples/cold-storage-ammonia.bowtie](examples/cold-storage-ammonia.bowtie).
## Changelog
@ -105,11 +146,16 @@ See [CHANGELOG.md](CHANGELOG.md).
```bash
npm install
npm run dev # watch mode
npm run build # production build
npm run lint # Obsidian plugin guidelines check
npm run dev # watch mode
npm run build # production build
npm run lint # Obsidian plugin guidelines check
npm test # unit tests (model + layout)
npm run screenshots # regenerate README images (needs: npx playwright install chromium)
npm run examples # regenerate example .bowtie files
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, asset regeneration, and the release/QA checklist.
## Third-party licenses
This plugin bundles [html-to-image](https://github.com/bubkoo/html-to-image) (MIT) for PNG export.

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

View file

@ -1,779 +1,541 @@
{
"id": "example-cold-storage-ammonia",
"name": "Cold Storage Ammonia Refrigeration",
"hazard": "Pressurized anhydrous ammonia (NH₃) refrigeration inventory",
"topEvent": "Loss of primary containment from ammonia refrigeration circuit",
"id": "bowtie-0",
"name": "Cold storage ammonia refrigeration",
"events": [
{
"id": "event-1",
"label": "Loss of primary containment from ammonia refrigeration circuit",
"hazard": "Pressurised anhydrous ammonia (NH3) refrigeration inventory",
"transitionBarriers": []
}
],
"threats": [
{
"id": "threat-corrosion",
"id": "threat-2",
"label": "Corrosion or fatigue of piping, vessels, or evaporator coils",
"notes": "Credible in aged plant with cyclic thermal loading, condensate, or external corrosion under insulation.",
"preventionBarriers": [
{
"id": "pb-integrity",
"id": "barrier-3",
"label": "Mechanical integrity inspection program",
"notes": "NDT, thickness monitoring, and replacement intervals per IIAR-6 / company standards.",
"escalationFactors": [
"degradationChains": [
{
"id": "ef-deferred-inspection",
"label": "Deferred inspection due to production pressure",
"escalationBarriers": [
"id": "chain-10",
"safeguards": [
{
"id": "eb-integrity-audit",
"label": "QA audit of mechanical integrity backlog",
"escalationFactors": [],
"stack": [],
"stackCollapsed": true
"id": "safeguard-11",
"label": "QA audit of mechanical integrity backlog"
}
]
],
"degradationFactor": {
"id": "degradation-12",
"label": "Deferred inspection due to production pressure"
}
}
],
"stack": [
{
"id": "stack-pb-integrity-type",
"id": "stack-4",
"field": "type",
"label": "Active Hardware",
"color": "#48c9b0"
},
{
"id": "stack-pb-integrity-effectiveness",
"id": "stack-5",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
},
{
"id": "stack-pb-integrity-criticality",
"id": "stack-6",
"field": "criticality",
"label": "Very High",
"color": "#2c3e50"
},
{
"id": "stack-pb-integrity-responsible",
"id": "stack-7",
"field": "responsible",
"label": "Tech Department",
"color": "#ffffff"
},
{
"id": "stack-pb-integrity-validation",
"id": "stack-8",
"field": "validation",
"label": "Inspection",
"color": "#ffffff"
},
{
"id": "stack-pb-integrity-status",
"id": "stack-9",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": false
"stackCollapsed": false,
"notes": "NDT, thickness monitoring, and replacement intervals per IIAR-6 / company standards."
},
{
"id": "pb-materials",
"label": "Materials of construction and corrosion allowance per design standard",
"notes": "Compatible metallurgy, coatings, and design margin for operating conditions.",
"escalationFactors": [],
"id": "barrier-13",
"label": "Materials of construction and corrosion allowance",
"degradationChains": [],
"stack": [
{
"id": "stack-pb-materials-type",
"id": "stack-14",
"field": "type",
"label": "Passive Hardware",
"color": "#7f8c8d"
},
{
"id": "stack-pb-materials-effectiveness",
"id": "stack-15",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
},
{
"id": "stack-pb-materials-criticality",
"id": "stack-16",
"field": "criticality",
"label": "High",
"color": "#566573"
},
{
"id": "stack-pb-materials-responsible",
"field": "responsible",
"label": "Tech Department",
"color": "#ffffff"
},
{
"id": "stack-pb-materials-validation",
"field": "validation",
"label": "Inspection",
"color": "#ffffff"
},
{
"id": "stack-pb-materials-status",
"id": "stack-17",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": true
"stackCollapsed": true,
"notes": "Compatible metallurgy, coatings, and design margin for operating conditions."
}
]
],
"notes": "Credible in aged plant with cyclic thermal loading, condensate, or corrosion under insulation."
},
{
"id": "threat-impact",
"id": "threat-18",
"label": "Forklift or vehicle impact on exposed ammonia piping",
"notes": "Common in warehouse aisles adjacent to machine rooms or overhead pipe runs.",
"preventionBarriers": [
{
"id": "pb-protection",
"id": "barrier-19",
"label": "Physical protection and traffic segregation",
"notes": "Bollards, rack guards, marked routes, and restricted access to machine room.",
"escalationFactors": [
"degradationChains": [
{
"id": "ef-bollard-bypass",
"label": "Bollards removed or bypassed for convenience",
"escalationBarriers": [
"id": "chain-24",
"safeguards": [
{
"id": "eb-supervisor-rounds",
"label": "Supervisor housekeeping and barrier inspection rounds",
"escalationFactors": [],
"stack": [],
"stackCollapsed": true
"id": "safeguard-25",
"label": "Supervisor housekeeping and barrier inspection rounds"
}
]
],
"degradationFactor": {
"id": "degradation-26",
"label": "Bollards removed or bypassed for convenience"
}
}
],
"stack": [
{
"id": "stack-pb-protection-type",
"id": "stack-20",
"field": "type",
"label": "Passive Hardware",
"color": "#7f8c8d"
},
{
"id": "stack-pb-protection-effectiveness",
"id": "stack-21",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
},
{
"id": "stack-pb-protection-criticality",
"id": "stack-22",
"field": "criticality",
"label": "High",
"color": "#566573"
},
{
"id": "stack-pb-protection-responsible",
"field": "responsible",
"label": "Operations",
"color": "#ffffff"
},
{
"id": "stack-pb-protection-validation",
"field": "validation",
"label": "Inspection",
"color": "#ffffff"
},
{
"id": "stack-pb-protection-status",
"id": "stack-23",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": true
"stackCollapsed": true,
"notes": "Bollards, rack guards, marked routes, and restricted machine-room access."
}
]
],
"notes": "Common in warehouse aisles adjacent to machine rooms or overhead pipe runs."
},
{
"id": "threat-overpressure",
"id": "threat-27",
"label": "Overpressure in low-side or high-side refrigeration circuit",
"preventionBarriers": [
{
"id": "pb-relief",
"id": "barrier-28",
"label": "Pressure relief devices and high-pressure interlocks",
"notes": "PSVs discharge to safe location; alarm and trip on abnormal pressure.",
"escalationFactors": [
"degradationChains": [
{
"id": "ef-psv-blocked",
"label": "Relief device isolated, blocked, or inspection overdue",
"escalationBarriers": [
"id": "chain-33",
"safeguards": [
{
"id": "eb-psv-program",
"label": "PSV inspection and register management program",
"escalationFactors": [],
"stack": [],
"stackCollapsed": true
"id": "safeguard-34",
"label": "PSV inspection and register management program"
}
]
],
"degradationFactor": {
"id": "degradation-35",
"label": "Relief device isolated, blocked, or inspection overdue"
}
}
],
"stack": [
{
"id": "stack-pb-relief-type",
"id": "stack-29",
"field": "type",
"label": "Active Hardware",
"color": "#48c9b0"
},
{
"id": "stack-pb-relief-effectiveness",
"id": "stack-30",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
},
{
"id": "stack-pb-relief-criticality",
"id": "stack-31",
"field": "criticality",
"label": "Very High",
"color": "#2c3e50"
},
{
"id": "stack-pb-relief-responsible",
"field": "responsible",
"label": "Tech Department",
"color": "#ffffff"
},
{
"id": "stack-pb-relief-validation",
"field": "validation",
"label": "Inspection",
"color": "#ffffff"
},
{
"id": "stack-pb-relief-status",
"id": "stack-32",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": true
"stackCollapsed": true,
"notes": "PSVs discharge to a safe location; alarm and trip on abnormal pressure."
}
]
},
{
"id": "threat-maintenance",
"id": "threat-36",
"label": "Maintenance isolation or re-commissioning error",
"preventionBarriers": [
{
"id": "pb-ptw",
"id": "barrier-37",
"label": "Permit-to-work and LOTO procedures",
"notes": "Verified isolation, zero-energy confirmation, and ammonia-specific competency.",
"escalationFactors": [
"degradationChains": [
{
"id": "ef-competency-gap",
"label": "Technician competency not verified for ammonia work",
"escalationBarriers": [
"id": "chain-42",
"safeguards": [
{
"id": "eb-competency",
"label": "Competency assessment and authorization records",
"escalationFactors": [],
"stack": [],
"stackCollapsed": true
"id": "safeguard-43",
"label": "Competency assessment and authorization records"
}
]
],
"degradationFactor": {
"id": "degradation-44",
"label": "Technician competency not verified for ammonia work"
}
}
],
"stack": [
{
"id": "stack-pb-ptw-type",
"id": "stack-38",
"field": "type",
"label": "Active Human",
"color": "#5dade2"
},
{
"id": "stack-pb-ptw-effectiveness",
"id": "stack-39",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
},
{
"id": "stack-pb-ptw-criticality",
"id": "stack-40",
"field": "criticality",
"label": "Very High",
"color": "#2c3e50"
},
{
"id": "stack-pb-ptw-responsible",
"field": "responsible",
"label": "Operations",
"color": "#ffffff"
},
{
"id": "stack-pb-ptw-validation",
"field": "validation",
"label": "Audit",
"color": "#ffffff"
},
{
"id": "stack-pb-ptw-status",
"id": "stack-41",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": true
"stackCollapsed": true,
"notes": "Verified isolation, zero-energy confirmation, and ammonia-specific competency."
}
]
},
{
"id": "threat-operator",
"id": "threat-45",
"label": "Operator error during startup, shutdown, or abnormal operation",
"preventionBarriers": [
{
"id": "pb-sop",
"id": "barrier-46",
"label": "Operating procedures and abnormal situation guidance",
"notes": "Written procedures for charge management, alarms, and emergency slowdown.",
"escalationFactors": [],
"degradationChains": [],
"stack": [
{
"id": "stack-pb-sop-type",
"id": "stack-47",
"field": "type",
"label": "Active Human",
"color": "#5dade2"
},
{
"id": "stack-pb-sop-effectiveness",
"id": "stack-48",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
},
{
"id": "stack-pb-sop-criticality",
"id": "stack-49",
"field": "criticality",
"label": "High",
"color": "#566573"
},
{
"id": "stack-pb-sop-responsible",
"field": "responsible",
"label": "Operations",
"color": "#ffffff"
},
{
"id": "stack-pb-sop-validation",
"field": "validation",
"label": "Audit",
"color": "#ffffff"
},
{
"id": "stack-pb-sop-status",
"id": "stack-50",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": true
"stackCollapsed": true,
"notes": "Written procedures for charge management, alarms, and emergency slowdown."
},
{
"id": "pb-training",
"id": "barrier-51",
"label": "Operator training and competency assurance",
"notes": "Refresher training on ammonia properties, alarms, and first actions.",
"escalationFactors": [],
"degradationChains": [],
"stack": [
{
"id": "stack-pb-training-type",
"id": "stack-52",
"field": "type",
"label": "Reactive Human",
"color": "#3498db"
"label": "Active Human",
"color": "#5dade2"
},
{
"id": "stack-pb-training-effectiveness",
"id": "stack-53",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
},
{
"id": "stack-pb-training-criticality",
"id": "stack-54",
"field": "criticality",
"label": "Medium",
"color": "#7f8c8d"
},
{
"id": "stack-pb-training-responsible",
"field": "responsible",
"label": "HSE Department",
"color": "#ffffff"
},
{
"id": "stack-pb-training-validation",
"field": "validation",
"label": "Drills and Exercise",
"color": "#ffffff"
},
{
"id": "stack-pb-training-status",
"id": "stack-55",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": true
}
]
},
{
"id": "threat-external-fire",
"label": "External fire impinging on ammonia equipment or pipework",
"notes": "Warehouse fire, arcing electrical equipment, or hot work in adjacent area.",
"preventionBarriers": [
{
"id": "pb-fire-prevention",
"label": "Fire prevention and hot-work controls in ammonia areas",
"notes": "Housekeeping, fire detection, hot-work permits, and separation from combustibles.",
"escalationFactors": [],
"stack": [
{
"id": "stack-pb-fire-prevention-type",
"field": "type",
"label": "Active Human",
"color": "#5dade2"
},
{
"id": "stack-pb-fire-prevention-effectiveness",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
},
{
"id": "stack-pb-fire-prevention-criticality",
"field": "criticality",
"label": "High",
"color": "#566573"
},
{
"id": "stack-pb-fire-prevention-responsible",
"field": "responsible",
"label": "HSE Department",
"color": "#ffffff"
},
{
"id": "stack-pb-fire-prevention-validation",
"field": "validation",
"label": "Audit",
"color": "#ffffff"
},
{
"id": "stack-pb-fire-prevention-status",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": true
"stackCollapsed": true,
"notes": "Refresher training on ammonia properties, alarms, and first actions."
}
]
}
],
"consequences": [
{
"id": "consequence-exposure",
"id": "consequence-56",
"label": "Personnel harm due to toxic ammonia exposure",
"mitigationBarriers": [
{
"id": "mb-detection",
"id": "barrier-57",
"label": "Fixed ammonia gas detection and alarm",
"notes": "Audible/visual alarm, potential automatic isolation or ventilation actuation.",
"escalationFactors": [
"degradationChains": [
{
"id": "ef-calibration",
"label": "Sensor calibration overdue or drift",
"escalationBarriers": [
"id": "chain-64",
"safeguards": [
{
"id": "eb-calibration-program",
"label": "Calibration management and functional test program",
"escalationFactors": [],
"stack": [],
"stackCollapsed": true
"id": "safeguard-65",
"label": "Calibration management and functional test program"
}
]
],
"degradationFactor": {
"id": "degradation-66",
"label": "Sensor calibration overdue or drift"
}
}
],
"stack": [
{
"id": "stack-mb-detection-type",
"id": "stack-58",
"field": "type",
"label": "Active Hardware",
"color": "#48c9b0"
},
{
"id": "stack-mb-detection-effectiveness",
"id": "stack-59",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
},
{
"id": "stack-mb-detection-criticality",
"id": "stack-60",
"field": "criticality",
"label": "Very High",
"color": "#2c3e50"
},
{
"id": "stack-mb-detection-responsible",
"id": "stack-61",
"field": "responsible",
"label": "HSE Department",
"color": "#ffffff"
},
{
"id": "stack-mb-detection-validation",
"id": "stack-62",
"field": "validation",
"label": "Barrier Test",
"color": "#ffffff"
},
{
"id": "stack-mb-detection-status",
"id": "stack-63",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": false
"stackCollapsed": false,
"notes": "Audible/visual alarm, with potential automatic isolation or ventilation actuation."
},
{
"id": "mb-ventilation",
"id": "barrier-67",
"label": "Emergency ventilation and isolation valves",
"notes": "Reduce airborne concentration; close isolation valves where safe to do so.",
"escalationFactors": [],
"degradationChains": [],
"stack": [
{
"id": "stack-mb-ventilation-type",
"id": "stack-68",
"field": "type",
"label": "Active Hardware",
"color": "#48c9b0"
},
{
"id": "stack-mb-ventilation-effectiveness",
"id": "stack-69",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
},
{
"id": "stack-mb-ventilation-criticality",
"id": "stack-70",
"field": "criticality",
"label": "Very High",
"color": "#2c3e50"
},
{
"id": "stack-mb-ventilation-responsible",
"field": "responsible",
"label": "Tech Department",
"color": "#ffffff"
},
{
"id": "stack-mb-ventilation-validation",
"field": "validation",
"label": "Barrier Test",
"color": "#ffffff"
},
{
"id": "stack-mb-ventilation-status",
"id": "stack-71",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": true
},
{
"id": "mb-evacuation",
"label": "Personnel evacuation and emergency muster",
"notes": "Alarm response, headcount, and upwind assembly per ERP.",
"escalationFactors": [],
"stack": [
{
"id": "stack-mb-evacuation-type",
"field": "type",
"label": "Reactive Human",
"color": "#3498db"
},
{
"id": "stack-mb-evacuation-effectiveness",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
},
{
"id": "stack-mb-evacuation-criticality",
"field": "criticality",
"label": "High",
"color": "#566573"
},
{
"id": "stack-mb-evacuation-responsible",
"field": "responsible",
"label": "HSE Department",
"color": "#ffffff"
},
{
"id": "stack-mb-evacuation-validation",
"field": "validation",
"label": "Drills and Exercise",
"color": "#ffffff"
},
{
"id": "stack-mb-evacuation-status",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": true
"stackCollapsed": true,
"notes": "Reduce airborne concentration; close isolation valves where safe to do so."
}
]
},
{
"id": "consequence-environment",
"id": "consequence-72",
"label": "Environmental damage due to ammonia release",
"mitigationBarriers": [
{
"id": "mb-containment",
"id": "barrier-73",
"label": "Containment sump and emergency dilution / knock-down",
"notes": "Limit spread to drains, waterways, or off-site receptors.",
"escalationFactors": [],
"degradationChains": [],
"stack": [
{
"id": "stack-mb-containment-type",
"id": "stack-74",
"field": "type",
"label": "Passive Hardware",
"color": "#7f8c8d"
},
{
"id": "stack-mb-containment-effectiveness",
"id": "stack-75",
"field": "effectiveness",
"label": "Fair",
"color": "#f1c40f"
},
{
"id": "stack-mb-containment-criticality",
"id": "stack-76",
"field": "criticality",
"label": "High",
"color": "#566573"
},
{
"id": "stack-mb-containment-responsible",
"field": "responsible",
"label": "Tech Department",
"color": "#ffffff"
},
{
"id": "stack-mb-containment-validation",
"field": "validation",
"label": "Inspection",
"color": "#ffffff"
},
{
"id": "stack-mb-containment-status",
"id": "stack-77",
"field": "status",
"label": "Degraded",
"color": "#f39c12"
}
],
"stackCollapsed": true
},
{
"id": "mb-notification",
"label": "Regulatory reporting and community notification",
"notes": "RMP/SEVESO-style reporting and liaison with authorities as required.",
"escalationFactors": [],
"stack": [
{
"id": "stack-mb-notification-type",
"field": "type",
"label": "Reactive Human",
"color": "#3498db"
},
{
"id": "stack-mb-notification-effectiveness",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
},
{
"id": "stack-mb-notification-criticality",
"field": "criticality",
"label": "Medium",
"color": "#7f8c8d"
},
{
"id": "stack-mb-notification-responsible",
"field": "responsible",
"label": "HSE Department",
"color": "#ffffff"
},
{
"id": "stack-mb-notification-validation",
"field": "validation",
"label": "Audit",
"color": "#ffffff"
},
{
"id": "stack-mb-notification-status",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": true
"stackCollapsed": true,
"notes": "Limit spread to drains, waterways, or off-site receptors."
}
]
},
{
"id": "consequence-business",
"id": "consequence-78",
"label": "Operational loss due to cold-chain interruption",
"mitigationBarriers": [
{
"id": "mb-erp",
"id": "barrier-79",
"label": "Emergency response plan and qualified contractor standby",
"notes": "Ammonia-qualified emergency repair and product salvage / transfer plan.",
"escalationFactors": [
"degradationChains": [
{
"id": "ef-contractor-gap",
"label": "Contractor availability or response time not assured",
"escalationBarriers": [
"id": "chain-85",
"safeguards": [
{
"id": "eb-contractor-retainer",
"label": "Pre-qualified contractor agreement and annual drill",
"escalationFactors": [],
"stack": [],
"stackCollapsed": true
"id": "safeguard-86",
"label": "Pre-qualified contractor agreement and annual drill"
}
]
],
"degradationFactor": {
"id": "degradation-87",
"label": "Contractor availability or response time not assured"
}
}
],
"stack": [
{
"id": "stack-mb-erp-type",
"id": "stack-80",
"field": "type",
"label": "Reactive Human",
"color": "#3498db"
"label": "Active Human",
"color": "#5dade2"
},
{
"id": "stack-mb-erp-effectiveness",
"id": "stack-81",
"field": "effectiveness",
"label": "Good",
"color": "#27ae60"
},
{
"id": "stack-mb-erp-criticality",
"id": "stack-82",
"field": "criticality",
"label": "High",
"color": "#566573"
},
{
"id": "stack-mb-erp-responsible",
"id": "stack-83",
"field": "responsible",
"label": "Management",
"color": "#ffffff"
},
{
"id": "stack-mb-erp-validation",
"field": "validation",
"label": "Drills and Exercise",
"color": "#ffffff"
},
{
"id": "stack-mb-erp-status",
"id": "stack-84",
"field": "status",
"label": "Available",
"color": "#1e8449"
}
],
"stackCollapsed": true
"stackCollapsed": true,
"notes": "Ammonia-qualified emergency repair and product salvage / transfer plan."
}
]
}
@ -783,6 +545,6 @@
"panX": 0,
"panY": 0
},
"createdAt": "2026-06-19T00:00:00.000Z",
"updatedAt": "2026-06-19T12:05:10.306Z"
"createdAt": "2026-06-12T00:00:00.000Z",
"updatedAt": "2026-06-12T00:00:00.000Z"
}

View file

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

View file

@ -1,9 +1,9 @@
{
"id": "o-tie",
"name": "O-Tie",
"version": "1.0.7",
"version": "1.0.8",
"minAppVersion": "1.4.0",
"description": "Build and edit risk bowtie diagrams in a visual editor with barriers, escalation factors, analysis stacks, and PNG export.",
"description": "Build risk bowtie diagrams in your vault: threats, prevention and mitigation barriers, escalation factors, barrier analysis stacks, and PNG export.",
"author": "Andre482",
"authorUrl": "https://github.com/Andre482",
"isDesktopOnly": false

1223
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,15 @@
{
"name": "o-tie",
"version": "1.0.7",
"version": "1.0.8",
"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",
"lint": "eslint src/",
"test": "vitest run",
"screenshots": "node scripts/render-screenshots.mjs",
"examples": "node scripts/generate-examples.mjs",
"deploy": "npm run build && node deploy.mjs",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
@ -26,7 +29,8 @@
"playwright": "^1.61.0",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"typescript-eslint": "^8.61.1"
"typescript-eslint": "^8.61.1",
"vitest": "^4.1.9"
},
"dependencies": {
"html-to-image": "^1.11.13"

34
scripts/engine.mjs Normal file
View file

@ -0,0 +1,34 @@
// Bundles the plugin's pure layout/model TypeScript so Node scripts can use it.
import { build } from "esbuild";
import { rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const scriptsDir = dirname(fileURLToPath(import.meta.url));
const root = join(scriptsDir, "..");
export async function loadEngine() {
const result = await build({
stdin: {
contents: `
export { layoutBowtie, DEFAULT_LAYOUT, barrierHeaderHeightFor } from "./src/layout";
export * as model from "./src/model";
`,
resolveDir: root,
loader: "ts",
sourcefile: "engine-entry.ts",
},
bundle: true,
format: "esm",
platform: "node",
write: false,
logLevel: "silent",
});
const tmp = join(scriptsDir, `_engine-${process.pid}.mjs`);
writeFileSync(tmp, result.outputFiles[0].text);
try {
return await import(pathToFileURL(tmp).href);
} finally {
rmSync(tmp, { force: true });
}
}

View file

@ -0,0 +1,307 @@
// Regenerates the committed example .bowtie files in the current schema.
//
// Authoring the scenarios through the real model guarantees the files validate
// and stay in canonical form. Run: npm run examples
import { writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { loadEngine } from "./engine.mjs";
const scriptsDir = dirname(fileURLToPath(import.meta.url));
const examplesDir = join(scriptsDir, "..", "examples");
const FIXED_TS = "2026-06-12T00:00:00.000Z";
const engine = await loadEngine();
const model = engine.model;
function fieldColor(fieldKey, label) {
const field = model.BARRIER_STACK_FIELDS.find((f) => f.key === fieldKey);
return field?.options.find((o) => o.label === label)?.color;
}
function barrier(label, opts = {}) {
const b = model.createBarrier(label);
if (opts.notes) b.notes = opts.notes;
if (opts.stack) {
b.stack = opts.stack.map(([field, value]) => {
const color = fieldColor(field, value);
if (color === undefined) throw new Error(`Unknown ${field} option: ${value}`);
return model.createBarrierStackItem(value, field, color);
});
model.sortBarrierStack(b.stack);
b.stackCollapsed = opts.expanded !== true;
}
if (opts.chains) {
b.degradationChains = opts.chains.map((c) => {
const chain = model.createDegradationChain(c.factor);
chain.safeguards = (c.safeguards ?? []).map((s) => model.createEscalationNode(s));
return chain;
});
}
return b;
}
function threat(label, notes, barriers) {
const t = model.createThreat(label);
if (notes) t.notes = notes;
t.preventionBarriers = barriers;
return t;
}
function consequence(label, notes, barriers) {
const c = model.createConsequence(label);
if (notes) c.notes = notes;
c.mitigationBarriers = barriers;
return c;
}
// Stable, readable ids so regeneration produces minimal diffs.
function reassignIds(bowtie) {
let n = 0;
const id = (prefix) => `${prefix}-${n++}`;
bowtie.id = id("bowtie");
for (const event of bowtie.events) {
event.id = id("event");
event.transitionBarriers.forEach(reassignBarrier(id));
}
for (const t of bowtie.threats) {
t.id = id("threat");
t.preventionBarriers.forEach(reassignBarrier(id));
}
for (const c of bowtie.consequences) {
c.id = id("consequence");
c.mitigationBarriers.forEach(reassignBarrier(id));
}
bowtie.createdAt = FIXED_TS;
bowtie.updatedAt = FIXED_TS;
return bowtie;
function reassignBarrier(idFn) {
return (b) => {
b.id = idFn("barrier");
for (const item of b.stack ?? []) item.id = idFn("stack");
for (const chain of b.degradationChains) {
chain.id = idFn("chain");
for (const sg of chain.safeguards) sg.id = idFn("safeguard");
chain.degradationFactor.id = idFn("degradation");
}
};
}
}
function buildAmmonia() {
const bt = model.createBowtie("Cold storage ammonia refrigeration");
bt.events[0].label = "Loss of primary containment from ammonia refrigeration circuit";
bt.events[0].hazard = "Pressurised anhydrous ammonia (NH3) refrigeration inventory";
bt.threats = [
threat(
"Corrosion or fatigue of piping, vessels, or evaporator coils",
"Credible in aged plant with cyclic thermal loading, condensate, or corrosion under insulation.",
[
barrier("Mechanical integrity inspection program", {
notes: "NDT, thickness monitoring, and replacement intervals per IIAR-6 / company standards.",
expanded: true,
stack: [
["type", "Active Hardware"],
["effectiveness", "Good"],
["criticality", "Very High"],
["responsible", "Tech Department"],
["validation", "Inspection"],
["status", "Available"],
],
chains: [
{
factor: "Deferred inspection due to production pressure",
safeguards: ["QA audit of mechanical integrity backlog"],
},
],
}),
barrier("Materials of construction and corrosion allowance", {
notes: "Compatible metallurgy, coatings, and design margin for operating conditions.",
stack: [
["type", "Passive Hardware"],
["effectiveness", "Good"],
["criticality", "High"],
["status", "Available"],
],
}),
]
),
threat(
"Forklift or vehicle impact on exposed ammonia piping",
"Common in warehouse aisles adjacent to machine rooms or overhead pipe runs.",
[
barrier("Physical protection and traffic segregation", {
notes: "Bollards, rack guards, marked routes, and restricted machine-room access.",
stack: [
["type", "Passive Hardware"],
["effectiveness", "Good"],
["criticality", "High"],
["status", "Available"],
],
chains: [
{
factor: "Bollards removed or bypassed for convenience",
safeguards: ["Supervisor housekeeping and barrier inspection rounds"],
},
],
}),
]
),
threat("Overpressure in low-side or high-side refrigeration circuit", undefined, [
barrier("Pressure relief devices and high-pressure interlocks", {
notes: "PSVs discharge to a safe location; alarm and trip on abnormal pressure.",
stack: [
["type", "Active Hardware"],
["effectiveness", "Good"],
["criticality", "Very High"],
["status", "Available"],
],
chains: [
{
factor: "Relief device isolated, blocked, or inspection overdue",
safeguards: ["PSV inspection and register management program"],
},
],
}),
]),
threat("Maintenance isolation or re-commissioning error", undefined, [
barrier("Permit-to-work and LOTO procedures", {
notes: "Verified isolation, zero-energy confirmation, and ammonia-specific competency.",
stack: [
["type", "Active Human"],
["effectiveness", "Good"],
["criticality", "Very High"],
["status", "Available"],
],
chains: [
{
factor: "Technician competency not verified for ammonia work",
safeguards: ["Competency assessment and authorization records"],
},
],
}),
]),
threat("Operator error during startup, shutdown, or abnormal operation", undefined, [
barrier("Operating procedures and abnormal situation guidance", {
notes: "Written procedures for charge management, alarms, and emergency slowdown.",
stack: [
["type", "Active Human"],
["effectiveness", "Good"],
["criticality", "High"],
["status", "Available"],
],
}),
barrier("Operator training and competency assurance", {
notes: "Refresher training on ammonia properties, alarms, and first actions.",
stack: [
["type", "Active Human"],
["effectiveness", "Fair"],
["criticality", "Medium"],
["status", "Available"],
],
}),
]),
];
bt.consequences = [
consequence("Personnel harm due to toxic ammonia exposure", undefined, [
barrier("Fixed ammonia gas detection and alarm", {
notes: "Audible/visual alarm, with potential automatic isolation or ventilation actuation.",
expanded: true,
stack: [
["type", "Active Hardware"],
["effectiveness", "Good"],
["criticality", "Very High"],
["responsible", "HSE Department"],
["validation", "Barrier Test"],
["status", "Available"],
],
chains: [
{
factor: "Sensor calibration overdue or drift",
safeguards: ["Calibration management and functional test program"],
},
],
}),
barrier("Emergency ventilation and isolation valves", {
notes: "Reduce airborne concentration; close isolation valves where safe to do so.",
stack: [
["type", "Active Hardware"],
["effectiveness", "Good"],
["criticality", "Very High"],
["status", "Available"],
],
}),
]),
consequence("Environmental damage due to ammonia release", undefined, [
barrier("Containment sump and emergency dilution / knock-down", {
notes: "Limit spread to drains, waterways, or off-site receptors.",
stack: [
["type", "Passive Hardware"],
["effectiveness", "Fair"],
["criticality", "High"],
["status", "Degraded"],
],
}),
]),
consequence("Operational loss due to cold-chain interruption", undefined, [
barrier("Emergency response plan and qualified contractor standby", {
notes: "Ammonia-qualified emergency repair and product salvage / transfer plan.",
stack: [
["type", "Active Human"],
["effectiveness", "Good"],
["criticality", "High"],
["responsible", "Management"],
["status", "Available"],
],
chains: [
{
factor: "Contractor availability or response time not assured",
safeguards: ["Pre-qualified contractor agreement and annual drill"],
},
],
}),
]),
];
return reassignIds(bt);
}
function buildSteamcracker() {
const bt = model.createBowtie("Defective steamcracker");
bt.events[0].label = "Loss of containment in steamcracker furnace";
bt.events[0].hazard = "High-pressure ethylene cracking system";
bt.threats = [
threat("Tube corrosion / fatigue", undefined, [
barrier("Periodic tube inspection program"),
barrier("Material selection standards"),
]),
threat("Operator error during startup", undefined, [
barrier("Standard operating procedures"),
]),
];
bt.consequences = [
consequence("Fire / explosion", undefined, [
barrier("Deluge and fire suppression systems"),
]),
consequence("Toxic gas release", undefined, [
barrier("Gas detection and isolation"),
barrier("Emergency response plan"),
]),
];
return reassignIds(bt);
}
function write(name, bowtie) {
const file = join(examplesDir, name);
writeFileSync(file, `${model.serializeBowtie(bowtie)}\n`);
console.log(`Wrote ${file}`);
}
write("cold-storage-ammonia.bowtie", buildAmmonia());
write("steamcracker.bowtie", buildSteamcracker());

View file

@ -0,0 +1,279 @@
// Generates authentic O-Tie diagram screenshots for the README.
//
// It reuses the plugin's real layout engine (src/layout.ts) and stylesheet
// (styles.css), reproduces the same node/edge DOM the plugin renders, and
// captures it with headless Chromium. The output mirrors the plugin's "Export
// as image" appearance (no editing chrome).
//
// Usage: npm run screenshots
// Requires Chromium once: npx playwright install chromium
import { chromium } from "playwright";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { loadEngine } from "./engine.mjs";
const scriptsDir = dirname(fileURLToPath(import.meta.url));
const root = join(scriptsDir, "..");
const assetsDir = join(root, "assets");
const stylesCss = readFileSync(join(root, "styles.css"), "utf8");
// Light-theme stand-ins for the Obsidian CSS variables the canvas relies on.
const THEME_VARS = `
--background-primary: #ffffff;
--background-secondary: #f4f6f8;
--background-modifier-border: #d6dae0;
--background-modifier-border-hover: #c2c8d0;
--background-modifier-hover: #ecedf1;
--text-normal: #1a1a1a;
--text-muted: #5d6d7e;
--text-error: #c0392b;
--interactive-accent: #7b6cf6;
--radius-s: 4px;
--font-ui-smaller: 11px;
--font-interface: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
`;
const LIGHT_STACK_ROW_COLORS = new Set(["#ffffff", "#f4ecf7", "#eafaf1"]);
function isLightStackColor(color) {
if (LIGHT_STACK_ROW_COLORS.has(color)) return true;
const match = /^#([0-9a-f]{6})$/i.exec(color);
if (!match) return false;
const n = parseInt(match[1], 16);
const r = (n >> 16) & 0xff;
const g = (n >> 8) & 0xff;
const b = n & 0xff;
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.62;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fieldColor(model, fieldKey, label) {
const field = model.BARRIER_STACK_FIELDS.find((f) => f.key === fieldKey);
const option = field?.options.find((o) => o.label === label);
return option?.color;
}
function addStack(model, barrier, rows) {
barrier.stack = rows.map(([field, label]) =>
model.createBarrierStackItem(label, field, fieldColor(model, field, label))
);
model.sortBarrierStack?.(barrier.stack);
barrier.stackCollapsed = false;
}
// Representative cold-storage ammonia scenario (matches the docs example).
function buildHeroBowtie(model) {
const bt = model.createBowtie("Cold storage ammonia — LOPC");
bt.events[0].label = "Loss of primary containment";
bt.events[0].hazard = "Pressurised anhydrous ammonia";
const mi = model.createBarrier("Mechanical integrity inspection");
addStack(model, mi, [
["type", "Active Hardware"],
["effectiveness", "Good"],
["criticality", "Very High"],
]);
const chain = model.createDegradationChain("Deferred inspection");
chain.safeguards = [model.createEscalationNode("QA audit of MI backlog")];
mi.degradationChains = [chain];
const corrosion = model.createThreat("Corrosion / fatigue of piping");
corrosion.preventionBarriers = [mi, model.createBarrier("Material selection standards")];
const impact = model.createThreat("Forklift impact on pipework");
impact.preventionBarriers = [model.createBarrier("Physical protection & traffic segregation")];
const maint = model.createThreat("Maintenance isolation error");
maint.preventionBarriers = [model.createBarrier("Permit-to-work and LOTO")];
bt.threats = [corrosion, impact, maint];
const toxic = model.createConsequence("Toxic exposure to personnel");
const detect = model.createBarrier("Fixed gas detection & alarm");
addStack(model, detect, [
["type", "Active Hardware+Human"],
["status", "Available"],
]);
toxic.mitigationBarriers = [detect, model.createBarrier("Emergency ventilation & isolation")];
const env = model.createConsequence("Environmental release");
env.mitigationBarriers = [model.createBarrier("Containment sump & dilution")];
const biz = model.createConsequence("Business interruption");
biz.mitigationBarriers = [model.createBarrier("Emergency response plan")];
bt.consequences = [toxic, env, biz];
return bt;
}
// Minimal scenario that showcases a single barrier's analysis stack + escalation.
function buildBarrierBowtie(model) {
const bt = model.createBowtie("Barrier analysis");
bt.events[0].label = "Loss of containment";
bt.events[0].hazard = "Pressurised ammonia";
const mi = model.createBarrier("Mechanical integrity inspection");
addStack(model, mi, [
["type", "Active Hardware"],
["effectiveness", "Good"],
["criticality", "Very High"],
["responsible", "Tech Department"],
["validation", "Inspection"],
["status", "Available"],
]);
const chain = model.createDegradationChain("Deferred inspection");
chain.safeguards = [model.createEscalationNode("QA audit of MI backlog")];
mi.degradationChains = [chain];
const threat = model.createThreat("Corrosion / fatigue");
threat.preventionBarriers = [mi];
bt.threats = [threat];
bt.consequences = [model.createConsequence("Toxic release")];
return bt;
}
function renderNodeHtml(node, barriersById) {
const wrapStyle = `left:${node.x}px;top:${node.y}px;width:${node.width}px;height:${node.height}px`;
const inner = renderNodeInner(node, barriersById);
return `<div class="o-tie-node-wrap o-tie-node-wrap-${node.kind}" style="${wrapStyle}">${inner}</div>`;
}
function renderNodeInner(node, barriersById) {
const subtitle = escapeHtml(node.subtitle);
const label = escapeHtml(node.label);
if (node.kind === "topEvent") {
return (
`<div class="o-tie-node o-tie-node-topEvent">` +
`<div class="o-tie-node-label o-tie-top-event-label">${label}</div></div>` +
`<div class="o-tie-top-event-badge">${subtitle}</div>`
);
}
const isBarrier =
node.kind === "preventionBarrier" ||
node.kind === "mitigationBarrier" ||
node.kind === "transitionBarrier";
if (isBarrier) {
const barrier = barriersById.get(node.ref.barrierId);
const headerH = barrierHeaderHeight(barrier);
let html = `<div class="o-tie-node o-tie-node-${node.kind}">`;
html += `<div class="o-tie-barrier-header" style="height:${headerH}px;min-height:${headerH}px">`;
html += `<div class="o-tie-node-stripe">${subtitle}</div>`;
html += `<div class="o-tie-barrier-header-body"><div class="o-tie-node-label">${label}</div></div>`;
html += `</div>`;
const stack = barrier?.stack ?? [];
if (stack.length > 0 && barrier?.stackCollapsed === false) {
html += `<div class="o-tie-barrier-stack">`;
for (const item of stack) {
const classes = ["o-tie-stack-row"];
if (item.field) classes.push("o-tie-stack-row-preset");
const style = [`height:${ROW_H}px`];
if (item.color) {
style.push(`background-color:${item.color}`);
if (isLightStackColor(item.color)) classes.push("o-tie-stack-row-light");
}
html += `<div class="${classes.join(" ")}" style="${style.join(";")}">`;
html += `<div class="o-tie-stack-row-label">${escapeHtml(item.label)}</div></div>`;
}
html += `</div>`;
}
html += `</div>`;
return html;
}
return (
`<div class="o-tie-node o-tie-node-${node.kind}">` +
`<div class="o-tie-node-stripe">${subtitle}</div>` +
`<div class="o-tie-node-label">${label}</div></div>`
);
}
function renderEdgesSvg(layout) {
const size = 8;
let svg = `<svg class="o-tie-svg" width="${layout.bounds.width}" height="${layout.bounds.height}">`;
for (const edge of layout.edges) {
svg += `<path class="o-tie-edge o-tie-edge-${edge.kind}" d="${edge.path}"></path>`;
}
for (const edge of layout.edges) {
const { x, y, angleDeg } = edge.arrow;
svg += `<g class="o-tie-edge-arrow o-tie-edge-arrow-${edge.kind}" transform="translate(${x} ${y}) rotate(${angleDeg})">`;
svg += `<path d="M0 0 L-${size} ${-size * 0.42} L-${size} ${size * 0.42} Z"></path></g>`;
}
svg += `</svg>`;
return svg;
}
function pageHtml(layout, barriersById) {
const nodes = layout.nodes.map((n) => renderNodeHtml(n, barriersById)).join("");
return `<!doctype html><html><head><meta charset="utf-8"><style>
${stylesCss}
html,body{margin:0;padding:0;background:#ffffff}
.shot-frame{
display:inline-block;padding:28px;
background-color:#ffffff;
background-image:radial-gradient(circle, #d6dae0 1px, transparent 1px);
background-size:20px 20px;
}
.o-tie-view-root{display:block;height:auto;${THEME_VARS}}
.o-tie-transform{width:${layout.bounds.width}px;height:${layout.bounds.height}px}
</style></head><body>
<div class="shot-frame"><div class="o-tie-view-root"><div class="o-tie-transform">
${renderEdgesSvg(layout)}
<div class="o-tie-nodes">${nodes}</div>
</div></div></div>
</body></html>`;
}
// Header height calculator captured from the engine at runtime.
let barrierHeaderHeight = () => 52;
let ROW_H = 24;
function indexBarriers(bowtie) {
const map = new Map();
const add = (b) => map.set(b.id, b);
for (const e of bowtie.events) e.transitionBarriers.forEach(add);
for (const t of bowtie.threats) t.preventionBarriers.forEach(add);
for (const c of bowtie.consequences) c.mitigationBarriers.forEach(add);
return map;
}
async function capture(page, engine, bowtie, outFile) {
const layout = engine.layoutBowtie(bowtie, engine.DEFAULT_LAYOUT);
ROW_H = engine.DEFAULT_LAYOUT.barrierStackRowHeight;
barrierHeaderHeight = (barrier) =>
barrier ? engine.barrierHeaderHeightFor(barrier, engine.DEFAULT_LAYOUT) : engine.DEFAULT_LAYOUT.barrierHeaderHeight;
const barriersById = indexBarriers(bowtie);
await page.setContent(pageHtml(layout, barriersById), { waitUntil: "networkidle" });
const frame = page.locator(".shot-frame");
await frame.screenshot({ path: outFile });
console.log(`Wrote ${outFile}`);
}
async function main() {
mkdirSync(assetsDir, { recursive: true });
const engine = await loadEngine();
const browser = await chromium.launch();
const page = await browser.newPage({ deviceScaleFactor: 2 });
try {
await capture(page, engine, buildHeroBowtie(engine.model), join(assetsDir, "screenshot-diagram.png"));
await capture(page, engine, buildBarrierBowtie(engine.model), join(assetsDir, "screenshot-barrier.png"));
} finally {
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -1,4 +1,4 @@
import { Menu, Notice, Platform, TextFileView, TFile, WorkspaceLeaf, setIcon } from "obsidian";
import { Menu, Notice, Platform, TextFileView, WorkspaceLeaf, setIcon } from "obsidian";
import { ExportImageModal } from "./exportImageModal";
import { HelpModal } from "./helpModal";
import type { BowtieExportViewport } from "./exportImage";
@ -22,7 +22,6 @@ import {
BOWTIE_VIEW_TYPE,
createBarrier,
createBarrierStackItem,
cloneBowtie,
createBowtie,
createConsequence,
createDegradationChain,
@ -37,52 +36,11 @@ import {
serializeBowtie,
sortBarrierStack,
touchBowtie,
bowtieStructureSignature,
} 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 isLightStackColor(color: string): boolean {
if (LIGHT_STACK_ROW_COLORS.has(color)) return true;
const match = /^#([0-9a-f]{6})$/i.exec(color);
if (!match) return false;
const n = parseInt(match[1], 16);
const r = (n >> 16) & 0xff;
const g = (n >> 8) & 0xff;
const b = n & 0xff;
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.62;
}
function createColorMenuTitle(color: string, label: string): DocumentFragment {
const frag = activeDocument.createDocumentFragment();
const wrap = frag.createEl("span", { cls: "o-tie-color-menu-title" });
const swatch = wrap.createEl("span", { cls: "o-tie-color-swatch" });
swatch.setCssStyles({ 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;
}
import { ExternalSync } from "./externalSync";
import { BowtieHistory } from "./history";
import { computeFit, computeZoomAt, normalizeWheelDelta, wheelFactorFromDelta } from "./panZoom";
import { STACK_ROW_COLOR_OPTIONS, createColorMenuTitle, isLightStackColor } from "./stackRows";
export { BOWTIE_VIEW_TYPE };
@ -105,6 +63,9 @@ export class BowtieView extends TextFileView {
private panPointerId: number | null = null;
private activePointers = new Map<number, { x: number; y: number }>();
private pinchLastDistance: number | null = null;
private gestureMoved = false;
private panStartedOnNode = false;
private suppressNextClick = false;
private saveTimeout: number | null = null;
private viewSaveTimeout: number | null = null;
private viewShellReady = false;
@ -125,14 +86,13 @@ export class BowtieView extends TextFileView {
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 history = new BowtieHistory(BowtieView.MAX_UNDO);
private isRestoringHistory = false;
private static readonly MAX_UNDO = 50;
private static readonly GRID_SIZE = 20;
private stackCollapseBackup: Map<string, boolean> | null = null;
private loadError = false;
private rawData: string | null = null;
private get useCssZoom(): boolean {
// CSS zoom distorts border-radius on child buttons in mobile WebKit.
@ -166,6 +126,11 @@ export class BowtieView extends TextFileView {
}
getViewData(): string {
// Preserve the original bytes of a file we could not parse so autosave
// never overwrites a malformed (but potentially recoverable) file.
if (this.loadError && this.rawData !== null) {
return this.rawData;
}
return serializeBowtie(this.bowtie);
}
@ -173,8 +138,15 @@ export class BowtieView extends TextFileView {
if (clear) this.clear();
try {
this.bowtie = deserializeBowtie(data);
this.loadError = false;
this.rawData = null;
} catch {
this.loadError = true;
this.rawData = data;
this.bowtie = createBowtie("Untitled");
new Notice(
"Could not read this .bowtie file — it may be malformed. The file has not been changed; editing here will overwrite it."
);
}
this.resetHistory();
this.render();
@ -188,6 +160,8 @@ export class BowtieView extends TextFileView {
this.toolbarTitleEl = null;
this.undoBtn = null;
this.redoBtn = null;
this.loadError = false;
this.rawData = null;
this.resetHistory();
}
@ -235,29 +209,22 @@ export class BowtieView extends TextFileView {
}
private resetHistory(): void {
this.undoStack = [];
this.redoStack = [];
this.undoSelectionStack = [];
this.redoSelectionStack = [];
this.history.reset();
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 = [];
// A deliberate user edit takes ownership of the file; allow saves again.
this.loadError = false;
this.rawData = null;
this.history.record(this.bowtie, this.selectedRef);
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;
if (this.undoBtn) this.undoBtn.disabled = !this.history.canUndo;
if (this.redoBtn) this.redoBtn.disabled = !this.history.canRedo;
}
private nodeRefExists(ref: NodeRef): boolean {
@ -294,21 +261,15 @@ export class BowtieView extends TextFileView {
}
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);
const snapshot = this.history.undo(this.bowtie, this.selectedRef);
if (!snapshot) return;
this.restoreHistorySnapshot(snapshot.bowtie, snapshot.selection);
}
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);
const snapshot = this.history.redo(this.bowtie, this.selectedRef);
if (!snapshot) return;
this.restoreHistorySnapshot(snapshot.bowtie, snapshot.selection);
}
private render(): void {
@ -392,10 +353,10 @@ export class BowtieView extends TextFileView {
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, "+ Event", () => this.addTopEvent(), { primary: true });
this.createToolbarBtn(addGroup, "+ Barrier", () => this.addBarrierToSelection());
this.createToolbarBtn(addGroup, "+ threat", () => this.addThreat(), { primary: true });
this.createToolbarBtn(addGroup, "+ consequence", () => this.addConsequence(), { primary: true });
this.createToolbarBtn(addGroup, "+ event", () => this.addTopEvent(), { primary: true });
this.createToolbarBtn(addGroup, "+ barrier", () => this.addBarrierToSelection());
this.toolbarActionsEl.createDiv({ cls: "o-tie-toolbar-separator" });
@ -524,7 +485,7 @@ export class BowtieView extends TextFileView {
this.selectedRef.kind === "mitigationBarrier" ||
this.selectedRef.kind === "transitionBarrier"
) {
const stackBtn = actions.createEl("button", { text: "+ Stack row", cls: "mod-small" });
const stackBtn = actions.createEl("button", { text: "+ stack row", cls: "mod-small" });
stackBtn.addEventListener("click", (e) => {
const rect = stackBtn.getBoundingClientRect();
this.showAddStackRowMenu(
@ -539,7 +500,7 @@ export class BowtieView extends TextFileView {
}
if (this.selectedRef.kind === "degradationFactor") {
const sgBtn = actions.createEl("button", { text: "+ Safeguard", cls: "mod-small" });
const sgBtn = actions.createEl("button", { text: "+ safeguard", cls: "mod-small" });
sgBtn.addEventListener("click", () => {
this.addSafeguard(this.selectedRef!);
});
@ -549,7 +510,7 @@ export class BowtieView extends TextFileView {
const eventIndex = this.bowtie.events.findIndex((e) => e.id === this.selectedRef!.eventId);
if (eventIndex >= 0 && eventIndex < this.bowtie.events.length - 1) {
const barBtn = actions.createEl("button", {
text: "+ Barrier to next event",
text: "+ barrier to next event",
cls: "mod-cta mod-small",
});
barBtn.addEventListener("click", () => {
@ -559,14 +520,14 @@ export class BowtieView extends TextFileView {
}
if (this.selectedRef.kind === "threat") {
const barBtn = actions.createEl("button", { text: "+ Prevention Barrier", cls: "mod-cta mod-small" });
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" });
const barBtn = actions.createEl("button", { text: "+ mitigation barrier", cls: "mod-cta mod-small" });
barBtn.addEventListener("click", () => {
this.addMitigationBarrier(this.selectedRef!.consequenceId!);
});
@ -593,7 +554,7 @@ export class BowtieView extends TextFileView {
});
this.svgEl.setAttribute("width", String(layout.bounds.width));
this.svgEl.setAttribute("height", String(layout.bounds.height));
this.svgEl.innerHTML = "";
this.svgEl.replaceChildren();
for (const edge of layout.edges) {
const path = activeDocument.createElementNS("http://www.w3.org/2000/svg", "path");
@ -632,6 +593,28 @@ export class BowtieView extends TextFileView {
this.svgEl.appendChild(group);
}
private selectNodeElement(ref: NodeRef, wrap: HTMLElement): void {
this.selectedRef = ref;
this.renderInspector();
this.nodesEl.querySelectorAll(".o-tie-node-wrap").forEach((n) => {
n.removeClass("o-tie-node-selected");
n.setAttribute("aria-pressed", "false");
});
wrap.addClass("o-tie-node-selected");
wrap.setAttribute("aria-pressed", "true");
this.updateOverlayVisibility();
}
private clearSelection(): void {
this.selectedRef = null;
this.renderInspector();
this.nodesEl.querySelectorAll(".o-tie-node-wrap").forEach((n) => {
n.removeClass("o-tie-node-selected");
n.setAttribute("aria-pressed", "false");
});
this.updateOverlayVisibility();
}
private renderNode(node: PositionedNode): void {
const wrap = this.nodesEl.createDiv({
cls: `o-tie-node-wrap o-tie-node-wrap-${node.kind}`,
@ -812,14 +795,28 @@ export class BowtieView extends TextFileView {
}
}
wrap.tabIndex = 0;
wrap.setAttribute("role", "button");
wrap.setAttribute("aria-label", node.label?.trim() ? `${node.subtitle}: ${node.label}` : node.subtitle);
wrap.setAttribute("aria-pressed", this.isNodeSelected(node.ref) ? "true" : "false");
wrap.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest("button, [role='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");
this.updateOverlayVisibility();
this.selectNodeElement(node.ref, wrap);
});
wrap.addEventListener("keydown", (e) => {
if (e.target !== wrap) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
this.selectNodeElement(node.ref, wrap);
} else if (e.key === "Escape") {
e.stopPropagation();
this.clearSelection();
wrap.blur();
}
});
if (!isBarrier) {
@ -1324,10 +1321,19 @@ export class BowtieView extends TextFileView {
this.containerEl_,
"pointerdown",
(e) => {
// A click only ever follows immediately after the previous pointerup;
// clear any stale suppression flag at the start of a new gesture.
this.suppressNextClick = false;
const target = e.target as HTMLElement;
if (!this.isPanZoomTarget(target)) return;
const isTouch = e.pointerType === "touch" || e.pointerType === "pen";
const onEmptyCanvas = this.isPanZoomTarget(target);
// On touch/pen, let a single-finger drag pan from anywhere on the
// canvas (including on top of a node), as long as the finger isn't on
// an interactive control. A tap still selects the node (see pointerup).
const fromNode = isTouch && !onEmptyCanvas && !this.isInteractiveControl(target);
if (!onEmptyCanvas && !fromNode) return;
if (e.pointerType === "touch" || e.pointerType === "pen") {
if (isTouch && onEmptyCanvas) {
e.preventDefault();
e.stopPropagation();
}
@ -1343,6 +1349,8 @@ export class BowtieView extends TextFileView {
if (this.activePointers.size === 1) {
this.panPointerId = e.pointerId;
this.pinchLastDistance = null;
this.gestureMoved = false;
this.panStartedOnNode = fromNode;
this.containerEl_.setPointerCapture(e.pointerId);
this.startPan(e.clientX, e.clientY);
}
@ -1388,6 +1396,12 @@ export class BowtieView extends TextFileView {
}
if (e.pointerId === this.panPointerId) {
// A drag that began on a node must not also fire the node's tap-select
// click. A pure tap (no movement) falls through and selects normally.
if (this.gestureMoved && this.panStartedOnNode) {
this.suppressNextClick = true;
}
this.panStartedOnNode = false;
this.endPan();
}
@ -1458,19 +1472,29 @@ export class BowtieView extends TextFileView {
const dy = this.wheelDeltaAccum;
this.wheelDeltaAccum = 0;
if (Math.abs(dy) < 0.01) return;
const factor = this.wheelFactorFromDelta(dy, this.wheelCtrlKey);
const factor = wheelFactorFromDelta(dy, this.wheelCtrlKey);
this.zoomAt(this.wheelClient.x, this.wheelClient.y, factor);
});
},
{ passive: false }
);
this.registerDomEvent(
this.containerEl_,
"click",
(e) => {
if (this.suppressNextClick) {
this.suppressNextClick = false;
e.stopPropagation();
e.preventDefault();
}
},
{ capture: true }
);
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.updateOverlayVisibility();
this.clearSelection();
});
this.registerDomEvent(activeDocument, "keydown", (e) => {
@ -1503,22 +1527,12 @@ export class BowtieView extends TextFileView {
}
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));
return normalizeWheelDelta(
e.deltaY,
e.deltaMode,
16,
this.containerEl_.clientHeight || window.innerHeight
);
}
private applyTransform(): void {
@ -1564,35 +1578,17 @@ export class BowtieView extends TextFileView {
if (this.externalSyncReady) return;
this.externalSyncReady = true;
this.registerEvent(
this.app.vault.on("modify", (file) => {
if (!(file instanceof TFile) || file !== this.file) return;
if (Date.now() - this.lastSelfSaveAt < 3000) return;
void this.handleExternalFileChange(file);
})
);
}
private async handleExternalFileChange(file: TFile): Promise<void> {
const disk = await this.app.vault.read(file);
if (disk === this.data || disk === this.getViewData()) return;
let diskBowtie: Bowtie;
let localBowtie: Bowtie;
try {
diskBowtie = deserializeBowtie(disk);
localBowtie = this.bowtie;
} catch {
return;
}
if (bowtieStructureSignature(diskBowtie) === bowtieStructureSignature(localBowtie)) {
return;
}
if (this.saveTimeout !== null) return;
this.setViewData(disk, false);
new ExternalSync({
app: this.app,
getFile: () => this.file,
getLocalBowtie: () => this.bowtie,
getViewData: () => this.getViewData(),
getLocalData: () => this.data,
hasPendingSave: () => this.saveTimeout !== null,
getLastSelfSaveAt: () => this.lastSelfSaveAt,
applyDiskData: (disk) => this.setViewData(disk, false),
registerEvent: (ref) => this.registerEvent(ref),
}).start();
}
private worldToScreen(wx: number, wy: number): { x: number; y: number } {
@ -1727,9 +1723,8 @@ export class BowtieView extends TextFileView {
e.stopPropagation();
}
private isPanZoomTarget(target: HTMLElement): boolean {
return !(
target.closest(".o-tie-node-wrap") ||
private isInteractiveControl(target: HTMLElement): boolean {
return !!(
target.closest(".o-tie-controls-overlay") ||
target.closest(".o-tie-lane-add") ||
target.closest("button") ||
@ -1737,6 +1732,10 @@ export class BowtieView extends TextFileView {
);
}
private isPanZoomTarget(target: HTMLElement): boolean {
return !(target.closest(".o-tie-node-wrap") || this.isInteractiveControl(target));
}
private blockCanvasGesture(e: Event): void {
e.stopPropagation();
if (e.cancelable) e.preventDefault();
@ -1757,6 +1756,7 @@ export class BowtieView extends TextFileView {
if (!this.isPanning) return;
const dx = clientX - this.panStart.x;
const dy = clientY - this.panStart.y;
if (!this.gestureMoved && Math.hypot(dx, dy) > 6) this.gestureMoved = true;
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;
@ -1804,17 +1804,9 @@ export class BowtieView extends TextFileView {
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;
const next = computeZoomAt(this.bowtie.view, clientX - rect.left, clientY - rect.top, factor);
if (!next) return;
this.bowtie.view = next;
this.applyTransform();
this.scheduleViewSave();
}
@ -1904,18 +1896,13 @@ export class BowtieView extends TextFileView {
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.bowtie.view = computeFit(
layout.bounds.width,
layout.bounds.height,
rect.width,
rect.height,
40
);
this.applyTransform();
if (animate) this.scheduleViewSave();
}
@ -2255,14 +2242,14 @@ export class BowtieView extends TextFileView {
private getNodeSubtitle(ref: NodeRef): string {
const map: Record<string, string> = {
hazard: "Hazard",
topEvent: "Top Event",
topEvent: "Top event",
threat: "Threat",
consequence: "Consequence",
preventionBarrier: "Prevention Barrier",
mitigationBarrier: "Mitigation Barrier",
preventionBarrier: "Prevention barrier",
mitigationBarrier: "Mitigation barrier",
transitionBarrier: "Barrier",
safeguard: "Safeguard",
degradationFactor: "Degradation Factor",
degradationFactor: "Degradation factor",
};
return map[ref.kind] ?? ref.kind;
}

View file

@ -11,13 +11,13 @@ export class NewBowtieNameModal extends Modal {
onOpen(): void {
const { contentEl, modalEl } = this;
modalEl.addClass("o-tie-modal");
contentEl.createEl("h2", { text: "Create New Bowtie" });
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.setPlaceholder("E.g. Defective steamcracker");
text.inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") this.submit(text.getValue());
});

56
src/externalSync.ts Normal file
View file

@ -0,0 +1,56 @@
import { App, type EventRef, TFile } from "obsidian";
import { type Bowtie, bowtieStructureSignature, deserializeBowtie } from "./model";
export interface ExternalSyncHost {
app: App;
getFile: () => TFile | null;
getLocalBowtie: () => Bowtie;
getViewData: () => string;
getLocalData: () => string;
hasPendingSave: () => boolean;
getLastSelfSaveAt: () => number;
applyDiskData: (disk: string) => void;
registerEvent: (ref: EventRef) => void;
}
/**
* Reloads the view when the underlying .bowtie file changes on disk (e.g. via
* Obsidian Sync), but only when the change is genuinely external and there are
* no unsaved local edits.
*/
export class ExternalSync {
private started = false;
constructor(private readonly host: ExternalSyncHost) {}
start(): void {
if (this.started) return;
this.started = true;
this.host.registerEvent(
this.host.app.vault.on("modify", (file) => {
if (!(file instanceof TFile) || file !== this.host.getFile()) return;
if (Date.now() - this.host.getLastSelfSaveAt() < 3000) return;
void this.handle(file);
})
);
}
private async handle(file: TFile): Promise<void> {
const disk = await this.host.app.vault.read(file);
if (disk === this.host.getLocalData() || disk === this.host.getViewData()) return;
let diskBowtie: Bowtie;
try {
diskBowtie = deserializeBowtie(disk);
} catch {
return;
}
if (bowtieStructureSignature(diskBowtie) === bowtieStructureSignature(this.host.getLocalBowtie())) {
return;
}
if (this.host.hasPendingSave()) return;
this.host.applyDiskData(disk);
}
}

View file

@ -11,7 +11,7 @@ export class HelpModal extends Modal {
onOpen(): void {
const { contentEl, modalEl } = this;
modalEl.addClass("o-tie-modal", "o-tie-help-modal");
contentEl.createEl("h2", { text: "O-Tie Help" });
contentEl.createEl("h2", { text: "Help" });
contentEl.createEl("p", {
cls: "o-tie-help-version",

53
src/history.ts Normal file
View file

@ -0,0 +1,53 @@
import { type Bowtie, cloneBowtie, type NodeRef } from "./model";
export interface BowtieSnapshot {
bowtie: Bowtie;
selection: NodeRef | null;
}
function snapshot(bowtie: Bowtie, selection: NodeRef | null): BowtieSnapshot {
return {
bowtie: cloneBowtie(bowtie),
selection: selection ? { ...selection } : null,
};
}
/** Undo/redo stack of bowtie snapshots with the selection at edit time. */
export class BowtieHistory {
private undoStack: BowtieSnapshot[] = [];
private redoStack: BowtieSnapshot[] = [];
constructor(private readonly maxUndo = 50) {}
reset(): void {
this.undoStack = [];
this.redoStack = [];
}
get canUndo(): boolean {
return this.undoStack.length > 0;
}
get canRedo(): boolean {
return this.redoStack.length > 0;
}
/** Record the state immediately before an edit; clears the redo stack. */
record(bowtie: Bowtie, selection: NodeRef | null): void {
this.undoStack.push(snapshot(bowtie, selection));
if (this.undoStack.length > this.maxUndo) this.undoStack.shift();
this.redoStack = [];
}
undo(currentBowtie: Bowtie, currentSelection: NodeRef | null): BowtieSnapshot | null {
if (this.undoStack.length === 0) return null;
this.redoStack.push(snapshot(currentBowtie, currentSelection));
return this.undoStack.pop() ?? null;
}
redo(currentBowtie: Bowtie, currentSelection: NodeRef | null): BowtieSnapshot | null {
if (this.redoStack.length === 0) return null;
this.undoStack.push(snapshot(currentBowtie, currentSelection));
return this.redoStack.pop() ?? null;
}
}

72
src/panZoom.ts Normal file
View file

@ -0,0 +1,72 @@
import type { BowtieViewState } from "./model";
export const MIN_ZOOM = 0.2;
export const MAX_ZOOM = 3;
export const FIT_MIN_ZOOM = 0.3;
export const FIT_MAX_ZOOM = 1.2;
export function clampZoom(zoom: number, min = MIN_ZOOM, max = MAX_ZOOM): number {
return Math.min(max, Math.max(min, zoom));
}
/** Normalize a wheel delta to pixels regardless of the browser's delta mode. */
export function normalizeWheelDelta(
deltaY: number,
deltaMode: number,
lineHeight: number,
pageHeight: number
): number {
if (deltaMode === WheelEvent.DOM_DELTA_LINE) return deltaY * lineHeight;
if (deltaMode === WheelEvent.DOM_DELTA_PAGE) return deltaY * pageHeight;
return deltaY;
}
/** Gentle zoom factor: ~5% per wheel notch, smoother for trackpad pinch (ctrl). */
export function wheelFactorFromDelta(deltaY: number, ctrlKey: boolean): number {
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));
}
/**
* Zoom toward a point (in container-local coordinates) by a factor, keeping the
* world point under the cursor stable. Returns null when the zoom is unchanged.
*/
export function computeZoomAt(
view: BowtieViewState,
localX: number,
localY: number,
factor: number
): BowtieViewState | null {
const oldZoom = view.zoom;
const newZoom = clampZoom(oldZoom * factor);
if (newZoom === oldZoom) return null;
const worldX = (localX - view.panX) / oldZoom;
const worldY = (localY - view.panY) / oldZoom;
return {
zoom: newZoom,
panX: localX - worldX * newZoom,
panY: localY - worldY * newZoom,
};
}
/** Center and scale a diagram of the given bounds inside a viewport rectangle. */
export function computeFit(
boundsWidth: number,
boundsHeight: number,
rectWidth: number,
rectHeight: number,
padding: number
): BowtieViewState {
const scaleX = (rectWidth - padding * 2) / boundsWidth;
const scaleY = (rectHeight - padding * 2) / boundsHeight;
const zoom = Math.min(FIT_MAX_ZOOM, Math.max(FIT_MIN_ZOOM, Math.min(scaleX, scaleY)));
return {
zoom,
panX: (rectWidth - boundsWidth * zoom) / 2,
panY: (rectHeight - boundsHeight * zoom) / 2,
};
}

View file

@ -42,68 +42,75 @@ export class OTieSettingTab extends PluginSettingTab {
})
);
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();
}
})
);
this.addPixelSetting(
containerEl,
"Column gap",
"Horizontal spacing between bowtie columns (pixels)",
120,
() => this.plugin.settings.columnGap,
(v) => (this.plugin.settings.columnGap = v)
);
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();
}
})
);
this.addPixelSetting(
containerEl,
"Row gap",
"Vertical spacing between threats/consequences (pixels)",
40,
() => this.plugin.settings.rowGap,
(v) => (this.plugin.settings.rowGap = v)
);
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();
}
})
);
this.addPixelSetting(
containerEl,
"Node width",
"Default width of bowtie nodes (pixels)",
220,
() => this.plugin.settings.nodeWidth,
(v) => (this.plugin.settings.nodeWidth = v)
);
this.addPixelSetting(
containerEl,
"Node height",
"Default height of bowtie nodes (pixels)",
80,
() => this.plugin.settings.nodeHeight,
(v) => (this.plugin.settings.nodeHeight = v)
);
}
private addPixelSetting(
containerEl: HTMLElement,
name: string,
desc: string,
placeholder: number,
get: () => number,
set: (value: number) => void
): void {
new Setting(containerEl)
.setName("Node height")
.setDesc("Default height of bowtie nodes (pixels)")
.addText((text) =>
.setName(name)
.setDesc(desc)
.addText((text) => {
text
.setPlaceholder("80")
.setValue(String(this.plugin.settings.nodeHeight))
.setPlaceholder(String(placeholder))
.setValue(String(get()))
.onChange(async (value) => {
const parsed = parseInt(value, 10);
if (!isNaN(parsed) && parsed > 0) {
this.plugin.settings.nodeHeight = parsed;
await this.plugin.saveSettings();
const parsed = Number(value.trim());
const valid = Number.isFinite(parsed) && Number.isInteger(parsed) && parsed > 0 && parsed <= 2000;
text.inputEl.toggleClass("o-tie-setting-invalid", !valid);
text.inputEl.setAttribute("aria-invalid", valid ? "false" : "true");
if (!valid) {
text.inputEl.title = "Enter a whole number of pixels between 1 and 2000";
return;
}
})
);
text.inputEl.title = "";
set(parsed);
await this.plugin.saveSettings();
});
text.inputEl.type = "number";
text.inputEl.inputMode = "numeric";
text.inputEl.min = "1";
text.inputEl.max = "2000";
});
}
}

43
src/stackRows.ts Normal file
View file

@ -0,0 +1,43 @@
export 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"]);
export function isLightStackColor(color: string): boolean {
if (LIGHT_STACK_ROW_COLORS.has(color)) return true;
const match = /^#([0-9a-f]{6})$/i.exec(color);
if (!match) return false;
const n = parseInt(match[1], 16);
const r = (n >> 16) & 0xff;
const g = (n >> 8) & 0xff;
const b = n & 0xff;
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.62;
}
export function createColorMenuTitle(color: string, label: string): DocumentFragment {
const frag = activeDocument.createDocumentFragment();
const wrap = frag.createEl("span", { cls: "o-tie-color-menu-title" });
const swatch = wrap.createEl("span", { cls: "o-tie-color-swatch" });
swatch.setCssStyles({ 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;
}

View file

@ -1,3 +1,9 @@
/* Settings */
.o-tie-setting-invalid {
border-color: var(--text-error) !important;
box-shadow: 0 0 0 1px var(--text-error);
}
/* Modal (create bowtie) */
.o-tie-modal {
max-width: 480px;

92
tests/layout.test.ts Normal file
View file

@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
bezierPointAt,
barrierHeaderHeightFor,
DEFAULT_LAYOUT,
layoutBowtie,
nodeBoxHeight,
} from "../src/layout";
import {
createBarrier,
createBowtie,
createConsequence,
createThreat,
type Bowtie,
} from "../src/model";
function sample(): Bowtie {
const bt = createBowtie("Sample");
bt.events[0].label = "Top event";
bt.events[0].hazard = "Hazard";
const threat = createThreat("Threat");
threat.preventionBarriers = [createBarrier("Prevention")];
bt.threats = [threat];
const consequence = createConsequence("Consequence");
consequence.mitigationBarriers = [createBarrier("Mitigation")];
bt.consequences = [consequence];
return bt;
}
describe("nodeBoxHeight", () => {
it("respects the minimum height", () => {
expect(nodeBoxHeight("", 200, 72)).toBeGreaterThanOrEqual(72);
});
it("grows for longer labels that wrap", () => {
const short = nodeBoxHeight("Short", 120, 40);
const long = nodeBoxHeight(
"A very long barrier label that will certainly wrap across several lines in a narrow box",
120,
40
);
expect(long).toBeGreaterThan(short);
});
});
describe("barrierHeaderHeightFor", () => {
it("is at least the configured header height", () => {
const barrier = createBarrier("B");
expect(barrierHeaderHeightFor(barrier, DEFAULT_LAYOUT)).toBeGreaterThanOrEqual(
DEFAULT_LAYOUT.barrierHeaderHeight
);
});
});
describe("bezierPointAt", () => {
it("returns endpoints at t=0 and t=1", () => {
expect(bezierPointAt(0, 0, 100, 0, 0)).toEqual({ x: 0, y: 0 });
expect(bezierPointAt(0, 0, 100, 0, 1)).toEqual({ x: 100, y: 0 });
});
it("returns the midpoint x for a horizontal segment at t=0.5", () => {
const p = bezierPointAt(0, 0, 100, 0, 0.5);
expect(p.x).toBeCloseTo(50, 5);
expect(p.y).toBeCloseTo(0, 5);
});
});
describe("layoutBowtie", () => {
it("produces nodes, edges, and positive bounds", () => {
const layout = layoutBowtie(sample(), DEFAULT_LAYOUT);
expect(layout.nodes.length).toBeGreaterThan(0);
expect(layout.edges.length).toBeGreaterThan(0);
expect(layout.bounds.width).toBeGreaterThan(0);
expect(layout.bounds.height).toBeGreaterThan(0);
});
it("places threat left of top event left of consequence", () => {
const layout = layoutBowtie(sample(), DEFAULT_LAYOUT);
const threat = layout.nodes.find((n) => n.kind === "threat")!;
const topEvent = layout.nodes.find((n) => n.kind === "topEvent")!;
const consequence = layout.nodes.find((n) => n.kind === "consequence")!;
expect(threat.x).toBeLessThan(topEvent.x);
expect(topEvent.x).toBeLessThan(consequence.x);
});
it("places the hazard above its top event", () => {
const layout = layoutBowtie(sample(), DEFAULT_LAYOUT);
const hazard = layout.nodes.find((n) => n.kind === "hazard")!;
const topEvent = layout.nodes.find((n) => n.kind === "topEvent")!;
expect(hazard.y).toBeLessThan(topEvent.y);
});
});

146
tests/model.test.ts Normal file
View file

@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
bowtieStructureSignature,
cloneBowtie,
createBarrier,
createBowtie,
createConsequence,
createThreat,
deserializeBowtie,
serializeBowtie,
type Bowtie,
} from "../src/model";
function sampleBowtie(): Bowtie {
const bt = createBowtie("Sample");
bt.events[0].label = "Loss of containment";
bt.events[0].hazard = "High pressure";
const threat = createThreat("Corrosion");
threat.preventionBarriers = [createBarrier("Inspection")];
bt.threats = [threat];
bt.consequences = [createConsequence("Fire")];
return bt;
}
describe("createBowtie", () => {
it("creates a valid single-event bowtie", () => {
const bt = createBowtie("New");
expect(bt.events).toHaveLength(1);
expect(bt.threats).toEqual([]);
expect(bt.consequences).toEqual([]);
expect(bt.id).toBeTruthy();
});
});
describe("serialize/deserialize", () => {
it("round-trips structure", () => {
const bt = sampleBowtie();
const restored = deserializeBowtie(serializeBowtie(bt));
expect(restored.name).toBe(bt.name);
expect(restored.events[0].label).toBe("Loss of containment");
expect(restored.events[0].hazard).toBe("High pressure");
expect(restored.threats[0].preventionBarriers[0].label).toBe("Inspection");
expect(restored.consequences[0].label).toBe("Fire");
});
it("adds a default view when missing", () => {
const bt = sampleBowtie();
delete bt.view;
const restored = deserializeBowtie(serializeBowtie(bt));
expect(restored.view).toEqual({ zoom: 1, panX: 0, panY: 0 });
});
});
describe("legacy migration", () => {
it("moves top-level hazard/topEvent into events", () => {
const legacy = JSON.stringify({
id: "x",
name: "Legacy",
hazard: "Ethylene",
topEvent: "Loss of containment",
threats: [],
consequences: [],
});
const bt = deserializeBowtie(legacy);
expect(bt.events).toHaveLength(1);
expect(bt.events[0].hazard).toBe("Ethylene");
expect(bt.events[0].label).toBe("Loss of containment");
expect((bt as Bowtie & { topEvent?: string }).topEvent).toBeUndefined();
expect((bt as Bowtie & { hazard?: string }).hazard).toBeUndefined();
});
it("converts barrier escalationFactors into degradationChains", () => {
const legacy = JSON.stringify({
id: "x",
name: "Legacy",
hazard: "H",
topEvent: "T",
threats: [
{
id: "t1",
label: "Threat",
preventionBarriers: [
{
id: "b1",
label: "Barrier",
escalationFactors: [
{
id: "ef1",
label: "Deferred inspection",
escalationBarriers: [{ id: "eb1", label: "Audit" }],
},
],
},
],
},
],
consequences: [],
});
const bt = deserializeBowtie(legacy);
const barrier = bt.threats[0].preventionBarriers[0];
expect(barrier.degradationChains).toHaveLength(1);
const chain = barrier.degradationChains[0];
const labels = chain.safeguards.map((s) => s.label);
expect(labels).toContain("Deferred inspection");
expect(labels).toContain("Audit");
expect((barrier as { escalationFactors?: unknown }).escalationFactors).toBeUndefined();
});
});
describe("validation", () => {
it("throws when id or name is missing", () => {
expect(() => deserializeBowtie(JSON.stringify({ name: "x", events: [{}], threats: [], consequences: [] }))).toThrow();
});
it("throws when threats/consequences are not arrays", () => {
expect(() =>
deserializeBowtie(JSON.stringify({ id: "a", name: "b", events: [{ hazard: "" }], threats: {}, consequences: [] }))
).toThrow();
});
it("repairs an empty events array into a single event", () => {
const bt = deserializeBowtie(
JSON.stringify({ id: "a", name: "b", events: [], threats: [], consequences: [] })
);
expect(bt.events).toHaveLength(1);
expect(typeof bt.events[0].hazard).toBe("string");
});
});
describe("bowtieStructureSignature", () => {
it("ignores view and timestamps", () => {
const a = sampleBowtie();
const b = cloneBowtie(a);
b.view = { zoom: 2, panX: 100, panY: 50 };
b.updatedAt = "2099-01-01T00:00:00.000Z";
b.createdAt = "2099-01-01T00:00:00.000Z";
expect(bowtieStructureSignature(a)).toBe(bowtieStructureSignature(b));
});
it("changes when content changes", () => {
const a = sampleBowtie();
const b = cloneBowtie(a);
b.threats[0].label = "Different";
expect(bowtieStructureSignature(a)).not.toBe(bowtieStructureSignature(b));
});
});

58
tests/panZoom.test.ts Normal file
View file

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
clampZoom,
computeFit,
computeZoomAt,
MAX_ZOOM,
MIN_ZOOM,
wheelFactorFromDelta,
} from "../src/panZoom";
describe("clampZoom", () => {
it("clamps to the configured range", () => {
expect(clampZoom(0.01)).toBe(MIN_ZOOM);
expect(clampZoom(99)).toBe(MAX_ZOOM);
expect(clampZoom(1)).toBe(1);
});
});
describe("wheelFactorFromDelta", () => {
it("zooms in for negative delta and out for positive delta", () => {
expect(wheelFactorFromDelta(-100, false)).toBeGreaterThan(1);
expect(wheelFactorFromDelta(100, false)).toBeLessThan(1);
});
it("caps the per-notch step", () => {
expect(wheelFactorFromDelta(-100000, false)).toBeLessThanOrEqual(1.06);
expect(wheelFactorFromDelta(100000, true)).toBeGreaterThanOrEqual(1 / 1.08);
});
});
describe("computeZoomAt", () => {
it("keeps the point under the cursor fixed", () => {
const view = { zoom: 1, panX: 0, panY: 0 };
const next = computeZoomAt(view, 200, 100, 2)!;
expect(next.zoom).toBe(2);
// World point under (200,100) before zoom: (200,100). After zoom it must map back to (200,100).
const worldX = (200 - next.panX) / next.zoom;
const worldY = (100 - next.panY) / next.zoom;
expect(worldX).toBeCloseTo(200, 5);
expect(worldY).toBeCloseTo(100, 5);
});
it("returns null when clamped zoom does not change", () => {
const view = { zoom: MAX_ZOOM, panX: 0, panY: 0 };
expect(computeZoomAt(view, 0, 0, 2)).toBeNull();
});
});
describe("computeFit", () => {
it("centers the diagram within the viewport", () => {
const fit = computeFit(400, 200, 1000, 600, 40);
expect(fit.zoom).toBeGreaterThan(0);
const renderedW = 400 * fit.zoom;
const renderedH = 200 * fit.zoom;
expect(fit.panX).toBeCloseTo((1000 - renderedW) / 2, 5);
expect(fit.panY).toBeCloseTo((600 - renderedH) / 2, 5);
});
});

View file

@ -6,5 +6,6 @@
"1.0.4": "1.4.0",
"1.0.5": "1.4.0",
"1.0.6": "1.4.0",
"1.0.7": "1.4.0"
"1.0.7": "1.4.0",
"1.0.8": "1.4.0"
}