Bug 1406287 - Part 2: Implement animation time tick and label. r?gl draft
authorDaisuke Akatsuka <dakatsuka@mozilla.com>
Wed, 25 Oct 2017 14:10:46 +0900
changeset 685935 73cca812d6827eede4535869ea8b294164b21f28
parent 685934 f116833168081cf412abb280d5614c5e5ba64a61
child 685936 1a4e0e53c99828848b4c2ff0da327c4704589797
push id86037
push userbmo:dakatsuka@mozilla.com
push dateWed, 25 Oct 2017 05:43:28 +0000
reviewersgl
bugs1406287
milestone58.0a1
Bug 1406287 - Part 2: Implement animation time tick and label. r?gl MozReview-Commit-ID: GlkOal5ClHu
devtools/client/inspector/animation/components/AnimationListContainer.js
devtools/client/inspector/animation/components/AnimationListHeader.js
devtools/client/inspector/animation/components/AnimationTimeTickItem.js
devtools/client/inspector/animation/components/AnimationTimeTickList.js
devtools/client/inspector/animation/components/moz.build
devtools/client/inspector/animation/utils/l10n.js
devtools/client/inspector/animation/utils/moz.build
devtools/client/inspector/animation/utils/timescale.js
devtools/client/inspector/animation/utils/utils.js
devtools/client/themes/animation.css
--- a/devtools/client/inspector/animation/components/AnimationListContainer.js
+++ b/devtools/client/inspector/animation/components/AnimationListContainer.js
@@ -4,36 +4,43 @@
 
 "use strict";
 
 const { createFactory, DOM: dom, PropTypes, PureComponent } =
   require("devtools/client/shared/vendor/react");
 
 const AnimationList = createFactory(require("./AnimationList"));
 const AnimationListHeader = createFactory(require("./AnimationListHeader"));
+const TimeScale = require("../utils/timescale");
 
 class AnimationListContainer extends PureComponent {
   static get displayName() {
     return "AnimationListContainer";
   }
 
   static get propTypes() {
     return {
       animations: PropTypes.arrayOf(PropTypes.object).isRequired,
     };
   }
 
   render() {
     const { animations } = this.props;
 
+    const timescale = new TimeScale(animations);
+
     return dom.div(
       {
         className: "animation-list-container"
       },
-      AnimationListHeader(),
+      AnimationListHeader(
+        {
+          timescale
+        }
+      ),
       AnimationList(
         {
           animations
         }
       )
     );
   }
 }
--- a/devtools/client/inspector/animation/components/AnimationListHeader.js
+++ b/devtools/client/inspector/animation/components/AnimationListHeader.js
@@ -1,27 +1,39 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-const { createFactory, DOM: dom, PureComponent } =
+const { createFactory, DOM: dom, PropTypes, PureComponent } =
   require("devtools/client/shared/vendor/react");
 
 const AnimationTimeTickList = createFactory(require("./AnimationTimeTickList"));
 
 class AnimationListHeader extends PureComponent {
   static get displayName() {
     return "AnimationListHeader";
   }
 
+  static get propTypes() {
+    return {
+      timescale: PropTypes.object.isRequired,
+    };
+  }
+
   render() {
+    const { timescale } = this.props;
+
     return dom.div(
       {
         className: "animation-list-header"
       },
-      AnimationTimeTickList()
+      AnimationTimeTickList(
+        {
+          timescale
+        }
+      )
     );
   }
 }
 
 module.exports = AnimationListHeader;
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/animation/components/AnimationTimeTickItem.js
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { DOM: dom, PropTypes, PureComponent } =
+  require("devtools/client/shared/vendor/react");
+
+class AnimationTimeTickItem extends PureComponent {
+  static get displayName() {
+    return "AnimationTimeTickItem";
+  }
+
+  static get propTypes() {
+    return {
+      position: PropTypes.number.isRequired,
+      text: PropTypes.string.isRequired,
+    };
+  }
+
+  render() {
+    const { position, text } = this.props;
+
+    return dom.div(
+      {
+        className: "animation-time-tick-item",
+        style: { left: `${ position }%` }
+      },
+      dom.div(),
+      dom.label(null, text)
+    );
+  }
+}
+
+module.exports = AnimationTimeTickItem;
--- a/devtools/client/inspector/animation/components/AnimationTimeTickList.js
+++ b/devtools/client/inspector/animation/components/AnimationTimeTickList.js
@@ -1,24 +1,82 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-const { DOM: dom, PureComponent } =
+const { createFactory, DOM: dom, PropTypes, PureComponent } =
   require("devtools/client/shared/vendor/react");
