pmndrs / react-spring

✌️ A spring physics based React animation library
http://www.react-spring.dev/
MIT License
28.04k stars 1.19k forks source link

Additional chart examples #5

Closed techniq closed 6 years ago

techniq commented 6 years ago

First, let me say this library looks great.

I am one of the contributors to vx and I am attempting to port a few of my animation examples from react-move to use react-spring. I've had performance issues with react-move on occasion and wanted to see how well react-spring performed, especially when leveraging the native prop.

I'm currently struggling with how my current react-move implementations port over to react-spring (and if they even can) and was hoping you could give some guidance (or show me how it's done πŸ˜„).

Collapsable tree

The first is a collapsable tree that animates the nodes (and links between them). When expanding a node, the child nodes will "zoom out" from their parent, and when collapsing, the child nodes will "zoom in" into their parent's previous location. In react-move, within each lifecycle hook (start, enter, update, leave) you pass a function that takes in a node instance and you return an object (as opposed to just setting an object, like react-spring does).

(see NodesMove.js in codesanbox, but the gist is...)

<NodeGroup
  data={nodes}
  keyAccessor={d => d.data.name}
  start={node => {
    const parentTopLeft = getTopLeft(node.parent || { x: 0, y: 0 }, layout, orientation);
    return {
      top: parentTopLeft.top,
      left: parentTopLeft.left,
      opacity: 0
    };
  }}
  enter={node => /* ... */}
  update={node => /* ... */}
  leave={node => /* ... */}
>

I'm not sure if using <Transition> can work in this situation, or if I'm missing something. Also, the location of a node's previous parent isn't always correct based on the order you close/open nodes, but it works OK.

Zoomable sunburst / partion

In these examples, I tween the domain and ranges of the x and y scales to collapse/hide the sunburst / partition pieces. I'm especially having difficulty wrapping my head around how to use Transition/Spring to replicate this.

Also, my react-move example was never as smooth as the original d3 example so I have high hopes this will work better with react-spring.

Any help you could provide on how to replicate these examples would be much appreciated.

drcmda commented 6 years ago

@techniq I recently got into vx, but would need some input in order to figure out how to make it fast. Generally the native prop mutates dom properties directly in the dom and can thereby skip render phases. Component props on the other hand will call render on every change. D3 writes to the dom, VX calls the component tree and react renders to the dom, which sometimes isn’t fast enough for animation purposes.

What we would need is something that exposes VX’es data calculation, like so:

path={template`$(vx(data))`}

Thereby we could render the svg once, pass an animated template to the path, which gets the actual path data from VX.

Would that be possible somehow?

techniq commented 6 years ago

@drcmda For the collapsable tree, this should definitely be possible. My biggest confusion with it is how to express a single element's new styles/state.

If you look at my example at NodesMove.js, you'll see the following, where a node's next state (position) is determined either by its parent's x/y for its start or leave values, or its own x/y for enter/update (when the hierarchy tree is recalculated, which happens when a node is shown/hidden)

<NodeGroup
  data={nodes}
  keyAccessor={d => d.data.name}
  start={node => {
    const parentTopLeft = getTopLeft(node.parent || { x: 0, y: 0 }, layout, orientation);
    return {
      top: parentTopLeft.top,
      left: parentTopLeft.left,
      opacity: 0
    };
  }}
  enter={node => {
    const topLeft = getTopLeft(node, layout, orientation);
    return {
      top: [topLeft.top],
      left: [topLeft.left],
      opacity: [1]
    };
  }}
  update={node => {
    const topLeft = getTopLeft(node, layout, orientation);
    return {
      top: [topLeft.top],
      left: [topLeft.left],
      opacity: [1]
    };
  }}
  leave={node => {
    const collapsedParent = findCollapsedParent(node.parent);
    const collapsedParentPrevPos = {
      x: collapsedParent.data.x0,
      y: collapsedParent.data.y0,
    }
    const topLeft = getTopLeft(collapsedParentPrevPos, layout, orientation);
    return {
      top: [topLeft.top],
      left: [topLeft.left],
      opacity: [0]
    };
  }}
>

It seems like I should use Transition, but I'm not sure how to access each individual node within from, enter, and leave. I could maybe wrap each individual node in a <Transition /> but that seems counter intuitive and possibly more expensive.

