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 += `${type} | ${weight} |
`;
+ }
+ html += '
';
+ 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 += `${type} | ${weight} |
`;
- }
- html += '
';
- 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 @@
diff --git a/forum-network/src/tests/all.test.html b/forum-network/src/tests/all.test.html
index 12ca110..b28655c 100644
--- a/forum-network/src/tests/all.test.html
+++ b/forum-network/src/tests/all.test.html
@@ -25,7 +25,7 @@
-
+
@@ -38,6 +38,7 @@
+
+
+
+
+
diff --git a/forum-network/src/tests/scripts/document.test.js b/forum-network/src/tests/scripts/document.test.js
new file mode 100644
index 0000000..c09fcfb
--- /dev/null
+++ b/forum-network/src/tests/scripts/document.test.js
@@ -0,0 +1,23 @@
+import { Box } from '../../classes/display/box.js';
+// import { Document } from '../../classes/display/document.js';
+import { Scene } from '../../classes/display/scene.js';
+import { mochaRun } from '../../util/helpers.js';
+
+const rootElement = document.getElementById('scene');
+const rootBox = new Box('rootBox', rootElement).flex();
+const scene = window.scene = new Scene('Document test', rootBox);
+
+scene.withDocument();
+
+describe('Document', () => {
+ describe('remark', () => {
+ it('can exist', () => {
+ const docFunction = (doc) => doc.remark('Hello');
+ scene.withDocument('Document', docFunction);
+ });
+ it.skip('can include handlebars expressions', () => { });
+ it.skip('updates rendered output when input changes', () => { });
+ });
+});
+
+mochaRun();
diff --git a/forum-network/src/tests/scripts/input.test.js b/forum-network/src/tests/scripts/input.test.js
index c9bab13..b3bf2ee 100644
--- a/forum-network/src/tests/scripts/input.test.js
+++ b/forum-network/src/tests/scripts/input.test.js
@@ -9,26 +9,55 @@ const scene = window.scene = new Scene('Input test', rootBox);
scene.withDocument();
-describe('Document', () => {
- it('Exists', () => {
- scene.withDocument('Document', (doc) => doc.remark('Hello'));
+describe('Document > Form > TextField', () => {
+ before(() => {
+ scene.withDocument('Document 1', (d) => d.form());
});
+ it('can accept input and call value update callback', () => {
+ const doc = scene.lastDocument;
+ const form = doc.lastElement;
+ /**
+ * Handler callback for form element value updates.
+ * In this case we use a collection of DisplayValues as a straightforward way to render the form element values.
+ */
+ const dvMap = new Map();
+ const updateFieldValueDisplay = ({ id, name, value }) => {
+ const dv = dvMap.get(id) ?? scene.addDisplayValue(name);
+ dvMap.set(id, dv);
+ dv.set(value);
+ };
- describe('Input', () => {
- it('Accepts input', () => {
- scene.withDocument('Document', (doc) => doc.form());
- const doc = scene.lastDocument;
- const form1 = doc.lastElement;
- const dvMap = new Map();
- const updateFieldValueDisplay = ({ name, value }) => {
- const dv = dvMap.get(name) ?? scene.addDisplayValue(name);
- dvMap.set(name, dv);
- dv.set(value);
- };
+ form.textField({ id: 'input1', name: 'Input 1', cb: updateFieldValueDisplay });
+ doc.remark('Hmm...!');
+ });
+ // it('can exist within a graph', () => {
+ // scene.withAdditionalFlowchart({ id: 'flowchart', name: 'Graph' });
+ // const graph = scene.lastFlowchart();
+ // });
+});
- form1.textField({ id: 'input1', name: 'Input 1', cb: updateFieldValueDisplay });
- doc.remark('Hmm...!');
- });
+describe('Document > Form > Button', () => {
+ before(() => {
+ scene.withDocument('Document 2', (d) => d.form());
+ });
+ it('calls a callback when clicked', () => {
+ const doc = scene.lastDocument;
+ const form = doc.lastElement;
+ const dvMap = new Map();
+ let clicks = 0;
+ const handleClick = ({ id, name }, { initializing }) => {
+ const dv = dvMap.get(id) ?? scene.addDisplayValue(name);
+ dvMap.set(id, dv);
+ if (!initializing) {
+ clicks++;
+ dv.set(`clicked ${clicks} time${clicks !== 1 ? 's' : ''}`);
+ }
+ };
+
+ doc.remark('
');
+ doc.remark('Button:');
+ form.button({ id: 'button1', name: 'Button 1', cb: handleClick });
+ doc.remark('Yeah?');
});
});
diff --git a/forum-network/src/tests/scripts/wdag.test.js b/forum-network/src/tests/scripts/wdg.test.js
similarity index 59%
rename from forum-network/src/tests/scripts/wdag.test.js
rename to forum-network/src/tests/scripts/wdg.test.js
index dfc15a3..44f8c6d 100644
--- a/forum-network/src/tests/scripts/wdag.test.js
+++ b/forum-network/src/tests/scripts/wdg.test.js
@@ -1,19 +1,19 @@
import { Box } from '../../classes/display/box.js';
import { Scene } from '../../classes/display/scene.js';
-import { WDAG } from '../../classes/supporting/wdag.js';
+import { WeightedDirectedGraph } from '../../classes/supporting/wdg.js';
import { mochaRun } from '../../util/helpers.js';
const rootElement = document.getElementById('scene');
const rootBox = new Box('rootBox', rootElement).flex();
-window.scene = new Scene('WDAG test', rootBox);
+window.scene = new Scene('WDG test', rootBox);
-describe('Query the graph', function tests() {
+describe('Weighted Directed Acyclic Graph', function tests() {
this.timeout(0);
let graph;
before(() => {
- graph = (window.graph = new WDAG()).withFlowchart();
+ graph = (window.graph = new WeightedDirectedGraph(window.scene)).withFlowchart();
graph.addVertex('v1', {});
graph.addVertex('v1', {});
@@ -34,17 +34,35 @@ describe('Query the graph', function tests() {
it('can query for all e1 edges from a particular vertex', () => {
const edges = graph.getEdges('e1', 2);
- edges.map(({ from, to, weight }) => [from.id, to.id, weight]).should.have.deep.members([[2, 1, 0.5]]);
+ edges.map(({ from, to, weight }) => [from.id, to.id, weight]).should.have.deep.members([[
+ '2', '1', 0.5,
+ ]]);
});
it('can query for all e1 edges to a particular vertex', () => {
const edges = graph.getEdges('e1', null, 1);
edges.map(({ from, to, weight }) => [from.id, to.id, weight]).should.have.deep.members([
- [0, 1, 1],
- [2, 1, 0.5],
- [3, 1, 0.25],
+ ['0', '1', 1],
+ ['2', '1', 0.5],
+ ['3', '1', 0.25],
]);
});
});
+describe('editable', () => {
+ let graph;
+
+ it('should be editable', () => {
+ graph = (window.graph2 = new WeightedDirectedGraph(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/forum-network/src/tests/wdag.test.html b/forum-network/src/tests/wdg.test.html
similarity index 88%
rename from forum-network/src/tests/wdag.test.html
rename to forum-network/src/tests/wdg.test.html
index 49eece4..bc53e80 100644
--- a/forum-network/src/tests/wdag.test.html
+++ b/forum-network/src/tests/wdg.test.html
@@ -1,7 +1,7 @@
- WDAG test
+ Weighted Directed Graph test
@@ -24,4 +24,4 @@
});
chai.should();
-
+