diff --git a/.gitignore b/.gitignore index d8092b3c2..d8dd43d83 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ object_database/web/content/dist \#* \.#* *~ +.projectile # leading slash means only match at repo-root level /.python-version diff --git a/object_database/web/content/src/CellHandler.js b/object_database/web/content/src/CellHandler.js index a1f8b7cd5..2cbed4f53 100644 --- a/object_database/web/content/src/CellHandler.js +++ b/object_database/web/content/src/CellHandler.js @@ -62,6 +62,7 @@ class CellHandler { // devtools related messages this.sendMessageToDevtools = this.sendMessageToDevtools.bind(this); this.updateDevtools = this.updateDevtools.bind(this); + this.setupDevtools = this.setupDevtools.bind(this); } tearDownAllLiveCells() { @@ -131,6 +132,7 @@ class CellHandler { ]) ]) ); + this.setupDevtools(); this.sendMessageToDevtools({ status: "initial load" }); @@ -507,6 +509,32 @@ class CellHandler { } } + /** + * Setup utilities for devtools to call using chrome.devtools.inspectedWindow.eval() + * TODO: potentially this should pass via the content-scripts + */ + setupDevtools(){ + const overlayId = "cells-devtools-overlay"; + window.addDevtoolsHighlight = (id) => { + const cell = document.querySelector(`[data-cell-id="${id}"]`); + const rect = cell.getBoundingClientRect(); + const overlay = document.createElement("div"); + overlay.style.position = "absolute"; + overlay.style.backgroundColor = "#cec848"; + overlay.style.opacity = "0.5"; + overlay.style.left = rect.left +"px"; + overlay.style.top = rect.top + "px"; + overlay.style.height = rect.height + "px"; + overlay.style.width = rect.width + "px"; + overlay.setAttribute("id", overlayId); + document.body.append(overlay); + } + + window.removeDevtoolsHighlight = () => { + const overlays = document.querySelectorAll(`#${overlayId}`); + overlays.forEach((el) => el.remove()); + } + } sendMessageToDevtools(msg){ // TODO perhaps this should be run by a worker msg.type = "cells_devtools"; @@ -518,10 +546,12 @@ class CellHandler { **/ updateDevtools(){ const buildTree = (cell) => { - return { - name: cell.constructor.name, - identity: cell.identity, - children: mapChildren(cell.namedChildren) + if (cell.isCell) { + return { + name: cell.constructor.name, + id: cell.identity, + children: mapChildren(cell.namedChildren) + } } } // NOTE: sometimes a named child is a cell, sometimes it's an array of cells @@ -533,10 +563,16 @@ class CellHandler { if (child){ if (child instanceof Array){ child.forEach((subchild) => { - children.push(buildTree(subchild)); + const subTree = buildTree(subchild); + if (subTree){ + children.push(buildTree(subchild)); + } }) } - children.push(buildTree(child)); + const subTree = buildTree(child); + if (subTree) { + children.push(buildTree(child)); + } } }) } diff --git a/object_database/web/devtools/background.js b/object_database/web/devtools/background.js index b97fc84c0..05a81972a 100644 --- a/object_database/web/devtools/background.js +++ b/object_database/web/devtools/background.js @@ -29,7 +29,7 @@ function connected(port) { // from the target window we forward this message to the panels // if the connection is not alive we log this in the devtools's // debugger console - notifyDevtoolsPabel(msg.data); + notifyDevtoolsPanel(msg.data); }); } // notify if the port has disconnected @@ -42,7 +42,7 @@ function connected(port) { chrome.runtime.onConnect.addListener(connected); -function notifyDevtoolsPabel(msg){ +function notifyDevtoolsPanel(msg){ if (portFromPanel){ portFromPanel.postMessage(msg); } else { diff --git a/object_database/web/devtools/cells_panel.css b/object_database/web/devtools/cells_panel.css index eff198b16..369acfdb6 100644 --- a/object_database/web/devtools/cells_panel.css +++ b/object_database/web/devtools/cells_panel.css @@ -22,6 +22,9 @@ div#cell-info { justify-content: center; align-items: center; min-width: 30%; + text-align: center; + font-family: Roboto; + font-size: 20px; } .node { diff --git a/object_database/web/devtools/cells_panel.html b/object_database/web/devtools/cells_panel.html index a89140912..b53cefe6b 100644 --- a/object_database/web/devtools/cells_panel.html +++ b/object_database/web/devtools/cells_panel.html @@ -3,8 +3,10 @@ Cells - + + +
diff --git a/object_database/web/devtools/js/cell_panel.js b/object_database/web/devtools/js/cell_panel.js index 66a16a53b..2b2b34a0e 100644 --- a/object_database/web/devtools/js/cell_panel.js +++ b/object_database/web/devtools/js/cell_panel.js @@ -1,4 +1,4 @@ -import {CellsTree} from './tree.js'; +// import {CellsTree} from './tree.js'; // GLOBALS (TODO: should be handled better) let state = null; @@ -44,13 +44,69 @@ const reconnectingDisplay = () => { main.textContent = "Reconnecting: no cells loaded"; } +const updateInfoPanel = (node) => { + const infoPanel = document.getElementById("cell-info"); + const id = node.getAttribute("data-original-id"); + const name = node.name; + let info = `${name}\ncell-id: ${id}`; + const tree = document.querySelector("tree-graph"); + const parentSubtree = tree.findParentSubTree(id, tree.data); + if (parentSubtree.name.match("Subscribed")) { + info = `${info}\nsubscribed to cell-id: ${parentSubtree.id}`; + } + /* + const nodeTree = parentSubtree.children.filter((n) => { + return n.id = node.id; + })[0] + let childIds = ""; + nodeTree.children.forEach((c) => { + childIds = `${childIds}, ${c.id}`; + }); + info = `${info}\nchild node ids: ${childIds}`; + */ + infoPanel.innerText = info; +} + const cellsTreeDisplay = (cells) => { clearDisplay(); // init and run // NOTE: the tree class itself attaches the // svg element to #main - const cTree = new CellsTree(cells); - cTree.setupTree(); + // const cTree = new CellsTree(cells); + // cTree.setupTree(); + const tree = document.createElement("tree-graph"); + const main = document.getElementById("main"); + main.append(tree); + tree.setAttribute("display-depth", 4); + // setup node hover event listeners + // NOTE: these are defined on window by CellHandler + tree.onNodeMouseover = (event) => { + // highlight the corresponding element in the target window + const id = event.target.getAttribute("data-original-id"); + chrome.devtools.inspectedWindow.eval( + `window.addDevtoolsHighlight(${id})` + ); + } + tree.onNodeMouseleave = (event) => { + // un-highlight the corresponding element in the target window + chrome.devtools.inspectedWindow.eval( + `window.removeDevtoolsHighlight()` + ); + } + + tree.onNodeClick = (event) => { + updateInfoPanel(event.target); + } + tree.customizeNode = (node) => { + if (node.name == "Subscribed") { + node.style.backgroundColor = "var(--palette-beige)"; + } + // customize a tooltip here + const id = node.getAttribute("data-original-id"); + node.title = `cell-id: ${id}`; + } + // displaying tree + tree.setup(cells); } diff --git a/object_database/web/devtools/js/tree.js b/object_database/web/devtools/js/tree.js deleted file mode 100644 index a8ac2594f..000000000 --- a/object_database/web/devtools/js/tree.js +++ /dev/null @@ -1,263 +0,0 @@ - -class CellsTree extends Object { - constructor(data) { - super(); - - this.data = data; - - // basic view settings - // TODO: maybe these should be passed as params to the constructor - this.duration = 750; - this.rectW = 60; - this.rectH=30; - - // bound methods - this.setupTree = this.setupTree.bind(this); - this.update = this.update.bind(this); - this.collapse = this.collapse.bind(this); - this.redraw = this.redraw.bind(this); - this.onDblclick = this.onDblclick.bind(this); - this.onClick = this.onClick.bind(this); - this.onMouseover = this.onMouseover.bind(this); - this.onMouseleave = this.onMouseleave.bind(this); - } - - setupTree(){ - this.tree = d3.layout.tree().nodeSize([70, 40]); - const zm = d3.behavior.zoom() - .scaleExtent([1,3]) - .on("zoom", this.redraw) - // the main svg container for the tree - this.svg = d3.select("#main").append("svg") - .attr("width", "100%") - .attr("height", 1000) - .call(zm) - .append("g") - .attr( - "transform", - `translate(${350},${20})` - ); - //necessary so that zoom knows where to zoom and unzoom from - zm.translate([350, 20]); - - this.data.x0 = 0; - this.data.y0 = 700 / 2; - - // collapse all children for now - // TODO do we want this? - this.data.children.forEach(this.collapse); - - // build the tree - this.update(this.data); - } - - update(source) { - // need to define these in method scope since a number of the - // callbacks are not bound to the class. TODO - let id = 0; - const rectW = this.rectW; - const rectH = this.rectH; - - const diagonal = d3.svg.diagonal() - .projection(function (d) { - return [d.x + rectW / 2, d.y + rectH / 2]; - }); - // Compute the new tree layout. - const nodes = this.tree.nodes(this.data).reverse(); - const links = this.tree.links(nodes); - - // Normalize for fixed-depth. - nodes.forEach(function (d) { - d.y = d.depth * 180; - }); - - // Update the nodes… - const node = this.svg.selectAll("g.node") - .data(nodes, function (d) { - return d.id = ++id; - } - ); - - // Enter any new nodes at the parent's previous position. - const nodeEnter = node.enter().append("g") - .attr("class", "node") - .attr("transform", function (d) { - return `translate(${source.x0},${source.y0})`; - }) - .on("dblclick", this.onDblclick) - .on("click", this.onClick) - .on("mouseover", this.onMouseover) - .on("mouseleave", this.onMouseleave); - - - nodeEnter.append("rect") - .attr("width", this.rectW) - .attr("height", this.rectH) - .attr("stroke", "black") - .attr("stroke-width", 1) - .style("fill", function (d) { - return d._children ? "lightsteelblue" : "#fff"; - } - ); - - nodeEnter.append("text") - .attr("x", this.rectW / 2) - .attr("y", this.rectH / 2) - .attr("dy", ".35em") - .attr("text-anchor", "middle") - .text(function (d) { - // limit the name to 7 chars since text-overflow - // doesn't seem to work here - let name = d.name; - if (name.length > 7){ - name = name.slice(0, 7) + "..."; - } - return name; - } - ); - - // Transition nodes to their new position. - const nodeUpdate = node.transition() - .duration(this.duration) - .attr("transform", function (d) { - return `translate(${d.x},${d.y})`; - } - ); - - nodeUpdate.select("rect") - .attr("width", this.rectW) - .attr("height", this.rectH) - .attr("stroke", "black") - .attr("stroke-width", 1) - .style("fill", function (d) { - return d._children ? "lightsteelblue" : "#fff"; - } - ); - - nodeUpdate.select("text") - .style("fill-opacity", 1); - - // Transition exiting nodes to the parent's new position. - const nodeExit = node.exit().transition() - .duration(this.duration) - .attr("transform", function (d) { - return `translate(${source.x},${source.y})`; - } - ).remove(); - - nodeExit.select("rect") - .attr("width", this.rectW) - .attr("height", this.rectH) - .attr("stroke", "black") - .attr("stroke-width", 1); - - nodeExit.select("text"); - - // Update the links… - const link = this.svg.selectAll("path.link") - .data(links, function (d) { - return d.target.id; - } - ); - - // Enter any new links at the parent's previous position. - link.enter().insert("path", "g") - .attr("class", "link") - .attr("x", this.rectW / 2) - .attr("y", this.rectH / 2) - .attr("d", function (d) { - const o = { - x: source.x0, - y: source.y0 - }; - return diagonal({ - source: o, - target: o - }); - }); - - // Transition links to their new position. - link.transition() - .duration(this.duration) - .attr("d", diagonal); - - // Transition exiting nodes to the parent's new position. - link.exit().transition() - .duration(this.duration) - .attr("d", function (d) { - const o = { - x: source.x, - y: source.y - }; - return diagonal({ - source: o, - target: o - }); - }) - .remove(); - - // Stash the old positions for transition. - nodes.forEach(function (d) { - d.x0 = d.x; - d.y0 = d.y; - }); - } - - collapse(d) { - if (d.children) { - d._children = d.children.slice(); - d._children.forEach(this.collapse); - d.children = null; - } - } - - - // Toggle children on click. - onDblclick(d) { - // prevent the default zooming in/out behavior - d3.event.stopPropagation(); - if (d.children) { - d._children = d.children.slice(); - d.children = null; - } else { - d.children = d._children.slice(); - d._children = null; - } - this.update(d); - } - - onClick(event){ - // update the cell data - // probably should be handled by a different class - const infoDiv = document.getElementById("cell-info") - infoDiv.textContent = `${event.name} (id: ${event.identity})`; - } - - onMouseover(event){ - // highlighte the corresponding element in the target window - chrome.devtools.inspectedWindow.eval( - `document.querySelector("[data-cell-id='${event.identity}']").style.backgroundColor = 'lightblue'` - ); - } - - onMouseleave(event){ - console.log(event); - // highlighte the corresponding element in the target window - chrome.devtools.inspectedWindow.eval( - `document.querySelector("[data-cell-id='${event.identity}']").style.backgroundColor= 'initial'` - ); - } - //Redraw for zoom - redraw() { - this.svg.attr("transform", - `translate(${d3.event.translate})` - +`scale(${d3.event.scale})` - ); - } -} - - -export { - CellsTree, - CellsTree as default -} diff --git a/object_database/web/devtools/tree/README.md b/object_database/web/devtools/tree/README.md new file mode 100644 index 000000000..3dc5e0270 --- /dev/null +++ b/object_database/web/devtools/tree/README.md @@ -0,0 +1,7 @@ +## About ## + +The tree plotting library is a simple way of plotting nested list n-arry tree representations. It is build on two main web-components: the [Tree](./Tree.js) and [TreeNode](./TreeNode.js). The nodes are connected by svg lines and any node of your choosing can be used. IE `Tree` class-element simply expects that there is a `tree-node` element defined in the DOM and that is has some width and height. + +### Installation and Build ### + +In a `nodeenv` run `npm install && npm run build`. To see an example serve the [examples](./examples) directory using `python -m http.server` (or whatever web-server you prefer). diff --git a/object_database/web/devtools/tree/Tree.js b/object_database/web/devtools/tree/Tree.js new file mode 100644 index 000000000..d30c7419f --- /dev/null +++ b/object_database/web/devtools/tree/Tree.js @@ -0,0 +1,404 @@ +/** + * Tree Graph Web component + **/ + + +// Simple grid-based sheet component +const templateString = ` + +
+`; + +class Tree extends HTMLElement { + constructor() { + super(); + this.template = document.createElement("template"); + this.template.innerHTML = templateString; + this.attachShadow({ mode: "open" }); + this.shadowRoot.appendChild(this.template.content.cloneNode(true)); + + this.data; + + // bind methods + this.setup = this.setup.bind(this); + this.clear = this.clear.bind(this); + this.setupNode = this.setupNode.bind(this); + this.setupPaths = this.setupPaths.bind(this); + this.addSVGPath = this.addSVGPath.bind(this); + this.onWindowResize = this.onWindowResize.bind(this); + this.customizeNode = this.customizeNode; + // event handlers + this.onNodeDblclick = this.onNodeDblclick.bind(this); + this.onNodeMouseover = this.onNodeMouseover.bind(this); + this.onNodeMouseleave = this.onNodeMouseleave.bind(this); + this.onNodeClick = this.onNodeClick.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + } + + connectedCallback(){ + if(this.isConnected){ + // add event listeners + window.addEventListener("resize", this.onWindowResize); + document.addEventListener("keyup", this.onKeyUp); + this.setAttribute("display-depth", 3); + } + } + + disconnectedCallback(){ + document.removeEventListener("key", this.onKeyUp); + } + + customizeNode(node){ + //Noop, to be used by consumers + } + + setup(data, cache=true){ + this.clear(); + if (cache){ + // cache the data; TODO: think through this + this.data = data; + } + const wrapper = this.shadowRoot.querySelector("#wrapper"); + // wrapper.addEventListener("dblclick", this.onNodeDblclick); + const nodeDepth = document.createElement("div"); + nodeDepth.classList.add("depth"); + nodeDepth.setAttribute("id", "depth-0"); + wrapper.append(nodeDepth); + const nodeWrapper = document.createElement("div"); + nodeWrapper.classList.add("child-wrapper"); + nodeDepth.append(nodeWrapper); + this.setupNode(data, nodeWrapper, 1, true); // this is a root node + // setup the node paths + const svg = document.createElementNS('http://www.w3.org/2000/svg', "svg"); + svg.setAttribute("width", wrapper.getBoundingClientRect().width); + svg.setAttribute("height", "100%"); + svg.style.position = "absolute"; + svg.style.left = 0; + svg.style.top = 0; + wrapper.append(svg); + this.setupPaths(svg, data); + // fade the wrapper + wrapper.classList.remove("animation-fade-out"); + wrapper.classList.add("animation-fade-in"); + } + + setupNode(nodeData, wrapperDiv, depth, root=false){ + if(nodeData){ + const node = document.createElement("tree-node"); + node.name = nodeData.name; + // since the id might be a non-valid DOM element id such as int + // we prepend "node-" and keep the original id in a data attribute + node.setAttribute("id", `node-${nodeData.id}`); + node.setAttribute("data-original-id", nodeData.id); + // add mouseover and leave event handlers (implemented outside of tree) + node.addEventListener("mouseover", this.onNodeMouseover); + node.addEventListener("mouseleave", this.onNodeMouseleave); + node.addEventListener("click", this.onNodeClick); + if (root) { + node.setAttribute("data-root-node", true); + // if the node is not the root of entire tree + // mark it as such and add the dblclick event listener + // to allow up the tree navigation + if (nodeData.id !== this.data.id) { + node.classList.add("non-starting-root"); + node.addEventListener("dblclick", this.onNodeDblclick); + } + } + this.customizeNode(node); + wrapperDiv.append(node); + // setup the children in a new node depth + if (nodeData.children.length) { + // if we are at the display-depth don't iterate on the children + // simply mark that the nodes have children + if (depth == this.getAttribute("display-depth")){ + node.classList.add("non-final-leaf"); + node.addEventListener("dblclick", this.onNodeDblclick); + return; + } + // if the corresponding depth has not been added, do so now + let depthDiv = this.shadowRoot.querySelector(`#depth-${depth}`); + if (!depthDiv) { + depthDiv = document.createElement("div"); + depthDiv.setAttribute("id", `depth-${depth}`); + depthDiv.classList.add("depth"); + const wrapper = this.shadowRoot.querySelector("#wrapper"); + wrapper.append(depthDiv); + } + const childWrapper = document.createElement("div"); + childWrapper.classList.add("child-wrapper"); + depthDiv.append(childWrapper); + nodeData.children.forEach((childData) => { + this.setupNode(childData, childWrapper, depth + 1); + + }) + } + return node; + } + } + + setupPaths(svg, nodeData){ + if (nodeData) { + this.attainedDepth += 1; + const parent = this.shadowRoot.querySelector(`#node-${nodeData.id}`); + nodeData.children.forEach((childData) => { + const child = this.shadowRoot.querySelector(`#node-${childData.id}`); + if (parent && child) { + this.addSVGPath(svg, parent, child); + this.setupPaths(svg, childData); + } + }) + } + } + + /** + * I add an SVG bezier curve which starts at the bottom middle + * of the startNode and ends at the top middle of the endNode + * NODE: svg-type elemnents need to be created using the + * SVG name space, ie document.createElementNS... + * @param {svg-element} svg + * @param {element} startNode + * @param {element} endNode + */ + addSVGPath(svg, startNode, endNode){ + // generic svg path attributes setup here + const path = document.createElementNS('http://www.w3.org/2000/svg', "path"); + path.setAttribute("stroke", "var(--palette-blue)"); + path.setAttribute("stroke-width", "3px"); + path.setAttribute("fill", "transparent"); + path.setAttribute("data-start-node-id", startNode.id); + path.setAttribute("data-end-node-id", endNode.id); + + // calculate position here + const startRect = startNode.getBoundingClientRect(); + + const startY = startRect.bottom; + const startX = startRect.left + (startRect.width / 2); + const endRect = endNode.getBoundingClientRect(); + const endY = endRect.top; + const endX = endRect.left + (endRect.width / 2); + + let d; // this is the path data + if ( Math.abs(endX - startX) < 100) { + // draw a straight vertical line, ie the two nodes + // are on top of each other + d = `M ${startX} ${startY} L ${endX} ${endY}`; + } else { + // add a quadratic bezier curve path + let midX; + const midY = startY + 0.5 * (endY - startY); + let controlX; + let controlY; + if (endX < startX) { + midX = endX + 0.5 * (startX - endX); + controlX = midX + 0.5 * (startX - midX); + } else { + midX = startX + 0.5 * (endX - startX); + controlX = startX + 0.5 * (midX - startX); + } + // the controlLine is perpendicular to the line which connects + // the start to the end points + const clSlope = -1 * (endY - startY) / (endX - startX); + const clYInt = midY - clSlope * midX; + controlY = clSlope * controlX + clYInt; + /* + const midY = startY + 0.5 * (endY - startY); + let controlY = startY + 0.5 * (midY - startY); + let controlSlope = 1; + if (endX < startX) { + midX = endX + 0.5 * (startX - endX); + controlX = midX + 0.5 * (startX - midX); + controlSlope = 1.02; + } else { + midX = startX + 0.5 * (endX - startX); + controlX = startX + 0.5 * (midX - startX); + controlSlope = 0.98; + } + controlX *= controlSlope; + controlY *= controlSlope; + */ + console.log("new controls") + + d = `M ${startX} ${startY} Q ${controlX} ${controlY}, ${midX} ${midY} T ${endX} ${endY}`; + } + path.setAttribute("d", d); + svg.append(path); + } + + /** + * On a window resize I first clear the tree and then redraw it + **/ + onWindowResize(event){ + this.setup(this.data); + } + + onNodeDblclick(event){ + // if clicking on the root node, reset back to cached this.data tree + if (event.target.nodeName == "TREE-NODE") { + if (event.target.hasAttribute("data-root-node")) { + const id = event.target.getAttribute("data-original-id"); + const subTree = this.findParentSubTree(id, this.data); + this.setup(subTree, false); // do not cache this data + } else { + const id = event.target.getAttribute("data-original-id"); + const subTree = this.findSubTree(id, this.data); + this.setup(subTree, false); // do not cache this data + } + } + } + + onKeyUp(event){ + event.preventDefault(); + event.stopPropagation(); + if (event.key == "ArrowUp") { + // re-render the tree from the parent of the current root node + const rootNode = this.shadowRoot.querySelector("tree-node[data-root-node]"); + const rootNodeId = rootNode.getAttribute("data-original-id"); + const subTree = this.findParentSubTree(rootNodeId, this.data); + this.setup(subTree, false); // do not cache this data + } else if (event.key == "Esc") { + // re-render from the starting root node + this.setup(this.data, false); + } + } + + onNodeMouseover(event) { + // no-op + } + + onNodeMouseleave(event) { + // no-op + } + + onNodeClick(event) { + // no-op + } + /** + * I recursively walk the tree to find the corresponding + * node, and when I do I return its subtree + **/ + findSubTree(id, node){ + if(node.id == id) { + return node; + } + let subTree; + node.children.forEach((childNode) => { + const out = this.findSubTree(id, childNode); + if (out) { + subTree = out; + } + }) + return subTree; + } + + /** + * I recursively walk the tree to find the corresponding + * parent node, and when I do I return its subtree + **/ + findParentSubTree(id, node){ + // if already at the top of the tree return it + if (id == this.data.id) { + return this.data; + } + let subTree; + const isParent = node.children.some((child) => child.id == id) + if (isParent) { + subTree = node; + } else { + node.children.forEach((childNode) => { + const out = this.findParentSubTree(id, childNode); + if (out) { + subTree = out; + } + }) + } + return subTree; + } + clear(){ + const wrapper = this.shadowRoot.querySelector("#wrapper"); + wrapper.classList.remove("animation-fade-in"); + wrapper.classList.add("animation-fade-out"); + wrapper.replaceChildren(); + } + + static get observedAttributes() { + return ["display-depth"]; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name == "display-depth") { + this.setup(this.data); + } + } +} + +window.customElements.define("tree-graph", Tree); + +export { Tree as default, Tree } diff --git a/object_database/web/devtools/tree/TreeNode.js b/object_database/web/devtools/tree/TreeNode.js new file mode 100644 index 000000000..941c466c1 --- /dev/null +++ b/object_database/web/devtools/tree/TreeNode.js @@ -0,0 +1,53 @@ +/** + * Tree Node Web component + **/ + + +// Simple grid-based sheet component +const templateString = ` + +
+ +
+`; + +class TreeNode extends HTMLElement { + constructor() { + super(); + + this.template = document.createElement("template"); + this.template.innerHTML = templateString; + this.attachShadow({ mode: "open" }); + this.shadowRoot.appendChild(this.template.content.cloneNode(true)); + } + + set name(s){ + const name = this.shadowRoot.querySelector("#name"); + name.innerText = s; + } + + get name() { + const name = this.shadowRoot.querySelector("#name"); + return name.innerText; + } +} + +window.customElements.define("tree-node", TreeNode); + +export { TreeNode as default, TreeNode } diff --git a/object_database/web/devtools/tree/examples/tree.html b/object_database/web/devtools/tree/examples/tree.html new file mode 100644 index 000000000..7d7e0a7c0 --- /dev/null +++ b/object_database/web/devtools/tree/examples/tree.html @@ -0,0 +1,51 @@ + + + + Basic Tree Tests + + + + +
+ + + diff --git a/object_database/web/devtools/tree/main.js b/object_database/web/devtools/tree/main.js new file mode 100644 index 000000000..9109ce112 --- /dev/null +++ b/object_database/web/devtools/tree/main.js @@ -0,0 +1,6 @@ +/* Main */ +import { Tree } from "./Tree.js"; +import { TreeNode } from "./TreeNode.js"; +import LeaderLine from "leader-line"; + +export { Tree, TreeNode, LeaderLine, Tree as default }; diff --git a/object_database/web/devtools/tree/package.json b/object_database/web/devtools/tree/package.json new file mode 100644 index 000000000..8361a9956 --- /dev/null +++ b/object_database/web/devtools/tree/package.json @@ -0,0 +1,21 @@ +{ + "type": "module", + "devDependencies": { + "chai": "^4.3.4", + "esm": "^3.2.25", + "jsdom": "^19.0.0", + "jsdom-global": "^3.0.2", + "mocha": "^10.0.0", + "prettier": "^2.7.1" + }, + "scripts": { + "test": "mocha --require ./tests/test-setup.js ./tests/", + "build": "./node_modules/webpack/bin/webpack.js" + }, + "dependencies": { + "leader-line": "^1.0.7", + "skeleton-loader": "^2.0.0", + "webpack": "^5.74.0", + "webpack-cli": "^4.10.0" + } +} diff --git a/object_database/web/devtools/tree/tests/component.js b/object_database/web/devtools/tree/tests/component.js new file mode 100644 index 000000000..85f344318 --- /dev/null +++ b/object_database/web/devtools/tree/tests/component.js @@ -0,0 +1,83 @@ +/** + * Core tests for the Tree component + **/ + +import chai from "chai"; +import crypto from 'crypto'; +import { Tree } from '../tree.js'; +const assert = chai.assert; + +const treeMaker = function(depth, maxChildNum){ + let rootNode = { + name: "root", + id: "roodID", + order: -1, + children: [] + }; + rootNode = addChildren(rootNode, 0, depth, maxChildNum); + return rootNode; +} + +const addChildren = (node, depth, totalDepth, maxChildNum) => { + const numChildren = Math.ceil(Math.random()* maxChildNum); + if (depth <= totalDepth) { + for (let i = 1; i <= numChildren; i++){ + const child = { + name: `node_${depth}_${i}`, + id: "id-" + crypto.randomUUID(), + order: i, + children: [] + }; + node.children.push( + addChildren(child, depth + 1, totalDepth, maxChildNum) + ); + + } + } + return node; +} + + +describe("Tree Component Tests", () => { + let tree; + let data + before(() => { + tree = document.createElement("tree-graph"); + data = treeMaker(3, 2); + tree.setup(data); + document.body.append(tree); + }); + it("Assert the tree exists", () => { + assert.exists(tree); + }); + it("Tree has a node for each element in the data", () => { + const nodeCheck = (nodeData) => { + if (nodeData) { + const node = tree.shadowRoot.querySelector(`#${nodeData.id}`); + assert.exists(node); + nodeData.children.forEach((childData) => { + const childNode = tree.shadowRoot.querySelector(`#${childData.id}`); + assert.exists(childNode); + nodeCheck(childData); + }) + } + } + nodeCheck(data); + }); + it("Tree has leaderlines for every parent-child node", () => { + const leaderlineCheck = (nodeData) => { + if (nodeData) { + nodeData.children.forEach((childData) => { + const svg = tree.shadowRoot.querySelector( + `svg[data-start-node-id="${nodeData.id}"][data-end-node-id="${childData.id}"]` + ); + assert.exists(svg); + leaderlineCheck(childData); + }) + } + } + leaderlineCheck(data); + }); +}) + + diff --git a/object_database/web/devtools/tree/tests/test-setup.js b/object_database/web/devtools/tree/tests/test-setup.js new file mode 100644 index 000000000..01ad11442 --- /dev/null +++ b/object_database/web/devtools/tree/tests/test-setup.js @@ -0,0 +1,18 @@ +// Preload file for JSDOM required tests +import { JSDOM } from "jsdom"; + +const resetDOM = () => { + const dom = new JSDOM( + "/>/>" + ); + globalThis.window = dom.window; + globalThis.document = dom.window.document; + globalThis.HTMLElement = dom.window.HTMLElement; + globalThis.customElements = dom.window.customElements; + globalThis.CustomEvent = dom.window.CustomEvent; + globalThis.KeyboardEvent = dom.window.KeyboardEvent; +}; + +globalThis.resetDOM = resetDOM; + +resetDOM(); diff --git a/object_database/web/devtools/tree/tree.css b/object_database/web/devtools/tree/tree.css new file mode 100644 index 000000000..30489caca --- /dev/null +++ b/object_database/web/devtools/tree/tree.css @@ -0,0 +1,9 @@ +:root { + --palette-beige: #FBE8A6; + --palette-blue: #303C6C; + --palette-lightblue: #B4DFE5; + --palette-cyan: #D2FDFF; + --palette-orange: #F4976C; + --palette-black: black; + --palette-white: white; +} diff --git a/object_database/web/devtools/tree/webpack.config.js b/object_database/web/devtools/tree/webpack.config.js new file mode 100644 index 000000000..a42b6a15c --- /dev/null +++ b/object_database/web/devtools/tree/webpack.config.js @@ -0,0 +1,31 @@ +import path from "path"; +import * as url from "url"; +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); + +let config = { + entry: "./main.js", + output: { + path: path.resolve(__dirname, "dist/"), + filename: "tree.bundle.js", + }, + module: { + rules: [ + { + test: path.resolve(__dirname, "node_modules/leader-line/"), + use: [ + { + loader: "skeleton-loader", + options: { + procedure: (content) => `${content}export default LeaderLine`, + }, + }, + ], + }, + ], + }, + devtool: 'cheap-module-source-map', + mode: "development", +}; + +export { config as default };