From 77d6698899cf3c0c15c7ad3578151073f3f2ea47 Mon Sep 17 00:00:00 2001 From: Ladd Hoffman Date: Mon, 3 Jul 2023 17:03:16 -0500 Subject: [PATCH] Basic graph editing Also renamed WDAG -> WDG --- forum-network/src/classes/dao/forum.js | 4 +- forum-network/src/classes/display/document.js | 18 ++- forum-network/src/classes/display/form.js | 88 +++++++++++-- forum-network/src/classes/display/mermaid.js | 11 +- forum-network/src/classes/display/scene.js | 5 + forum-network/src/classes/supporting/edge.js | 119 ++++++++++++++++++ .../src/classes/supporting/vertex.js | 82 ++++++++++++ .../classes/supporting/{wdag.js => wdg.js} | 114 ++++++----------- forum-network/src/index.css | 4 + forum-network/src/index.html | 2 +- forum-network/src/tests/all.test.html | 2 +- forum-network/src/tests/scripts/input.test.js | 2 +- .../scripts/{wdag.test.js => wdg.test.js} | 34 +++-- .../tests/{wdag.test.html => wdg.test.html} | 4 +- 14 files changed, 382 insertions(+), 107 deletions(-) create mode 100644 forum-network/src/classes/supporting/edge.js create mode 100644 forum-network/src/classes/supporting/vertex.js rename forum-network/src/classes/supporting/{wdag.js => wdg.js} (62%) rename forum-network/src/tests/scripts/{wdag.test.js => wdg.test.js} (59%) rename forum-network/src/tests/{wdag.test.html => wdg.test.html} (88%) diff --git a/forum-network/src/classes/dao/forum.js b/forum-network/src/classes/dao/forum.js index 2ac0d5b..e65dee2 100644 --- a/forum-network/src/classes/dao/forum.js +++ b/forum-network/src/classes/dao/forum.js @@ -1,4 +1,4 @@ -import { WDAG } from '../supporting/wdag.js'; +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'; @@ -51,7 +51,7 @@ export class Forum extends ReputationHolder { super(name, scene); this.dao = dao; this.id = this.reputationPublicKey; - this.graph = new WDAG(scene); + this.graph = new WeightedDirectedGraph(scene); this.actions = { propagate: new Action('propagate', scene), confirm: new Action('confirm', scene), diff --git a/forum-network/src/classes/display/document.js b/forum-network/src/classes/display/document.js index 69545be..2726e18 100644 --- a/forum-network/src/classes/display/document.js +++ b/forum-network/src/classes/display/document.js @@ -2,8 +2,8 @@ import { Box } from './box.js'; import { Form } from './form.js'; export class Remark extends Box { - constructor(doc, text, opts) { - super('Remark', doc.el, opts); + constructor(doc, text, opts = {}) { + super('Remark', opts.parentEl ?? doc.el, opts); this.setInnerHTML(text); } } @@ -16,8 +16,10 @@ export class Remark extends Box { * ``` */ export class Document extends Box { - form() { - return this.addElement(new Form(this)); + elements = []; + + form(opts) { + return this.addElement(new Form(this, opts)); } remark(text, opts) { @@ -25,13 +27,17 @@ export class Document extends Box { } addElement(element) { - this.elements = this.elements ?? []; this.elements.push(element); return this; } + clear() { + this.el.innerHTML = ''; + this.elements = []; + } + get lastElement() { - if (!this.elements?.length) return null; + if (!this.elements.length) return null; return this.elements[this.elements.length - 1]; } } diff --git a/forum-network/src/classes/display/form.js b/forum-network/src/classes/display/form.js index 3f04d6c..9ad297b 100644 --- a/forum-network/src/classes/display/form.js +++ b/forum-network/src/classes/display/form.js @@ -4,13 +4,16 @@ import { Box } from './box.js'; const updateValuesOnEventTypes = ['keyup', 'mouseup']; export class FormElement extends Box { - constructor(name, parentEl, opts) { + constructor(name, form, opts) { + const parentEl = opts.parentEl ?? form.el; super(name, parentEl, opts); + this.form = form; this.id = opts.id ?? name; + this.includeInOutput = opts.includeInOutput ?? true; const { cb } = opts; if (cb) { updateValuesOnEventTypes.forEach((eventType) => this.el.addEventListener(eventType, () => { - cb(this); + cb(this, { initializing: false }); })); cb(this, { initializing: true }); } @@ -18,21 +21,26 @@ export class FormElement extends Box { } export class Button extends FormElement { - constructor(name, parentEl, opts) { - super(name, parentEl, opts); + constructor(name, form, opts) { + super(name, form, opts); this.button = document.createElement('button'); this.button.setAttribute('type', 'button'); this.button.innerHTML = name; + this.button.disabled = !!opts.disabled; this.el.appendChild(this.button); } } export class TextField extends FormElement { - constructor(name, parentEl, opts) { - super(name, parentEl, opts); + constructor(name, form, opts) { + super(name, form, opts); this.label = document.createElement('label'); - this.label.innerHTML = name; + this.labelDiv = document.createElement('div'); + this.label.appendChild(this.labelDiv); + this.labelDiv.innerHTML = name; this.input = document.createElement('input'); + this.input.disabled = !!opts.disabled; + this.input.defaultValue = opts.defaultValue || ''; this.label.appendChild(this.input); this.el.appendChild(this.label); } @@ -44,25 +52,83 @@ export class TextField extends FormElement { export class TextArea extends FormElement { } -export class Form { +export class SubFormArray extends FormElement { + constructor(name, form, opts) { + super(name, form, opts); + this.subForms = []; + } + + get value() { + return this.subForms.map((subForm) => subForm.value); + } + + remove(subForm) { + const idx = this.subForms.findIndex((s) => s === subForm); + this.subForms.splice(idx, 1); + subForm.el.remove(); + } +} + +export class SubForm extends FormElement { + constructor(name, form, opts) { + const parentEl = opts.subFormArray ? opts.subFormArray.el : form.el; + const subForm = form.document.form({ name, parentEl }).lastElement; + super(name, form, { ...opts, parentEl }); + this.subForm = subForm; + if (opts.subFormArray) { + opts.subFormArray.subForms.push(this); + this.includeInOutput = false; + } + } + + get value() { + return this.subForm.value; + } +} + +export class Form extends Box { constructor(document, opts = {}) { + super(opts.name, opts.parentEl || document.el, opts); this.document = document; this.items = []; this.id = opts.id ?? `form_${randomID()}`; } button(opts) { - this.items.push(new Button(opts.name, this.document.el, opts)); + this.items.push(new Button(opts.name, this, opts)); return this; } textField(opts) { - this.items.push(new TextField(opts.name, this.document.el, opts)); + this.items.push(new TextField(opts.name, this, opts)); return this; } textArea(opts) { - this.items.push(new TextArea(opts.name, this.document.el, opts)); + this.items.push(new TextArea(opts.name, this, opts)); return this; } + + subForm(opts) { + this.items.push(new SubForm(opts.name, this, opts)); + return this; + } + + subFormArray(opts) { + this.items.push(new SubFormArray(opts.name, this, opts)); + return this; + } + + get lastItem() { + return this.items[this.items.length - 1]; + } + + get value() { + return this.items.reduce((result, { id, value, includeInOutput }) => { + if (includeInOutput && value !== undefined) { + result[id] = value; + } + return result; + }, {}); + } } diff --git a/forum-network/src/classes/display/mermaid.js b/forum-network/src/classes/display/mermaid.js index 72f0171..201bba1 100644 --- a/forum-network/src/classes/display/mermaid.js +++ b/forum-network/src/classes/display/mermaid.js @@ -29,6 +29,8 @@ export class MermaidDiagram { activationBkgColor: '#1d3f49', activationBorderColor: '#569595', }, + securityLevel: 'loose', // 'loose' so that we can use click events + // logLevel: 'debug', }); } @@ -51,11 +53,16 @@ export class MermaidDiagram { return debounce(async () => { const text = this.getText(); try { - const graph = await mermaid.mermaidAPI.render( + await mermaid.mermaidAPI.render( this.element.getId(), text, + (svgCode, bindFunctions) => { + this.renderBox.setInnerHTML(svgCode); + if (bindFunctions) { + bindFunctions(this.renderBox.el); + } + }, ); - this.renderBox.setInnerHTML(graph); } catch (e) { console.error(`render text:\n${text}`); throw e; diff --git a/forum-network/src/classes/display/scene.js b/forum-network/src/classes/display/scene.js index 3b14ec6..e6a0130 100644 --- a/forum-network/src/classes/display/scene.js +++ b/forum-network/src/classes/display/scene.js @@ -21,6 +21,7 @@ export class Scene { this.actors = new Set(); this.dateStart = new Date(); this.flowcharts = new Map(); + this.documents = []; MermaidDiagram.initializeAPI(); @@ -105,6 +106,10 @@ export class Scene { return this.documents[this.documents.length - 1]; } + getDocument(name) { + return this.documents.find((doc) => doc.name === name); + } + registerActor(actor) { this.actors.add(actor); if (actor.options.announce) { diff --git a/forum-network/src/classes/supporting/edge.js b/forum-network/src/classes/supporting/edge.js new file mode 100644 index 0000000..ba82aa2 --- /dev/null +++ b/forum-network/src/classes/supporting/edge.js @@ -0,0 +1,119 @@ +export class Edge { + constructor(graph, type, from, to, weight, data, options = {}) { + this.graph = graph; + this.from = from; + this.to = to; + this.type = type; + this.weight = weight; + this.data = data; + this.options = options; + } + + static getKey({ + from, to, type, + }) { + return ['edge', from.id, to.id, type].join(':'); + } + + static getCombinedKey({ from, to }) { + return ['edge', from.id, to.id].join(':'); + } + + getComorphicEdges() { + return this.graph.getEdges(null, this.from, this.to); + } + + getHtml() { + let html = ''; + for (const { type, weight } of this.getComorphicEdges()) { + html += ``; + } + html += '
${type}${weight}
'; + return html; + } + + getFlowchartNode() { + return `${Edge.getCombinedKey(this)}(${this.getHtml()})`; + } + + displayEdgeNode() { + if (this.options.hide) { + return; + } + this.graph.flowchart?.log(this.getFlowchartNode()); + } + + displayEdge() { + if (this.options.hide) { + return; + } + this.graph.flowchart?.log(`${this.from.id} --- ${this.getFlowchartNode()} --> ${this.to.id}`); + this.graph.flowchart?.log(`class ${Edge.getCombinedKey(this)} edge`); + if (this.graph.editable) { + this.graph.flowchart?.log(`click ${Edge.getCombinedKey(this)} WDGHandler${this.graph.index} "Edit Edge"`); + } + } + + static prepareEditorDocument(graph, doc, from, to) { + doc.clear(); + const form = doc.form({ name: 'editorForm' }).lastElement; + doc.remark('

