From ec3401845d2ea0d79c67b4b583f3fcba58e28c68 Mon Sep 17 00:00:00 2001 From: Ladd Hoffman Date: Thu, 3 Aug 2023 18:23:21 -0500 Subject: [PATCH] Further WIP --- src/classes/dao/forum.js | 23 ++-- src/classes/display/box.js | 15 ++- src/classes/display/force-directed.js | 145 +++++++++++++++----- src/classes/display/geometry.js | 6 +- src/classes/display/wdg-mermaid-ui.js | 164 +++++++++++++++++++++++ src/classes/supporting/edge.js | 12 +- src/classes/supporting/vertex.js | 22 +-- src/classes/supporting/wdg.js | 136 +------------------ src/index.css | 16 +++ src/tests/scripts/force-directed.test.js | 30 +++-- src/tests/scripts/wdg.test.js | 19 +-- src/util/constants.js | 11 +- src/wdg-editor/index.js | 4 +- 13 files changed, 365 insertions(+), 238 deletions(-) create mode 100644 src/classes/display/wdg-mermaid-ui.js diff --git a/src/classes/dao/forum.js b/src/classes/dao/forum.js index 5dbba05..e3a9e1a 100644 --- a/src/classes/dao/forum.js +++ b/src/classes/dao/forum.js @@ -1,4 +1,3 @@ -import { WeightedDirectedGraph } from '../supporting/wdg.js'; import { Action } from '../display/action.js'; import { Actor } from '../display/actor.js'; import { ReputationHolder } from '../reputation/reputation-holder.js'; @@ -6,6 +5,7 @@ import { displayNumber } from '../../util/helpers.js'; import { EPSILON, INCINERATOR_ADDRESS, EdgeTypes, VertexTypes, } from '../../util/constants.js'; +import { WDGDiagram } from '../display/wdg-mermaid-ui.js'; class Post extends Actor { constructor(forum, senderId, postContent) { @@ -44,7 +44,7 @@ export class Forum extends ReputationHolder { super(name, scene); this.dao = dao; this.id = this.reputationPublicKey; - this.graph = new WeightedDirectedGraph('forum', scene); + this.graph = new WDGDiagram('forum', scene); this.actions = { propagate: new Action('propagate', scene), confirm: new Action('confirm', scene), @@ -88,10 +88,10 @@ export class Forum extends ReputationHolder { console.log('onValidate', { pool, postId, tokenAddress }); // What we have here now is an ERC-1155 rep token, which can contain multiple reputation types. - // ERC-1155 supports a batch transfer operation, so it makes sense to leverage that. + // TODO: ERC-1155 supports a batch transfer operation, so it probably makes sense to leverage that. - const initialValues = pool.reputationTypeIds - .map((tokenTypeId) => this.dao.reputation.valueOf(tokenAddress, tokenTypeId)); + const initialValues = new Map(pool.reputationTypeIds + .map((tokenTypeId) => [tokenTypeId, this.dao.reputation.valueOf(tokenAddress, tokenTypeId)])); const postVertex = this.graph.getVertex(postId); const post = postVertex.data; post.setStatus('Validated'); @@ -193,7 +193,8 @@ export class Forum extends ReputationHolder { }) { const postVertex = edge.to; const post = postVertex.data; - const incrementsStr = `(${increments.join(')(')})`; + const incrementsStrArray = Array.from(increments.entries()).map(([k, v]) => `${k} ${v}`); + const incrementsStr = `(${incrementsStrArray.join(')(')})`; this.actions.propagate.log(edge.from.data, post, incrementsStr); if (!!referenceChainLimit && depth > referenceChainLimit) { @@ -211,7 +212,7 @@ export class Forum extends ReputationHolder { from: edge.from.id ?? edge.from, to: edge.to.id, depth, - value: post.value, + values: post.values, increments, initialNegative, }); @@ -303,15 +304,15 @@ export class Forum extends ReputationHolder { } // Now propagate value via positive citations - const totalDonationAmount = await propagate(true); + const totalDonationAmounts = await propagate(true); for (let idx = 0; idx < totalDonationAmounts.length; idx++) { increments[idx] -= totalDonationAmounts[idx] * leachingValue; } // Apply the remaining increment to the present post - const rawNewValues = post.value + increment; - const newValue = Math.max(0, rawNewValue); - const appliedIncrement = newValue - post.value; + const rawNewValues = post.values.map((value, idx) => value + increments[idx]); + const newValues = rawNewValues.map((rawNewValue) => Math.max(0, rawNewValue)); + const appliedIncrements = newValue - post.value; const refundToInbound = increment - appliedIncrement; // Apply reputation effects to post authors, not to the post directly diff --git a/src/classes/display/box.js b/src/classes/display/box.js index 7c5078d..6a658c8 100644 --- a/src/classes/display/box.js +++ b/src/classes/display/box.js @@ -1,6 +1,6 @@ import { DisplayValue } from './display-value.js'; import { randomID } from '../../util/helpers.js'; -import { Rectangle } from './geometry.js'; +import { Rectangle, Vector } from './geometry.js'; export class Box { constructor(name, parentEl, options = {}) { @@ -21,7 +21,7 @@ export class Box { parentEl.appendChild(this.el); } } - this.boxes = []; + this.position = options.position ?? Vector.zeros(2); } flex({ center = false } = {}) { @@ -44,7 +44,6 @@ export class Box { addBox(name) { const box = new Box(name, this.el); - this.boxes.push(box); return box; } @@ -64,13 +63,15 @@ export class Box { getGeometry() { const { - x, y, width, height, + width, height, } = this.el.getBoundingClientRect(); - return new Rectangle([x, y], [width, height]); + return new Rectangle(this.position, [width, height]); } move(vector) { - this.el.style.left = `${parseInt(this.el.style.left, 10) + vector[0]}px`; - this.el.style.top = `${parseInt(this.el.style.top, 10) + vector[1]}px`; + this.position = this.position.add(vector); + this.el.style.left = `${Math.floor(this.position[0])}px`; + this.el.style.top = `${Math.floor(this.position[1])}px`; + // this.el.dispatchEvent(new CustomEvent('moved')); } } diff --git a/src/classes/display/force-directed.js b/src/classes/display/force-directed.js index e1d1adf..eda9254 100644 --- a/src/classes/display/force-directed.js +++ b/src/classes/display/force-directed.js @@ -1,6 +1,16 @@ import { - DEFAULT_TARGET_RADIUS, DISTANCE_FACTOR, MINIMUM_FORCE, OVERLAP_FORCE, VISCOSITY_FACTOR, + DEFAULT_OVERLAP_FORCE, + DEFAULT_TARGET_RADIUS, + DEFAULT_TIME_STEP, + DISTANCE_FACTOR, + EPSILON, + MINIMUM_FORCE, + VISCOSITY_FACTOR, + TIME_DILATION_FACTOR, + MINIMUM_VELOCITY, } from '../../util/constants.js'; +import { Edge } from '../supporting/edge.js'; +import { WeightedDirectedGraph } from '../supporting/wdg.js'; import { Box } from './box.js'; import { Rectangle, Vector } from './geometry.js'; @@ -28,19 +38,67 @@ import { Rectangle, Vector } from './geometry.js'; // NOTE: When mouse is in our box, we could hijack the scroll actions to zoom in/out. -export class ForceDirectedGraph extends Box { +export class ForceDirectedGraph extends WeightedDirectedGraph { constructor(name, parentEl, options = {}) { - super(name, parentEl, options); - this.addClass('fixed'); + super(name, options); + this.box = new Box(name, parentEl, options); + this.box.addClass('fixed'); + this.box.addClass('force-directed-graph'); + this.intervalTask = null; + this.canvas = window.document.createElement('canvas'); + this.box.el.style.width = `${options.width ?? 800}px`; + this.box.el.style.height = `${options.height ?? 600}px`; + this.box.el.appendChild(this.canvas); + this.fitCanvasToGraph(); + this.nodes = []; + this.edges = []; } - addBox(name) { - const box = super.addBox(name); + fitCanvasToGraph() { + [this.canvas.width, this.canvas.height] = this.box.getGeometry().dimensions; + } + + addVertex(...args) { + const vertex = super.addVertex(...args); + const box = this.box.addBox(vertex.id); box.addClass('absolute'); + box.addClass('vertex'); box.el.style.left = '0px'; box.el.style.top = '0px'; box.velocity = Vector.from([0, 0]); - return box; + box.setInnerHTML(vertex.getHTML()); + box.vertex = vertex; + this.nodes.push(box); + vertex.onUpdate = () => { + box.setInnerHTML(vertex.getHTML()); + }; + return vertex; + } + + addEdge(type, from, to, ...rest) { + const fromBox = this.nodes.find(({ name }) => name === from); + const toBox = this.nodes.find(({ name }) => name === to); + if (!fromBox) throw new Error(`from ${from}: Node not found`); + if (!toBox) throw new Error(`to ${to}: Node not found`); + + const edge = super.addEdge(type, from, to, ...rest); + const box = this.box.addBox(Edge.getKey({ from, to, type })); + box.addClass('absolute'); + box.addClass('edge'); + // TODO: Center between nodes + box.el.style.left = '0px'; + box.el.style.top = '0px'; + box.velocity = Vector.from([0, 0]); + box.setInnerHTML(edge.getHTML()); + box.edge = edge; + this.edges.push(box); + return edge; + } + + setEdgeWeight(...args) { + const edge = super.setEdgeWeight(...args); + edge.displayEdgeNode(); + return edge; } static pairwiseForce(boxA, boxB, targetRadius) { @@ -54,9 +112,9 @@ export class ForceDirectedGraph extends Box { if (rectA.doesOverlap(rectB)) { // if their centers actually coincide we can just randomize the direction. if (r.magnitudeSquared === 0) { - return Vector.randomUnitVector(rectA.dim).scale(OVERLAP_FORCE); + return Vector.randomUnitVector(rectA.dim).scale(DEFAULT_OVERLAP_FORCE); } - return r.normalize().scale(OVERLAP_FORCE); + return r.normalize().scale(DEFAULT_OVERLAP_FORCE); } // repel if closer than targetRadius // attract if farther than targetRadius @@ -64,13 +122,30 @@ export class ForceDirectedGraph extends Box { return r.normalize().scale(force); } - computeEulerFrame(tDelta) { + async runUntilEquilibrium(tDelta = DEFAULT_TIME_STEP) { + if (this.intervalTask) { + return Promise.resolve(); + } + return new Promise((resolve) => { + this.intervalTask = setInterval(() => { + const { atEquilibrium } = this.computeEulerFrame(tDelta); + if (atEquilibrium) { + clearInterval(this.intervalTask); + this.intervalTask = null; + resolve(); + } + }, tDelta * TIME_DILATION_FACTOR); + }); + } + + computeEulerFrame(tDelta = DEFAULT_TIME_STEP) { // Compute all net forces - const netForces = Array.from(Array(this.boxes.length), () => Vector.from([0, 0])); - for (const boxA of this.boxes) { - const idxA = this.boxes.indexOf(boxA); - for (const boxB of this.boxes.slice(idxA + 1)) { - const idxB = this.boxes.indexOf(boxB); + const netForces = Array.from(Array(this.nodes.length), () => Vector.from([0, 0])); + let atEquilibrium = true; + for (const boxA of this.nodes) { + const idxA = this.nodes.indexOf(boxA); + for (const boxB of this.nodes.slice(idxA + 1)) { + const idxB = this.nodes.indexOf(boxB); const force = ForceDirectedGraph.pairwiseForce(boxA, boxB, DEFAULT_TARGET_RADIUS); // Ignore forces below a certain threshold if (force.magnitude >= MINIMUM_FORCE) { @@ -81,31 +156,37 @@ export class ForceDirectedGraph extends Box { } // Compute motions - for (const box of this.boxes) { - const idx = this.boxes.indexOf(box); + for (const box of this.nodes) { + const idx = this.nodes.indexOf(box); box.velocity = box.velocity.add(netForces[idx].scale(tDelta)); // Apply some drag box.velocity = box.velocity.scale(1 - VISCOSITY_FACTOR); } - for (const box of this.boxes) { - box.move(box.velocity); - } - - // TODO: translate everything to keep coordinates positive - const translate = Vector.zeros(2); - for (const box of this.boxes) { - const rect = box.getGeometry(); - console.log({ box, rect }); - for (const vertex of rect.vertices) { - for (let dim = 0; dim < vertex.dim; dim++) { - translate[dim] = Math.max(translate[dim], -vertex[dim]); - console.log(`vertex[${dim}] = ${vertex[dim]}, translate[${dim}] = ${translate[dim]}`); - } + for (const box of this.nodes) { + if (box.velocity.magnitude >= MINIMUM_VELOCITY) { + atEquilibrium = false; + box.move(box.velocity); } } - for (const box of this.boxes) { + + // Translate everything to keep coordinates positive + // TODO: Consider centering and scaling to viewport size + const topLeft = this.box.getGeometry().startPoint; + const translate = Vector.zeros(2); + for (const box of this.nodes) { + const rect = box.getGeometry(); + for (const vertex of rect.vertices) { + translate[0] = Math.max(translate[0], topLeft[0] - vertex[0]); + translate[1] = Math.max(translate[1], topLeft[1] - vertex[1]); + } + } + for (const box of this.nodes) { box.move(translate); } + + this.fitCanvasToGraph(); + + return { atEquilibrium }; } } diff --git a/src/classes/display/geometry.js b/src/classes/display/geometry.js index f8b21eb..73fcc13 100644 --- a/src/classes/display/geometry.js +++ b/src/classes/display/geometry.js @@ -18,7 +18,7 @@ export class Vector extends Array { } static unitVector(dim, totalDim) { - return Vector.from(Array(totalDim).map((_, idx) => (idx === dim ? 1 : 0))); + return Vector.from(Array(totalDim), (_, idx) => (idx === dim ? 1 : 0)); } get magnitudeSquared() { @@ -71,12 +71,12 @@ export class Rectangle extends Polygon { // Next point is obtained by moving the specified length along each dimension // one at a time, then reversing these movements in the same order. let point = this.startPoint; - for (let dim = 0; dim < dimensions.length; dim++) { + for (let dim = dimensions.length - 1; dim >= 0; dim--) { this.addVertex(point); const increment = Vector.unitVector(dim, dimensions.length); point = point.add(increment); } - for (let dim = 0; dim < dimensions.length; dim++) { + for (let dim = dimensions.length - 1; dim >= 0; dim--) { this.addVertex(point); const increment = Vector.unitVector(dim, dimensions.length); point = point.subtract(increment); diff --git a/src/classes/display/wdg-mermaid-ui.js b/src/classes/display/wdg-mermaid-ui.js new file mode 100644 index 0000000..272ee28 --- /dev/null +++ b/src/classes/display/wdg-mermaid-ui.js @@ -0,0 +1,164 @@ +import { Vertex } from '../supporting/vertex.js'; +import { Edge } from '../supporting/edge.js'; +import { Document } from './document.js'; +import { WeightedDirectedGraph } from '../supporting/wdg.js'; + +const allGraphs = []; + +const makeWDGHandler = (graphIndex) => (vertexId) => { + const graph = allGraphs[graphIndex]; + // We want a document for editing this node, which may be a vertex or an edge + const { editorDoc } = graph; + editorDoc.clear(); + if (vertexId.startsWith('edge:')) { + const [, from, to] = vertexId.split(':'); + Edge.prepareEditorDocument(graph, editorDoc, from, to); + } else { + Vertex.prepareEditorDocument(graph, editorDoc, vertexId); + } +}; + +export class WDGDiagram extends WeightedDirectedGraph { + constructor(name, scene, options = {}) { + super(name, options); + this.scene = scene; + this.flowchart = scene?.flowchart; + this.editable = options.editable; + + // Mermaid supports a click callback, but we can't customize arguments; we just get the vertex ID. + // In order to provide the appropriate graph context for each callback, we create a separate callback + // function for each graph. + this.index = allGraphs.length; + allGraphs.push(this); + window[`WDGHandler${this.index}`] = makeWDGHandler(this.index); + } + + fromJSON(...args) { + super.fromJSON(...args); + this.redraw(); + return this; + } + + addVertex(...args) { + const vertex = super.addVertex(...args); + vertex.displayVertex(); + return vertex; + } + + addEdge(type, from, to, ...rest) { + const existingEdges = this.getEdges(null, from, to); + const edge = super.addEdge(type, from, to, ...rest); + if (existingEdges.length) { + edge.displayEdgeNode(); + } else { + edge.displayEdge(); + } + return edge; + } + + setEdgeWeight(...args) { + const edge = super.setEdgeWeight(...args); + edge.displayEdgeNode(); + return edge; + } + + redraw() { + // Call .reset() on all vertices and edges + for (const vertex of this.vertices.values()) { + vertex.reset(); + } + for (const edges of this.edgeTypes.values()) { + for (const edge of edges.values()) { + edge.reset(); + } + } + + // Clear the target div + this.flowchart?.reset(); + this.flowchart?.init(); + + // Draw all vertices and edges + for (const vertex of this.vertices.values()) { + vertex.displayVertex(); + } + // Let's flatmap and dedupe by [from, to] since each edge + // renders all comorphic edges as well. + const edgesFrom = new Map(); // edgeMap[from][to] = edge + for (const edges of this.edgeTypes.values()) { + for (const edge of edges.values()) { + const edgesTo = edgesFrom.get(edge.from) || new Map(); + edgesTo.set(edge.to, edge); + edgesFrom.set(edge.from, edgesTo); + } + } + + for (const edgesTo of edgesFrom.values()) { + for (const edge of edgesTo.values()) { + edge.displayEdge(); + } + } + + // Ensure rerender + this.flowchart?.render(); + } + + withFlowchart() { + this.scene?.withSectionFlowchart(); + this.flowchart = this.scene?.lastFlowchart; + if (this.editable) { + this.controlDoc = new Document('WDGControl', this.flowchart.box.el, { prepend: true }); + this.editorDoc = new Document('WDGEditor', this.flowchart.box.el); + this.errorDoc = new Document('WDGErrors', this.flowchart.box.el); + this.resetEditor(); + } + return this; + } + + prepareControlDoc() { + const form = this.controlDoc.form({ name: 'controlForm' }).lastElement; + const { subForm: graphPropertiesForm } = form.subForm({ name: 'graphPropsForm' }).lastItem; + graphPropertiesForm.flex() + .textField({ + name: 'name', + label: 'Graph name', + defaultValue: this.name, + cb: (({ form: { value: { name } } }) => { + this.name = name; + }), + }); + const { subForm: exportImportForm } = form.subForm({ name: 'exportImportForm' }).lastItem; + exportImportForm.flex() + .button({ + name: 'Export', + cb: () => { + const a = window.document.createElement('a'); + const json = JSON.stringify(this.toJSON(), null, 2); + const currentTime = Math.floor(new Date().getTime() / 1000); + a.href = `data:attachment/text,${encodeURI(json)}`; + a.target = '_blank'; + a.download = `wdg_${this.name}_${currentTime}.json`; + a.click(); + }, + }) + .fileInput({ + name: 'Import', + cb: ({ input: { files: [file] } }) => { + const reader = new FileReader(); + reader.onload = ({ target: { result: text } }) => { + console.log('imported file', { file }); + // this.flowchart?.log(`%% Imported file ${file}`) + const data = JSON.parse(text); + this.fromJSON(data); + }; + reader.readAsText(file); + }, + }); + } + + resetEditor() { + this.editorDoc.clear(); + this.controlDoc.clear(); + this.prepareControlDoc(); + Vertex.prepareEditorDocument(this, this.editorDoc); + } +} diff --git a/src/classes/supporting/edge.js b/src/classes/supporting/edge.js index 1b9cc64..b571401 100644 --- a/src/classes/supporting/edge.js +++ b/src/classes/supporting/edge.js @@ -37,7 +37,7 @@ export class Edge { return this.graph.getEdges(null, this.from, this.to); } - getHtml() { + getHTML() { const edges = this.getComorphicEdges(); let html = ''; html += ''; @@ -46,25 +46,21 @@ export class Edge { } html += '
'; - return html; - } - - getFlowchartNode() { - return `${Edge.getCombinedKey(this)}("${this.getHtml()}")`; + return `${Edge.getCombinedKey(this)}("${html}")`; } displayEdgeNode() { if (this.options.hide) { return; } - this.graph.flowchart?.log(this.getFlowchartNode()); + this.graph.flowchart?.log(this.getHTML()); } displayEdge() { if (this.options.hide) { return; } - this.graph.flowchart?.log(`${this.from.id} --- ${this.getFlowchartNode()} --> ${this.to.id}`); + this.graph.flowchart?.log(`${this.from.id} --- ${this.getHTML()} --> ${this.to.id}`); this.graph.flowchart?.log(`class ${Edge.getCombinedKey(this)} edge`); if (this.graph.editable && !this.installedClickCallback) { this.graph.flowchart?.log(`click ${Edge.getCombinedKey(this)} WDGHandler${this.graph.index} \ diff --git a/src/classes/supporting/vertex.js b/src/classes/supporting/vertex.js index 92671c0..3ff56a7 100644 --- a/src/classes/supporting/vertex.js +++ b/src/classes/supporting/vertex.js @@ -40,23 +40,19 @@ export class Vertex { ); } - setProperty(key, value) { + async setProperty(key, value) { this.properties.set(key, value); + await this.onUpdate?.(); return this; } - displayVertex() { - if (this.options.hide) { - return; - } - + getHTML() { let html = ''; if (this.type) { - html += `${this.type}`; + html += `${this.type}
`; } html += `${this.label || this.id}`; html += ''; - console.log('displayVertex', { properties: this.properties }); for (const [key, value] of this.properties.entries()) { const displayValue = typeof value === 'number' ? displayNumber(value) : value; html += ``; @@ -66,7 +62,15 @@ export class Vertex { html += `${this.id}
`; } html = html.replaceAll(/\n\s*/g, ''); - this.graph.flowchart?.log(`${this.id}["${html}"]`); + return html; + } + + displayVertex() { + if (this.options.hide) { + return; + } + + this.graph.flowchart?.log(`${this.id}["${this.getHTML()}"]`); if (this.graph.editable && !this.installedClickCallback) { this.graph.flowchart?.log(`click ${this.id} WDGHandler${this.graph.index} "Edit Vertex ${this.id}"`); diff --git a/src/classes/supporting/wdg.js b/src/classes/supporting/wdg.js index 8636c3c..7f4f1c1 100644 --- a/src/classes/supporting/wdg.js +++ b/src/classes/supporting/wdg.js @@ -1,38 +1,12 @@ import { Vertex } from './vertex.js'; import { Edge } from './edge.js'; -import { Document } from '../display/document.js'; - -const allGraphs = []; - -const makeWDGHandler = (graphIndex) => (vertexId) => { - const graph = allGraphs[graphIndex]; - // We want a document for editing this node, which may be a vertex or an edge - const { editorDoc } = graph; - editorDoc.clear(); - if (vertexId.startsWith('edge:')) { - const [, from, to] = vertexId.split(':'); - Edge.prepareEditorDocument(graph, editorDoc, from, to); - } else { - Vertex.prepareEditorDocument(graph, editorDoc, vertexId); - } -}; export class WeightedDirectedGraph { - constructor(name, scene, options = {}) { + constructor(name) { this.name = name; - this.scene = scene; this.vertices = new Map(); this.edgeTypes = new Map(); this.nextVertexId = 0; - this.flowchart = scene?.flowchart; - this.editable = options.editable; - - // Mermaid supports a click callback, but we can't customize arguments; we just get the vertex ID. - // In order to provide the appropriate graph context for each callback, we create a separate callback - // function for each graph. - this.index = allGraphs.length; - allGraphs.push(this); - window[`WDGHandler${this.index}`] = makeWDGHandler(this.index); // TODO: Populate history this.history = []; @@ -72,109 +46,9 @@ export class WeightedDirectedGraph { } of edges) { this.addEdge(type, from, to, weight); } - this.redraw(); - } - - redraw() { - // Call .reset() on all vertices and edges - for (const vertex of this.vertices.values()) { - vertex.reset(); - } - for (const edges of this.edgeTypes.values()) { - for (const edge of edges.values()) { - edge.reset(); - } - } - - // Clear the target div - this.flowchart?.reset(); - this.flowchart?.init(); - - // Draw all vertices and edges - for (const vertex of this.vertices.values()) { - vertex.displayVertex(); - } - // Let's flatmap and dedupe by [from, to] since each edge - // renders all comorphic edges as well. - const edgesFrom = new Map(); // edgeMap[from][to] = edge - for (const edges of this.edgeTypes.values()) { - for (const edge of edges.values()) { - const edgesTo = edgesFrom.get(edge.from) || new Map(); - edgesTo.set(edge.to, edge); - edgesFrom.set(edge.from, edgesTo); - } - } - - for (const edgesTo of edgesFrom.values()) { - for (const edge of edgesTo.values()) { - edge.displayEdge(); - } - } - - // Ensure rerender - this.flowchart?.render(); - } - - withFlowchart() { - this.scene?.withSectionFlowchart(); - this.flowchart = this.scene?.lastFlowchart; - if (this.editable) { - this.controlDoc = new Document('WDGControl', this.flowchart.box.el, { prepend: true }); - this.editorDoc = new Document('WDGEditor', this.flowchart.box.el); - this.errorDoc = new Document('WDGErrors', this.flowchart.box.el); - this.resetEditor(); - } return this; } - prepareControlDoc() { - const form = this.controlDoc.form({ name: 'controlForm' }).lastElement; - const { subForm: graphPropertiesForm } = form.subForm({ name: 'graphPropsForm' }).lastItem; - graphPropertiesForm.flex() - .textField({ - name: 'name', - label: 'Graph name', - defaultValue: this.name, - cb: (({ form: { value: { name } } }) => { - this.name = name; - }), - }); - const { subForm: exportImportForm } = form.subForm({ name: 'exportImportForm' }).lastItem; - exportImportForm.flex() - .button({ - name: 'Export', - cb: () => { - const a = window.document.createElement('a'); - const json = JSON.stringify(this.toJSON(), null, 2); - const currentTime = Math.floor(new Date().getTime() / 1000); - a.href = `data:attachment/text,${encodeURI(json)}`; - a.target = '_blank'; - a.download = `wdg_${this.name}_${currentTime}.json`; - a.click(); - }, - }) - .fileInput({ - name: 'Import', - cb: ({ input: { files: [file] } }) => { - const reader = new FileReader(); - reader.onload = ({ target: { result: text } }) => { - console.log('imported file', { file }); - // this.flowchart?.log(`%% Imported file ${file}`) - const data = JSON.parse(text); - this.fromJSON(data); - }; - reader.readAsText(file); - }, - }); - } - - resetEditor() { - this.editorDoc.clear(); - this.controlDoc.clear(); - this.prepareControlDoc(); - Vertex.prepareEditorDocument(this, this.editorDoc); - } - addVertex(type, id, data, label, options) { // Supports simple case of auto-incremented numeric ids if (typeof id === 'object') { @@ -187,7 +61,6 @@ export class WeightedDirectedGraph { } const vertex = new Vertex(this, type, id, data, { ...options, label }); this.vertices.set(id, vertex); - vertex.displayVertex(); return vertex; } @@ -230,7 +103,6 @@ export class WeightedDirectedGraph { } const edgeKey = Edge.getKey(edge); edges.set(edgeKey, edge); - edge.displayEdgeNode(); return edge; } @@ -240,15 +112,9 @@ export class WeightedDirectedGraph { if (this.getEdge(type, from, to)) { throw new Error(`Edge ${type} from ${from.id} to ${to.id} already exists`); } - const existingEdges = this.getEdges(null, from, to); const edge = this.setEdgeWeight(type, from, to, weight, data, options); from.edges.from.push(edge); to.edges.to.push(edge); - if (existingEdges.length) { - edge.displayEdgeNode(); - } else { - edge.displayEdge(); - } return edge; } diff --git a/src/index.css b/src/index.css index 72c4aaf..70d0d6c 100644 --- a/src/index.css +++ b/src/index.css @@ -112,4 +112,20 @@ span.small { position: absolute; white-space: nowrap; width: 1px; +} +.force-directed-graph { + border: 1px white dotted; + margin: 20px; +} +.force-directed-graph > canvas { + position: absolute; + border: 1px red dashed; +} +.force-directed-graph > .vertex { + border: 1px #46b4b4 solid; + text-align: center; +} +.force-directed-graph > .edge { + border: 1px #51b769 solid; + text-align: center; } \ No newline at end of file diff --git a/src/tests/scripts/force-directed.test.js b/src/tests/scripts/force-directed.test.js index 7754d4d..0ba009f 100644 --- a/src/tests/scripts/force-directed.test.js +++ b/src/tests/scripts/force-directed.test.js @@ -10,15 +10,17 @@ const rootElement = document.getElementById('scene'); const rootBox = new Box('rootBox', rootElement).flex(); window.scene = new Scene('WDG test', rootBox); -describe('Weighted Directed Graph', function tests() { +describe('Force-Directed Graph', function tests() { this.timeout(0); let graph; before(() => { - graph = (window.graph = new ForceDirectedGraph('test1', window.scene.middleSection.el)); + graph = (window.graph = new ForceDirectedGraph('test1', window.scene.middleSection.el, { + width: 800, height: 600, + })); - graph.addBox('box1').setInnerHTML('Box 1'); + graph.addVertex('v1', 'box1'); }); it('rectangle should be a polygon with 4 vertices', () => { @@ -35,7 +37,7 @@ describe('Weighted Directed Graph', function tests() { rect1.center.should.eql([0.5, 0.5]); rect2.center.should.eql([0.5, 1]); const force1 = ForceDirectedGraph.pairwiseForce(rect1, rect2, 10); - force1.should.eql([0, 100]); + force1.should.eql([0, 200]); }); it('boxes at target radius should have no net force', () => { @@ -48,6 +50,11 @@ describe('Weighted Directed Graph', function tests() { force[1].should.be.within(-EPSILON, EPSILON); }); + it('can construct a unit vector', () => { + Vector.unitVector(0, 2).should.eql([1, 0]); + Vector.unitVector(1, 2).should.eql([0, 1]); + }); + it('normalized vector should have length = 1', () => { const v = Vector.from([2, 0]); const u = v.normalize(); @@ -56,17 +63,20 @@ describe('Weighted Directed Graph', function tests() { it('random unit vector should have length = 1', () => { const u = Vector.randomUnitVector(2); - console.log('unit vector:', u); u.magnitude.should.be.within(1 - EPSILON, 1 + EPSILON); }); it('can add a second box to the graph', async () => { await delayOrWait(1000); - graph.addBox('box2').setInnerHTML('Box 2'); - for (let i = 1; i < 50; i++) { - await delayOrWait(100); - graph.computeEulerFrame(0.1); - } + const v = graph.addVertex('v1', 'box2'); + v.setProperty('prop', 'value'); + await graph.runUntilEquilibrium(); + }); + + it('can add an edge to the graph', async () => { + await delayOrWait(1000); + graph.addEdge('e1', 'box1', 'box2', 1); + await graph.runUntilEquilibrium(); }); }); diff --git a/src/tests/scripts/wdg.test.js b/src/tests/scripts/wdg.test.js index 64520eb..e10e30a 100644 --- a/src/tests/scripts/wdg.test.js +++ b/src/tests/scripts/wdg.test.js @@ -1,5 +1,6 @@ import { Box } from '../../classes/display/box.js'; import { Scene } from '../../classes/display/scene.js'; +import { WDGDiagram } from '../../classes/display/wdg-mermaid-ui.js'; import { WeightedDirectedGraph } from '../../classes/supporting/wdg.js'; import { mochaRun } from '../../util/helpers.js'; @@ -13,7 +14,7 @@ describe('Weighted Directed Graph', function tests() { let graph; before(() => { - graph = (window.graph = new WeightedDirectedGraph('test1', window.scene)).withFlowchart(); + graph = (window.graph = new WDGDiagram('test1', window.scene)).withFlowchart(); graph.addVertex('v1', {}); graph.addVertex('v1', {}); @@ -150,20 +151,4 @@ describe('Weighted Directed Graph', function tests() { }); }); -describe('Editable', () => { - let graph; - - it('should be editable', () => { - graph = (window.graph2 = new WeightedDirectedGraph('test2', window.scene, { editable: true })).withFlowchart(); - - graph.addVertex('v1', {}); - graph.addVertex('v2', {}); - graph.addVertex('v3', {}); - - graph.addEdge('e1', 2, 1, 1); - graph.addEdge('e2', 1, 0, 0.5); - graph.addEdge('e3', 2, 0, 0.25); - }); -}); - mochaRun(); diff --git a/src/util/constants.js b/src/util/constants.js index a3303c7..d8b1aac 100644 --- a/src/util/constants.js +++ b/src/util/constants.js @@ -7,11 +7,14 @@ export const VertexTypes = { POST: 'post', AUTHOR: 'author', }; +export const DEFAULT_OVERLAP_FORCE = 200; export const DEFAULT_REP_TOKEN_TYPE_ID = 0; -export const DEFAULT_TARGET_RADIUS = 100; +export const DEFAULT_TARGET_RADIUS = 300; +export const DEFAULT_TIME_STEP = 0.1; +export const DISTANCE_FACTOR = 0.25; export const EPSILON = 2.23e-16; export const INCINERATOR_ADDRESS = '0'; -export const OVERLAP_FORCE = 100; -export const DISTANCE_FACTOR = 0.25; -export const MINIMUM_FORCE = 2; +export const MINIMUM_FORCE = 1; +export const MINIMUM_VELOCITY = 0.1; +export const TIME_DILATION_FACTOR = 500; export const VISCOSITY_FACTOR = 0.4; diff --git a/src/wdg-editor/index.js b/src/wdg-editor/index.js index 004f9dc..a2bd53c 100644 --- a/src/wdg-editor/index.js +++ b/src/wdg-editor/index.js @@ -1,9 +1,9 @@ import { Box } from '../classes/display/box.js'; import { Scene } from '../classes/display/scene.js'; -import { WeightedDirectedGraph } from '../classes/supporting/wdg.js'; +import { WDGDiagram } from '../classes/display/wdg-mermaid-ui.js'; const rootElement = document.getElementById('scene'); const rootBox = new Box('rootBox', rootElement).flex(); window.disableSceneControls = true; window.scene = new Scene('WDG Editor', rootBox); -window.graph = new WeightedDirectedGraph('new', window.scene, { editable: true }).withFlowchart(); +window.graph = new WDGDiagram('new', window.scene, { editable: true }).withFlowchart();
${key}${displayValue}