<Transition
    keys={items.map(item => item.key)}
    from={{ opacity: 0, height: 0 }}
    enter={{ opacity: 1, height: 20 }}
    leave={{ opacity: 0, height: 0 }}>
    {items.map(item => styles => <li style={styles}>{item.text}</li>)}
</Transition>

As for the links, currently you pass an object to it's data prop, and the path is built within it (typically using d3-path). You can find all the path types here. Ultimately it sounds like we would need to export the link path generators (for example, this one for a horizontal curve) and then apply them using react-spring's template. I think once I understand how to layout the node's, I should be able to tackle this.

The performance of the collapsable tree hasn't been as bad as the zoomable sunburst, but I think it would be better to focus on the simpler tree first, then look at how to improve the sunburst.

techniq commented 6 years ago

If you want to see a pure-d3 example (what I initially based mine on), you can see some here and here.

drcmda commented 6 years ago

@techniq sorry for the delay, vacations...

yep, the link you sent that builds the path, if we manage to make this exposable i think it's possible to make vx as fast as plain d3. Without this part, no matter what, it will always be on the slow side because React has to re-render. I think this would be the important bit to tackle first. Maybe with a very simplistic example first (perhaps the area-graph). This would set us up to make everything else fast afterwards.

As for the tree, i've never done anything big in d3 before, so it's hard for me to understand what's going on or what would be needed. Transition is a very basic primitive by design, i wanted to take out some of the complexity. If it needs more, like an updater, we could extend it - all this stuff is possible with animated.

techniq commented 6 years ago

@drcmda No worries, and I'm under a few deadlines and just looking at this as I get time πŸ˜„

The Link components are currently just convenient wrappers to:

These 2 could be split (or least be able to import the parts separately), as we would need to wrap the path data in a template like you mentioned: <path d={template`$(pathGenerator(data))`} />

I don't see any changes needed for react-spring regarding this, if vx exposes the applicable pieces. This is also just for the Link components, which are mostly just useful for tree views (but we have a ton of other components). VX is focused on being a small wrapper over the underlying svg/d3 pieces and attempts to not be too opinionated (one reason we do not currently provide any animation components/helpers - https://github.com/hshoff/vx/issues/6).

One change that would be very useful for me is if you could pass a function to the lifecycle props of Transitions, similar to how react-move's NodeGroup works

<Transition
    keys={items.map(item => item.key)}
    from={item => ({ opacity: 0, height: 0 })}
    enter={item => ({ opacity: 1, height: 20 })}
    leave={item => ({ opacity: 0, height: 0 })}>
    {items.map(item => styles => <li style={styles}>{item.text}</li>)}
</Transition>

I think this would be enough for me to use react-spring to replicate the collapsible tree.


For the zoomable sunburst example, I currently use d3-interpolate to interpolate the xScale's domain, and yScale's domain and range based on the timing. If there is a way we could do this using Animated

For example, this is the applicable part using react-move and d3-interpolate:

<Animate
    start={() => {
      this.xScale.domain(xDomain).range(xRange);
      this.yScale.domain(yDomain).range(yRange);
    }}
    update={() => {
      const xd = interpolate(this.xScale.domain(), xDomain);
      const yd = interpolate(this.yScale.domain(), yDomain);
      const yr = interpolate(this.yScale.range(), yRange);

      return {
        unused: t => {
          this.xScale.domain(xd(t));
          this.yScale.domain(yd(t)).range(yr(t));
        },
        timing: {
          duration: 800
        }
      }
    }}
  >
drcmda commented 6 years ago

@techniq So basically it's just a 0-1 toggle. I was confused about how you pass a zero object in there. So that would be a simple reset spring: https://codesandbox.io/s/nww6yxo0jl

