diff --git a/.github/workflows/build-app-beta.yaml b/.github/workflows/build-app-beta.yaml index e7a95860b..7068b4e86 100644 --- a/.github/workflows/build-app-beta.yaml +++ b/.github/workflows/build-app-beta.yaml @@ -42,7 +42,6 @@ jobs: if: matrix.os == 'ubuntu-18.04' uses: samuelmeuli/action-snapcraft@v1 - name: Publish - if: matrix.os != 'macOS-10.15' run: | yarn run build:app env: @@ -50,13 +49,6 @@ jobs: WIN_CSC_LINK: ${{ secrets.WINCERT_CERTIFICATE }} WIN_CSC_KEY_PASSWORD: ${{ secrets.WINCERT_PASSWORD }} - - name: Publish Mac - if: matrix.os == 'macOS-10.15' - run: | - yarn run build:app:mac - env: - GH_TOKEN: ${{ secrets.GH_TOKEN }} # token for electron publish - - name: Save snap login if: matrix.os == 'ubuntu-18.04' run: 'echo "$SNAPCRAFT_LOGIN" > snapcraft.login' @@ -81,7 +73,7 @@ jobs: cp app/dist/*win*.exe artifacts/dbgate-beta.exe || true cp app/dist/*win_x64.zip artifacts/dbgate-windows-beta.zip || true cp app/dist/*win_arm64.zip artifacts/dbgate-windows-beta-arm64.zip || true - cp app/dist/*-mac.dmg artifacts/dbgate-beta.dmg || true + cp app/dist/*-mac_x64.dmg artifacts/dbgate-beta.dmg || true cp app/dist/*-mac_arm64.dmg artifacts/dbgate-beta-arm64.dmg || true mv app/dist/*.exe artifacts/ || true diff --git a/.github/workflows/build-app.yaml b/.github/workflows/build-app.yaml index a6b6b4df6..0b5bd3265 100644 --- a/.github/workflows/build-app.yaml +++ b/.github/workflows/build-app.yaml @@ -81,7 +81,7 @@ jobs: cp app/dist/*.exe artifacts/dbgate-latest.exe || true cp app/dist/*win_x64.zip artifacts/dbgate-windows-latest.zip || true cp app/dist/*win_arm64.zip artifacts/dbgate-windows-latest-arm64.zip || true - cp app/dist/*-mac.dmg artifacts/dbgate-latest.dmg || true + cp app/dist/*-mac_x64.dmg artifacts/dbgate-latest.dmg || true cp app/dist/*-mac_arm64.dmg artifacts/dbgate-latest-arm64.dmg || true mv app/dist/*.exe artifacts/ || true diff --git a/app/package.json b/app/package.json index bacda4e3b..af5a3edc2 100644 --- a/app/package.json +++ b/app/package.json @@ -92,7 +92,6 @@ "start:local": "cross-env electron .", "dist": "electron-builder", "build": "cd ../packages/api && yarn build && cd ../web && yarn build && cd ../../app && yarn dist", - "build:mac": "cd ../packages/api && yarn build && cd ../web && yarn build && cd ../../app && node setMacPlatform x64 && yarn dist && node setMacPlatform arm64 && yarn dist", "build:local": "cd ../packages/api && yarn build && cd ../web && yarn build && cd ../../app && yarn predist", "postinstall": "yarn rebuild && patch-package", "rebuild": "electron-builder install-app-deps", diff --git a/app/setMacPlatform.js b/app/setMacPlatform.js deleted file mode 100644 index ccf644ef3..000000000 --- a/app/setMacPlatform.js +++ /dev/null @@ -1,8 +0,0 @@ -const fs = require('fs'); - -const text = fs.readFileSync('package.json', { encoding: 'utf-8' }); -const json = JSON.parse(text); - -json.build.mac.target.arch = process.argv[2]; - -fs.writeFileSync('package.json', JSON.stringify(json, null, 2), { encoding: 'utf-8' }); diff --git a/package.json b/package.json index 5a1dc3968..445578e13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "4.5.1", + "version": "4.5.2-beta.8", "name": "dbgate-all", "workspaces": [ "packages/*", @@ -25,7 +25,6 @@ "build:tools": "yarn workspace dbgate-tools build", "build:lib": "yarn build:querysplitter && yarn build:tools && yarn build:sqltree && yarn build:filterparser && yarn build:datalib", "build:app": "yarn plugins:copydist && cd app && yarn install && yarn build", - "build:app:mac": "yarn plugins:copydist && cd app && yarn install && yarn build:mac", "build:api": "yarn workspace dbgate-api build", "build:web:docker": "yarn workspace dbgate-web build", "build:plugins:frontend": "workspaces-run --only=\"dbgate-plugin-*\" -- yarn build:frontend", diff --git a/packages/api/src/controllers/files.js b/packages/api/src/controllers/files.js index 225005bad..78a297784 100644 --- a/packages/api/src/controllers/files.js +++ b/packages/api/src/controllers/files.js @@ -6,6 +6,7 @@ const getChartExport = require('../utility/getChartExport'); const hasPermission = require('../utility/hasPermission'); const socket = require('../utility/socket'); const scheduler = require('./scheduler'); +const getDiagramExport = require('../utility/getDiagramExport'); function serialize(format, data) { if (format == 'text') return data; @@ -86,8 +87,9 @@ module.exports = { const dir = resolveArchiveFolder(folder.substring('archive:'.length)); await fs.writeFile(path.join(dir, file), serialize(format, data)); socket.emitChanged(`archive-files-changed-${folder.substring('archive:'.length)}`); + return true; } else { - if (!hasPermission(`files/${folder}/write`)) return; + if (!hasPermission(`files/${folder}/write`)) return false; const dir = path.join(filesdir(), folder); if (!(await fs.exists(dir))) { await fs.mkdir(dir); @@ -98,6 +100,7 @@ module.exports = { if (folder == 'shell') { scheduler.reload(); } + return true; } }, @@ -150,4 +153,10 @@ module.exports = { } return true; }, + + exportDiagram_meta: true, + async exportDiagram({ filePath, html, css, themeType, themeClassName }) { + await fs.writeFile(filePath, getDiagramExport(html, css, themeType, themeClassName)); + return true; + }, }; diff --git a/packages/api/src/utility/getDiagramExport.js b/packages/api/src/utility/getDiagramExport.js new file mode 100644 index 000000000..3e45fa459 --- /dev/null +++ b/packages/api/src/utility/getDiagramExport.js @@ -0,0 +1,25 @@ +const getDiagramExport = (html, css, themeType, themeClassName) => { + return ` + + + + + + + + + + ${html} + + + `; +}; + +module.exports = getDiagramExport; diff --git a/packages/web/package.json b/packages/web/package.json index 781b921ae..a1ba7b058 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -55,6 +55,7 @@ "uuid": "^3.4.0" }, "dependencies": { - "chartjs-plugin-zoom": "^1.2.0" + "chartjs-plugin-zoom": "^1.2.0", + "interval-operations": "^1.0.7" } } diff --git a/packages/web/public/global.css b/packages/web/public/global.css index 9f4b0ca27..df96b6ae1 100644 --- a/packages/web/public/global.css +++ b/packages/web/public/global.css @@ -45,6 +45,9 @@ body { .nowrap { white-space: nowrap; } +.noselect { + user-select: none; +} .bold { font-weight: bold; } diff --git a/packages/web/src/Screen.svelte b/packages/web/src/Screen.svelte index f1e7b201c..8d2e919de 100644 --- a/packages/web/src/Screen.svelte +++ b/packages/web/src/Screen.svelte @@ -27,7 +27,7 @@
e.preventDefault()} > diff --git a/packages/web/src/appobj/DatabaseAppObject.svelte b/packages/web/src/appobj/DatabaseAppObject.svelte index 74eb1ca4d..13bda44b8 100644 --- a/packages/web/src/appobj/DatabaseAppObject.svelte +++ b/packages/web/src/appobj/DatabaseAppObject.svelte @@ -85,6 +85,34 @@ }); }; + const handleShowDiagram = async () => { + const db = await getDatabaseInfo({ + conid: connection._id, + database: name, + }); + openNewTab( + { + title: 'Diagram #', + icon: 'img diagram', + tabComponent: 'DiagramTab', + props: { + conid: connection._id, + database: name, + }, + }, + { + editor: { + tables: db.tables.map(table => ({ + ...table, + designerId: `${table.pureName}-${uuidv1()}`, + })), + references: [], + autoLayout: true, + }, + } + ); + }; + const handleDisconnect = () => { const electron = getElectron(); if (electron) { @@ -138,6 +166,7 @@ { divider: true }, { onClick: handleImport, text: 'Import' }, { onClick: handleExport, text: 'Export' }, + { onClick: handleShowDiagram, text: 'Show diagram' }, { onClick: handleSqlGenerator, text: 'SQL Generator' }, { onClick: handleOpenJsonModel, text: 'Open model as JSON' }, { onClick: handleExportModel, text: 'Export DB model - experimental' }, @@ -157,6 +186,7 @@ - + + diff --git a/packages/web/src/appobj/SavedFileAppObject.svelte b/packages/web/src/appobj/SavedFileAppObject.svelte index 4f2e561fc..e7321334c 100644 --- a/packages/web/src/appobj/SavedFileAppObject.svelte +++ b/packages/web/src/appobj/SavedFileAppObject.svelte @@ -55,6 +55,14 @@ currentConnection: true, }; + const diagrams: FileTypeHandler = { + icon: 'img diagram', + format: 'json', + tabComponent: 'DiagramTab', + folder: 'diagrams', + currentConnection: true, + }; + const HANDLERS = { sql, shell, @@ -62,6 +70,7 @@ charts, query, sqlite, + diagrams, }; export const extractKey = data => data.file; @@ -74,7 +83,7 @@ import { showModal } from '../modals/modalTools'; import { currentDatabase } from '../stores'; -import { apiCall } from '../utility/api'; + import { apiCall } from '../utility/api'; import getConnectionLabel from '../utility/getConnectionLabel'; import hasPermission from '../utility/hasPermission'; diff --git a/packages/web/src/designer/ColumnLine.svelte b/packages/web/src/designer/ColumnLine.svelte index 013801dc2..a722a358c 100644 --- a/packages/web/src/designer/ColumnLine.svelte +++ b/packages/web/src/designer/ColumnLine.svelte @@ -17,6 +17,7 @@ export let onCreateReference; export let onAddReferenceByColumn; export let onSelectColumn; + export let settings; $: designerColumn = (designer.columns || []).find( x => x.designerId == designerId && x.columnName == column.columnName @@ -38,9 +39,11 @@ }; return [ - { text: 'Sort ascending', onClick: () => setSortOrder(1) }, - { text: 'Sort descending', onClick: () => setSortOrder(-1) }, - { text: 'Unsort', onClick: () => setSortOrder(0) }, + settings?.allowColumnOperations && [ + { text: 'Sort ascending', onClick: () => setSortOrder(1) }, + { text: 'Sort descending', onClick: () => setSortOrder(-1) }, + { text: 'Unsort', onClick: () => setSortOrder(0) }, + ], foreignKey && { text: 'Add reference', onClick: addReference }, ]; } @@ -48,9 +51,12 @@
{ + if (!settings?.allowCreateRefByDrag) return; + const dragData = { ...column, designerId, @@ -90,32 +96,34 @@ ...column, designerId, })} - use:contextMenu={createMenu} + use:contextMenu={settings?.canSelectColumns ? createMenu : '__no_menu'} > - x.designerId == designerId && x.columnName == column.columnName && x.isOutput - )} - on:change={e => { - if (e.target.checked) { - onChangeColumn( - { - ...column, - designerId, - }, - col => ({ ...col, isOutput: true }) - ); - } else { - onChangeColumn( - { - ...column, - designerId, - }, - col => ({ ...col, isOutput: false }) - ); - } - }} - /> + {#if settings?.allowColumnOperations} + x.designerId == designerId && x.columnName == column.columnName && x.isOutput + )} + on:change={e => { + if (e.target.checked) { + onChangeColumn( + { + ...column, + designerId, + }, + col => ({ ...col, isOutput: true }) + ); + } else { + onChangeColumn( + { + ...column, + designerId, + }, + col => ({ ...col, isOutput: false }) + ); + } + }} + /> + {/if} {#if designerColumn?.filter} @@ -129,16 +137,36 @@ {#if designerColumn?.isGrouped} {/if} + + {#if designer?.style?.showNullability || designer?.style?.showDataType} +
+ {#if designer?.style?.showDataType && column?.dataType} +
+ {column?.dataType.toLowerCase()} +
+ {/if} + {#if designer?.style?.showNullability} +
+ {column?.notNull ? 'NOT NULL' : 'NULL'} +
+ {/if} + {/if}
diff --git a/packages/web/src/designer/Designer.svelte b/packages/web/src/designer/Designer.svelte index beccec287..9c0676a71 100644 --- a/packages/web/src/designer/Designer.svelte +++ b/packages/web/src/designer/Designer.svelte @@ -1,32 +1,78 @@ -
+
{#if !(tables?.length > 0)}
Drag & drop tables or views from left panel here
{/if} -
e.preventDefault()} on:drop={handleDrop}> +
e.preventDefault()} + on:drop={handleDrop} + style={`width:${canvasWidth}px;height:${canvasHeight}px; + ${settings?.customizeStyle && value?.style?.zoomKoef ? `zoom:${value?.style?.zoomKoef};` : ''} + `} + on:mousedown={e => { + if (e.button == 0 && settings?.canSelectTables) { + callChange( + current => ({ + ...current, + tables: (current.tables || []).map(x => ({ ...x, isSelectedTable: false })), + }), + true + ); + } + }} + use:moveDrag={settings?.canSelectTables ? [handleMoveStart, handleMove, handleMoveEnd] : null} + > {#each references || [] as ref (ref.designerId)} - {/each} + diff --git a/packages/web/src/designer/GraphLayout.ts b/packages/web/src/designer/GraphLayout.ts new file mode 100644 index 000000000..9afd7e6ae --- /dev/null +++ b/packages/web/src/designer/GraphLayout.ts @@ -0,0 +1,484 @@ +import _ from 'lodash'; +import { + IBoxBounds, + IPoint, + rectangleDistance, + rectangleIntersectArea, + solveOverlapsInIntervalArray, + Vector2D, +} from './designerMath'; +import { union, intersection } from 'interval-operations'; + +const MIN_NODE_DISTANCE = 50; +const SPRING_LENGTH = 100; +const SPRINGY_STEPS = 50; +const GRAVITY_X = 0.005; +const GRAVITY_Y = 0.01; +// const REPULSION = 500_000; +const REPULSION = 1000; +const MAX_FORCE_SIZE = 100; +const NODE_MARGIN = 30; +const GRAVITY_EXPONENT = 1.05; + +// const MOVE_STEP = 20; +// const MOVE_BIG_STEP = 50; +// const MOVE_STEP_COUNT = 100; +// const MINIMAL_SCORE_BENEFIT = 1; +// const SCORE_ASPECT_RATIO = 1.6; + +class GraphNode { + neightboors: GraphNode[] = []; + radius: number; + constructor( + public graph: GraphDefinition, + public designerId: string, + public width: number, + public height: number, + public fixedPosition: IPoint + ) {} + + initialize() { + this.radius = Math.sqrt((this.width * this.width) / 4 + (this.height * this.height) / 4); + } +} + +class GraphEdge { + constructor(public graph: GraphDefinition, public source: GraphNode, public target: GraphNode) {} +} + +// function initialCompareNodes(a: GraphNode, b: GraphNode) { +// if (a.neightboors.length < b.neightboors.length) return -1; +// if (a.neightboors.length > b.neightboors.length) return 1; + +// if (a.height < b.height) return -1; +// if (a.height > b.height) return 1; + +// return; +// } + +export class GraphDefinition { + nodes: { [designerId: string]: GraphNode } = {}; + edges: GraphEdge[] = []; + + addNode(designerId: string, width: number, height: number, fixedPosition: IPoint) { + this.nodes[designerId] = new GraphNode(this, designerId, width, height, fixedPosition); + } + + addEdge(sourceId: string, targetId: string) { + const source = this.nodes[sourceId]; + const target = this.nodes[targetId]; + if ( + source && + target && + !this.edges.find(x => (x.source == source && x.target == target) || (x.target == source && x.source == target)) + ) { + this.edges.push(new GraphEdge(this, source, target)); + } + } + + initialize() { + for (const node of Object.values(this.nodes)) { + for (const edge of this.edges) { + if (edge.source == node && !node.neightboors.includes(edge.target)) node.neightboors.push(edge.target); + if (edge.target == node && !node.neightboors.includes(edge.source)) node.neightboors.push(edge.source); + } + node.initialize(); + } + } + + detectCentreNode(): GraphNode { + if (_.values(this.nodes).find(x => x.fixedPosition)) { + return null; + } + const res: GraphNode[] = []; + for (const n1 of _.values(this.nodes)) { + let candidate = true; + for (const n2 of _.values(this.nodes)) { + if (n1 == n2) { + continue; + } + if (!n1.neightboors.includes(n2)) { + candidate = false; + break; + } + } + if (candidate) { + res.push(n1); + } + } + if (res.length == 1) return res[0]; + return null; + } +} + +class LayoutNode { + position: Vector2D; + left: number; + right: number; + top: number; + bottom: number; + // paddedRect: IBoxBounds; + + rangeXPadded: [number, number]; + rangeYPadded: [number, number]; + + constructor(public node: GraphNode, public x: number, public y: number) { + this.left = x - node.width / 2; + this.top = y - node.height / 2; + this.right = x + node.width / 2; + this.bottom = y + node.height / 2; + this.position = new Vector2D(x, y); + + this.rangeXPadded = [this.left - NODE_MARGIN, this.right + NODE_MARGIN]; + this.rangeYPadded = [this.top - NODE_MARGIN, this.bottom + NODE_MARGIN]; + // this.paddedRect = { + // left: this.left - NODE_MARGIN, + // top: this.top - NODE_MARGIN, + // right: this.right + NODE_MARGIN, + // bottom: this.bottom + NODE_MARGIN, + // }; + } + + translate(dx: number, dy: number, forceMoveFixed = false) { + if (this.node.fixedPosition && !forceMoveFixed) return this; + return new LayoutNode(this.node, this.x + dx, this.y + dy); + } + + distanceTo(node: LayoutNode) { + return rectangleDistance(this, node); + } + + // intersectArea(node: LayoutNode) { + // return rectangleIntersectArea(this.paddedRect, node.paddedRect); + // } + hasPaddedIntersect(node: LayoutNode) { + const xIntersection = intersection(this.rangeXPadded, node.rangeXPadded) as [number, number]; + const yIntersection = intersection(this.rangeYPadded, node.rangeYPadded) as [number, number]; + return ( + xIntersection && + xIntersection[1] - xIntersection[0] > 0.1 && + yIntersection && + yIntersection[1] - yIntersection[0] > 0.1 + ); + } +} + +class ForceAlgorithmStep { + nodeForces: { [designerId: string]: Vector2D } = {}; + constructor(public layout: GraphLayout) {} + + applyForce(node: LayoutNode, force: Vector2D) { + const size = force.magnitude(); + if (size > MAX_FORCE_SIZE) { + force = force.normalise().multiply(MAX_FORCE_SIZE); + } + + if (node.node.designerId in this.nodeForces) { + this.nodeForces[node.node.designerId] = this.nodeForces[node.node.designerId].add(force); + } else { + this.nodeForces[node.node.designerId] = force; + } + } + + applyCoulombsLaw() { + for (const n1 of _.values(this.layout.nodes)) { + for (const n2 of _.values(this.layout.nodes)) { + if (n1.node.designerId == n2.node.designerId) { + continue; + } + + const d = n1.position.subtract(n2.position); + const direction = d.normalise(); + const distance = n1.distanceTo(n2) + MIN_NODE_DISTANCE; + + this.applyForce(n1, direction.multiply((+0.5 * REPULSION) / (distance * distance))); + this.applyForce(n2, direction.multiply((-0.5 * REPULSION) / (distance * distance))); + } + } + } + + applyHooksLaw() { + for (const edge of this.layout.edges) { + const d = edge.target.position.subtract(edge.source.position); // the direction of the spring + const displacement = SPRING_LENGTH - edge.length; + var direction = d.normalise(); + + // apply force to each end point + this.applyForce(edge.source, direction.multiply(displacement * -0.5)); + this.applyForce(edge.target, direction.multiply(displacement * +0.5)); + } + } + + applyGravity() { + for (const node of _.values(this.layout.nodes)) { + this.applyForce( + node, + // new Vector2D(-node.position.x * GRAVITY_X, -node.position.y * GRAVITY_Y) + + new Vector2D( + -Math.pow(Math.abs(node.position.x), GRAVITY_EXPONENT) * Math.sign(node.position.x) * GRAVITY_X, + -Math.pow(Math.abs(node.position.y), GRAVITY_EXPONENT) * Math.sign(node.position.y) * GRAVITY_Y + ) + ); + } + } + + moveNode(node: LayoutNode): LayoutNode { + const force = this.nodeForces[node.node.designerId]; + if (force) { + return node.translate(force.x, force.y); + } + return node; + } +} + +class LayoutEdge { + edge: GraphEdge; + length: number; + source: LayoutNode; + target: LayoutNode; +} + +function addNodeNeighboors(nodes: GraphNode[], res: GraphNode[], addedNodes: Set) { + const nodesSorted = _.sortBy(nodes, [x => x.neightboors.length, x => x.height, x => x.designerId]); + for (const node of nodesSorted) { + if (addedNodes.has(node.designerId)) continue; + addedNodes.add(node.designerId); + res.push(node); + addNodeNeighboors(node.neightboors, res, addedNodes); + } + + return res; +} + +export class GraphLayout { + nodes: { [designerId: string]: LayoutNode } = {}; + edges: LayoutEdge[] = []; + + constructor(public graph: GraphDefinition) {} + + static createCircle(graph: GraphDefinition, middle: IPoint = { x: 0, y: 0 }): GraphLayout { + const res = new GraphLayout(graph); + if (_.isEmpty(graph.nodes)) return res; + + const addedNodes = new Set(); + const circleSortedNodes: GraphNode[] = []; + + const centreNode = graph.detectCentreNode(); + + addNodeNeighboors( + _.values(graph.nodes).filter(x => x != centreNode && !x.fixedPosition), + circleSortedNodes, + addedNodes + ); + const nodeRadius = _.max(circleSortedNodes.map(x => x.radius)); + const nodeCount = circleSortedNodes.length; + const radius = (nodeCount * nodeRadius) / (2 * Math.PI) + nodeRadius; + + let angle = 0; + const dangle = (2 * Math.PI) / circleSortedNodes.length; + for (const node of circleSortedNodes) { + res.nodes[node.designerId] = new LayoutNode( + node, + middle.x + Math.sin(angle) * radius, + middle.y + Math.cos(angle) * radius + ); + angle += dangle; + } + + for (const node of _.values(graph.nodes).filter(x => x.fixedPosition)) { + res.nodes[node.designerId] = new LayoutNode(node, node.fixedPosition.x, node.fixedPosition.y); + } + + if (centreNode) { + res.nodes[centreNode.designerId] = new LayoutNode(centreNode, middle.x, middle.y); + } + + res.fillEdges(); + + return res; + } + + fillEdges() { + this.edges = this.graph.edges.map(edge => { + const res = new LayoutEdge(); + res.edge = edge; + const n1 = this.nodes[edge.source.designerId]; + const n2 = this.nodes[edge.target.designerId]; + res.length = n1.distanceTo(n2); + res.source = n1; + res.target = n2; + return res; + }); + } + + changePositions(nodeFunc: (node: LayoutNode) => LayoutNode, callFillEdges = true): GraphLayout { + const res = new GraphLayout(this.graph); + res.nodes = _.mapValues(this.nodes, nodeFunc); + if (callFillEdges) res.fillEdges(); + return res; + } + + fixViewBox() { + const minX = _.min(_.values(this.nodes).map(n => n.left)); + const minY = _.min(_.values(this.nodes).map(n => n.top)); + + return this.changePositions(n => n.translate(-minX + 50, -minY + 50, true)); + } + + springyStep() { + const step = new ForceAlgorithmStep(this); + step.applyHooksLaw(); + step.applyCoulombsLaw(); + step.applyGravity(); + return this.changePositions(node => step.moveNode(node)); + } + + springyAlg() { + let res: GraphLayout = this; + for (let step = 0; step < SPRINGY_STEPS; step++) { + res = res.springyStep(); + } + return res; + } + + // score() { + // let res = 0; + + // for (const n1 of _.values(this.nodes)) { + // for (const n2 of _.values(this.nodes)) { + // if (n1.node.designerId == n2.node.designerId) { + // continue; + // } + + // res += n1.intersectArea(n2); + // } + // } + + // const minX = _.min(_.values(this.nodes).map(n => n.left)); + // const minY = _.min(_.values(this.nodes).map(n => n.top)); + // const maxX = _.max(_.values(this.nodes).map(n => n.right)); + // const maxY = _.max(_.values(this.nodes).map(n => n.bottom)); + + // res += maxX - minX; + // res += (maxY - minY) * SCORE_ASPECT_RATIO; + + // return res; + // } + + // tryMoveNode(node: LayoutNode): GraphLayout[] { + // if (node.node.fixedPosition) return []; + // return [ + // this.changePositions(x => (x == node ? node.translate(MOVE_STEP, 0) : x), false), + // this.changePositions(x => (x == node ? node.translate(-MOVE_STEP, 0) : x), false), + // this.changePositions(x => (x == node ? node.translate(0, MOVE_STEP) : x), false), + // this.changePositions(x => (x == node ? node.translate(0, -MOVE_STEP) : x), false), + + // this.changePositions(x => (x == node ? node.translate(MOVE_BIG_STEP, MOVE_BIG_STEP) : x), false), + // this.changePositions(x => (x == node ? node.translate(MOVE_BIG_STEP, -MOVE_BIG_STEP) : x), false), + // this.changePositions(x => (x == node ? node.translate(-MOVE_BIG_STEP, MOVE_BIG_STEP) : x), false), + // this.changePositions(x => (x == node ? node.translate(-MOVE_BIG_STEP, -MOVE_BIG_STEP) : x), false), + // ]; + // } + + // tryMoveElement() { + // let res = null; + // let resScore = null; + + // for (const node of _.values(this.nodes)) { + // for (const item of this.tryMoveNode(node)) { + // const score = item.score(); + // if (resScore == null || score < resScore) { + // res = item; + // resScore = score; + // } + // } + // } + + // return res; + // } + + // doMoveSteps() { + // let res: GraphLayout = this; + // let score = res.score(); + // const start = new Date().getTime(); + // for (let step = 0; step < MOVE_STEP_COUNT; step++) { + // const lastRes = res; + // res = res.tryMoveElement(); + // if (!res) { + // lastRes.fillEdges(); + // return lastRes; + // } + // const newScore = res.score(); + // // console.log('STEP, SCORE, NEW SCORE', step, score, newScore); + // if (score - newScore < MINIMAL_SCORE_BENEFIT || new Date().getTime() - start > 1000) { + // lastRes.fillEdges(); + // return lastRes; + // } + // score = newScore; + // } + // res.fillEdges(); + // return res; + // } + + solveOverlaps(): GraphLayout { + const nodes = _.sortBy(_.values(this.nodes), x => x.position.magnitude()); + const res = new GraphLayout(this.graph); + for (const node of nodes) { + const placedNodes = _.values(res.nodes); + if (placedNodes.find(x => x.hasPaddedIntersect(node))) { + // if (node.node.designerId.startsWith('ProtocolWinPriceAllocation')) { + // console.log('PLACING NODE', node); + // console.log('PLACED NODES', placedNodes); + // } + + // intersection found, must perform moving algorithm + const xIntervalArray = union( + ...placedNodes.filter(x => intersection(x.rangeYPadded, node.rangeYPadded)).map(x => x.rangeXPadded) + ); + + const yIntervalArray = union( + ...placedNodes.filter(x => intersection(x.rangeXPadded, node.rangeXPadded)).map(x => x.rangeYPadded) + ); + + // if (node.node.designerId.startsWith('ProtocolWinPriceAllocation')) { + // console.log('xIntervalArray', xIntervalArray); + // console.log('yIntervalArray', yIntervalArray); + // } + + const newX = solveOverlapsInIntervalArray(node.x, node.node.width + NODE_MARGIN * 2, xIntervalArray as any); + const newY = solveOverlapsInIntervalArray(node.y, node.node.height + NODE_MARGIN * 2, yIntervalArray as any); + + // if (node.node.designerId.startsWith('ProtocolWinPriceAllocation')) { + // console.log('NEWXY', newX, newY); + // } + + const newNode = + Math.abs(newX - node.x) < Math.abs(newY - node.y) + ? new LayoutNode(node.node, newX, node.y) + : new LayoutNode(node.node, node.x, newY); + res.nodes[node.node.designerId] = newNode; + + // if (placedNodes.find(x => x.hasPaddedIntersect(newNode))) { + // console.log('!!!!! LOGICAL ERROR WHEN PLACING', newNode); + // } + } else { + res.nodes[node.node.designerId] = node; + } + } + res.fillEdges(); + return res; + } + + print() { + for (const node of _.values(this.nodes)) { + console.log({ + designerId: node.node.designerId, + left: node.left, + top: node.top, + right: node.right, + bottom: node.bottom, + }); + } + } +} diff --git a/packages/web/src/designer/QueryDesigner.svelte b/packages/web/src/designer/QueryDesigner.svelte index 782816e47..7eb74fa20 100644 --- a/packages/web/src/designer/QueryDesigner.svelte +++ b/packages/web/src/designer/QueryDesigner.svelte @@ -1,5 +1,26 @@ - + diff --git a/packages/web/src/designer/DesignerReference.svelte b/packages/web/src/designer/QueryDesignerReference.svelte similarity index 99% rename from packages/web/src/designer/DesignerReference.svelte rename to packages/web/src/designer/QueryDesignerReference.svelte index 4349fadc5..9affea768 100644 --- a/packages/web/src/designer/DesignerReference.svelte +++ b/packages/web/src/designer/QueryDesignerReference.svelte @@ -8,6 +8,7 @@ export let onChangeReference; export let designer; export let domTables; + export let settings; let src = null; let dst = null; diff --git a/packages/web/src/designer/designerMath.ts b/packages/web/src/designer/designerMath.ts new file mode 100644 index 000000000..caf11e8e8 --- /dev/null +++ b/packages/web/src/designer/designerMath.ts @@ -0,0 +1,170 @@ +import { intersection, arrayDifference } from 'interval-operations'; +import _ from 'lodash'; + +export interface IPoint { + x: number; + y: number; +} + +export interface IBoxBounds { + left: number; + top: number; + right: number; + bottom: number; +} + +// helpers for figuring out where to draw arrows +export function intersectLineLine(p1: IPoint, p2: IPoint, p3: IPoint, p4: IPoint): IPoint { + const denom = (p4.y - p3.y) * (p2.x - p1.x) - (p4.x - p3.x) * (p2.y - p1.y); + + // lines are parallel + if (denom === 0) { + return null; + } + + const ua = ((p4.x - p3.x) * (p1.y - p3.y) - (p4.y - p3.y) * (p1.x - p3.x)) / denom; + const ub = ((p2.x - p1.x) * (p1.y - p3.y) - (p2.y - p1.y) * (p1.x - p3.x)) / denom; + + if (ua < 0 || ua > 1 || ub < 0 || ub > 1) { + return null; + } + + return { + x: p1.x + ua * (p2.x - p1.x), + y: p1.y + ua * (p2.y - p1.y), + }; +} + +export function intersectLineBox(p1: IPoint, p2: IPoint, box: IBoxBounds): IPoint[] { + const tl = { x: box.left, y: box.top }; + const tr = { x: box.right, y: box.top }; + const bl = { x: box.left, y: box.bottom }; + const br = { x: box.right, y: box.bottom }; + + const res = []; + let item; + if ((item = intersectLineLine(p1, p2, tl, tr))) { + res.push(item); + } // top + if ((item = intersectLineLine(p1, p2, tr, br))) { + res.push(item); + } // right + if ((item = intersectLineLine(p1, p2, br, bl))) { + res.push(item); + } // bottom + if ((item = intersectLineLine(p1, p2, bl, tl))) { + res.push(item); + } // left + + return res; +} + +export function rectangleDistance(r1: IBoxBounds, r2: IBoxBounds) { + function dist(x1: number, y1: number, x2: number, y2: number) { + let dx = x1 - x2; + let dy = y1 - y2; + return Math.sqrt(dx * dx + dy * dy); + } + + const x1 = r1.left; + const y1 = r1.top; + const x1b = r1.right; + const y1b = r1.bottom; + const x2 = r2.left; + const y2 = r2.top; + const x2b = r2.right; + const y2b = r2.bottom; + + const left = x2b < x1; + const right = x1b < x2; + const bottom = y2b < y1; + const top = y1b < y2; + + if (top && left) return dist(x1, y1b, x2b, y2); + else if (left && bottom) return dist(x1, y1, x2b, y2b); + else if (bottom && right) return dist(x1b, y1, x2, y2b); + else if (right && top) return dist(x1b, y1b, x2, y2); + else if (left) return x1 - x2b; + else if (right) return x2 - x1b; + else if (bottom) return y1 - y2b; + else if (top) return y2 - y1b; + // rectangles intersect + else return 0; +} + +export function rectangleIntersectArea(rect1: IBoxBounds, rect2: IBoxBounds) { + const x_overlap = Math.max(0, Math.min(rect1.right, rect2.right) - Math.max(rect1.left, rect2.left)); + const y_overlap = Math.max(0, Math.min(rect1.bottom, rect2.bottom) - Math.max(rect1.top, rect2.top)); + // console.log('rectangleIntersectArea', rect1, rect2, x_overlap * y_overlap); + // if (rect1.left < 100 && rect2.left < 100) { + // console.log('rectangleIntersectArea', rect1, rect2, x_overlap * y_overlap); + // } + return x_overlap * y_overlap; +} + +export function rectanglesHaveIntersection(rect1: IBoxBounds, rect2: IBoxBounds) { + const xIntersection = intersection([rect1.left, rect1.right], [rect2.left, rect2.right]); + const yIntersection = intersection([rect1.top, rect1.bottom], [rect2.top, rect2.bottom]); + + return !!xIntersection && !!yIntersection; +} + +export class Vector2D { + constructor(public x: number, public y: number) {} + + static random() { + return new Vector2D(10.0 * (Math.random() - 0.5), 10.0 * (Math.random() - 0.5)); + } + + add(v2: Vector2D) { + return new Vector2D(this.x + v2.x, this.y + v2.y); + } + + subtract(v2: Vector2D) { + return new Vector2D(this.x - v2.x, this.y - v2.y); + } + + multiply(n: number) { + return new Vector2D(this.x * n, this.y * n); + } + + divide(n: number) { + return new Vector2D(this.x / n || 0, this.y / n || 0); // Avoid divide by zero errors.. + } + + magnitude() { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + normal(n: number) { + return new Vector2D(-this.y, this.x); + } + + normalise() { + return this.divide(this.magnitude()); + } +} + +export function solveOverlapsInIntervalArray(position: number, size: number, usedIntervals: [number, number][]) { + const freeIntervals = arrayDifference([[-Infinity, Infinity]], usedIntervals) as [number, number][]; + + const candidates = []; + + for (const interval of freeIntervals) { + const intervalSize = interval[1] - interval[0]; + if (intervalSize < size) continue; + if (interval[1] < position) { + candidates.push(interval[1] - size / 2); + } else if (interval[0] > position) { + candidates.push(interval[0] + size / 2); + } else { + // position is in interval + let candidate = position; + if (candidate - size / 2 < interval[0]) candidate = interval[0] + size / 2; + if (candidate + size / 2 > interval[1]) candidate = interval[1] - size / 2; + candidates.push(candidate); + } + } + + return _.minBy(candidates, x => Math.abs(x - position)); +} diff --git a/packages/web/src/icons/FontIcon.svelte b/packages/web/src/icons/FontIcon.svelte index 472029d7c..2c79d71f9 100644 --- a/packages/web/src/icons/FontIcon.svelte +++ b/packages/web/src/icons/FontIcon.svelte @@ -25,6 +25,7 @@ 'icon settings': 'mdi mdi-cog', 'icon version': 'mdi mdi-ticket-confirmation', 'icon pin': 'mdi mdi-pin', + 'icon arrange': 'mdi mdi-arrange-send-to-back', 'icon columns': 'mdi mdi-view-column', 'icon columns-outline': 'mdi mdi-view-column-outline', @@ -122,6 +123,7 @@ 'img preview': 'mdi mdi-file-find color-icon-red', 'img favorite': 'mdi mdi-star color-icon-yellow', 'img query-design': 'mdi mdi-vector-polyline-edit color-icon-red', + 'img diagram': 'mdi mdi-graph color-icon-blue', 'img yaml': 'mdi mdi-code-brackets color-icon-red', 'img compare': 'mdi mdi-compare color-icon-red', diff --git a/packages/web/src/modals/ChooseColorModal.svelte b/packages/web/src/modals/ChooseColorModal.svelte new file mode 100644 index 000000000..72249acd0 --- /dev/null +++ b/packages/web/src/modals/ChooseColorModal.svelte @@ -0,0 +1,27 @@ + + + + {header} + +
+ {text} +
+ + { + value = e.detail; + onChange(value); + }} + /> +
diff --git a/packages/web/src/tabs/DiagramTab.svelte b/packages/web/src/tabs/DiagramTab.svelte new file mode 100644 index 000000000..0da293c16 --- /dev/null +++ b/packages/web/src/tabs/DiagramTab.svelte @@ -0,0 +1,93 @@ + + + + + diff --git a/packages/web/src/tabs/index.js b/packages/web/src/tabs/index.js index 7e6824834..e3002e0d4 100644 --- a/packages/web/src/tabs/index.js +++ b/packages/web/src/tabs/index.js @@ -18,6 +18,7 @@ import * as YamlEditorTab from './YamlEditorTab.svelte'; import * as CompareModelTab from './CompareModelTab.svelte'; import * as JsonTab from './JsonTab.svelte'; import * as ChangelogTab from './ChangelogTab.svelte'; +import * as DiagramTab from './DiagramTab.svelte'; export default { TableDataTab, @@ -40,4 +41,5 @@ export default { CompareModelTab, JsonTab, ChangelogTab, + DiagramTab, }; diff --git a/packages/web/src/utility/contextMenu.ts b/packages/web/src/utility/contextMenu.ts index a89c2759c..5dc3d781e 100644 --- a/packages/web/src/utility/contextMenu.ts +++ b/packages/web/src/utility/contextMenu.ts @@ -9,13 +9,13 @@ export function registerMenu(...items) { setContext('componentContextMenu', [parentMenu, ...items]); } -export default function contextMenu(node, items = []) { +export default function contextMenu(node, items: any = []) { const handleContextMenu = async e => { e.preventDefault(); e.stopPropagation(); await invalidateCommands(); - + if (items) { const left = e.pageX; const top = e.pageY; @@ -23,6 +23,8 @@ export default function contextMenu(node, items = []) { } }; + if (items == '__no_menu') return; + node.addEventListener('contextmenu', handleContextMenu); return { diff --git a/packages/web/src/utility/moveDrag.ts b/packages/web/src/utility/moveDrag.ts index cac23b039..8712398b5 100644 --- a/packages/web/src/utility/moveDrag.ts +++ b/packages/web/src/utility/moveDrag.ts @@ -1,32 +1,49 @@ -export default function moveDrag(node, [onStart, onMove, onEnd]) { +export default function moveDrag(node, dragEvents) { + if (!dragEvents) return; + + const [onStart, onMove, onEnd] = dragEvents; let startX = null; let startY = null; + let clientX = null; + let clientY = null; + const handleMoveDown = e => { if (e.button != 0) return; + + const zoomKoef = window.getComputedStyle(node)['zoom']; + + const clientRect = node.getBoundingClientRect(); + clientX = clientRect.left * zoomKoef; + clientY = clientRect.top * zoomKoef; + startX = e.clientX; startY = e.clientY; document.addEventListener('mousemove', handleMoveMove, true); document.addEventListener('mouseup', handleMoveEnd, true); - onStart(); + onStart(e.clientX - clientX, e.clientY - clientY); }; const handleMoveMove = e => { + const zoomKoef = window.getComputedStyle(node)['zoom']; + e.preventDefault(); const diffX = e.clientX - startX; startX = e.clientX; const diffY = e.clientY - startY; startY = e.clientY; - onMove(diffX, diffY); + onMove(diffX, diffY, e.clientX - clientX, e.clientY - clientY); }; const handleMoveEnd = e => { + const zoomKoef = window.getComputedStyle(node)['zoom']; + e.preventDefault(); startX = null; startY = null; document.removeEventListener('mousemove', handleMoveMove, true); document.removeEventListener('mouseup', handleMoveEnd, true); - onEnd(); + onEnd(e.clientX - clientX, e.clientY - clientY); }; node.addEventListener('mousedown', handleMoveDown); diff --git a/packages/web/src/utility/statusBarStore.ts b/packages/web/src/utility/statusBarStore.ts new file mode 100644 index 000000000..59bba75b3 --- /dev/null +++ b/packages/web/src/utility/statusBarStore.ts @@ -0,0 +1,28 @@ +import { writable } from "svelte/store"; + +export const statusBarTabInfo = writable({}); + +// export function updateStatuBarInfo(tabid, info) { +// statusBarTabInfo.update(x => ({ +// ...x, +// [tabid]: info, +// })); +// } + +export function updateStatuBarInfoItem(tabid, key, item) { + statusBarTabInfo.update(tabs => { + const items = tabs[tabid] || []; + let newItems; + if (item == null) { + newItems = items.filter(x => x.key != key); + } else if (items.find(x => x.key == key)) { + newItems = items.map(x => (x.key == key ? { ...item, key } : x)); + } else { + newItems = [...items, { ...item, key }]; + } + return { + ...tabs, + [tabid]: newItems, + }; + }); +} diff --git a/packages/web/src/utility/useTimerLabel.ts b/packages/web/src/utility/useTimerLabel.ts index 9eb2d2221..b0cf98329 100644 --- a/packages/web/src/utility/useTimerLabel.ts +++ b/packages/web/src/utility/useTimerLabel.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; import { getContext, onDestroy } from 'svelte'; -import { updateStatuBarInfoItem } from '../widgets/StatusBar.svelte'; +import { updateStatuBarInfoItem } from './statusBarStore'; function formatSeconds(duration) { if (duration == null) return ''; diff --git a/packages/web/src/widgets/SavedFilesList.svelte b/packages/web/src/widgets/SavedFilesList.svelte index fc9fecbcd..4920441eb 100644 --- a/packages/web/src/widgets/SavedFilesList.svelte +++ b/packages/web/src/widgets/SavedFilesList.svelte @@ -1,10 +1,8 @@ diff --git a/packages/web/src/widgets/StatusBar.svelte b/packages/web/src/widgets/StatusBar.svelte index 70e35042a..5e640d7ca 100644 --- a/packages/web/src/widgets/StatusBar.svelte +++ b/packages/web/src/widgets/StatusBar.svelte @@ -1,35 +1,5 @@ - -