Jump to content
Search Community

Animated maps with React - progress() method, and keeping track of timeline instance

Stephen Marshall test
Moderator Tag

Warning: Please note

This thread was started before GSAP 3 was released. Some information, especially the syntax, may be out of date for GSAP 3. Please see the GSAP 3 migration guide and release notes for more information about how to update the code to GSAP 3's syntax. 

Recommended Posts

Hi friends!

 

It's been many months since my last post...and this post piggybacks off this one here:

Before @Rodrigo was _super_ helpful! I decided to start a new thread since that one was getting rather long and went off in a lot of different directions (Hint: I was pretty new to GSAP and React etc...PS at that time @UnioninDesign was me before I had my boss get us an enterprise license - same guy!)

 

I also had _lots_ of code in there but no working demo...I've made one in stackblitz that replicates many of the troubles I am experiencing:

https://stackblitz.com/edit/react-lzbh3m?embed=1&file=map.js

 

I do feel that these issues are the results of unpredictable behavior from 3rd party component libraries (react-map-gl), and React's component lifecycle methods for class components...what I am hoping for is that someone here may be able to suggest some methods to help keep track of which timeline instance is occuring, and are there ways to keep that timeline instance intact with pause() and resume() methods etc? Maybe some new bleeding edge React Hooks solution for GSAP lol?

 

