From a7cfab2e0789790d932feee9d6cdbc5e45791226 Mon Sep 17 00:00:00 2001 From: Anders Rantala Hunderi Date: Mon, 3 Feb 2025 08:16:05 +0100 Subject: [PATCH] feat: GroupTreePlot --- Refactor to typescript + better D3 and React integration + some small features (#2367) Resolves #1829 * `refactor`: Fully rewrites the old D3-only `GroupTreePlot` component to more seemlessly use both D3 and React to build the graph * In essence; D3 is now purely used to calculate hierarchies and node positions, whilst React handles the rendering of said nodes * D3 is also used to animate transitions between graph states (assisted by React to trigger the animation changes) * ~`dependency`: Installed the `react-transition-group` package to facilitate leave-enter transitions for graph elements~ * `dependency`: Installed the [`motion` library](https://motion.dev/) to facilitate leave-enter transitions for graph elements * `refactor`: `groupTreeAssembler` has been replaced; it has been split it into by react components (to handle svg rendering), and the new `DataAssembler` class, to handle the data itself. Rewritten in proper Typescript * _Note, the old assembler would display dummy data if an empty data tree list was fed to the constructor. The new version is more picky, and will throw an error if this is done. The Core component will instead display an error showing that the data is invalid, to make it more clear that something is wrong_ * `fix`: The rendered tree is now more centered in the SVG * `fix`: The tree will now fill the entire width of the SVG * `feature/fix`: Child nodes can now be minimized and expanded, as intended * `feature`: Added `initialVisibleDepth`; renders the tree with all nodes from the given depth collapsed * `feature`: The plot is now fully resizable. * An example of resizing is show in the "Resizable" Story (`/?path=/story/grouptreeplot-demo--resizable`) ### Known issue (slight animation regression) Because each component initializes their own enter/leave animations in their enter/leave-callbakcs, the D3 animations get slightly de-synchronized, so new/old tree edges get slightly "detached" from the tree when it grows shrinks (only during the animation). The d3 only version did not have this issue (as it set up all animations in one single update), so it's a regression. However, this is only noticable in slower animations, and with a 200ms transition duration, it's more or less impossible to notice, so I'm leaving it unresolved for now --- typescript/package-lock.json | 67 +- .../example-data/dated-trees.ts | 2 +- .../packages/group-tree-plot/package.json | 3 +- .../GroupTreeAssembler/groupTreeAssembler.js | 703 ------------------ .../group-tree-plot/src/GroupTreePlot.tsx | 139 ++-- .../src/components/PlotErrorOverlay.tsx | 30 + .../TreePlotRenderer/TreePlotRenderer.tsx | 146 ++++ .../src/components/TreePlotRenderer/index.ts | 1 + .../privateComponents/HiddenChildren.tsx | 29 + .../privateComponents/TransitionTreeEdge.tsx | 82 ++ .../privateComponents/TransitionTreeNode.tsx | 103 +++ .../{GroupTreeAssembler => }/group_tree.css | 0 .../src/hooks/useCollapseMotionProps.ts | 61 ++ .../src/storybook/GroupTreePlot.stories.tsx | 92 ++- .../grouptreeplot-demo--default.png | Bin 27311 -> 28523 bytes .../grouptreeplot-demo--resizable.png | Bin 0 -> 23752 bytes .../packages/group-tree-plot/src/types.ts | 5 + .../src/utils/DataAssembler.ts | 246 ++++++ .../group-tree-plot/src/utils/treePlot.ts | 101 +++ 19 files changed, 1033 insertions(+), 777 deletions(-) delete mode 100644 typescript/packages/group-tree-plot/src/GroupTreeAssembler/groupTreeAssembler.js create mode 100644 typescript/packages/group-tree-plot/src/components/PlotErrorOverlay.tsx create mode 100644 typescript/packages/group-tree-plot/src/components/TreePlotRenderer/TreePlotRenderer.tsx create mode 100644 typescript/packages/group-tree-plot/src/components/TreePlotRenderer/index.ts create mode 100644 typescript/packages/group-tree-plot/src/components/TreePlotRenderer/privateComponents/HiddenChildren.tsx create mode 100644 typescript/packages/group-tree-plot/src/components/TreePlotRenderer/privateComponents/TransitionTreeEdge.tsx create mode 100644 typescript/packages/group-tree-plot/src/components/TreePlotRenderer/privateComponents/TransitionTreeNode.tsx rename typescript/packages/group-tree-plot/src/{GroupTreeAssembler => }/group_tree.css (100%) create mode 100644 typescript/packages/group-tree-plot/src/hooks/useCollapseMotionProps.ts create mode 100644 typescript/packages/group-tree-plot/src/storybook/__image_snapshots__/grouptreeplot-demo--resizable.png create mode 100644 typescript/packages/group-tree-plot/src/utils/DataAssembler.ts create mode 100644 typescript/packages/group-tree-plot/src/utils/treePlot.ts diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 590bb80c29..494fced6ca 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -17097,6 +17097,32 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.0.tgz", + "integrity": "sha512-Vmjl5Al7XqKHzDFnVqzi1H9hzn5w4eN/bdqXTymVpU2UuMQuz9w6UPdsL9dFBeH7loBlnu4qcEXME+nvbkcIOw==", + "dependencies": { + "motion-dom": "^11.16.4", + "motion-utils": "^11.16.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -24650,6 +24676,44 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/motion": { + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-11.18.0.tgz", + "integrity": "sha512-uJ4zNXh/4K9C5wftxHKlXLHC0Rc9dHSHPyO1P6T9XE2bTn2z8C2lOZX/M8vAmFp0gtJTJ3aYkv44lTtJSfv6+A==", + "dependencies": { + "framer-motion": "^11.18.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.16.4.tgz", + "integrity": "sha512-2wuCie206pCiP2K23uvwJeci4pMFfyQKpWI0Vy6HrCTDzDCer4TsYtT7IVnuGbDeoIV37UuZiUr6SZMHEc1Vww==", + "dependencies": { + "motion-utils": "^11.16.0" + } + }, + "node_modules/motion-utils": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.16.0.tgz", + "integrity": "sha512-ngdWPjg31rD4WGXFi0eZ00DQQqKKu04QExyv/ymlC+3k+WIgYVFbt6gS5JsFPbJODTF/r8XiE/X+SsoT9c0ocw==" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -35143,7 +35207,8 @@ "license": "MPL-2.0", "dependencies": { "d3": "^7.8.2", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "motion": "^11.18.0" }, "peerDependencies": { "react": "^17 || ^18", diff --git a/typescript/packages/group-tree-plot/example-data/dated-trees.ts b/typescript/packages/group-tree-plot/example-data/dated-trees.ts index ae329232f0..08a0213810 100644 --- a/typescript/packages/group-tree-plot/example-data/dated-trees.ts +++ b/typescript/packages/group-tree-plot/example-data/dated-trees.ts @@ -152,7 +152,7 @@ const secondDatedTree: DatedTree = { }, edge_label: "VFP14", edge_data: { - waterrate: [20, 30], + waterrate: [20, 0], oilrate: [30, 40], gasrate: [40, 50], waterinjrate: [50, 60], diff --git a/typescript/packages/group-tree-plot/package.json b/typescript/packages/group-tree-plot/package.json index f9bbe0c55f..2d91ae0d13 100644 --- a/typescript/packages/group-tree-plot/package.json +++ b/typescript/packages/group-tree-plot/package.json @@ -17,7 +17,8 @@ "license": "MPL-2.0", "dependencies": { "d3": "^7.8.2", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "motion": "^11.18.0" }, "peerDependencies": { "react": "^17 || ^18", diff --git a/typescript/packages/group-tree-plot/src/GroupTreeAssembler/groupTreeAssembler.js b/typescript/packages/group-tree-plot/src/GroupTreeAssembler/groupTreeAssembler.js deleted file mode 100644 index 3d806cf6c8..0000000000 --- a/typescript/packages/group-tree-plot/src/GroupTreeAssembler/groupTreeAssembler.js +++ /dev/null @@ -1,703 +0,0 @@ -/** This code is copied directly from - * https://github.com/anders-kiaer/webviz-subsurface-components/blob/dynamic_tree/src/lib/components/DynamicTree/group_tree.js - * This needs to be refactored to develop further - * - * 9 july 2021: refactored to use new format. - */ -import * as d3 from "d3"; -import "./group_tree.css"; -import { cloneDeep } from "lodash"; - -/* eslint camelcase: "off" */ -/* eslint array-callback-return: "off" */ -/* eslint no-return-assign: "off" */ -/* eslint no-use-before-define: "off" */ -/* eslint no-useless-concat: "off" */ -/* Fix this lint when rewriting the whole file */ - -/** - * Class to assemble Group tree visualization. Creates an _svg, and appends to the - * assigned HTML element. Draws the tree provided in datedTrees with the current flow rate, - * node info and date time. - * - * Provides methods to update selected date time, and change flow rate and node info. - */ -export default class GroupTreeAssembler { - /** - * - * @param dom_element_id - id of the HTML element to append the _svg to - * @param datedTrees - List of dated tree data structure containing the trees to visualize - * @param initialFlowRate - key identifying the initial selected flow rate for the tree edges - * @param initialNodeInfo - key identifying the initial selected node info for the tree nodes - * @param currentDateTime - the initial/current date time - * @param edgeMetadataList - List of metadata for the edge keys in the tree data structure - * @param nodeMetadataList - List of metadata for the node keys in the tree data structure - */ - constructor( - dom_element_id, - datedTrees, - initialFlowRate, - initialNodeInfo, - currentDateTime, - edgeMetadataList, - nodeMetadataList - ) { - // Cloned as it is mutated within class - let clonedDatedTrees = cloneDeep(datedTrees); - - // Add "#" if missing. - if (dom_element_id.charAt(0) !== "#") { - dom_element_id = "#" + dom_element_id; - } - - // Map from property to [label/name, unit] - const metadataList = [...edgeMetadataList, ...nodeMetadataList]; - this._propertyToLabelMap = new Map(); - metadataList.forEach((elm) => { - this._propertyToLabelMap.set(elm.key, [ - elm.label ?? "", - elm.unit ?? "", - ]); - }); - - // Represent possible empty data by single empty node. - if (clonedDatedTrees.length === 0) { - currentDateTime = ""; - clonedDatedTrees = [ - { - dates: [currentDateTime], - tree: { - node_label: "NO DATA", - edge_label: "NO DATA", - node_data: {}, - edge_data: {}, - }, - }, - ]; - } - - this._currentFlowRate = initialFlowRate; - this._currentNodeInfo = initialNodeInfo; - this._currentDateTime = currentDateTime; - - this._transitionTime = 200; - - const tree_values = {}; - - clonedDatedTrees.map((datedTree) => { - let tree = datedTree.tree; - d3.hierarchy(tree, (d) => d.children).each((node) => { - // edge_data - Object.keys(node.data.edge_data).forEach((key) => { - if (!tree_values[key]) { - tree_values[key] = []; - } - tree_values[key].push(node.data.edge_data[key]); - }); - }); - }); - - this._path_scale = new Map(); - Object.keys(tree_values).forEach((key) => { - const extent = [0, d3.max(tree_values[key].flat())]; - this._path_scale[key] = d3 - .scaleLinear() - .domain(extent) - .range([2, 100]); - }); - - const margin = { - top: 10, - right: 90, - bottom: 30, - left: 90, - }; - - const select = d3.select(dom_element_id); - - // Svg bounding client rect - this._rectWidth = select.node().getBoundingClientRect().width; - this._rectHeight = 700; - this._rectLeftMargin = -margin.left; - this._rectTopMargin = -margin.top; - - const treeHeight = this._rectHeight - margin.top - margin.bottom; - this._treeWidth = this._rectWidth - margin.left - margin.right; - - // Clear possible existing svg's. - d3.select(dom_element_id).selectAll("svg").remove(); - - this._svg = d3 - .select(dom_element_id) - .append("svg") - .attr("width", this._treeWidth + margin.right + margin.left) - .attr("height", treeHeight + margin.top + margin.bottom) - .append("g") - .attr("transform", `translate(${margin.left},${margin.top})`); - - this._textpaths = this._svg.append("g"); - - this._renderTree = d3.tree().size([treeHeight, this._treeWidth]); - - this._data = GroupTreeAssembler.initHierarchies( - clonedDatedTrees, - treeHeight - ); - - this._currentTree = {}; - - this.update(currentDateTime); - } - - /** - * Initialize all trees in the group tree datastructure, once for the entire visualization. - * - */ - static initHierarchies(tree_data, height) { - // generate the node-id used to match in the enter, update and exit selections - const getId = (d) => - d.parent === null - ? d.data.node_label - : `${d.parent.id}_${d.data.node_label}`; - - tree_data.map((datedTree) => { - let tree = datedTree.tree; - tree = d3.hierarchy(tree, (dd) => dd.children); - tree.descendants().map((n) => (n.id = getId(n))); - tree.x0 = height / 2; - tree.y0 = 0; - datedTree.tree = tree; - }); - - return tree_data; - } - - /** - * @returns {*} -The initialized hierarchical group tree data structure - */ - get data() { - return this._data; - } - - /** - * Set the flowrate and update display of all edges accordingly. - * - * @param flowrate - key identifying the flowrate of the incoming edge - */ - set flowrate(flowrate) { - this._currentFlowRate = flowrate; - - const current_tree_index = this._data.findIndex((e) => { - return e.dates.includes(this._currentDateTime); - }); - - if (current_tree_index === -1) { - this._svg.selectAll("path.link").remove(); - return; - } - - const date_index = this._data[current_tree_index].dates.indexOf( - this._currentDateTime - ); - - if (date_index === -1) { - this._svg.selectAll("path.link").remove(); - return; - } - - this._svg - .selectAll("path.link") - .transition() - .duration(this._transitionTime) - .attr( - "class", - () => `link grouptree_link grouptree_link__${flowrate}` - ) - .style("stroke-width", (d) => - this.getEdgeStrokeWidth( - flowrate, - d.data.edge_data[flowrate]?.[date_index] ?? 0 - ) - ) - .style("stroke-dasharray", (d) => { - return (d.data.edge_data[flowrate]?.[date_index] ?? 0) > 0 - ? "none" - : "5,5"; - }); - } - - get flowrate() { - return this._currentFlowRate; - } - - set nodeinfo(nodeinfo) { - this._currentNodeInfo = nodeinfo; - - const current_tree_index = this._data.findIndex((e) => { - return e.dates.includes(this._currentDateTime); - }); - - if (current_tree_index === -1) { - this._svg.selectAll("path.link").remove(); - return; - } - - const date_index = this._data[current_tree_index].dates.indexOf( - this._currentDateTime - ); - - if (date_index === -1) { - this._svg.selectAll("path.link").remove(); - return; - } - - this._svg - .selectAll(".grouptree__pressurelabel") - .text( - (d) => - d.data.node_data?.[nodeinfo]?.[date_index]?.toFixed(0) ?? - "NA" - ); - - this._svg.selectAll(".grouptree__pressureunit").text(() => { - const t = this._propertyToLabelMap.get(nodeinfo) ?? ["", ""]; - return t[1]; - }); - } - - get nodeinfo() { - return this._currentNodeInfo; - } - - getEdgeStrokeWidth(key, val) { - const normalized = - this._path_scale[key] !== undefined - ? this._path_scale[key](val ?? 0) - : 2; - return `${normalized}px`; - } - - /** - * Sets the state of the current tree, and updates the tree visualization accordingly. - * The state is changed either due to a branch open/close, or that the tree is entirely changed - * when moving back and fourth in time. - * - * @param root - */ - update(newDateTime) { - const self = this; - - const new_tree_index = self._data.findIndex((e) => { - return e.dates.includes(newDateTime); - }); - - const root = self._data[new_tree_index]; - - const date_index = root?.dates.indexOf(newDateTime) ?? -1; - - // Invalid date gives invalid indices - const hasInvalidDate = - !root || date_index === -1 || new_tree_index === -1; - - if (hasInvalidDate) { - self._currentDateTime = newDateTime; - } - - /** - * Assigns y coordinates to all tree nodes in the rendered tree. - * @param t - a rendered tree - * @param {int} width - the - * @returns a rendered tree width coordinates for all nodes. - */ - function growNewTree(t, width) { - t.descendants().forEach((d) => { - d.y = (d.depth * width) / (t.height + 1); - }); - - return t; - } - - function doPostUpdateOperations(tree) { - setEndPositions(tree.descendants()); - setNodeVisibility(tree.descendants(), true); - return tree; - } - - function findClosestVisibleParent(d) { - let c = d; - while (c.parent && !c.isvisible) { - c = c.parent; - } - return c; - } - - function getClosestVisibleParentStartCoordinates(d) { - const p = findClosestVisibleParent(d); - return { x: p.x0 ?? 0, y: p.y0 ?? 0 }; - } - - function getClosestVisibleParentEndCoordinates(d) { - const p = findClosestVisibleParent(d); - return { x: p.x, y: p.y }; - } - - /** - * Implicitly alter the state of a node, by hiding its children - * @param node - */ - function toggleBranch(node) { - if (node.children) { - node._children = node.children; - node.children = null; - } else { - node.children = node._children; - node._children = null; - } - - self.update(self._currentDateTime); - } - - /** - * Toggles visibility of a node. This state determines if the node, and its children - * @param nodes - * @param visibility - */ - function setNodeVisibility(nodes, visibility) { - nodes.forEach((d) => { - d.isvisible = visibility; - }); - } - - /** - * After node translation transition, save end position - * @param nodes - */ - function setEndPositions(nodes) { - nodes.forEach((d) => { - d.x0 = d.x; - d.y0 = d.y; - }); - } - - function getToolTipText(data, date_index) { - if (data === undefined || date_index === undefined) { - return ""; - } - - const propNames = Object.keys(data); - let text = ""; - propNames.forEach(function (s) { - const t = self._propertyToLabelMap.get(s) ?? [s, ""]; - const pre = t[0]; - const unit = t[1]; - text += - pre + - " " + - (data[s]?.[date_index]?.toFixed(0) ?? "") + - " " + - unit + - "\n"; - }); - return text; - } - - /** - * Clone old node start position to new node start position. - * Clone new node end position to old node end position. - * Clone old visibility to new. - * - * @param newRoot - * @param oldRoot - */ - function cloneExistingNodeStates(newRoot, oldRoot) { - if (Object.keys(oldRoot).length > 0) { - oldRoot.descendants().forEach((oldNode) => { - newRoot.descendants().forEach((newNode) => { - if (oldNode.id === newNode.id) { - newNode.x0 = oldNode.x0; - newNode.y0 = oldNode.y0; - - oldNode.x = newNode.x; - oldNode.y = newNode.y; - - newNode.isvisible = oldNode.isvisible; - } - }); - }); - } - return newRoot; - } - - /** - * Merge the existing tree, with nodes from a new tree. - * New nodes fold out from the closest visible parent. - * Old nodes are removed. - * - * @param nodes - list of nodes in a tree - */ - function updateNodes(nodes, nodeinfo) { - const node = self._svg.selectAll("g.node").data(nodes, (d) => d.id); - - const nodeEnter = node - .enter() - .append("g") - .attr("class", "node") - .attr("id", (d) => d.id) - .attr("transform", (d) => { - const c = getClosestVisibleParentStartCoordinates(d); - return `translate(${c.y},${c.x})`; - }) - .on("click", toggleBranch); - - nodeEnter - .append("circle") - .attr("id", (d) => d.id) - .attr("r", 6) - .transition() - .duration(self._transitionTime) - .attr("x", (d) => d.x) - .attr("y", (d) => d.y); - - nodeEnter - .append("text") - .attr("class", "grouptree__nodelabel") - .attr("dy", ".35em") - .style("fill-opacity", 1) - .attr("x", (d) => (d.children || d._children ? -21 : 21)) - .attr("text-anchor", (d) => - d.children || d._children ? "end" : "start" - ) - .text((d) => d.data.node_label); - - nodeEnter - .append("text") - .attr("class", "grouptree__pressurelabel") - .attr("x", 0) - .attr("dy", "-.05em") - .attr("text-anchor", "middle") - .text( - (d) => - d.data.node_data[nodeinfo]?.[date_index]?.toFixed(0) ?? - "NA" - ); - - nodeEnter - .append("text") - .attr("class", "grouptree__pressureunit") - .attr("x", 0) - .attr("dy", ".04em") - .attr("dominant-baseline", "text-before-edge") - .attr("text-anchor", "middle") - .text(() => { - const t = self._propertyToLabelMap.get(nodeinfo) ?? [ - "", - "", - ]; - return t[1]; - }); - - nodeEnter - .append("title") - .text((d) => getToolTipText(d.data.node_data, date_index)); - - const nodeUpdate = nodeEnter.merge(node); - - // Nodes from earlier exit selection may reenter if transition is interupted. Restore state. - nodeUpdate - .filter(".exiting") - .interrupt() - .classed("exiting", false) - .attr("opacity", 1); - - nodeUpdate - .select("text.grouptree__pressurelabel") - .text( - (d) => - d.data.node_data[nodeinfo]?.[date_index]?.toFixed(0) ?? - "NA" - ); - - nodeUpdate - .transition() - .duration(self._transitionTime) - .attr("transform", (d) => `translate(${d.y},${d.x})`); - - nodeUpdate - .select("circle") - .attr( - "class", - (d) => - `${"grouptree__node" + " "}${ - d.children || d._children - ? "grouptree__node--withchildren" - : "grouptree__node" - }` - ) - .transition() - .duration(self._transitionTime) - .attr("r", 15); - - nodeUpdate - .select("title") - .text((d) => getToolTipText(d.data.node_data, date_index)); - - node.exit() - .classed("exiting", true) - .attr("opacity", 1) - .transition() - .duration(self._transitionTime) - .attr("opacity", 1e-6) - .attr("transform", (d) => { - d.isvisible = false; - const c = getClosestVisibleParentEndCoordinates(d); - return `translate(${c.y},${c.x})`; - }) - .remove(); - } - - /** - * Draw new edges, and update existing ones. - * - * @param edges -list of edges in a tree - * @param flowrate - key identifying the flowrate of the incoming edge - */ - function updateEdges(edges, flowrate) { - const link = self._svg - .selectAll("path.link") - .data(edges, (d) => d.id); - - const linkEnter = link - .enter() - .insert("path", "g") - .attr("id", (d) => `path ${d.id}`) - .attr("d", (d) => { - const c = getClosestVisibleParentStartCoordinates(d); - return diagonal(c, c); - }); - - linkEnter - .append("title") - .text((d) => getToolTipText(d.data.edge_data, date_index)); - - const linkUpdate = linkEnter.merge(link); - - linkUpdate - .attr( - "class", - () => `link grouptree_link grouptree_link__${flowrate}` - ) - .transition() - .duration(self._transitionTime) - .attr("d", (d) => diagonal(d, d.parent)) - .style("stroke-width", (d) => - self.getEdgeStrokeWidth( - flowrate, - d.data.edge_data[flowrate]?.[date_index] ?? 0 - ) - ) - .style("stroke-dasharray", (d) => { - return (d.data.edge_data[flowrate]?.[date_index] ?? 0) > 0 - ? "none" - : "5,5"; - }); - - linkUpdate - .select("title") - .text((d) => getToolTipText(d.data.edge_data, date_index)); - - link.exit() - .transition() - .duration(self._transitionTime) - .attr("d", (d) => { - d.isvisible = false; - const c = getClosestVisibleParentEndCoordinates(d); - return diagonal(c, c); - }) - .remove(); - - /** - * Create the curve definition for the edge between node s and node d. - * @param s - source node - * @param d - destination node - */ - function diagonal(s, d) { - return `M ${d.y} ${d.x} - C ${(d.y + s.y) / 2} ${d.x}, - ${(d.y + s.y) / 2} ${s.x}, - ${s.y} ${s.x}`; - } - } - - /** - * Add new and update existing texts/textpaths on edges. - * - * @param edges - list of edges in a tree - */ - function updateEdgeTexts(edges) { - const textpath = self._textpaths - .selectAll(".edge_info_text") - .data(edges, (d) => d.id); - - const enter = textpath - .enter() - .insert("text") - .attr("dominant-baseline", "central") - .attr("text-anchor", "middle") - .append("textPath") - .attr("class", "edge_info_text") - .attr("startOffset", "50%") - .attr("xlink:href", (d) => `#path ${d.id}`); - - enter - .merge(textpath) - .attr("fill-opacity", 1e-6) - .transition() - .duration(self._transitionTime) - .attr("fill-opacity", 1) - .text((d) => d.data.edge_label); - - textpath.exit().remove(); - } - - // Clear any existing error overlay - this._svg - .selectAll(".error-overlay-background, .error-overlay") - .remove(); - - if (hasInvalidDate) { - // // Add opacity to overlay background - this._svg - .append("rect") - .attr("class", "error-overlay-background") - .attr("width", this._rectWidth) - .attr("height", this._rectHeight) - .attr("x", this._rectLeftMargin) - .attr("y", this._rectTopMargin) - .attr("fill", "rgba(255, 255, 255, 0.8)"); - - // Show overlay text with error message - this._svg - .append("text") - .attr("class", "error-overlay") - .attr("x", this._rectWidth / 2 + 2 * this._rectLeftMargin) - .attr("y", this._rectHeight / 2 + 2 * this._rectTopMargin) - .style("fill", "red") - .style("font-size", "16px") - .text("Date not found in data"); - } else { - // Grow new tree - const newTree = cloneExistingNodeStates( - growNewTree(this._renderTree(root.tree), this._treeWidth), - this._currentTree - ); - - // execute visualization operations on enter, update and exit selections - updateNodes(newTree.descendants(), this.nodeinfo); - updateEdges(newTree.descendants().slice(1), this.flowrate); - updateEdgeTexts(newTree.descendants().slice(1)); - - // save the state of the now current tree, before next update - this._currentTree = doPostUpdateOperations(newTree); - } - } -} diff --git a/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx b/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx index d0eb6cf3dc..93e1f144f7 100644 --- a/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx +++ b/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx @@ -1,8 +1,15 @@ import React from "react"; +import _ from "lodash"; + +import "./group_tree.css"; -import GroupTreeAssembler from "./GroupTreeAssembler/groupTreeAssembler"; import type { DatedTree, EdgeMetadata, NodeMetadata } from "./types"; -import { isEqual } from "lodash"; +import { TreePlotRenderer } from "./components/TreePlotRenderer"; +import { PlotErrorOverlay } from "./components/PlotErrorOverlay"; +import { + useDataAssembler, + useUpdateAssemblerDate, +} from "./utils/DataAssembler"; export interface GroupTreePlotProps { id: string; @@ -12,76 +19,80 @@ export interface GroupTreePlotProps { selectedEdgeKey: string; selectedNodeKey: string; selectedDateTime: string; + + initialVisibleDepth?: number; } -export const GroupTreePlot: React.FC = ( - props: GroupTreePlotProps -) => { - const divRef = React.useRef(null); - const groupTreeAssemblerRef = React.useRef(); +export function GroupTreePlot(props: GroupTreePlotProps): React.ReactNode { + // References to handle resizing + const svgRootRef = React.useRef(null); + const [svgHeight, setSvgHeight] = React.useState(0); + const [svgWidth, setSvgWidth] = React.useState(0); - // State to ensure divRef is defined before creating GroupTree - const [isMounted, setIsMounted] = React.useState(false); + const [dataAssembler, initError] = useDataAssembler( + props.datedTrees, + props.edgeMetadataList, + props.nodeMetadataList + ); - // Remove when typescript version is implemented using ref - const [prevId, setPrevId] = React.useState(null); + const dateUpdateError = useUpdateAssemblerDate( + dataAssembler, + props.selectedDateTime + ); - const [prevDatedTrees, setPrevDatedTrees] = React.useState< - DatedTree[] | null - >(null); + const errorToPrint = initError ?? dateUpdateError; - const [prevSelectedEdgeKey, setPrevSelectedEdgeKey] = - React.useState(props.selectedEdgeKey); - const [prevSelectedNodeKey, setPrevSelectedNodeKey] = - React.useState(props.selectedNodeKey); - const [prevSelectedDateTime, setPrevSelectedDateTime] = - React.useState(props.selectedDateTime); + // Mount hook + React.useEffect(function setupResizeObserver() { + if (!svgRootRef.current) throw new Error("Expected root ref to be set"); - React.useEffect(function initialRender() { - setIsMounted(true); - }, []); + const svgElement = svgRootRef.current; - if ( - isMounted && - divRef.current && - (!isEqual(prevDatedTrees, props.datedTrees) || - prevId !== divRef.current.id) - ) { - setPrevDatedTrees(props.datedTrees); - setPrevId(divRef.current.id); - groupTreeAssemblerRef.current = new GroupTreeAssembler( - divRef.current.id, - props.datedTrees, - props.selectedEdgeKey, - props.selectedNodeKey, - props.selectedDateTime, - props.edgeMetadataList, - props.nodeMetadataList + // Debounce to avoid excessive re-renders + const debouncedResizeObserverCheck = _.debounce( + function debouncedResizeObserverCheck(entries) { + if (!Array.isArray(entries)) return; + if (!entries.length) return; + + const entry = entries[0]; + + setSvgWidth(entry.contentRect.width); + setSvgHeight(entry.contentRect.height); + }, + 100 ); - } - - if (prevSelectedEdgeKey !== props.selectedEdgeKey) { - setPrevSelectedEdgeKey(props.selectedEdgeKey); - if (groupTreeAssemblerRef.current) { - groupTreeAssemblerRef.current.flowrate = props.selectedEdgeKey; - } - } - - if (prevSelectedNodeKey !== props.selectedNodeKey) { - setPrevSelectedNodeKey(props.selectedNodeKey); - if (groupTreeAssemblerRef.current) { - groupTreeAssemblerRef.current.nodeinfo = props.selectedNodeKey; - } - } - - if (prevSelectedDateTime !== props.selectedDateTime) { - setPrevSelectedDateTime(props.selectedDateTime); - if (groupTreeAssemblerRef.current) { - groupTreeAssemblerRef.current.update(props.selectedDateTime); - } - } - - return
; -}; + + // Since the debounce will delay calling the setters, we call them early now + setSvgHeight(svgElement.getBoundingClientRect().height); + setSvgWidth(svgElement.getBoundingClientRect().width); + + // Set up a resize-observer to check for svg size changes + const resizeObserver = new ResizeObserver(debouncedResizeObserverCheck); + resizeObserver.observe(svgElement); + + // Cleanup on unmount + return () => { + debouncedResizeObserverCheck.cancel(); + resizeObserver.disconnect(); + }; + }, []); + + return ( + + {dataAssembler && svgHeight && svgWidth && ( + + )} + + {errorToPrint && } + + ); +} GroupTreePlot.displayName = "GroupTreePlot"; diff --git a/typescript/packages/group-tree-plot/src/components/PlotErrorOverlay.tsx b/typescript/packages/group-tree-plot/src/components/PlotErrorOverlay.tsx new file mode 100644 index 0000000000..5dab68f0a4 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/components/PlotErrorOverlay.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +export type PlotErrorOverlayProps = { + message: string; +}; + +export function PlotErrorOverlay( + props: PlotErrorOverlayProps +): React.ReactNode { + return ( + <> + + + {props.message} + + + ); +} diff --git a/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/TreePlotRenderer.tsx b/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/TreePlotRenderer.tsx new file mode 100644 index 0000000000..875e54506d --- /dev/null +++ b/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/TreePlotRenderer.tsx @@ -0,0 +1,146 @@ +import React from "react"; +import type { ReactNode } from "react"; +import _ from "lodash"; +import * as d3 from "d3"; + +import { AnimatePresence } from "motion/react"; + +import type { D3TreeNode, RecursiveTreeNode } from "../../types"; +import type { DataAssembler } from "../../utils/DataAssembler"; + +import { makeLinkId, makeNodeId } from "../../utils/treePlot"; +import { TransitionTreeEdge } from "./privateComponents/TransitionTreeEdge"; +import { TransitionTreeNode } from "./privateComponents/TransitionTreeNode"; + +export type TreePlotRendererProps = { + dataAssembler: DataAssembler; + primaryEdgeProperty: string; + primaryNodeProperty: string; + height: number; + width: number; + + initialVisibleDepth?: number; +}; + +const PLOT_MARGINS = { + top: 10, + right: 120, + bottom: 10, + left: 70, +}; + +export function TreePlotRenderer(props: TreePlotRendererProps): ReactNode { + const activeTree = props.dataAssembler.getActiveTree(); + const rootTreeNode = activeTree.tree; + + const [nodeCollapseFlags, setNodeCollapseFlags] = React.useState< + Record + >({}); + + const heightPadding = PLOT_MARGINS.top + PLOT_MARGINS.bottom; + const widthPadding = PLOT_MARGINS.left + PLOT_MARGINS.right; + const layoutHeight = props.height - heightPadding; + const layoutWidth = props.width - widthPadding; + + const treeLayout = React.useMemo( + function computeLayout() { + // Note that we invert height / width to render the tree sideways + return d3 + .tree() + .size([layoutHeight, layoutWidth]); + }, + [layoutHeight, layoutWidth] + ); + + const nodeTree = React.useMemo( + function computeTree() { + const hierarchy = d3.hierarchy(rootTreeNode).each((node) => { + // Collapse nodes based on collapse flags and minimum depth + // ! I'd argue that it'd be more correct to collapse nodes using the hierarchy constructor: + // d3.hierarchy(rootTreeNode, (datum) => { + // if(someCheck) return null + // else return datum.children + // }) + // However, nodes are being collapsed after-the-fact here, since we need the depth value for implicit collapses, and it's not available in the constructor + + const collapseFlag = nodeCollapseFlags[makeNodeId(node)]; + const visibleDepth = + props.initialVisibleDepth ?? Number.MAX_SAFE_INTEGER; + + // Return only if collapse flag is *explicitly* set to false + if (collapseFlag === false) return; + if (node.depth >= visibleDepth || collapseFlag === true) { + node.children = undefined; + } + }); + + return treeLayout(hierarchy); + }, + [treeLayout, rootTreeNode, nodeCollapseFlags, props.initialVisibleDepth] + ); + + // Storing the previous value so entering nodes know where to expand from + const oldNodeTree = usePreviousTree(nodeTree); + + function toggleNodeCollapse(node: D3TreeNode) { + const nodeIdent = makeNodeId(node); + // Might be collapsed implicitly due to visibleDepth prop + const collapsed = Boolean(node.children?.length); + + setNodeCollapseFlags((prev) => { + const existingVal = prev[nodeIdent]; + + const newVal = collapsed && !existingVal; + const newFlags = { ...prev, [nodeIdent]: newVal }; + + // When closing a node, reset any stored flag for all children + if (newVal) { + node.descendants() + // descendants() includes this node, slice to skip it + .slice(1) + .forEach((child) => delete newFlags[makeNodeId(child)]); + } + + return newFlags; + }); + } + + return ( + + + {nodeTree.links().map((link) => ( + + ))} + {nodeTree.descendants().map((node) => ( + + ))} + + + ); +} + +function usePreviousTree(treeRootNode: D3TreeNode): D3TreeNode { + // Make the first "old" tree be just the root, so everything expands from the root + const initRoot: D3TreeNode = _.clone(treeRootNode); + initRoot.children = undefined; + + const oldNodeTree = React.useRef(initRoot); + React.useEffect(() => { + oldNodeTree.current = treeRootNode; + }, [treeRootNode]); + + return oldNodeTree.current; +} diff --git a/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/index.ts b/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/index.ts new file mode 100644 index 0000000000..ba15ce865e --- /dev/null +++ b/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/index.ts @@ -0,0 +1 @@ +export { TreePlotRenderer } from "./TreePlotRenderer"; diff --git a/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/privateComponents/HiddenChildren.tsx b/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/privateComponents/HiddenChildren.tsx new file mode 100644 index 0000000000..fbec9f5714 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/privateComponents/HiddenChildren.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import type { RecursiveTreeNode } from "../../../types"; + +export type HiddenChildrenProps = { + hiddenChildren: RecursiveTreeNode[]; +}; + +export function HiddenChildren(props: HiddenChildrenProps): React.ReactNode { + let msg = "+ " + props.hiddenChildren.length; + if (props.hiddenChildren.length > 1) { + msg += " children"; + } else { + msg += " child"; + } + + return ( + + + {msg} + + + ); +} diff --git a/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/privateComponents/TransitionTreeEdge.tsx b/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/privateComponents/TransitionTreeEdge.tsx new file mode 100644 index 0000000000..7c9ea06bc4 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/privateComponents/TransitionTreeEdge.tsx @@ -0,0 +1,82 @@ +import React from "react"; + +import { motion } from "motion/react"; + +import { diagonalPath } from "../../../utils/treePlot"; +import type { DataAssembler } from "../../../utils/DataAssembler"; +import type { D3TreeEdge, D3TreeNode } from "../../../types"; +import { useCollapseMotionProps } from "../../../hooks/useCollapseMotionProps"; + +export type TreeEdgeProps = { + link: D3TreeEdge; + dataAssembler: DataAssembler; + primaryEdgeProperty: string; + + // Kinda messy solution for this, might warrant a change in the future + oldNodeTree: D3TreeNode; +}; + +export function TransitionTreeEdge(props: TreeEdgeProps): React.ReactNode { + const linkPath = diagonalPath(props.link); + + const mainTreeNode = props.link.target.data; + const edgeData = mainTreeNode.edge_data; + + const linkId = React.useId(); + + const groupPropertyStrokeClass = `grouptree_link__${props.primaryEdgeProperty}`; + + const edgeTooltip = props.dataAssembler.getTooltip(edgeData); + const normalizedValue = props.dataAssembler.normalizeValue( + edgeData, + props.primaryEdgeProperty + ); + + // Keep minimum width at 2 so line doesnt dissapear + const strokeWidth = Math.max(normalizedValue, 2); + + const motionProps = useCollapseMotionProps( + props.link.target, + props.oldNodeTree, + { + strokeOpacity: 1, + strokeWidth: strokeWidth, + d: linkPath, + }, + (collapseTarget: D3TreeNode) => ({ + strokeOpacity: 0, + strokeWidth: strokeWidth / 4, + d: diagonalPath({ source: collapseTarget, target: collapseTarget }), + }) + ); + + return ( + + 0 ? "none" : "5,5"} + > + {edgeTooltip} + + + + + {mainTreeNode.edge_label} + + + + ); +} diff --git a/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/privateComponents/TransitionTreeNode.tsx b/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/privateComponents/TransitionTreeNode.tsx new file mode 100644 index 0000000000..53b2ba30f9 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/components/TreePlotRenderer/privateComponents/TransitionTreeNode.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { motion } from "motion/react"; +import type { DataAssembler } from "../../../utils/DataAssembler"; +import type { D3TreeNode } from "../../../types"; +import { printTreeValue } from "../../../utils/treePlot"; +import { useCollapseMotionProps } from "../../../hooks/useCollapseMotionProps"; + +import { HiddenChildren } from "./HiddenChildren"; + +export type TransitionTreeNodeProps = { + primaryNodeProperty: string; + dataAssembler: DataAssembler; + node: D3TreeNode; + + oldNodeTree: D3TreeNode; + + onNodeClick?: ( + node: TransitionTreeNodeProps["node"], + evt: React.MouseEvent + ) => void; +}; + +export function TransitionTreeNode( + props: TransitionTreeNodeProps +): React.ReactNode { + const recursiveTreeNode = props.node.data; + const nodeData = recursiveTreeNode.node_data; + // ! This is whether the node is a leaf *in the actual tree*, not the rendered one + const isLeaf = !recursiveTreeNode.children?.length; + const canBeExpanded = !props.node.children?.length && !isLeaf; + const nodeLabel = recursiveTreeNode.node_label; + + let circleClass = "grouptree__node"; + if (!isLeaf) circleClass += " grouptree__node--withchildren"; + + const [, primaryUnit] = props.dataAssembler.getPropertyInfo( + props.primaryNodeProperty + ); + + const toolTip = props.dataAssembler.getTooltip(nodeData); + const primaryNodeValue = props.dataAssembler.getPropertyValue( + nodeData, + props.primaryNodeProperty + ); + + const motionProps = useCollapseMotionProps( + props.node, + props.oldNodeTree, + { + opacity: 1, + x: props.node.y, + y: props.node.x, + }, + (collapseTarget: D3TreeNode) => ({ + opacity: 0, + x: collapseTarget.y, + y: collapseTarget.x, + }) + ); + + return ( + props.onNodeClick?.(props.node, evt)} + > + + + {nodeLabel} + + + + {printTreeValue(primaryNodeValue)} + + + + {primaryUnit} + + {toolTip} + + {canBeExpanded && ( + + )} + + ); +} diff --git a/typescript/packages/group-tree-plot/src/GroupTreeAssembler/group_tree.css b/typescript/packages/group-tree-plot/src/group_tree.css similarity index 100% rename from typescript/packages/group-tree-plot/src/GroupTreeAssembler/group_tree.css rename to typescript/packages/group-tree-plot/src/group_tree.css diff --git a/typescript/packages/group-tree-plot/src/hooks/useCollapseMotionProps.ts b/typescript/packages/group-tree-plot/src/hooks/useCollapseMotionProps.ts new file mode 100644 index 0000000000..5720fa3537 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/hooks/useCollapseMotionProps.ts @@ -0,0 +1,61 @@ +import type { + SVGMotionProps, + TargetAndTransition, + Variants, + Transition, +} from "motion/dist/react"; +import type { D3TreeNode } from "../types"; +import { findClosestVisibleInNewTree } from "../utils/treePlot"; + +/** + * Settings for the tree's animation timing + */ +export const TREE_MOTION_TRANSITION: Transition = { + // Other settings that might be nice + // type: "tween", + // ease: "anticipate", + // ease: (@motion).cubicBezier(0.77, 0.12, 0.54, 0.91), + + type: "spring", + bounce: 0, + duration: 0.5, +}; + +/** + * Generates component properties for a tree element that animates to collapse/expand into other nodes in tree + * @param node The main node that should be animated (for edges, the target node) + * @param oldTreeRoot A reference to the old tree + * @param animationTarget The normal animation target for the node + * @param buildCollapseTargets A generator method to build an animation target for the node fully collapsed + * @returns A properties object for the node's animation + */ +export function useCollapseMotionProps( + node: D3TreeNode, + oldTreeRoot: D3TreeNode, + animationTarget: TargetAndTransition, + buildCollapseTargets: (targetNode: D3TreeNode) => TargetAndTransition +): SVGMotionProps { + const variants: Variants = { + expand() { + const collapseInto = findClosestVisibleInNewTree(node, oldTreeRoot); + return buildCollapseTargets(collapseInto); + }, + expanded() { + return animationTarget; + }, + collapse(futureTree?: D3TreeNode) { + if (!futureTree) return {}; + + const collapseInto = findClosestVisibleInNewTree(node, futureTree); + return buildCollapseTargets(collapseInto); + }, + }; + + return { + initial: "expand", + animate: "expanded", + exit: "collapse", + transition: TREE_MOTION_TRANSITION, + variants, + }; +} diff --git a/typescript/packages/group-tree-plot/src/storybook/GroupTreePlot.stories.tsx b/typescript/packages/group-tree-plot/src/storybook/GroupTreePlot.stories.tsx index 734c68abed..ccec0124d3 100644 --- a/typescript/packages/group-tree-plot/src/storybook/GroupTreePlot.stories.tsx +++ b/typescript/packages/group-tree-plot/src/storybook/GroupTreePlot.stories.tsx @@ -1,7 +1,8 @@ -import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; +import _ from "lodash"; +import type { Meta, StoryObj } from "@storybook/react"; -import { GroupTreePlot } from "../GroupTreePlot"; +import { GroupTreePlot, type GroupTreePlotProps } from "../GroupTreePlot"; import type { EdgeMetadata, NodeMetadata } from "../types"; @@ -10,13 +11,15 @@ import { exampleDates, } from "../../example-data/dated-trees"; -const stories: Meta = { +const stories: Meta = { component: GroupTreePlot, title: "GroupTreePlot/Demo", argTypes: { selectedDateTime: { description: "The selected `string` must be a date time present in one of the `dates` arrays in an element of the`datedTrees`-prop.\n\n", + options: exampleDates, + control: { type: "select" }, }, selectedEdgeKey: { description: @@ -26,6 +29,10 @@ const stories: Meta = { description: "The selected `string` must be a node key present in one of the `node_data` objects in the `tree`-prop of an element in `datedTrees`-prop.\n\n", }, + initialVisibleDepth: { + description: + "When initially rendering the tree, automatically collapse all nodes at or below this depth", + }, }, }; export default stories; @@ -34,8 +41,7 @@ export default stories; * Storybook test for the group tree plot component */ -// @ts-expect-error TS7006 -const Template = (args) => { +const Template = (args: GroupTreePlotProps) => { return ( { selectedDateTime={args.selectedDateTime} selectedEdgeKey={args.selectedEdgeKey} selectedNodeKey={args.selectedNodeKey} + initialVisibleDepth={args.initialVisibleDepth} /> ); }; @@ -60,7 +67,7 @@ const edgeMetadataList: EdgeMetadata[] = [ const nodeMetadataList: NodeMetadata[] = [ { key: "pressure", label: "Pressure", unit: "Bar" }, { key: "bhp", label: "Bottom Hole Pressure", unit: "N/m2" }, - { key: "wmctl", label: "Missing label", unit: "Unknown unit" }, + { key: "wmctl", label: "" }, ]; export const Default: StoryObj = { @@ -73,5 +80,76 @@ export const Default: StoryObj = { selectedEdgeKey: edgeMetadataList[0].key, selectedNodeKey: nodeMetadataList[0].key, }, - render: (args) =>