bkrem / react-d3-tree

:deciduous_tree: React component to create interactive D3 tree graphs
https://bkrem.github.io/react-d3-tree
MIT License
1.06k stars 268 forks source link

Support for dynamic `data` scenarios #330

Open McJezuss opened 3 years ago

McJezuss commented 3 years ago

Thank you for taking the time to report an issue with react-d3-tree!

Feel free to delete any questions that do not apply.

Are you reporting a bug, or opening a feature request?

Bug

What is the actual behavior/output?

When updating the data dynamically with a state, the tree data doesn't actually update. The data I set at creation is the only data that it displays, regardless if I update it in the state or not.

What is the behavior/output you expect?

I expect the internal data of the tree to update.

Can you consistently reproduce the issue/create a reproduction case (e.g. on https://codesandbox.io)?

I would need access to our system, so no, but I am willing to share whatever you need to see.

What version of react-d3-tree are you using?

2.0.0

McJezuss commented 3 years ago

Update: When I wrap it in a container, it clearly shows that it is indeed updating, but it is also rerendering. Is there a way to stop the rerender?

McJezuss commented 3 years ago

If anyone else is facing this issue, I found a workaround by doing the following:

bkrem commented 3 years ago

Hi @LunarAI, thanks for making me aware of this issue in v2.

I've created a small repro attempt with a (simplified) data fetch here (codesandbox.io). By default the tree seems to append data as expected, so it's not clear to me what exactly you're encountering in your case.

Could you fork the sandbox and configure it in a way that reproduces the issue? If you can't reproduce it there, please post an excerpt of your Tree component and which TreeProps/configuration you're using.

I'm aware that this is generally a tricky use case (#201; saw your additional comment there too), so I'm keen to do some bug squashing and adding explicit support for incremental data fetching.

McJezuss commented 3 years ago

Hi @bkrem, I am so sorry for not getting back to you yet. I will try to replicate it and submit the link here. It will probably be in early February.

bkrem commented 3 years ago

No need to apologise 😄 Thanks again for bringing my attention (back) to this, since it's a use case that should get first-class support instead of requiring workarounds due to re-render quirks.

I think I realised what you were encountering after re-reading your explanations and playing around with the codesandbox above some more:

Without setting any initialDepth this approach works (as you mentioned in your workaround, thank you for that too 👌 ).

But there's definitely unintended behaviour when using initialDepth together with this incremental fetching approach due to re-rendering, i.e. the tree becomes "frozen".

I'm looking into a fix for the issue at the moment 🤞

stevepetebruce commented 3 years ago

Hi @bkrem. We have a similar issue where we are filtering a large amount of data, so need to add the data incrementally. I have replicated your codesandbox and have it working, but as you mention we need to set the initialDepth because the tree freezes when removed. Do you have any updates on if you are any closer to fixing the issue? I realise your last comment was just 5 weeks ago. Thanks.

bkrem commented 3 years ago

Hi @stevepetebruce,

February was a super busy month for me both in- and outside of work, but I had some time this weekend to look more deeply into this topic and make some progress.

I want to clarify that this is really a whole new feature set for the library (explained below), rather than a bug, since there's currently no explicit support for this advanced use case.

TL;DR: react-d3-tree does not currently differentiate between the concepts of two distinct data sets vs one augmented/modified data set. I'm currently figuring out how to allow for this differentiation in a meaningful way without adding boatloads of complexity.

The key issue is the following:

If data changes, how does Tree know whether the new value is an entirely different dataset (A -> B) or is a mutation of the previous dataset (A -> A*)?

This differentiation can be achieved by adding a new dataKey: string | number prop:

This approach works, but I'm still working on making the tree's behaviour align with common sense for the A -> A* scenario:

akasranjan005 commented 3 years ago

Any solution for this? I am facing a similar challenge, re-rendering the entire tree when tree size gets too big doesn't look like a good option. Any suggestion on how to handle updates in a large tree? Thanks.

jlit commented 1 year ago

I saw the recent merge of https://github.com/bkrem/react-d3-tree/pull/417/files by @bkrem last month. Thanks. It looks like what I want but I've had no luck. Is there a test or example of using dataKey?

If I specify a dataKey (an id from my root node) on initial render then nothing renders. If I useState to set a datakey after my initial render of data that's fine but changes to data still re-render the tree (expanding all collapsed nodes). Seems like maybe the dataKey test in getDerivedStateFromProps should/could look to see it there is no prevState data and go ahead and render if not. Might save some hassle.

I was adding the children to my data manually so then I thought maybe I could/had to use the addChildren function. I called that from within my renderCustomNodeElement function but it doesn't add any children. I can see the children being pushed onto targetNodeDatum in handleAddChildrenToNode but they don't render and aren't on the nodeDatum in subsequent calls to renderCustomNodeElement. Probably a mistake in how I'm implementing this.

I tried modifying the codesandbox example above but couldn't get a version above 3.5.1 there.

ajzyn commented 2 months ago

Any updates? I faced the same issue

jlit commented 2 months ago

It's been a long time since I looked at this @ajzyn . I got it working but I forget what fixed it. Here's my code. If this helps you then maybe you could post the relevant portion.

import { FunctionComponent, useEffect, useState, useCallback } from "react";
import { Link } from "react-router-dom";
import Tree, {TreeProps, RawNodeDatum, CustomNodeElementProps, TreeNodeDatum} from "react-d3-tree";
import axios from "axios";
import { Card, Button, Modal, Header, Icon, Dimmer, Loader } from "semantic-ui-react";
import { get } from 'lodash';

const fetchTreeData = async (nodeId: string) => {
  let { data } = await axios.get(`/api/myRoute/${nodeId}/treeData?depth=3`);
  return data;
}

interface iProps {
  rootNodeId: string,
  orientation?: string
};

interface iCustomNodeProps extends CustomNodeElementProps {
  foreignObjectProps: object;
}

const ScaleTree: FunctionComponent<iProps> = ({rootNodeId, orientation}) => {
  const [ loading, setLoading ] = useState(false);
  const [ dataKey, setDataKey ] = useState("");
  const [ detailNodesMap, setDetailNodesMap ] = useState(new Map());
  const [ errorFetching, setErrorFetching ] = useState(false);
  const [ dimensions, setDimensions ] = useState({width: 1000, height: 1000});
  const [ treeData, setTreeData ] = useState({} as RawNodeDatum);

  // Fetches the specified node and its children from the API to a depth of 3 and
  // returns the result
  const fetchNode = useCallback(async (nodeId: string) => {
    setLoading(true);
    setErrorFetching(false);
    let data = {} as RawNodeDatum;
    try {
      data = await fetchTreeData(nodeId);
    }
    catch (error) {
      setErrorFetching(true);
      console.log('error', error);
    }
    finally {
      setLoading(false);
      return data;
    }
  }, []);

  const getInitialTreeData = useCallback(async (nodeId: string) => {
    const data = await fetchNode(nodeId);
    setTreeData(data);
    setDataKey(nodeId);
  }, [fetchNode]);

  useEffect(() => {
    const dims = document.getElementById("treeWrapper")?.getBoundingClientRect();
    setDimensions({
      width: dims?.width || 1000,
      height: dims?.height || 1000
    });

    getInitialTreeData(rootNodeId);
  }, [rootNodeId, getInitialTreeData])

  const handleDetailsToggle = (nodeId: string) => {
    const detailNodes = new Map(detailNodesMap);
    detailNodes.set(nodeId, detailNodes.get(nodeId) ? false : true);
    setDetailNodesMap(detailNodes);
  }

  const handleNodeToggle = async (nodeDatum: TreeNodeDatum, toggleNode: () => void, addChildren: any) => {
    const nodeId: string = get(nodeDatum, 'attributes.id', 'n/a') as string;
    const needToFetch: boolean = get(nodeDatum, 'children.length', 0) < get(nodeDatum, 'attributes.childCount', 0);
    if(nodeId && needToFetch && nodeId !== 'n/a') {
      const node = await fetchNode(nodeId);
      if(get(node, "children.length", 0) > 0) {
        addChildren(node.children);
      }
      if (nodeDatum.__rd3t.collapsed) {
        toggleNode();
      }
    }
    else {
      toggleNode();
    }
  }

  const renderForeignObjectNode = ({
    nodeDatum,
    toggleNode,
    addChildren,
    foreignObjectProps
  }: iCustomNodeProps) => {
    const nodeId = nodeDatum.attributes?.id;
    let circleColor = nodeDatum.children && nodeDatum.__rd3t.collapsed ? "darkGray" : "white";
    if(nodeDatum.attributes?.isTerminal) { circleColor = "blue"; }
    if(nodeDatum.attributes?.isLeaf) { circleColor = "green"; }
    if(nodeDatum.attributes?.isMissing) { circleColor = "red"; }

    let childCount = nodeDatum.attributes?.childCount || "";

    let details = "";
    if(nodeDatum.attributes?.nodeTitle) {
      details += `${nodeDatum.attributes?.nodeTitle}<br /><br />`;
    }
    if(nodeDatum.attributes?.calcField) {
      details += `calcField: ${nodeDatum.attributes?.calcField}<br />`;
    }
    if(nodeDatum.attributes?.compValue) {
      details += `compValue: ${nodeDatum.attributes?.compValue}<br />`;
    }
    if(nodeDatum.attributes?.WGAScale) {
      const wgaScales = JSON.parse(nodeDatum.attributes?.WGAScale as string);
      for(const wgaScale of wgaScales) {
        details += `WGAScale: ${new Date(wgaScale.effectiveDate).getFullYear()}: $${wgaScale.value.toLocaleString('en-US', {maximumFractionDigits: 0})}<br />`;
      }
    }
    if(nodeId && nodeDatum.attributes?.isLeaf && detailNodesMap.get(nodeId) === undefined) {
      // show leaf node details by default first time through
      const detailNodes = new Map(detailNodesMap);
      detailNodes.set(nodeId, true);
      setDetailNodesMap(detailNodes);
    }

    return (
      <g>
        <circle r={20} onClick={handleNodeToggle.bind(this, nodeDatum, toggleNode, addChildren)} fill={circleColor} />
        <text onClick={handleNodeToggle.bind(this, nodeDatum, toggleNode, addChildren)} textAnchor="middle" stroke="black" strokeWidth="2px" dy=".3em">{childCount}</text>
        {/* `foreignObject` requires width & height to be explicitly set. */}
        <foreignObject {...foreignObjectProps} >
          <div style={{textAlign: "center", padding: "10px 50px 20px 0"}}>
            <Card style={{backgroundColor: "#f9f9f9"}}>
              <Card.Content>
                <Card.Header>{nodeDatum.name}</Card.Header>
                {!nodeDatum.attributes?.isLeaf && (
                  <Card.Meta>{nodeDatum.attributes?.nodeText}</Card.Meta>
                )}
                {(nodeId && detailNodesMap.get(nodeDatum.attributes!.id as string)) && (
                  <Card.Description dangerouslySetInnerHTML={{__html: details}}></Card.Description>
                )}
              </Card.Content>
              {nodeId && (
                <Card.Content extra>
                  <div className='ui three buttons'>
                    <Button secondary content='Details' onClick={() => handleDetailsToggle(nodeId as string)} />
                    <Button primary content='Edit' as={Link} to={`/node/${nodeId}`} />
                    <Button content='Copy' onClick={() => navigator.clipboard.writeText(nodeId as string)} />
                  </div>
                </Card.Content>
              )}
            </Card>
          </div>
        </foreignObject>
      </g>
    );
  }

  const nodeSize = { x: 450, y: 350 };
  const separation = { siblings: 1, nonSiblings: 1 };
  const foreignObjectProps = {
    width: nodeSize.x,
    height: nodeSize.y,
    x: orientation === "vertical" ? -150 : 25,
    y: orientation === "vertical" ? 15 : -55
  };

  return (
    <div
      style={{width: "100%", height: "100%"}}
    >      
      <Dimmer active={loading}>
        {loading && <Loader>Fetching Node...</Loader>}
      </Dimmer>
      <Tree
        data={treeData}
        orientation={(orientation || 'vertical') as TreeProps["orientation"]}
        zoom={0.5}
        nodeSize={nodeSize}
        dimensions={dimensions}
        translate={{ x: dimensions.width / 2, y: dimensions.height / 2 }}
        separation={separation}
        renderCustomNodeElement={(rd3tProps) =>
          renderForeignObjectNode({ ...rd3tProps, foreignObjectProps })
        }
        dataKey={dataKey}
      />
      <Modal
        basic
        onClose={() => setErrorFetching(false)}
        open={errorFetching}
        size='small'
      >
        <Header icon>
          <Icon name='warning circle' />
        </Header>
        <Modal.Content>
          <p>Error fetching Node</p>
        </Modal.Content>
        <Modal.Actions>
          <Button basic color='red' inverted onClick={() => setErrorFetching(false)}>
            <Icon name='remove' /> OK
          </Button>
        </Modal.Actions>
      </Modal>
    </div>
  );
}

export default ScaleTree;
DennisWeru commented 1 week ago

The code above worked for me. I have been searching for a solution to add nodes dynamically for a while now. The takeaway is you need to need to keep the DataKey constant and use the addChildren function directly on the node.