diff --git a/package-lock.json b/package-lock.json
index 3c96b5a..ecd7016 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,14 +11,25 @@
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@tauri-apps/api": "^1.4.0",
+ "@types/cytoscape": "^3.19.15",
+ "@types/cytoscape-dagre": "^2.3.3",
+ "cytoscape": "^3.27.0",
+ "cytoscape-dagre": "^2.5.0",
+ "cytoscape-dblclick": "^0.3.1",
+ "cytoscape-edgehandles": "^4.0.1",
+ "cytoscape-popper": "^2.0.0",
"dataclass": "^2.1.1",
"lit": "^3.0.2",
+ "lodash": "^4.17.21",
"uikit": "^3.17.8",
"uikit-icons": "^0.5.0"
},
"devDependencies": {
"@microsoft/eslint-formatter-sarif": "^3.0.0",
"@tauri-apps/cli": "^1.4.0",
+ "@types/cytoscape-edgehandles": "^4.0.3",
+ "@types/cytoscape-popper": "^2.0.4",
+ "@types/lodash": "^4.14.201",
"@types/node": "^20.3.2",
"@types/rollup-plugin-less": "^1.1.4",
"@types/uikit": "^3.14.0",
@@ -737,6 +748,15 @@
"node": ">= 8"
}
},
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
"node_modules/@tauri-apps/api": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-1.4.0.tgz",
@@ -939,6 +959,38 @@
"node": ">= 10"
}
},
+ "node_modules/@types/cytoscape": {
+ "version": "3.19.15",
+ "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.19.15.tgz",
+ "integrity": "sha512-v1PNoMBzoIrOGZfuU/PFwDEPxfP4GnfVCTrZPx4M2G4EFS7BV/FLCCoVMOzdBG98MJbNBXpx1LCrs8wh0vybEw=="
+ },
+ "node_modules/@types/cytoscape-dagre": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/@types/cytoscape-dagre/-/cytoscape-dagre-2.3.3.tgz",
+ "integrity": "sha512-FJBsNMbBZpqNwT6rp5leVYMevWUjnyD1QS8erNMAMWoBifvaVUklXIjE+bllLDSowjM3abXuRvljliSXUU+d1A==",
+ "dependencies": {
+ "@types/cytoscape": "*"
+ }
+ },
+ "node_modules/@types/cytoscape-edgehandles": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/cytoscape-edgehandles/-/cytoscape-edgehandles-4.0.3.tgz",
+ "integrity": "sha512-n/nUzfSudfbrtvcJIsruCvfduoW2zg/r+EjjFmceDDP+Pbdfx5A/fA/bAfAc4QlOwnkZ3HzF2oESAzes5mQHcg==",
+ "dev": true,
+ "dependencies": {
+ "@types/cytoscape": "*"
+ }
+ },
+ "node_modules/@types/cytoscape-popper": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@types/cytoscape-popper/-/cytoscape-popper-2.0.4.tgz",
+ "integrity": "sha512-vGRiAMXeEIoY5ziPO0NrS8xmJyVkT8j8ARzvyD/x6CXciAO1+80Q2/Triyd9/+5I4PeatGo1Pch5YBwDMB1D6A==",
+ "dev": true,
+ "dependencies": {
+ "@popperjs/core": "^2.0.0",
+ "@types/cytoscape": "*"
+ }
+ },
"node_modules/@types/eslint": {
"version": "8.44.6",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.6.tgz",
@@ -986,6 +1038,12 @@
"integrity": "sha512-OdhItUN0/Cx9+sWumdb3dxASoA0yStnZahvKcaSQmSR5qd7hZ6zhSriSQGUU3F8GkzFpIILKzut4xn9/GvhusA==",
"dev": true
},
+ "node_modules/@types/lodash": {
+ "version": "4.14.201",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.201.tgz",
+ "integrity": "sha512-y9euML0cim1JrykNxADLfaG0FgD1g/yTHwUs/Jg9ZIU7WKj2/4IW9Lbb1WZbvck78W/lfGXFfe+u2EGfIJXdLQ==",
+ "dev": true
+ },
"node_modules/@types/node": {
"version": "20.3.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz",
@@ -2070,6 +2128,69 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
},
+ "node_modules/cytoscape": {
+ "version": "3.27.0",
+ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.27.0.tgz",
+ "integrity": "sha512-pPZJilfX9BxESwujODz5pydeGi+FBrXq1rcaB1mfhFXXFJ9GjE6CNndAk+8jPzoXGD+16LtSS4xlYEIUiW4Abg==",
+ "dependencies": {
+ "heap": "^0.2.6",
+ "lodash": "^4.17.21"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/cytoscape-dagre": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/cytoscape-dagre/-/cytoscape-dagre-2.5.0.tgz",
+ "integrity": "sha512-VG2Knemmshop4kh5fpLO27rYcyUaaDkRw+6PiX4bstpB+QFt0p2oauMrsjVbUamGWQ6YNavh7x2em2uZlzV44g==",
+ "dependencies": {
+ "dagre": "^0.8.5"
+ },
+ "peerDependencies": {
+ "cytoscape": "^3.2.22"
+ }
+ },
+ "node_modules/cytoscape-dblclick": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/cytoscape-dblclick/-/cytoscape-dblclick-0.3.1.tgz",
+ "integrity": "sha512-xeL6lP2Td0RIvVSTxsBDY94KM0JfDsi9X6BPr6Y7PQGi5CP2RXx+1fNEtageXpk+gDkKFylW0pjoGbk2uc1J7Q==",
+ "peerDependencies": {
+ "cytoscape": "^3.4.2"
+ }
+ },
+ "node_modules/cytoscape-edgehandles": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/cytoscape-edgehandles/-/cytoscape-edgehandles-4.0.1.tgz",
+ "integrity": "sha512-uSYshkqRZ4luCxK295bEVTg46q4ZW+fwJhcIzMrtfNR7zeAnJ38Z48kUGeu5ibtXkgLbcZAg0YE4ED2dRuaePg==",
+ "dependencies": {
+ "lodash.memoize": "^4.1.2",
+ "lodash.throttle": "^4.1.1"
+ },
+ "peerDependencies": {
+ "cytoscape": "^3.2.0"
+ }
+ },
+ "node_modules/cytoscape-popper": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/cytoscape-popper/-/cytoscape-popper-2.0.0.tgz",
+ "integrity": "sha512-b7WSOn8qXHWtdIXFNmrgc8qkaOs16tMY0EwtRXlxzvn8X+al6TAFrUwZoYATkYSlotfd/36ZMoeKMEoUck6feA==",
+ "dependencies": {
+ "@popperjs/core": "^2.0.0"
+ },
+ "peerDependencies": {
+ "cytoscape": "^3.2.0"
+ }
+ },
+ "node_modules/dagre": {
+ "version": "0.8.5",
+ "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
+ "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
+ "dependencies": {
+ "graphlib": "^2.1.8",
+ "lodash": "^4.17.15"
+ }
+ },
"node_modules/dataclass": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/dataclass/-/dataclass-2.1.1.tgz",
@@ -3244,6 +3365,14 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true
},
+ "node_modules/graphlib": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
+ "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
+ "dependencies": {
+ "lodash": "^4.17.15"
+ }
+ },
"node_modules/has-bigints": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz",
@@ -3325,6 +3454,11 @@
"node": ">= 0.4"
}
},
+ "node_modules/heap": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz",
+ "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg=="
+ },
"node_modules/htmlparser2": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-7.2.0.tgz",
@@ -3917,8 +4051,12 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "dev": true
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+ },
+ "node_modules/lodash.memoize": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
+ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
@@ -3926,6 +4064,11 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
+ "node_modules/lodash.throttle": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
+ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
diff --git a/package.json b/package.json
index 874700f..b5a7dc1 100644
--- a/package.json
+++ b/package.json
@@ -13,14 +13,25 @@
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@tauri-apps/api": "^1.4.0",
+ "@types/cytoscape": "^3.19.15",
+ "@types/cytoscape-dagre": "^2.3.3",
+ "cytoscape": "^3.27.0",
+ "cytoscape-dagre": "^2.5.0",
+ "cytoscape-dblclick": "^0.3.1",
+ "cytoscape-edgehandles": "^4.0.1",
+ "cytoscape-popper": "^2.0.0",
"dataclass": "^2.1.1",
"lit": "^3.0.2",
+ "lodash": "^4.17.21",
"uikit": "^3.17.8",
"uikit-icons": "^0.5.0"
},
"devDependencies": {
"@microsoft/eslint-formatter-sarif": "^3.0.0",
"@tauri-apps/cli": "^1.4.0",
+ "@types/cytoscape-edgehandles": "^4.0.3",
+ "@types/cytoscape-popper": "^2.0.4",
+ "@types/lodash": "^4.14.201",
"@types/node": "^20.3.2",
"@types/rollup-plugin-less": "^1.1.4",
"@types/uikit": "^3.14.0",
diff --git a/src/html/component/content-pane/content-pane.less b/src/html/component/content-pane/content-pane.less
index 0a3be3d..b23cbe3 100644
--- a/src/html/component/content-pane/content-pane.less
+++ b/src/html/component/content-pane/content-pane.less
@@ -7,4 +7,10 @@
.content-pane {
outline: white solid 1px;
height: 99%;
+ padding: 0.5em;
+}
+
+.pin-button {
+ position: absolute;
+ z-index: 999;
}
diff --git a/src/html/component/content-pane/content-pane.ts b/src/html/component/content-pane/content-pane.ts
index 43d73b0..0fa367f 100644
--- a/src/html/component/content-pane/content-pane.ts
+++ b/src/html/component/content-pane/content-pane.ts
@@ -3,6 +3,7 @@ import { customElement, property } from 'lit/decorators.js'
import style_less from './content-pane.less?inline'
import { type TabData } from '../../util/tab-data'
import { library, icon, findIconDefinition } from '@fortawesome/fontawesome-svg-core'
+import '../regulations-editor/regulations-editor'
import { faLock, faLockOpen } from '@fortawesome/free-solid-svg-icons'
library.add(faLock, faLockOpen)
@@ -30,7 +31,7 @@ export class ContentPane extends LitElement {
-
${this.tab.data}
+ ${this.tab.data}
`
}
diff --git a/src/html/component/regulations-editor/element-type.ts b/src/html/component/regulations-editor/element-type.ts
new file mode 100644
index 0000000..7d9b069
--- /dev/null
+++ b/src/html/component/regulations-editor/element-type.ts
@@ -0,0 +1,12 @@
+export enum ElementType {
+ NONE,
+ EDGE,
+ NODE
+}
+
+export enum Monotonicity {
+ UNSPECIFIED = 'unspecified',
+ ACTIVATION = 'activation',
+ INHIBITION = 'inhibition',
+ OFF = 'off'
+}
diff --git a/src/html/component/regulations-editor/node-menu.less b/src/html/component/regulations-editor/node-menu.less
new file mode 100644
index 0000000..e52a653
--- /dev/null
+++ b/src/html/component/regulations-editor/node-menu.less
@@ -0,0 +1,49 @@
+@import "uikit/src/less/uikit.theme.less";
+
+.menu-icon {
+ max-height: 1em;
+ width: 100%;
+ padding-top: 0.2em;
+ color: #2f2f2f;
+}
+
+.float-menu {
+ position: absolute;
+ /* This is to accommodate longer hints */
+ width: 12em;
+ height: 4em;
+ /* Ensure hint is in the middle */
+ text-align: center;
+ /* Ensures the zoom is properly applied when needed */
+ transform-origin: 0 0;
+ /* We don't animate other stuff because that needs to be in sync with the graph */
+ transition: opacity 0.3s;
+ pointer-events: none;
+
+ .float-button {
+ width: 2em;
+ height: 2em;
+ border-radius: 24px;
+ transition: 0.3s;
+ }
+
+ .float-button:hover {
+ background-color: #CFD8DC;
+ }
+
+ .button-row {
+ width: 6em;
+ height: 2em;
+ margin: 0 auto;
+ border-radius: 24px;
+ background-color: #ECEFF1;
+ pointer-events: all;
+ }
+
+ .hint {
+ font-weight: bold;
+ transition: opacity 0.3s;
+ pointer-events: none;
+ text-shadow: 0 2px 5px #d0d0d0;
+ }
+}
diff --git a/src/html/component/regulations-editor/node-menu.ts b/src/html/component/regulations-editor/node-menu.ts
new file mode 100644
index 0000000..a7c11e1
--- /dev/null
+++ b/src/html/component/regulations-editor/node-menu.ts
@@ -0,0 +1,175 @@
+import { css, html, LitElement, type TemplateResult, unsafeCSS } from 'lit'
+import { customElement, property, state } from 'lit/decorators.js'
+import style_less from './node-menu.less?inline'
+import { findIconDefinition, icon, library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faArrowTrendDown,
+ faArrowTrendUp,
+ faCalculator,
+ faEye,
+ faEyeSlash,
+ faPen,
+ faRightLeft,
+ faTrash
+} from '@fortawesome/free-solid-svg-icons'
+import { type Position } from 'cytoscape'
+import { map } from 'lit/directives/map.js'
+import { ElementType, Monotonicity } from './element-type'
+
+library.add(faRightLeft, faArrowTrendUp, faArrowTrendDown, faCalculator, faEye, faEyeSlash, faPen, faTrash)
+
+@customElement('node-menu')
+class NodeMenu extends LitElement {
+ static styles = css`${unsafeCSS(style_less)}`
+ @property() type = ElementType.NONE
+ @property() position: Position = { x: 0, y: 0 }
+ @property() zoom = 1.0
+ @property() data: { id: string, observable: boolean, monotonicity: Monotonicity } | undefined
+ @state() selectedButton: IButton | undefined = undefined
+
+ nodeButtons: IButton[] = [
+ {
+ icon: () => icon(findIconDefinition({ prefix: 'fas', iconName: 'pen' })).node[0],
+ label: () => 'Edit name (E)',
+ click: () => {}
+ },
+ {
+ icon: () => icon(findIconDefinition({ prefix: 'fas', iconName: 'calculator' })).node[0],
+ label: () => 'Edit update function (F)',
+ click: () => {}
+ },
+ {
+ icon: () => icon(findIconDefinition({ prefix: 'fas', iconName: 'trash' })).node[0],
+ label: () => 'Remove (⌫)',
+ click: this.removeElement
+ }
+ ]
+
+ edgeButtons: IButton[] = [
+ {
+ icon: () => icon(findIconDefinition({ prefix: 'fas', iconName: ((this.data?.observable) === true) ? 'eye-slash' : 'eye' })).node[0],
+ label: () =>
+ this.data === null || this.data?.observable === null
+ ? 'Toggle observability (O)'
+ : ((this.data?.observable) === true) ? 'Observability off (O)' : 'Observability on (O)',
+ click: () => {
+ this.dispatchEvent(new CustomEvent('update-edge', {
+ detail: {
+ edgeId: this.data?.id,
+ observable: !(this.data?.observable ?? false),
+ monotonicity: this.data?.monotonicity
+ },
+ bubbles: true,
+ composed: true
+ }))
+ if (this.data !== undefined) this.data = { ...this.data, observable: !(this.data?.observable ?? false) }
+ }
+ },
+ {
+ icon: () => icon(findIconDefinition({
+ prefix: 'fas',
+ iconName: this.data?.monotonicity === Monotonicity.INHIBITION
+ ? 'right-left'
+ : this.data?.monotonicity === Monotonicity.ACTIVATION ? 'arrow-trend-down' : 'arrow-trend-up'
+ })).node[0],
+ label: () => {
+ switch (this.data?.monotonicity) {
+ case Monotonicity.OFF:
+ return 'Make activating (M)'
+ case Monotonicity.ACTIVATION:
+ return 'Make inhibiting (M)'
+ case Monotonicity.INHIBITION:
+ return 'Monotonicity off (M)'
+ default:
+ return 'Toggle monotonicity (M)'
+ }
+ },
+ click: () => {
+ let monotonicity
+ switch (this.data?.monotonicity) {
+ case Monotonicity.ACTIVATION:
+ monotonicity = Monotonicity.INHIBITION
+ break
+ case Monotonicity.INHIBITION:
+ monotonicity = Monotonicity.OFF
+ break
+ default:
+ monotonicity = Monotonicity.ACTIVATION
+ break
+ }
+ if (this.data !== undefined) this.data = { ...this.data, monotonicity }
+ this.dispatchEvent(new CustomEvent('update-edge', {
+ detail: {
+ edgeId: this.data?.id,
+ observable: (this.data?.observable ?? true),
+ monotonicity
+ },
+ bubbles: true,
+ composed: true
+ }))
+ }
+ },
+ {
+ icon: () => icon(findIconDefinition({ prefix: 'fas', iconName: 'trash' })).node[0],
+ label: () => 'Remove (⌫)',
+ click: this.removeElement
+ }
+ ]
+
+ private removeElement (): void {
+ this.dispatchEvent(new CustomEvent('remove-element', {
+ detail: {
+ id: this.data?.id
+ },
+ bubbles: true,
+ composed: true
+ }))
+ }
+
+ render (): TemplateResult {
+ let buttons: IButton[]
+ let yOffset = 0
+ switch (this.type) {
+ case ElementType.NODE:
+ buttons = this.nodeButtons
+ yOffset = 30 * this.zoom
+ break
+ case ElementType.EDGE:
+ buttons = this.edgeButtons
+ break
+ case ElementType.NONE:
+ default:
+ buttons = []
+ break
+ }
+ return html`
+ ${this.type !== ElementType.NONE && html`
+ `
+ }
+ `
+ }
+}
+
+interface IButton {
+ icon: () => Element
+ label: () => string
+ click: (event: MouseEvent) => void
+}
diff --git a/src/html/component/regulations-editor/regulations-editor.config.ts b/src/html/component/regulations-editor/regulations-editor.config.ts
new file mode 100644
index 0000000..b57609e
--- /dev/null
+++ b/src/html/component/regulations-editor/regulations-editor.config.ts
@@ -0,0 +1,181 @@
+import { type CytoscapeOptions } from 'cytoscape'
+
+export const edgeOptions = {
+ preview: true, // whether to show added edges preview before releasing selection
+ // hoverDelay: 150, // time spent hovering over a target node before it is considered selected
+ handleNodes: 'node', // selector/filter function for whether edges can be made from a given node
+ snap: false,
+ snapThreshold: 50,
+ snapFrequency: 15,
+ noEdgeEventsInDraw: false,
+ disableBrowserGestures: true,
+ nodeLoopOffset: -50,
+ // The `+` button should be drawn on top of each node
+ handlePosition: () => 'middle top',
+ handleInDrawMode: false,
+ edgeType: () => 'flat',
+ // Loops are always allowed
+ loopAllowed: () => true,
+ // Initialize edge with default parameters
+ edgeParams: () => {
+ return {
+ data: {
+ observable: true,
+ monotonicity: {
+ unspecified: 'unspecified',
+ activation: 'activation',
+ inhibition: 'inhibition'
+ }
+ }
+ }
+ }
+ // Add the edge to the live model
+ // complete: (sourceNode: string, targetNode: string, addedEles: HTMLElement) => {
+ // if (!LiveModel.addRegulation(sourceNode.id(), targetNode.id(), true, RegulationsEditor.edgeMonotonicity.unspecified)) {
+ // addedEles.remove() // if we can't create the regulation, remove new edge
+ // } else {
+ // this._initEdge(addedEles[0])
+ // }
+ // }
+}
+export const initOptions = (container: HTMLElement): CytoscapeOptions => {
+ const addBoxSvg = ''
+ return {
+ wheelSensitivity: 0.5,
+ container,
+ // Some sensible default auto-layout algorithm
+ layout: {
+ animate: true,
+ animationDuration: 300,
+ animationThreshold: 250,
+ refresh: 20,
+ fit: true,
+ name: 'cose',
+ padding: 250,
+ nodeRepulsion: () => 100000,
+ nodeDimensionsIncludeLabels: true
+ },
+ boxSelectionEnabled: false,
+ selectionType: 'single',
+ style: [
+ { // Style of the graph nodes
+ selector: 'node[name]',
+ style: {
+ //
+ label: 'data(name)',
+ // put label in the middle of the node (vertically)
+ 'text-valign': 'center',
+ // a rectangle with slightly sloped edges
+ shape: 'round-rectangle',
+ // when selecting, do not display any overlay
+ 'overlay-opacity': 0,
+ // other visual styles
+ 'background-color': '#dddddd',
+ 'font-family': 'FiraMono',
+ 'font-size': '12pt',
+ 'border-width': '1px',
+ 'border-color': '#bbbbbb',
+ 'border-style': 'solid',
+ 'padding-bottom': '12'
+ }
+ },
+ { // When a node is highlighted by mouse, show it with a dashed blue border.
+ selector: 'node.hover',
+ style: {
+ 'border-width': '2.0px',
+ 'border-color': '#6a7ea5',
+ 'border-style': 'dashed'
+ }
+ },
+ { // When a node is selected, show it with a thick blue border.
+ selector: 'node:selected',
+ style: {
+ 'border-width': '2.0px',
+ 'border-color': '#6a7ea5',
+ 'border-style': 'solid'
+ }
+ },
+ { // General style of the graph edge
+ selector: 'edge',
+ style: {
+ width: 3.0,
+ 'curve-style': 'bezier',
+ 'loop-direction': '-15deg',
+ 'loop-sweep': '30deg',
+ 'text-outline-width': 2.3,
+ 'text-outline-color': '#cacaca',
+ 'font-family': 'FiraMono'
+ }
+ },
+ {
+ selector: 'edge.hover',
+ style: { 'overlay-opacity': 0.1 }
+ },
+ { // Show non-observable edges as dashed
+ selector: 'edge[observable]',
+ style: {
+ 'line-style': (edge) => { if (edge.data().observable as boolean) { return 'solid' } else { return 'dashed' } },
+ 'line-dash-pattern': [8, 3]
+ }
+ },
+ { // When the edge has unspecified monotonicity, show it as grey with normal arrow
+ selector: 'edge',
+ style: {
+ 'line-color': '#797979',
+ 'target-arrow-color': '#797979',
+ 'target-arrow-shape': 'triangle'
+ }
+ },
+ { // When the edge is an activation, show it as green with normal arrow
+ selector: 'edge[monotonicity="activation"]',
+ style: {
+ 'line-color': '#4abd73',
+ 'target-arrow-color': '#4abd73',
+ 'target-arrow-shape': 'triangle'
+ }
+ },
+ { // When the edge is an inhibition, show it as red with a `tee` arrow
+ selector: 'edge[monotonicity="inhibition"]',
+ style: {
+ 'line-color': '#d05d5d',
+ 'target-arrow-color': '#d05d5d',
+ 'target-arrow-shape': 'tee'
+ }
+ },
+ { // A selected edge should be drawn with an overlay
+ selector: 'edge:selected',
+ style: {
+ 'overlay-opacity': 0.1
+ }
+ },
+ { // Edge handles pseudo-node for adding
+ selector: '.eh-handle',
+ style: {
+ width: '32px',
+ height: '32px',
+ 'background-opacity': 0,
+ 'background-color': 'red',
+ 'background-image': () => 'data:image/svg+xml;utf8,' + encodeURIComponent(addBoxSvg),
+ 'background-width': '32px',
+ 'background-height': '32px',
+ 'overlay-opacity': 0,
+ 'border-width': 0,
+ 'border-opacity': 0
+ }
+ },
+ { // Change ghost edge preview colors
+ selector: '.eh-preview, .eh-ghost-edge',
+ style: {
+ 'background-color': '#797979',
+ 'line-color': '#797979',
+ 'target-arrow-color': '#797979',
+ 'target-arrow-shape': 'triangle'
+ }
+ },
+ { // Hide ghost edge when a snapped preview is visible
+ selector: '.eh-ghost-edge.eh-preview-active',
+ style: { opacity: 0 }
+ }
+ ]
+ }
+}
diff --git a/src/html/component/regulations-editor/regulations-editor.less b/src/html/component/regulations-editor/regulations-editor.less
new file mode 100644
index 0000000..ca97d3c
--- /dev/null
+++ b/src/html/component/regulations-editor/regulations-editor.less
@@ -0,0 +1,7 @@
+@import "uikit/src/less/uikit.theme.less";
+
+#cytoscape-editor {
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+}
diff --git a/src/html/component/regulations-editor/regulations-editor.ts b/src/html/component/regulations-editor/regulations-editor.ts
new file mode 100644
index 0000000..2cafe12
--- /dev/null
+++ b/src/html/component/regulations-editor/regulations-editor.ts
@@ -0,0 +1,410 @@
+import { css, html, LitElement, type TemplateResult, unsafeCSS } from 'lit'
+import { customElement, query, state } from 'lit/decorators.js'
+import style_less from './regulations-editor.less?inline'
+import cytoscape, { type Core, type EdgeSingular, type NodeSingular, type Position } from 'cytoscape'
+import dagre from 'cytoscape-dagre'
+import edgeHandles, { type EdgeHandlesInstance } from 'cytoscape-edgehandles'
+import dblclick from 'cytoscape-dblclick'
+import './node-menu'
+import { edgeOptions, initOptions } from './regulations-editor.config'
+import { ElementType, Monotonicity } from './element-type'
+
+const SAVE_NODES = 'nodes'
+const SAVE_EDGES = 'edges'
+
+@customElement('regulations-editor')
+class RegulationsEditor extends LitElement {
+ static styles = css`${unsafeCSS(style_less)}`
+
+ @query('#node-menu')
+ nodeMenu!: HTMLElement
+
+ @query('#edge-menu')
+ edgeMenu!: HTMLElement
+
+ editorElement
+ cy: Core | undefined
+ edgeHandles: EdgeHandlesInstance | undefined
+ _lastClickTimestamp
+ @state() _nodes: INodeData[] = []
+ @state() _edges: IEdgeData[] = []
+ @state() menuType = ElementType.NONE
+ @state() menuPosition = { x: 0, y: 0 }
+ @state() menuZoom = 1.0
+ @state() menuData = undefined
+ @state() drawMode = false
+
+ constructor () {
+ super()
+ cytoscape.use(dagre)
+ cytoscape.use(edgeHandles)
+ cytoscape.use(dblclick)
+ this.addEventListener('update-edge', this.updateEdge)
+ this.addEventListener('remove-element', this.removeElement)
+
+ this.editorElement = document.createElement('div')
+ this.editorElement.id = 'cytoscape-editor'
+ this._lastClickTimestamp = 0
+ }
+
+ render (): TemplateResult {
+ return html`
+
+
+ ${this.editorElement}
+
+ `
+ }
+
+ toggleDraw (): void {
+ if (this.drawMode) {
+ this.edgeHandles?.disableDrawMode()
+ } else {
+ this.edgeHandles?.enableDrawMode()
+ }
+ this.drawMode = !this.drawMode
+ }
+
+ firstUpdated (): void {
+ this.init()
+ if (!this.loadCachedNodes() || !this.loadCachedEdges()) this.loadDummyData()
+ }
+
+ init (): void {
+ this.cy = cytoscape(initOptions(this.editorElement))
+ this.edgeHandles = this.cy.edgehandles(edgeOptions)
+
+ this.cy.on('add remove position', this.saveState)
+
+ this.cy.on('zoom', () => {
+ this._renderMenuForSelectedNode()
+ this._renderMenuForSelectedEdge()
+ })
+ this.cy.on('pan', () => {
+ this._renderMenuForSelectedNode()
+ this._renderMenuForSelectedEdge()
+ })
+ this.cy.on('dblclick ', (e) => {
+ const name = (Math.random() + 1).toString(36).substring(8).toUpperCase()
+ this.addNode(name, name, e.position)
+ })
+ this.cy.on('mouseover', 'node', function (e) {
+ e.target.addClass('hover')
+ })
+ // this.addEventListener('mousemove', () => {
+ // this.cy?.forceRender()
+ // })
+
+ // this.cy.on('ehcomplete', (event, sourceNode, targetNode, addedEdge) => {
+ // const { position } = event
+ //
+ // // edge complete handler
+ // })
+
+ this.cy.on('mouseover', 'node', (e) => {
+ e.target.addClass('hover')
+ // node.addClass('hover')
+ // this._modelEditor.hoverVariable(id, true)
+ })
+ this.cy.on('mouseout', 'node', (e) => {
+ e.target.removeClass('hover')
+ // this._modelEditor.hoverVariable(id, false)
+ })
+ this.cy.on('select', 'node', (e) => {
+ // deselect any previous selection - we don't support multiselection yet
+ // this.cy?.$(':selected').forEach((selected) => {
+ // if (selected.data().id !== id) {
+ // selected.unselect()
+ // }
+ // })
+ this._renderMenuForSelectedNode(e.target)
+ // this._modelEditor.selectVariable(id, true)
+ })
+ this.cy.on('unselect', 'node', () => {
+ this.toggleMenu(ElementType.NONE)
+ // this._modelEditor.selectVariable(id, false)
+ })
+ // node.on('click', () => {
+ // this._lastClickTimestamp = 0 // ensure that we cannot double-click inside the node
+ // })
+ this.cy.on('drag', (e) => {
+ if ((e.target as NodeSingular).selected()) this._renderMenuForSelectedNode(e.target)
+ this._renderMenuForSelectedEdge()
+ })
+
+ this.cy.on('select', 'edge', (e) => {
+ this._renderMenuForSelectedEdge(e.target)
+ })
+ this.cy.on('unselect', 'edge', () => {
+ this.toggleMenu(ElementType.NONE) // hide menu
+ })
+ this.cy.on('mouseover', 'edge', (e) => {
+ e.target.addClass('hover')
+ // ModelEditor.hoverRegulation(edge.data().source, edge.data().target, true);
+ })
+ this.cy.on('mouseout', 'edge', (e) => {
+ e.target.removeClass('hover')
+ // ModelEditor.hoverRegulation(edge.data().source, edge.data().target, false);
+ })
+
+ this.cy.on('ehcomplete ', () => {
+ this.edgeHandles?.disableDrawMode()
+ })
+
+ this.cy.ready(() => {
+ this.cy?.center()
+ this.cy?.fit()
+ this.cy?.resize()
+ })
+ }
+
+ loadDummyData (): void {
+ console.log('loading dummy data')
+ this.cy?.remove('node')
+ this.cy?.edges().remove()
+ this.addNodes(dummyData.nodes)
+ this.addEdges(dummyData.edges)
+ this.saveState()
+
+ this.cy?.ready(() => {
+ this.cy?.center()
+ this.cy?.fit()
+ this.cy?.resize()
+ })
+ }
+
+ _renderMenuForSelectedNode (node: NodeSingular | undefined = undefined): void {
+ if (node === undefined) {
+ node = this.cy?.nodes(':selected').first()
+ if (node === undefined || node.length === 0) return // nothing selected
+ }
+ const zoom = this.cy?.zoom()
+ const position = node.renderedPosition()
+ this.toggleMenu(ElementType.NODE, position, zoom, node.data())
+ }
+
+ _renderMenuForSelectedEdge (edge: EdgeSingular | undefined = undefined): void {
+ if (edge === undefined) {
+ edge = this.cy?.edges(':selected').first()
+ if (edge === undefined || edge.length === 0) return // nothing selected
+ }
+ const zoom = this.cy?.zoom()
+ const boundingBox = edge.renderedBoundingBox()
+ const position = {
+ x: (boundingBox.x1 + boundingBox.x2) / 2,
+ y: (boundingBox.y1 + boundingBox.y2) / 2
+ }
+ this.toggleMenu(ElementType.EDGE, position, zoom, edge.data())
+ }
+
+ addNode (id: string, name: string, position = { x: 0, y: 0 }): void {
+ this.cy?.add({
+ data: { id, name },
+ position: { ...position }
+ })
+ }
+
+ toggleMenu (type: ElementType, position: Position | undefined = undefined, zoom = 1.0, data = undefined): void {
+ this.menuType = type
+ this.menuPosition = position ?? { x: 0, y: 0 }
+ this.menuZoom = zoom
+ this.menuData = data
+ this.saveState()
+ }
+
+ ensureRegulation (regulation: IRegulation): void {
+ // const currentEdge = this._findRegulationEdge(regulation.regulator, regulation.target)
+ // if (currentEdge !== undefined) {
+ // // Edge exists - just make sure to update data
+ // const data = currentEdge.data()
+ // data.observable = regulation.observable
+ // data.monotonicity = regulation.monotonicity
+ // this.cy?.style().update() // redraw graph
+ // if (currentEdge.selected()) {
+ // // if the edge is selected, we also redraw the edge menu
+ // this._renderMenuForSelectedEdge(currentEdge)
+ // }
+ // } else {
+ // Edge does not exist - create a new one
+ this.cy?.add({
+ group: 'edges',
+ data: {
+ source: regulation.source,
+ target: regulation.target,
+ observable: regulation.observable,
+ monotonicity: regulation.monotonicity
+ }
+ })
+ }
+
+ updateEdge (event: Event): void {
+ const e = (event as CustomEvent)
+ this.cy?.$id(e.detail.edgeId)
+ .data('observable', e.detail.observable)
+ .data('monotonicity', e.detail.monotonicity)
+ this.menuData = this.cy?.$id(e.detail.edgeId).data()
+ }
+
+ removeElement (event: Event): void {
+ const e = (event as CustomEvent)
+ console.log(e)
+ this.cy?.$id(e.detail.id).remove()
+ this.toggleMenu(ElementType.NONE)
+ }
+
+ saveState (): void {
+ const nodes = ((this.cy?.nodes()) ?? []).map((node): INodeData => {
+ return {
+ id: node.data().id,
+ name: node.data().name,
+ position: node.position()
+ }
+ })
+ const edges: IEdgeData[] = ((this.cy?.edges()) ?? []).map((edge): IEdgeData => {
+ return {
+ id: edge.id(),
+ source: edge.source().id(),
+ target: edge.target().id(),
+ observable: edge.data().observable as boolean,
+ monotonicity: edge.data().monotonicity as Monotonicity
+ }
+ })
+ if (nodes.length > 0) {
+ this._nodes = nodes
+ localStorage.setItem(SAVE_NODES, JSON.stringify(nodes))
+ }
+ if (edges.length > 0) {
+ this._edges = edges
+ localStorage.setItem(SAVE_EDGES, JSON.stringify(edges))
+ }
+ }
+
+ loadCachedNodes (): boolean {
+ try {
+ const parsed = (JSON.parse(localStorage.getItem(SAVE_NODES) ?? '[]') as INodeData[])
+ if (parsed.length === 0) return false
+ this.addNodes(parsed)
+ } catch (e) {
+ return false
+ }
+ console.log('nodes loaded')
+ return true
+ }
+
+ loadCachedEdges (): boolean {
+ try {
+ const parsed = (JSON.parse(localStorage.getItem(SAVE_EDGES) ?? '[]') as IEdgeData[])
+ if (parsed.length === 0) return false
+ this.addEdges(parsed)
+ } catch (e) {
+ return false
+ }
+ console.log('edges loaded')
+ return true
+ }
+
+ addNodes (nodes: INodeData[]): void {
+ nodes.forEach((node) => {
+ this.addNode(node.id, node.name, node.position)
+ })
+ }
+
+ addEdges (edges: IEdgeData[]): void {
+ edges.forEach((edge) => {
+ this.ensureRegulation(edge)
+ })
+ }
+}
+
+const dummyData: { nodes: INodeData[], edges: IEdgeData[] } = {
+ nodes: [
+ {
+ id: 'YOX1',
+ name: 'YOX1',
+ position: { x: 297, y: 175 }
+ },
+ {
+ id: 'CLN3',
+ name: 'CLN3',
+ position: { x: 128, y: 68 }
+ },
+ {
+ id: 'YHP1',
+ name: 'YHP1',
+ position: { x: 286, y: 254 }
+ },
+ {
+ id: 'ACE2',
+ name: 'ACE2',
+ position: { x: 74, y: 276 }
+ },
+ {
+ id: 'SWI5',
+ name: 'SWI5',
+ position: { x: 47, y: 207 }
+ },
+ {
+ id: 'MBF',
+ name: 'MBF',
+ position: { x: 219, y: 96 }
+ },
+ {
+ id: 'SBF',
+ name: 'SBF',
+ position: { x: 281, y: 138 }
+ },
+ {
+ id: 'HCM1',
+ name: 'HCM1',
+ position: { x: 305, y: 217 }
+ },
+ {
+ id: 'SFF',
+ name: 'SFF',
+ position: { x: 186, y: 302 }
+ }
+ ],
+ edges: [
+ { source: 'MBF', target: 'YOX1', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'SBF', target: 'YOX1', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'YOX1', target: 'CLN3', observable: true, monotonicity: Monotonicity.INHIBITION, id: '' },
+ { source: 'YHP1', target: 'CLN3', observable: true, monotonicity: Monotonicity.INHIBITION, id: '' },
+ { source: 'ACE2', target: 'CLN3', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'SWI5', target: 'CLN3', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'MBF', target: 'YHP1', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'SBF', target: 'YHP1', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'SFF', target: 'ACE2', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'SFF', target: 'SWI5', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'CLN3', target: 'MBF', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'MBF', target: 'SBF', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'YOX1', target: 'SBF', observable: true, monotonicity: Monotonicity.INHIBITION, id: '' },
+ { source: 'YHP1', target: 'SBF', observable: true, monotonicity: Monotonicity.INHIBITION, id: '' },
+ { source: 'CLN3', target: 'SBF', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'MBF', target: 'HCM1', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'SBF', target: 'HCM1', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'SBF', target: 'SFF', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' },
+ { source: 'HCM1', target: 'SFF', observable: true, monotonicity: Monotonicity.ACTIVATION, id: '' }
+ ]
+
+}
+
+interface IRegulation {
+ source: string
+ target: string
+ observable: boolean
+ monotonicity: Monotonicity
+}
+
+interface INodeData {
+ id: string
+ name: string
+ position: Position
+}
+
+interface IEdgeData {
+ id: string
+ source: string
+ target: string
+ observable: boolean
+ monotonicity: Monotonicity
+}
diff --git a/src/html/component/root-component/root-component.ts b/src/html/component/root-component/root-component.ts
index c2d85e2..0b7e88e 100644
--- a/src/html/component/root-component/root-component.ts
+++ b/src/html/component/root-component/root-component.ts
@@ -38,7 +38,6 @@ class RootComponent extends LitElement {
pinned: data.pinned
})
})
- console.log(this.tabs)
}
private pinTab (e: Event): void {
diff --git a/src/html/component/tab-bar/tab-bar.less b/src/html/component/tab-bar/tab-bar.less
index db3a858..65f47ab 100644
--- a/src/html/component/tab-bar/tab-bar.less
+++ b/src/html/component/tab-bar/tab-bar.less
@@ -3,6 +3,7 @@
.svg-inline--fa {
min-height: 1em;
height: 1em;
+ min-width: 1.1em;
}
.tab {
diff --git a/src/html/signpost.html b/src/html/signpost.html
index ab5b8f3..595bfb7 100644
--- a/src/html/signpost.html
+++ b/src/html/signpost.html
@@ -1,30 +1,32 @@
-
-
Welcome to signpost!
+
+
-
+
-
- -
- Item 1
-
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
-
-
- -
- Item 2
-
-
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor reprehenderit.
-
-
- -
- Item 3
-
-
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat proident.
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/html/util/config.ts b/src/html/util/config.ts
index f9ac2e2..204538d 100644
--- a/src/html/util/config.ts
+++ b/src/html/util/config.ts
@@ -1,4 +1,5 @@
import { TabData } from './tab-data'
+import { html } from 'lit'
let index = 0
@@ -6,31 +7,31 @@ export const tabList: TabData[] = [
TabData.create({
id: index++,
name: 'Regulations',
- data: 'Content of regulations tab',
+ data: html`
`,
icon: 'r'
}),
TabData.create({
id: index++,
name: 'Functions',
- data: 'Content of functions tab',
+ data: html`
Content of functions tab
`,
icon: 'f'
}),
TabData.create({
id: index++,
name: 'Observations',
- data: 'Content of observations tab',
+ data: html`
Content of observations tab
`,
icon: 'o'
}),
TabData.create({
id: index++,
name: 'Properties',
- data: 'Content of properties tab',
+ data: html`
Content of properties tab
`,
icon: 'p'
}),
TabData.create({
id: index++,
name: 'Analysis',
- data: 'Content of analysis tab',
+ data: html`
Content of analysis tab
`,
icon: 'a'
})
]
diff --git a/src/html/util/tab-data.ts b/src/html/util/tab-data.ts
index 1c4074c..6712648 100644
--- a/src/html/util/tab-data.ts
+++ b/src/html/util/tab-data.ts
@@ -1,10 +1,11 @@
import { Data } from 'dataclass'
+import { html, type TemplateResult } from 'lit'
export class TabData extends Data {
id: number = -1
name: string = 'unknown'
pinned: boolean = false
- data: string = 'unknown'
+ data: TemplateResult<1> = html`unknown`
active: boolean = false
icon: string = 'question'
}
diff --git a/src/uikit-theme.less b/src/uikit-theme.less
index d2240e1..ec6c9a7 100644
--- a/src/uikit-theme.less
+++ b/src/uikit-theme.less
@@ -6,3 +6,6 @@
@import "uikit/src/less/uikit.theme.less";
@global-link-color: #DA7D02;
+body {
+ overflow: hidden;
+}
\ No newline at end of file