<Spring reset from={{ t: 0 }} to={{ t: 1 }}>
    {({ t }) => {
        this.xScale.domain(xd(t))
        this.yScale.domain(yd(t)).range(yr(t))
        return (
            <Group top={height / 2} left={width / 2}>

It still re-renders frame by frame of course, but the implementation is more logical to me.

To make Transition read functions for each item so that you can give them individual values, that shouldn't be hard at all. Are you interested in lending a hand? I imagine we could really do something awesome here, VX could basically reach native speeds if react-spring (and vx on the exposing functions side) would be a little bit more flexible.

techniq commented 6 years ago

@drcmda Nice! Pulling the xd/yd/yr interpolators up above <Spring> also improves the smoothness (and spring/easing)

    const xd = interpolate(this.xScale.domain(), xDomain)
    const yd = interpolate(this.yScale.domain(), yDomain)
    const yr = interpolate(this.yScale.range(), yRange)

    return (
      <svg width={width} height={height}>
        <Partition top={margin.top} left={margin.left} root={root}>
          {({ data }) => (
            <Spring reset from={{ t: 0 }} to={{ t: 1 }}>
              {({ t }) => {
                this.xScale.domain(xd(t))
                this.yScale.domain(yd(t)).range(yr(t))
                return (
drcmda commented 6 years ago

Yeah, i just updated it : D Looks ways smoother like that. That was me having no idea what this d3 stuff is actually doing.

techniq commented 6 years ago

I'm assuming we could use Animated to do the interpolation of the values as well...

drcmda commented 6 years ago

First draft that renders the chart natively: https://codesandbox.io/s/nww6yxo0jl

screen shot 2018-04-04 at 10 10 26

I added an "interpolate" function that takes an animated value and calls you back with its actual value, which you can then pass to vx/d3/... to obtain the end-result that's going to be written into the path.

.map((node, i) => (
    <animated.path
        d={interpolate(t, t => this.calculate(t, xd, yd, yr, node))}

If you profile the component, it renders only once, that is, all 250 or so nodes render once. React is not involved any longer in the animation. I still see ripped frames, but that's perhaps something else, maybe domain/range are expensive - who knows.

Does this go into the direction you want it to?

techniq commented 6 years ago

@drcmda performance wise this looks great. I've noticed a little jank/stutter towards, primarily on mobile, but it is vast improvement over what I had.

Regarding the api, I was thinking/hoping I could get rid of the use of d3interpolate and have this hidden within react-spring. The t.addListener feels a little odd to me too (do I need to add a t.removeListener to not leak, although I figure t is GC'd after it completes

Instead of the current...

  render() {
    const { root, width, height, margin = { top: 0, left: 0, right: 0, bottom: 0 } } = this.props
    const { xDomain, yDomain, yRange } = this.state
    if (width < 10) return null
    const xd = d3interpolate(this.xScale.domain(), xDomain)
    const yd = d3interpolate(this.yScale.domain(), yDomain)
    const yr = d3interpolate(this.yScale.range(), yRange)
    return (
      <svg width={width} height={height}>
        <Partition top={margin.top} left={margin.left} root={root}>
          {({ data }) => (
            <Spring native reset from={{ t: 0 }} to={{ t: 1 }} config={{ tension: 200, friction: 50 }}>
              {({ t }) => {
                t.addListener(({ value }) => {
                  this.xScale.domain(xd(value))
                  this.yScale.domain(yd(value)).range(yr(value))
                })
                return (
                  <Group top={height / 2} left={width / 2}>
                    {data
                      .descendants()
                      .map((node, i) => (
                        <animated.path
                          d={interpolate(t, () => this.arc(node))}
                          stroke="#fff"
                          fill={color((node.children ? node.data : node.parent.data).name)}
                          fillRule="evenodd"
                          onClick={() => this.handleClick(node)}
                          key={`node-${i}`}
                        />
                      ))}
                  </Group>
                )
              }}
            </Spring>
          )}
        </Partition>
      </svg>
    )
  }

Possibly to something like:

  render() {
    const { root, width, height, margin = { top: 0, left: 0, right: 0, bottom: 0 } } = this.props
    if (width < 10) return null
    return (
      <svg width={width} height={height}>
        <Partition top={margin.top} left={margin.left} root={root}>
          {({ data }) => (
            <Spring
              native
              reset
              from={{ xd: this.xScale.domain(), yd: this.xScale.domain(), yr: this.yScale.range() }}
              to={{   xd: this.state.xDomain,   yd: this.state.yDomain,   yr: this.state.xRange }}
              onFrame={({ xd, yd, yr }) => {
                this.xScale.domain(xd)
                this.yScale.domain(yd).range(yr)
              }}
              config={{ tension: 200, friction: 50 }}>
              {({ t }) => {
                return (
                  <Group top={height / 2} left={width / 2}>
                    {data
                      .descendants()
                      .map((node, i) => (
                        <animated.path
                          d={interpolate(t, () => this.arc(node))}
                          stroke="#fff"
                          fill={color((node.children ? node.data : node.parent.data).name)}
                          fillRule="evenodd"
                          onClick={() => this.handleClick(node)}
                          key={`node-${i}`}
                        />
                      ))}
                  </Group>
                )
              }}
            </Spring>
          )}
        </Partition>
      </svg>
    )
  }

I don't know about the name onFrame, but a general hook to get the values at the point within the animation. I would think there is an implied time of 0 => 1.

Just a thought (and I'm likely overlooking something).

drcmda commented 6 years ago

Looks reasonable. I added it as onUpdate to the previous demo in the sandbox: https://codesandbox.io/s/nww6yxo0jl

You can certainly animate array values like in your last example, though i copied your code and it did nothing. Probably still something to do with the the domain stuff. Just be aware that d={interpolate(t, () => this.arc(node))} has to track some spring and t doesn't exist now, it could be xd or any of the other.

techniq commented 6 years ago

My code was "pseudo" to express the idea (I didn't expect it to work) :). The idea is to encapsulate the tweening of the domain/range values as much as possible (to keep the api clean).

While on the topic of api, could <Transition />'s lifecycle props also accept a function that gives you each item (if keys is an array) that returns the object?

<Transition
    keys={items.map(item => item.key)}
    from={item => ({ opacity: 0, height: 0 })}
    enter={item => ({ opacity: 1, height: 20 })}
    leave={item => ({ opacity: 0, height: 0 })}>
    {items.map(item => styles => <li style={styles}>{item.text}</li>)}
</Transition>
drcmda commented 6 years ago

Having functions is no problem, it's live under react-spring@3.2.0-beta.5, but Transition doesn't know items. It knows keys and gets a couple of functions as children, but nowhere does it have access to the collection itself. Instead of item it'll give you the key, like so:

<Transition
    keys={items.map(item => item.key)}
    from={key => ({ opacity: 0, height: 0 })}
    enter={key => ({ opacity: 1, height: 20 })}
    leave={key => ({ opacity: 0, height: 0 })}>
    {items.map(item => styles => <li style={styles}>{item.text}</li>)}
</Transition>

Can you give it a try and see if it's fine?

techniq commented 6 years ago

key should be fine as calling items[key] within each lifecycle will work. That should allow my react-move example using NodeGroup to port over to Transition pretty seamlessly.

Another quick question, the styles passed down do not necessarily have to be passed to the style prop correct?

<Transition
    keys={items.map(item => item.key)}
    from={key => ({ opacity: 0, height: 0 })}
    enter={key => ({ opacity: 1, height: 20 })}
    leave={key => ({ opacity: 0, height: 0 })}>
    {items.map(item => styles => <li style={styles}>{item.text}</li>)}
</Transition>

For instance, could I peel off the props and pass them as other props?

<Transition
    keys={items.map(item => item.key)}
    from={key => ({ x: 0, y: 0 })}
    enter={key => ({ x: 1, y: 20 })}
    leave={key => ({ x: 0, y: 0 })}>
    {items.map(item => styles => <rect x={styles.x} y={styles.y}>{item.text}</rect>)}
</Transition>

Once again, arbitrary, just understanding what is possible. I plan to look at this more tonight (need to get back to my other priorities for now).

If you get add key to the function callbacks, I'll see how far I can take it this evening to port my collapsible tree.

Thanks again for all your help.

drcmda commented 6 years ago

Another quick question, the styles passed down do not necessarily have to be passed to the style prop correct? For instance, could I peel off the props and pass them as other props?

Correct. If you're feeding regular styles and/or props, you can use native as well to make it faster.

If you get add key to the function callbacks, I'll see how far I can take it this evening to port my collapsible tree.

It's in the latest beta. I'll publish all this stuff officially (we've worked out reset, interpolate, onUpdate and functional styles so far) when your examples run.

techniq commented 6 years ago

Awesome. I hadn't seen any commits/branches on Github so I wasn't sure what was pushed, but I figured out you were pushing npm betas from your local working copy.

I'll hope to get to it this evening (if all goes well, but it might be another night or two as my kids' have practices and I'm under some work deadlines).

Once again, thanks for the awesome work πŸ˜„. All of this looks very promising. Btw, a few other animation patterns I might look into after we get some VX patterns working are some of these react-flip-move examples I made:

and some of the examples for react-morph.

Not that we need one library too rule them all, but it seems from all of your examples, the few components can go a long way.

techniq commented 6 years ago

@drcmda I spent a little time on it tonight, but ran into a few issues. See NodesSpring.js here for the WIP

Anyways, I didn't have a bunch of time tonight and likely overlooking something, but wanted to give you an update. Speaking of update, I think that is one issue I think I'll need help on your end to resolve. Thoughts?

drcmda commented 6 years ago

@techniq

screen shot 2018-04-05 at 10 22 03

Live in beta.7

Should fix all your issues. update => onUpdate, keys can return the actual objects if you supply an accessor, leave can therefore refer to an item that's not in your collection any longer.

The reason i went for item => styles => without Transition knowing the actual objects instead of the traditional way was because it's very simple for most cases. But i can see how that would inhibit complex animations, i hope accessor fixes it.

edit

I'm a little confused for what you need update. onUpdate isn't meant to return animated props, it's basically just a callback. from/enter/leave should know the positions where nodes should spring to. It seems to work: https://codesandbox.io/s/9jrjqvq954 only that after the second time when new nodes come up, the y offset seems off for some reason.

techniq commented 6 years ago

@drcmda thanks. I just got a moment to look at this again.

Being able to pass all the nodes to keys and getting the node within each lifecycle (especially leave) is great and solves that issue. The naming is a little odd (react-move uses items and keyAccessor but I understand that you typically only need the keys for other use cases. How are the keys used internally? I was a little surprised I didn't need to apply them onto the animating items below. I'm assuming you're calling React.cloneElement and applying them for each child within Transition. Maybe supporting both the items/keyAccessor pair and keys would be a descent compromise?

Regarding update, it seems like this is needed to handle the case where the items already exist but need to relocate. In DOM, these items would move during a re-layout when other items are added/removed (like your list item example, but within svg / absolute positioned items, they would need to be updated explicitly (unless I'm missing something).

Here is the comparison between the react-move (NodesMove.js) and react-spring (NodesSpring.js) currently

react-move

react_move_tree

react-spring

*note: only the nodes are using react-spring, the links are not (was waiting until I extracted the path generators) react_spring_tree

drcmda commented 6 years ago

You don't need to set keys because each element is wrapped in a spring internally, Transition sets keys by itself. items/keyAccessor pair and keys is fine with me. I'll add it in the next beta.

As for update, it just dawns me that this isn't the same as onUpdate (the frame-by-frame callback) at all. If i understood what it needs to do i can add it. react-move says:

A function that returns an object or array of objects describing how the state should transform on update. The function is passed the data and index.

But ... don't enter/leave already describe it? I am confused as to where the difference is and what exactly triggers it, or in other words, what constitutes an update.

For instance, when T is clicked, it updates, but how does Transition know that? Do you trigger it elsewhere?

drcmda commented 6 years ago

Ok, looks like "update" means all children that neither were added nor removed. I made a quick test and it looks like that's it: https://codesandbox.io/s/9jrjqvq954

There's some cleaning-up to do with the naming of it all, but i'll do that tomorrow.

techniq commented 6 years ago

@drcmda I was just writing something up, but yes πŸ˜„ , and thank you.

Btw, the enter, update, and exit is one of the core principals in d3 selectors

techniq commented 6 years ago

@drcmda I see your beta.11 progress πŸ‘

drcmda commented 6 years ago

just tried it out using native ... works : D updated the sandbox.

react-move:

screen shot 2018-04-06 at 03 16 33

native:

screen shot 2018-04-06 at 03 16 42
techniq commented 6 years ago

@drcmda that's still using react-spring for the links as well 😁. I have a deliverable due Monday that I need to focus on, but I hope to get around to getting the path generators exported this weekend possibly (and see if @hshoff can cut a release afterwards).

not going to lie, I'm having trouble telling the exact improvement between react-move and spring based on the performance charts (especially with different zoom / selection). native shows 871ms scripting while react-move shows 296ms, but it definitely feels smoother

drcmda commented 6 years ago

the overall amount is just for the time it took for the snapshot. I only recorded half a sec for the first and a bit more for the second. The important part is the timeline: react is totally out of the loop except the first render and the forceupdates, all frames are around 15ms, so it runs smooth. In the other react is taking a large chunk of the main thread, causing frames to blow up to 50ms, which rips into a smooth animation.

techniq commented 6 years ago

@drcmda thanks for the explantation :)

drcmda commented 6 years ago

Links are updated, too. Don't know how to do it natively here, it uses VX props. I'll take a nap. Thanks for all the input and work!

techniq commented 6 years ago

@drcmda hope it's not 4:30am there (based on your github profile). Have a good rest :)

techniq commented 6 years ago

@drcmda I'm not sure if I'll get time to tackle https://github.com/hshoff/vx/issues/263 this weekend with other commitments, but it doesn't look like you'll need it (other than for better path performance on the tree example).

Btw, regarding examples, here's some ideas / suggestions:

I've been collecting a lot of examples/inspiration but thought these might be a good start. I hope over the coming month or so to start building out some visuals using react-spring and vx that I'll share.

Lastly, regarding the react-flip-move / react-morph examples mentioned above, it might be a cool new component that uses the FLIP technique to calculate the from/to states automatically (using getBoundingClientRect/etc).

Thanks again for all your work on react-spring. With your upcoming release, it definitely looks like a great foundation to use with vx.

techniq commented 6 years ago

@drcmda btw, at this point I'm cool with closing this issue and creating other issues for say the FLIP component, if you think it's worth pursuing.

drcmda commented 6 years ago

I collected some demos, three of them have vx stuff in them: https://o48nx3n19z.codesandbox.io/ I think it's fine to get the idea across. All these are under /examples as well.

Flip move and react morph is very interesting as well, it would be nice to have an easy primitive for that, a new topic to flesh this out would be great.

Overall i think we've made quite some progress. The biggest questionmark for me atm is how to promote it. I feel like barely anyone has even heard of it or knows it exists. Would be great if it had more contributors so it could grow into something that really covers ground, and animated underneath is maybe the perfect base to make this happen. There are so many things to explore, from primitives to react-native to a truly native web-driver (web-animations).

techniq commented 6 years ago

I don't have much outreach myself, but I'll see what I can do about promoting it. Harrison Shoff (works at Airbnb, creator of VX and Airbnb style guide) and Elijah Meeks (works at Netflix, does a lot of work in d3, including https://github.com/emeeks/semiotic and a few books) among others hang out in the VX Slack channel and we've had some discussions about animation / etc. I think they'll be very receptive to promoting it.

drcmda commented 6 years ago

That would be awesome! I'm always open to suggestions should they or you have specific animation related wishes - so it could benefit VX as well.

techniq commented 6 years ago

thoughts?

image

sghall commented 5 years ago

This thread is very helpful.

Hey, @techniq your experiments with these different APIs is really interesting work. I found this because I was thinking about trying react-spring for a new project. I'm a little hazy on the outcome here in this thread. Did you find that you were able to handle the "d3 style" updates using react-spring? Were there things you couldn't do? Might need to just dig in here, but it would be great to hear your thoughts. One of the key pieces is how interrupts get handled, not sure if you got into that at all.

@drcmda, this idea of porting over the native animated library is really great. Don't understand it completely, but it's clever and performs really well. I'm wondering if it would make sense to offer the animated components as a standalone package. Would that make any sense or is everything in this repo pretty tightly bound together? Probably just need to dig into the code a bit, but just wanted to see what you thought about this.

techniq commented 5 years ago

@sghall I was using vx and had great success with it and react-spring. I haven't looked at them in probably a year though, especially with the later versions of react-spring / hooks, but they paired very well when I was.

Here are two good examples

I hope to have to get back into this in the next few months (a few features planned) and coincidentally someone was asking me about this today (after many many months). In fact you were in that discussion which might be what triggered your comment :)