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/flowchart.js b/forum-network/src/classes/display/flowchart.js index 120b5c3..058e928 100644 --- a/forum-network/src/classes/display/flowchart.js +++ b/forum-network/src/classes/display/flowchart.js @@ -3,7 +3,11 @@ import { MermaidDiagram } from './mermaid.js'; export class Flowchart extends MermaidDiagram { constructor(box, logBox, direction = 'BT') { super(box, logBox); + this.direction = direction; + this.init(); + } - this.log(`graph ${direction}`, false); + init() { + this.log(`graph ${this.direction}`, false); } } diff --git a/forum-network/src/classes/display/form.js b/forum-network/src/classes/display/form.js index a818f11..9ad297b 100644 --- a/forum-network/src/classes/display/form.js +++ b/forum-network/src/classes/display/form.js @@ -4,27 +4,43 @@ 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); + cb(this, { initializing: true }); } } } -export class Button extends FormElement { } +export class Button extends FormElement { + 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); } @@ -36,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..a11a588 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,15 +53,24 @@ 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; } }, 100); } + + reset() { + this.logBoxPre.textContent = ''; + } } diff --git a/forum-network/src/classes/display/scene.js b/forum-network/src/classes/display/scene.js index 3b14ec6..cc40e96 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(); @@ -60,8 +61,7 @@ export class Scene { const container = this.middleSection.addBox(name).flex(); const box = container.addBox('Flowchart').addClass('padded'); const logBox = container.addBox('Flowchart text').addClass('dim'); - const flowchart = new MermaidDiagram(box, logBox); - flowchart.log(`graph ${direction}`, false); + const flowchart = new Flowchart(box, logBox, direction); this.flowcharts.set(id, flowchart); return this; } @@ -105,6 +105,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..bc15b81 --- /dev/null +++ b/forum-network/src/classes/supporting/edge.js @@ -0,0 +1,123 @@ +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; + this.installedClickCallback = false; + } + + reset() { + this.installedClickCallback = false; + } + + 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.installedClickCallback) { + this.graph.flowchart?.log(`click ${Edge.getCombinedKey(this)} WDGHandler${this.graph.index} "Edit Edge"`); + this.installedClickCallback = true; + } + } + + 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; + // Handle additions and updates + for (const { type, weight } of value.edges) { + graph.setEdgeWeight(type, from, to, weight); + } + // Handle removals + for (const edge of graph.getEdges(null, from, to)) { + if (!value.edges.find(({ type }) => type === edge.type)) { + graph.deleteEdge(edge.type, from, to); + } + } + graph.redraw(); + }, + }); + } +} diff --git a/forum-network/src/classes/supporting/vertex.js b/forum-network/src/classes/supporting/vertex.js new file mode 100644 index 0000000..160126c --- /dev/null +++ b/forum-network/src/classes/supporting/vertex.js @@ -0,0 +1,86 @@ +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; + } + + reset() { + 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/wdag.js deleted file mode 100644 index c28d314..0000000 --- a/forum-network/src/classes/supporting/wdag.js +++ /dev/null @@ -1,175 +0,0 @@ -const getEdgeKey = ({ from, to }) => btoa([from.id, to.id]).replaceAll(/[^A-Za-z0-9]+/g, ''); - -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: [], - }; - } - - 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) { - this.scene = scene; - this.vertices = new Map(); - this.edgeTypes = new Map(); - this.nextVertexId = 0; - this.flowchart = scene?.flowchart; - } - - withFlowchart() { - this.scene?.withAdditionalFlowchart(); - this.flowchart = this.scene?.lastFlowchart(); - return this; - } - - addVertex(type, id, data, label, options) { - // Support simple case of auto-incremented numeric ids - if (typeof id === 'object') { - data = id; - id = this.nextVertexId++; - } - if (this.vertices.has(id)) { - throw new Error(`Vertex already exists with id: ${id}`); - } - const vertex = new Vertex(this, type, id, data, options); - this.vertices.set(id, vertex); - vertex.setDisplayLabel(label ?? id); - return vertex; - } - - getVertex(id) { - return this.vertices.get(id); - } - - getVertexData(id) { - return this.getVertex(id)?.data; - } - - getVerticesData() { - return Array.from(this.vertices.values()).map(({ data }) => data); - } - - getEdge(type, from, to) { - from = from instanceof Vertex ? from : this.getVertex(from); - to = to instanceof Vertex ? to : this.getVertex(to); - if (!from || !to) { - return undefined; - } - const edges = this.edgeTypes.get(type); - const edgeKey = getEdgeKey({ from, to }); - return edges?.get(edgeKey); - } - - getEdgeWeight(type, from, to) { - return this.getEdge(type, from, to)?.weight; - } - - setEdgeWeight(type, from, to, weight, data, options) { - from = from instanceof Vertex ? from : this.getVertex(from); - to = to instanceof Vertex ? to : this.getVertex(to); - const edge = new Edge(this, type, from, to, weight, data, options); - let edges = this.edgeTypes.get(type); - if (!edges) { - edges = new Map(); - this.edgeTypes.set(type, edges); - } - const edgeKey = getEdgeKey(edge); - edges.set(edgeKey, edge); - edge.displayEdgeNode(); - return edge; - } - - addEdge(type, from, to, weight, data, options) { - from = from instanceof Vertex ? from : this.getVertex(from); - to = to instanceof Vertex ? to : this.getVertex(to); - if (this.getEdge(type, from, to)) { - throw new Error(`Edge ${type} from ${from.id} to ${to.id} already exists`); - } - const edge = this.setEdgeWeight(type, from, to, weight, data, options); - from.edges.from.push(edge); - to.edges.to.push(edge); - edge.displayEdge(); - return edge; - } - - getEdges(type, from, to) { - from = from instanceof Vertex ? from : this.getVertex(from); - to = to instanceof Vertex ? to : this.getVertex(to); - const edgeTypes = type ? [type] : Array.from(this.edgeTypes.keys()); - return edgeTypes.flatMap((edgeType) => { - const edges = this.edgeTypes.get(edgeType); - return Array.from(edges?.values() || []).filter((edge) => { - const matchFrom = from === null || from === undefined || from === edge.from; - const matchTo = to === null || to === undefined || to === edge.to; - return matchFrom && matchTo; - }); - }); - } - - countVertices(type) { - if (!type) { - return this.vertices.size; - } - return Array.from(this.vertices.values()).filter((vertex) => vertex.type === type).length; - } -} diff --git a/forum-network/src/classes/supporting/wdg.js b/forum-network/src/classes/supporting/wdg.js new file mode 100644 index 0000000..41f2ad6 --- /dev/null +++ b/forum-network/src/classes/supporting/wdg.js @@ -0,0 +1,209 @@ +import { Vertex } from './vertex.js'; +import { Edge } from './edge.js'; + +const graphs = []; + +const makeWDGHandler = (graphIndex) => (vertexId) => { + const graph = graphs[graphIndex]; + // 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); + } +}; + +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.index); + this.history = {}; + } + + getHistory() { + // record operations that modify the graph + return this.history; + } + + toJSON() { + return { + vertices: Array.from(this.vertices.values()), + edgeTypes: Array.from(this.edgeTypes.keys()), + edges: Array.from(this.edgeTypes.values()).flatMap((edges) => Array.from(edges.values())), + history: this.getHistory(), + }; + } + + 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(); + } + } + + // Rerender + this.flowchart?.render(); + } + + withFlowchart() { + this.scene?.withAdditionalFlowchart(); + this.flowchart = this.scene?.lastFlowchart(); + return this; + } + + addVertex(type, id, data, label, options) { + // 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}`); + } + const vertex = new Vertex(this, type, id, data, options); + this.vertices.set(id, vertex); + vertex.setDisplayLabel(label ?? id); + return vertex; + } + + getVertex(id) { + id = (typeof id === 'number') ? id.toString(10) : id; + return this.vertices.get(id); + } + + getVertexData(id) { + return this.getVertex(id)?.data; + } + + getVerticesData() { + return Array.from(this.vertices.values()).map(({ data }) => data); + } + + getEdge(type, from, to) { + from = from instanceof Vertex ? from : this.getVertex(from); + to = to instanceof Vertex ? to : this.getVertex(to); + if (!from || !to) { + return undefined; + } + const edges = this.edgeTypes.get(type); + 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); + if (edges.size === 0) { + this.edgeTypes.delete(type); + } + } + + getEdgeWeight(type, from, to) { + return this.getEdge(type, from, to)?.weight; + } + + setEdgeWeight(type, from, to, weight, data, options) { + from = from instanceof Vertex ? from : this.getVertex(from); + to = to instanceof Vertex ? to : this.getVertex(to); + const edge = new Edge(this, type, from, to, weight, data, options); + let edges = this.edgeTypes.get(type); + if (!edges) { + edges = new Map(); + this.edgeTypes.set(type, edges); + } + const edgeKey = Edge.getKey(edge); + edges.set(edgeKey, edge); + edge.displayEdgeNode(); + return edge; + } + + addEdge(type, from, to, weight, data, options) { + from = from instanceof Vertex ? from : this.getVertex(from); + to = to instanceof Vertex ? to : this.getVertex(to); + const existingEdges = this.getEdges(type, from, to); + if (this.getEdge(type, from, to)) { + throw new Error(`Edge ${type} from ${from.id} to ${to.id} already exists`); + } + 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; + } + + getEdges(type, from, to) { + from = from instanceof Vertex ? from : this.getVertex(from); + to = to instanceof Vertex ? to : this.getVertex(to); + const edgeTypes = type ? [type] : Array.from(this.edgeTypes.keys()); + return edgeTypes.flatMap((edgeType) => { + const edges = this.edgeTypes.get(edgeType); + return Array.from(edges?.values() || []).filter((edge) => { + const matchFrom = from === null || from === undefined || from === edge.from; + const matchTo = to === null || to === undefined || to === edge.to; + return matchFrom && matchTo; + }); + }); + } + + countVertices(type) { + if (!type) { + return this.vertices.size; + } + return Array.from(this.vertices.values()).filter((vertex) => vertex.type === type).length; + } +} 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 f93c8b4..8b95bbf 100644 --- a/forum-network/src/index.html +++ b/forum-network/src/index.html @@ -55,11 +55,12 @@