If you open the codepen, I am trying to simulate what one of my live booking maps looks like...every few seconds a map marker with some data pops up, with a card/tooltip showing additional data. Of course, I am using sample data, and the timeline repeats infinitely instead of fetching new data on an interval (ask me about that if you're curious!)

 

**Issues: **

  • In the codepen, everything looks like it's working...until the user interacts with the map! Any zooming or panning causes the timeline to go all haywire!
    • Map markers and cards tend to double in speed, it seems like the wrong ones are showing up...sometimes they try to render multiple times quickly
    • I think what is going on is that the state of the viewport updates, calling the componentDidUpate method again, which in turn fires the animations again...without proper garbage collection things get weird? Tweens are being called midway through their cycle etc?
    • I've tried to add some code to the method that updates the viewport - not working - any ideas for something I might try besides this.tl.pause().resume()?
    •     //Mapbox viewport for resize, zoom pan etc
          _onViewportChange = viewport => {
              //TODO timeline restarts on viewport change with zoom, panning...
              //Always runs this function on initial load, tl will be null
              if (_.isNull(this.tl)) {
                  console.log("tl is null on viewport change");
                  this.setState({ viewport });
              } else {
                  //if there's a valid timeline instance, pause and resume it
                  console.log("tl is valid on viewport change, pause and resume");
                  //Below does not work
                  this.tl.pause().resume();
                  this.setState({ viewport });
              }
          };
  • Normally I call the animations in the componentDidMount method, but in this case the timeline will not start and all markers render at once.
    • I believe the map calls the method to update the state of the viewport immediately onload, that way the map gets the initial state for zooming, which coordinates are at the center of the screen etc...and this kills the original timeline instance...hence calling a new one again in componentDidUpdate?
  • Timeline Controls / Range Slider:
    • like the original post I referenced, @Rodrigo created pen with a working range slider that gives the user feedback of where you are at in the timeline:
    • https://stackblitz.com/edit/react-rc-slider-gsap?file=index.js
    • This has been working for several months! Thank you! However, I'm making a new world map using a different library (react-map-gl ) and things are wonky!
      • In the codepen, you can click and drag along the range slider and it works!
      • the timeline onUpdate method is creating an infinite loop! in this case the timeline instance is undefined...about 60x per second...and chrome crashes hard! So you have a nearly working slider, but the dot does not move along with the timeline as it is not getting a value between 0 and 1 for the progress() method?
    • Play, Pause buttons etc work...but only if the original timeline is running. After any zooming or panning (and a few other state updates in the production app that trigger a re-render) these buttons no longer function as expected.

 

So far I've also tried to write a debugging method that I've called in various places when the component mounts or updates, and I've also called this method below inside the timeline's onUpdate - not really get any results? PS These are LoDash methods to check if the timeline instance is still null (initial state) or undefined (something has updated...)

timelineProgressChecker() {
        if (_.isUndefined(this.tl)) {
            console.log("Timeline Undefined");
        } else if (_.isNull(this.tl)) {
            console.log("Timeline is null");
        } else {
            console.log("Timeline Progress? " + this.tl.progress());
        }
    }

 

Would appreciate any help - whether that's refactoring my React code, or maybe some GSAP methods I didn't know about, or did not use properly!

 

 

See the Pen by edit (@edit) on CodePen

Link to comment
Share on other sites

Hey Stephen,

 

First thanks for supporting GSAP by buying a license, very cool of you guys!!

 

Second, I had a chance to take a peek at the code (not an in-depth look though) and what caught my attention is this:

 

componentDidUpdate(){
  console.log("========\n Updated \n========\n")
  this.animateMapMarkers()
  console.log("2 Progress on update: ", this.tl.progress())
}

This code is executed on every update, at some point your component could have more properties in the state object or props coming from the parent component or a store (Redux, Mobx, etc), so you need to update the animation only on a specific state update, one that actually calls for completely creating the animation again. My guess is that the slider progress shouldn't trigger a side effect that re-creates the animation but updates it's progress. Keep in mind that the componentDidUpdate method has a couple of params pass to it that allows you to compare the previous state and props with the new ones and execute code accordingly https://reactjs.org/docs/react-component.html#componentdidupdate Perhaps that is the reason why the slider is not working, because every time the slider is updated the state property is updated as well and that trigger the componentDidUpdate method creating the animation again with it progress at 0.

 

As far as the map pan/zoom events if possible try to use an event start to pause the timeline and an event end to create the timeline again. For example as soon as the user starts panning the map, you pause the timeline and update the state and re-create the timeline only when the panning event is completed, like that you avoid a bunch on unnecessary renders of the component. Also If I was you on the start event I would register the current progress of the timeline, pause, kill and invalidate it and in the end event I would clear the props of all the elements, create the timeline again and set it's progress to the value you stored before. It seems that react map gl allows to create some custom events, but at a quick glance the API seems a bit convoluted to me and there are  no clear examples of how to create and implement that in a component and how to subscribe to the specific events since the event handlers will reside in the same component, perhaps using a method inside the component class that instantiates a new controller adds the event listeners and returns the controller, could work. You can always ask in stack overflow and create an issue in the repo.

 

This is as much as I can do right now, sorry for not being more helpful. Hopefully is enough to get you started.

 

Happy Tweening!!!

  • Like 4
Link to comment
Share on other sites

This sounds about right...around the end of the day yesterday I was making some progress by adding some conditional logic to the componentDidUpdate method - i'll update the codepen shortly, but this is mostly solved and the range slider is working! One more question remains...you said:

Quote

As far as the map pan/zoom events if possible try to use an event start to pause the timeline and an event end to create the timeline again. For example as soon as the user starts panning the map, you pause the timeline and update the state and re-create the timeline only when the panning event is completed, like that you avoid a bunch on unnecessary renders of the component.

How do I pause the timeline, register the current progress, and restart it? below I am using this line of code below, but this will in fact restart the timeline?

this.tl.pause(this.tl.progress()).play();

 

EDIT: I figured this one out too, I was close...all I needed was:

this.tl.pause().play();

 

Other than that just about everything is working! I may have to pick your brain about handling onClick events to get the cards to pop-up when you click on a map marker...and some weird business with client-side filters I built. As always I appreciate the help and feedback!

 

Here is my code in componentDidUpdate() if anyone is curious...this from my production code, where I have a method called animateMapMarkers that is called conditionally. Just about everything works other than pausing and resuming the timeline? PS _.isNull, _.isEqual, and _.isUndefined are from LoDash - very helpful library for this sort of thing!

	componentDidUpdate(prevProps, prevState) {
		console.log("===============\nUpdated\n==============");
		if (_.isNull(this.tl)) {
			console.log("Timeline is null on update, calling animation");
			this.animateMapMarkers();
		} else if (_.isUndefined(this.tl)) {
			console.log("Timeline Undefined on update, calling animation");
			this.animateMapMarkers();
		} else if (
			this.tl.isActive() &&
          //onTheMap is the array of data passed down the component tree
			_.isEqual(prevProps.onTheMap, this.props.onTheMap) &&
          //the state of the viewport has changed - zoom/pan etc
			!_.isEqual(prevState.viewport, this.state.viewport)
		) {
			console.log(
				"Timeline isActive on update,viewport changed, no new data - pause and resume"
			);
          //This does not break...but timeline restarts? How can I change this line of code below?
			this.tl.pause(this.tl.progress()).play();
		} else if (
			this.tl.isActive() &&
          //When there's new data...kill and restart the timeline
			!_.isEqual(prevProps.onTheMap, this.props.onTheMap)
		) {
			console.log(
				"Timeline isActive on update, new data and props, kill and restart"
			);
			//kills the timeline when data set updates
			this.tl.pause(this.tl.duration()).kill();
			this.animateMapMarkers();
		}
	}

 

Link to comment
Share on other sites

Hey, basically when you want to stop the timeline and create it again you don't need to pass the duration or anything to the pause method, because that would create a jump to the timeline's end and that could look a bit odd (unless that is exactly what you're after).

 

Since I presume that the progress of the timeline is not needed as a state property in the component, you can create an instance property in the constructor to store and update the progress:

 

constructor() {
  super();
  // default to 0 or null
  this.currentProgress = 0;
}

Then when you pause the timeline you can run this code

// stop the timeline
this.tl.pause();
// record it's progress
this.currentProgress = this.tl.progress();
// kill the timeline
this.tl.kill().invalidate();
// then create the timeline again
this.animateMapMarkers();
// set the updated timeline's progress to the one stored and resume
this.tl.progress(this.currentProgress).play();

That is the most logical and simple sequence I can think of to do that.

 

As for the click events, I took a quick look at the API of react map gl yesterday and by going through it quite fast is not extremely simple to get up-to-speed with it. Perhaps a more in-depth dive and playing with it would allow getting your feet wet and better understand how it works.

 

Happy Tweening!!!

  • Like 4
Link to comment
Share on other sites

Thanks again @Rodrigo as always - you're the king! If I could ask another question concerning one final bug...this time it's a kind-of edge case with ref callbacks in React...getting an error about 'cannot tween a null target'

 

I had mentioned:

Quote

I may have to pick your brain about handling onClick events to get the cards to pop-up when you click on a map marker...and some weird business with client-side filters I built. As always I appreciate the help and feedback!

First of all - I've figured out onClick event handlers, and have added this my codepen here: https://stackblitz.com/edit/react-lzbh3m?embed=1&file=map.js

 

So on to client-side filters...to avoid posting huge blocks of code, I'll leave that for the codepen, but a general description of what I'm trying to do:

  • In production, my map fetches the newest data every 5 minutes. However, we've had some lag in updating our databases, and sometimes I don't have data that current!
  • As a result, I go back several hours, and then use the array filter method to split up the JSON response into smaller arrays like past 15, past 30 minutes, past hour etc. The end-user can select which data set they want to see. I call a setState for all of these sub-arrays when I fetch the data...but ultimately the props passed to the map component are an array called onTheMap - so if the user picks one data set versus the other, a method is invoked like so:

handleData15Min = () => {this.setState({ onTheMap: this.state.past15min}) }  I don't think this is best practice to turn this.state.onTheMap into this.state.pastXXXminutes, not sure if this is mutating state? Because this is React issue as well I do have a post going in stack overflow too, FYI.

  • Nonetheless, this works fine, except if a data set has been previously selected! If the user picks 15 minutes, then 30 minutes...then goes back to 15 minutes, the app crashes! 
  • Unfortunately,  I could not reproduce this exact error in stackblitz. I did implement some client-side filtering of the data, and this crashes every time, not just on 2nd time the user selects a sub-array of data, but does not give me a 'null target' error like I get in production.

Per the React docs, I believe this is what is happening:

Quote

If the ref callback is defined as an inline function, it will get called twice during updates, first with null and then again with the DOM element. This is because a new instance of the function is created with each render, so React needs to clear the old ref and set up the new one. You can avoid this by defining the ref callback as a bound method on the class, but note that it shouldn’t matter in most cases.

 

So - if I create references to the map markers like so:

 ref={e => (this.MapMarkers[i] = e)}

I can call the animations in componentDidMount, and everything works fine (unless the end-user behavior described above happens - d'oh!)

I've tried a lot of things and spent more time than I'd like on this problem - this is the final bug before deployment! Seeing this in the React docs made me think maybe there's some tricks to ref callbacks with GSAP that I'm missing - hoping someone can help! The latest thing I tried was, like the quote above, to define the ref callback as a bound method on the class, like so:

constructor() {
        super();
this.setMarkerRef = i => e => {
      this.MapMarkers[i] = e;
    };
...

And then bind in the constructor: 

this.setMarkerRef = this.setMarkerRef.bind(this);

And then call ref={this.setMarkerRef} to get the DOM element inside of render...which I must be doing something wrong because the refs log as undefined. So...with this approach, I can pick any dataset I want, as many times as I want - no crash - but also no animations due to the refs being undefined...but no error about "cannot tween a null target" either it just renders everything?

 

I feel like I'm getting close - what am I missing here?

Link to comment
Share on other sites

Hi,

 

Unfortunately stackblitz is giving a generic error, so there is no way to actually pinpoint what and where is happening here.

 

For mutating state, if you're using setState() then you don't have to worry about it. React is handling things for you when updating the component's state. Immutability is suggested when you use a store software such as Flux, Redux, Mobx, VueX, etc., where the dev is in charge of creating and maintaining the state. In those cases is recommended to create a new object instead of changing the current one when a state mutation happens. Although VueX does handles that for you.


A few pointers though. Is not at all necessary to do all that convoluted filtering when is not needed. What I mean is that when you create the app you have all the data in the state. On top of that you filter that data in sub-sets and add all those sub-set to the state as well. Basically that creates a duplicated data set. Data filtering is not an extremely expensive process. It all depends on the amount of data, but under 500 elements it should really fast specially if the filter test is simple like your case. When the user actually wants a sub-set of the original data, create it and set it as the state property. So instead of having a bunch of state properties for all the cities and every possible sub-set, just create one that is what the user wants. More arrays in your app means more memory, more CPU to handle all of them, which leads to more battery use in devices and so on. Right now you're using onTheMap for the data set shown in the app, keep using just that when filtering the data. My best guess is that the animation is not being stopped when the data is updated, therefore GSAP looks for an element to animate which is being returned as null, therefore throwing the error. My advice is to try a version of the app with no animations whatsoever and just show the filtered elements (dots on the map) and then if that works add GSAP to the mix, remembering to stop, kill and re-create the animation again as the data changes.

 

Sorry I can't help you more than this but unfortunately Stackblitz doesn't give us more to work with. Hopefully this will help you in some way.

 

Happy Tweening!!!

  • Like 3
Link to comment
Share on other sites

Thanks Rodrigo these are good ideas, and I'll take them out for a spin ?

 

I like the idea of filtering the data on demand instead of doing so by default, but I'm afraid it may be necessary? Got to work this morning and I have current data...yesterday I was running 4-8 hours behind most of the day. With unpredictable amount of lag in updating our databases, my data-fetching method does its filtering, and then something likeif(past5minData.length === 0){//move onto the next one} - that way the visualization always has something to load into onTheMap and then animate it. Perhaps I can run the filters, but move the setState calls into the client-side data selection methods so I'm not storing duplicate data in application state? I'll update the thread once I've found a way to get this working 100% of the time!

Link to comment
Share on other sites

6 hours ago, Stephen Marshall said:

I like the idea of filtering the data on demand instead of doing so by default, but I'm afraid it may be necessary? Got to work this morning and I have current data...yesterday I was running 4-8 hours behind most of the day.

Actually what I meant by that is how to handle the server response, regardless of when the data actually is. The main idea is to keep the state as clean as possible. I assume that you have some sort of recursive data fetching code in your app or you're running something like Firebase that can update, on-demand, the client data whenever the server data is updated. The point of that suggestion is not limit the data you're receiving from the server, just how to filter it and keep the component's state as clean as possible once the response is in the client-side.

 

Happy Tweening!!

  • Like 2
Link to comment
Share on other sites

Got it! I did play around with moving setState calls for filtered data sets into the handle methods...this did not solve the null refs issue, but sounds like best practice to implement going forward! It may be necessary in my case to run the filters when fetching the data, but I agree 100% that I don't need to store more than one array of data in application state!

 

In any case, I've arrived at a solution to share with the community! Remove the null refs from your array! I did this using the loDash _.compact() method, which removes falsey values from an array, and I implemented this code when setting my animation targets. You could probably do this in componentDidUpate, but with how often that lifecycle method is invoked I think it would be overkill, as opposed to doing it when you call the animations and create a new timeline instance. Should work unless you have booleans with deliberately false values, and you are trying to animate them lol ?

 

I've updated my codepen here, and you can see this at work: https://stackblitz.com/edit/react-lzbh3m?embed=1&file=map.js

 

To explain a bit more what you're dealing with when the React Docs say: 

Quote

If the ref callback is defined as an inline function, it will get called twice during updates, first with null and then again with the DOM element. This is because a new instance of the function is created with each render, so React needs to clear the old ref and set up the new one. You can avoid this by defining the ref callback as a bound method on the class, but note that it shouldn’t matter in most cases.

 

To me, that's not quite what I was seeing? Let's say you have an array of data, and you use .map() to loop through and generate a <div> for each one that you want to animate...and the end-user can interact with your app , sending new props from a parent that updates your child component (without unmounting and remounting, which would probably work fine since the refs would be re-created?) If you were to log your refs, something like 

console.log("Refs: " + this.myArrayOfDivs);

...and you were going back to a previously selected set of data, you would see something like this in the console:

 

[object HTMLDivElement],

[object HTMLDivElement],

[object HTMLDivElement],

,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,

You can see the null values ,,,,,,,,  appear to be appended to the end of the array, instead of returning a separate array/returning the array twice, first with null values and then with the actual DOM nodes? I suspect that checking for them, killing, and restarting the animations might not work in this circumstance?

 

So - how do you get the null values out of the array? There's lots of ways to look for them, including other loDash methods like _.isEmpty, _.isNull etc...and loDash had several methods I tried first that will return a new array without the falsey values (_.pickBy or _.omitBy are good examples). These did let me client-side filtering methods work, but would somehow mutate the array of refs so the animation target would be obfuscated (no "null target" error, they just didn't work?)

 

What worked for me looks something like this:

 

myAnimationMethod = () => {
  this.tl = new TimelineMax();
  //when a new timeline instance is created, remove any null or falsey values from the array of refs
  const myArrayOfTweens = _.compact(this.myRefToAnArrayOfDOMElements)
  //animate them!
  this.tl.staggerTo(myArrayOfTweens, 1, {//do stuff}, etc.
  }
  

since _.compact(myArray) removes the falsey values without returning a new array, you're in business! Go figure it'd be a one-liner...

 

Thanks again Rodrigo! Until the next!

Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...