Edit Edge

', { parentEl: form.el }); + form + .textField({ + id: 'from', name: 'from', defaultValue: from, disabled: true, + }) + .textField({ + id: 'to', name: 'to', defaultValue: to, disabled: true, + }); + + const subFormArray = form.subFormArray({ id: 'edges', name: 'edges' }).lastItem; + const addEdgeForm = (edge) => { + const { subForm } = form.subForm({ name: 'subform', subFormArray }).lastItem; + doc.remark('
', { parentEl: subForm.el }); + subForm.textField({ id: 'type', name: 'type', defaultValue: edge.type }); + subForm.textField({ id: 'weight', name: 'weight', defaultValue: edge.weight }); + subForm.button({ + id: 'remove', + name: 'Remove Edge Type', + cb: (_, { initializing }) => { + if (initializing) return; + subFormArray.remove(subForm); + }, + }); + }; + + for (const edge of graph.getEdges(null, from, to)) { + addEdgeForm(edge); + } + + form.button({ + id: 'add', + name: 'Add Edge Type', + cb: (_, { initializing }) => { + if (initializing) return; + addEdgeForm(new Edge(graph, null, graph.getVertex(from), graph.getVertex(to))); + }, + }); + + form.button({ + id: 'save', + name: 'Save', + cb: ({ form: { value } }, { initializing }) => { + if (initializing) return; + console.log('graph before save', { ...graph }); + // Handle additions and updates + for (const { type, weight } of value.edges) { + graph.setEdgeWeight(type, from, to, weight); + } + // Handle removals + for (const edgeType of graph.edgeTypes.values()) { + if (!value.edges.find((edge) => edge.type === edgeType)) { + graph.deleteEdge(edgeType, from, to); + } + } + console.log('graph after save', { ...graph }); + // TODO: Redraw graph + }, + }); + } +} diff --git a/forum-network/src/classes/supporting/vertex.js b/forum-network/src/classes/supporting/vertex.js new file mode 100644 index 0000000..0b86e9b --- /dev/null +++ b/forum-network/src/classes/supporting/vertex.js @@ -0,0 +1,82 @@ +export class Vertex { + constructor(graph, type, id, data, options = {}) { + this.graph = graph; + this.type = type; + this._id = id; + this.data = data; + this.options = options; + this.edges = { + from: [], + to: [], + }; + this.installedClickCallback = false; + } + + set id(newId) { + this._id = newId; + } + + get id() { + return this._id; + } + + getEdges(type, away) { + return this.edges[away ? 'from' : 'to'].filter( + (edge) => edge.type === type, + ); + } + + setDisplayLabel(label) { + this.label = label; + this.displayVertex(); + } + + displayVertex() { + if (this.options.hide) { + return; + } + this.graph.flowchart?.log(`${this.id}[${this.label}]`); + if (this.graph.editable && !this.installedClickCallback) { + this.graph.flowchart?.log(`click ${this.id} WDGHandler${this.graph.index} "Edit Vertex"`); + this.installedClickCallback = true; + } + } + + static prepareEditorDocument(graph, doc, vertexId) { + doc.clear(); + const vertex = graph.getVertex(vertexId); + if (!vertex) { + throw new Error(`Could not find WDG Vertex ${vertexId}`); + } + doc.remark('

