mirror of
https://github.com/andre482/O-tie.git
synced 2026-07-22 07:44:11 +00:00
Release 1.0.3: Mobile touch support, sync hardening, and circular add buttons.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6e4de6938f
commit
a0abb2f177
13 changed files with 1888 additions and 411 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -2,6 +2,19 @@
|
|||
|
||||
All notable changes to O-Tie are documented here.
|
||||
|
||||
## [1.0.3] — 2026-06-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mobile + buttons** — Lane and node add controls keep a circular shape on phone and tablet by overriding mobile button min-height and enforcing a 1:1 aspect ratio.
|
||||
- **Mobile pan and zoom** — Canvas navigation now uses pointer events: one finger to pan, two fingers to pinch zoom (toolbar +/− still works as a fallback).
|
||||
|
||||
### Improved
|
||||
|
||||
- **Obsidian Sync** — Pending edits flush when the editor closes so changes reach disk before switching devices. Remote file updates reload automatically when there are no unsaved local edits. Help documents the Obsidian Sync settings required for `.bowtie` files.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.2] — 2026-06-19
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ Open **Settings → O-Tie** to configure:
|
|||
}
|
||||
```
|
||||
|
||||
See [examples/steamcracker.bowtie](examples/steamcracker.bowtie).
|
||||
See [examples/steamcracker.bowtie](examples/steamcracker.bowtie) and [examples/cold-storage-ammonia.bowtie](examples/cold-storage-ammonia.bowtie) (LinkedIn promo scenario).
|
||||
|
||||
## Changelog
|
||||
|
||||
|
|
|
|||
788
examples/cold-storage-ammonia.bowtie
Normal file
788
examples/cold-storage-ammonia.bowtie
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
{
|
||||
"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",
|
||||
"threats": [
|
||||
{
|
||||
"id": "threat-corrosion",
|
||||
"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",
|
||||
"label": "Mechanical integrity inspection program",
|
||||
"notes": "NDT, thickness monitoring, and replacement intervals per IIAR-6 / company standards.",
|
||||
"escalationFactors": [
|
||||
{
|
||||
"id": "ef-deferred-inspection",
|
||||
"label": "Deferred inspection due to production pressure",
|
||||
"escalationBarriers": [
|
||||
{
|
||||
"id": "eb-integrity-audit",
|
||||
"label": "QA audit of mechanical integrity backlog",
|
||||
"escalationFactors": [],
|
||||
"stack": [],
|
||||
"stackCollapsed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stack": [
|
||||
{
|
||||
"id": "stack-pb-integrity-type",
|
||||
"field": "type",
|
||||
"label": "Active Hardware",
|
||||
"color": "#48c9b0"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-integrity-effectiveness",
|
||||
"field": "effectiveness",
|
||||
"label": "Good",
|
||||
"color": "#27ae60"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-integrity-criticality",
|
||||
"field": "criticality",
|
||||
"label": "Very High",
|
||||
"color": "#2c3e50"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-integrity-responsible",
|
||||
"field": "responsible",
|
||||
"label": "Tech Department",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-integrity-validation",
|
||||
"field": "validation",
|
||||
"label": "Inspection",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-integrity-status",
|
||||
"field": "status",
|
||||
"label": "Available",
|
||||
"color": "#1e8449"
|
||||
}
|
||||
],
|
||||
"stackCollapsed": false
|
||||
},
|
||||
{
|
||||
"id": "pb-materials",
|
||||
"label": "Materials of construction and corrosion allowance per design standard",
|
||||
"notes": "Compatible metallurgy, coatings, and design margin for operating conditions.",
|
||||
"escalationFactors": [],
|
||||
"stack": [
|
||||
{
|
||||
"id": "stack-pb-materials-type",
|
||||
"field": "type",
|
||||
"label": "Passive Hardware",
|
||||
"color": "#7f8c8d"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-materials-effectiveness",
|
||||
"field": "effectiveness",
|
||||
"label": "Good",
|
||||
"color": "#27ae60"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-materials-criticality",
|
||||
"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",
|
||||
"field": "status",
|
||||
"label": "Available",
|
||||
"color": "#1e8449"
|
||||
}
|
||||
],
|
||||
"stackCollapsed": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "threat-impact",
|
||||
"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",
|
||||
"label": "Physical protection and traffic segregation",
|
||||
"notes": "Bollards, rack guards, marked routes, and restricted access to machine room.",
|
||||
"escalationFactors": [
|
||||
{
|
||||
"id": "ef-bollard-bypass",
|
||||
"label": "Bollards removed or bypassed for convenience",
|
||||
"escalationBarriers": [
|
||||
{
|
||||
"id": "eb-supervisor-rounds",
|
||||
"label": "Supervisor housekeeping and barrier inspection rounds",
|
||||
"escalationFactors": [],
|
||||
"stack": [],
|
||||
"stackCollapsed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stack": [
|
||||
{
|
||||
"id": "stack-pb-protection-type",
|
||||
"field": "type",
|
||||
"label": "Passive Hardware",
|
||||
"color": "#7f8c8d"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-protection-effectiveness",
|
||||
"field": "effectiveness",
|
||||
"label": "Good",
|
||||
"color": "#27ae60"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-protection-criticality",
|
||||
"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",
|
||||
"field": "status",
|
||||
"label": "Available",
|
||||
"color": "#1e8449"
|
||||
}
|
||||
],
|
||||
"stackCollapsed": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "threat-overpressure",
|
||||
"label": "Overpressure in low-side or high-side refrigeration circuit",
|
||||
"preventionBarriers": [
|
||||
{
|
||||
"id": "pb-relief",
|
||||
"label": "Pressure relief devices and high-pressure interlocks",
|
||||
"notes": "PSVs discharge to safe location; alarm and trip on abnormal pressure.",
|
||||
"escalationFactors": [
|
||||
{
|
||||
"id": "ef-psv-blocked",
|
||||
"label": "Relief device isolated, blocked, or inspection overdue",
|
||||
"escalationBarriers": [
|
||||
{
|
||||
"id": "eb-psv-program",
|
||||
"label": "PSV inspection and register management program",
|
||||
"escalationFactors": [],
|
||||
"stack": [],
|
||||
"stackCollapsed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stack": [
|
||||
{
|
||||
"id": "stack-pb-relief-type",
|
||||
"field": "type",
|
||||
"label": "Active Hardware",
|
||||
"color": "#48c9b0"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-relief-effectiveness",
|
||||
"field": "effectiveness",
|
||||
"label": "Good",
|
||||
"color": "#27ae60"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-relief-criticality",
|
||||
"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",
|
||||
"field": "status",
|
||||
"label": "Available",
|
||||
"color": "#1e8449"
|
||||
}
|
||||
],
|
||||
"stackCollapsed": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "threat-maintenance",
|
||||
"label": "Maintenance isolation or re-commissioning error",
|
||||
"preventionBarriers": [
|
||||
{
|
||||
"id": "pb-ptw",
|
||||
"label": "Permit-to-work and LOTO procedures",
|
||||
"notes": "Verified isolation, zero-energy confirmation, and ammonia-specific competency.",
|
||||
"escalationFactors": [
|
||||
{
|
||||
"id": "ef-competency-gap",
|
||||
"label": "Technician competency not verified for ammonia work",
|
||||
"escalationBarriers": [
|
||||
{
|
||||
"id": "eb-competency",
|
||||
"label": "Competency assessment and authorization records",
|
||||
"escalationFactors": [],
|
||||
"stack": [],
|
||||
"stackCollapsed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stack": [
|
||||
{
|
||||
"id": "stack-pb-ptw-type",
|
||||
"field": "type",
|
||||
"label": "Active Human",
|
||||
"color": "#5dade2"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-ptw-effectiveness",
|
||||
"field": "effectiveness",
|
||||
"label": "Good",
|
||||
"color": "#27ae60"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-ptw-criticality",
|
||||
"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",
|
||||
"field": "status",
|
||||
"label": "Available",
|
||||
"color": "#1e8449"
|
||||
}
|
||||
],
|
||||
"stackCollapsed": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "threat-operator",
|
||||
"label": "Operator error during startup, shutdown, or abnormal operation",
|
||||
"preventionBarriers": [
|
||||
{
|
||||
"id": "pb-sop",
|
||||
"label": "Operating procedures and abnormal situation guidance",
|
||||
"notes": "Written procedures for charge management, alarms, and emergency slowdown.",
|
||||
"escalationFactors": [],
|
||||
"stack": [
|
||||
{
|
||||
"id": "stack-pb-sop-type",
|
||||
"field": "type",
|
||||
"label": "Active Human",
|
||||
"color": "#5dade2"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-sop-effectiveness",
|
||||
"field": "effectiveness",
|
||||
"label": "Good",
|
||||
"color": "#27ae60"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-sop-criticality",
|
||||
"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",
|
||||
"field": "status",
|
||||
"label": "Available",
|
||||
"color": "#1e8449"
|
||||
}
|
||||
],
|
||||
"stackCollapsed": true
|
||||
},
|
||||
{
|
||||
"id": "pb-training",
|
||||
"label": "Operator training and competency assurance",
|
||||
"notes": "Refresher training on ammonia properties, alarms, and first actions.",
|
||||
"escalationFactors": [],
|
||||
"stack": [
|
||||
{
|
||||
"id": "stack-pb-training-type",
|
||||
"field": "type",
|
||||
"label": "Reactive Human",
|
||||
"color": "#3498db"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-training-effectiveness",
|
||||
"field": "effectiveness",
|
||||
"label": "Fair",
|
||||
"color": "#f1c40f"
|
||||
},
|
||||
{
|
||||
"id": "stack-pb-training-criticality",
|
||||
"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",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"consequences": [
|
||||
{
|
||||
"id": "consequence-exposure",
|
||||
"label": "Personnel harm due to toxic ammonia exposure",
|
||||
"mitigationBarriers": [
|
||||
{
|
||||
"id": "mb-detection",
|
||||
"label": "Fixed ammonia gas detection and alarm",
|
||||
"notes": "Audible/visual alarm, potential automatic isolation or ventilation actuation.",
|
||||
"escalationFactors": [
|
||||
{
|
||||
"id": "ef-calibration",
|
||||
"label": "Sensor calibration overdue or drift",
|
||||
"escalationBarriers": [
|
||||
{
|
||||
"id": "eb-calibration-program",
|
||||
"label": "Calibration management and functional test program",
|
||||
"escalationFactors": [],
|
||||
"stack": [],
|
||||
"stackCollapsed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stack": [
|
||||
{
|
||||
"id": "stack-mb-detection-type",
|
||||
"field": "type",
|
||||
"label": "Active Hardware",
|
||||
"color": "#48c9b0"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-detection-effectiveness",
|
||||
"field": "effectiveness",
|
||||
"label": "Good",
|
||||
"color": "#27ae60"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-detection-criticality",
|
||||
"field": "criticality",
|
||||
"label": "Very High",
|
||||
"color": "#2c3e50"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-detection-responsible",
|
||||
"field": "responsible",
|
||||
"label": "HSE Department",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-detection-validation",
|
||||
"field": "validation",
|
||||
"label": "Barrier Test",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-detection-status",
|
||||
"field": "status",
|
||||
"label": "Available",
|
||||
"color": "#1e8449"
|
||||
}
|
||||
],
|
||||
"stackCollapsed": false
|
||||
},
|
||||
{
|
||||
"id": "mb-ventilation",
|
||||
"label": "Emergency ventilation and isolation valves",
|
||||
"notes": "Reduce airborne concentration; close isolation valves where safe to do so.",
|
||||
"escalationFactors": [],
|
||||
"stack": [
|
||||
{
|
||||
"id": "stack-mb-ventilation-type",
|
||||
"field": "type",
|
||||
"label": "Active Hardware",
|
||||
"color": "#48c9b0"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-ventilation-effectiveness",
|
||||
"field": "effectiveness",
|
||||
"label": "Good",
|
||||
"color": "#27ae60"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-ventilation-criticality",
|
||||
"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",
|
||||
"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
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "consequence-environment",
|
||||
"label": "Environmental damage due to ammonia release",
|
||||
"mitigationBarriers": [
|
||||
{
|
||||
"id": "mb-containment",
|
||||
"label": "Containment sump and emergency dilution / knock-down",
|
||||
"notes": "Limit spread to drains, waterways, or off-site receptors.",
|
||||
"escalationFactors": [],
|
||||
"stack": [
|
||||
{
|
||||
"id": "stack-mb-containment-type",
|
||||
"field": "type",
|
||||
"label": "Passive Hardware",
|
||||
"color": "#7f8c8d"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-containment-effectiveness",
|
||||
"field": "effectiveness",
|
||||
"label": "Fair",
|
||||
"color": "#f1c40f"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-containment-criticality",
|
||||
"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",
|
||||
"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
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "consequence-business",
|
||||
"label": "Operational loss due to cold-chain interruption",
|
||||
"mitigationBarriers": [
|
||||
{
|
||||
"id": "mb-erp",
|
||||
"label": "Emergency response plan and qualified contractor standby",
|
||||
"notes": "Ammonia-qualified emergency repair and product salvage / transfer plan.",
|
||||
"escalationFactors": [
|
||||
{
|
||||
"id": "ef-contractor-gap",
|
||||
"label": "Contractor availability or response time not assured",
|
||||
"escalationBarriers": [
|
||||
{
|
||||
"id": "eb-contractor-retainer",
|
||||
"label": "Pre-qualified contractor agreement and annual drill",
|
||||
"escalationFactors": [],
|
||||
"stack": [],
|
||||
"stackCollapsed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stack": [
|
||||
{
|
||||
"id": "stack-mb-erp-type",
|
||||
"field": "type",
|
||||
"label": "Reactive Human",
|
||||
"color": "#3498db"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-erp-effectiveness",
|
||||
"field": "effectiveness",
|
||||
"label": "Good",
|
||||
"color": "#27ae60"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-erp-criticality",
|
||||
"field": "criticality",
|
||||
"label": "High",
|
||||
"color": "#566573"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-erp-responsible",
|
||||
"field": "responsible",
|
||||
"label": "Management",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-erp-validation",
|
||||
"field": "validation",
|
||||
"label": "Drills and Exercise",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"id": "stack-mb-erp-status",
|
||||
"field": "status",
|
||||
"label": "Available",
|
||||
"color": "#1e8449"
|
||||
}
|
||||
],
|
||||
"stackCollapsed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"view": {
|
||||
"zoom": 1,
|
||||
"panX": 0,
|
||||
"panY": 0
|
||||
},
|
||||
"createdAt": "2026-06-19T00:00:00.000Z",
|
||||
"updatedAt": "2026-06-19T12:05:10.306Z"
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "o-tie",
|
||||
"name": "O-Tie",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"minAppVersion": "1.4.0",
|
||||
"description": "Build and edit risk bowtie diagrams in a visual editor with barriers, escalation factors, analysis stacks, and PNG export.",
|
||||
"author": "Andre482",
|
||||
|
|
|
|||
48
package-lock.json
generated
48
package-lock.json
generated
|
|
@ -18,6 +18,7 @@
|
|||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"obsidian": "latest",
|
||||
"playwright": "^1.61.0",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.61.1"
|
||||
|
|
@ -2769,6 +2770,21 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
|
@ -4117,6 +4133,38 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "o-tie",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "Obsidian plugin for building risk bowtie diagrams",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"obsidian": "latest",
|
||||
"playwright": "^1.61.0",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.61.1"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Menu, Notice, TextFileView, WorkspaceLeaf, setIcon } from "obsidian";
|
||||
import { Menu, Notice, TextFileView, TFile, WorkspaceLeaf, setIcon } from "obsidian";
|
||||
import { ExportImageModal } from "./exportImageModal";
|
||||
import { HelpModal } from "./helpModal";
|
||||
import type { BowtieExportViewport } from "./exportImage";
|
||||
|
|
@ -25,10 +25,13 @@ import {
|
|||
cloneBowtie,
|
||||
createBowtie,
|
||||
createConsequence,
|
||||
createEscalationFactor,
|
||||
createDegradationChain,
|
||||
createEscalationNode,
|
||||
createThreat,
|
||||
createTopEvent,
|
||||
deserializeBowtie,
|
||||
type EscalationFactor,
|
||||
type DegradationChain,
|
||||
type EscalationNode,
|
||||
nodeRefKey,
|
||||
type NodeRef,
|
||||
serializeBowtie,
|
||||
|
|
@ -97,10 +100,14 @@ export class BowtieView extends TextFileView {
|
|||
private isPanning = false;
|
||||
private panStart = { x: 0, y: 0 };
|
||||
private panOrigin = { x: 0, y: 0 };
|
||||
private panPointerId: number | null = null;
|
||||
private activePointers = new Map<number, { x: number; y: number }>();
|
||||
private pinchLastDistance: number | null = null;
|
||||
private saveTimeout: number | null = null;
|
||||
private viewSaveTimeout: number | null = null;
|
||||
private viewShellReady = false;
|
||||
private panZoomReady = false;
|
||||
private externalSyncReady = false;
|
||||
private wheelRaf: number | null = null;
|
||||
private wheelDeltaAccum = 0;
|
||||
private wheelClient = { x: 0, y: 0 };
|
||||
|
|
@ -160,6 +167,7 @@ export class BowtieView extends TextFileView {
|
|||
this.contentEl.empty();
|
||||
this.viewShellReady = false;
|
||||
this.panZoomReady = false;
|
||||
this.externalSyncReady = false;
|
||||
this.toolbarTitleEl = null;
|
||||
this.undoBtn = null;
|
||||
this.redoBtn = null;
|
||||
|
|
@ -180,11 +188,33 @@ export class BowtieView extends TextFileView {
|
|||
private scheduleSave(): void {
|
||||
if (this.saveTimeout !== null) window.clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = window.setTimeout(() => {
|
||||
this.saveTimeout = null;
|
||||
this.bowtie = touchBowtie(this.bowtie);
|
||||
this.requestSave();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
private flushPendingSave(): void {
|
||||
const hadPending =
|
||||
this.saveTimeout !== null || this.viewSaveTimeout !== null;
|
||||
if (this.viewSaveTimeout !== null) {
|
||||
window.clearTimeout(this.viewSaveTimeout);
|
||||
this.viewSaveTimeout = null;
|
||||
}
|
||||
if (this.saveTimeout !== null) {
|
||||
window.clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
if (hadPending) {
|
||||
this.bowtie = touchBowtie(this.bowtie);
|
||||
this.requestSave();
|
||||
}
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> {
|
||||
this.flushPendingSave();
|
||||
}
|
||||
|
||||
private resetHistory(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
|
|
@ -214,21 +244,22 @@ export class BowtieView extends TextFileView {
|
|||
private nodeRefExists(ref: NodeRef): boolean {
|
||||
switch (ref.kind) {
|
||||
case "hazard":
|
||||
return this.bowtie.events.some((e) => e.id === ref.eventId);
|
||||
case "topEvent":
|
||||
return true;
|
||||
return this.bowtie.events.some((e) => e.id === ref.eventId);
|
||||
case "threat":
|
||||
return this.bowtie.threats.some((t) => t.id === ref.threatId);
|
||||
case "consequence":
|
||||
return this.bowtie.consequences.some((c) => c.id === ref.consequenceId);
|
||||
case "preventionBarrier":
|
||||
case "mitigationBarrier":
|
||||
case "transitionBarrier":
|
||||
return this.findBarrier(ref) !== null;
|
||||
case "escalationFactor":
|
||||
return this.findEscalationFactor(ref) !== null;
|
||||
case "escalationBarrier": {
|
||||
const factor = this.findEscalationFactor(ref);
|
||||
return factor?.escalationBarriers.some((eb) => eb.id === ref.escalationBarrierId) ?? false;
|
||||
case "safeguard": {
|
||||
return this.findSafeguard(ref) !== null;
|
||||
}
|
||||
case "degradationFactor":
|
||||
return this.findDegradationChain(ref) !== null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -302,6 +333,7 @@ export class BowtieView extends TextFileView {
|
|||
this.inspectorEl = this.contentEl.createDiv({ cls: "o-tie-inspector" });
|
||||
|
||||
this.setupPanZoom();
|
||||
this.setupExternalSync();
|
||||
this.viewShellReady = true;
|
||||
}
|
||||
|
||||
|
|
@ -341,6 +373,7 @@ export class BowtieView extends TextFileView {
|
|||
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.toolbarActionsEl.createDiv({ cls: "o-tie-toolbar-separator" });
|
||||
|
|
@ -465,7 +498,11 @@ export class BowtieView extends TextFileView {
|
|||
|
||||
const actions = row.createDiv({ cls: "o-tie-inspector-actions" });
|
||||
|
||||
if (this.selectedRef.kind === "preventionBarrier" || this.selectedRef.kind === "mitigationBarrier") {
|
||||
if (
|
||||
this.selectedRef.kind === "preventionBarrier" ||
|
||||
this.selectedRef.kind === "mitigationBarrier" ||
|
||||
this.selectedRef.kind === "transitionBarrier"
|
||||
) {
|
||||
const stackBtn = actions.createEl("button", { text: "+ Stack row", cls: "mod-small" });
|
||||
stackBtn.addEventListener("click", (e) => {
|
||||
const rect = stackBtn.getBoundingClientRect();
|
||||
|
|
@ -474,22 +511,32 @@ export class BowtieView extends TextFileView {
|
|||
this.selectedRef!
|
||||
);
|
||||
});
|
||||
const escBtn = actions.createEl("button", { text: "+ Escalation factor", cls: "mod-small" });
|
||||
escBtn.addEventListener("click", () => {
|
||||
this.addEscalationFactor(this.selectedRef!);
|
||||
const degBtn = actions.createEl("button", { text: "⚡ Degradation factor", cls: "mod-small" });
|
||||
degBtn.addEventListener("click", () => {
|
||||
this.addDegradationFactor(this.selectedRef!);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.selectedRef.kind === "escalationFactor") {
|
||||
const escBarBtn = actions.createEl("button", {
|
||||
text: "+ Escalation barrier",
|
||||
cls: "mod-small",
|
||||
});
|
||||
escBarBtn.addEventListener("click", () => {
|
||||
this.addEscalationBarrier(this.selectedRef!);
|
||||
if (this.selectedRef.kind === "degradationFactor") {
|
||||
const sgBtn = actions.createEl("button", { text: "+ Safeguard", cls: "mod-small" });
|
||||
sgBtn.addEventListener("click", () => {
|
||||
this.addSafeguard(this.selectedRef!);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.selectedRef.kind === "topEvent" && this.selectedRef.eventId) {
|
||||
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",
|
||||
cls: "mod-cta mod-small",
|
||||
});
|
||||
barBtn.addEventListener("click", () => {
|
||||
this.addTransitionBarrier(this.selectedRef!.eventId!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.selectedRef.kind === "threat") {
|
||||
const barBtn = actions.createEl("button", { text: "+ Prevention Barrier", cls: "mod-cta mod-small" });
|
||||
barBtn.addEventListener("click", () => {
|
||||
|
|
@ -582,7 +629,9 @@ export class BowtieView extends TextFileView {
|
|||
const el = wrap.createDiv({ cls: `o-tie-node o-tie-node-${node.kind}` });
|
||||
|
||||
const isBarrier =
|
||||
node.kind === "preventionBarrier" || node.kind === "mitigationBarrier";
|
||||
node.kind === "preventionBarrier" ||
|
||||
node.kind === "mitigationBarrier" ||
|
||||
node.kind === "transitionBarrier";
|
||||
let labelEl: HTMLElement;
|
||||
|
||||
if (isBarrier) {
|
||||
|
|
@ -621,25 +670,38 @@ export class BowtieView extends TextFileView {
|
|||
});
|
||||
}
|
||||
|
||||
if (node.kind === "topEvent" && node.ref.eventId) {
|
||||
const eventIndex = this.bowtie.events.findIndex((e) => e.id === node.ref.eventId);
|
||||
if (eventIndex >= 0 && eventIndex < this.bowtie.events.length - 1) {
|
||||
const addBar = wrap.createEl("button", { cls: "o-tie-node-add-barrier o-tie-plus-btn" });
|
||||
addBar.setAttribute("aria-label", "Add barrier to next event");
|
||||
addBar.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
addBar.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.addTransitionBarrier(node.ref.eventId!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isBarrier) {
|
||||
const addEsc = wrap.createEl("button", { cls: "o-tie-node-add-escalation", text: "⚡" });
|
||||
addEsc.setAttribute("aria-label", "Add escalation factor");
|
||||
addEsc.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
addEsc.addEventListener("click", (e) => {
|
||||
const addDeg = wrap.createEl("button", { cls: "o-tie-node-add-escalation", text: "⚡" });
|
||||
addDeg.setAttribute("aria-label", "Add degradation factor");
|
||||
addDeg.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
addDeg.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.addEscalationFactor(node.ref);
|
||||
this.addDegradationFactor(node.ref);
|
||||
});
|
||||
}
|
||||
|
||||
if (node.kind === "escalationFactor") {
|
||||
const addEscBar = wrap.createEl("button", {
|
||||
if (node.kind === "degradationFactor") {
|
||||
const addSg = wrap.createEl("button", {
|
||||
cls: "o-tie-node-add-esc-barrier o-tie-plus-btn",
|
||||
});
|
||||
addEscBar.setAttribute("aria-label", "Add escalation barrier");
|
||||
addEscBar.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
addEscBar.addEventListener("click", (e) => {
|
||||
addSg.setAttribute("aria-label", "Add safeguard");
|
||||
addSg.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
addSg.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.addEscalationBarrier(node.ref);
|
||||
this.addSafeguard(node.ref);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1035,7 +1097,22 @@ export class BowtieView extends TextFileView {
|
|||
.onClick(() => this.addMitigationBarrier(ref.consequenceId!))
|
||||
);
|
||||
}
|
||||
if (ref.kind === "preventionBarrier" || ref.kind === "mitigationBarrier") {
|
||||
if (ref.kind === "topEvent" && ref.eventId) {
|
||||
const eventIndex = this.bowtie.events.findIndex((e) => e.id === ref.eventId);
|
||||
if (eventIndex >= 0 && eventIndex < this.bowtie.events.length - 1) {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Add barrier to next event")
|
||||
.setIcon("shield")
|
||||
.onClick(() => this.addTransitionBarrier(ref.eventId!))
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
ref.kind === "preventionBarrier" ||
|
||||
ref.kind === "mitigationBarrier" ||
|
||||
ref.kind === "transitionBarrier"
|
||||
) {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Add stack row")
|
||||
|
|
@ -1043,15 +1120,15 @@ export class BowtieView extends TextFileView {
|
|||
.onClick(() => this.showAddStackRowMenu(event, ref))
|
||||
);
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Add escalation factor").setIcon("zap").onClick(() => this.addEscalationFactor(ref))
|
||||
item
|
||||
.setTitle("Add degradation factor")
|
||||
.setIcon("zap")
|
||||
.onClick(() => this.addDegradationFactor(ref))
|
||||
);
|
||||
}
|
||||
if (ref.kind === "escalationFactor") {
|
||||
if (ref.kind === "degradationFactor") {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Add escalation barrier")
|
||||
.setIcon("shield")
|
||||
.onClick(() => this.addEscalationBarrier(ref))
|
||||
item.setTitle("Add safeguard").setIcon("shield").onClick(() => this.addSafeguard(ref))
|
||||
);
|
||||
}
|
||||
menu.showAtPosition({ x: event.clientX, y: event.clientY });
|
||||
|
|
@ -1113,42 +1190,73 @@ export class BowtieView extends TextFileView {
|
|||
menu.showAtMouseEvent(e);
|
||||
});
|
||||
|
||||
this.registerDomEvent(this.containerEl_, "mousedown", (e) => {
|
||||
this.registerDomEvent(this.containerEl_, "pointerdown", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.closest(".o-tie-node-wrap") ||
|
||||
target.closest(".o-tie-lane-add") ||
|
||||
target.closest("button")
|
||||
) {
|
||||
if (!this.isPanZoomTarget(target)) return;
|
||||
|
||||
this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
|
||||
if (this.activePointers.size === 2) {
|
||||
this.endPan();
|
||||
this.pinchLastDistance = this.pointerDistance();
|
||||
return;
|
||||
}
|
||||
this.isPanning = true;
|
||||
this.panStart = { x: e.clientX, y: e.clientY };
|
||||
this.panOrigin = {
|
||||
x: this.bowtie.view?.panX ?? 0,
|
||||
y: this.bowtie.view?.panY ?? 0,
|
||||
};
|
||||
this.containerEl_.addClass("o-tie-panning");
|
||||
});
|
||||
|
||||
this.registerDomEvent(window, "mousemove", (e) => {
|
||||
if (!this.isPanning) return;
|
||||
const dx = e.clientX - this.panStart.x;
|
||||
const dy = e.clientY - this.panStart.y;
|
||||
if (!this.bowtie.view) this.bowtie.view = { zoom: 1, panX: 0, panY: 0 };
|
||||
this.bowtie.view.panX = this.panOrigin.x + dx;
|
||||
this.bowtie.view.panY = this.panOrigin.y + dy;
|
||||
this.applyTransform();
|
||||
});
|
||||
|
||||
this.registerDomEvent(window, "mouseup", () => {
|
||||
if (this.isPanning) {
|
||||
this.isPanning = false;
|
||||
this.containerEl_.removeClass("o-tie-panning");
|
||||
this.scheduleViewSave();
|
||||
if (this.activePointers.size === 1) {
|
||||
this.panPointerId = e.pointerId;
|
||||
this.pinchLastDistance = null;
|
||||
this.containerEl_.setPointerCapture(e.pointerId);
|
||||
this.startPan(e.clientX, e.clientY);
|
||||
}
|
||||
});
|
||||
|
||||
this.registerDomEvent(this.containerEl_, "pointermove", (e) => {
|
||||
if (!this.activePointers.has(e.pointerId)) return;
|
||||
this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
|
||||
if (this.activePointers.size >= 2) {
|
||||
this.handlePinchZoom();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isPanning && e.pointerId === this.panPointerId) {
|
||||
this.updatePan(e.clientX, e.clientY);
|
||||
}
|
||||
});
|
||||
|
||||
const releasePointer = (e: PointerEvent): void => {
|
||||
if (!this.activePointers.has(e.pointerId)) return;
|
||||
this.activePointers.delete(e.pointerId);
|
||||
|
||||
if (this.containerEl_.hasPointerCapture(e.pointerId)) {
|
||||
this.containerEl_.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
if (this.activePointers.size < 2) {
|
||||
this.pinchLastDistance = null;
|
||||
}
|
||||
|
||||
if (e.pointerId === this.panPointerId) {
|
||||
this.endPan();
|
||||
}
|
||||
|
||||
if (this.activePointers.size === 1) {
|
||||
const remaining = this.activePointers.entries().next().value as
|
||||
| [number, { x: number; y: number }]
|
||||
| undefined;
|
||||
if (remaining) {
|
||||
const [pointerId, point] = remaining;
|
||||
this.panPointerId = pointerId;
|
||||
this.pinchLastDistance = null;
|
||||
this.containerEl_.setPointerCapture(pointerId);
|
||||
this.startPan(point.x, point.y);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.registerDomEvent(this.containerEl_, "pointerup", releasePointer);
|
||||
this.registerDomEvent(this.containerEl_, "pointercancel", releasePointer);
|
||||
|
||||
this.registerDomEvent(
|
||||
this.containerEl_,
|
||||
"wheel",
|
||||
|
|
@ -1257,10 +1365,95 @@ export class BowtieView extends TextFileView {
|
|||
private scheduleViewSave(): void {
|
||||
if (this.viewSaveTimeout !== null) window.clearTimeout(this.viewSaveTimeout);
|
||||
this.viewSaveTimeout = window.setTimeout(() => {
|
||||
this.viewSaveTimeout = null;
|
||||
this.scheduleSave();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private setupExternalSync(): void {
|
||||
if (this.externalSyncReady) return;
|
||||
this.externalSyncReady = true;
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on("modify", (file) => {
|
||||
if (!(file instanceof TFile) || file !== this.file) return;
|
||||
if (this.saveTimeout !== null || this.viewSaveTimeout !== null) {
|
||||
new Notice(
|
||||
"O-tie: this bowtie was updated elsewhere. Close and reopen the file to load remote changes.",
|
||||
6000
|
||||
);
|
||||
return;
|
||||
}
|
||||
void this.app.vault.read(file).then((data) => {
|
||||
if (data !== this.getViewData()) {
|
||||
this.setViewData(data, false);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private isPanZoomTarget(target: HTMLElement): boolean {
|
||||
return !(
|
||||
target.closest(".o-tie-node-wrap") ||
|
||||
target.closest(".o-tie-lane-add") ||
|
||||
target.closest("button")
|
||||
);
|
||||
}
|
||||
|
||||
private startPan(clientX: number, clientY: number): void {
|
||||
this.isPanning = true;
|
||||
this.panStart = { x: clientX, y: clientY };
|
||||
this.panOrigin = {
|
||||
x: this.bowtie.view?.panX ?? 0,
|
||||
y: this.bowtie.view?.panY ?? 0,
|
||||
};
|
||||
this.containerEl_.addClass("o-tie-panning");
|
||||
}
|
||||
|
||||
private updatePan(clientX: number, clientY: number): void {
|
||||
if (!this.isPanning) return;
|
||||
const dx = clientX - this.panStart.x;
|
||||
const dy = clientY - this.panStart.y;
|
||||
if (!this.bowtie.view) this.bowtie.view = { zoom: 1, panX: 0, panY: 0 };
|
||||
this.bowtie.view.panX = this.panOrigin.x + dx;
|
||||
this.bowtie.view.panY = this.panOrigin.y + dy;
|
||||
this.applyTransform();
|
||||
}
|
||||
|
||||
private endPan(): void {
|
||||
if (!this.isPanning) return;
|
||||
this.isPanning = false;
|
||||
this.panPointerId = null;
|
||||
this.containerEl_.removeClass("o-tie-panning");
|
||||
this.scheduleViewSave();
|
||||
}
|
||||
|
||||
private pointerDistance(): number {
|
||||
const pts = Array.from(this.activePointers.values());
|
||||
if (pts.length < 2) return 0;
|
||||
return Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
|
||||
}
|
||||
|
||||
private pointerMidpoint(): { x: number; y: number } {
|
||||
const pts = Array.from(this.activePointers.values());
|
||||
return {
|
||||
x: (pts[0].x + pts[1].x) / 2,
|
||||
y: (pts[0].y + pts[1].y) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
private handlePinchZoom(): void {
|
||||
const distance = this.pointerDistance();
|
||||
if (distance <= 0) return;
|
||||
if (this.pinchLastDistance !== null && this.pinchLastDistance > 0) {
|
||||
const factor = distance / this.pinchLastDistance;
|
||||
const mid = this.pointerMidpoint();
|
||||
this.zoomAt(mid.x, mid.y, factor);
|
||||
}
|
||||
this.pinchLastDistance = distance;
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
@ -1321,6 +1514,11 @@ export class BowtieView extends TextFileView {
|
|||
}
|
||||
|
||||
private forEachBarrier(callback: (barrier: Barrier) => void): void {
|
||||
for (const event of this.bowtie.events) {
|
||||
for (const barrier of event.transitionBarriers) {
|
||||
callback(barrier);
|
||||
}
|
||||
}
|
||||
for (const threat of this.bowtie.threats) {
|
||||
for (const barrier of threat.preventionBarriers) {
|
||||
callback(barrier);
|
||||
|
|
@ -1329,11 +1527,6 @@ export class BowtieView extends TextFileView {
|
|||
for (const consequence of this.bowtie.consequences) {
|
||||
for (const barrier of consequence.mitigationBarriers) {
|
||||
callback(barrier);
|
||||
for (const factor of barrier.escalationFactors) {
|
||||
for (const escBarrier of factor.escalationBarriers) {
|
||||
callback(escBarrier);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1394,6 +1587,14 @@ export class BowtieView extends TextFileView {
|
|||
this.scheduleSave();
|
||||
}
|
||||
|
||||
private addTopEvent(): void {
|
||||
this.commitEdit();
|
||||
this.bowtie.events.push(createTopEvent("New event"));
|
||||
this.render();
|
||||
this.scheduleSave();
|
||||
new Notice("Top event added.");
|
||||
}
|
||||
|
||||
private addBarrierToSelection(): void {
|
||||
if (this.selectedRef?.kind === "threat" && this.selectedRef.threatId) {
|
||||
this.addPreventionBarrier(this.selectedRef.threatId);
|
||||
|
|
@ -1403,6 +1604,13 @@ export class BowtieView extends TextFileView {
|
|||
this.addMitigationBarrier(this.selectedRef.consequenceId);
|
||||
return;
|
||||
}
|
||||
if (this.selectedRef?.kind === "topEvent" && this.selectedRef.eventId) {
|
||||
const eventIndex = this.bowtie.events.findIndex((e) => e.id === this.selectedRef!.eventId);
|
||||
if (eventIndex >= 0 && eventIndex < this.bowtie.events.length - 1) {
|
||||
this.addTransitionBarrier(this.selectedRef.eventId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.bowtie.threats.length > 0) {
|
||||
this.addPreventionBarrier(this.bowtie.threats[0].id);
|
||||
return;
|
||||
|
|
@ -1411,7 +1619,13 @@ export class BowtieView extends TextFileView {
|
|||
this.addMitigationBarrier(this.bowtie.consequences[0].id);
|
||||
return;
|
||||
}
|
||||
new Notice("Add a threat or consequence first, then add barriers.");
|
||||
const lastEvent = this.bowtie.events[this.bowtie.events.length - 1];
|
||||
const prevEvent = this.bowtie.events[this.bowtie.events.length - 2];
|
||||
if (prevEvent && lastEvent) {
|
||||
this.addTransitionBarrier(prevEvent.id);
|
||||
return;
|
||||
}
|
||||
new Notice("Add a threat, consequence, or event first, then add barriers.");
|
||||
}
|
||||
|
||||
private addPreventionBarrier(threatId: string): void {
|
||||
|
|
@ -1436,9 +1650,26 @@ export class BowtieView extends TextFileView {
|
|||
new Notice("Mitigation barrier added.");
|
||||
}
|
||||
|
||||
private addTransitionBarrier(eventId: string): void {
|
||||
const event = this.bowtie.events.find((e) => e.id === eventId);
|
||||
if (!event) return;
|
||||
const eventIndex = this.bowtie.events.findIndex((e) => e.id === eventId);
|
||||
if (eventIndex < 0 || eventIndex >= this.bowtie.events.length - 1) {
|
||||
new Notice("Barriers can only be added between consecutive events.");
|
||||
return;
|
||||
}
|
||||
this.commitEdit();
|
||||
event.transitionBarriers.push(createBarrier("New barrier"));
|
||||
this.selectedRef = { kind: "topEvent", eventId };
|
||||
this.render();
|
||||
this.scheduleSave();
|
||||
new Notice("Barrier added between events.");
|
||||
}
|
||||
|
||||
private renderLaneAddButtons(layout: import("./layout").LayoutResult): void {
|
||||
const topEvent = layout.nodes.find((n) => n.kind === "topEvent");
|
||||
if (!topEvent) return;
|
||||
const firstTopEvent = layout.nodes.find((n) => n.kind === "topEvent");
|
||||
const lastTopEvent = [...layout.nodes].reverse().find((n) => n.kind === "topEvent");
|
||||
if (!firstTopEvent || !lastTopEvent) return;
|
||||
|
||||
for (const threat of this.bowtie.threats) {
|
||||
const threatNode = layout.nodes.find(
|
||||
|
|
@ -1451,7 +1682,7 @@ export class BowtieView extends TextFileView {
|
|||
(n) => n.kind === "preventionBarrier" && n.ref.threatId === threat.id
|
||||
);
|
||||
const fromNode = lastBarrier ?? threatNode;
|
||||
const pos = this.laneAddPosition(fromNode, topEvent, layout);
|
||||
const pos = this.laneAddPosition(fromNode, firstTopEvent, layout);
|
||||
if (!pos) continue;
|
||||
|
||||
this.createLaneAddButton(pos.x, pos.y, "Add prevention barrier", () =>
|
||||
|
|
@ -1459,6 +1690,29 @@ export class BowtieView extends TextFileView {
|
|||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.bowtie.events.length - 1; i++) {
|
||||
const event = this.bowtie.events[i];
|
||||
const fromEventNode = layout.nodes.find(
|
||||
(n) => n.kind === "topEvent" && n.ref.eventId === event.id
|
||||
);
|
||||
const toEventNode = layout.nodes.find(
|
||||
(n) => n.kind === "topEvent" && n.ref.eventId === this.bowtie.events[i + 1].id
|
||||
);
|
||||
if (!fromEventNode || !toEventNode) continue;
|
||||
|
||||
const lastTransition = this.findLastBarrierInLane(
|
||||
layout,
|
||||
(n) => n.kind === "transitionBarrier" && n.ref.eventId === event.id
|
||||
);
|
||||
const fromNode = lastTransition ?? fromEventNode;
|
||||
const pos = this.laneAddPosition(fromNode, toEventNode, layout);
|
||||
if (!pos) continue;
|
||||
|
||||
this.createLaneAddButton(pos.x, pos.y, "Add barrier between events", () =>
|
||||
this.addTransitionBarrier(event.id)
|
||||
);
|
||||
}
|
||||
|
||||
for (const consequence of this.bowtie.consequences) {
|
||||
const consNode = layout.nodes.find(
|
||||
(n) => n.kind === "consequence" && n.ref.consequenceId === consequence.id
|
||||
|
|
@ -1472,7 +1726,7 @@ export class BowtieView extends TextFileView {
|
|||
n.ref.consequenceId === consequence.id
|
||||
);
|
||||
|
||||
const fromNode = lastMitigation ?? topEvent;
|
||||
const fromNode = lastMitigation ?? lastTopEvent;
|
||||
const pos = this.laneAddPosition(fromNode, consNode, layout);
|
||||
if (!pos) continue;
|
||||
|
||||
|
|
@ -1545,37 +1799,48 @@ export class BowtieView extends TextFileView {
|
|||
btn.setCssStyles({ left: `${x}px`, top: `${y}px` });
|
||||
btn.setAttribute("aria-label", label);
|
||||
btn.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
btn.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
});
|
||||
}
|
||||
|
||||
private addEscalationFactor(ref: NodeRef): void {
|
||||
private addDegradationFactor(ref: NodeRef): void {
|
||||
const barrier = this.findBarrier(ref);
|
||||
if (!barrier) return;
|
||||
this.commitEdit();
|
||||
barrier.escalationFactors.push(createEscalationFactor("Escalation factor"));
|
||||
barrier.degradationChains.push(createDegradationChain("Degradation factor"));
|
||||
this.render();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
private addEscalationBarrier(ref: NodeRef): void {
|
||||
const factor = this.findEscalationFactor(ref);
|
||||
if (!factor) return;
|
||||
private addSafeguard(ref: NodeRef): void {
|
||||
const chain = this.findDegradationChain(ref);
|
||||
if (!chain) return;
|
||||
this.commitEdit();
|
||||
factor.escalationBarriers.push(createBarrier("Escalation barrier"));
|
||||
chain.safeguards.push(createEscalationNode("Safeguard"));
|
||||
this.render();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
private findEscalationFactor(ref: NodeRef): EscalationFactor | null {
|
||||
if (!ref.escalationId) return null;
|
||||
private findDegradationChain(ref: NodeRef): DegradationChain | null {
|
||||
const barrier = this.findBarrier(ref);
|
||||
return barrier?.escalationFactors.find((f) => f.id === ref.escalationId) ?? null;
|
||||
if (!barrier || !ref.chainId) return null;
|
||||
return barrier.degradationChains.find((c) => c.id === ref.chainId) ?? null;
|
||||
}
|
||||
|
||||
private findSafeguard(ref: NodeRef): EscalationNode | null {
|
||||
const chain = this.findDegradationChain(ref);
|
||||
if (!chain || !ref.safeguardId) return null;
|
||||
return chain.safeguards.find((sg) => sg.id === ref.safeguardId) ?? null;
|
||||
}
|
||||
|
||||
private findBarrier(ref: NodeRef): Barrier | null {
|
||||
if (ref.eventId && ref.barrierId) {
|
||||
const event = this.bowtie.events.find((e) => e.id === ref.eventId);
|
||||
return event?.transitionBarriers.find((b) => b.id === ref.barrierId) ?? null;
|
||||
}
|
||||
if (ref.threatId && ref.barrierId) {
|
||||
const threat = this.bowtie.threats.find((t) => t.id === ref.threatId);
|
||||
return threat?.preventionBarriers.find((b) => b.id === ref.barrierId) ?? null;
|
||||
|
|
@ -1587,12 +1852,21 @@ export class BowtieView extends TextFileView {
|
|||
return null;
|
||||
}
|
||||
|
||||
private findTopEvent(ref: NodeRef) {
|
||||
if (!ref.eventId) return null;
|
||||
return this.bowtie.events.find((e) => e.id === ref.eventId) ?? null;
|
||||
}
|
||||
|
||||
private getNodeLabel(ref: NodeRef): string {
|
||||
switch (ref.kind) {
|
||||
case "hazard":
|
||||
return this.bowtie.hazard;
|
||||
case "topEvent":
|
||||
return this.bowtie.topEvent;
|
||||
case "hazard": {
|
||||
const event = this.findTopEvent(ref);
|
||||
return event?.hazard ?? "";
|
||||
}
|
||||
case "topEvent": {
|
||||
const event = this.findTopEvent(ref);
|
||||
return event?.label ?? "";
|
||||
}
|
||||
case "threat": {
|
||||
const t = this.bowtie.threats.find((x) => x.id === ref.threatId);
|
||||
return t?.label ?? "";
|
||||
|
|
@ -1602,20 +1876,18 @@ export class BowtieView extends TextFileView {
|
|||
return c?.label ?? "";
|
||||
}
|
||||
case "preventionBarrier":
|
||||
case "mitigationBarrier": {
|
||||
case "mitigationBarrier":
|
||||
case "transitionBarrier": {
|
||||
const b = this.findBarrier(ref);
|
||||
return b?.label ?? "";
|
||||
}
|
||||
case "escalationFactor": {
|
||||
const b = this.findBarrier(ref);
|
||||
const f = b?.escalationFactors.find((x) => x.id === ref.escalationId);
|
||||
return f?.label ?? "";
|
||||
case "safeguard": {
|
||||
const sg = this.findSafeguard(ref);
|
||||
return sg?.label ?? "";
|
||||
}
|
||||
case "escalationBarrier": {
|
||||
const b = this.findBarrier(ref);
|
||||
const f = b?.escalationFactors.find((x) => x.id === ref.escalationId);
|
||||
const escB = f?.escalationBarriers.find((x) => x.id === ref.escalationBarrierId);
|
||||
return escB?.label ?? "";
|
||||
case "degradationFactor": {
|
||||
const chain = this.findDegradationChain(ref);
|
||||
return chain?.degradationFactor.label ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1628,14 +1900,19 @@ export class BowtieView extends TextFileView {
|
|||
consequence: "Consequence",
|
||||
preventionBarrier: "Prevention Barrier",
|
||||
mitigationBarrier: "Mitigation Barrier",
|
||||
escalationFactor: "Escalation Factor",
|
||||
escalationBarrier: "Escalation Barrier",
|
||||
transitionBarrier: "Barrier",
|
||||
safeguard: "Safeguard",
|
||||
degradationFactor: "Degradation Factor",
|
||||
};
|
||||
return map[ref.kind] ?? ref.kind;
|
||||
}
|
||||
|
||||
private getNodeNotes(ref: NodeRef): string {
|
||||
switch (ref.kind) {
|
||||
case "topEvent": {
|
||||
const event = this.findTopEvent(ref);
|
||||
return event?.notes ?? "";
|
||||
}
|
||||
case "threat": {
|
||||
const t = this.bowtie.threats.find((x) => x.id === ref.threatId);
|
||||
return t?.notes ?? "";
|
||||
|
|
@ -1645,14 +1922,18 @@ export class BowtieView extends TextFileView {
|
|||
return c?.notes ?? "";
|
||||
}
|
||||
case "preventionBarrier":
|
||||
case "mitigationBarrier": {
|
||||
case "mitigationBarrier":
|
||||
case "transitionBarrier": {
|
||||
const b = this.findBarrier(ref);
|
||||
return b?.notes ?? "";
|
||||
}
|
||||
case "escalationFactor": {
|
||||
const b = this.findBarrier(ref);
|
||||
const f = b?.escalationFactors.find((x) => x.id === ref.escalationId);
|
||||
return f?.notes ?? "";
|
||||
case "safeguard": {
|
||||
const sg = this.findSafeguard(ref);
|
||||
return sg?.notes ?? "";
|
||||
}
|
||||
case "degradationFactor": {
|
||||
const chain = this.findDegradationChain(ref);
|
||||
return chain?.degradationFactor.notes ?? "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
|
|
@ -1660,12 +1941,16 @@ export class BowtieView extends TextFileView {
|
|||
|
||||
private setNodeLabel(ref: NodeRef, label: string): void {
|
||||
switch (ref.kind) {
|
||||
case "hazard":
|
||||
this.bowtie.hazard = label;
|
||||
case "hazard": {
|
||||
const event = this.findTopEvent(ref);
|
||||
if (event) event.hazard = label;
|
||||
break;
|
||||
case "topEvent":
|
||||
this.bowtie.topEvent = label;
|
||||
}
|
||||
case "topEvent": {
|
||||
const event = this.findTopEvent(ref);
|
||||
if (event) event.label = label;
|
||||
break;
|
||||
}
|
||||
case "threat": {
|
||||
const t = this.bowtie.threats.find((x) => x.id === ref.threatId);
|
||||
if (t) t.label = label;
|
||||
|
|
@ -1677,22 +1962,20 @@ export class BowtieView extends TextFileView {
|
|||
break;
|
||||
}
|
||||
case "preventionBarrier":
|
||||
case "mitigationBarrier": {
|
||||
case "mitigationBarrier":
|
||||
case "transitionBarrier": {
|
||||
const b = this.findBarrier(ref);
|
||||
if (b) b.label = label;
|
||||
break;
|
||||
}
|
||||
case "escalationFactor": {
|
||||
const b = this.findBarrier(ref);
|
||||
const f = b?.escalationFactors.find((x) => x.id === ref.escalationId);
|
||||
if (f) f.label = label;
|
||||
case "safeguard": {
|
||||
const sg = this.findSafeguard(ref);
|
||||
if (sg) sg.label = label;
|
||||
break;
|
||||
}
|
||||
case "escalationBarrier": {
|
||||
const b = this.findBarrier(ref);
|
||||
const f = b?.escalationFactors.find((x) => x.id === ref.escalationId);
|
||||
const escB = f?.escalationBarriers.find((x) => x.id === ref.escalationBarrierId);
|
||||
if (escB) escB.label = label;
|
||||
case "degradationFactor": {
|
||||
const chain = this.findDegradationChain(ref);
|
||||
if (chain) chain.degradationFactor.label = label;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1700,6 +1983,11 @@ export class BowtieView extends TextFileView {
|
|||
|
||||
private setNodeNotes(ref: NodeRef, notes: string): void {
|
||||
switch (ref.kind) {
|
||||
case "topEvent": {
|
||||
const event = this.findTopEvent(ref);
|
||||
if (event) event.notes = notes;
|
||||
break;
|
||||
}
|
||||
case "threat": {
|
||||
const t = this.bowtie.threats.find((x) => x.id === ref.threatId);
|
||||
if (t) t.notes = notes;
|
||||
|
|
@ -1711,15 +1999,20 @@ export class BowtieView extends TextFileView {
|
|||
break;
|
||||
}
|
||||
case "preventionBarrier":
|
||||
case "mitigationBarrier": {
|
||||
case "mitigationBarrier":
|
||||
case "transitionBarrier": {
|
||||
const b = this.findBarrier(ref);
|
||||
if (b) b.notes = notes;
|
||||
break;
|
||||
}
|
||||
case "escalationFactor": {
|
||||
const b = this.findBarrier(ref);
|
||||
const f = b?.escalationFactors.find((x) => x.id === ref.escalationId);
|
||||
if (f) f.notes = notes;
|
||||
case "safeguard": {
|
||||
const sg = this.findSafeguard(ref);
|
||||
if (sg) sg.notes = notes;
|
||||
break;
|
||||
}
|
||||
case "degradationFactor": {
|
||||
const chain = this.findDegradationChain(ref);
|
||||
if (chain) chain.degradationFactor.notes = notes;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1728,12 +2021,29 @@ export class BowtieView extends TextFileView {
|
|||
private deleteNode(ref: NodeRef): void {
|
||||
this.commitEdit();
|
||||
switch (ref.kind) {
|
||||
case "hazard":
|
||||
this.bowtie.hazard = "";
|
||||
case "hazard": {
|
||||
const event = this.findTopEvent(ref);
|
||||
if (event) event.hazard = "";
|
||||
break;
|
||||
case "topEvent":
|
||||
this.bowtie.topEvent = "";
|
||||
}
|
||||
case "topEvent": {
|
||||
if (this.bowtie.events.length <= 1) {
|
||||
const event = this.findTopEvent(ref);
|
||||
if (event) event.label = "";
|
||||
break;
|
||||
}
|
||||
this.bowtie.events = this.bowtie.events.filter((e) => e.id !== ref.eventId);
|
||||
break;
|
||||
}
|
||||
case "transitionBarrier": {
|
||||
const event = this.bowtie.events.find((e) => e.id === ref.eventId);
|
||||
if (event) {
|
||||
event.transitionBarriers = event.transitionBarriers.filter(
|
||||
(b) => b.id !== ref.barrierId
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "threat":
|
||||
this.bowtie.threats = this.bowtie.threats.filter((t) => t.id !== ref.threatId);
|
||||
break;
|
||||
|
|
@ -1754,19 +2064,18 @@ export class BowtieView extends TextFileView {
|
|||
}
|
||||
break;
|
||||
}
|
||||
case "escalationFactor": {
|
||||
const b = this.findBarrier(ref);
|
||||
if (b) {
|
||||
b.escalationFactors = b.escalationFactors.filter((f) => f.id !== ref.escalationId);
|
||||
case "safeguard": {
|
||||
const chain = this.findDegradationChain(ref);
|
||||
if (chain) {
|
||||
chain.safeguards = chain.safeguards.filter((sg) => sg.id !== ref.safeguardId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "escalationBarrier": {
|
||||
const b = this.findBarrier(ref);
|
||||
const f = b?.escalationFactors.find((x) => x.id === ref.escalationId);
|
||||
if (f) {
|
||||
f.escalationBarriers = f.escalationBarriers.filter(
|
||||
(eb) => eb.id !== ref.escalationBarrierId
|
||||
case "degradationFactor": {
|
||||
const barrier = this.findBarrier(ref);
|
||||
if (barrier && ref.chainId) {
|
||||
barrier.degradationChains = barrier.degradationChains.filter(
|
||||
(c) => c.id !== ref.chainId
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -36,44 +36,44 @@ export class HelpModal extends Modal {
|
|||
body.createEl("pre", {
|
||||
cls: "o-tie-help-diagram",
|
||||
text: [
|
||||
"Threats → Prevention Barriers → Top Event → Mitigation Barriers → Consequences",
|
||||
" ↑",
|
||||
" Hazard",
|
||||
"Threats → Prevention Barriers → Event 1 → Barriers → Event 2 → … → Mitigation Barriers → Consequences",
|
||||
" ↑ Hazard ↑ Hazard",
|
||||
].join("\n"),
|
||||
});
|
||||
this.addList(body, [
|
||||
"Hazard (gold) — the source of risk, shown above the top event",
|
||||
"Top Event (red) — the central loss-of-control event",
|
||||
"Threats (gray) — left side; prevention barriers (green) sit on connectors to the top event",
|
||||
"Consequences (purple) — right side; mitigation barriers (green) sit on connectors from the top event",
|
||||
"Escalation factors (purple, dashed) — branch downward from a barrier when something could weaken that barrier",
|
||||
"Escalation barriers (purple, dashed) — sit on escalation factors and control those weakening influences",
|
||||
"Hazard (gold) — the source of risk, shown above each top event",
|
||||
"Top Event (red) — one or more loss-of-control events chained left to right in the center",
|
||||
"Barriers between events — green controls in the lanes connecting consecutive top events",
|
||||
"Threats (gray) — far left; prevention barriers (green) sit on connectors to the first top event",
|
||||
"Consequences (purple) — far right; mitigation barriers (green) sit on connectors from the last top event",
|
||||
"Safeguards (slate blue) — branch downward from a barrier; add as many as needed before each degradation factor",
|
||||
"Degradation factors (warm bronze) — one per ⚡ click; multiple appear in parallel columns below the barrier",
|
||||
]);
|
||||
|
||||
this.addSection(body, "Barriers", [
|
||||
"A barrier is a control that stops or reduces risk along the main bowtie path. In O-Tie, barriers are green nodes on the connectors between threats/consequences and the top event.",
|
||||
"Prevention barriers sit between a threat and the top event. They stop the threat from causing the top event — for example an alarm, an interlock, a procedure, or operator training.",
|
||||
"Mitigation barriers sit between the top event and a consequence. They limit harm if the top event happens — for example emergency shutdown, fire suppression, or evacuation.",
|
||||
"Add prevention barriers from a selected threat (+ Prevention Barrier in the inspector, the lane + button, or the barrier + on the threat node). Add mitigation barriers from a selected consequence the same way, or use + Barrier in the toolbar when a threat or consequence is selected.",
|
||||
"A barrier is a control that stops or reduces risk along the main bowtie path. In O-Tie, barriers are green nodes on the connectors between threats/consequences and top events, or between consecutive top events.",
|
||||
"Prevention barriers sit between a threat and the first top event. They stop the threat from causing the top event — for example an alarm, an interlock, a procedure, or operator training.",
|
||||
"Barriers between events sit in the lanes connecting one top event to the next. They represent controls that prevent escalation from one loss-of-control state to the next.",
|
||||
"Mitigation barriers sit between the last top event and a consequence. They limit harm if the top event happens — for example emergency shutdown, fire suppression, or evacuation.",
|
||||
"Add prevention barriers from a selected threat (+ Prevention Barrier in the inspector, the lane + button, or the barrier + on the threat node). Add barriers between events from a selected top event (except the last) or via the lane + button between events. Add mitigation barriers from a selected consequence the same way, or use + Barrier in the toolbar when a relevant node is selected.",
|
||||
"Each barrier can carry an analysis stack (type, effectiveness, criticality, and more) — see Barrier analysis stacks below.",
|
||||
]);
|
||||
|
||||
this.addSection(body, "Escalation factors and escalation barriers", [
|
||||
"Barriers are not always reliable on their own. An escalation factor describes a condition that could weaken, bypass, or defeat a barrier — such as missed maintenance, fatigue, corrosion, or conflicting procedures.",
|
||||
"In the diagram, escalation factors appear as purple dashed nodes branching below their parent barrier. Dashed connectors show that this is a secondary path: the factor does not replace the main threat-to-event or event-to-consequence flow, but explains how the barrier might fail.",
|
||||
"An escalation barrier is a control targeted at the escalation factor itself — the measure that keeps the weakening influence in check. For example, if an escalation factor is “sensor not calibrated”, an escalation barrier might be “annual calibration program”.",
|
||||
"To add an escalation factor, select a prevention or mitigation barrier, then click + Escalation factor in the inspector, the ⚡ button on the barrier, or use the context menu. To add an escalation barrier, select the escalation factor and use + Escalation barrier, the + button on the factor, or the context menu.",
|
||||
"Name each node to match your risk assessment. Use notes in the inspector for evidence, assumptions, or actions. A complete bowtie often chains several barriers, each with its own escalation factors and escalation barriers where relevant.",
|
||||
this.addSection(body, "Safeguards and degradation factors", [
|
||||
"Barriers are not always reliable on their own. Safeguards are controls that branch downward from a barrier — for example training, inspections, or backup systems. You can add as many safeguards as needed.",
|
||||
"The degradation factor is always the last node in the chain below a barrier and describes the ultimate weakening influence if safeguards fail.",
|
||||
"To add a degradation factor, select a barrier and click the ⚡ button, or use the inspector or context menu. To add safeguards before it, select the degradation factor and use the + button.",
|
||||
"Name each node to match your risk assessment. Use notes in the inspector for evidence, assumptions, or actions.",
|
||||
]);
|
||||
|
||||
this.addSection(body, "Editing", [
|
||||
"Use the toolbar to add threats, consequences, and barriers.",
|
||||
"Use the toolbar to add threats, consequences, top events, and barriers.",
|
||||
"Double-click any node or the diagram title to rename inline.",
|
||||
"Click a node to select it — the inspector bar at the bottom shows label, notes, and actions.",
|
||||
"Right-click nodes or the canvas for a context menu.",
|
||||
"Hover nodes for quick buttons: add barrier, add escalation, or delete.",
|
||||
"Hover nodes for quick buttons: add barrier, add safeguard, or delete.",
|
||||
"Press Delete to remove the selected node.",
|
||||
"Use the + buttons in lanes between threats/consequences and the top event to add barriers quickly.",
|
||||
"Use the + buttons in lanes between nodes to add barriers quickly — including between consecutive top events.",
|
||||
]);
|
||||
|
||||
this.addSection(body, "Barrier analysis stacks", [
|
||||
|
|
@ -85,11 +85,21 @@ export class HelpModal extends Modal {
|
|||
|
||||
this.addSection(body, "Navigation", [
|
||||
"Drag empty canvas space to pan.",
|
||||
"Scroll the mouse wheel to zoom (hold Ctrl for finer control).",
|
||||
"On phone or tablet: use one finger to pan and two fingers to pinch zoom.",
|
||||
"Scroll the mouse wheel to zoom on desktop (hold Ctrl for finer control).",
|
||||
"Use the toolbar + and − buttons as a zoom fallback on any device.",
|
||||
"Use Fit to center and scale the diagram to the visible area.",
|
||||
"Collapse the toolbar with the chevron button; click the floating button on the canvas to show it again.",
|
||||
]);
|
||||
|
||||
this.addSection(body, "Syncing across devices", [
|
||||
".bowtie files are normal vault files and sync through Obsidian Sync like any other note.",
|
||||
"In Settings → Sync → Selective sync, enable Sync all other types on every device. Custom extensions such as .bowtie are not included in the default image/audio/video/PDF set.",
|
||||
"Under Vault configuration sync, enable Active community plugin list and Installed community plugins so O-Tie is available on mobile.",
|
||||
"After changing sync settings, force-quit and reopen Obsidian on each device.",
|
||||
"Confirm each device is connected to the same remote vault and that no folder containing .bowtie files is listed under Excluded folders.",
|
||||
]);
|
||||
|
||||
this.addSection(body, "Undo and redo", [
|
||||
"Use the ← and → toolbar buttons, or Ctrl+Z to undo and Ctrl+Y (or Ctrl+Shift+Z) to redo.",
|
||||
]);
|
||||
|
|
|
|||
496
src/layout.ts
496
src/layout.ts
|
|
@ -1,4 +1,5 @@
|
|||
import type { Barrier, Bowtie, Consequence, Threat } from "./model";
|
||||
import type { Barrier, Bowtie, Consequence, Threat, TopEvent } from "./model";
|
||||
import { hasSafeguardChain } from "./model";
|
||||
import type { NodeKind, NodeRef } from "./model";
|
||||
|
||||
export interface LayoutConfig {
|
||||
|
|
@ -14,6 +15,7 @@ export interface LayoutConfig {
|
|||
columnGap: number;
|
||||
rowGap: number;
|
||||
barrierGap: number;
|
||||
degradationChainGap: number;
|
||||
escalationOffsetY: number;
|
||||
padding: number;
|
||||
}
|
||||
|
|
@ -31,6 +33,7 @@ export const DEFAULT_LAYOUT: LayoutConfig = {
|
|||
columnGap: 100,
|
||||
rowGap: 48,
|
||||
barrierGap: 24,
|
||||
degradationChainGap: 36,
|
||||
escalationOffsetY: 70,
|
||||
padding: 80,
|
||||
};
|
||||
|
|
@ -140,7 +143,7 @@ export interface EdgeArrow {
|
|||
export interface EdgePath {
|
||||
id: string;
|
||||
path: string;
|
||||
kind: "main" | "hazard" | "escalation";
|
||||
kind: "main" | "hazard" | "safeguard" | "degradation";
|
||||
fromRef: NodeRef;
|
||||
toRef: NodeRef;
|
||||
arrow: EdgeArrow;
|
||||
|
|
@ -152,21 +155,6 @@ export interface LayoutResult {
|
|||
bounds: { width: number; height: number };
|
||||
}
|
||||
|
||||
function placeBarriersInLane(
|
||||
startX: number,
|
||||
laneWidth: number,
|
||||
count: number,
|
||||
barrierWidth: number,
|
||||
barrierGap: number
|
||||
): number[] {
|
||||
if (count === 0) return [];
|
||||
const usedWidth = count * barrierWidth + Math.max(0, count - 1) * barrierGap;
|
||||
const offset = (laneWidth - usedWidth) / 2;
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => startX + offset + i * (barrierWidth + barrierGap)
|
||||
);
|
||||
}
|
||||
|
||||
function nodeRight(node: PositionedNode): { x: number; y: number } {
|
||||
return { x: node.x + node.width, y: node.y + node.height / 2 };
|
||||
|
|
@ -304,37 +292,87 @@ function makeLineEdge(
|
|||
};
|
||||
}
|
||||
|
||||
function escalationFactorGap(layout: LayoutConfig): number {
|
||||
return layout.rowGap * 0.35;
|
||||
function degradationChainGap(layout: LayoutConfig): number {
|
||||
return layout.degradationChainGap;
|
||||
}
|
||||
|
||||
function escalationColumnHeight(barrier: Barrier, layout: LayoutConfig): number {
|
||||
const factors = barrier.escalationFactors;
|
||||
if (factors.length === 0) return 0;
|
||||
function degradationChainsFootprint(barrier: Barrier, layout: LayoutConfig): number {
|
||||
if (!hasSafeguardChain(barrier)) return layout.barrierWidth;
|
||||
const chainCount = barrier.degradationChains.length;
|
||||
const chainGap = degradationChainGap(layout);
|
||||
return chainCount * layout.escalationWidth + Math.max(0, chainCount - 1) * chainGap;
|
||||
}
|
||||
|
||||
const factorGap = escalationFactorGap(layout);
|
||||
function barrierSlotWidth(barrier: Barrier, layout: LayoutConfig): number {
|
||||
const footprint = degradationChainsFootprint(barrier, layout);
|
||||
return Math.max(layout.barrierWidth, footprint + 16);
|
||||
}
|
||||
|
||||
function barriersLaneWidth(barriers: Barrier[], layout: LayoutConfig): number {
|
||||
if (barriers.length === 0) return 0;
|
||||
const slots = barriers.map((b) => barrierSlotWidth(b, layout));
|
||||
return slots.reduce((sum, w) => sum + w, 0) + Math.max(0, barriers.length - 1) * layout.barrierGap;
|
||||
}
|
||||
|
||||
interface BarrierSlot {
|
||||
x: number;
|
||||
slotWidth: number;
|
||||
}
|
||||
|
||||
function layoutBarrierSlots(
|
||||
startX: number,
|
||||
laneWidth: number,
|
||||
barriers: Barrier[],
|
||||
layout: LayoutConfig
|
||||
): BarrierSlot[] {
|
||||
if (barriers.length === 0) return [];
|
||||
const slotWidths = barriers.map((b) => barrierSlotWidth(b, layout));
|
||||
const usedWidth =
|
||||
slotWidths.reduce((sum, w) => sum + w, 0) +
|
||||
Math.max(0, barriers.length - 1) * layout.barrierGap;
|
||||
let slotStart = startX + (laneWidth - usedWidth) / 2;
|
||||
const result: BarrierSlot[] = [];
|
||||
for (const slotWidth of slotWidths) {
|
||||
result.push({
|
||||
x: slotStart + (slotWidth - layout.barrierWidth) / 2,
|
||||
slotWidth,
|
||||
});
|
||||
slotStart += slotWidth + layout.barrierGap;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function degradationChainHeight(chain: import("./model").DegradationChain, layout: LayoutConfig): number {
|
||||
const escGap = 12;
|
||||
let height = 0;
|
||||
|
||||
factors.forEach((factor, factorIndex) => {
|
||||
if (factorIndex > 0) height += factorGap;
|
||||
height += escalationNodeHeight(
|
||||
factor.label || "Escalation",
|
||||
chain.safeguards.forEach((safeguard, safeguardIndex) => {
|
||||
height +=
|
||||
(safeguardIndex === 0 ? layout.rowGap * 0.5 : escGap) +
|
||||
escalationNodeHeight(
|
||||
safeguard.label || "Safeguard",
|
||||
layout.escalationWidth,
|
||||
layout.escalationHeight
|
||||
);
|
||||
});
|
||||
height +=
|
||||
(chain.safeguards.length === 0 ? layout.rowGap * 0.5 : escGap) +
|
||||
escalationNodeHeight(
|
||||
chain.degradationFactor.label || "Degradation",
|
||||
layout.escalationWidth,
|
||||
layout.escalationHeight
|
||||
);
|
||||
factor.escalationBarriers.forEach((escBarrier, escIndex) => {
|
||||
height +=
|
||||
(escIndex === 0 ? layout.rowGap * 0.5 : escGap) +
|
||||
escalationNodeHeight(
|
||||
escBarrier.label || "Esc. Barrier",
|
||||
layout.escalationWidth,
|
||||
layout.escalationHeight
|
||||
);
|
||||
});
|
||||
});
|
||||
return height;
|
||||
}
|
||||
|
||||
return layout.escalationOffsetY * ESCALATION_GAP_FACTOR + height;
|
||||
function escalationColumnHeight(barrier: Barrier, layout: LayoutConfig): number {
|
||||
if (!hasSafeguardChain(barrier)) return 0;
|
||||
|
||||
const maxChainHeight = Math.max(
|
||||
...barrier.degradationChains.map((chain) => degradationChainHeight(chain, layout))
|
||||
);
|
||||
|
||||
return layout.escalationOffsetY * ESCALATION_GAP_FACTOR + maxChainHeight;
|
||||
}
|
||||
|
||||
function escalationExtraForBarriers(barriers: Barrier[], layout: LayoutConfig): number {
|
||||
|
|
@ -398,6 +436,28 @@ function computeRowYPositions(heights: number[], contentTop: number, rowGap: num
|
|||
return positions;
|
||||
}
|
||||
|
||||
function pushChainEdges(
|
||||
chain: PositionedNode[],
|
||||
edges: EdgePath[],
|
||||
idSuffix: string
|
||||
): void {
|
||||
for (let i = 0; i < chain.length - 1; i++) {
|
||||
const from = chain[i];
|
||||
const to = chain[i + 1];
|
||||
const p1 = nodeRight(from);
|
||||
const p2 = nodeLeft(to);
|
||||
const edge = makeBezierEdge(p1.x, p1.y, p2.x, p2.y);
|
||||
edges.push({
|
||||
id: `edge-${from.ref.kind}-${to.ref.kind}-${i}${idSuffix}`,
|
||||
path: edge.path,
|
||||
kind: "main",
|
||||
fromRef: from.ref,
|
||||
toRef: to.ref,
|
||||
arrow: edge.arrow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function layoutBowtie(bowtie: Bowtie, layout: LayoutConfig = DEFAULT_LAYOUT): LayoutResult {
|
||||
const nodes: PositionedNode[] = [];
|
||||
const edges: EdgePath[] = [];
|
||||
|
|
@ -407,11 +467,15 @@ export function layoutBowtie(bowtie: Bowtie, layout: LayoutConfig = DEFAULT_LAYO
|
|||
|
||||
const maxBarriersLeft = Math.max(
|
||||
0,
|
||||
...bowtie.threats.map((t) => t.preventionBarriers.length)
|
||||
...bowtie.threats.map((t) => barriersLaneWidth(t.preventionBarriers, layout))
|
||||
);
|
||||
const maxBarriersRight = Math.max(
|
||||
0,
|
||||
...bowtie.consequences.map((c) => c.mitigationBarriers.length)
|
||||
...bowtie.consequences.map((c) => barriersLaneWidth(c.mitigationBarriers, layout))
|
||||
);
|
||||
const maxTransitionLaneWidth = Math.max(
|
||||
0,
|
||||
...bowtie.events.slice(0, -1).map((e) => barriersLaneWidth(e.transitionBarriers, layout))
|
||||
);
|
||||
|
||||
const threatColX = layout.padding;
|
||||
|
|
@ -421,59 +485,132 @@ export function layoutBowtie(bowtie: Bowtie, layout: LayoutConfig = DEFAULT_LAYO
|
|||
layout.padding +
|
||||
layout.nodeWidth +
|
||||
layout.columnGap +
|
||||
maxBarriersLeft * (layout.barrierWidth + layout.barrierGap);
|
||||
maxBarriersLeft;
|
||||
|
||||
const centerX = preventionEndX + layout.columnGap * 0.5;
|
||||
const topEventSize = Math.max(layout.nodeWidth, layout.nodeHeight);
|
||||
const firstEventX = preventionEndX + layout.columnGap * 0.5;
|
||||
|
||||
const mitigationStartX = centerX + topEventSize + layout.columnGap * 0.5;
|
||||
const consequenceColX =
|
||||
mitigationStartX +
|
||||
maxBarriersRight * (layout.barrierWidth + layout.barrierGap) +
|
||||
layout.columnGap;
|
||||
const eventPositions: number[] = [];
|
||||
const transitionLanes: { startX: number; endX: number; event: TopEvent }[] = [];
|
||||
let eventX = firstEventX;
|
||||
|
||||
const hazardH = nodeBoxHeight(
|
||||
bowtie.hazard || "Hazard",
|
||||
layout.nodeWidth,
|
||||
layout.hazardHeight
|
||||
);
|
||||
const hazardNode: PositionedNode = {
|
||||
ref: { kind: "hazard" },
|
||||
kind: "hazard",
|
||||
label: bowtie.hazard || "Hazard",
|
||||
subtitle: "Hazard",
|
||||
x: centerX,
|
||||
y: centerY - topEventSize / 2 - hazardH - layout.rowGap,
|
||||
width: layout.nodeWidth,
|
||||
height: hazardH,
|
||||
};
|
||||
nodes.push(hazardNode);
|
||||
for (let i = 0; i < bowtie.events.length; i++) {
|
||||
eventPositions.push(eventX);
|
||||
eventX += topEventSize;
|
||||
|
||||
const topEventNode: PositionedNode = {
|
||||
ref: { kind: "topEvent" },
|
||||
kind: "topEvent",
|
||||
label: bowtie.topEvent || "Top Event",
|
||||
subtitle: "Top Event",
|
||||
x: centerX,
|
||||
y: centerY - topEventSize / 2,
|
||||
width: topEventSize,
|
||||
height: topEventSize,
|
||||
};
|
||||
nodes.push(topEventNode);
|
||||
if (i < bowtie.events.length - 1) {
|
||||
const transitionStartX = eventX + layout.columnGap * 0.5;
|
||||
const transitionEndX = transitionStartX + layout.columnGap + maxTransitionLaneWidth;
|
||||
transitionLanes.push({
|
||||
startX: transitionStartX,
|
||||
endX: transitionEndX,
|
||||
event: bowtie.events[i],
|
||||
});
|
||||
eventX = transitionEndX + layout.columnGap * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
const lastEventX = eventPositions[eventPositions.length - 1] ?? firstEventX;
|
||||
const mitigationStartX = lastEventX + topEventSize + layout.columnGap * 0.5;
|
||||
const consequenceColX = mitigationStartX + maxBarriersRight + layout.columnGap;
|
||||
|
||||
const topEventNodes: PositionedNode[] = bowtie.events.map((event, index) => {
|
||||
const eventNode: PositionedNode = {
|
||||
ref: { kind: "topEvent", eventId: event.id },
|
||||
kind: "topEvent",
|
||||
label: event.label || "Top Event",
|
||||
subtitle: "Top Event",
|
||||
x: eventPositions[index],
|
||||
y: centerY - topEventSize / 2,
|
||||
width: topEventSize,
|
||||
height: topEventSize,
|
||||
};
|
||||
nodes.push(eventNode);
|
||||
return eventNode;
|
||||
});
|
||||
|
||||
bowtie.events.forEach((event, index) => {
|
||||
const eventNode = topEventNodes[index];
|
||||
if (!eventNode) return;
|
||||
|
||||
const hazardH = nodeBoxHeight(
|
||||
event.hazard || "Hazard",
|
||||
layout.nodeWidth,
|
||||
layout.hazardHeight
|
||||
);
|
||||
const hazardNode: PositionedNode = {
|
||||
ref: { kind: "hazard", eventId: event.id },
|
||||
kind: "hazard",
|
||||
label: event.hazard || "Hazard",
|
||||
subtitle: "Hazard",
|
||||
x: eventPositions[index],
|
||||
y: centerY - topEventSize / 2 - hazardH - layout.rowGap,
|
||||
width: layout.nodeWidth,
|
||||
height: hazardH,
|
||||
};
|
||||
nodes.push(hazardNode);
|
||||
|
||||
{
|
||||
const from = nodeBottom(hazardNode);
|
||||
const to = nodeTop(topEventNode);
|
||||
const to = nodeTop(eventNode);
|
||||
const edge = makeLineEdge(from.x, from.y, to.x, to.y);
|
||||
edges.push({
|
||||
id: "hazard-top",
|
||||
id: `hazard-top-${event.id}`,
|
||||
path: edge.path,
|
||||
kind: "hazard",
|
||||
fromRef: hazardNode.ref,
|
||||
toRef: topEventNode.ref,
|
||||
toRef: eventNode.ref,
|
||||
arrow: edge.arrow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const firstTopEventNode = topEventNodes[0];
|
||||
|
||||
transitionLanes.forEach((lane, laneIndex) => {
|
||||
const fromEventNode = topEventNodes[laneIndex];
|
||||
const toEventNode = topEventNodes[laneIndex + 1];
|
||||
if (!fromEventNode || !toEventNode) return;
|
||||
|
||||
const chain: PositionedNode[] = [fromEventNode];
|
||||
const laneWidth = lane.endX - lane.startX;
|
||||
const transitionSlots = layoutBarrierSlots(
|
||||
lane.startX,
|
||||
laneWidth,
|
||||
lane.event.transitionBarriers,
|
||||
layout
|
||||
);
|
||||
const y = centerY - topEventSize / 2 + (topEventSize - layout.barrierHeaderHeight) / 2;
|
||||
|
||||
lane.event.transitionBarriers.forEach((barrier, barrierIndex) => {
|
||||
const slot = transitionSlots[barrierIndex];
|
||||
if (!slot) return;
|
||||
const bx = slot.x;
|
||||
const barrierHeight = barrierRenderHeight(barrier, layout);
|
||||
const barrierNode: PositionedNode = {
|
||||
ref: {
|
||||
kind: "transitionBarrier",
|
||||
eventId: lane.event.id,
|
||||
barrierId: barrier.id,
|
||||
},
|
||||
kind: "transitionBarrier",
|
||||
label: barrier.label || "Barrier",
|
||||
subtitle: "Barrier",
|
||||
x: bx,
|
||||
y,
|
||||
width: layout.barrierWidth,
|
||||
height: barrierHeight,
|
||||
};
|
||||
nodes.push(barrierNode);
|
||||
chain.push(barrierNode);
|
||||
layoutEscalation(barrier, barrierNode, nodes, edges, layout, {
|
||||
eventId: lane.event.id,
|
||||
});
|
||||
});
|
||||
|
||||
chain.push(toEventNode);
|
||||
pushChainEdges(chain, edges, `-trans-${laneIndex}`);
|
||||
});
|
||||
|
||||
const lastTopEventNode = topEventNodes[topEventNodes.length - 1];
|
||||
|
||||
const contentTop = layout.padding;
|
||||
const threatRowHeights = bowtie.threats.map((t) => threatRowHeight(t, layout));
|
||||
|
|
@ -497,18 +634,18 @@ export function layoutBowtie(bowtie: Bowtie, layout: LayoutConfig = DEFAULT_LAYO
|
|||
nodes.push(threatNode);
|
||||
|
||||
const chain: PositionedNode[] = [threatNode];
|
||||
const barrierCount = threat.preventionBarriers.length;
|
||||
const laneWidth = preventionEndX - preventionStartX;
|
||||
const preventionPositions = placeBarriersInLane(
|
||||
const preventionSlots = layoutBarrierSlots(
|
||||
preventionStartX,
|
||||
laneWidth,
|
||||
barrierCount,
|
||||
layout.barrierWidth,
|
||||
layout.barrierGap
|
||||
threat.preventionBarriers,
|
||||
layout
|
||||
);
|
||||
|
||||
threat.preventionBarriers.forEach((barrier, barrierIndex) => {
|
||||
const bx = preventionPositions[barrierIndex];
|
||||
const slot = preventionSlots[barrierIndex];
|
||||
if (!slot) return;
|
||||
const bx = slot.x;
|
||||
const barrierHeight = barrierRenderHeight(barrier, layout);
|
||||
const barrierNode: PositionedNode = {
|
||||
ref: {
|
||||
|
|
@ -530,21 +667,9 @@ export function layoutBowtie(bowtie: Bowtie, layout: LayoutConfig = DEFAULT_LAYO
|
|||
layoutEscalation(barrier, barrierNode, nodes, edges, layout);
|
||||
});
|
||||
|
||||
chain.push(topEventNode);
|
||||
for (let i = 0; i < chain.length - 1; i++) {
|
||||
const from = chain[i];
|
||||
const to = chain[i + 1];
|
||||
const p1 = nodeRight(from);
|
||||
const p2 = nodeLeft(to);
|
||||
const edge = makeBezierEdge(p1.x, p1.y, p2.x, p2.y);
|
||||
edges.push({
|
||||
id: `edge-${from.ref.kind}-${to.ref.kind}-${i}`,
|
||||
path: edge.path,
|
||||
kind: "main",
|
||||
fromRef: from.ref,
|
||||
toRef: to.ref,
|
||||
arrow: edge.arrow,
|
||||
});
|
||||
if (firstTopEventNode) {
|
||||
chain.push(firstTopEventNode);
|
||||
pushChainEdges(chain, edges, "");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -567,19 +692,19 @@ export function layoutBowtie(bowtie: Bowtie, layout: LayoutConfig = DEFAULT_LAYO
|
|||
};
|
||||
nodes.push(consequenceNode);
|
||||
|
||||
const chain: PositionedNode[] = [topEventNode];
|
||||
const barrierCount = consequence.mitigationBarriers.length;
|
||||
const chain: PositionedNode[] = lastTopEventNode ? [lastTopEventNode] : [];
|
||||
const laneWidth = consequenceColX - mitigationStartX;
|
||||
const mitigationPositions = placeBarriersInLane(
|
||||
const mitigationSlots = layoutBarrierSlots(
|
||||
mitigationStartX,
|
||||
laneWidth,
|
||||
barrierCount,
|
||||
layout.barrierWidth,
|
||||
layout.barrierGap
|
||||
consequence.mitigationBarriers,
|
||||
layout
|
||||
);
|
||||
|
||||
consequence.mitigationBarriers.forEach((barrier, barrierIndex) => {
|
||||
const bx = mitigationPositions[barrierIndex];
|
||||
const slot = mitigationSlots[barrierIndex];
|
||||
if (!slot) return;
|
||||
const bx = slot.x;
|
||||
const barrierHeight = barrierRenderHeight(barrier, layout);
|
||||
const barrierNode: PositionedNode = {
|
||||
ref: {
|
||||
|
|
@ -598,25 +723,13 @@ export function layoutBowtie(bowtie: Bowtie, layout: LayoutConfig = DEFAULT_LAYO
|
|||
nodes.push(barrierNode);
|
||||
chain.push(barrierNode);
|
||||
|
||||
layoutEscalation(barrier, barrierNode, nodes, edges, layout, consequence.id);
|
||||
layoutEscalation(barrier, barrierNode, nodes, edges, layout, {
|
||||
consequenceId: consequence.id,
|
||||
});
|
||||
});
|
||||
|
||||
chain.push(consequenceNode);
|
||||
for (let i = 0; i < chain.length - 1; i++) {
|
||||
const from = chain[i];
|
||||
const to = chain[i + 1];
|
||||
const p1 = nodeRight(from);
|
||||
const p2 = nodeLeft(to);
|
||||
const edge = makeBezierEdge(p1.x, p1.y, p2.x, p2.y);
|
||||
edges.push({
|
||||
id: `edge-${from.ref.kind}-${to.ref.kind}-${i}-r`,
|
||||
path: edge.path,
|
||||
kind: "main",
|
||||
fromRef: from.ref,
|
||||
toRef: to.ref,
|
||||
arrow: edge.arrow,
|
||||
});
|
||||
}
|
||||
pushChainEdges(chain, edges, "-r");
|
||||
});
|
||||
|
||||
let maxX = consequenceColX + layout.nodeWidth + layout.padding;
|
||||
|
|
@ -640,97 +753,104 @@ function layoutEscalation(
|
|||
nodes: PositionedNode[],
|
||||
edges: EdgePath[],
|
||||
layout: LayoutConfig,
|
||||
consequenceId?: string
|
||||
context: { eventId?: string; consequenceId?: string } = {}
|
||||
): void {
|
||||
if (!hasSafeguardChain(barrier)) return;
|
||||
|
||||
const threatId = barrierNode.ref.threatId;
|
||||
const factorGap = escalationFactorGap(layout);
|
||||
const { eventId, consequenceId } = context;
|
||||
const escGap = 12;
|
||||
const chainGap = degradationChainGap(layout);
|
||||
const baseY = barrierNode.y + barrierNode.height + layout.escalationOffsetY * ESCALATION_GAP_FACTOR;
|
||||
const factorX = barrierNode.x + barrierNode.width / 2 - layout.escalationWidth / 2;
|
||||
const chainCount = barrier.degradationChains.length;
|
||||
const totalWidth =
|
||||
chainCount * layout.escalationWidth + Math.max(0, chainCount - 1) * chainGap;
|
||||
const startX = barrierNode.x + barrierNode.width / 2 - totalWidth / 2;
|
||||
|
||||
let attachFrom: PositionedNode = barrierNode;
|
||||
let nextY = baseY;
|
||||
barrier.degradationChains.forEach((chain, chainIndex) => {
|
||||
const chainX = startX + chainIndex * (layout.escalationWidth + chainGap);
|
||||
let nextY = baseY;
|
||||
let attachFrom: PositionedNode = barrierNode;
|
||||
|
||||
barrier.escalationFactors.forEach((factor, factorIndex) => {
|
||||
if (factorIndex > 0) nextY += factorGap;
|
||||
chain.safeguards.forEach((safeguard, safeguardIndex) => {
|
||||
nextY += safeguardIndex === 0 ? layout.rowGap * 0.5 : escGap;
|
||||
|
||||
const factorH = escalationNodeHeight(
|
||||
factor.label || "Escalation",
|
||||
layout.escalationWidth,
|
||||
layout.escalationHeight
|
||||
);
|
||||
const factorNode: PositionedNode = {
|
||||
ref: {
|
||||
kind: "escalationFactor",
|
||||
threatId,
|
||||
consequenceId,
|
||||
barrierId: barrier.id,
|
||||
escalationId: factor.id,
|
||||
},
|
||||
kind: "escalationFactor",
|
||||
label: factor.label || "Escalation",
|
||||
subtitle: "Escalation Factor",
|
||||
x: factorX,
|
||||
y: nextY,
|
||||
width: layout.escalationWidth,
|
||||
height: factorH,
|
||||
};
|
||||
nodes.push(factorNode);
|
||||
nextY += factorH;
|
||||
|
||||
const b1 = nodeBottom(attachFrom);
|
||||
const b2 = nodeTop(factorNode);
|
||||
const escEdge = makeLineEdge(b1.x, b1.y, b2.x, b2.y);
|
||||
edges.push({
|
||||
id: `esc-${barrier.id}-${factor.id}`,
|
||||
path: escEdge.path,
|
||||
kind: "escalation",
|
||||
fromRef: attachFrom.ref,
|
||||
toRef: factorNode.ref,
|
||||
arrow: escEdge.arrow,
|
||||
});
|
||||
attachFrom = factorNode;
|
||||
|
||||
factor.escalationBarriers.forEach((escBarrier, escIndex) => {
|
||||
nextY += escIndex === 0 ? layout.rowGap * 0.5 : escGap;
|
||||
|
||||
const escH = escalationNodeHeight(
|
||||
escBarrier.label || "Esc. Barrier",
|
||||
const safeguardH = escalationNodeHeight(
|
||||
safeguard.label || "Safeguard",
|
||||
layout.escalationWidth,
|
||||
layout.escalationHeight
|
||||
);
|
||||
const escNode: PositionedNode = {
|
||||
const safeguardNode: PositionedNode = {
|
||||
ref: {
|
||||
kind: "escalationBarrier",
|
||||
kind: "safeguard",
|
||||
eventId,
|
||||
threatId,
|
||||
consequenceId,
|
||||
barrierId: barrier.id,
|
||||
escalationId: factor.id,
|
||||
escalationBarrierId: escBarrier.id,
|
||||
chainId: chain.id,
|
||||
safeguardId: safeguard.id,
|
||||
},
|
||||
kind: "escalationBarrier",
|
||||
label: escBarrier.label || "Esc. Barrier",
|
||||
subtitle: "Escalation Barrier",
|
||||
x: factorX,
|
||||
kind: "safeguard",
|
||||
label: safeguard.label || "Safeguard",
|
||||
subtitle: "Safeguard",
|
||||
x: chainX,
|
||||
y: nextY,
|
||||
width: layout.escalationWidth,
|
||||
height: escH,
|
||||
height: safeguardH,
|
||||
};
|
||||
nodes.push(escNode);
|
||||
nextY += escH;
|
||||
nodes.push(safeguardNode);
|
||||
nextY += safeguardH;
|
||||
|
||||
const e1 = nodeBottom(attachFrom);
|
||||
const e2 = nodeTop(escNode);
|
||||
const e2 = nodeTop(safeguardNode);
|
||||
const escEdge = makeLineEdge(e1.x, e1.y, e2.x, e2.y);
|
||||
edges.push({
|
||||
id: `escb-${factor.id}-${escBarrier.id}`,
|
||||
id: `sg-${chain.id}-${safeguard.id}`,
|
||||
path: escEdge.path,
|
||||
kind: "escalation",
|
||||
kind: "safeguard",
|
||||
fromRef: attachFrom.ref,
|
||||
toRef: escNode.ref,
|
||||
toRef: safeguardNode.ref,
|
||||
arrow: escEdge.arrow,
|
||||
});
|
||||
attachFrom = escNode;
|
||||
attachFrom = safeguardNode;
|
||||
});
|
||||
|
||||
nextY += chain.safeguards.length === 0 ? layout.rowGap * 0.5 : escGap;
|
||||
const degradation = chain.degradationFactor;
|
||||
const degradationH = escalationNodeHeight(
|
||||
degradation.label || "Degradation",
|
||||
layout.escalationWidth,
|
||||
layout.escalationHeight
|
||||
);
|
||||
const degradationNode: PositionedNode = {
|
||||
ref: {
|
||||
kind: "degradationFactor",
|
||||
eventId,
|
||||
threatId,
|
||||
consequenceId,
|
||||
barrierId: barrier.id,
|
||||
chainId: chain.id,
|
||||
},
|
||||
kind: "degradationFactor",
|
||||
label: degradation.label || "Degradation factor",
|
||||
subtitle: "Degradation Factor",
|
||||
x: chainX,
|
||||
y: nextY,
|
||||
width: layout.escalationWidth,
|
||||
height: degradationH,
|
||||
};
|
||||
nodes.push(degradationNode);
|
||||
|
||||
const d1 = nodeBottom(attachFrom);
|
||||
const d2 = nodeTop(degradationNode);
|
||||
const degEdge = makeLineEdge(d1.x, d1.y, d2.x, d2.y);
|
||||
edges.push({
|
||||
id: `deg-${chain.id}-${degradation.id}`,
|
||||
path: degEdge.path,
|
||||
kind: "degradation",
|
||||
fromRef: attachFrom.ref,
|
||||
toRef: degradationNode.ref,
|
||||
arrow: degEdge.arrow,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,8 +111,8 @@ export default class OTiePlugin extends Plugin {
|
|||
}
|
||||
|
||||
const bowtie = createBowtie(name);
|
||||
bowtie.hazard = "Hazard";
|
||||
bowtie.topEvent = "Top Event";
|
||||
bowtie.events[0].hazard = "Hazard";
|
||||
bowtie.events[0].label = "Top Event";
|
||||
|
||||
const file = await this.app.vault.create(filePath, serializeBowtie(bowtie));
|
||||
await this.openBowtieFile(file);
|
||||
|
|
|
|||
191
src/model.ts
191
src/model.ts
|
|
@ -23,11 +23,11 @@ export const BARRIER_STACK_FIELDS: BarrierStackField[] = [
|
|||
key: "type",
|
||||
name: "Barrier type",
|
||||
options: [
|
||||
{ label: "Active Human", color: "#5dade2" },
|
||||
{ label: "Reactive Human", color: "#3498db" },
|
||||
{ label: "Active Hardware", color: "#48c9b0" },
|
||||
{ label: "Reactive Hardware", color: "#1abc9c" },
|
||||
{ label: "Passive Hardware", color: "#7f8c8d" },
|
||||
{ label: "Continuous Hardware", color: "#1abc9c" },
|
||||
{ label: "Active Hardware", color: "#48c9b0" },
|
||||
{ label: "Active Hardware+Human", color: "#3498db" },
|
||||
{ label: "Active Human", color: "#5dade2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -83,11 +83,16 @@ export const BARRIER_STACK_FIELDS: BarrierStackField[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export interface EscalationFactor {
|
||||
export interface EscalationNode {
|
||||
id: string;
|
||||
label: string;
|
||||
notes?: string;
|
||||
escalationBarriers: Barrier[];
|
||||
}
|
||||
|
||||
export interface DegradationChain {
|
||||
id: string;
|
||||
safeguards: EscalationNode[];
|
||||
degradationFactor: EscalationNode;
|
||||
}
|
||||
|
||||
export interface Barrier {
|
||||
|
|
@ -96,7 +101,7 @@ export interface Barrier {
|
|||
notes?: string;
|
||||
color?: string;
|
||||
effectiveness?: BarrierEffectiveness;
|
||||
escalationFactors: EscalationFactor[];
|
||||
degradationChains: DegradationChain[];
|
||||
stack?: BarrierStackItem[];
|
||||
stackCollapsed?: boolean;
|
||||
}
|
||||
|
|
@ -115,6 +120,14 @@ export interface Consequence {
|
|||
mitigationBarriers: Barrier[];
|
||||
}
|
||||
|
||||
export interface TopEvent {
|
||||
id: string;
|
||||
label: string;
|
||||
hazard: string;
|
||||
notes?: string;
|
||||
transitionBarriers: Barrier[];
|
||||
}
|
||||
|
||||
export interface BowtieViewState {
|
||||
zoom: number;
|
||||
panX: number;
|
||||
|
|
@ -124,8 +137,7 @@ export interface BowtieViewState {
|
|||
export interface Bowtie {
|
||||
id: string;
|
||||
name: string;
|
||||
hazard: string;
|
||||
topEvent: string;
|
||||
events: TopEvent[];
|
||||
threats: Threat[];
|
||||
consequences: Consequence[];
|
||||
view?: BowtieViewState;
|
||||
|
|
@ -163,21 +175,28 @@ export function createBarrierStackItem(
|
|||
};
|
||||
}
|
||||
|
||||
export function createDegradationChain(label = "Degradation factor"): DegradationChain {
|
||||
return {
|
||||
id: generateId(),
|
||||
safeguards: [],
|
||||
degradationFactor: createEscalationNode(label),
|
||||
};
|
||||
}
|
||||
|
||||
export function createBarrier(label = ""): Barrier {
|
||||
return {
|
||||
id: generateId(),
|
||||
label,
|
||||
escalationFactors: [],
|
||||
degradationChains: [],
|
||||
stack: [],
|
||||
stackCollapsed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEscalationFactor(label = ""): EscalationFactor {
|
||||
export function createEscalationNode(label = ""): EscalationNode {
|
||||
return {
|
||||
id: generateId(),
|
||||
label,
|
||||
escalationBarriers: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -197,13 +216,21 @@ export function createConsequence(label = ""): Consequence {
|
|||
};
|
||||
}
|
||||
|
||||
export function createTopEvent(label = "", hazard = "Hazard"): TopEvent {
|
||||
return {
|
||||
id: generateId(),
|
||||
label,
|
||||
hazard,
|
||||
transitionBarriers: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createBowtie(name: string): Bowtie {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: generateId(),
|
||||
name,
|
||||
hazard: "",
|
||||
topEvent: "",
|
||||
events: [createTopEvent("", "")],
|
||||
threats: [],
|
||||
consequences: [],
|
||||
view: { zoom: 1, panX: 0, panY: 0 },
|
||||
|
|
@ -217,22 +244,101 @@ export function serializeBowtie(bowtie: Bowtie): string {
|
|||
}
|
||||
|
||||
export function deserializeBowtie(json: string): Bowtie {
|
||||
const data = JSON.parse(json) as Bowtie;
|
||||
validateBowtie(data);
|
||||
if (!data.view) {
|
||||
data.view = { zoom: 1, panX: 0, panY: 0 };
|
||||
const raw = JSON.parse(json) as Bowtie & { topEvent?: string; hazard?: string };
|
||||
migrateLegacyBowtie(raw);
|
||||
validateBowtie(raw);
|
||||
if (!raw.view) {
|
||||
raw.view = { zoom: 1, panX: 0, panY: 0 };
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function migrateLegacyBowtie(data: Bowtie & { topEvent?: string; hazard?: string }): void {
|
||||
const legacyHazard = typeof data.hazard === "string" ? data.hazard : "";
|
||||
if (!Array.isArray(data.events)) {
|
||||
const legacyLabel = typeof data.topEvent === "string" ? data.topEvent : "";
|
||||
data.events = [createTopEvent(legacyLabel, legacyHazard)];
|
||||
}
|
||||
delete data.topEvent;
|
||||
delete data.hazard;
|
||||
if (data.events.length === 0) {
|
||||
data.events = [createTopEvent("")];
|
||||
}
|
||||
if (legacyHazard && !data.events[0].hazard) {
|
||||
data.events[0].hazard = legacyHazard;
|
||||
}
|
||||
for (const event of data.events) {
|
||||
if (typeof event.hazard !== "string") {
|
||||
event.hazard = "";
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function cloneBowtie(bowtie: Bowtie): Bowtie {
|
||||
return deserializeBowtie(serializeBowtie(bowtie));
|
||||
}
|
||||
|
||||
function normalizeBarrier(barrier: Barrier): void {
|
||||
if (!Array.isArray(barrier.escalationFactors)) {
|
||||
barrier.escalationFactors = [];
|
||||
type LegacyEscalationFactor = {
|
||||
id: string;
|
||||
label?: string;
|
||||
notes?: string;
|
||||
safeguards?: EscalationNode[];
|
||||
degradationFactor?: EscalationNode;
|
||||
escalationBarriers?: Barrier[];
|
||||
};
|
||||
|
||||
function normalizeBarrier(
|
||||
barrier: Barrier & {
|
||||
safeguards?: EscalationNode[];
|
||||
degradationFactor?: EscalationNode;
|
||||
escalationFactors?: LegacyEscalationFactor[];
|
||||
}
|
||||
): void {
|
||||
if (!Array.isArray(barrier.degradationChains)) {
|
||||
barrier.degradationChains = [];
|
||||
}
|
||||
|
||||
if (barrier.safeguards !== undefined || barrier.degradationFactor !== undefined) {
|
||||
const safeguards = barrier.safeguards ?? [];
|
||||
const degradation = barrier.degradationFactor ?? createEscalationNode("");
|
||||
if (safeguards.length > 0 || degradation.label?.trim()) {
|
||||
barrier.degradationChains.push({
|
||||
id: generateId(),
|
||||
safeguards,
|
||||
degradationFactor: degradation,
|
||||
});
|
||||
}
|
||||
delete barrier.safeguards;
|
||||
delete barrier.degradationFactor;
|
||||
}
|
||||
|
||||
if (Array.isArray(barrier.escalationFactors) && barrier.escalationFactors.length > 0) {
|
||||
for (const factor of barrier.escalationFactors) {
|
||||
const factorSafeguards = Array.isArray(factor.safeguards)
|
||||
? factor.safeguards
|
||||
: (factor.escalationBarriers ?? []).map((legacyBarrier) => ({
|
||||
id: legacyBarrier.id,
|
||||
label: legacyBarrier.label,
|
||||
notes: legacyBarrier.notes,
|
||||
}));
|
||||
const safeguards = [...factorSafeguards];
|
||||
if (factor.label?.trim()) {
|
||||
safeguards.unshift({
|
||||
id: factor.id,
|
||||
label: factor.label,
|
||||
notes: factor.notes,
|
||||
});
|
||||
}
|
||||
barrier.degradationChains.push({
|
||||
id: generateId(),
|
||||
safeguards,
|
||||
degradationFactor:
|
||||
factor.degradationFactor ?? createEscalationNode("Degradation factor"),
|
||||
});
|
||||
}
|
||||
delete barrier.escalationFactors;
|
||||
}
|
||||
|
||||
if (!Array.isArray(barrier.stack)) {
|
||||
barrier.stack = [];
|
||||
} else {
|
||||
|
|
@ -241,6 +347,14 @@ function normalizeBarrier(barrier: Barrier): void {
|
|||
if (barrier.stackCollapsed === undefined) {
|
||||
barrier.stackCollapsed = true;
|
||||
}
|
||||
for (const chain of barrier.degradationChains) {
|
||||
if (!Array.isArray(chain.safeguards)) {
|
||||
chain.safeguards = [];
|
||||
}
|
||||
if (!chain.degradationFactor) {
|
||||
chain.degradationFactor = createEscalationNode("Degradation factor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateBowtie(data: Bowtie): void {
|
||||
|
|
@ -250,6 +364,20 @@ function validateBowtie(data: Bowtie): void {
|
|||
if (!Array.isArray(data.threats) || !Array.isArray(data.consequences)) {
|
||||
throw new Error("Invalid bowtie: threats and consequences must be arrays");
|
||||
}
|
||||
if (!Array.isArray(data.events) || data.events.length === 0) {
|
||||
throw new Error("Invalid bowtie: events must be a non-empty array");
|
||||
}
|
||||
for (const event of data.events) {
|
||||
if (typeof event.hazard !== "string") {
|
||||
event.hazard = "";
|
||||
}
|
||||
if (!Array.isArray(event.transitionBarriers)) {
|
||||
event.transitionBarriers = [];
|
||||
}
|
||||
for (const barrier of event.transitionBarriers) {
|
||||
normalizeBarrier(barrier);
|
||||
}
|
||||
}
|
||||
for (const threat of data.threats) {
|
||||
for (const barrier of threat.preventionBarriers) {
|
||||
normalizeBarrier(barrier);
|
||||
|
|
@ -262,6 +390,10 @@ function validateBowtie(data: Bowtie): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function hasSafeguardChain(barrier: Barrier): boolean {
|
||||
return barrier.degradationChains.length > 0;
|
||||
}
|
||||
|
||||
export function getBowtieFilePath(basePath: string): string {
|
||||
return `${basePath}${BOWTIE_EXTENSION}`;
|
||||
}
|
||||
|
|
@ -280,26 +412,29 @@ export type NodeKind =
|
|||
| "consequence"
|
||||
| "preventionBarrier"
|
||||
| "mitigationBarrier"
|
||||
| "escalationFactor"
|
||||
| "escalationBarrier";
|
||||
| "transitionBarrier"
|
||||
| "safeguard"
|
||||
| "degradationFactor";
|
||||
|
||||
export interface NodeRef {
|
||||
kind: NodeKind;
|
||||
eventId?: string;
|
||||
threatId?: string;
|
||||
consequenceId?: string;
|
||||
barrierId?: string;
|
||||
escalationId?: string;
|
||||
escalationBarrierId?: string;
|
||||
chainId?: string;
|
||||
safeguardId?: string;
|
||||
}
|
||||
|
||||
export function nodeRefKey(ref: NodeRef): string {
|
||||
const parts = [
|
||||
ref.kind,
|
||||
ref.eventId ?? "",
|
||||
ref.threatId ?? "",
|
||||
ref.consequenceId ?? "",
|
||||
ref.barrierId ?? "",
|
||||
ref.escalationId ?? "",
|
||||
ref.escalationBarrierId ?? "",
|
||||
ref.chainId ?? "",
|
||||
ref.safeguardId ?? "",
|
||||
];
|
||||
return parts.join(":");
|
||||
}
|
||||
|
|
|
|||
102
styles.css
102
styles.css
|
|
@ -159,8 +159,12 @@
|
|||
--o-tie-consequence-bg: #f5eef8;
|
||||
--o-tie-barrier: #1e8449;
|
||||
--o-tie-barrier-bg: #eafaf1;
|
||||
--o-tie-escalation: #6c3483;
|
||||
--o-tie-escalation-bg: #f4ecf7;
|
||||
--o-tie-safeguard: #516a8a;
|
||||
--o-tie-safeguard-bg: #f6f8fb;
|
||||
--o-tie-degradation: #9a7344;
|
||||
--o-tie-degradation-bg: #faf8f4;
|
||||
--o-tie-escalation: var(--o-tie-degradation);
|
||||
--o-tie-escalation-bg: var(--o-tie-degradation-bg);
|
||||
--o-tie-edge-color: #7f8c8d;
|
||||
}
|
||||
|
||||
|
|
@ -375,13 +379,30 @@
|
|||
stroke: var(--o-tie-hazard);
|
||||
}
|
||||
|
||||
.o-tie-edge-escalation {
|
||||
stroke: var(--o-tie-escalation);
|
||||
stroke-dasharray: 4 3;
|
||||
.o-tie-edge-safeguard {
|
||||
stroke: var(--o-tie-safeguard);
|
||||
stroke-dasharray: 5 4;
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.85;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
.o-tie-edge-degradation {
|
||||
stroke: var(--o-tie-degradation);
|
||||
stroke-dasharray: 5 4;
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.85;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
.o-tie-edge-arrow-safeguard path {
|
||||
fill: var(--o-tie-safeguard);
|
||||
}
|
||||
|
||||
.o-tie-edge-arrow-degradation path {
|
||||
fill: var(--o-tie-degradation);
|
||||
}
|
||||
|
||||
.o-tie-edge-arrow {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
@ -395,7 +416,7 @@
|
|||
}
|
||||
|
||||
.o-tie-edge-arrow-escalation path {
|
||||
fill: var(--o-tie-escalation);
|
||||
fill: var(--o-tie-degradation);
|
||||
}
|
||||
|
||||
/* Nodes layer */
|
||||
|
|
@ -434,7 +455,8 @@
|
|||
}
|
||||
|
||||
.o-tie-node-preventionBarrier,
|
||||
.o-tie-node-mitigationBarrier {
|
||||
.o-tie-node-mitigationBarrier,
|
||||
.o-tie-node-transitionBarrier {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -539,7 +561,8 @@
|
|||
}
|
||||
|
||||
.o-tie-node-preventionBarrier,
|
||||
.o-tie-node-mitigationBarrier {
|
||||
.o-tie-node-mitigationBarrier,
|
||||
.o-tie-node-transitionBarrier {
|
||||
border-color: var(--o-tie-barrier);
|
||||
background: var(--o-tie-barrier-bg);
|
||||
border-radius: 4px;
|
||||
|
|
@ -548,7 +571,8 @@
|
|||
}
|
||||
|
||||
.o-tie-node-preventionBarrier .o-tie-node-stripe,
|
||||
.o-tie-node-mitigationBarrier .o-tie-node-stripe {
|
||||
.o-tie-node-mitigationBarrier .o-tie-node-stripe,
|
||||
.o-tie-node-transitionBarrier .o-tie-node-stripe {
|
||||
background: var(--o-tie-barrier);
|
||||
color: #fff;
|
||||
}
|
||||
|
|
@ -685,10 +709,16 @@
|
|||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
line-height: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
aspect-ratio: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.o-tie-node-wrap-preventionBarrier .o-tie-stack-add,
|
||||
.o-tie-node-wrap-mitigationBarrier .o-tie-stack-add {
|
||||
.o-tie-node-wrap-mitigationBarrier .o-tie-stack-add,
|
||||
.o-tie-node-wrap-transitionBarrier .o-tie-stack-add {
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
|
@ -724,22 +754,30 @@
|
|||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.o-tie-node-escalationFactor,
|
||||
.o-tie-node-escalationBarrier {
|
||||
border-color: var(--o-tie-escalation);
|
||||
background: var(--o-tie-escalation-bg);
|
||||
border-style: dashed;
|
||||
.o-tie-node-safeguard {
|
||||
border-color: var(--o-tie-safeguard);
|
||||
background: var(--o-tie-safeguard-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.o-tie-node-escalationFactor .o-tie-node-stripe,
|
||||
.o-tie-node-escalationBarrier .o-tie-node-stripe {
|
||||
background: var(--o-tie-escalation);
|
||||
.o-tie-node-degradationFactor {
|
||||
border-color: var(--o-tie-degradation);
|
||||
background: var(--o-tie-degradation-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.o-tie-node-safeguard .o-tie-node-stripe {
|
||||
background: var(--o-tie-safeguard);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.o-tie-node-escalationFactor .o-tie-node-label,
|
||||
.o-tie-node-escalationBarrier .o-tie-node-label {
|
||||
.o-tie-node-degradationFactor .o-tie-node-stripe {
|
||||
background: var(--o-tie-degradation);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.o-tie-node-safeguard .o-tie-node-label,
|
||||
.o-tie-node-degradationFactor .o-tie-node-label {
|
||||
padding: 6px 8px 14px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
|
@ -769,6 +807,10 @@
|
|||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
aspect-ratio: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.o-tie-node-wrap:hover .o-tie-node-delete,
|
||||
|
|
@ -873,6 +915,8 @@
|
|||
transform: translate(-40%, 40%);
|
||||
font-size: 11px;
|
||||
background: #fff;
|
||||
color: var(--o-tie-degradation);
|
||||
border-color: var(--o-tie-degradation);
|
||||
}
|
||||
|
||||
.o-tie-node-add-esc-barrier {
|
||||
|
|
@ -880,13 +924,15 @@
|
|||
right: 0;
|
||||
transform: translate(40%, 40%);
|
||||
font-weight: 600;
|
||||
color: var(--o-tie-escalation);
|
||||
border-color: var(--o-tie-escalation);
|
||||
color: var(--o-tie-safeguard);
|
||||
border-color: var(--o-tie-safeguard);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.o-tie-lane-add {
|
||||
position: absolute;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
|
|
@ -902,6 +948,11 @@
|
|||
opacity: 0.85;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
line-height: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
aspect-ratio: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.o-tie-lane-add:hover {
|
||||
|
|
@ -964,9 +1015,10 @@
|
|||
.o-tie-inspector-kind-threat { background: var(--o-tie-threat); }
|
||||
.o-tie-inspector-kind-consequence { background: var(--o-tie-consequence); }
|
||||
.o-tie-inspector-kind-preventionBarrier,
|
||||
.o-tie-inspector-kind-mitigationBarrier { background: var(--o-tie-barrier); }
|
||||
.o-tie-inspector-kind-escalationFactor,
|
||||
.o-tie-inspector-kind-escalationBarrier { background: var(--o-tie-escalation); }
|
||||
.o-tie-inspector-kind-mitigationBarrier,
|
||||
.o-tie-inspector-kind-transitionBarrier { background: var(--o-tie-barrier); }
|
||||
.o-tie-inspector-kind-safeguard { background: var(--o-tie-safeguard); }
|
||||
.o-tie-inspector-kind-degradationFactor { background: var(--o-tie-degradation); }
|
||||
|
||||
.o-tie-inspector-fields {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"1.0.0": "1.4.0",
|
||||
"1.0.1": "1.4.0",
|
||||
"1.0.2": "1.4.0"
|
||||
"1.0.2": "1.4.0",
|
||||
"1.0.3": "1.4.0"
|
||||
}
|
||||
Loading…
Reference in a new issue