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/src/designer/Designer.svelte b/packages/web/src/designer/Designer.svelte index aea2af21b..fe5059791 100644 --- a/packages/web/src/designer/Designer.svelte +++ b/packages/web/src/designer/Designer.svelte @@ -523,7 +523,16 @@ graph.initialize(); - const layout = GraphLayout.createCircle(graph, circleMiddle).springyAlg().doMoveSteps().fixViewBox(); + const layout = GraphLayout + // initial circle layout + .createCircle(graph, circleMiddle) + // simulation with Hook's, Coulomb's and gravity law + .springyAlg() + // move nodes to avoid overlaps + .solveOverlaps() + // view box starts with [0,0] + .fixViewBox(); + // layout.print(); callChange(current => { diff --git a/packages/web/src/designer/GraphLayout.ts b/packages/web/src/designer/GraphLayout.ts index ba78776ac..79913cc24 100644 --- a/packages/web/src/designer/GraphLayout.ts +++ b/packages/web/src/designer/GraphLayout.ts @@ -1,19 +1,29 @@ import _ from 'lodash'; -import { IBoxBounds, IPoint, rectangleDistance, rectangleIntersectArea, Vector2D } from './designerMath'; +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 = 500_000; +const REPULSION = 1000; const MAX_FORCE_SIZE = 100; -const NODE_MARGIN = 20; -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; +const NODE_MARGIN = 30; + +// 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[] = []; @@ -106,7 +116,10 @@ class LayoutNode { right: number; top: number; bottom: number; - paddedRect: IBoxBounds; + // paddedRect: IBoxBounds; + + rangeXPadded: [number, number]; + rangeYPadded: [number, number]; constructor(public node: GraphNode, public x: number, public y: number) { this.left = x - node.width / 2; @@ -115,12 +128,14 @@ class LayoutNode { this.bottom = y + node.height / 2; this.position = new Vector2D(x, y); - this.paddedRect = { - left: this.left - NODE_MARGIN, - top: this.top - NODE_MARGIN, - right: this.right + NODE_MARGIN, - bottom: this.bottom + NODE_MARGIN, - }; + 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) { @@ -132,8 +147,14 @@ class LayoutNode { return rectangleDistance(this, node); } - intersectArea(node: LayoutNode) { - return rectangleIntersectArea(this.paddedRect, node.paddedRect); + // intersectArea(node: LayoutNode) { + // return rectangleIntersectArea(this.paddedRect, node.paddedRect); + // } + hasPaddedIntersect(node: LayoutNode) { + return !!( + intersection(this.rangeXPadded, node.rangeXPadded) && + intersection(this.rangeYPadded, node.rangeYPadded) + ); } } @@ -309,81 +330,113 @@ export class GraphLayout { return res; } - score() { - let res = 0; + // 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; - } + // 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); + // 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))) { + // 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) + ); + + const newX = solveOverlapsInIntervalArray(node.x, node.node.width, xIntervalArray as any); + const newY = solveOverlapsInIntervalArray(node.y, node.node.height, yIntervalArray as any); + + if (newX < newY) res.nodes[node.node.designerId] = new LayoutNode(node.node, newX, node.y); + else res.nodes[node.node.designerId] = new LayoutNode(node.node, node.x, newY); + } else { + res.nodes[node.node.designerId] = node; } } - - 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; } diff --git a/packages/web/src/designer/designerMath.ts b/packages/web/src/designer/designerMath.ts index bcc513182..6fd2f3d18 100644 --- a/packages/web/src/designer/designerMath.ts +++ b/packages/web/src/designer/designerMath.ts @@ -1,3 +1,5 @@ +import { arrayDifference } from 'interval-operations'; +import _ from 'lodash'; export interface IPoint { x: number; y: number; @@ -134,3 +136,27 @@ export class Vector2D { 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/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/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 @@ - -