mirror of
https://github.com/andre482/O-tie.git
synced 2026-07-22 07:44:11 +00:00
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:
parent
d86fd3291c
commit
8a376a87a8
28 changed files with 3023 additions and 689 deletions
22
.github/workflows/ci.yml
vendored
Normal file
22
.github/workflows/ci.yml
vendored
Normal 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
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ main.js.map
|
|||
*.log
|
||||
deploy.local.mjs
|
||||
deploy.local.ps1
|
||||
*.code-workspace
|
||||
|
|
|
|||
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -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
100
CONTRIBUTING.md
Normal 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
|
||||
```
|
||||
68
README.md
68
README.md
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
*A bowtie for a cold-storage ammonia loss-of-primary-containment scenario.*
|
||||
|
||||

|
||||
|
||||
*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.
|
||||
|
|
|
|||
BIN
assets/screenshot-barrier.png
Normal file
BIN
assets/screenshot-barrier.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
assets/screenshot-diagram.png
Normal file
BIN
assets/screenshot-diagram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 225 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1223
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
34
scripts/engine.mjs
Normal 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 });
|
||||
}
|
||||
}
|
||||
307
scripts/generate-examples.mjs
Normal file
307
scripts/generate-examples.mjs
Normal 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());
|
||||
279
scripts/render-screenshots.mjs
Normal file
279
scripts/render-screenshots.mjs
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
56
src/externalSync.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
53
src/history.ts
Normal 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
72
src/panZoom.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
43
src/stackRows.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
92
tests/layout.test.ts
Normal 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
146
tests/model.test.ts
Normal 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
58
tests/panZoom.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in a new issue