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 };