+const ReactDOM = require("devtools/client/shared/vendor/react-dom");
+
+const AnimationTimeTickItem = createFactory(require("./AnimationTimeTickItem"));
+const { findOptimalTimeInterval } = require("../utils/utils");
+
+// The minimum spacing between 2 time graduation headers in the timeline (px).
+const TIME_GRADUATION_MIN_SPACING = 40;
 
 class AnimationTimeTickList extends PureComponent {
   static get displayName() {
     return "AnimationTimeTickList";
   }
 
+  static get propTypes() {
+    return {
+      timescale: PropTypes.object.isRequired,
+    };
+  }
+
+  constructor() {
+    super();
+
+    this.state = {
+      tickList: []
+    };
+  }
+
+  componentDidMount() {
+    this.updateTickList();
+  }
+
+  createTickList() {
+    const { timescale } = this.props;
+
+    const tickListEl = ReactDOM.findDOMNode(this);
+    if (!tickListEl) {
+      // Not mounted yet.
+      return [];
+    }
+    const width = tickListEl.offsetWidth;
+    const animationDuration = timescale.getDuration();
+    const minTimeInterval = TIME_GRADUATION_MIN_SPACING * animationDuration / width;
+    const intervalLength = findOptimalTimeInterval(minTimeInterval);
+    const intervalWidth = intervalLength * width / animationDuration;
+
+    const tickList = [];
+    for (let i = 0; i <= width / intervalWidth; i++) {
+      const position = 100 * i * intervalWidth / width;
+      const text = timescale.formatTime(timescale.distanceToRelativeTime(position));
+      tickList.push({ position, text });
+    }
+    return tickList;
+  }
+
+  updateTickList() {
+    const tickList = this.createTickList();
+    this.setState({ tickList });
+  }
+
   render() {
+    const { tickList } = this.state;
+
     return dom.div(
       {
         className: "animation-time-tick-list"
-      }
+      },
+      tickList.map(tickItem => {
+        return AnimationTimeTickItem(tickItem);
+      })
     );
   }
 }
 
 module.exports = AnimationTimeTickList;
