From 5824f516b067d60cad2e89cfd601234cbf5afcc9 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 3 Jul 2019 01:42:17 +0000 Subject: [PATCH] Split into file-per-class --- Architype.js | 561 +++++++++++++++++ Collection.js | 54 ++ Editor.js | 122 ++++ EditorEntryBase.js | 73 +++ EditorGroup.js | 148 +++++ EditorLink.js | 154 +++++ EditorNode.js | 188 ++++++ List.js | 175 ++++++ ListenUtils.js | 17 + architype.js | 1494 -------------------------------------------- index.html | 2 +- utils.js | 8 + 12 files changed, 1501 insertions(+), 1495 deletions(-) create mode 100644 Architype.js create mode 100644 Collection.js create mode 100644 Editor.js create mode 100644 EditorEntryBase.js create mode 100644 EditorGroup.js create mode 100644 EditorLink.js create mode 100644 EditorNode.js create mode 100644 List.js create mode 100644 ListenUtils.js delete mode 100644 architype.js create mode 100644 utils.js diff --git a/Architype.js b/Architype.js new file mode 100644 index 0000000..fc24fc2 --- /dev/null +++ b/Architype.js @@ -0,0 +1,561 @@ +'use strict'; + +class Architype { + constructor(container) { + this.container_ = container; + + this.container_.classList.add('architype'); + // TODO: make theme selectable + this.container_.classList.add('dark'); + + this.container_.addEventListener('keydown', (e) => { this.onKeyDown(e); }); + addEventListener('resize', (e) => { this.onResize(e); }); + + let editorElem = document.createElement('ul'); + this.container_.appendChild(editorElem); + this.editor_ = new Editor(editorElem); + + this.targets_ = document.createElement('datalist'); + this.targets_.id = 'arch-targets'; + this.container_.appendChild(this.targets_); + + this.lines_ = document.createElement('div'); + this.lines_.innerHTML = ``; + this.lines_ = this.lines_.firstElementChild; + this.lines_.classList.add('gridLines'); + this.lines_.classList.add('white'); + + this.grid_ = document.createElement('div'); + this.grid_.classList.add('grid'); + this.container_.appendChild(this.grid_); + + this.unserialize(JSON.parse(localStorage.getItem('currentState'))); + + this.observeStructure_ = new MutationObserver(e => { this.onStructureChange(e); }); + this.observeStructure_.observe(editorElem, { + attributes: true, + attributeFilter: ['data-struct-change'], + childList: true, + subtree: true, + }); + + // TODO: handle case when value change alters link graph + this.observeContent_ = new MutationObserver(e => { this.onContentChange(e); }); + this.observeContent_.observe(editorElem, { + attributes: true, + attributeFilter: ['data-arch-value'], + subtree: true, + }); + + this.onStructureChange(); + } + + serialize() { + return { + version: 1, + editor: this.editor_.serialize(), + }; + } + + unserialize(ser) { + if (!ser) { + return; + } + + switch (ser.version) { + case 1: + this.editor_.unserialize(ser.editor); + break; + + default: + console.log('unrecognized localStorage.currentState version', ser); + break; + } + } + + onStructureChange(e) { + this.graph_ = this.buildGraph(); + this.onContentChange(e); + } + + onContentChange(e) { + localStorage.setItem('currentState', JSON.stringify(this.serialize())); + this.buildGrid(this.graph_); + this.updateTargets(this.graph_); + this.fixSizes(this.graph_.nodes); + } + + onKeyDown(e) { + switch (e.key) { + case 'z': + this.exportGraphviz(); + break; + } + } + + onResize(e) { + this.fixSizes(this.graph_.nodes); + } + + exportGraphviz() { + let lines = [ + 'digraph G {', + '\trankdir = "LR";', + ]; + + for (let type of ['nodes', 'links', 'groups']) { + for (let obj of this.graph_[type]) { + for (let line of obj.exportGraphviz()) { + lines.push('\t' + line); + } + } + } + + lines.push('}'); + navigator.clipboard.writeText(lines.join('\n')); + } + + updateTargets(graph) { + // Lots of effort to avoid churning the datalist + + let curTargets = new Map(); + for (let option of this.targets_.options) { + curTargets.set(option.value, option); + } + + for (let [label, entries] of graph.nodesByLabel.entries()) { + if (curTargets.has(label)) { + continue; + } + if (entries.length == 1 && + document.activeElement.parentElement.xArchObj && + document.activeElement.parentElement.xArchObj == entries[0]) { + // Skip an element currently being edited + continue; + } + let option = document.createElement('option'); + option.value = label; + this.targets_.appendChild(option); + } + + for (let [label, option] of curTargets.entries()) { + if (graph.nodesByLabel.has(label)) { + continue; + } + option.remove(); + } + } + + buildGraph() { + let graph = { + nodesByLabel: new Map(), + nodesByPageRank: new Map(), + nodesByPos: new Map(), + nodesBySubgraph: new Map(), + groups: [], + links: [], + nodes: [], + }; + // Order here is important, as each step carefully builds on data + // constructed by the previous + this.buildGraphInt(graph, this.editor_.getEntries()); + this.trimSoftNodes(graph); + this.processLinks(graph); + this.processGroups(graph); + this.manifestNodes(graph); + this.setPageRank(graph); + this.bucketByPageRank(graph); + this.bucketBySubgraph(graph); + this.setInitialPositions(graph); + this.setAffinity(graph); + + while (this.iterate(graph)); + this.fixOrigin(graph); + + return graph; + } + + buildGraphInt(graph, entries) { + for (let entry of entries) { + if (entry instanceof EditorNode) { + this.buildGraphNode(graph, entry); + } else if (entry instanceof EditorGroup) { + this.buildGraphGroup(graph, entry); + } else if (entry instanceof EditorLink) { + this.buildGraphLink(graph, entry); + } + } + } + + buildGraphNode(graph, node) { + node.clear(); + if (node.getLabel() == '') { + return; + } + let targets = graph.nodesByLabel.get(node.getLabel()); + if (!targets) { + targets = []; + graph.nodesByLabel.set(node.getLabel(), targets); + } + targets.push(node); + } + + buildGraphGroup(graph, group) { + group.clear(); + graph.groups.push(group); + this.buildGraphInt(graph, group.getNodes()); + } + + buildGraphLink(graph, link) { + link.clear(); + graph.links.push(link); + this.buildGraphInt(graph, [link.getFrom(), link.getTo()]); + } + + trimSoftNodes(graph) { + for (let entries of graph.nodesByLabel.values()) { + for (let i = entries.length - 1; i >= 0 && entries.length > 1; --i) { + if (entries[i] instanceof EditorNode && entries[i].isSoft()) { + entries.splice(i, 1); + } + } + } + } + + processLinks(graph) { + for (let link of graph.links) { + // Re-resolve each from/to reference by label, so we skip soft nodes and + // handle multiple objects with the same label + link.from = graph.nodesByLabel.get(link.getFrom().getLabel()) || []; + link.to = graph.nodesByLabel.get(link.getTo().getLabel()) || []; + for (let from of link.from) { + for (let to of link.to) { + from.links.push(to); + } + } + } + } + + processGroups(graph) { + for (let group of graph.groups) { + group.nodes = []; + for (let member of group.getNodes()) { + let nodes = graph.nodesByLabel.get(member.getLabel()) || []; + for (let node of nodes) { + group.nodes.push(node); + node.groups.push(group); + } + } + } + } + + manifestNodes(graph) { + for (let entries of graph.nodesByLabel.values()) { + for (let entry of entries) { + if (entry instanceof EditorNode) { + graph.nodes.push(entry); + } + } + } + } + + setPageRank(graph) { + for (let link of graph.links) { + for (let to of link.to) { + this.setPageRankInt(to, new Set()); + } + } + for (let group of graph.groups) { + // All members of a group get the rank of the maximum member, so the + // initial positions will put them all near each other + let maxRank = 0; + for (let member of group.nodes) { + maxRank = Math.max(maxRank, member.pageRank); + } + for (let member of group.nodes) { + member.pageRank = maxRank; + } + } + } + + setPageRankInt(node, visited) { + if (visited.has(node)) { + // Loop detection + return; + } + ++node.pageRank; + visited.add(node); + for (let out of node.links) { + this.setPageRankInt(out, visited); + } + visited.delete(node); + } + + bucketByPageRank(graph) { + for (let node of graph.nodes) { + let bucket = graph.nodesByPageRank.get(node.pageRank); + if (!bucket) { + bucket = []; + graph.nodesByPageRank.set(node.pageRank, bucket); + } + bucket.push(node); + } + let cmp = (a, b) => { + if (a < b) { + return -1; + } else if (a > b) { + return 1; + } else { + return 0; + } + }; + for (let bucket of graph.nodesByPageRank.values()) { + bucket.sort(cmp); + } + } + + bucketBySubgraph(graph) { + let nodes = new Set(); + let ranks = Array.from(graph.nodesByPageRank.keys()); + ranks.sort((a, b) => a - b); + for (let rank of ranks) { + for (let node of graph.nodesByPageRank.get(rank)) { + nodes.add(node); + } + } + for (let subgraph = 0; nodes.size; ++subgraph) { + let node = nodes.values().next().value; + let subgraphArr = []; + graph.nodesBySubgraph.set(subgraph, subgraphArr); + this.recurseSubgraph(subgraph, subgraphArr, node, nodes); + } + } + + recurseSubgraph(subgraph, subgraphArr, node, nodes) { + if (node.subgraph !== null) { + return; + } + node.subgraph = subgraph; + subgraphArr.push(node); + nodes.delete(node); + for (let to of node.links) { + this.recurseSubgraph(subgraph, subgraphArr, to, nodes); + } + for (let group of node.groups) { + for (let member of group.nodes) { + this.recurseSubgraph(subgraph, subgraphArr, member, nodes); + } + } + } + + setInitialPositions(graph) { + const SPACING = 4; + + let maxRankNodes = 0; + for (let nodes of graph.nodesByPageRank.values()) { + maxRankNodes = Math.max(maxRankNodes, nodes.length); + } + + let ranks = Array.from(graph.nodesByPageRank.keys()); + ranks.sort((a, b) => a - b); + for (let r = 0; r < ranks.length; ++r) { + let nodes = graph.nodesByPageRank.get(ranks[r]); + for (let n = 0; n < nodes.length; ++n) { + let node = nodes[n]; + let pos = [ + r * SPACING, + Math.floor((nodes.length / 2) * SPACING) + (n * SPACING) + + (node.subgraph * SPACING * maxRankNodes), + ]; + node.moveTo(graph, pos); + } + } + } + + fixOrigin(graph) { + let min = [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]; + let max = [Number.MIN_SAFE_INTEGER, Number.MIN_SAFE_INTEGER]; + for (let node of graph.nodes) { + for (let i of [0, 1]) { + min[i] = Math.min(min[i], node.pos[i]); + max[i] = Math.max(max[i], node.pos[i]); + } + } + // Offset is negative minimum, e.g min -1 means +1 to all values + for (let node of graph.nodes) { + for (let i of [0, 1]) { + node.pos[i] -= min[i]; + } + } + graph.size = [ + max[0] - min[0] + 1, + max[1] - min[1] + 1, + ]; + } + + setAffinity(graph) { + const INF = 999999; + for (let node of graph.nodes) { + for (let other of graph.nodes) { + // Weak affinity full mesh + // Keep unassociated subgroups together + this.addAffinity(node, other, d => d); + + if (node.subgraph != other.subgraph) { + this.addAffinity(node, other, d => d < 1.5 ? -INF : 0); + } + } + for (let to of node.links) { + // Stronger affinity for links + // Prefer to move toward the target instance + this.addAffinity(node, to, d => d < 1.5 ? -INF : d * 11); + this.addAffinity(to, node, d => d < 1.5 ? -INF : d * 9); + } + for (let group of node.groups) { + for (let member of group.nodes) { + // Even stronger affinity for groups + // Other nodes will reference this one and take care of the full + // group mesh + this.addAffinity(node, member, d => d * 100); + } + let members = new Set(group.nodes); + for (let other of graph.nodes) { + if (members.has(other)) { + continue; + } + // Nodes not in this group run away + this.addAffinity(other, node, d => d < 1.5 ? -INF : 0); + } + } + } + } + + addAffinity(node, other, func) { + if (node == other) { + return; + } + node.affinity.push({ + node: other, + distanceToWeight: func, + }); + } + + buildGrid(graph) { + this.grid_.innerHTML = ''; + + this.grid_.style.gridTemplateColumns = + 'repeat(' + graph.size[0] + ',1fr)'; + this.grid_.style.gridTemplateRows = + 'repeat(' + graph.size[1] + + ',minmax(0, calc((100vw - var(--editor-width)) / ' + + graph.size[0] + ')))'; + + this.drawGridNodes(graph); + } + + addLines(pos, cls) { + let lines = this.lines_.cloneNode(true); + lines.classList.add(cls); + lines.style.gridColumn = pos[0] + 1; + lines.style.gridRow = pos[1] + 1; + this.grid_.appendChild(lines); + return lines; + } + + iterate(graph) { + let nodes = Array.from(graph.nodes); + this.sortByMostTension(nodes); + for (let group of graph.groups) { + nodes.push(group.getCollection()); + } + for (let subgraph of graph.nodesBySubgraph.values()) { + nodes.push(new Collection(subgraph)); + } + + let newOffset = null; + let newTension = this.getTotalTension(nodes); + for (let node of nodes) { + let origPos = node.pos; + let offsets = new Map(); + let addOffset = (x, y) => { + if (!offsets.has([x, y].toString())) { + offsets.set([x, y].toString(), [x, y]); + } + }; + for (let dir of [-1, 0, 1]) { + addOffset(Math.sign(node.vec[0]), dir); + addOffset(dir, Math.sign(node.vec[1])); + } + for (let offset of offsets.values()) { + if (node.offsetCollides(graph, offset)) { + continue; + } + node.savePos(); + node.moveBy(graph, offset); + let testTension = this.getTotalTension(nodes); + node.restorePos(graph); + if (testTension < newTension) { + newOffset = offset; + newTension = testTension; + } + } + if (newOffset) { + node.moveBy(graph, newOffset); + return true; + } + } + return false; + } + + getTotalTension(nodes) { + let total = 0; + for (let node of nodes) { + node.setTension(); + total += node.tension; + } + return total; + } + + sortByMostTension(nodes) { + for (let node of nodes) { + node.setTension(); + } + nodes.sort((a, b) => b.tension - a.tension); + } + + drawGridNodes(graph) { + for (let node of graph.nodes) { + node.gridElem = document.createElement('div'); + node.gridElem.classList.add('gridNode'); + this.grid_.appendChild(node.gridElem); + node.gridElem.innerText = node.getLabel(); + node.gridElem.style.gridColumn = node.pos[0] + 1; + node.gridElem.style.gridRow = node.pos[1] + 1; + } + } + + fixSizes(nodes) { + for (let node of nodes) { + let elem = node.gridElem; + elem.style.fontSize = null; + for (let size = 20; + size && (elem.scrollWidth > elem.clientWidth || + elem.scrollHeight > elem.clientHeight); + --size) { + elem.style.fontSize = size + 'px'; + } + } + } +} + + + + + + + + + + + +new Architype(document.getElementById('architype')); diff --git a/Collection.js b/Collection.js new file mode 100644 index 0000000..540ab15 --- /dev/null +++ b/Collection.js @@ -0,0 +1,54 @@ +class Collection { + constructor(nodes) { + this.nodes = nodes; + } + + setTension() { + this.vec = [0, 0]; + this.tension = 0; + for (let node of this.nodes) { + node.setTension(); + for (let i of [0, 1]) { + this.vec[i] += node.vec[i]; + }; + this.tension += node.tension; + } + } + + offsetCollides(graph, offset) { + // TODO: make this.nodes always a set + let nodeSet = new Set(this.nodes); + for (let node of this.nodes) { + let other = node.offsetCollides(graph, offset); + if (other && !nodeSet.has(other)) { + return other; + } + } + return null; + } + + savePos() { + for (let node of this.nodes) { + node.savePos(); + } + } + + restorePos(graph) { + for (let node of this.nodes) { + node.restorePos(graph); + } + } + + moveBy(graph, offset) { + let nodes = new Set(this.nodes); + while (nodes.size) { + for (let node of nodes) { + if (node.offsetCollides(graph, offset)) { + continue; + } + node.moveBy(graph, offset); + nodes.delete(node); + } + } + } +} diff --git a/Editor.js b/Editor.js new file mode 100644 index 0000000..f8179c9 --- /dev/null +++ b/Editor.js @@ -0,0 +1,122 @@ + + +class Editor extends List { + static NODE = 1; + static GROUP = 2; + static LINK = 3; + + constructor(container, allowedTypes) { + super(container); + + this.allowedTypes_ = new Set(allowedTypes || + [Editor.NODE, Editor.GROUP, Editor.LINK]); + + this.container_.classList.add('editor'); + // Needs to accept focus to receive keydown, but shouldn't be in the normal + // tab flow. + this.container_.tabIndex = 99999; + this.container_.addEventListener('keydown', e => { this.onKeyDown(e); }); + this.container_.focus(); + } + + clear() { + this.container_.innerHTML = ''; + } + + serialize() { + // Doesn't have a type, only used as part of other objects + let ret = []; + for (let entry of this.getEntries()) { + ret.push(entry.serialize()); + } + return ret; + } + + unserialize(ser) { + for (let entry of ser) { + this.container_.appendChild(EditorEntryBase.unserialize(entry)); + } + } + + isAllowed(type) { + return this.mayAdd() && this.allowedTypes_.has(type); + } + + addNodeAfter() { + if (this.isAllowed(Editor.NODE)) { + EditorNode.addAfter(this.container_, this.getSelected()); + } + } + + addNodeBefore() { + if (this.isAllowed(Editor.NODE)) { + EditorNode.addBefore(this.container_, this.getSelected()); + } + } + + addLinkAfter() { + if (this.isAllowed(Editor.LINK)) { + EditorLink.addAfter(this.container_, this.getSelected()); + } + } + + addLinkBefore() { + if (this.isAllowed(Editor.LINK)) { + EditorLink.addBefore(this.container_, this.getSelected()); + } + } + + addGroupAfter() { + if (this.isAllowed(Editor.GROUP)) { + EditorGroup.addAfter(this.container_, this.getSelected()); + } + } + + addGroupBefore() { + if (this.isAllowed(Editor.GROUP)) { + EditorGroup.addBefore(this.container_, this.getSelected()); + } + } + + onKeyDown(e) { + switch (e.key) { + case 'g': + this.addGroupAfter(); + e.stopPropagation(); + e.preventDefault(); + return; + + case 'G': + this.addGroupBefore(); + e.stopPropagation(); + e.preventDefault(); + return; + + case 'i': + this.addLinkAfter(); + e.stopPropagation(); + e.preventDefault(); + return; + + case 'I': + this.addLinkBefore(); + e.stopPropagation(); + e.preventDefault(); + return; + + case 'n': + this.addNodeAfter(); + e.stopPropagation(); + e.preventDefault(); + return; + + case 'N': + this.addNodeBefore(); + e.stopPropagation(); + e.preventDefault(); + return; + } + + super.onKeyDown(e); + } +} diff --git a/EditorEntryBase.js b/EditorEntryBase.js new file mode 100644 index 0000000..7cab616 --- /dev/null +++ b/EditorEntryBase.js @@ -0,0 +1,73 @@ + + +class EditorEntryBase extends ListenUtils { + constructor() { + super(); + + this.id = randStr64(); + + this.elem_ = document.createElement('li'); + this.elem_.tabIndex = 0; + this.listen(this.elem_, 'focus', () => this.onElemFocus()); + this.listen(this.elem_, 'keydown', (e) => this.onKeyDown(e)); + + this.elem_.xArchObj = this; + } + + remove() { + if (document.activeElement == this.elem_ || + document.activeElement == document.body) { + if (this.elem_.nextElementSibling) { + this.elem_.nextElementSibling.focus(); + } else if (this.elem_.previousElementSibling) { + this.elem_.previousElementSibling.focus(); + } else if (this.elem_.parentElement) { + this.elem_.parentElement.focus(); + } + } + + this.elem_.remove(); + this.clearListeners(); + this.elem_.xArchObj = null; + } + + clear() { + } + + wantFocus() { + return false; + } + + onElemFocus() { + this.elem_.scrollIntoView({block: 'nearest'}); + } + + onKeyDown(e) { + } + + afterDomAdd() { + } + + static addBefore(container, elem) { + let entry = new this(); + container.insertBefore(entry.getElement(), elem); + entry.afterDomAdd(); + } + + static addAfter(container, elem) { + let entry = new this(); + container.insertBefore(entry.getElement(), elem ? elem.nextSibling : null); + entry.afterDomAdd(); + } + + static unserialize(ser) { + switch (ser.type) { + case 'group': + return EditorGroup.unserialize(ser); + case 'link': + return EditorLink.unserialize(ser); + case 'node': + return EditorNode.unserialize(ser); + } + } +} diff --git a/EditorGroup.js b/EditorGroup.js new file mode 100644 index 0000000..f77ef5e --- /dev/null +++ b/EditorGroup.js @@ -0,0 +1,148 @@ +class EditorGroup extends EditorEntryBase { + constructor() { + super(); + + this.elem_.innerText = 'Group:'; + this.elem_.classList.add('group'); + + this.input_ = document.createElement('input'); + this.input_.type = 'text'; + this.input_.placeholder = 'group name'; + this.listen(this.input_, 'keydown', (e) => this.onInputKeyDown(e)); + this.listen(this.input_, 'input', (e) => this.onInput()); + this.listen(this.input_, 'blur', (e) => this.onInput()); + this.elem_.appendChild(this.input_); + + let nodeList = document.createElement('div'); + this.nodes_ = new Editor(nodeList, [Editor.NODE]); + this.nodes_.setMinEntries(1); + this.nodes_.addNodeAfter(); + this.elem_.appendChild(nodeList); + } + + afterDomAdd() { + this.input_.focus(); + } + + serialize() { + return { + type: 'group', + label: this.getLabel(), + members: this.nodes_.serialize(), + }; + } + + exportGraphviz() { + let lines = [ + 'subgraph "cluster_' + this.id + '" {', + ]; + + if (this.getLabel() != '') { + lines.push('\tlabel = "' + this.getLabel() + '";'); + } + + for (let obj of this.nodes) { + for (let line of obj.exportGraphviz()) { + lines.push('\t' + line); + } + } + + lines.push('}'); + return lines; + } + + clear() { + super.clear(); + } + + getNodes() { + return this.nodes_.getEntries(); + } + + getLabel() { + return this.input_.value; + } + + setLabel(label) { + this.input_.value = label; + this.onInput(); + } + + getElement() { + return this.elem_; + } + + getCollection() { + return new Collection(this.nodes); + } + + onInputKeyDown(e) { + switch (e.key) { + case 'Enter': + e.stopPropagation(); + e.preventDefault(); + this.stopEdit(); + { + let nodes = this.nodes_.getEntries(); + if (nodes.length == 1 && nodes[0].getLabel() == '') { + nodes[0].startEdit(); + } + } + break; + + case 'Escape': + e.stopPropagation(); + e.preventDefault(); + this.stopEdit(); + break; + + case 'ArrowUp': + case 'ArrowDown': + case 'PageUp': + case 'PageDown': + this.stopEdit(); + break; + + default: + e.stopPropagation(); + break; + } + } + + onKeyDown(e) { + super.onKeyDown(e); + + switch (e.key) { + case 'Enter': + this.startEdit(); + e.stopPropagation(); + e.preventDefault(); + break; + + case 'ArrowRight': + case 'l': + this.nodes_.selectNext(); + break; + } + } + + onInput() { + this.input_.setAttribute('data-arch-value', this.input_.value); + } + + startEdit() { + this.input_.focus(); + } + + stopEdit() { + this.elem_.focus(); + } + + static unserialize(ser) { + let group = new EditorGroup(); + group.setLabel(ser.label); + group.nodes_.clear(); + group.nodes_.unserialize(ser.members); + return group.getElement(); + } +} diff --git a/EditorLink.js b/EditorLink.js new file mode 100644 index 0000000..3cc09ec --- /dev/null +++ b/EditorLink.js @@ -0,0 +1,154 @@ +class EditorLink extends EditorEntryBase { + constructor() { + super(); + + this.elem_.innerText = 'Link:'; + this.elem_.classList.add('link'); + + this.input_ = document.createElement('input'); + this.input_.type = 'text'; + this.input_.placeholder = 'label'; + this.listen(this.input_, 'keydown', (e) => this.onInputKeyDown(e)); + this.listen(this.input_, 'input', (e) => this.onInput()); + this.listen(this.input_, 'blur', (e) => this.onInput()); + this.elem_.appendChild(this.input_); + + let nodeList = document.createElement('div'); + this.nodes_ = new Editor(nodeList, [Editor.NODE]); + this.nodes_.setMinEntries(2); + this.nodes_.setMaxEntries(2); + this.nodes_.addNodeAfter(); + this.nodes_.addNodeAfter(); + this.elem_.appendChild(nodeList); + } + + afterDomAdd() { + this.input_.focus(); + } + + serialize() { + return { + type: 'link', + label: this.getLabel(), + from: this.getFrom().serialize(), + to: this.getTo().serialize(), + }; + } + + exportGraphviz() { + if (this.getFrom().getLabel() == '' || this.getTo().getLabel() == '') { + return []; + } + + let label = ''; + if (this.getLabel() != '') { + label = ' [label="' + this.getLabel() + '"]'; + } + + let ret = []; + for (let from of this.from) { + for (let to of this.to) { + ret.push('"' + from.id + '" -> "' + to.id + '"' + label + ';'); + } + } + + return ret; + }; + + clear() { + super.clear(); + } + + getFrom() { + return this.nodes_.getEntries()[0]; + } + + getTo() { + return this.nodes_.getEntries()[1]; + } + + getLabel() { + return this.input_.value; + } + + setLabel(label) { + this.input_.value = label; + this.onInput(); + } + + getElement() { + return this.elem_; + } + + onInput() { + this.input_.setAttribute('data-arch-value', this.input_.value); + } + + onInputKeyDown(e) { + switch (e.key) { + case 'Enter': + e.stopPropagation(); + e.preventDefault(); + this.stopEdit(); + { + let nodes = this.nodes_.getEntries(); + if (nodes[0].getLabel() == '') { + nodes[0].startEdit(); + } else if (nodes[1].getLabel() == '') { + nodes[1].startEdit(); + } + } + break; + + case 'Escape': + e.stopPropagation(); + e.preventDefault(); + this.stopEdit(); + break; + + case 'ArrowUp': + case 'ArrowDown': + case 'PageUp': + case 'PageDown': + this.stopEdit(); + break; + + default: + e.stopPropagation(); + break; + } + } + + onKeyDown(e) { + super.onKeyDown(e); + + switch (e.key) { + case 'Enter': + this.startEdit(); + e.stopPropagation(); + e.preventDefault(); + break; + + case 'ArrowRight': + case 'l': + this.nodes_.selectNext(); + break; + } + } + + startEdit() { + this.input_.focus(); + } + + stopEdit() { + this.elem_.focus(); + } + + static unserialize(ser) { + let link = new EditorLink(); + link.setLabel(ser.label); + link.nodes_.clear(); + link.nodes_.unserialize([ser.from, ser.to]); + return link.getElement(); + } +} diff --git a/EditorNode.js b/EditorNode.js new file mode 100644 index 0000000..12dde0d --- /dev/null +++ b/EditorNode.js @@ -0,0 +1,188 @@ +class EditorNode extends EditorEntryBase { + constructor() { + super(); + + this.elem_.innerText = 'Node:'; + this.elem_.classList.add('node'); + + this.input_ = document.createElement('input'); + this.input_.type = 'text'; + this.input_.placeholder = 'node name'; + this.input_.setAttribute('list', 'arch-targets'); + this.listen(this.input_, 'keydown', (e) => this.onInputKeyDown(e)); + this.listen(this.input_, 'input', (e) => this.onInput()); + this.listen(this.input_, 'blur', (e) => this.onInput()); + this.elem_.appendChild(this.input_); + } + + afterDomAdd() { + this.input_.focus(); + } + + serialize() { + return { + type: 'node', + label: this.getLabel(), + }; + } + + exportGraphviz() { + if (this.getLabel() == '') { + return []; + } + return ['"' + this.id + '" [label="' + this.getLabel() + '"];']; + } + + clear() { + super.clear(); + this.links = []; + this.groups = []; + this.affinity = []; + this.pageRank = 0; + this.subgraph = null; + } + + getLabel() { + return this.input_.value; + } + + setLabel(label) { + this.input_.value = label; + this.onInput(); + } + + getElement() { + return this.elem_; + } + + wantFocus() { + return this.getLabel() == ''; + } + + isSoft() { + // Nested nodes are presumed to be references to other nodes if they exist + let iter = this.elem_.parentElement; + for (let iter = this.elem_.parentElement; iter; iter = iter.parentElement) { + if (iter.xArchObj) { + return true; + } + } + return false; + } + + setTension() { + this.vec = [0, 0]; + this.tension = 0; + for (let aff of this.affinity) { + let vec = [], vecsum = 0; + for (let i of [0, 1]) { + vec[i] = aff.node.pos[i] - this.pos[i]; + vecsum += Math.abs(vec[i]); + }; + let distance = Math.sqrt(Math.pow(vec[0], 2) + Math.pow(vec[1], 2)); + let weight = aff.distanceToWeight(distance); + for (let i of [0, 1]) { + this.vec[i] += (weight * vec[i]) / vecsum; + } + this.tension += Math.abs(weight); + } + } + + offsetToPos(offset) { + return [ + this.pos[0] + offset[0], + this.pos[1] + offset[1], + ]; + } + + offsetCollides(graph, offset) { + let newPos = this.offsetToPos(offset); + return graph.nodesByPos.get(newPos.toString()); + } + + moveBy(graph, offset) { + this.moveTo(graph, this.offsetToPos(offset)); + } + + moveTo(graph, pos) { + if (this.pos) { + graph.nodesByPos.delete(this.pos.toString()); + } + this.pos = pos; + graph.nodesByPos.set(this.pos.toString(), this); + } + + savePos() { + this.savedPos = this.pos; + } + + restorePos(graph) { + this.moveTo(graph, this.savedPos); + } + + onInput() { + if (!this.input_.getAttribute('data-arch-value') || + this.input_.value == '') { + this.input_.setAttribute('data-struct-change', 'x'); + } + this.input_.setAttribute('data-arch-value', this.input_.value); + } + + onInputKeyDown(e) { + switch (e.key) { + case 'Enter': + e.stopPropagation(); + if (this.elem_.nextElementSibling && + this.elem_.nextElementSibling.xArchObj && + this.elem_.nextElementSibling.xArchObj.wantFocus()) { + this.elem_.nextElementSibling.xArchObj.startEdit(); + } else { + this.stopEdit(); + } + break; + + case 'Escape': + e.stopPropagation(); + this.stopEdit(); + break; + + case 'ArrowUp': + case 'ArrowDown': + case 'PageUp': + case 'PageDown': + this.stopEdit(); + break; + + default: + e.stopPropagation(); + break; + } + } + + onKeyDown(e) { + super.onKeyDown(e); + + switch (e.key) { + case 'Enter': + this.startEdit(); + e.stopPropagation(); + e.preventDefault(); + break; + } + } + + startEdit() { + this.input_.focus(); + } + + stopEdit() { + this.elem_.focus(); + } + + static unserialize(ser) { + let node = new EditorNode(); + node.setLabel(ser.label); + return node.getElement(); + } +} + diff --git a/List.js b/List.js new file mode 100644 index 0000000..e06b567 --- /dev/null +++ b/List.js @@ -0,0 +1,175 @@ +class List { + constructor(container) { + this.container_ = container; + this.minEntries_ = 0; + this.maxEntries_ = Number.MAX_SAFE_INTEGER; + } + + setMinEntries(min) { + this.minEntries_ = min; + } + + setMaxEntries(max) { + this.maxEntries_ = max; + } + + getEntries() { + let ret = []; + for (let elem of this.container_.children) { + ret.push(elem.xArchObj); + } + return ret; + } + + mayAdd() { + return this.container_.children.length < this.maxEntries_; + } + + getSelected() { + let iter = document.activeElement; + while (iter) { + if (iter.parentElement == this.container_) { + return iter; + } + iter = iter.parentElement; + } + return null; + } + + deleteSelected() { + if (this.container_.children.length <= this.minEntries_) { + return; + } + let sel = this.getSelected(); + if (sel) { + sel.xArchObj.remove(); + } + } + + deleteSelectedAndAfter() { + let sel = this.getSelected(); + if (sel) { + while (this.container_.lastElementChild != sel && + this.container_.children.length > this.minEntries_) { + this.container_.lastElementChild.xArchObj.remove(); + } + this.deleteSelected(); + } + } + + selectNext() { + let sel = this.getSelected() || this.container_.lastElementChild; + if (sel) { + this.select(sel.nextElementSibling || + this.container_.firstElementChild); + } + } + + selectPrev() { + let sel = this.getSelected() || this.container_.firstElementChild; + if (sel) { + this.select(sel.previousElementSibling || + this.container_.lastElementChild); + } + } + + selectPrevPage() { + let targetTop = this.container_.scrollTop - this.container_.clientHeight; + let sel = this.getSelected() || this.container_.lastElementSibling; + if (sel) { + while (sel.previousElementSibling && + this.container_.scrollTop > targetTop) { + sel = sel.previousElementSibling; + this.select(sel); + } + } + } + + selectNextPage() { + let targetTop = this.container_.scrollTop + this.container_.clientHeight; + let sel = this.getSelected() || this.container_.firstElementSibling; + if (sel) { + while (sel.nextElementSibling && this.container_.scrollTop < targetTop) { + sel = sel.nextElementSibling; + this.select(sel); + } + } + } + + selectFirst() { + this.select(this.container_.firstElementChild); + } + + selectLast() { + this.select(this.container_.lastElementChild); + } + + select(elem) { + if (!elem) { + return; + } + elem.focus(); + } + + onKeyDown(e) { + switch (e.key) { + case 'Escape': + case 'ArrowLeft': + case 'h': + if (this.container_.parentElement.xArchObj) { + this.container_.parentElement.focus(); + } + break; + + case 'd': + this.deleteSelected(); + e.stopPropagation(); + e.preventDefault(); + break; + + case 'D': + this.deleteSelectedAndAfter(); + e.stopPropagation(); + e.preventDefault(); + break; + + case 'j': + case 'ArrowDown': + this.selectNext(); + e.stopPropagation(); + e.preventDefault(); + break; + + case 'k': + case 'ArrowUp': + this.selectPrev(); + e.stopPropagation(); + e.preventDefault(); + break; + + case 'PageUp': + this.selectPrevPage(); + e.stopPropagation(); + e.preventDefault(); + break; + + case 'PageDown': + this.selectNextPage(); + e.stopPropagation(); + e.preventDefault(); + break; + + case 'Home': + this.selectFirst(); + e.stopPropagation(); + e.preventDefault(); + break; + + case 'End': + this.selectLast(); + e.stopPropagation(); + e.preventDefault(); + break; + } + } +} diff --git a/ListenUtils.js b/ListenUtils.js new file mode 100644 index 0000000..cbcd261 --- /dev/null +++ b/ListenUtils.js @@ -0,0 +1,17 @@ +class ListenUtils { + constructor() { + this.listeners_ = []; + } + + listen(source, type, callback) { + source.addEventListener(type, callback); + this.listeners_.push([source, type, callback]); + } + + clearListeners() { + for (let [source, type, callback] of this.listeners_) { + source.removeEventListener(type, callback); + } + this.listeners_ = []; + } +} diff --git a/architype.js b/architype.js deleted file mode 100644 index 32f7b7a..0000000 --- a/architype.js +++ /dev/null @@ -1,1494 +0,0 @@ -'use strict'; - -function randStr32() { - let num = Math.floor(Math.random() * Math.pow(2, 32)); - return num.toString(16).padStart(8, '0'); -} - -function randStr64() { - return randStr32() + randStr32(); -} - -class Architype { - constructor(container) { - this.container_ = container; - - this.container_.classList.add('architype'); - // TODO: make theme selectable - this.container_.classList.add('dark'); - - this.container_.addEventListener('keydown', (e) => { this.onKeyDown(e); }); - addEventListener('resize', (e) => { this.onResize(e); }); - - let editorElem = document.createElement('ul'); - this.container_.appendChild(editorElem); - this.editor_ = new Editor(editorElem); - - this.targets_ = document.createElement('datalist'); - this.targets_.id = 'arch-targets'; - this.container_.appendChild(this.targets_); - - this.lines_ = document.createElement('div'); - this.lines_.innerHTML = ``; - this.lines_ = this.lines_.firstElementChild; - this.lines_.classList.add('gridLines'); - this.lines_.classList.add('white'); - - this.grid_ = document.createElement('div'); - this.grid_.classList.add('grid'); - this.container_.appendChild(this.grid_); - - this.unserialize(JSON.parse(localStorage.getItem('currentState'))); - - this.observeStructure_ = new MutationObserver(e => { this.onStructureChange(e); }); - this.observeStructure_.observe(editorElem, { - attributes: true, - attributeFilter: ['data-struct-change'], - childList: true, - subtree: true, - }); - - // TODO: handle case when value change alters link graph - this.observeContent_ = new MutationObserver(e => { this.onContentChange(e); }); - this.observeContent_.observe(editorElem, { - attributes: true, - attributeFilter: ['data-arch-value'], - subtree: true, - }); - - this.onStructureChange(); - } - - serialize() { - return { - version: 1, - editor: this.editor_.serialize(), - }; - } - - unserialize(ser) { - if (!ser) { - return; - } - - switch (ser.version) { - case 1: - this.editor_.unserialize(ser.editor); - break; - - default: - console.log('unrecognized localStorage.currentState version', ser); - break; - } - } - - onStructureChange(e) { - this.graph_ = this.buildGraph(); - this.onContentChange(e); - } - - onContentChange(e) { - localStorage.setItem('currentState', JSON.stringify(this.serialize())); - this.buildGrid(this.graph_); - this.updateTargets(this.graph_); - this.fixSizes(this.graph_.nodes); - } - - onKeyDown(e) { - switch (e.key) { - case 'z': - this.exportGraphviz(); - break; - } - } - - onResize(e) { - this.fixSizes(this.graph_.nodes); - } - - exportGraphviz() { - let lines = [ - 'digraph G {', - '\trankdir = "LR";', - ]; - - for (let type of ['nodes', 'links', 'groups']) { - for (let obj of this.graph_[type]) { - for (let line of obj.exportGraphviz()) { - lines.push('\t' + line); - } - } - } - - lines.push('}'); - navigator.clipboard.writeText(lines.join('\n')); - } - - updateTargets(graph) { - // Lots of effort to avoid churning the datalist - - let curTargets = new Map(); - for (let option of this.targets_.options) { - curTargets.set(option.value, option); - } - - for (let [label, entries] of graph.nodesByLabel.entries()) { - if (curTargets.has(label)) { - continue; - } - if (entries.length == 1 && - document.activeElement.parentElement.xArchObj && - document.activeElement.parentElement.xArchObj == entries[0]) { - // Skip an element currently being edited - continue; - } - let option = document.createElement('option'); - option.value = label; - this.targets_.appendChild(option); - } - - for (let [label, option] of curTargets.entries()) { - if (graph.nodesByLabel.has(label)) { - continue; - } - option.remove(); - } - } - - buildGraph() { - let graph = { - nodesByLabel: new Map(), - nodesByPageRank: new Map(), - nodesByPos: new Map(), - nodesBySubgraph: new Map(), - groups: [], - links: [], - nodes: [], - }; - // Order here is important, as each step carefully builds on data - // constructed by the previous - this.buildGraphInt(graph, this.editor_.getEntries()); - this.trimSoftNodes(graph); - this.processLinks(graph); - this.processGroups(graph); - this.manifestNodes(graph); - this.setPageRank(graph); - this.bucketByPageRank(graph); - this.bucketBySubgraph(graph); - this.setInitialPositions(graph); - this.setAffinity(graph); - - while (this.iterate(graph)); - this.fixOrigin(graph); - - return graph; - } - - buildGraphInt(graph, entries) { - for (let entry of entries) { - if (entry instanceof Node) { - this.buildGraphNode(graph, entry); - } else if (entry instanceof Group) { - this.buildGraphGroup(graph, entry); - } else if (entry instanceof Link) { - this.buildGraphLink(graph, entry); - } - } - } - - buildGraphNode(graph, node) { - node.clear(); - if (node.getLabel() == '') { - return; - } - let targets = graph.nodesByLabel.get(node.getLabel()); - if (!targets) { - targets = []; - graph.nodesByLabel.set(node.getLabel(), targets); - } - targets.push(node); - } - - buildGraphGroup(graph, group) { - group.clear(); - graph.groups.push(group); - this.buildGraphInt(graph, group.getNodes()); - } - - buildGraphLink(graph, link) { - link.clear(); - graph.links.push(link); - this.buildGraphInt(graph, [link.getFrom(), link.getTo()]); - } - - trimSoftNodes(graph) { - for (let entries of graph.nodesByLabel.values()) { - for (let i = entries.length - 1; i >= 0 && entries.length > 1; --i) { - if (entries[i] instanceof Node && entries[i].isSoft()) { - entries.splice(i, 1); - } - } - } - } - - processLinks(graph) { - for (let link of graph.links) { - // Re-resolve each from/to reference by label, so we skip soft nodes and - // handle multiple objects with the same label - link.from = graph.nodesByLabel.get(link.getFrom().getLabel()) || []; - link.to = graph.nodesByLabel.get(link.getTo().getLabel()) || []; - for (let from of link.from) { - for (let to of link.to) { - from.links.push(to); - } - } - } - } - - processGroups(graph) { - for (let group of graph.groups) { - group.nodes = []; - for (let member of group.getNodes()) { - let nodes = graph.nodesByLabel.get(member.getLabel()) || []; - for (let node of nodes) { - group.nodes.push(node); - node.groups.push(group); - } - } - } - } - - manifestNodes(graph) { - for (let entries of graph.nodesByLabel.values()) { - for (let entry of entries) { - if (entry instanceof Node) { - graph.nodes.push(entry); - } - } - } - } - - setPageRank(graph) { - for (let link of graph.links) { - for (let to of link.to) { - this.setPageRankInt(to, new Set()); - } - } - for (let group of graph.groups) { - // All members of a group get the rank of the maximum member, so the - // initial positions will put them all near each other - let maxRank = 0; - for (let member of group.nodes) { - maxRank = Math.max(maxRank, member.pageRank); - } - for (let member of group.nodes) { - member.pageRank = maxRank; - } - } - } - - setPageRankInt(node, visited) { - if (visited.has(node)) { - // Loop detection - return; - } - ++node.pageRank; - visited.add(node); - for (let out of node.links) { - this.setPageRankInt(out, visited); - } - visited.delete(node); - } - - bucketByPageRank(graph) { - for (let node of graph.nodes) { - let bucket = graph.nodesByPageRank.get(node.pageRank); - if (!bucket) { - bucket = []; - graph.nodesByPageRank.set(node.pageRank, bucket); - } - bucket.push(node); - } - let cmp = (a, b) => { - if (a < b) { - return -1; - } else if (a > b) { - return 1; - } else { - return 0; - } - }; - for (let bucket of graph.nodesByPageRank.values()) { - bucket.sort(cmp); - } - } - - bucketBySubgraph(graph) { - let nodes = new Set(); - let ranks = Array.from(graph.nodesByPageRank.keys()); - ranks.sort((a, b) => a - b); - for (let rank of ranks) { - for (let node of graph.nodesByPageRank.get(rank)) { - nodes.add(node); - } - } - for (let subgraph = 0; nodes.size; ++subgraph) { - let node = nodes.values().next().value; - let subgraphArr = []; - graph.nodesBySubgraph.set(subgraph, subgraphArr); - this.recurseSubgraph(subgraph, subgraphArr, node, nodes); - } - } - - recurseSubgraph(subgraph, subgraphArr, node, nodes) { - if (node.subgraph !== null) { - return; - } - node.subgraph = subgraph; - subgraphArr.push(node); - nodes.delete(node); - for (let to of node.links) { - this.recurseSubgraph(subgraph, subgraphArr, to, nodes); - } - for (let group of node.groups) { - for (let member of group.nodes) { - this.recurseSubgraph(subgraph, subgraphArr, member, nodes); - } - } - } - - setInitialPositions(graph) { - const SPACING = 4; - - let maxRankNodes = 0; - for (let nodes of graph.nodesByPageRank.values()) { - maxRankNodes = Math.max(maxRankNodes, nodes.length); - } - - let ranks = Array.from(graph.nodesByPageRank.keys()); - ranks.sort((a, b) => a - b); - for (let r = 0; r < ranks.length; ++r) { - let nodes = graph.nodesByPageRank.get(ranks[r]); - for (let n = 0; n < nodes.length; ++n) { - let node = nodes[n]; - let pos = [ - r * SPACING, - Math.floor((nodes.length / 2) * SPACING) + (n * SPACING) + - (node.subgraph * SPACING * maxRankNodes), - ]; - node.moveTo(graph, pos); - } - } - } - - fixOrigin(graph) { - let min = [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]; - let max = [Number.MIN_SAFE_INTEGER, Number.MIN_SAFE_INTEGER]; - for (let node of graph.nodes) { - for (let i of [0, 1]) { - min[i] = Math.min(min[i], node.pos[i]); - max[i] = Math.max(max[i], node.pos[i]); - } - } - // Offset is negative minimum, e.g min -1 means +1 to all values - for (let node of graph.nodes) { - for (let i of [0, 1]) { - node.pos[i] -= min[i]; - } - } - graph.size = [ - max[0] - min[0] + 1, - max[1] - min[1] + 1, - ]; - } - - setAffinity(graph) { - const INF = 999999; - for (let node of graph.nodes) { - for (let other of graph.nodes) { - // Weak affinity full mesh - // Keep unassociated subgroups together - this.addAffinity(node, other, d => d); - - if (node.subgraph != other.subgraph) { - this.addAffinity(node, other, d => d < 1.5 ? -INF : 0); - } - } - for (let to of node.links) { - // Stronger affinity for links - // Prefer to move toward the target instance - this.addAffinity(node, to, d => d < 1.5 ? -INF : d * 11); - this.addAffinity(to, node, d => d < 1.5 ? -INF : d * 9); - } - for (let group of node.groups) { - for (let member of group.nodes) { - // Even stronger affinity for groups - // Other nodes will reference this one and take care of the full - // group mesh - this.addAffinity(node, member, d => d * 100); - } - let members = new Set(group.nodes); - for (let other of graph.nodes) { - if (members.has(other)) { - continue; - } - // Nodes not in this group run away - this.addAffinity(other, node, d => d < 1.5 ? -INF : 0); - } - } - } - } - - addAffinity(node, other, func) { - if (node == other) { - return; - } - node.affinity.push({ - node: other, - distanceToWeight: func, - }); - } - - buildGrid(graph) { - this.grid_.innerHTML = ''; - - this.grid_.style.gridTemplateColumns = - 'repeat(' + graph.size[0] + ',1fr)'; - this.grid_.style.gridTemplateRows = - 'repeat(' + graph.size[1] + - ',minmax(0, calc((100vw - var(--editor-width)) / ' + - graph.size[0] + ')))'; - - this.drawGridNodes(graph); - } - - addLines(pos, cls) { - let lines = this.lines_.cloneNode(true); - lines.classList.add(cls); - lines.style.gridColumn = pos[0] + 1; - lines.style.gridRow = pos[1] + 1; - this.grid_.appendChild(lines); - return lines; - } - - iterate(graph) { - let nodes = Array.from(graph.nodes); - this.sortByMostTension(nodes); - for (let group of graph.groups) { - nodes.push(group.getCollection()); - } - for (let subgraph of graph.nodesBySubgraph.values()) { - nodes.push(new Collection(subgraph)); - } - - let newOffset = null; - let newTension = this.getTotalTension(nodes); - for (let node of nodes) { - let origPos = node.pos; - let offsets = new Map(); - let addOffset = (x, y) => { - if (!offsets.has([x, y].toString())) { - offsets.set([x, y].toString(), [x, y]); - } - }; - for (let dir of [-1, 0, 1]) { - addOffset(Math.sign(node.vec[0]), dir); - addOffset(dir, Math.sign(node.vec[1])); - } - for (let offset of offsets.values()) { - if (node.offsetCollides(graph, offset)) { - continue; - } - node.savePos(); - node.moveBy(graph, offset); - let testTension = this.getTotalTension(nodes); - node.restorePos(graph); - if (testTension < newTension) { - newOffset = offset; - newTension = testTension; - } - } - if (newOffset) { - node.moveBy(graph, newOffset); - return true; - } - } - return false; - } - - getTotalTension(nodes) { - let total = 0; - for (let node of nodes) { - node.setTension(); - total += node.tension; - } - return total; - } - - sortByMostTension(nodes) { - for (let node of nodes) { - node.setTension(); - } - nodes.sort((a, b) => b.tension - a.tension); - } - - drawGridNodes(graph) { - for (let node of graph.nodes) { - node.gridElem = document.createElement('div'); - node.gridElem.classList.add('gridNode'); - this.grid_.appendChild(node.gridElem); - node.gridElem.innerText = node.getLabel(); - node.gridElem.style.gridColumn = node.pos[0] + 1; - node.gridElem.style.gridRow = node.pos[1] + 1; - } - } - - fixSizes(nodes) { - for (let node of nodes) { - let elem = node.gridElem; - elem.style.fontSize = null; - for (let size = 20; - size && (elem.scrollWidth > elem.clientWidth || - elem.scrollHeight > elem.clientHeight); - --size) { - elem.style.fontSize = size + 'px'; - } - } - } -} - -class ListenUtils { - constructor() { - this.listeners_ = []; - } - - listen(source, type, callback) { - source.addEventListener(type, callback); - this.listeners_.push([source, type, callback]); - } - - clearListeners() { - for (let [source, type, callback] of this.listeners_) { - source.removeEventListener(type, callback); - } - this.listeners_ = []; - } -} - -class List { - constructor(container) { - this.container_ = container; - this.minEntries_ = 0; - this.maxEntries_ = Number.MAX_SAFE_INTEGER; - } - - setMinEntries(min) { - this.minEntries_ = min; - } - - setMaxEntries(max) { - this.maxEntries_ = max; - } - - getEntries() { - let ret = []; - for (let elem of this.container_.children) { - ret.push(elem.xArchObj); - } - return ret; - } - - mayAdd() { - return this.container_.children.length < this.maxEntries_; - } - - getSelected() { - let iter = document.activeElement; - while (iter) { - if (iter.parentElement == this.container_) { - return iter; - } - iter = iter.parentElement; - } - return null; - } - - deleteSelected() { - if (this.container_.children.length <= this.minEntries_) { - return; - } - let sel = this.getSelected(); - if (sel) { - sel.xArchObj.remove(); - } - } - - deleteSelectedAndAfter() { - let sel = this.getSelected(); - if (sel) { - while (this.container_.lastElementChild != sel && - this.container_.children.length > this.minEntries_) { - this.container_.lastElementChild.xArchObj.remove(); - } - this.deleteSelected(); - } - } - - selectNext() { - let sel = this.getSelected() || this.container_.lastElementChild; - if (sel) { - this.select(sel.nextElementSibling || - this.container_.firstElementChild); - } - } - - selectPrev() { - let sel = this.getSelected() || this.container_.firstElementChild; - if (sel) { - this.select(sel.previousElementSibling || - this.container_.lastElementChild); - } - } - - selectPrevPage() { - let targetTop = this.container_.scrollTop - this.container_.clientHeight; - let sel = this.getSelected() || this.container_.lastElementSibling; - if (sel) { - while (sel.previousElementSibling && - this.container_.scrollTop > targetTop) { - sel = sel.previousElementSibling; - this.select(sel); - } - } - } - - selectNextPage() { - let targetTop = this.container_.scrollTop + this.container_.clientHeight; - let sel = this.getSelected() || this.container_.firstElementSibling; - if (sel) { - while (sel.nextElementSibling && this.container_.scrollTop < targetTop) { - sel = sel.nextElementSibling; - this.select(sel); - } - } - } - - selectFirst() { - this.select(this.container_.firstElementChild); - } - - selectLast() { - this.select(this.container_.lastElementChild); - } - - select(elem) { - if (!elem) { - return; - } - elem.focus(); - } - - onKeyDown(e) { - switch (e.key) { - case 'Escape': - case 'ArrowLeft': - case 'h': - if (this.container_.parentElement.xArchObj) { - this.container_.parentElement.focus(); - } - break; - - case 'd': - this.deleteSelected(); - e.stopPropagation(); - e.preventDefault(); - break; - - case 'D': - this.deleteSelectedAndAfter(); - e.stopPropagation(); - e.preventDefault(); - break; - - case 'j': - case 'ArrowDown': - this.selectNext(); - e.stopPropagation(); - e.preventDefault(); - break; - - case 'k': - case 'ArrowUp': - this.selectPrev(); - e.stopPropagation(); - e.preventDefault(); - break; - - case 'PageUp': - this.selectPrevPage(); - e.stopPropagation(); - e.preventDefault(); - break; - - case 'PageDown': - this.selectNextPage(); - e.stopPropagation(); - e.preventDefault(); - break; - - case 'Home': - this.selectFirst(); - e.stopPropagation(); - e.preventDefault(); - break; - - case 'End': - this.selectLast(); - e.stopPropagation(); - e.preventDefault(); - break; - } - } -} - -class Editor extends List { - static NODE = 1; - static GROUP = 2; - static LINK = 3; - - constructor(container, allowedTypes) { - super(container); - - this.allowedTypes_ = new Set(allowedTypes || - [Editor.NODE, Editor.GROUP, Editor.LINK]); - - this.container_.classList.add('editor'); - // Needs to accept focus to receive keydown, but shouldn't be in the normal - // tab flow. - this.container_.tabIndex = 99999; - this.container_.addEventListener('keydown', e => { this.onKeyDown(e); }); - this.container_.focus(); - } - - clear() { - this.container_.innerHTML = ''; - } - - serialize() { - // Doesn't have a type, only used as part of other objects - let ret = []; - for (let entry of this.getEntries()) { - ret.push(entry.serialize()); - } - return ret; - } - - unserialize(ser) { - for (let entry of ser) { - this.container_.appendChild(EditorEntryBase.unserialize(entry)); - } - } - - isAllowed(type) { - return this.mayAdd() && this.allowedTypes_.has(type); - } - - addNodeAfter() { - if (this.isAllowed(Editor.NODE)) { - Node.addAfter(this.container_, this.getSelected()); - } - } - - addNodeBefore() { - if (this.isAllowed(Editor.NODE)) { - Node.addBefore(this.container_, this.getSelected()); - } - } - - addLinkAfter() { - if (this.isAllowed(Editor.LINK)) { - Link.addAfter(this.container_, this.getSelected()); - } - } - - addLinkBefore() { - if (this.isAllowed(Editor.LINK)) { - Link.addBefore(this.container_, this.getSelected()); - } - } - - addGroupAfter() { - if (this.isAllowed(Editor.GROUP)) { - Group.addAfter(this.container_, this.getSelected()); - } - } - - addGroupBefore() { - if (this.isAllowed(Editor.GROUP)) { - Group.addBefore(this.container_, this.getSelected()); - } - } - - onKeyDown(e) { - switch (e.key) { - case 'g': - this.addGroupAfter(); - e.stopPropagation(); - e.preventDefault(); - return; - - case 'G': - this.addGroupBefore(); - e.stopPropagation(); - e.preventDefault(); - return; - - case 'i': - this.addLinkAfter(); - e.stopPropagation(); - e.preventDefault(); - return; - - case 'I': - this.addLinkBefore(); - e.stopPropagation(); - e.preventDefault(); - return; - - case 'n': - this.addNodeAfter(); - e.stopPropagation(); - e.preventDefault(); - return; - - case 'N': - this.addNodeBefore(); - e.stopPropagation(); - e.preventDefault(); - return; - } - - super.onKeyDown(e); - } -} - -class EditorEntryBase extends ListenUtils { - constructor() { - super(); - - this.id = randStr64(); - - this.elem_ = document.createElement('li'); - this.elem_.tabIndex = 0; - this.listen(this.elem_, 'focus', () => this.onElemFocus()); - this.listen(this.elem_, 'keydown', (e) => this.onKeyDown(e)); - - this.elem_.xArchObj = this; - } - - remove() { - if (document.activeElement == this.elem_ || - document.activeElement == document.body) { - if (this.elem_.nextElementSibling) { - this.elem_.nextElementSibling.focus(); - } else if (this.elem_.previousElementSibling) { - this.elem_.previousElementSibling.focus(); - } else if (this.elem_.parentElement) { - this.elem_.parentElement.focus(); - } - } - - this.elem_.remove(); - this.clearListeners(); - this.elem_.xArchObj = null; - } - - clear() { - } - - wantFocus() { - return false; - } - - onElemFocus() { - this.elem_.scrollIntoView({block: 'nearest'}); - } - - onKeyDown(e) { - } - - afterDomAdd() { - } - - static addBefore(container, elem) { - let entry = new this(); - container.insertBefore(entry.getElement(), elem); - entry.afterDomAdd(); - } - - static addAfter(container, elem) { - let entry = new this(); - container.insertBefore(entry.getElement(), elem ? elem.nextSibling : null); - entry.afterDomAdd(); - } - - static unserialize(ser) { - switch (ser.type) { - case 'group': - return Group.unserialize(ser); - case 'link': - return Link.unserialize(ser); - case 'node': - return Node.unserialize(ser); - } - } -} - -class Node extends EditorEntryBase { - constructor() { - super(); - - this.elem_.innerText = 'Node:'; - this.elem_.classList.add('node'); - - this.input_ = document.createElement('input'); - this.input_.type = 'text'; - this.input_.placeholder = 'node name'; - this.input_.setAttribute('list', 'arch-targets'); - this.listen(this.input_, 'keydown', (e) => this.onInputKeyDown(e)); - this.listen(this.input_, 'input', (e) => this.onInput()); - this.listen(this.input_, 'blur', (e) => this.onInput()); - this.elem_.appendChild(this.input_); - } - - afterDomAdd() { - this.input_.focus(); - } - - serialize() { - return { - type: 'node', - label: this.getLabel(), - }; - } - - exportGraphviz() { - if (this.getLabel() == '') { - return []; - } - return ['"' + this.id + '" [label="' + this.getLabel() + '"];']; - } - - clear() { - super.clear(); - this.links = []; - this.groups = []; - this.affinity = []; - this.pageRank = 0; - this.subgraph = null; - } - - getLabel() { - return this.input_.value; - } - - setLabel(label) { - this.input_.value = label; - this.onInput(); - } - - getElement() { - return this.elem_; - } - - wantFocus() { - return this.getLabel() == ''; - } - - isSoft() { - // Nested nodes are presumed to be references to other nodes if they exist - let iter = this.elem_.parentElement; - for (let iter = this.elem_.parentElement; iter; iter = iter.parentElement) { - if (iter.xArchObj) { - return true; - } - } - return false; - } - - setTension() { - this.vec = [0, 0]; - this.tension = 0; - for (let aff of this.affinity) { - let vec = [], vecsum = 0; - for (let i of [0, 1]) { - vec[i] = aff.node.pos[i] - this.pos[i]; - vecsum += Math.abs(vec[i]); - }; - let distance = Math.sqrt(Math.pow(vec[0], 2) + Math.pow(vec[1], 2)); - let weight = aff.distanceToWeight(distance); - for (let i of [0, 1]) { - this.vec[i] += (weight * vec[i]) / vecsum; - } - this.tension += Math.abs(weight); - } - } - - offsetToPos(offset) { - return [ - this.pos[0] + offset[0], - this.pos[1] + offset[1], - ]; - } - - offsetCollides(graph, offset) { - let newPos = this.offsetToPos(offset); - return graph.nodesByPos.get(newPos.toString()); - } - - moveBy(graph, offset) { - this.moveTo(graph, this.offsetToPos(offset)); - } - - moveTo(graph, pos) { - if (this.pos) { - graph.nodesByPos.delete(this.pos.toString()); - } - this.pos = pos; - graph.nodesByPos.set(this.pos.toString(), this); - } - - savePos() { - this.savedPos = this.pos; - } - - restorePos(graph) { - this.moveTo(graph, this.savedPos); - } - - onInput() { - if (!this.input_.getAttribute('data-arch-value') || - this.input_.value == '') { - this.input_.setAttribute('data-struct-change', 'x'); - } - this.input_.setAttribute('data-arch-value', this.input_.value); - } - - onInputKeyDown(e) { - switch (e.key) { - case 'Enter': - e.stopPropagation(); - if (this.elem_.nextElementSibling && - this.elem_.nextElementSibling.xArchObj && - this.elem_.nextElementSibling.xArchObj.wantFocus()) { - this.elem_.nextElementSibling.xArchObj.startEdit(); - } else { - this.stopEdit(); - } - break; - - case 'Escape': - e.stopPropagation(); - this.stopEdit(); - break; - - case 'ArrowUp': - case 'ArrowDown': - case 'PageUp': - case 'PageDown': - this.stopEdit(); - break; - - default: - e.stopPropagation(); - break; - } - } - - onKeyDown(e) { - super.onKeyDown(e); - - switch (e.key) { - case 'Enter': - this.startEdit(); - e.stopPropagation(); - e.preventDefault(); - break; - } - } - - startEdit() { - this.input_.focus(); - } - - stopEdit() { - this.elem_.focus(); - } - - static unserialize(ser) { - let node = new Node(); - node.setLabel(ser.label); - return node.getElement(); - } -} - -class Collection { - constructor(nodes) { - this.nodes = nodes; - } - - setTension() { - this.vec = [0, 0]; - this.tension = 0; - for (let node of this.nodes) { - node.setTension(); - for (let i of [0, 1]) { - this.vec[i] += node.vec[i]; - }; - this.tension += node.tension; - } - } - - offsetCollides(graph, offset) { - // TODO: make this.nodes always a set - let nodeSet = new Set(this.nodes); - for (let node of this.nodes) { - let other = node.offsetCollides(graph, offset); - if (other && !nodeSet.has(other)) { - return other; - } - } - return null; - } - - savePos() { - for (let node of this.nodes) { - node.savePos(); - } - } - - restorePos(graph) { - for (let node of this.nodes) { - node.restorePos(graph); - } - } - - moveBy(graph, offset) { - let nodes = new Set(this.nodes); - while (nodes.size) { - for (let node of nodes) { - if (node.offsetCollides(graph, offset)) { - continue; - } - node.moveBy(graph, offset); - nodes.delete(node); - } - } - } -} - -class Group extends EditorEntryBase { - constructor() { - super(); - - this.elem_.innerText = 'Group:'; - this.elem_.classList.add('group'); - - this.input_ = document.createElement('input'); - this.input_.type = 'text'; - this.input_.placeholder = 'group name'; - this.listen(this.input_, 'keydown', (e) => this.onInputKeyDown(e)); - this.listen(this.input_, 'input', (e) => this.onInput()); - this.listen(this.input_, 'blur', (e) => this.onInput()); - this.elem_.appendChild(this.input_); - - let nodeList = document.createElement('div'); - this.nodes_ = new Editor(nodeList, [Editor.NODE]); - this.nodes_.setMinEntries(1); - this.nodes_.addNodeAfter(); - this.elem_.appendChild(nodeList); - } - - afterDomAdd() { - this.input_.focus(); - } - - serialize() { - return { - type: 'group', - label: this.getLabel(), - members: this.nodes_.serialize(), - }; - } - - exportGraphviz() { - let lines = [ - 'subgraph "cluster_' + this.id + '" {', - ]; - - if (this.getLabel() != '') { - lines.push('\tlabel = "' + this.getLabel() + '";'); - } - - for (let obj of this.nodes) { - for (let line of obj.exportGraphviz()) { - lines.push('\t' + line); - } - } - - lines.push('}'); - return lines; - } - - clear() { - super.clear(); - } - - getNodes() { - return this.nodes_.getEntries(); - } - - getLabel() { - return this.input_.value; - } - - setLabel(label) { - this.input_.value = label; - this.onInput(); - } - - getElement() { - return this.elem_; - } - - getCollection() { - return new Collection(this.nodes); - } - - onInputKeyDown(e) { - switch (e.key) { - case 'Enter': - e.stopPropagation(); - e.preventDefault(); - this.stopEdit(); - { - let nodes = this.nodes_.getEntries(); - if (nodes.length == 1 && nodes[0].getLabel() == '') { - nodes[0].startEdit(); - } - } - break; - - case 'Escape': - e.stopPropagation(); - e.preventDefault(); - this.stopEdit(); - break; - - case 'ArrowUp': - case 'ArrowDown': - case 'PageUp': - case 'PageDown': - this.stopEdit(); - break; - - default: - e.stopPropagation(); - break; - } - } - - onKeyDown(e) { - super.onKeyDown(e); - - switch (e.key) { - case 'Enter': - this.startEdit(); - e.stopPropagation(); - e.preventDefault(); - break; - - case 'ArrowRight': - case 'l': - this.nodes_.selectNext(); - break; - } - } - - onInput() { - this.input_.setAttribute('data-arch-value', this.input_.value); - } - - startEdit() { - this.input_.focus(); - } - - stopEdit() { - this.elem_.focus(); - } - - static unserialize(ser) { - let group = new Group(); - group.setLabel(ser.label); - group.nodes_.clear(); - group.nodes_.unserialize(ser.members); - return group.getElement(); - } -} - -class Link extends EditorEntryBase { - constructor() { - super(); - - this.elem_.innerText = 'Link:'; - this.elem_.classList.add('link'); - - this.input_ = document.createElement('input'); - this.input_.type = 'text'; - this.input_.placeholder = 'label'; - this.listen(this.input_, 'keydown', (e) => this.onInputKeyDown(e)); - this.listen(this.input_, 'input', (e) => this.onInput()); - this.listen(this.input_, 'blur', (e) => this.onInput()); - this.elem_.appendChild(this.input_); - - let nodeList = document.createElement('div'); - this.nodes_ = new Editor(nodeList, [Editor.NODE]); - this.nodes_.setMinEntries(2); - this.nodes_.setMaxEntries(2); - this.nodes_.addNodeAfter(); - this.nodes_.addNodeAfter(); - this.elem_.appendChild(nodeList); - } - - afterDomAdd() { - this.input_.focus(); - } - - serialize() { - return { - type: 'link', - label: this.getLabel(), - from: this.getFrom().serialize(), - to: this.getTo().serialize(), - }; - } - - exportGraphviz() { - if (this.getFrom().getLabel() == '' || this.getTo().getLabel() == '') { - return []; - } - - let label = ''; - if (this.getLabel() != '') { - label = ' [label="' + this.getLabel() + '"]'; - } - - let ret = []; - for (let from of this.from) { - for (let to of this.to) { - ret.push('"' + from.id + '" -> "' + to.id + '"' + label + ';'); - } - } - - return ret; - }; - - clear() { - super.clear(); - } - - getFrom() { - return this.nodes_.getEntries()[0]; - } - - getTo() { - return this.nodes_.getEntries()[1]; - } - - getLabel() { - return this.input_.value; - } - - setLabel(label) { - this.input_.value = label; - this.onInput(); - } - - getElement() { - return this.elem_; - } - - onInput() { - this.input_.setAttribute('data-arch-value', this.input_.value); - } - - onInputKeyDown(e) { - switch (e.key) { - case 'Enter': - e.stopPropagation(); - e.preventDefault(); - this.stopEdit(); - { - let nodes = this.nodes_.getEntries(); - if (nodes[0].getLabel() == '') { - nodes[0].startEdit(); - } else if (nodes[1].getLabel() == '') { - nodes[1].startEdit(); - } - } - break; - - case 'Escape': - e.stopPropagation(); - e.preventDefault(); - this.stopEdit(); - break; - - case 'ArrowUp': - case 'ArrowDown': - case 'PageUp': - case 'PageDown': - this.stopEdit(); - break; - - default: - e.stopPropagation(); - break; - } - } - - onKeyDown(e) { - super.onKeyDown(e); - - switch (e.key) { - case 'Enter': - this.startEdit(); - e.stopPropagation(); - e.preventDefault(); - break; - - case 'ArrowRight': - case 'l': - this.nodes_.selectNext(); - break; - } - } - - startEdit() { - this.input_.focus(); - } - - stopEdit() { - this.elem_.focus(); - } - - static unserialize(ser) { - let link = new Link(); - link.setLabel(ser.label); - link.nodes_.clear(); - link.nodes_.unserialize([ser.from, ser.to]); - return link.getElement(); - } -} - -new Architype(document.getElementById('architype')); diff --git a/index.html b/index.html index 17d6ac0..d545d03 100644 --- a/index.html +++ b/index.html @@ -13,6 +13,6 @@
diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..1e638ee --- /dev/null +++ b/utils.js @@ -0,0 +1,8 @@ +function randStr32() { + let num = Math.floor(Math.random() * Math.pow(2, 32)); + return num.toString(16).padStart(8, '0'); +} + +function randStr64() { + return randStr32() + randStr32(); +}