diff --git a/source/SIL.AppBuilder.Portal/src/lib/springyGraph.ts b/source/SIL.AppBuilder.Portal/src/lib/springyGraph.ts new file mode 100644 index 000000000..27f7840cc --- /dev/null +++ b/source/SIL.AppBuilder.Portal/src/lib/springyGraph.ts @@ -0,0 +1,781 @@ +/** + * Heavily based on: https://github.com/dhotson/springy/ + * + * Modifications: + * Updated to modern TypeScript + * + * The original license of the inpiring code is included below. + * + * Springy v2.7.1 + * + * Copyright (c) 2010-2013 Dennis Hotson + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +export namespace Springy { + export type NodeData = { + mass?: number; + label?: string; + }; + + export type Node = { + id: string; + data?: NodeData; + }; + + export type EdgeData = { + length?: number; + type?: any; + }; + + export type Edge = { + id: number; + source: Node; + target: Node; + directed?: boolean; + data?: EdgeData; + }; + + export class Graph { + nodeSet: { [key: string]: Node }; + nodes: Node[]; + edges: Edge[]; + adjacency: { [key: string]: { [key: string]: Edge[] } }; + + nextEdgeId: number; + eventListeners: any[]; + + constructor() { + this.nodeSet = {}; + this.nodes = []; + this.edges = []; + this.adjacency = {}; + + this.nextEdgeId = 0; + this.eventListeners = []; + } + + addNode(node: Node): Node { + if (!(node.id in this.nodeSet)) { + this.nodes.push(node); + } + + this.nodeSet[node.id] = node; + + this.notify(); + return node; + } + + addNodes(args: string[]) { + // accepts variable number of arguments, where each argument + // is a string that becomes both node identifier and label + for (let i = 0; i < args.length; i++) { + const name = args[i]; + const node: Node = { id: name, data: { label: name } }; + this.addNode(node); + } + } + + addEdge(edge: Edge): Edge { + let exists = false; + this.edges.forEach(function (e) { + if (edge.id === e.id) { + exists = true; + } + }); + + if (!exists) { + this.edges.push(edge); + } + + if (!(edge.source.id in this.adjacency)) { + this.adjacency[edge.source.id] = {}; + } + if (!(edge.target.id in this.adjacency[edge.source.id])) { + this.adjacency[edge.source.id][edge.target.id] = []; + } + + exists = false; + this.adjacency[edge.source.id][edge.target.id].forEach((e) => { + if (edge.id === e.id) { + exists = true; + } + }); + + if (!exists) { + this.adjacency[edge.source.id][edge.target.id].push(edge); + } + + this.notify(); + return edge; + } + + addEdges(args: { source: string; target: string; data?: EdgeData }[]) { + for (var i = 0; i < args.length; i++) { + const e = args[i]; + const node1 = this.nodeSet[e.source]; + if (node1 == undefined) { + throw new TypeError('invalid node name: ' + e.source); + } + var node2 = this.nodeSet[e.target]; + if (node2 == undefined) { + throw new TypeError('invalid node name: ' + e.target); + } + + this.newEdge(node1, node2, e.data); + } + } + + newNode(id: string, data?: NodeData): Node { + return this.addNode({ id: id, data: data }); + } + + newEdge(source: Node, target: Node, data?: EdgeData) { + return this.addEdge({ id: this.nextEdgeId++, source: source, target: target, data: data }); + } + + /** + * add nodes and edges from JSON object + * + * Springy's simple JSON format for graphs. + * + * historically, Springy uses separate lists of nodes and edges: + * + * { + * "nodes": [ + * "center", + * "left", + * "right", + * "up", + * "satellite" + * ], + * "edges": [ + * ["center", "left"], + * ["center", "right"], + * ["center", "up"] + * ] + * } + * + **/ + loadJSON(json: string | { nodes: string[], edges: string[][]}) { + const obj = typeof json === 'string'? JSON.parse(json) : json; + + if ('nodes' in obj || 'edges' in obj) { + this.addNodes(obj.nodes); + this.addEdges(obj.edges.map((e: string[]) => { + return { + source: e[0], + target: e[1] + } + })); + } + } + + /** find the edges from node1 to node2 */ + getEdges(node1: Node, node2: Node): Edge[] { + if (node1.id in this.adjacency && node2.id in this.adjacency[node1.id]) { + return this.adjacency[node1.id][node2.id]; + } + return []; + } + + /** remove a node and its associated edges from the graph */ + removeNode(node: Node) { + if (node.id in this.nodeSet) { + delete this.nodeSet[node.id]; + } + + for (let i = this.nodes.length - 1; i >= 0; i--) { + if (this.nodes[i].id === node.id) { + this.nodes.splice(i, 1); + } + } + + this.detachNode(node); + } + + /** removes edges associated with a given node */ + detachNode(node: Node) { + const tmpEdges = this.edges.slice(); + tmpEdges.forEach((e) => { + if (e.source.id === node.id || e.target.id === node.id) { + this.removeEdge(e); + } + }); + + this.notify(); + } + + /** remove a node and it's associated edges from the graph */ + removeEdge(edge: Edge) { + for (let i = this.edges.length - 1; i >= 0; i--) { + if (this.edges[i].id === edge.id) { + this.edges.splice(i, 1); + } + } + + for (let x in this.adjacency) { + for (let y in this.adjacency[x]) { + const edges = this.adjacency[x][y]; + + for (let j = edges.length - 1; j >= 0; j--) { + if (this.adjacency[x][y][j].id === edge.id) { + this.adjacency[x][y].splice(j, 1); + } + } + + // Clean up empty edge arrays + if (this.adjacency[x][y].length == 0) { + delete this.adjacency[x][y]; + } + } + + // Clean up empty objects + if (isEmpty(this.adjacency[x])) { + delete this.adjacency[x]; + } + } + + this.notify(); + } + + /** Merge a list of nodes and edges into the current graph. eg. */ + merge(data: { nodes: Node[]; edges: Edge[] }) { + const nodes: { [key: string]: Node } = {}; + data.nodes.forEach((n) => { + nodes[n.id] = this.addNode({ id: n.id, data: n.data }); + }); + + data.edges.forEach((e) => { + const from = nodes[e.source.id]; + const to = nodes[e.target.id]; + const edge = this.addEdge({ + id: this.nextEdgeId++, + source: from, + target: to, + data: e.data + }); + }, this); + } + + /** Remove node if filter returns true */ + filterNodes(filter: (node: Node) => boolean) { + const tmpNodes = this.nodes.slice(); + tmpNodes.forEach((n) => { + if (!filter(n)) { + this.removeNode(n); + } + }); + } + + /** Remove edge if filter returns true */ + filterEdges(filter: (edge: Edge) => boolean) { + const tmpEdges = this.edges.slice(); + tmpEdges.forEach((e) => { + if (!filter(e)) { + this.removeEdge(e); + } + }); + } + + subscribe(cb: (graph?: Graph) => void) { + this.eventListeners.push(cb); + } + + notify() { + this.eventListeners.forEach((cb) => { + cb(this); + }); + } + } + + export namespace Physics { + export class Vector { + x: number; + y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + add(v2: Vector) { + return new Vector(this.x + v2.x, this.y + v2.y); + } + + subtract(v2: Vector) { + return new Vector(this.x - v2.x, this.y - v2.y); + } + + multiply(n: number) { + return new Vector(this.x * n, this.y * n); + } + + divide(n: number) { + return new Vector(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() { + return new Vector(-this.y, this.x); + } + + normalise() { + return this.divide(this.magnitude()); + } + + static random() { + return new Vector(10.0 * (Math.random() - 0.5), 10.0 * (Math.random() - 0.5)); + } + + translateToScreenSpace(offset: Vector, scale: number | Vector) { + const sx = typeof scale === 'number'? scale: scale.x; + const sy = typeof scale === 'number'? scale: scale.y; + return new Vector(offset.x + this.x * sx, offset.y + this.y * sy); + } + } + + export class Point { + p: Vector; // position + m: number; // mass + v: Vector; // velocity + a: Vector; // acceleration + + constructor(position: Vector, mass: number) { + this.p = position; // position + this.m = mass; // mass + this.v = new Vector(0, 0); // velocity + this.a = new Vector(0, 0); // acceleration + } + + applyForce(force: Vector) { + this.a = this.a.add(force.divide(this.m)); + } + } + + export class Spring { + point1: Point; + point2: Point; + length: number; // spring length at rest + k: number; // spring constant (See Hooke's law) .. how stiff the spring is + + constructor(point1: Point, point2: Point, length: number, k: number) { + this.point1 = point1; + this.point2 = point2; + this.length = length; // spring length at rest + this.k = k; // spring constant (See Hooke's law) .. how stiff the spring is + } + + distanceToPoint(point: Point) { + // hardcore vector arithmetic.. ohh yeah! + // .. see http://stackoverflow.com/questions/849211/shortest-distance-between-a-point-and-a-line-segment/865080#865080 + var n = this.point2.p.subtract(this.point1.p).normalise().normal(); + var ac = point.p.subtract(this.point1.p); + return Math.abs(ac.x * n.x + ac.y * n.y); + } + } + } + + export class Layout { + graph: Graph; + _started: boolean = false; + _stop: boolean = false; + /** keep track of points associated with nodes */ + nodePoints: { [key: string]: Physics.Point }; + /** keep track of springs associated with edges */ + edgeSprings: { [key: number]: Physics.Spring }; + /** spring stiffness constant */ + stiffness: number; + + constructor(graph: Graph, stiffness: number) { + this.graph = graph; + + this.nodePoints = {}; + this.edgeSprings = {}; + this.stiffness = stiffness; + } + + point(node: Node) { + if (!(node.id in this.nodePoints)) { + var mass = node.data?.mass !== undefined ? node.data.mass : 1.0; + this.nodePoints[node.id] = new Physics.Point(Physics.Vector.random(), mass); + } + + return this.nodePoints[node.id]; + } + + spring(edge: Edge) { + if (!(edge.id in this.edgeSprings)) { + const length = edge.data?.length !== undefined ? edge.data.length : 1.0; + + let existingSpring: Physics.Spring | undefined; + + const from = this.graph.getEdges(edge.source, edge.target); + from.forEach((e) => { + if (!existingSpring && e.id in this.edgeSprings) { + existingSpring = this.edgeSprings[e.id]; + return new Physics.Spring(existingSpring.point1, existingSpring.point2, 0.0, 0.0); + } + }); + + const to = this.graph.getEdges(edge.target, edge.source); + to.forEach((e) => { + if (!existingSpring && e.id in this.edgeSprings) { + existingSpring = this.edgeSprings[e.id]; + return new Physics.Spring(existingSpring.point2, existingSpring.point1, 0.0, 0.0); + } + }); + + this.edgeSprings[edge.id] = new Physics.Spring( + this.point(edge.source), + this.point(edge.target), + length, + this.stiffness + ); + } + + return this.edgeSprings[edge.id]; + } + + eachNode(callback: (node: Node, point: Physics.Point) => void) { + var t = this; + this.graph.nodes.forEach(function (n) { + callback.call(t, n, t.point(n)); + }); + } + + eachEdge(callback: (edge: Edge, spring: Physics.Spring) => void) { + var t = this; + this.graph.edges.forEach(function (e) { + callback.call(t, e, t.spring(e)); + }); + } + + eachSpring(callback: (spring: Physics.Spring) => void) { + var t = this; + this.graph.edges.forEach(function (e) { + callback.call(t, t.spring(e)); + }); + } + + /** + * Start simulation if it's not running already. + * In case it's running then the call is ignored, and none of the callbacks passed is ever executed. + */ + start( + render?: () => void, + onRenderStop?: () => void, + onRenderStart?: () => void, + tick?: () => void, + stopCondition?: () => boolean + ) { + var t = this; + + if (this._started) return; + this._started = true; + this._stop = false; + + if (onRenderStart) { + onRenderStart(); + } + + requestAnimationFrame(function step() { + if (tick) { + tick(); + } + + if (render) { + render(); + } + + // stop simulation when energy of the system goes below a threshold + if (t._stop || (stopCondition && stopCondition())) { + t._started = false; + if (onRenderStop) { + onRenderStop(); + } + } else { + requestAnimationFrame(step); + } + }); + } + + stop() { + this._stop = true; + } + } + + export class ForceDirected extends Layout { + /** repulsion constant */ + repulsion: number; + /** velocity damping factor */ + damping: number; + /** threshold used to determine render stop */ + minEnergyThreshold: number; + /** nodes aren't allowed to exceed this speed */ + maxSpeed: number; + + constructor( + graph: Graph, + stiffness: number, + repulsion: number, + damping: number, + minEnergyThreshold: number = 0.01, + maxSpeed: number = Infinity + ) { + super(graph, stiffness); + this.repulsion = repulsion; + this.damping = damping; + this.minEnergyThreshold = minEnergyThreshold; + this.maxSpeed = maxSpeed; + } + + // Physics stuff + applyCoulombsLaw() { + this.eachNode((n1, point1) => { + this.eachNode((n2, point2) => { + if (point1 !== point2) { + var d = point1.p.subtract(point2.p); + var distance = d.magnitude() + 0.1; // avoid massive forces at small distances (and divide by zero) + var direction = d.normalise(); + + // apply force to each end point + point1.applyForce(direction.multiply(this.repulsion).divide(distance * distance * 0.5)); + point2.applyForce( + direction.multiply(this.repulsion).divide(distance * distance * -0.5) + ); + } + }); + }); + } + + applyHookesLaw() { + this.eachSpring((spring) => { + var d = spring.point2.p.subtract(spring.point1.p); // the direction of the spring + var displacement = spring.length - d.magnitude(); + var direction = d.normalise(); + + // apply force to each end point + spring.point1.applyForce(direction.multiply(spring.k * displacement * -0.5)); + spring.point2.applyForce(direction.multiply(spring.k * displacement * 0.5)); + }); + } + + attractToCentre() { + this.eachNode((node, point) => { + var direction = point.p.multiply(-1.0); + point.applyForce(direction.multiply(this.repulsion / 50.0)); + }); + } + + updateVelocity(timestep: number) { + this.eachNode((node, point) => { + point.v = point.v.add(point.a.multiply(timestep)).multiply(this.damping); + if (point.v.magnitude() > this.maxSpeed) { + point.v = point.v.normalise().multiply(this.maxSpeed); + } + point.a = new Physics.Vector(0, 0); + }); + } + + updatePosition(timestep: number) { + this.eachNode((node, point) => { + point.p = point.p.add(point.v.multiply(timestep)); + }); + } + + // Calculate the total kinetic energy of the system + totalEnergy() { + var energy = 0.0; + this.eachNode(function (node, point) { + var speed = point.v.magnitude(); + energy += 0.5 * point.m * speed * speed; + }); + + return energy; + } + + /** + * Start simulation if it's not running already. + * In case it's running then the call is ignored, and none of the callbacks passed is ever executed. + */ + start( + render?: () => void, + onRenderStop?: () => void, + onRenderStart?: () => void, + tick?: () => void, + stopCondition?: () => boolean + ) { + super.start( + render, + onRenderStop, + onRenderStart, + () => { + this.tick(0.03); + }, + () => this.totalEnergy() < this.minEnergyThreshold + ); + } + + tick(timestep: number) { + this.applyCoulombsLaw(); + this.applyHookesLaw(); + this.attractToCentre(); + this.updateVelocity(timestep); + this.updatePosition(timestep); + } + + // Find the nearest point to a particular position + nearest(pos: Physics.Vector) { + var min: { node: Node; point: Physics.Point; distance: number } | undefined; + var t = this; + this.graph.nodes.forEach(function (n) { + var point = t.point(n); + var distance = point.p.subtract(pos).magnitude(); + + if (min?.distance === undefined || distance < min.distance) { + min = { node: n, point: point, distance: distance }; + } + }); + + return min; + } + + getBoundingBox() { + var bottomleft = new Physics.Vector(-2, -2); + var topright = new Physics.Vector(2, 2); + + this.eachNode(function (n, point) { + if (point.p.x < bottomleft.x) { + bottomleft.x = point.p.x; + } + if (point.p.y < bottomleft.y) { + bottomleft.y = point.p.y; + } + if (point.p.x > topright.x) { + topright.x = point.p.x; + } + if (point.p.y > topright.y) { + topright.y = point.p.y; + } + }); + + var padding = topright.subtract(bottomleft).multiply(0.07); // ~5% padding + + return { bottomleft: bottomleft.subtract(padding), topright: topright.add(padding) }; + } + } + + /** + * Renderer handles the layout rendering loop + * @param onRenderStop optional callback function that gets executed whenever rendering stops. + * @param onRenderStart optional callback function that gets executed whenever rendering starts. + * @param onRenderFrame optional callback function that gets executed after each frame is rendered. + */ + export class Renderer { + layout: Layout; + clear: () => void; + drawEdge: (edge: Edge, source: Physics.Vector, target: Physics.Vector) => void; + drawNode: (node: Node, position: Physics.Vector) => void; + onRenderStop: () => void; + onRenderStart: () => void; + onRenderFrame: () => void; + + constructor( + layout: Layout, + clear: () => void, + drawEdge: (edge: Edge, source: Physics.Vector, target: Physics.Vector) => void, + drawNode: (node: Node, position: Physics.Vector) => void, + onRenderStop: () => void, + onRenderStart: () => void, + onRenderFrame: () => void + ) { + this.layout = layout; + this.clear = clear; + this.drawEdge = drawEdge; + this.drawNode = drawNode; + this.onRenderStop = onRenderStop; + this.onRenderStart = onRenderStart; + this.onRenderFrame = onRenderFrame; + + this.layout.graph.subscribe((e) => { this.graphChanged(); }); + } + + /** + * Starts the simulation of the layout in use. + * + * Note that in case the algorithm is still or already running then the layout that's in use + * might silently ignore the call, and your optional done callback is never executed. + * At least the built-in ForceDirected layout behaves in this way. + * + * @param done An optional callback function that gets executed when the springy algorithm stops, + * either because it ended or because stop() was called. + */ + start(done?: () => void) { + var t = this; + this.layout.start( + function render() { + t.clear(); + + t.layout.eachEdge(function (edge, spring) { + t.drawEdge(edge, spring.point1.p, spring.point2.p); + }); + + t.layout.eachNode(function (node, point) { + t.drawNode(node, point.p); + }); + + if (t.onRenderFrame !== undefined) { + t.onRenderFrame(); + } + }, + done + ? () => { + this.onRenderStop(); + done(); + } + : this.onRenderStop, + this.onRenderStart + ); + } + + stop() { + this.layout.stop(); + } + + graphChanged() { + this.start(); + } + } + + function isEmpty(obj: any) { + for (var k in obj) { + if (obj.hasOwnProperty(k)) { + return false; + } + } + return true; + } +} diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/admin/workflows/[product_id]/+page.svelte b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/admin/workflows/[product_id]/+page.svelte index e44703c49..8fc468ecc 100644 --- a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/admin/workflows/[product_id]/+page.svelte +++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/admin/workflows/[product_id]/+page.svelte @@ -9,6 +9,8 @@ } from 'xstate'; import { Node, Svelvet, Anchor } from 'svelvet'; import { HamburgerIcon } from '$lib/icons/index.js'; + import { Springy } from '$lib/springyGraph.js'; + import { onMount } from 'svelte'; export let data; @@ -26,6 +28,8 @@ label: string; connections: { id: number; target?: string; label?: string }[]; inCount: number; + start: boolean; + final: boolean; }; function targetStringFromEvent( @@ -60,22 +64,53 @@ // treat no target on transition as self target return { from: k, to: targetStringFromEvent(e, id) || k }; }); - }).reduce((p, c) => { + }) + .reduce((p, c) => { return p.concat(c); }, []) - .filter((v) => k === v.to).length + .filter((v) => k === v.to).length, + start: k === 'Start', + final: v.type === 'final' }; }); - console.log(JSON.stringify(a, null, 4)); return a; } function jumpState() { - console.log(selected); - console.log('old: ' + $snapshot.value); send({ type: 'jump', target: selected }); - console.log('new: ' + $snapshot.value); } + + let positions: { [key: string]: Springy.Physics.Vector } = {}; + + let ready = false; + + onMount(() => { + const graph = new Springy.Graph(); + + const renderer = new Springy.Renderer( + new Springy.ForceDirected(graph, 400.0, 400.0, 0.5, 0.00001), + () => {}, // clear + () => {}, // drawEdge + (node: Springy.Node, position: Springy.Physics.Vector) => { + // drawNode + positions[node.id] = position; + }, + () => { + // onRenderStop + ready = true; + }, + () => {}, // onRenderStart + () => {} // onRenderFrame + ); + graph.loadJSON({ + nodes: Object.keys(NoAdminS3.toJSON().states), + edges: Object.entries(NoAdminS3.toJSON().states) + .map(([k, v]) => { + return Object.values(v.on).map((o) => [k, targetStringFromEvent(o, NoAdminS3.id)]); + }) + .reduce((p, c) => p.concat(c), []) + }); + }); - +{#if ready} + {#each transform(NoAdminS3.toJSON()) as state, i} { selected = state.label; }} @@ -144,19 +185,28 @@ {/each} +{:else} + +{/if}