--- a/devtools/client/inspector/animation/components/moz.build
+++ b/devtools/client/inspector/animation/components/moz.build
@@ -2,12 +2,13 @@
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DevToolsModules(
     'AnimationItem.js',
     'AnimationList.js',
     'AnimationListContainer.js',
     'AnimationListHeader.js',
+    'AnimationTimeTickItem.js',
     'AnimationTimeTickList.js',
     'App.js',
     'NoAnimationPanel.js'
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/animation/utils/l10n.js
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { LocalizationHelper } = require("devtools/shared/l10n");
+module.exports =
+  new LocalizationHelper("devtools/client/locales/animationinspector.properties");
--- a/devtools/client/inspector/animation/utils/moz.build
+++ b/devtools/client/inspector/animation/utils/moz.build
@@ -1,7 +1,9 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DevToolsModules(
+    'l10n.js',
+    'timescale.js',
     'utils.js'
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/animation/utils/timescale.js
@@ -0,0 +1,112 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const L10N = require("./l10n");
+
+const MILLIS_TIME_FORMAT_MAX_DURATION = 4000;
+
+/**
+ * The TimeScale helper object is used to know which size should something be
+ * displayed with in the animation panel, depending on the animations that are
+ * currently displayed.
+ * If there are 5 animations displayed, and the first one starts at 10000ms and
+ * the last one ends at 20000ms, then this helper can be used to convert any
+ * time in this range to a distance in pixels.
+ *
+ * For the helper to know how to convert, it needs to know all the animations.
+ * Whenever a new animation is added to the panel, addAnimation(state) should be
+ * called.
+ */
+class TimeScale {
+  constructor(animations) {
+    this.minStartTime = Infinity;
+    this.maxEndTime = 0;
+    animations.forEach(animation => {
+      this.addAnimation(animation.state);
+    });
+  }
+
+  /**
+   * Add a new animation to time scale.
+   * @param {Object} state A PlayerFront.state object.
+   */
+  addAnimation(state) {
+    let {previousStartTime, delay, duration, endDelay,
+         iterationCount, playbackRate} = state;
+
+    endDelay = typeof endDelay === "undefined" ? 0 : endDelay;
+    let toRate = v => v / playbackRate;
+    let minZero = v => Math.max(v, 0);
+    let rateRelativeDuration =
+      toRate(duration * (!iterationCount ? 1 : iterationCount));
+    // Negative-delayed animations have their startTimes set such that we would
+    // be displaying the delay outside the time window if we didn't take it into
+    // account here.
+    let relevantDelay = delay < 0 ? toRate(delay) : 0;
+    previousStartTime = previousStartTime || 0;
+
+    let startTime = toRate(minZero(delay)) +
+                    rateRelativeDuration +
+                    endDelay;
+    this.minStartTime = Math.min(
+      this.minStartTime,
+      previousStartTime +
+      relevantDelay +
+      Math.min(startTime, 0)
+    );
+    let length = toRate(delay) +
+                 rateRelativeDuration +
+                 toRate(minZero(endDelay));
+    let endTime = previousStartTime + length;
+    this.maxEndTime = Math.max(this.maxEndTime, endTime);
+  }
+
+  /**
+   * Convert a distance in % to a time, in the current time scale.
+   * @param {Number} distance
+   * @return {Number}
+   */
+  distanceToTime(distance) {
+    return this.minStartTime + (this.getDuration() * distance / 100);
+  }
+
+  /**
+   * Convert a distance in % to a time, in the current time scale.
+   * The time will be relative to the current minimum start time.
+   * @param {Number} distance
+   * @return {Number}
+   */
+  distanceToRelativeTime(distance) {
+    const time = this.distanceToTime(distance);
+    return time - this.minStartTime;
+  }
+
+  /**
+   * Depending on the time scale, format the given time as milliseconds or
+   * seconds.
+   * @param {Number} time
+   * @return {String} The formatted time string.
+   */
+  formatTime(time) {
+    // Format in milliseconds if the total duration is short enough.
+    if (this.getDuration() <= MILLIS_TIME_FORMAT_MAX_DURATION) {
+      return L10N.getFormatStr("timeline.timeGraduationLabel", time.toFixed(0));
+    }
+
+    // Otherwise format in seconds.
+    return L10N.getFormatStr("player.timeLabel", (time / 1000).toFixed(1));
+  }
+
+  /**
+   * Return entire animations duration.
+   * @return {Number} duration
+   */
+  getDuration() {
+    return this.maxEndTime - this.minStartTime;
+  }
+}
+
+module.exports = TimeScale;
--- a/devtools/client/inspector/animation/utils/utils.js
+++ b/devtools/client/inspector/animation/utils/utils.js
@@ -1,14 +1,49 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
+// How many times, maximum, can we loop before we find the optimal time
+// interval in the timeline graph.
+const OPTIMAL_TIME_INTERVAL_MAX_ITERS = 100;
+// Time graduations should be multiple of one of these number.
+const OPTIMAL_TIME_INTERVAL_MULTIPLES = [1, 2.5, 5];
+
+/**
+ * Find the optimal interval between time graduations in the animation timeline
+ * graph based on a minimum time interval.
+ *
+ * @param {Number} minTimeInterval Minimum time in ms in one interval
+ * @return {Number} The optimal interval time in ms
+ */
+function findOptimalTimeInterval(minTimeInterval) {
+  if (!minTimeInterval) {
+    return 0;
+  }
+
+  let numIters = 0;
+  let multiplier = 1;
+  let interval;
+  while (true) {
+    for (let i = 0; i < OPTIMAL_TIME_INTERVAL_MULTIPLES.length; i++) {
+      interval = OPTIMAL_TIME_INTERVAL_MULTIPLES[i] * multiplier;
+      if (minTimeInterval <= interval) {
+        return interval;
+      }
+    }
+    if (++numIters > OPTIMAL_TIME_INTERVAL_MAX_ITERS) {
+      return interval;
+    }
+    multiplier *= 10;
+  }
+}
+
 /**
  * Check the equality timing effects from given animations.
  *
  * @param {Array} animations.
  * @param {Array} same to avobe.
  * @return {Boolean} true: same timing effects
  */
 function isAllTimingEffectEqual(animationsA, animationsB) {
@@ -37,10 +72,11 @@ function isTimingEffectEqual(stateA, sta
          stateA.duration === stateB.duration &&
          stateA.easing === stateB.easing &&
          stateA.endDelay === stateB.endDelay &&
          stateA.fill === stateB.fill &&
          stateA.iterationCount === stateB.iterationCount &&
          stateA.iterationStart === stateB.iterationStart;
 }
 
+module.exports.findOptimalTimeInterval = findOptimalTimeInterval;
 module.exports.isAllTimingEffectEqual = isAllTimingEffectEqual;
 module.exports.isTimingEffectEqual = isTimingEffectEqual;
--- a/devtools/client/themes/animation.css
+++ b/devtools/client/themes/animation.css
@@ -7,38 +7,54 @@
 :root {
   --animation-even-background-color: rgba(0,0,0,0.05);
   --command-pick-image: url(chrome://devtools/skin/images/command-pick.svg);
   /* How wide should the sidebar be (should be wide enough to contain long
      property names like 'border-bottom-right-radius' without ellipsis) */
   --default-sidebar-width: 200px;
   /* The size of a keyframe marker in the keyframes diagram */
   --keyframes-marker-size: 10px;
+  /* The color of the time tick borders */
+  --time-tick-border-color: rgba(128, 136, 144, .5);
 }
 
 :root.theme-dark {
   --animation-even-background-color: rgba(255,255,255,0.05);
 }
 
 :root.theme-firebug {
   --command-pick-image: url(chrome://devtools/skin/images/firebug/command-pick.svg);
 }
 
 /* Settings for animation-list-header */
 .animation-list-header {
   height: 20px;
 }
 
 .animation-time-tick-list {
-  height: 100%;
   left: var(--default-sidebar-width);
   position: absolute;
   right: var(--keyframes-marker-size);
 }
 
+.animation-time-tick-item {
+  position: absolute;
+}
+
+.animation-time-tick-item > div {
+  border-left: 0.5px solid var(--time-tick-border-color);
+  height: 100vh;
+  position: absolute;
+}
+
+.animation-time-tick-item > label {
+  position: relative;
+  top: 3px;
+}
+
 /* Settings for animations element */
 .animation-list {
   list-style-type: none;
   margin-top: 0;
   padding: 0;
 }
 
 /* Settings for each animation element */