From d29638785f12bac4ee83033350435041e749d7f9 Mon Sep 17 00:00:00 2001 From: Anders Rantala Hunderi Date: Wed, 13 Nov 2024 14:43:38 +0100 Subject: [PATCH 01/25] Installed react-transition-group package --- typescript/package-lock.json | 3 ++- typescript/packages/group-tree-plot/package.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 5ac286c8b..6e64e7c4f 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -35426,7 +35426,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 7e6fefb1a..4c01d079f 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", From 37926c2ff6c97e646e1d7e288c00cd0f7faf8934 Mon Sep 17 00:00:00 2001 From: Anders Rantala Hunderi Date: Wed, 13 Nov 2024 15:40:05 +0100 Subject: [PATCH 02/25] Recreated GroupTree component with a better mix of D3 and React --- .../src/DataAssembler/DataAssembler.ts | 208 ++++++++++++++++++ .../src/DataAssembler/DataAssemblerHooks.ts | 70 ++++++ .../GroupTreeAssembler/groupTreeAssembler.js | 2 + .../group-tree-plot/src/GroupTreePlot.tsx | 83 ++----- .../group-tree-plot/src/GroupTreePlotOld.tsx | 90 ++++++++ .../TreePlotRenderer/TransitionTreeEdge.tsx | 197 +++++++++++++++++ .../TreePlotRenderer/TransitionTreeNode.tsx | 182 +++++++++++++++ .../src/TreePlotRenderer/group_tree.css | 60 +++++ .../src/TreePlotRenderer/index.tsx | 93 ++++++++ .../packages/group-tree-plot/src/utils.ts | 114 ++++++++++ 10 files changed, 1036 insertions(+), 63 deletions(-) create mode 100644 typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts create mode 100644 typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts create mode 100644 typescript/packages/group-tree-plot/src/GroupTreePlotOld.tsx create mode 100644 typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeEdge.tsx create mode 100644 typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeNode.tsx create mode 100644 typescript/packages/group-tree-plot/src/TreePlotRenderer/group_tree.css create mode 100644 typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx create mode 100644 typescript/packages/group-tree-plot/src/utils.ts 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 000000000..e5b7b21b4 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts @@ -0,0 +1,208 @@ +import * as d3 from "d3"; + +import type { + DatedTree, + EdgeData, + EdgeMetadata, + NodeData, + NodeMetadata, +} from "../types"; +import _ from "lodash"; +import { printTreeValue } from "../utils"; + +export enum AssemblerEvent { + TREE_CHANGED, + DATE_CHANGED, + ERROR, +} + +const FALLBACK_DATE_TREE_DATA: DatedTree[] = [ + { + dates: ["UNKOWN"], + tree: { + node_type: "Group", + node_label: "NO DATA", + edge_label: "NO DATA", + node_data: {}, + edge_data: {}, + }, + }, +]; + +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 { + // ? Should we just throw instead? + console.warn("Tree-list is empty"); + this.datedTrees = FALLBACK_DATE_TREE_DATA; + } + + 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; + } + + 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 : "?", + ]; + } + + 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) { + // TODO: Somehow propagate this error to renderers + throw new Error("Invalid date for data assembler"); + } + + const treeChanged = this._currentTreeIndex !== newTreeIdx; + const dateChanged = this._currentTreeIndex !== newTreeIdx; + + // TODO: Is there any bigger recalculations that must be done here? + this._currentTreeIndex = newTreeIdx; + this._currentDateIndex = newDateIdx; + + // Notify subscribers that something changed + if (treeChanged) { + this._notifyEventListeners(AssemblerEvent.TREE_CHANGED); + } + if (dateChanged) { + this._notifyEventListeners(AssemblerEvent.DATE_CHANGED); + } + } + + getActiveTree() { + return this.datedTrees[this._currentTreeIndex] ?? null; + } + + 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); + } + + private _eventListeners: Map void>> = new Map(); + + registerEventListener(event: AssemblerEvent, listener: () => void) { + const listeners = this._eventListeners.get(event) ?? new Set(); + + listeners.add(listener); + this._eventListeners.set(event, listeners); + + return () => this.removeEventListener(event, listener); + } + + removeEventListener(event: AssemblerEvent, listener: () => void) { + const listeners = this._eventListeners.get(event) ?? new Set(); + + listeners.delete(listener); + + this._eventListeners.set(event, listeners); + } + + private _notifyEventListeners(event: AssemblerEvent) { + const listeners = this._eventListeners.get(event) ?? []; + + listeners.forEach((fn) => fn()); + } +} + +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 000000000..faf4f5289 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts @@ -0,0 +1,70 @@ +import React from "react"; + +import type { + DatedTree, + EdgeData, + EdgeMetadata, + NodeData, + NodeMetadata, +} from "../types"; +import DataAssembler, { AssemblerEvent } from "./DataAssembler"; + +export function useDataAssembler( + datedTrees: DatedTree[], + edgeMetadataList: EdgeMetadata[], + nodeMetadataList: NodeMetadata[] +): DataAssembler { + const dataAssembler = React.useMemo(() => { + const assembler = new DataAssembler( + datedTrees, + edgeMetadataList, + nodeMetadataList + ); + + return assembler; + }, [datedTrees, edgeMetadataList, nodeMetadataList]); + + return dataAssembler; +} + +function makeStoreSubscriberFunc( + assembler: DataAssembler, + changeEvent: AssemblerEvent +) { + return (onStoreChange: () => void) => { + const unsub = assembler.registerEventListener( + changeEvent, + onStoreChange + ); + + return () => unsub; + }; +} + +export function useDataAssemblerTree(assembler: DataAssembler) { + return React.useSyncExternalStore( + makeStoreSubscriberFunc(assembler, AssemblerEvent.TREE_CHANGED), + () => assembler.getActiveTree() + ); +} + +export function useDataAssemblerPropertyValue( + assembler: DataAssembler, + data: NodeData | EdgeData, + property: string +): number | null { + return React.useSyncExternalStore( + makeStoreSubscriberFunc(assembler, AssemblerEvent.DATE_CHANGED), + () => assembler.getPropertyValue(data, property) + ); +} + +export function useDataAssemblerTooltip( + assembler: DataAssembler, + data: NodeData | EdgeData +): string { + return React.useSyncExternalStore( + makeStoreSubscriberFunc(assembler, AssemblerEvent.DATE_CHANGED), + () => 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 index 3d806cf6c..c5239004a 100644 --- a/typescript/packages/group-tree-plot/src/GroupTreeAssembler/groupTreeAssembler.js +++ b/typescript/packages/group-tree-plot/src/GroupTreeAssembler/groupTreeAssembler.js @@ -21,6 +21,8 @@ import { cloneDeep } from "lodash"; * node info and date time. * * Provides methods to update selected date time, and change flow rate and node info. + * + * @deprecated See 'TreePlotRender.tsx' instead. Component kept for historical purposes */ export default class GroupTreeAssembler { /** diff --git a/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx b/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx index d0eb6cf3d..4c31a9cd1 100644 --- a/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx +++ b/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx @@ -1,8 +1,8 @@ import React from "react"; -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"; export interface GroupTreePlotProps { id: string; @@ -17,71 +17,28 @@ export interface GroupTreePlotProps { export const GroupTreePlot: React.FC = ( props: GroupTreePlotProps ) => { - const divRef = React.useRef(null); - const groupTreeAssemblerRef = React.useRef(); + const [prevDate, setPrevDate] = React.useState(null); - // State to ensure divRef is defined before creating GroupTree - const [isMounted, setIsMounted] = React.useState(false); + const dataAssembler = useDataAssembler( + props.datedTrees, + props.edgeMetadataList, + props.nodeMetadataList + ); - // Remove when typescript version is implemented using ref - const [prevId, setPrevId] = React.useState(null); - - const [prevDatedTrees, setPrevDatedTrees] = React.useState< - DatedTree[] | null - >(null); - - const [prevSelectedEdgeKey, setPrevSelectedEdgeKey] = - React.useState(props.selectedEdgeKey); - const [prevSelectedNodeKey, setPrevSelectedNodeKey] = - React.useState(props.selectedNodeKey); - const [prevSelectedDateTime, setPrevSelectedDateTime] = - React.useState(props.selectedDateTime); - - React.useEffect(function initialRender() { - setIsMounted(true); - }, []); - - 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 (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); - } + if (props.selectedDateTime !== prevDate) { + dataAssembler.setActiveDate(props.selectedDateTime); + setPrevDate(props.selectedDateTime); } - return
; + return ( + + ); }; GroupTreePlot.displayName = "GroupTreePlot"; diff --git a/typescript/packages/group-tree-plot/src/GroupTreePlotOld.tsx b/typescript/packages/group-tree-plot/src/GroupTreePlotOld.tsx new file mode 100644 index 000000000..85130eb92 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/GroupTreePlotOld.tsx @@ -0,0 +1,90 @@ +import React from "react"; + +import GroupTreeAssembler from "./GroupTreeAssembler/groupTreeAssembler"; +import type { DatedTree, EdgeMetadata, NodeMetadata } from "./types"; +import { isEqual } from "lodash"; + +export interface GroupTreePlotProps { + id: string; + edgeMetadataList: EdgeMetadata[]; + nodeMetadataList: NodeMetadata[]; + datedTrees: DatedTree[]; + selectedEdgeKey: string; + selectedNodeKey: string; + selectedDateTime: string; +} + +/** + * @deprecated + */ +export const GroupTreePlotOld: React.FC = ( + props: GroupTreePlotProps +) => { + const divRef = React.useRef(null); + const groupTreeAssemblerRef = React.useRef(); + + // State to ensure divRef is defined before creating GroupTree + const [isMounted, setIsMounted] = React.useState(false); + + // Remove when typescript version is implemented using ref + const [prevId, setPrevId] = React.useState(null); + + const [prevDatedTrees, setPrevDatedTrees] = React.useState< + DatedTree[] | null + >(null); + + const [prevSelectedEdgeKey, setPrevSelectedEdgeKey] = + React.useState(props.selectedEdgeKey); + const [prevSelectedNodeKey, setPrevSelectedNodeKey] = + React.useState(props.selectedNodeKey); + const [prevSelectedDateTime, setPrevSelectedDateTime] = + React.useState(props.selectedDateTime); + + React.useEffect(function initialRender() { + setIsMounted(true); + }, []); + + 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 (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
; +}; + +GroupTreePlotOld.displayName = "GroupTreePlotOld"; 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 000000000..d8ec7a14c --- /dev/null +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeEdge.tsx @@ -0,0 +1,197 @@ +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 { RecursiveTreeNode } from "../types"; +import type DataAssembler from "../DataAssembler/DataAssembler"; + +export type TreeEdgeProps = { + link: d3.HierarchyPointLink; + dataAssembler: DataAssembler; + primaryEdgeProperty: string; + transitionState?: TransitionStatus; + + // Kinda messy solution for this, might warrant a change in the future + nodeTree: d3.HierarchyPointNode; + oldNodeTree: d3.HierarchyPointNode | 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(() => { + 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) + .interrupt() + .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 000000000..c89f763ae --- /dev/null +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeNode.tsx @@ -0,0 +1,182 @@ +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 { RecursiveTreeNode } from "../types"; +import { + findClosestVisibleInNewTree, + printTreeValue, + TREE_TRANSITION_DURATION, +} from "../utils"; + +import * as d3 from "d3"; + +export type TransitionTreeNodeProps = { + primaryNodeProperty: string; + dataAssembler: DataAssembler; + node: d3.HierarchyPointNode; + + nodeTree: d3.HierarchyPointNode; + oldNodeTree: d3.HierarchyPointNode | null; + + in: boolean; +}; + +export function TransitionTreeNode( + props: TransitionTreeNodeProps +): React.ReactNode { + const rootRef = React.useRef(null); + const [transitionState, setTransitionState] = + React.useState("exited"); + + const recursiveTreeNode = props.node.data; + const isLeaf = !props.node.children; + 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(() => { + 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(() => { + 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 ( + + + + + {nodeLabel} + + + + {printTreeValue(primaryNodeValue)} + + + + {primaryUnit} + + {toolTip} + + + ); +} diff --git a/typescript/packages/group-tree-plot/src/TreePlotRenderer/group_tree.css b/typescript/packages/group-tree-plot/src/TreePlotRenderer/group_tree.css new file mode 100644 index 000000000..70806aace --- /dev/null +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/group_tree.css @@ -0,0 +1,60 @@ +.link { + fill: none; + stroke: #5c5c5c; + opacity: 1; + animation-timing-function: cubic-bezier(0.75, 0.1, 0.25, 0.9); +} + +.grouptree_link { + opacity: 0.3; +} + +.grouptree_link__oilrate { + stroke: #60be6c; +} + +.grouptree_link__waterrate { + stroke: #0d1b9e; +} + +.grouptree_link__gasrate { + stroke: #c5221c; +} + +.grouptree_link__waterinjrate { + stroke: #00c3ff; +} + +.grouptree_link__gasinjrate { + stroke: #d6397a; +} + +.grouptree__node { + fill: #fff; + stroke: #60be6c; + stroke-width: 1px; + cursor: default; +} + +.grouptree__nodelabel { + font-size: 10px; + font-family: sans-serif; +} + +.grouptree__pressurelabel { + font-size: 10px; + font-family: "Statoil Sans Light", Lucida, Arial, Helvetica, sans-serif; +} + +.grouptree__pressureunit { + font-size: 9px; +} + +.grouptree__grupnet_text { + font-size: 12px; +} + +.grouptree__node--withchildren { + stroke-width: 2.5px; + cursor: pointer; +} 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 000000000..9c27ded63 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import * as d3 from "d3"; + +import type { ReactNode } from "react"; +import type { RecursiveTreeNode } from "../types"; +import type DataAssembler from "../DataAssembler/DataAssembler"; + +import "./group_tree.css"; +import { useDataAssemblerTree } from "../DataAssembler/DataAssemblerHooks"; +import { computeLinkId, computeNodeId } 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; + // TODO: Make dynamic + height: number; + width: number; +}; + +const PLOT_MARGINS = { + top: 10, + right: 120, + bottom: 30, + left: 70, +}; + +export default function TreePlotRenderer( + props: TreePlotRendererProps +): ReactNode { + const activeTree = useDataAssemblerTree(props.dataAssembler); + const rootTreeNode = activeTree.tree; + + const treeLayout = React.useMemo(() => { + // TODO: Remove constant number + const treeHeight = + props.height - PLOT_MARGINS.top - PLOT_MARGINS.bottom; + const treeWidth = props.width - PLOT_MARGINS.left - PLOT_MARGINS.right; + + return d3.tree().size([treeHeight, treeWidth]); + }, [props.height, props.width]); + + const lastComputedTreeRef = + React.useRef | null>(null); + + const [nodeTree, oldNodeTree] = React.useMemo(() => { + const previousTree = lastComputedTreeRef.current; + const hierarcy = d3.hierarchy(rootTreeNode); + const tree = treeLayout(hierarcy); + + lastComputedTreeRef.current = tree; + + return [tree, previousTree]; + }, [treeLayout, rootTreeNode]); + + 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/utils.ts b/typescript/packages/group-tree-plot/src/utils.ts new file mode 100644 index 000000000..09cf1f7d8 --- /dev/null +++ b/typescript/packages/group-tree-plot/src/utils.ts @@ -0,0 +1,114 @@ +import _ from "lodash"; +import type DataAssembler from "./DataAssembler/DataAssembler"; +import type { RecursiveTreeNode } from "./types"; + +export const TREE_TRANSITION_DURATION = 200; + +export function computeNodeId(node: d3.HierarchyPointNode) { + if (!node.parent) { + return node.data.node_label; + } else { + return `${node.parent.data.node_label}_${node.data.node_label}`; + } +} + +export function computeLinkId(link: d3.HierarchyPointLink) { + return `path ${computeNodeId(link.target)}`; +} + +export function printTreeValue(value: number | null): string { + if (value === null) return "N/A"; + else return value.toFixed(0); +} + +export function findClosestVisibleInNewTree( + targetNode: d3.HierarchyPointNode, + newTreeRoot: d3.HierarchyPointNode | null +): d3.HierarchyPointNode | null { + if (!newTreeRoot) return null; + + // Get a path of d3 nodes from the root to the treeNode + let pathToNode = [targetNode]; + let traversingNode = targetNode; + while (traversingNode.parent) { + traversingNode = traversingNode.parent; + pathToNode = [traversingNode, ...pathToNode]; + } + + let visibleParent = null; + let childrenInTree = [newTreeRoot]; + + // Compare the node's expected path the currently active assembler tree + for (let i = 0; i < pathToNode.length; i++) { + const pathNode = pathToNode[i]; + + const label = pathNode.data.node_label; + + const foundChild = _.find(childrenInTree, ["data.node_label", label]); + + // Previous node was the last visible parent for the node + if (!foundChild) { + return visibleParent; + } else { + visibleParent = foundChild; + childrenInTree = foundChild.children ?? []; + } + } + + return visibleParent; +} + +export function findClosestVisibleParent( + assembler: DataAssembler, + treeNode: d3.HierarchyPointNode +): d3.HierarchyPointNode | null { + const activeTreeRoot = assembler.getActiveTree()?.tree; + if (!activeTreeRoot) return null; + + // Get a path of d3 nodes from the root to the treeNode + let pathToNode = [treeNode]; + let traversingNode = treeNode; + while (traversingNode.parent) { + traversingNode = traversingNode.parent; + pathToNode = [traversingNode, ...pathToNode]; + } + + let visibleParent = null; + let childrenInTree = [activeTreeRoot]; + + // Compare the node's expected path the currently active assembler tree + for (let i = 0; i < pathToNode.length; i++) { + const pathNode = pathToNode[i]; + + const label = pathNode.data.node_label; + + const foundChild = _.find(childrenInTree, ["node_label", label]); + + // Previous node was the last visible parent for the node + if (!foundChild) { + return visibleParent; + } else { + visibleParent = pathToNode[i]; + childrenInTree = foundChild.children ?? []; + } + } + + // ? I think this might be unreachable? + return visibleParent; +} + +export function diagonalPath( + link: d3.HierarchyPointLink +): string { + const { source, target } = link; + + const avgY = (target.y + source.y) / 2; + // Svg path drawing commands. Note that x and y is swapped to show the tree sideways + return ` + M ${source.y} ${source.x} \ + C \ + ${avgY} ${source.x}, \ + ${avgY} ${target.x}, \ + ${target.y} ${target.x} + `; +} From 9dfb9d9290dffec6fb460aa53e3d6e785823fe8a Mon Sep 17 00:00:00 2001 From: Anders Rantala Hunderi Date: Wed, 13 Nov 2024 15:40:45 +0100 Subject: [PATCH 03/25] Tweaked story to make date changes easier --- .../group-tree-plot/src/storybook/GroupTreePlot.stories.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 734c68abe..91d91aa64 100644 --- a/typescript/packages/group-tree-plot/src/storybook/GroupTreePlot.stories.tsx +++ b/typescript/packages/group-tree-plot/src/storybook/GroupTreePlot.stories.tsx @@ -17,6 +17,8 @@ const stories: Meta = { 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: @@ -34,7 +36,6 @@ export default stories; * Storybook test for the group tree plot component */ -// @ts-expect-error TS7006 const Template = (args) => { return ( = { From 274adf15c00e2ef9b2fbd41f1dedfd8239ff1deb Mon Sep 17 00:00:00 2001 From: Anders Rantala Hunderi Date: Wed, 13 Nov 2024 15:44:09 +0100 Subject: [PATCH 04/25] refactor: remove event handling from DataAssembler and simplify hooks --- .../src/DataAssembler/DataAssembler.ts | 59 +------------------ .../src/DataAssembler/DataAssemblerHooks.ts | 31 ++-------- 2 files changed, 5 insertions(+), 85 deletions(-) diff --git a/typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts index e5b7b21b4..1f8b2660b 100644 --- a/typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts +++ b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts @@ -10,25 +10,6 @@ import type { import _ from "lodash"; import { printTreeValue } from "../utils"; -export enum AssemblerEvent { - TREE_CHANGED, - DATE_CHANGED, - ERROR, -} - -const FALLBACK_DATE_TREE_DATA: DatedTree[] = [ - { - dates: ["UNKOWN"], - tree: { - node_type: "Group", - node_label: "NO DATA", - edge_label: "NO DATA", - node_data: {}, - edge_data: {}, - }, - }, -]; - export default class DataAssembler { datedTrees: DatedTree[]; edgeMetadataList: EdgeMetadata[]; @@ -53,8 +34,7 @@ export default class DataAssembler { this.datedTrees = datedTrees; } else { // ? Should we just throw instead? - console.warn("Tree-list is empty"); - this.datedTrees = FALLBACK_DATE_TREE_DATA; + throw new Error("Tree-list is empty"); } this._propertyToLabelMap = new Map(); @@ -132,20 +112,8 @@ export default class DataAssembler { throw new Error("Invalid date for data assembler"); } - const treeChanged = this._currentTreeIndex !== newTreeIdx; - const dateChanged = this._currentTreeIndex !== newTreeIdx; - - // TODO: Is there any bigger recalculations that must be done here? this._currentTreeIndex = newTreeIdx; this._currentDateIndex = newDateIdx; - - // Notify subscribers that something changed - if (treeChanged) { - this._notifyEventListeners(AssemblerEvent.TREE_CHANGED); - } - if (dateChanged) { - this._notifyEventListeners(AssemblerEvent.DATE_CHANGED); - } } getActiveTree() { @@ -160,31 +128,6 @@ export default class DataAssembler { return d3.scaleLinear().domain([0, maxVal]).range([2, 100])(value); } - - private _eventListeners: Map void>> = new Map(); - - registerEventListener(event: AssemblerEvent, listener: () => void) { - const listeners = this._eventListeners.get(event) ?? new Set(); - - listeners.add(listener); - this._eventListeners.set(event, listeners); - - return () => this.removeEventListener(event, listener); - } - - removeEventListener(event: AssemblerEvent, listener: () => void) { - const listeners = this._eventListeners.get(event) ?? new Set(); - - listeners.delete(listener); - - this._eventListeners.set(event, listeners); - } - - private _notifyEventListeners(event: AssemblerEvent) { - const listeners = this._eventListeners.get(event) ?? []; - - listeners.forEach((fn) => fn()); - } } function findTreeAndDateIndex( diff --git a/typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts index faf4f5289..09e865c94 100644 --- a/typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts +++ b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts @@ -7,7 +7,7 @@ import type { NodeData, NodeMetadata, } from "../types"; -import DataAssembler, { AssemblerEvent } from "./DataAssembler"; +import DataAssembler from "./DataAssembler"; export function useDataAssembler( datedTrees: DatedTree[], @@ -27,25 +27,8 @@ export function useDataAssembler( return dataAssembler; } -function makeStoreSubscriberFunc( - assembler: DataAssembler, - changeEvent: AssemblerEvent -) { - return (onStoreChange: () => void) => { - const unsub = assembler.registerEventListener( - changeEvent, - onStoreChange - ); - - return () => unsub; - }; -} - export function useDataAssemblerTree(assembler: DataAssembler) { - return React.useSyncExternalStore( - makeStoreSubscriberFunc(assembler, AssemblerEvent.TREE_CHANGED), - () => assembler.getActiveTree() - ); + return assembler.getActiveTree(); } export function useDataAssemblerPropertyValue( @@ -53,18 +36,12 @@ export function useDataAssemblerPropertyValue( data: NodeData | EdgeData, property: string ): number | null { - return React.useSyncExternalStore( - makeStoreSubscriberFunc(assembler, AssemblerEvent.DATE_CHANGED), - () => assembler.getPropertyValue(data, property) - ); + return assembler.getPropertyValue(data, property); } export function useDataAssemblerTooltip( assembler: DataAssembler, data: NodeData | EdgeData ): string { - return React.useSyncExternalStore( - makeStoreSubscriberFunc(assembler, AssemblerEvent.DATE_CHANGED), - () => assembler.getTooltip(data) - ); + return assembler.getTooltip(data); } From 8e0a2a3dbdd678fe528227ddb8e0a7983b472b10 Mon Sep 17 00:00:00 2001 From: Anders Rantala Hunderi Date: Thu, 14 Nov 2024 13:02:31 +0100 Subject: [PATCH 05/25] fixed node childre show/hide toggle --- .../src/TreePlotRenderer/HiddenChildren.tsx | 29 +++++++++++++++++++ .../TreePlotRenderer/TransitionTreeNode.tsx | 18 ++++++++++-- .../src/TreePlotRenderer/index.tsx | 26 +++++++++++++++-- 3 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 typescript/packages/group-tree-plot/src/TreePlotRenderer/HiddenChildren.tsx 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 000000000..e3c05f0f5 --- /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/TransitionTreeNode.tsx b/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeNode.tsx index c89f763ae..a63d00bf9 100644 --- a/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeNode.tsx +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeNode.tsx @@ -23,6 +23,11 @@ export type TransitionTreeNodeProps = { oldNodeTree: d3.HierarchyPointNode | null; in: boolean; + + onNodeClick?: ( + node: TransitionTreeNodeProps["node"], + evt: React.MouseEvent + ) => void; }; export function TransitionTreeNode( @@ -33,7 +38,8 @@ export function TransitionTreeNode( React.useState("exited"); const recursiveTreeNode = props.node.data; - const isLeaf = !props.node.children; + const isLeaf = !recursiveTreeNode.children; + const canBeExpanded = !props.node.children && recursiveTreeNode.children; const nodeLabel = recursiveTreeNode.node_label; let circleClass = "grouptree__node"; @@ -148,11 +154,13 @@ export function TransitionTreeNode( className="node" // ! Defaulting to opacity=0 to avoid flickering. D3 will override this in the transition hooks opacity={0} + cursor={!isLeaf ? "pointer" : undefined} + onClick={(evt) => props.onNodeClick?.(props.node, evt)} > @@ -176,6 +184,12 @@ export function TransitionTreeNode( {primaryUnit} {toolTip} + + {canBeExpanded && ( + + )} ); diff --git a/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx b/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx index 9c27ded63..a32b620bc 100644 --- a/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx @@ -34,6 +34,11 @@ export default function TreePlotRenderer( const activeTree = useDataAssemblerTree(props.dataAssembler); const rootTreeNode = activeTree.tree; + const [nodeHideChildren, setNodeHideChildren] = React.useState< + Record + >({}); + + const treeLayout = React.useMemo(() => { // TODO: Remove constant number const treeHeight = @@ -48,13 +53,29 @@ export default function TreePlotRenderer( const [nodeTree, oldNodeTree] = React.useMemo(() => { const previousTree = lastComputedTreeRef.current; - const hierarcy = d3.hierarchy(rootTreeNode); + const hierarcy = d3.hierarchy(rootTreeNode, (datum) => { + if (nodeHideChildren[datum.node_label]) return null; + else return datum.children; + }); + const tree = treeLayout(hierarcy); lastComputedTreeRef.current = tree; return [tree, previousTree]; - }, [treeLayout, rootTreeNode]); + }, [treeLayout, rootTreeNode, nodeHideChildren]); + + const toggleShowChildren = React.useCallback( + (node: D3TreeNode) => { + const label = node.data.node_label; + const existingVal = nodeHideChildren[label]; + setNodeHideChildren({ + ...nodeHideChildren, + [label]: Boolean(node.children?.length) && !existingVal, + }); + }, + [nodeHideChildren] + ); return ( @@ -84,6 +105,7 @@ export default function TreePlotRenderer( primaryNodeProperty={props.primaryNodeProperty} dataAssembler={props.dataAssembler} node={node} + onNodeClick={toggleShowChildren} /> ))} From 6468a11a9d4a94a8626a4d0007726e1ab47d3f41 Mon Sep 17 00:00:00 2001 From: Anders Rantala Hunderi Date: Thu, 14 Nov 2024 13:08:05 +0100 Subject: [PATCH 06/25] Added error overlay to GroupTreePlot --- .../src/DataAssembler/DataAssembler.ts | 40 ++++++++-------- .../src/DataAssembler/DataAssemblerHooks.ts | 4 +- .../group-tree-plot/src/GroupTreePlot.tsx | 46 +++++++++++++++---- .../group-tree-plot/src/PlotErrorOverlay.tsx | 30 ++++++++++++ .../src/TreePlotRenderer/index.tsx | 20 ++++---- 5 files changed, 98 insertions(+), 42 deletions(-) create mode 100644 typescript/packages/group-tree-plot/src/PlotErrorOverlay.tsx diff --git a/typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts index 1f8b2660b..68f97db73 100644 --- a/typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts +++ b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssembler.ts @@ -33,7 +33,6 @@ export default class DataAssembler { if (datedTrees.length) { this.datedTrees = datedTrees; } else { - // ? Should we just throw instead? throw new Error("Tree-list is empty"); } @@ -66,6 +65,25 @@ export default class DataAssembler { 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 ""; @@ -100,26 +118,6 @@ export default class DataAssembler { ]; } - 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) { - // TODO: Somehow propagate this error to renderers - throw new Error("Invalid date for data assembler"); - } - - this._currentTreeIndex = newTreeIdx; - this._currentDateIndex = newDateIdx; - } - - getActiveTree() { - return this.datedTrees[this._currentTreeIndex] ?? null; - } - normalizeValue(property: string, value: number) { const maxVal = this._propertyMaxVals.get(property); diff --git a/typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts index 09e865c94..468dc74a3 100644 --- a/typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts +++ b/typescript/packages/group-tree-plot/src/DataAssembler/DataAssemblerHooks.ts @@ -13,8 +13,10 @@ export function useDataAssembler( datedTrees: DatedTree[], edgeMetadataList: EdgeMetadata[], nodeMetadataList: NodeMetadata[] -): DataAssembler { +): DataAssembler | null { const dataAssembler = React.useMemo(() => { + if (datedTrees.length === 0) return null; + const assembler = new DataAssembler( datedTrees, edgeMetadataList, diff --git a/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx b/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx index 4c31a9cd1..a94fc839c 100644 --- a/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx +++ b/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx @@ -3,6 +3,8 @@ import React from "react"; import type { DatedTree, EdgeMetadata, NodeMetadata } from "./types"; 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; @@ -14,30 +16,54 @@ export interface GroupTreePlotProps { selectedDateTime: string; } +// TODO: Should be dynamic instead +const CONTAINER_HEIGHT = 700; +const CONTAINER_WIDTH = 938; + export const GroupTreePlot: React.FC = ( props: GroupTreePlotProps ) => { + let errorMsg = ""; const [prevDate, setPrevDate] = React.useState(null); + // Storing a copy of the last successfully assembeled data to render when data becomes invalid + const lastValidDataAssembler = React.useRef(null); + const dataAssembler = useDataAssembler( props.datedTrees, props.edgeMetadataList, props.nodeMetadataList ); - if (props.selectedDateTime !== prevDate) { - dataAssembler.setActiveDate(props.selectedDateTime); - setPrevDate(props.selectedDateTime); + if (dataAssembler === null) { + errorMsg = "Invalid data for assembler"; + } else if (dataAssembler !== lastValidDataAssembler.current) { + lastValidDataAssembler.current = dataAssembler; + } + + if (dataAssembler && props.selectedDateTime !== prevDate) { + try { + dataAssembler.setActiveDate(props.selectedDateTime); + setPrevDate(props.selectedDateTime); + } catch (error) { + errorMsg = (error as Error).message; + } } return ( - + + {lastValidDataAssembler.current && ( + + )} + + {errorMsg && } + ); }; 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 000000000..5dab68f0a --- /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/index.tsx b/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx index a32b620bc..2301a04d5 100644 --- a/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx @@ -24,7 +24,7 @@ export type TreePlotRendererProps = { const PLOT_MARGINS = { top: 10, right: 120, - bottom: 30, + bottom: 10, left: 70, }; @@ -38,15 +38,15 @@ export default function TreePlotRenderer( 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(() => { - // TODO: Remove constant number - const treeHeight = - props.height - PLOT_MARGINS.top - PLOT_MARGINS.bottom; - const treeWidth = props.width - PLOT_MARGINS.left - PLOT_MARGINS.right; - - return d3.tree().size([treeHeight, treeWidth]); - }, [props.height, props.width]); + // Note that we invert height / width to render the tree sideways + return d3.tree().size([layoutHeight, layoutWidth]); + }, [layoutHeight, layoutWidth]); const lastComputedTreeRef = React.useRef | null>(null); @@ -78,7 +78,7 @@ export default function TreePlotRenderer( ); return ( - + <> @@ -110,6 +110,6 @@ export default function TreePlotRenderer( ))} - + ); } From bf458af0b2591964055671f604e971667995c932 Mon Sep 17 00:00:00 2001 From: Anders Rantala Hunderi Date: Thu, 14 Nov 2024 13:09:37 +0100 Subject: [PATCH 07/25] Replaced cumbersom D3 typing with alias type --- .../TreePlotRenderer/TransitionTreeEdge.tsx | 8 ++++---- .../TreePlotRenderer/TransitionTreeNode.tsx | 9 +++++---- .../src/TreePlotRenderer/index.tsx | 5 ++--- .../packages/group-tree-plot/src/types.ts | 5 +++++ .../packages/group-tree-plot/src/utils.ts | 20 +++++++++---------- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeEdge.tsx b/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeEdge.tsx index d8ec7a14c..e9926e43c 100644 --- a/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeEdge.tsx +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeEdge.tsx @@ -11,18 +11,18 @@ import { useDataAssemblerPropertyValue, useDataAssemblerTooltip, } from "../DataAssembler/DataAssemblerHooks"; -import type { RecursiveTreeNode } from "../types"; +import type { D3TreeEdge, D3TreeNode } from "../types"; import type DataAssembler from "../DataAssembler/DataAssembler"; export type TreeEdgeProps = { - link: d3.HierarchyPointLink; + link: D3TreeEdge; dataAssembler: DataAssembler; primaryEdgeProperty: string; transitionState?: TransitionStatus; // Kinda messy solution for this, might warrant a change in the future - nodeTree: d3.HierarchyPointNode; - oldNodeTree: d3.HierarchyPointNode | null; + nodeTree: D3TreeNode; + oldNodeTree: D3TreeNode | null; in?: boolean; }; diff --git a/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeNode.tsx b/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeNode.tsx index a63d00bf9..e6162681a 100644 --- a/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeNode.tsx +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/TransitionTreeNode.tsx @@ -5,7 +5,7 @@ import { useDataAssemblerPropertyValue, useDataAssemblerTooltip, } from "../DataAssembler/DataAssemblerHooks"; -import type { RecursiveTreeNode } from "../types"; +import type { D3TreeNode } from "../types"; import { findClosestVisibleInNewTree, printTreeValue, @@ -13,14 +13,15 @@ import { } from "../utils"; import * as d3 from "d3"; +import { HiddenChildren } from "./HiddenChildren"; export type TransitionTreeNodeProps = { primaryNodeProperty: string; dataAssembler: DataAssembler; - node: d3.HierarchyPointNode; + node: D3TreeNode; - nodeTree: d3.HierarchyPointNode; - oldNodeTree: d3.HierarchyPointNode | null; + nodeTree: D3TreeNode; + oldNodeTree: D3TreeNode | null; in: boolean; diff --git a/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx b/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx index 2301a04d5..bc30c32fb 100644 --- a/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx @@ -2,7 +2,7 @@ import React from "react"; import * as d3 from "d3"; import type { ReactNode } from "react"; -import type { RecursiveTreeNode } from "../types"; +import type { D3TreeNode, RecursiveTreeNode } from "../types"; import type DataAssembler from "../DataAssembler/DataAssembler"; import "./group_tree.css"; @@ -48,8 +48,7 @@ export default function TreePlotRenderer( return d3.tree().size([layoutHeight, layoutWidth]); }, [layoutHeight, layoutWidth]); - const lastComputedTreeRef = - React.useRef | null>(null); + const lastComputedTreeRef = React.useRef(null); const [nodeTree, oldNodeTree] = React.useMemo(() => { const previousTree = lastComputedTreeRef.current; diff --git a/typescript/packages/group-tree-plot/src/types.ts b/typescript/packages/group-tree-plot/src/types.ts index 6c603748a..9620d52c0 100644 --- a/typescript/packages/group-tree-plot/src/types.ts +++ b/typescript/packages/group-tree-plot/src/types.ts @@ -1,3 +1,4 @@ +import type { HierarchyPointNode, HierarchyPointLink } from "d3"; import PropTypes from "prop-types"; // Node key and values map - one value per date in DatedTree @@ -83,3 +84,7 @@ export const DatedTreePropTypes = PropTypes.shape({ dates: PropTypes.arrayOf(PropTypes.string).isRequired, tree: RecursiveTreeNodePropTypes, }); + +// Simple shorthand for the type passed among the tree plot components +export type D3TreeNode = HierarchyPointNode; +export type D3TreeEdge = HierarchyPointLink; diff --git a/typescript/packages/group-tree-plot/src/utils.ts b/typescript/packages/group-tree-plot/src/utils.ts index 09cf1f7d8..af76de1cd 100644 --- a/typescript/packages/group-tree-plot/src/utils.ts +++ b/typescript/packages/group-tree-plot/src/utils.ts @@ -1,10 +1,10 @@ import _ from "lodash"; import type DataAssembler from "./DataAssembler/DataAssembler"; -import type { RecursiveTreeNode } from "./types"; +import type { D3TreeEdge, D3TreeNode } from "./types"; export const TREE_TRANSITION_DURATION = 200; -export function computeNodeId(node: d3.HierarchyPointNode) { +export function computeNodeId(node: D3TreeNode) { if (!node.parent) { return node.data.node_label; } else { @@ -12,7 +12,7 @@ export function computeNodeId(node: d3.HierarchyPointNode) { } } -export function computeLinkId(link: d3.HierarchyPointLink) { +export function computeLinkId(link: D3TreeEdge) { return `path ${computeNodeId(link.target)}`; } @@ -22,9 +22,9 @@ export function printTreeValue(value: number | null): string { } export function findClosestVisibleInNewTree( - targetNode: d3.HierarchyPointNode, - newTreeRoot: d3.HierarchyPointNode | null -): d3.HierarchyPointNode | null { + targetNode: D3TreeNode, + newTreeRoot: D3TreeNode | null +): D3TreeNode | null { if (!newTreeRoot) return null; // Get a path of d3 nodes from the root to the treeNode @@ -60,8 +60,8 @@ export function findClosestVisibleInNewTree( export function findClosestVisibleParent( assembler: DataAssembler, - treeNode: d3.HierarchyPointNode -): d3.HierarchyPointNode | null { + treeNode: D3TreeNode +): D3TreeNode | null { const activeTreeRoot = assembler.getActiveTree()?.tree; if (!activeTreeRoot) return null; @@ -97,9 +97,7 @@ export function findClosestVisibleParent( return visibleParent; } -export function diagonalPath( - link: d3.HierarchyPointLink -): string { +export function diagonalPath(link: D3TreeEdge): string { const { source, target } = link; const avgY = (target.y + source.y) / 2; From c989f3e93045a32cde9c426531e710b7537ce8d4 Mon Sep 17 00:00:00 2001 From: Anders Rantala Hunderi Date: Thu, 14 Nov 2024 15:19:35 +0100 Subject: [PATCH 08/25] Made group tree renderer dynamically resize the plot to always fit the parent --- .../group-tree-plot/src/GroupTreePlot.tsx | 50 ++++++++++-- .../src/TreePlotRenderer/index.tsx | 60 +++++++------- .../src/storybook/GroupTreePlot.stories.tsx | 80 ++++++++++++++++++- 3 files changed, 151 insertions(+), 39 deletions(-) diff --git a/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx b/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx index a94fc839c..3b4463468 100644 --- a/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx +++ b/typescript/packages/group-tree-plot/src/GroupTreePlot.tsx @@ -1,4 +1,5 @@ import React from "react"; +import _ from "lodash"; import type { DatedTree, EdgeMetadata, NodeMetadata } from "./types"; import TreePlotRenderer from "./TreePlotRenderer/index"; @@ -17,13 +18,18 @@ export interface GroupTreePlotProps { } // TODO: Should be dynamic instead -const CONTAINER_HEIGHT = 700; -const CONTAINER_WIDTH = 938; export const GroupTreePlot: React.FC = ( props: GroupTreePlotProps ) => { let errorMsg = ""; + + // References to handle resizing + const svgRootRef = React.useRef(null); + const [svgHeight, setSvgHeight] = React.useState(0); + const [svgWidth, setSvgWidth] = React.useState(0); + + // Data update props const [prevDate, setPrevDate] = React.useState(null); // Storing a copy of the last successfully assembeled data to render when data becomes invalid @@ -50,15 +56,47 @@ export const GroupTreePlot: React.FC = ( } } + // Mount hook + React.useEffect(function setupResizeObserver() { + if (!svgRootRef.current) throw new Error("Expected root ref to be set"); + + 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; + + 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 && ( + + {lastValidDataAssembler.current && svgHeight && svgWidth && ( )} diff --git a/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx b/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx index bc30c32fb..bb2090fff 100644 --- a/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx +++ b/typescript/packages/group-tree-plot/src/TreePlotRenderer/index.tsx @@ -77,38 +77,34 @@ export default function TreePlotRenderer( ); return ( - <> - + + React.cloneElement(child, { oldNodeTree, nodeTree }) + } > - - 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 - - ))} - - - + {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 91d91aa64..cf4335c06 100644 --- a/typescript/packages/group-tree-plot/src/storybook/GroupTreePlot.stories.tsx +++ b/typescript/packages/group-tree-plot/src/storybook/GroupTreePlot.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; + import React from "react"; import { GroupTreePlot } from "../GroupTreePlot"; @@ -9,6 +10,7 @@ import { exampleDatedTrees, exampleDates, } from "../../example-data/dated-trees"; +import _ from "lodash"; const stories: Meta = { component: GroupTreePlot, @@ -74,5 +76,81 @@ export const Default: StoryObj = { selectedEdgeKey: edgeMetadataList[0].key, selectedNodeKey: nodeMetadataList[0].key, }, - render: (args) => , + render: (args) => { + const CONTAINER_HEIGHT = 700; + const CONTAINER_WIDTH = 938; + + return ( +
+