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 @@
-
-