odoe

Stay on Schedule

Blog Post created by odoe on Dec 9, 2015

esrijs-scheduler.png

 

The ArcGIS JS API 4.0 Beta is still getting some love. Lots of updates with each beta release. Updated features, new features, updated docs and all that good stuff.

 

But there are still some dark corners of the API that are yet be completed...or doc'd! This is where I live.

 

When the 4.0 beta first came out, I did a cool blog post on using Accessors to record the camera of a view and play it back. It works pretty well, but I always felt it could be a little better. You'll notice the sample in that post, I'm using this odd module called esri/core/Scheduler.

 

What is that?

 

That is some low-level API goodness is what that is. The Scheduler is a modified version of the Scheduler found in Dojo2. It's basically a way to work with requestAnimationFrame. It's not doc'd yet, because beta, things could change.

 

So what you can do is schedule for stuff to happen during the next frame of your application. This is used for ... can you guess ... animations! Here is a cool little tutorial on requestAnimationFrame.

 

When you're working with 3D maps, you're working with animations. You don't need to concern yourself about the details, but if you want to do something to stay in sync with a 3D map, you should get familiar with requestAnimationFrame.

 

Scheduler can help with this.

 

In my old sample, I was still crutching on setInterval, so here's how I would do it all with Scheduler.

 

require([
  "esri/Map",
  "esri/views/SceneView",
  "esri/core/watchUtils",
  "esri/core/Scheduler",
  "dojo/on",
  // Widget items
  'dojo/_base/declare',
  'dojo/dom-class',
  'dijit/_WidgetBase',
  'dijit/_TemplatedMixin',
  "dojo/domReady!"
], function (Map, SceneView, watchUtils, Scheduler, on, declare, domClass, _WidgetBase, _TemplatedMixin){

  var template = '' +
  '<div>'+
    '  <input class="camera-slider" data-dojo-attach-point="slider"'+
    '  type="range" min="1" max="1" step="1" value="1">'+
    '  <a href="javascript:void(0)" data-dojo-attach-point="reverseBtn"'+
    '    data-dojo-attach-event="click:playReverse"'+
    '    title="Play views in reverse"'+
    '    class="btn btn-info btn-fab btn-raised mdi-av-fast-rewind"></a>'+
    '  <a href="javascript:void(0)" data-dojo-attach-point="playBtn"'+
    '    data-dojo-attach-event="click:play"'+
    '    title="Play views"'+
    '    class="btn btn-info btn-fab btn-raised mdi-av-play-arrow"></a>'+
    '  <a href="javascript:void(0)" data-dojo-attach-point="stopBtn"'+
    '    data-dojo-attach-event="click:stop"'+
    '    title="Pause recording view"'+
    '    class="btn btn-info btn-fab btn-raised mdi-av-pause"></a>'+
    '</div>';

  var CameraRecorder = declare([_WidgetBase, _TemplatedMixin], {
    templateString: template,
    constructor: function() {
      this.cameras = [null];
      this.timer = null;
      this.watcher = null;
      this.handler = null;
      this.isPlaying = false;
    },
    clear: function() {
      if (this.watcher) {
        this.watcher.remove();
      }
      if (this.handler) {
        this.handler.remove();
      }
      if (this.timer) {
        this.timer.remove();
      }
      this.recordStart();
    },
    recordStart: function() {
      if (this.isPlaying || this.isPaused) {
        return;
      }
      this.timer = Scheduler.schedule(function() {
        this._cameraWatch();
        this._sliderWatch();
      }.bind(this));
    },
    play: function() {
      if (this.isPlaying) {
        return;
      }
      domClass.toggle(this.playBtn, 'btn-info btn-success');
      this._play(false);
    },
    stop: function() {
      this.isPaused = !this.isPaused;
      domClass.toggle(this.stopBtn, 'btn-info btn-danger');
      if (!this.isPaused) {
        this.recordStart();
      }
    },
    playReverse: function() {
      if (this.isPlaying) {
        return;
      }
      domClass.toggle(this.reverseBtn, 'btn-info btn-success');
      this._play(true);
    },
    _cameraWatch: function() {
      var view = this.get('view');
      var cameras = this.cameras;
      var slider = this.slider;
      this.watcher = view.watch('camera', function(val) {
        cameras.push(val.clone());
        slider.max = slider.value = cameras.length;
        this.clear();
      }.bind(this));
    },
    _sliderWatch: function() {
      var view = this.get('view');
      var cameras = this.cameras;
      this.handler = on(this.slider, 'input', function(e) {
        var val = parseInt(e.target.value);
        view.camera = cameras[val] || view.camera.clone();
        this.clear();
      }.bind(this));
    },
    _play: function(inReverse) {
      this.isPlaying = true;
      var slider = this.slider;
      var view = this.view;
      var cameras = this.cameras;
      var len = cameras.length;

      var i = 0;
      var task = Scheduler.addFrameTask({ update: function() {
        if (!inReverse) {
          slider.value = i;
          view.camera = cameras[i++] || view.camera.clone();
          if (i === len) {
            task.remove();
            task = null;
            domClass.toggle(this.playBtn, 'btn-info btn-success');
            this.isPlaying = false;
            this.recordStart();
          }
        } else {
          slider.value = len;
          view.camera = cameras[len--] || view.camera.clone();
          if (len < 1) {
            task.remove();
            task = null;
            domClass.toggle(this.reverseBtn, 'btn-info btn-success');
            this.isPlaying = false;
            this.recordStart();
          }
        }
      }.bind(this)});
    }
  });
  
  var map = new Map({
    basemap: "dark-gray"
  });

  var view = new SceneView({
    container: "viewDiv",
    map: map,
    scale: 240000000
  });

  var camRecorder = new CameraRecorder(
    { view: view }, document.getElementById('recorder')
  );

  view.then(function() {
    camRecorder.recordStart();
  });

});

 

What I'm doing here is two things.

 

I'm using Scheduler.schedule() here to schedule some methods to occur on the next frame. These are the methods that store the camera or slider changes to record the view.

 

Then when you play the recorded camera views in sequence, I use Scheduler.addFrameTask({update: function(){}}) to add a function when the view is updated, basically rendered. There is some internal lifecycle stuff at work here in the addFrameTask, but I'm just shooting at the hip here, so let's go with update as the one to hook into.

 

If you don't sync operations like this that have to do with animation, you'll end up with a stuttering map. My first draft of this app was strictly working on watching for property changes, but it jacked up my app. Now it is much smoother.

 

You can see this updated example here.

 

So if you are interested with playing around with animating the view, playing with the camera or working in sync with animateTo to enrich your app, play around with Scheduler or just requestAnimationFrame if you like.

 

For more geodev tips and tricks, check out my blog.

Outcomes