Edit Vertex

'); + const form = doc.form().lastElement; + form + .textField({ + id: 'id', name: 'id', defaultValue: vertex.id, disabled: true, + }) + .textField({ id: 'type', name: 'type', defaultValue: vertex.type }) + .textField({ id: 'label', name: 'label', defaultValue: vertex.label }) + + .button({ + id: 'save', + name: 'Save', + cb: ({ form: { value } }, { initializing }) => { + if (initializing) return; + if (value.id && value.id !== vertex.id) { + // TODO: When an ID changes we really need to wipe out and redraw! + // But we don't yet have a systematic approach for doing that. + // Everything is getting rendered as needed. Lacking abstraction. + // HMM we're not actually that far! Just wipe everything out and draw each vertex and edge :) + // for (const vertex of ) + // for (const edge of [...vertex.edges.to, ...vertex.edges.from]) { + // edge.displayEdge(); + // } + } + Object.assign(vertex, value); + vertex.displayVertex(); + }, + }); + + return doc; + } +} diff --git a/forum-network/src/classes/supporting/wdag.js b/forum-network/src/classes/supporting/wdg.js similarity index 62% rename from forum-network/src/classes/supporting/wdag.js rename to forum-network/src/classes/supporting/wdg.js index 15b6446..fe0984b 100644 --- a/forum-network/src/classes/supporting/wdag.js +++ b/forum-network/src/classes/supporting/wdg.js @@ -1,79 +1,34 @@ -const getEdgeKey = ({ from, to }) => btoa([from.id, to.id]).replaceAll(/[^A-Za-z0-9]+/g, ''); +import { Vertex } from './vertex.js'; +import { Edge } from './edge.js'; -export class Vertex { - constructor(graph, type, id, data, options = {}) { - this.graph = graph; - this.type = type; - this.id = id; - this.data = data; - this.options = options; - this.edges = { - from: [], - to: [], - }; +const graphs = []; + +const makeWDGHandler = (graph) => (vertexId) => { + // We want a document for editing this node, which may be a vertex or an edge + const editorDoc = graph.scene.getDocument('editorDocument') + ?? graph.scene.withDocument('editorDocument').lastDocument; + if (vertexId.startsWith('edge:')) { + const [, from, to] = vertexId.split(':'); + Edge.prepareEditorDocument(graph, editorDoc, from, to); + } else { + Vertex.prepareEditorDocument(graph, editorDoc, vertexId); } +}; - getEdges(type, away) { - return this.edges[away ? 'from' : 'to'].filter( - (edge) => edge.type === type, - ); - } - - setDisplayLabel(label) { - if (this.options.hide) { - return; - } - this.graph.flowchart?.log(`${this.id}[${label}]`); - } -} - -export class Edge { - constructor(graph, type, from, to, weight, data, options = {}) { - this.graph = graph; - this.from = from; - this.to = to; - this.type = type; - this.weight = weight; - this.data = data; - this.options = options; - } - - getHtml() { - let html = ''; - for (const { type, weight } of this.graph.getEdges(null, this.from, this.to)) { - html += ``; - } - html += '
${type}${weight}
'; - return html; - } - - getFlowchartNode() { - return `${getEdgeKey(this)}(${this.getHtml()})`; - } - - displayEdgeNode() { - if (this.options.hide) { - return; - } - this.graph.flowchart?.log(this.getFlowchartNode()); - } - - displayEdge() { - if (this.options.hide) { - return; - } - this.graph.flowchart?.log(`${this.from.id} --- ${this.getFlowchartNode()} --> ${this.to.id}`); - this.graph.flowchart?.log(`class ${getEdgeKey(this)} edge`); - } -} - -export class WDAG { - constructor(scene) { +export class WeightedDirectedGraph { + constructor(scene, options = {}) { this.scene = scene; this.vertices = new Map(); this.edgeTypes = new Map(); this.nextVertexId = 0; this.flowchart = scene?.flowchart; + this.editable = options.editable; + this.index = graphs.length; + graphs.push(this); + // 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. + window[`WDGHandler${this.index}`] = makeWDGHandler(this); } withFlowchart() { @@ -83,11 +38,12 @@ export class WDAG { } addVertex(type, id, data, label, options) { - // Support simple case of auto-incremented numeric ids + // Supports simple case of auto-incremented numeric ids if (typeof id === 'object') { data = id; id = this.nextVertexId++; } + id = (typeof id === 'number') ? id.toString(10) : id; if (this.vertices.has(id)) { throw new Error(`Vertex already exists with id: ${id}`); } @@ -98,6 +54,7 @@ export class WDAG { } getVertex(id) { + id = (typeof id === 'number') ? id.toString(10) : id; return this.vertices.get(id); } @@ -116,10 +73,23 @@ export class WDAG { return undefined; } const edges = this.edgeTypes.get(type); - const edgeKey = getEdgeKey({ from, to }); + const edgeKey = Edge.getKey({ from, to, type }); return edges?.get(edgeKey); } + deleteEdge(type, from, to) { + from = from instanceof Vertex ? from : this.getVertex(from); + to = to instanceof Vertex ? to : this.getVertex(to); + const edges = this.edgeTypes.get(type); + const edgeKey = Edge.getKey({ type, from, to }); + if (!edges) return; + const edge = edges.get(edgeKey); + if (!edge) return; + to.edges.from.forEach((x, i) => (x === edge) && to.edges.from.splice(i, 1)); + from.edges.to.forEach((x, i) => (x === edge) && from.edges.to.splice(i, 1)); + edges.delete(edgeKey); + } + getEdgeWeight(type, from, to) { return this.getEdge(type, from, to)?.weight; } @@ -133,7 +103,7 @@ export class WDAG { edges = new Map(); this.edgeTypes.set(type, edges); } - const edgeKey = getEdgeKey(edge); + const edgeKey = Edge.getKey(edge); edges.set(edgeKey, edge); edge.displayEdgeNode(); return edge; @@ -172,6 +142,4 @@ export class WDAG { } return Array.from(this.vertices.values()).filter((vertex) => vertex.type === type).length; } - - // TODO: Add support for inputs to add/edit vertices and edges } diff --git a/forum-network/src/index.css b/forum-network/src/index.css index 2826340..2db2c1b 100644 --- a/forum-network/src/index.css +++ b/forum-network/src/index.css @@ -79,3 +79,7 @@ label { font-size: smaller; color: #999999; } +label > div { + display: inline-block; + min-width: 50px; +} diff --git a/forum-network/src/index.html b/forum-network/src/index.html index 1c198fb..8b95bbf 100644 --- a/forum-network/src/index.html +++ b/forum-network/src/index.html @@ -55,7 +55,7 @@