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` +
+
+ ${map(buttons, (buttonData) => { + const icon = buttonData.icon() + icon.classList.add('menu-icon') + return html` + { this.selectedButton = buttonData }} + @mouseout=${() => { this.selectedButton = undefined }} + @click=${buttonData.click}> + ${icon} + + ` +})} +
+ ${this.selectedButton?.label()} +
` + } + ` + } +} + +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