Bug 1383974 - Part 1: Display easing in keyframes. r=pbro
☠☠ backed out by c2bbc9f00c63 ☠ ☠
authorDaisuke Akatsuka <dakatsuka@mozilla.com>
Fri, 22 Sep 2017 22:36:53 +0900
changeset 382452 693f32f563d2c4610c4b6da682394dfe82d7fc38
parent 382451 6c69f8021a5e4eee44ec3ce78e46d1cd49fc6277
child 382453 ac38504618340e90a619c4015be68d2a317d41ea
push id32558
push userkwierso@gmail.com
push dateFri, 22 Sep 2017 21:29:46 +0000
treeherdermozilla-central@61e58a7d800b [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbro
bugs1383974
milestone58.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1383974 - Part 1: Display easing in keyframes. r=pbro MozReview-Commit-ID: 8pIUMAurfS3
devtools/client/animationinspector/components/animation-time-block.js
devtools/client/animationinspector/components/keyframes.js
devtools/client/animationinspector/graph-helper.js
devtools/client/themes/animationinspector.css
--- a/devtools/client/animationinspector/components/animation-time-block.js
+++ b/devtools/client/animationinspector/components/animation-time-block.js
@@ -457,33 +457,33 @@ function renderGraph(parentEl, state, to
  * Render delay section.
  * @param {Element} parentEl - Parent element of this appended path element.
  * @param {Object} state - State of animation.
  * @param {Object} graphHelper - SummaryGraphHelper.
  */
 function renderDelay(parentEl, state, graphHelper) {
   const startSegment = graphHelper.getSegment(0);
   const endSegment = { x: state.delay, y: startSegment.y };
-  graphHelper.appendPathElement(parentEl, [startSegment, endSegment], "delay-path");
+  graphHelper.appendShapePath(parentEl, [startSegment, endSegment], "delay-path");
 }
 
 /**
  * Render first iteration section.
  * @param {Element} parentEl - Parent element of this appended path element.
  * @param {Object} state - State of animation.
  * @param {Number} mainIterationStartTime - Starting time of main iteration.
  * @param {Number} firstSectionCount - Iteration count of first section.
  * @param {Object} graphHelper - SummaryGraphHelper.
  */
 function renderFirstIteration(parentEl, state, mainIterationStartTime,
                               firstSectionCount, graphHelper) {
   const startTime = mainIterationStartTime;
   const endTime = startTime + firstSectionCount * state.duration;
   const segments = graphHelper.createPathSegments(startTime, endTime);
-  graphHelper.appendPathElement(parentEl, segments, "iteration-path");
+  graphHelper.appendShapePath(parentEl, segments, "iteration-path");
 }
 
 /**
  * Render middle iterations section.
  * @param {Element} parentEl - Parent element of this appended path element.
  * @param {Object} state - State of animation.
  * @param {Number} mainIterationStartTime - Starting time of main iteration.
  * @param {Number} firstSectionCount - Iteration count of first section.
@@ -494,17 +494,17 @@ function renderMiddleIterations(parentEl
                                 firstSectionCount, middleSectionCount,
                                 graphHelper) {
   const offset = mainIterationStartTime + firstSectionCount * state.duration;
   for (let i = 0; i < middleSectionCount; i++) {
     // Get the path segments of each iteration.
     const startTime = offset + i * state.duration;
     const endTime = startTime + state.duration;
     const segments = graphHelper.createPathSegments(startTime, endTime);
-    graphHelper.appendPathElement(parentEl, segments, "iteration-path");
+    graphHelper.appendShapePath(parentEl, segments, "iteration-path");
   }
 }
 
 /**
  * Render last iteration section.
  * @param {Element} parentEl - Parent element of this appended path element.
  * @param {Object} state - State of animation.
  * @param {Number} mainIterationStartTime - Starting time of main iteration.
@@ -515,17 +515,17 @@ function renderMiddleIterations(parentEl
  */
 function renderLastIteration(parentEl, state, mainIterationStartTime,
                              firstSectionCount, middleSectionCount,
                              lastSectionCount, graphHelper) {
   const startTime = mainIterationStartTime +
                       (firstSectionCount + middleSectionCount) * state.duration;
   const endTime = startTime + lastSectionCount * state.duration;
   const segments = graphHelper.createPathSegments(startTime, endTime);
-  graphHelper.appendPathElement(parentEl, segments, "iteration-path");
+  graphHelper.appendShapePath(parentEl, segments, "iteration-path");
 }
 
 /**
  * Render Infinity iterations.
  * @param {Element} parentEl - Parent element of this appended path element.
  * @param {Object} state - State of animation.
  * @param {Number} mainIterationStartTime - Starting time of main iteration.
  * @param {Number} firstSectionCount - Iteration count of first section.
@@ -547,17 +547,17 @@ function renderInfinity(parentEl, state,
              Math.ceil(uncappedInfinityIterationCount));
 
   // Append first full iteration path.
   const firstStartTime =
     mainIterationStartTime + firstSectionCount * state.duration;
   const firstEndTime = firstStartTime + state.duration;
   const firstSegments =
     graphHelper.createPathSegments(firstStartTime, firstEndTime);
-  graphHelper.appendPathElement(parentEl, firstSegments, "iteration-path infinity");
+  graphHelper.appendShapePath(parentEl, firstSegments, "iteration-path infinity");
 
   // Append other iterations. We can copy first segments.
   const isAlternate = state.direction.match(/alternate/);
   for (let i = 1; i < infinityIterationCount; i++) {
     const startTime = firstStartTime + i * state.duration;
     let segments;
     if (isAlternate && i % 2) {
       // Copy as reverse.
@@ -565,34 +565,34 @@ function renderInfinity(parentEl, state,
         return { x: firstEndTime - segment.x + startTime, y: segment.y };
       });
     } else {
       // Copy as is.
       segments = firstSegments.map(segment => {
         return { x: segment.x - firstStartTime + startTime, y: segment.y };
       });
     }
-    graphHelper.appendPathElement(parentEl, segments, "iteration-path infinity copied");
+    graphHelper.appendShapePath(parentEl, segments, "iteration-path infinity copied");
   }
 }
 
 /**
  * Render endDelay section.
  * @param {Element} parentEl - Parent element of this appended path element.
  * @param {Object} state - State of animation.
  * @param {Number} mainIterationStartTime - Starting time of main iteration.
  * @param {Number} iterationCount - Whole iteration count.
  * @param {Object} graphHelper - SummaryGraphHelper.
  */
 function renderEndDelay(parentEl, state,
                         mainIterationStartTime, iterationCount, graphHelper) {
   const startTime = mainIterationStartTime + iterationCount * state.duration;
   const startSegment = graphHelper.getSegment(startTime);
   const endSegment = { x: startTime + state.endDelay, y: startSegment.y };
-  graphHelper.appendPathElement(parentEl, [startSegment, endSegment], "enddelay-path");
+  graphHelper.appendShapePath(parentEl, [startSegment, endSegment], "enddelay-path");
 }
 
 /**
  * Render forwards fill section.
  * @param {Element} parentEl - Parent element of this appended path element.
  * @param {Object} state - State of animation.
  * @param {Number} mainIterationStartTime - Starting time of main iteration.
  * @param {Number} iterationCount - Whole iteration count.
@@ -600,45 +600,44 @@ function renderEndDelay(parentEl, state,
  * @param {Object} graphHelper - SummaryGraphHelper.
  */
 function renderForwardsFill(parentEl, state, mainIterationStartTime,
                             iterationCount, totalDuration, graphHelper) {
   const startTime = mainIterationStartTime + iterationCount * state.duration +
                       (state.endDelay > 0 ? state.endDelay : 0);
   const startSegment = graphHelper.getSegment(startTime);
   const endSegment = { x: totalDuration, y: startSegment.y };
-  graphHelper.appendPathElement(parentEl, [startSegment, endSegment],
-                                "fill-forwards-path");
+  graphHelper.appendShapePath(parentEl, [startSegment, endSegment], "fill-forwards-path");
 }
 
 /**
  * Render hidden progress of negative delay.
  * @param {Element} parentEl - Parent element of this appended path element.
  * @param {Object} state - State of animation.
  * @param {Object} graphHelper - SummaryGraphHelper.
  */
 function renderNegativeDelayHiddenProgress(parentEl, state, graphHelper) {
   const startTime = state.delay;
   const endTime = 0;
   const segments =
     graphHelper.createPathSegments(startTime, endTime);
-  graphHelper.appendPathElement(parentEl, segments, "delay-path negative");
+  graphHelper.appendShapePath(parentEl, segments, "delay-path negative");
 }
 
 /**
  * Render hidden progress of negative endDelay.
  * @param {Element} parentEl - Parent element of this appended path element.
  * @param {Object} state - State of animation.
  * @param {Object} graphHelper - SummaryGraphHelper.
  */
 function renderNegativeEndDelayHiddenProgress(parentEl, state, graphHelper) {
   const endTime = state.delay + state.iterationCount * state.duration;
   const startTime = endTime + state.endDelay;
   const segments = graphHelper.createPathSegments(startTime, endTime);
-  graphHelper.appendPathElement(parentEl, segments, "enddelay-path negative");
+  graphHelper.appendShapePath(parentEl, segments, "enddelay-path negative");
 }
 
 /**
  * Create new keyframes object which has only offset and easing.
  * Also, the returned value has no duplication.
  * @param {Object} tracks - The value of AnimationsTimeline.getTracks().
  * @return {Array} keyframes list.
  */
--- a/devtools/client/animationinspector/components/keyframes.js
+++ b/devtools/client/animationinspector/components/keyframes.js
@@ -4,17 +4,17 @@
  * 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 {createNode, createSVGNode} =
   require("devtools/client/animationinspector/utils");
 const {ProgressGraphHelper, getPreferredKeyframesProgressThreshold} =
-         require("devtools/client/animationinspector/graph-helper.js");
+  require("devtools/client/animationinspector/graph-helper.js");
 
 // Counter for linearGradient ID.
 let LINEAR_GRADIENT_ID_COUNTER = 0;
 
 /**
  * UI component responsible for displaying a list of keyframes.
  * Also, shows a graphical graph for the animation progress of one iteration.
  */
@@ -50,39 +50,44 @@ Keyframes.prototype = {
         "preserveAspectRatio": "none"
       }
     });
 
     // This visual is only one iteration,
     // so we use animation.state.duration as total duration.
     const totalDuration = animation.state.duration;
 
