diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 221fcc65c4..21831280ba 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -35431,7 +35431,8 @@ "license": "MPL-2.0", "dependencies": { "d3": "^7.8.2", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^17 || ^18", diff --git a/typescript/packages/group-tree-plot/package.json b/typescript/packages/group-tree-plot/package.json index 17f54b80d2..70c3bcda05 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", + "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^17 || ^18", diff --git a/typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts new file mode 100644 index 0000000000..68f97db731 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts @@ -0,0 +1,149 @@ +import * as d3 from "d3"; + +import type { + DatedTree, + EdgeData, + EdgeMetadata, + NodeData, + NodeMetadata, +} from "../types"; +import _ from "lodash"; +import { printTreeValue } from "../utils"; + +export default class DataAssembler { + datedTrees: DatedTree[]; + edgeMetadataList: EdgeMetadata[]; + nodeMetadataList: NodeMetadata[]; + + private _propertyToLabelMap: Map; + private _propertyMaxVals: Map; + + private _currentTreeIndex = -1; + private _currentDateIndex = -1; + + constructor( + datedTrees: DatedTree[], + edgeMetadataList: EdgeMetadata[], + nodeMetadataList: NodeMetadata[] + ) { + this.edgeMetadataList = edgeMetadataList; + this.nodeMetadataList = nodeMetadataList; + + // Represent possible empty data by single empty node. + if (datedTrees.length) { + this.datedTrees = datedTrees; + } else { + throw new Error("Tree-list is empty"); + } + + this._propertyToLabelMap = new Map(); + [...edgeMetadataList, ...nodeMetadataList].forEach((elm) => { + this._propertyToLabelMap.set(elm.key, [ + elm.label ?? "", + elm.unit ?? "", + ]); + }); + + this._propertyMaxVals = new Map(); + this.datedTrees.forEach(({ tree }) => { + // Utilizing d3 to iterate over each child node from our tree-root + d3.hierarchy(tree, (d) => d.children).each((node) => { + const { edge_data } = node.data; + + Object.entries(edge_data).forEach(([key, vals]) => { + const existingMax = + this._propertyMaxVals.get(key) ?? Number.MIN_VALUE; + + const newMax = Math.max(...vals, existingMax); + + this._propertyMaxVals.set(key, newMax); + }); + }); + }); + + this._currentTreeIndex = 0; + this._currentDateIndex = 0; + } + + setActiveDate(newDate: string) { + const [newTreeIdx, newDateIdx] = findTreeAndDateIndex( + newDate, + this.datedTrees + ); + + // I do think these will always both be -1, or not -1, so checking both might be excessive + if (newTreeIdx === -1 || newDateIdx == -1) { + throw new Error("Invalid date for data assembler"); + } + + this._currentTreeIndex = newTreeIdx; + this._currentDateIndex = newDateIdx; + } + + getActiveTree(): DatedTree { + return this.datedTrees[this._currentTreeIndex]; + } + + getTooltip(data: NodeData | EdgeData) { + if (this._currentDateIndex === -1) return ""; + + let text = ""; + + for (const propName in data) { + const [label, unit] = this.getPropertyInfo(propName); + const value = this.getPropertyValue(data, propName); + const valueString = printTreeValue(value); + + text += `${label}: ${valueString} ${unit}\n`; + } + + return text.trimEnd(); + } + + getPropertyValue(data: EdgeData | NodeData, property: string) { + if (this._currentDateIndex === -1) return null; + + const value = data[property]?.[this._currentDateIndex]; + + return value ?? null; + } + + getPropertyInfo(propertyKey: string) { + const infos = this._propertyToLabelMap.get(propertyKey); + const [label, unit] = infos ?? ["", ""]; + + return [ + label !== "" ? label : _.upperFirst(propertyKey), + unit !== "" ? unit : "?", + ]; + } + + normalizeValue(property: string, value: number) { + const maxVal = this._propertyMaxVals.get(property); + + // Invalid property, return a default of 2 + if (!maxVal) return 2; + + return d3.scaleLinear().domain([0, maxVal]).range([2, 100])(value); + } +} + +function findTreeAndDateIndex( + targetDate: string, + datedTrees: DatedTree[] +): [treeIndex: number, dateIndex: number] { + // Using standard for loop so we can return early when we find a matching tree + for (let treeIdx = 0; treeIdx < datedTrees.length; treeIdx++) { + const datedTree = datedTrees[treeIdx]; + const dateIdx = datedTree.dates.findIndex( + (date) => date === targetDate + ); + + if (dateIdx !== -1) { + return [treeIdx, dateIdx]; + } + } + + // No matching entry found + return [-1, -1]; +} diff --git a/typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts new file mode 100644 index 0000000000..468dc74a31 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts @@ -0,0 +1,49 @@ +import React from "react"; + +import type { + DatedTree, + EdgeData, + EdgeMetadata, + NodeData, + NodeMetadata, +} from "../types"; +import DataAssembler from "./DataAssembler"; + +export function useDataAssembler( + datedTrees: DatedTree[], + edgeMetadataList: EdgeMetadata[], + nodeMetadataList: NodeMetadata[] +): DataAssembler | null { + const dataAssembler = React.useMemo(() => { + if (datedTrees.length === 0) return null; + + const assembler = new DataAssembler( + datedTrees, + edgeMetadataList, + nodeMetadataList + ); + + return assembler; + }, [datedTrees, edgeMetadataList, nodeMetadataList]); + + return dataAssembler; +} + +export function useDataAssemblerTree(assembler: DataAssembler) { + return assembler.getActiveTree(); +} + +export function useDataAssemblerPropertyValue( + assembler: DataAssembler, + data: NodeData | EdgeData, + property: string +): number | null { + return assembler.getPropertyValue(data, property); +} + +export function useDataAssemblerTooltip( + assembler: DataAssembler, + data: NodeData | EdgeData +): string { + return assembler.getTooltip(data); +} 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..85bfb719eb 100644 --- a/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx +++ b/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx @@ -1,8 +1,11 @@ import React from "react"; +import _ from "lodash"; -import GroupTreeAssembler from "./GroupTreeAssembler/groupTreeAssembler"; import type { DatedTree, EdgeMetadata, NodeMetadata } from "./types"; -import { isEqual } from "lodash"; +import TreePlotRenderer from "./TreePlotRenderer/index"; +import { useDataAssembler } from "./DataAssembler/DataAssemblerHooks"; +import { PlotErrorOverlay } from "./PlotErrorOverlay"; +import type DataAssembler from "./DataAssembler/DataAssembler"; export interface GroupTreePlotProps { id: string; @@ -12,76 +15,93 @@ export interface GroupTreePlotProps { selectedEdgeKey: string; selectedNodeKey: string; selectedDateTime: string; -} -export const GroupTreePlot: React.FC = ( - props: GroupTreePlotProps -) => { - const divRef = React.useRef(null); - const groupTreeAssemblerRef = React.useRef(); + initialVisibleDepth?: number; +} - // State to ensure divRef is defined before creating GroupTree - const [isMounted, setIsMounted] = React.useState(false); +export function GroupTreePlot(props: GroupTreePlotProps): React.ReactNode { + let errorMsg = ""; - // Remove when typescript version is implemented using ref - const [prevId, setPrevId] = React.useState(null); + // References to handle resizing + const svgRootRef = React.useRef(null); + const [svgHeight, setSvgHeight] = React.useState(0); + const [svgWidth, setSvgWidth] = React.useState(0); - const [prevDatedTrees, setPrevDatedTrees] = React.useState< - DatedTree[] | null - >(null); + // Data update props + const [prevDate, setPrevDate] = React.useState(null); - const [prevSelectedEdgeKey, setPrevSelectedEdgeKey] = - React.useState(props.selectedEdgeKey); - const [prevSelectedNodeKey, setPrevSelectedNodeKey] = - React.useState(props.selectedNodeKey); - const [prevSelectedDateTime, setPrevSelectedDateTime] = - React.useState(props.selectedDateTime); + // Storing a copy of the last successfully assembeled data to render when data becomes invalid + const lastValidDataAssembler = React.useRef(null); - React.useEffect(function initialRender() { - setIsMounted(true); - }, []); + const dataAssembler = useDataAssembler( + props.datedTrees, + props.edgeMetadataList, + props.nodeMetadataList + ); - 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 - ); + if (dataAssembler === null) { + errorMsg = "Invalid data for assembler"; + } else if (dataAssembler !== lastValidDataAssembler.current) { + lastValidDataAssembler.current = dataAssembler; } - if (prevSelectedEdgeKey !== props.selectedEdgeKey) { - setPrevSelectedEdgeKey(props.selectedEdgeKey); - if (groupTreeAssemblerRef.current) { - groupTreeAssemblerRef.current.flowrate = props.selectedEdgeKey; + if (dataAssembler && props.selectedDateTime !== prevDate) { + try { + dataAssembler.setActiveDate(props.selectedDateTime); + setPrevDate(props.selectedDateTime); + } catch (error) { + errorMsg = (error as Error).message; } } - if (prevSelectedNodeKey !== props.selectedNodeKey) { - setPrevSelectedNodeKey(props.selectedNodeKey); - if (groupTreeAssemblerRef.current) { - groupTreeAssemblerRef.current.nodeinfo = props.selectedNodeKey; - } - } + // Mount hook + React.useEffect(function setupResizeObserver() { + if (!svgRootRef.current) throw new Error("Expected root ref to be set"); - if (prevSelectedDateTime !== props.selectedDateTime) { - setPrevSelectedDateTime(props.selectedDateTime); - if (groupTreeAssemblerRef.current) { - groupTreeAssemblerRef.current.update(props.selectedDateTime); - } - } + const svgElement = svgRootRef.current; + + // Debounce to avoid excessive re-renders + const debouncedResizeObserverCheck = _.debounce( + function debouncedResizeObserverCheck(entries) { + if (!Array.isArray(entries)) return; + if (!entries.length) return; - return
; -}; + const entry = entries[0]; + + setSvgWidth(entry.contentRect.width); + setSvgHeight(entry.contentRect.height); + }, + 100 + ); + + // 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); + + // Unsubscribe on unmount + return () => resizeObserver.unobserve(svgElement); + }, []); + + return ( + + {lastValidDataAssembler.current && svgHeight && svgWidth && ( + + )} + + {errorMsg && } + + ); +} GroupTreePlot.displayName = "GroupTreePlot"; diff --git a/typescript/packages/group-tree-plot/src/PlotErrorOverlay.tsx b/typescript/packages/group-tree-plot/src/PlotErrorOverlay.tsx new file mode 100644 index 0000000000..5dab68f0a4 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/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/TreePlotRenderer/HiddenChildren.tsx b/typescript/packages/group-tree-plot/src/TreePlotRenderer/HiddenChildren.tsx new file mode 100644 index 0000000000..e3c05f0f5d --- /dev/null +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/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/TreePlotRenderer/TransitionTreeEdge.tsx b/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeEdge.tsx new file mode 100644 index 0000000000..6d8fd91135 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeEdge.tsx @@ -0,0 +1,199 @@ +import React from "react"; +import { Transition, type TransitionStatus } from "react-transition-group"; + +import * as d3 from "d3"; +import { + diagonalPath, + findClosestVisibleInNewTree, + TREE_TRANSITION_DURATION, +} from "../utils"; +import { + useDataAssemblerPropertyValue, + useDataAssemblerTooltip, +} from "../DataAssembler/DataAssemblerHooks"; +import type { D3TreeEdge, D3TreeNode } from "../types"; +import type DataAssembler from "../DataAssembler/DataAssembler"; + +export type TreeEdgeProps = { + link: D3TreeEdge; + dataAssembler: DataAssembler; + primaryEdgeProperty: string; + transitionState?: TransitionStatus; + + // Kinda messy solution for this, might warrant a change in the future + nodeTree: D3TreeNode; + oldNodeTree: D3TreeNode | null; + + in?: boolean; +}; + +export function TransitionTreeEdge(props: TreeEdgeProps): React.ReactNode { + const rootRef = React.useRef(null); + const pathRef = React.useRef(null); + const labelRef = React.useRef(null); + + const [transitionState, setTransitionState] = + React.useState("exited"); + + const linkPath = diagonalPath(props.link); + + const mainTreeNode = props.link.target.data; + const linkId = React.useId(); + + const groupPropertyStrokeClass = `grouptree_link__${props.primaryEdgeProperty}`; + + const edgeValue = + useDataAssemblerPropertyValue( + props.dataAssembler, + mainTreeNode.edge_data, + props.primaryEdgeProperty + ) ?? 0; + + const strokeWidth = props.dataAssembler.normalizeValue( + props.primaryEdgeProperty, + edgeValue + ); + + const edgeTooltip = useDataAssemblerTooltip( + props.dataAssembler, + mainTreeNode.edge_data + ); + + const onTransitionEnter = React.useCallback( + function onTransitionEnter() { + const isAppearing = transitionState === "exited"; + const alreadyExiting = transitionState === "exiting"; + + const node = d3.select(pathRef.current); + const labelNode = d3.select(labelRef.current); + const targetPath = diagonalPath(props.link); + + setTransitionState("entering"); + + if (alreadyExiting) { + node.interrupt(); + } + + if (isAppearing) { + const closestVisibleParent = findClosestVisibleInNewTree( + props.link.target, + props.oldNodeTree + ); + + const expandFrom = closestVisibleParent ?? props.nodeTree; + const initPath = diagonalPath({ + source: expandFrom, + target: expandFrom, + }); + + node.attr("d", initPath).attr("stroke-width", strokeWidth / 4); + + labelNode.style("fill-opacity", 0); + } + + node.transition() + .duration(TREE_TRANSITION_DURATION) + .ease(d3.easeCubicInOut) + .attr("d", targetPath) + .attr("stroke-width", strokeWidth) + .on("end", () => { + setTransitionState("entered"); + }); + + labelNode + .transition() + .duration(TREE_TRANSITION_DURATION) + .style("fill-opacity", 1); + }, + [ + props.link, + props.oldNodeTree, + strokeWidth, + transitionState, + props.nodeTree, + ] + ); + + const onTransitionExit = React.useCallback(() => { + setTransitionState("exiting"); + + const closestVisibleParent = findClosestVisibleInNewTree( + props.link.target, + props.nodeTree + ); + + const retractTo = closestVisibleParent ?? props.link.source; + + const finalPath = diagonalPath({ + source: retractTo, + target: retractTo, + }); + + const node = d3.select(pathRef.current); + const labelNode = d3.select(labelRef.current); + + node.transition() + .duration(TREE_TRANSITION_DURATION) + .ease(d3.easeCubicInOut) + .attr("d", finalPath) + .attr("stroke-width", strokeWidth / 4) + .on("end", () => { + setTransitionState("exited"); + }); + + labelNode + .transition() + .duration(TREE_TRANSITION_DURATION) + .style("fill-opacity", 0); + }, [props.link, props.nodeTree, strokeWidth]); + + // Animate other changes + // TODO: Gets desynced with exiting animation if you spam new dates + const isEntered = transitionState === "entered"; + if (isEntered) { + d3.select(pathRef.current) + .transition() + .duration(TREE_TRANSITION_DURATION) + .ease(d3.easeCubicInOut) + .attr("d", linkPath) + .attr("stroke-width", strokeWidth); + } + + return ( + + + 0 ? "none" : "5,5"} + > + {edgeTooltip} + + + + + {mainTreeNode.edge_label} + + + + + ); +} diff --git a/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeNode.tsx b/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeNode.tsx new file mode 100644 index 0000000000..4eec18246a --- /dev/null +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeNode.tsx @@ -0,0 +1,204 @@ +import React from "react"; +import type DataAssembler from "../DataAssembler/DataAssembler"; +import { Transition, type TransitionStatus } from "react-transition-group"; +import { + useDataAssemblerPropertyValue, + useDataAssemblerTooltip, +} from "../DataAssembler/DataAssemblerHooks"; +import type { D3TreeNode } from "../types"; +import { + findClosestVisibleInNewTree, + printTreeValue, + TREE_TRANSITION_DURATION, +} from "../utils"; + +import * as d3 from "d3"; +import { HiddenChildren } from "./HiddenChildren"; + +export type TransitionTreeNodeProps = { + primaryNodeProperty: string; + dataAssembler: DataAssembler; + node: D3TreeNode; + + nodeTree: D3TreeNode; + oldNodeTree: D3TreeNode | null; + + in: boolean; + + onNodeClick?: ( + node: TransitionTreeNodeProps["node"], + evt: React.MouseEvent + ) => void; +}; + +export function TransitionTreeNode( + props: TransitionTreeNodeProps +): React.ReactNode { + const rootRef = React.useRef(null); + const [transitionState, setTransitionState] = + React.useState("exited"); + + const recursiveTreeNode = props.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 targetTransform = `translate(${props.node.y},${props.node.x})`; + + const [, primaryUnit] = props.dataAssembler.getPropertyInfo( + props.primaryNodeProperty + ); + + const primaryNodeValue = useDataAssemblerPropertyValue( + props.dataAssembler, + recursiveTreeNode.node_data, + props.primaryNodeProperty + ); + + const toolTip = useDataAssemblerTooltip( + props.dataAssembler, + recursiveTreeNode.node_data + ); + + const onTransitionEnter = React.useCallback( + function onTransitionEnter() { + const isAppearing = transitionState === "exited"; + const alreadyExiting = transitionState === "exiting"; + + setTransitionState("entering"); + + const node = d3.select(rootRef.current); + + if (alreadyExiting) { + node.interrupt(); + } + + if (isAppearing) { + const closestVisibleParent = findClosestVisibleInNewTree( + props.node, + props.oldNodeTree + ); + + const expandFrom = closestVisibleParent ?? props.nodeTree; + const initTransform = `translate(${expandFrom.y},${expandFrom.x})`; + + node.attr("transform", initTransform).attr("opacity", 0); + } + + node.transition() + .duration(TREE_TRANSITION_DURATION) + .ease(d3.easeCubicInOut) + .attr("transform", targetTransform) + .attr("opacity", 1) + .on("end", () => { + setTransitionState("entered"); + }); + }, + [ + props.oldNodeTree, + props.node, + props.nodeTree, + transitionState, + targetTransform, + ] + ); + + const onTransitionExit = React.useCallback( + function onTransitionExit() { + setTransitionState("exiting"); + + const node = d3.select(rootRef.current); + + const closestVisibleParent = findClosestVisibleInNewTree( + props.node, + props.nodeTree + ); + + const retractTo = closestVisibleParent ?? props.node; + const targetTransform = `translate(${retractTo.y},${retractTo.x})`; + + node.transition() + .duration(TREE_TRANSITION_DURATION) + .ease(d3.easeCubicInOut) + .attr("transform", targetTransform) + .attr("opacity", 0) + .on("end", () => { + setTransitionState("exited"); + }); + }, + [props.node, props.nodeTree] + ); + + // Animate other changes + // TODO: Gets desynced with exiting animation if you spam new dates + const isEntered = transitionState === "entered"; + if (isEntered) { + const node = d3.select(rootRef.current); + + node.transition() + .duration(TREE_TRANSITION_DURATION) + .ease(d3.easeCubicInOut) + .attr("transform", targetTransform); + } + + 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/TreePlotRenderer/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/TreePlotRenderer/group_tree.css diff --git a/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx b/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx new file mode 100644 index 0000000000..d017ed48f5 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import * as d3 from "d3"; + +import type { ReactNode } from "react"; +import type { D3TreeNode, RecursiveTreeNode } from "../types"; +import type DataAssembler from "../DataAssembler/DataAssembler"; + +import "./group_tree.css"; +import { useDataAssemblerTree } from "../DataAssembler/DataAssemblerHooks"; +import { computeLinkId, computeNodeId, usePrevious } from "../utils"; +import { TransitionGroup } from "react-transition-group"; +import { TransitionTreeEdge } from "./TransitionTreeEdge"; +import { TransitionTreeNode } from "./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 default function TreePlotRenderer( + props: TreePlotRendererProps +): ReactNode { + const activeTree = useDataAssemblerTree(props.dataAssembler); + 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 hierarcy = d3 + // .hierarchy(rootTreeNode). + .hierarchy(rootTreeNode, (datum) => { + // Stop traversal at all collapsed nodes + if (nodeCollapseFlags[datum.node_label]) return null; + else return datum.children; + }) + .each((node) => { + // Secondary collapse-run; collapse nodes based on `props.initialVisibleDepth`. Keep explicitly expanded nodes open + const nodeLabel = node.data.node_label; + if (props.initialVisibleDepth == null) return; + if (nodeCollapseFlags[nodeLabel] === false) return; + + if (node.depth >= props.initialVisibleDepth) { + node.children = undefined; + } + }); + + return treeLayout(hierarcy); + }, + [treeLayout, rootTreeNode, nodeCollapseFlags, props.initialVisibleDepth] + ); + + // Storing the previous value so entering nodes know where to expand from + const oldNodeTree = usePrevious(nodeTree); + + const toggleNodeCollapse = React.useCallback( + function toggleNodeCollapse(node: D3TreeNode) { + const label = node.data.node_label; + const existingVal = nodeCollapseFlags[label]; + + const newVal = Boolean(node.children?.length) && !existingVal; + const newFlags = { ...nodeCollapseFlags, [label]: 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(({ data }) => delete newFlags[data.node_label]); + } + + setNodeCollapseFlags(newFlags); + }, + [nodeCollapseFlags] + ); + + return ( + + + React.cloneElement(child, { oldNodeTree, nodeTree }) + } + > + {nodeTree.links().map((link) => ( + // @ts-expect-error Missing props are injected by child-factory above + + ))} + + {nodeTree.descendants().map((node) => ( + // @ts-expect-error Missing props are injected by child-factory above + + ))} + + + ); +} 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) =>