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 += '
${key} | ${displayValue} |