-    // Calculate stroke height in viewBox to display stroke of path.
-    const strokeHeightForViewBox = 0.5 / this.containerEl.clientHeight;
     // Minimum segment duration is the duration of one pixel.
     const minSegmentDuration =
       totalDuration / this.containerEl.clientWidth;
 
-    // Set viewBox.
-    graphEl.setAttribute("viewBox",
-                         `0 -${ 1 + strokeHeightForViewBox }
-                          ${ totalDuration }
-                          ${ 1 + strokeHeightForViewBox * 2 }`);
-
     // Create graph helper to render the animation property graph.
+    const win = this.containerEl.ownerGlobal;
     const graphHelper =
-      new ProgressGraphHelper(this.containerEl.ownerDocument.defaultView,
-                              propertyName, animationType, keyframes, totalDuration);
+      new ProgressGraphHelper(win, propertyName, animationType, keyframes, totalDuration);
 
     renderPropertyGraph(graphEl, totalDuration, minSegmentDuration,
                         getPreferredKeyframesProgressThreshold(keyframes), graphHelper);
 
     // Destroy ProgressGraphHelper resources.
     graphHelper.destroy();
 
+    // Set viewBox which includes invisible stroke width.
+    // At first, calculate invisible stroke width from maximum width.
+    // The reason why divide by 2 is that half of stroke width will be invisible
+    // if we use 0 or 1 for y axis.
+    const maxStrokeWidth =
+      win.getComputedStyle(graphEl.querySelector(".keyframes svg .hint")).strokeWidth;
+    const invisibleStrokeWidthInViewBox =
+      maxStrokeWidth / 2 / this.containerEl.clientHeight;
+    graphEl.setAttribute("viewBox",
+                         `0 -${ 1 + invisibleStrokeWidthInViewBox }
+                          ${ totalDuration }
+                          ${ 1 + invisibleStrokeWidthInViewBox * 2 }`);
+
     // Append elements to display keyframe values.
     this.keyframesEl.classList.add(animation.state.type);
     for (let frame of this.keyframes) {
       createNode({
         parent: this.keyframesEl,
         attributes: {
           "class": "frame",
           "style": `left:${frame.offset * 100}%;`,
@@ -105,25 +110,26 @@ Keyframes.prototype = {
  */
 function renderPropertyGraph(parentEl, duration, minSegmentDuration,
                              minProgressThreshold, graphHelper) {
   const segments = graphHelper.createPathSegments(0, duration, minSegmentDuration,
                                                   minProgressThreshold);
 
   const graphType = graphHelper.getGraphType();
   if (graphType !== "color") {
-    graphHelper.appendPathElement(parentEl, segments, graphType);
+    graphHelper.appendShapePath(parentEl, segments, graphType);
+    renderEasingHint(parentEl, segments, graphHelper);
     return;
   }
 
   // Append the color to the path.
   segments.forEach(segment => {
     segment.y = 1;
   });
-  const path = graphHelper.appendPathElement(parentEl, segments, graphType);
+  const path = graphHelper.appendShapePath(parentEl, segments, graphType);
   const defEl = createSVGNode({
     parent: parentEl,
     nodeType: "def"
   });
   const id = `color-property-${ LINEAR_GRADIENT_ID_COUNTER++ }`;
   const linearGradientEl = createSVGNode({
     parent: defEl,
     nodeType: "linearGradient",
@@ -137,9 +143,110 @@ function renderPropertyGraph(parentEl, d
       nodeType: "stop",
       attributes: {
         "stop-color": segment.style,
         "offset": segment.x / duration
       }
     });
   });
   path.style.fill = `url(#${ id })`;
+
+  renderEasingHintForColor(parentEl, graphHelper);
 }
+
+/**
+ * Renders the easing hint.
+ * This method renders an emphasized path over the easing path for a keyframe.
+ * It appears when hovering over the easing.
+ * It also renders a tooltip that appears when hovering.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {Array} path segments - [{x: {Number} time, y: {Number} progress}, ...]
+ * @param {ProgressGraphHelper} graphHelper - The object of ProgressGraphHelper.
+ */
+function renderEasingHint(parentEl, segments, helper) {
+  const keyframes = helper.getKeyframes();
+  const duration = helper.getDuration();
+
+  // Split segments for each keyframe.
+  for (let i = 0, indexOfSegments = 0; i < keyframes.length - 1; i++) {
+    const startKeyframe = keyframes[i];
+    const startTime = startKeyframe.offset * duration;
+    const endKeyframe = keyframes[i + 1];
+    const endTime = endKeyframe.offset * duration;
+
+    const keyframeSegments = [];
+    for (; indexOfSegments < segments.length; indexOfSegments++) {
+      const segment = segments[indexOfSegments];
+      if (segment.x < startTime) {
+        // If previous easings were linear, we need to increment the indexOfSegments.
+        continue;
+      }
+      if (segment.x > endTime) {
+        indexOfSegments -= 1;
+        break;
+      }
+      keyframeSegments.push(segment);
+    }
+
+    // If keyframeSegments does not have segment which is at startTime,
+    // get and set the segment.
+    if (keyframeSegments[0].x !== startTime) {
+      keyframeSegments.unshift(helper.getSegment(startTime));
+    }
+    // Also, endTime.
+    if (keyframeSegments[keyframeSegments.length - 1].x !== endTime) {
+      keyframeSegments.push(helper.getSegment(endTime));
+    }
+
+    // Append easing hint as text and emphasis path.
+    const gEl = createSVGNode({
+      parent: parentEl,
+      nodeType: "g"
+    });
+    createSVGNode({
+      parent: gEl,
+      nodeType: "title",
+      textContent: startKeyframe.easing
+    });
+    helper.appendLinePath(gEl, keyframeSegments, `${helper.getGraphType()} hint`);
+  }
+}
+
+/**
+ * Render easing hint for properties that are represented by color.
+ * This method render as text only.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {ProgressGraphHelper} graphHelper - The object of ProgressGraphHalper.
+ */
+function renderEasingHintForColor(parentEl, helper) {
+  const keyframes = helper.getKeyframes();
+  const duration = helper.getDuration();
+
+  // Split segments for each keyframe.
+  for (let i = 0; i < keyframes.length - 1; i++) {
+    const startKeyframe = keyframes[i];
+    const startTime = startKeyframe.offset * duration;
+    const endKeyframe = keyframes[i + 1];
+    const endTime = endKeyframe.offset * duration;
+
+    // Append easing hint.
+    const gEl = createSVGNode({
+      parent: parentEl,
+      nodeType: "g"
+    });
+    createSVGNode({
+      parent: gEl,
+      nodeType: "title",
+      textContent: startKeyframe.easing
+    });
+    createSVGNode({
+      parent: gEl,
+      nodeType: "rect",
+      attributes: {
+        x: startTime,
+        y: -1,
+        width: endTime - startTime,
+        height: 1,
+        class: "hint",
+      }
+    });
+  }
+}
--- a/devtools/client/animationinspector/graph-helper.js
+++ b/devtools/client/animationinspector/graph-helper.js
@@ -81,16 +81,32 @@ ProgressGraphHelper.prototype = {
     this.propertyCSSName = null;
     this.propertyJSName = null;
     this.animationType = null;
     this.keyframes = null;
     this.win = null;
   },
 
   /**
+   * Return animation duration.
+   * @return {Number} duration
+   */
+  getDuration: function () {
+    return this.animation.effect.timing.duration;
+  },
+
+  /**
+   * Return animation's keyframe.
+   * @return {Object} keyframe
+   */
+  getKeyframes: function () {
+    return this.keyframes;
+  },
+
+  /**
    * Return graph type.
    * @return {String} if property is 'opacity' or 'transform', return that value.
    *                  Otherwise, return given animation type in constructor.
    */
   getGraphType: function () {
     return (this.propertyJSName === "opacity" || this.propertyJSName === "transform")
            ? this.propertyJSName : this.animationType;
   },
@@ -243,40 +259,53 @@ ProgressGraphHelper.prototype = {
                                 minSegmentDuration, minProgressThreshold) {
     return !this.valueHelperFunction
            ? createKeyframesPathSegments(endTime - startTime, this.devtoolsKeyframes)
            : createPathSegments(startTime, endTime,
                                 minSegmentDuration, minProgressThreshold, this);
   },
 
   /**
-   * Append path element.
+   * Append path element as shape. Also, this method appends two segment
+   * that are {start x, 0} and {end x, 0} to make shape.
    * @param {Element} parentEl - Parent element of this appended path element.
    * @param {Array} pathSegments - Path segments. Please see createPathSegments.
    * @param {String} cls - Class name.
    * @return {Element} path element.
    */
-  appendPathElement: function (parentEl, pathSegments, cls) {
-    return appendPathElement(parentEl, pathSegments, cls);
+  appendShapePath: function (parentEl, pathSegments, cls) {
+    return appendShapePath(parentEl, pathSegments, cls);
+  },
+
+  /**
+   * Append path element as line.
+   * @param {Element} parentEl - Parent element of this appended path element.
+   * @param {Array} pathSegments - Path segments. Please see createPathSegments.
+   * @param {String} cls - Class name.
+   * @return {Element} path element.
+   */
+  appendLinePath: function (parentEl, pathSegments, cls) {
+    const isClosePathNeeded = false;
+    return appendPathElement(parentEl, pathSegments, cls, isClosePathNeeded);
   },
 };
 
 exports.ProgressGraphHelper = ProgressGraphHelper;
 
 /**
  * This class is used for creating the summary graph in animation-timeline.
  * The shape of the graph can be changed by using the following methods:
  * setKeyframes:
  *   If null, the shape is by computed timing progress.
  *   Otherwise, by computed style of 'opacity' to combine effect easing and
  *   keyframe's easing.
  * setFillMode:
  *   Animation fill-mode (e.g. "none", "backwards", "forwards" or "both")
  * setClosePathNeeded:
- *   If true, appendPathElement make the last segment of <path> element to
+ *   If true, appendShapePath make the last segment of <path> element to
  *   "close" segment("Z").
  *   Therefore, if don't need under-line of graph, please set false.
  * setOriginalBehavior:
  *   In Animation::SetCurrentTime spec, even if current time of animation is over
  *   the endTime, the progress is changed. Likewise, in case the time is less than 0.
  *   If set true, prevent the time to make the same animation behavior as the original.
  * setMinProgressThreshold:
  *   SummaryGraphHelper searches and creates the summary graph until the progress
@@ -355,17 +384,17 @@ SummaryGraphHelper.prototype = {
    * Set animation fill mode.
    * @param {String} fill - "both", "forwards", "backwards" or "both"
    */
   setFillMode: function (fill) {
     this.animation.effect.timing.fill = fill;
   },
 
   /**
-   * Set true if need to close path in appendPathElement.
+   * Set true if need to close path in appendShapePath.
    * @param {bool} isClosePathNeeded - true: close, false: open.
    */
   setClosePathNeeded: function (isClosePathNeeded) {
     this.isClosePathNeeded = isClosePathNeeded;
   },
 
   /**
    * SummaryGraphHelper searches and creates the summary graph untill the progress
@@ -406,24 +435,25 @@ SummaryGraphHelper.prototype = {
    *                 [{x: {Number} time, y: {Number} progress}, ...]
    */
   createPathSegments: function (startTime, endTime) {
     return createPathSegments(startTime, endTime,
                               this.minSegmentDuration, this.minProgressThreshold, this);
   },
 
   /**
-   * Append path element.
+   * Append path element as shape. Also, this method appends two segment
+   * that are {start x, 0} and {end x, 0} to make shape.
    * @param {Element} parentEl - Parent element of this appended path element.
    * @param {Array} pathSegments - Path segments. Please see createPathSegments.
    * @param {String} cls - Class name.
    * @return {Element} path element.
    */
-  appendPathElement: function (parentEl, pathSegments, cls) {
-    return appendPathElement(parentEl, pathSegments, cls, this.isClosePathNeeded);
+  appendShapePath: function (parentEl, pathSegments, cls) {
+    return appendShapePath(parentEl, pathSegments, cls, this.isClosePathNeeded);
   },
 
   /**
    * Return current computed timing progress of the animation.
    * @return {float} computed timing progress as float value of Y axis.
    */
   getProgressValue: function () {
     return Math.max(this.animation.effect.getComputedTiming().progress, 0);
@@ -493,50 +523,60 @@ function createPathSegments(startTime, e
     pathSegments.push(currentSegment);
     previousSegment = currentSegment;
   }
 
   return pathSegments;
 }
 
 /**
- * Append path element.
+ * Append path element as shape. Also, this method appends two segment
+ * that are {start x, 0} and {end x, 0} to make shape.
+ * But does not affect given pathSegments.
  * @param {Element} parentEl - Parent element of this appended path element.
  * @param {Array} pathSegments - Path segments. Please see createPathSegments.
  * @param {String} cls - Class name.
  * @param {bool} isClosePathNeeded - Set true if need to close the path. (default true)
  * @return {Element} path element.
  */
-function appendPathElement(parentEl, pathSegments, cls, isClosePathNeeded = true) {
+function appendShapePath(parentEl, pathSegments, cls, isClosePathNeeded = true) {
+  const segments = [
+    { x: pathSegments[0].x, y: 0 },
+    ...pathSegments,
+    { x: pathSegments[pathSegments.length - 1].x, y: 0 }
+  ];
+  return appendPathElement(parentEl, segments, cls, isClosePathNeeded);
+}
+
+/**
+ * Append path element.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {Array} pathSegments - Path segments. Please see createPathSegments.
+ * @param {String} cls - Class name.
+ * @param {bool} isClosePathNeeded - Set true if need to close the path.
+ * @return {Element} path element.
+ */
+function appendPathElement(parentEl, pathSegments, cls, isClosePathNeeded) {
   // Create path string.
-  let path = `M${ pathSegments[0].x },0`;
-  for (let i = 0; i < pathSegments.length; i++) {
-    const pathSegment = pathSegments[i];
-    if (!pathSegment.easing || pathSegment.easing === "linear") {
-      path += createLinePathString(pathSegment);
-      continue;
-    }
-
-    if (i + 1 === pathSegments.length) {
-      // We already create steps or cubic-bezier path string in previous.
-      break;
+  let currentSegment = pathSegments[0];
+  let path = `M${ currentSegment.x },${ currentSegment.y }`;
+  for (let i = 1; i < pathSegments.length; i++) {
+    const currentEasing = currentSegment.easing ? currentSegment.easing : "linear";
+    const nextSegment = pathSegments[i];
+    if (currentEasing === "linear") {
+      path += createLinePathString(nextSegment);
+    } else if (currentEasing.startsWith("steps")) {
+      path += createStepsPathString(currentSegment, nextSegment);
+    } else if (currentEasing.startsWith("frames")) {
+      path += createFramesPathString(currentSegment, nextSegment);
+    } else {
+      path += createCubicBezierPathString(currentSegment, nextSegment);
     }
-
-    const nextPathSegment = pathSegments[i + 1];
-    let createPathFunction;
-    if (pathSegment.easing.startsWith("steps")) {
-      createPathFunction = createStepsPathString;
-    } else if (pathSegment.easing.startsWith("frames")) {
-      createPathFunction = createFramesPathString;
-    } else {
-      createPathFunction = createCubicBezierPathString;
-    }
-    path += createPathFunction(pathSegment, nextPathSegment);
+    currentSegment = nextSegment;
   }
-  path += ` L${ pathSegments[pathSegments.length - 1].x },0`;
   if (isClosePathNeeded) {
     path += " Z";
   }
   // Append and return the path element.
   return createSVGNode({
     parent: parentEl,
     nodeType: "path",
     attributes: {
--- a/devtools/client/themes/animationinspector.css
+++ b/devtools/client/themes/animationinspector.css
@@ -692,16 +692,34 @@ body {
   stroke: var(--transform-border-color);
 }
 
 .keyframes svg path.color {
   stroke: none;
   height: 100%;
 }
 
+.keyframes svg .hint {
+  stroke-opacity: 0;
+  stroke-linecap: round;
+  stroke-width: 5;
+}
+
+.keyframes svg path.hint {
+  fill: none;
+}
+
+.keyframes svg path.hint:hover {
+  stroke-opacity: 1;
+}
+
+.keyframes svg rect.hint {
+  fill-opacity: .1;
+}
+
 .animation-detail {
   position: relative;
   width: 100%;
   background-color: var(--theme-body-background);
   z-index: 5;
 }
 
 .animation-detail .animation-detail-header {