tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

LinearEasingFunctionWidget.js (22972B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 /**
      8 * This is a chart-like editor for linear() easing function, used in the Rules View.
      9 */
     10 
     11 const EventEmitter = require("devtools/shared/event-emitter");
     12 const { InspectorCSSParserWrapper } = require("devtools/shared/css/lexer");
     13 const { throttle } = require("devtools/shared/throttle");
     14 const {
     15  canPointerEventDrag,
     16 } = require("resource://devtools/client/shared/events.js");
     17 const XHTML_NS = "http://www.w3.org/1999/xhtml";
     18 const SVG_NS = "http://www.w3.org/2000/svg";
     19 
     20 const numberFormatter = new Intl.NumberFormat("en", {
     21  maximumFractionDigits: 3,
     22 });
     23 const percentFormatter = new Intl.NumberFormat("en", {
     24  maximumFractionDigits: 2,
     25  style: "percent",
     26 });
     27 
     28 /**
     29 * Easing function widget. Draw the lines and control points in an svg.
     30 *
     31 * XXX: The spec allows input and output values in the [-Infinity,Infinity] range,
     32 * but this will be hard to have proper visual representation to handle those cases, so we
     33 * only handle points inside [0,0] [1,1] to represent most common use cases (even though
     34 * the line will properly link points outside of this range)
     35 *
     36 * @fires "updated" events whenever the line is changed, with the updated property value.
     37 */
     38 class LinearEasingFunctionWidget extends EventEmitter {
     39  /**
     40   * @param {DOMNode} parent The container where the widget should be created
     41   */
     42  constructor(parent) {
     43    super();
     44 
     45    this.parent = parent;
     46    this.#initMarkup();
     47 
     48    this.#svgEl.addEventListener(
     49      "pointerdown",
     50      this.#onPointerDown.bind(this),
     51      {
     52        signal: this.#abortController.signal,
     53        passive: true,
     54      }
     55    );
     56    this.#svgEl.addEventListener("dblclick", this.#onDoubleClick.bind(this), {
     57      signal: this.#abortController.signal,
     58    });
     59 
     60    // Add the timing function previewer
     61    // if prefers-reduced-motion is not set
     62    this.#reducedMotion = parent.ownerGlobal.matchMedia(
     63      "(prefers-reduced-motion)"
     64    );
     65    if (!this.#reducedMotion.matches) {
     66      this.#timingPreview = new TimingFunctionPreviewWidget(this.#wrapperEl);
     67    }
     68 
     69    // add event listener to change prefers-reduced-motion
     70    // of the timing function preview during runtime
     71    this.#reducedMotion.addEventListener(
     72      "change",
     73      event => {
     74        // if prefers-reduced-motion is enabled destroy timing function preview
     75        // else create it if it does not exist
     76        if (event.matches) {
     77          if (this.#timingPreview) {
     78            this.#timingPreview.destroy();
     79          }
     80          this.#timingPreview = undefined;
     81        } else if (!this.#timingPreview) {
     82          this.#timingPreview = new TimingFunctionPreviewWidget(
     83            this.#wrapperEl
     84          );
     85        }
     86      },
     87      { signal: this.#abortController.signal }
     88    );
     89  }
     90 
     91  static CONTROL_POINTS_CLASSNAME = "control-point";
     92 
     93  // Handles event listener that are enabled for the whole widget lifetime
     94  #abortController = new AbortController();
     95 
     96  // Array<Object>: Object has `input` (plotted on x axis) and `output` (plotted on y axis) properties
     97  #functionPoints;
     98 
     99  // MediaQueryList
    100  #reducedMotion;
    101 
    102  // TimingFunctionPreviewWidget
    103  #timingPreview;
    104 
    105  // current dragged element. null if there's no dragging happening
    106  #draggedEl = null;
    107 
    108  // handles event listeners added when user starts dragging an element
    109  #dragAbortController;
    110 
    111  // element references
    112  #wrapperEl;
    113  #svgEl;
    114  #linearLineEl;
    115  #controlPointGroupEl;
    116 
    117  /**
    118   * Creates the markup of the widget
    119   */
    120  #initMarkup() {
    121    const doc = this.parent.ownerDocument;
    122 
    123    const wrap = doc.createElementNS(XHTML_NS, "div");
    124    wrap.className = "display-wrap";
    125    this.#wrapperEl = wrap;
    126 
    127    const svg = doc.createElementNS(SVG_NS, "svg");
    128    svg.classList.add("chart");
    129 
    130    // Add some "padding" to the viewBox so circles near the edges are not clipped.
    131    const padding = 0.1;
    132    const length = 1 + padding * 2;
    133    // XXX: The spec allows input and output values in the [-Infinity,Infinity] range,
    134    // but this will be hard to have proper visual representation for all cases, so we
    135    // set the viewBox is basically starting at 0,0 and has a size of 1 (if we don't take the
    136    // padding into account), to represent most common use cases.
    137    svg.setAttribute(
    138      "viewBox",
    139      `${0 - padding} ${0 - padding} ${length} ${length}`
    140    );
    141 
    142    // Create a background grid
    143    const chartGrid = doc.createElementNS(SVG_NS, "g");
    144    chartGrid.setAttribute("stroke-width", "0.005");
    145    chartGrid.classList.add("chart-grid");
    146    for (let i = 0; i <= 10; i++) {
    147      const value = i / 10;
    148      const hLine = doc.createElementNS(SVG_NS, "line");
    149      hLine.setAttribute("x1", 0);
    150      hLine.setAttribute("y1", value);
    151      hLine.setAttribute("x2", 1);
    152      hLine.setAttribute("y2", value);
    153      const vLine = doc.createElementNS(SVG_NS, "line");
    154      vLine.setAttribute("x1", value);
    155      vLine.setAttribute("y1", 0);
    156      vLine.setAttribute("x2", value);
    157      vLine.setAttribute("y2", 1);
    158      chartGrid.append(hLine, vLine);
    159    }
    160 
    161    // Create the actual graph line
    162    const linearLine = doc.createElementNS(SVG_NS, "polyline");
    163    linearLine.classList.add("chart-linear");
    164    linearLine.setAttribute("fill", "none");
    165    linearLine.setAttribute("stroke", "context-stroke black");
    166    linearLine.setAttribute("stroke-width", "0.01");
    167 
    168    // And a group for all the control points
    169    const controlPointGroup = doc.createElementNS(SVG_NS, "g");
    170    controlPointGroup.classList.add("control-points-group");
    171 
    172    this.#linearLineEl = linearLine;
    173    this.#svgEl = svg;
    174    this.#controlPointGroupEl = controlPointGroup;
    175 
    176    svg.append(chartGrid, linearLine, controlPointGroup);
    177    wrap.append(svg);
    178    this.parent.append(wrap);
    179  }
    180 
    181  /**
    182   * Remove widget markup, called on destroy
    183   */
    184  #removeMarkup() {
    185    this.#wrapperEl.remove();
    186  }
    187 
    188  /**
    189   * Handle pointerdown event on the svg
    190   *
    191   * @param {PointerEvent} event
    192   */
    193  #onPointerDown(event) {
    194    if (
    195      !canPointerEventDrag(event) ||
    196      !event.target.classList.contains(
    197        LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME
    198      )
    199    ) {
    200      return;
    201    }
    202 
    203    this.#draggedEl = event.target;
    204    this.#draggedEl.setPointerCapture(event.pointerId);
    205 
    206    this.#dragAbortController = new AbortController();
    207    // Note that "pointermove" is also fired when the button state is changed.
    208    // Therefore, we should listen to "mousemove".
    209    this.#draggedEl.addEventListener(
    210      "mousemove",
    211      this.#onMouseMove.bind(this),
    212      { signal: this.#dragAbortController.signal }
    213    );
    214    this.#draggedEl.addEventListener(
    215      "pointerup",
    216      this.#onPointerUp.bind(this),
    217      {
    218        signal: this.#dragAbortController.signal,
    219      }
    220    );
    221  }
    222 
    223  /**
    224   * Handle mousemove event on a control point. Only active when there's a control point
    225   * being dragged.
    226   *
    227   * @param {MouseEvent} event
    228   */
    229  #onMouseMove = throttle(event => {
    230    if (!this.#draggedEl) {
    231      return;
    232    }
    233 
    234    const { x, y } = this.#getPositionInSvgFromEvent(event);
    235 
    236    // XXX: The spec allows input and output values in the [-Infinity,Infinity] range,
    237    // but this will be hard to have proper visual representation for all cases, so we
    238    // clamp x and y between 0 and 1 as it's more likely the range that will be used.
    239    let cx = clamp(0, 1, x);
    240    let cy = clamp(0, 1, y);
    241 
    242    if (this.#draggedEl.previousSibling) {
    243      // We don't allow moving the point before the previous point
    244      cx = Math.max(
    245        cx,
    246        parseFloat(this.#draggedEl.previousSibling.getAttribute("cx"))
    247      );
    248    }
    249    if (this.#draggedEl.nextSibling) {
    250      // We don't allow moving the point after the next point
    251      cx = Math.min(
    252        cx,
    253        parseFloat(this.#draggedEl.nextSibling.getAttribute("cx"))
    254      );
    255    }
    256 
    257    // Enable "Snap to grid" when the user holds the shift key
    258    if (event.shiftKey) {
    259      cx = Math.round(cx * 10) / 10;
    260      cy = Math.round(cy * 10) / 10;
    261    }
    262 
    263    this.#draggedEl.setAttribute("cx", cx);
    264    this.#draggedEl.setAttribute("cy", cy);
    265 
    266    this.#updateFunctionPointsFromControlPoints();
    267    this.#redrawLineFromFunctionPoints();
    268    this.emit("updated", this.getCssLinearValue());
    269  }, 20);
    270 
    271  /**
    272   * Handle pointerup event on a control point. Only active when there's a control point
    273   * being dragged.
    274   */
    275  #onPointerUp() {
    276    this.#draggedEl = null;
    277    this.#dragAbortController.abort();
    278    this.#dragAbortController = null;
    279  }
    280 
    281  /**
    282   * Handle dblclick event on the svg.
    283   * If the target is a control point, this will remove it, otherwise this will add
    284   * a new control point at the clicked position.
    285   *
    286   * @param {MouseEvent} event
    287   */
    288  #onDoubleClick(event) {
    289    const existingPoints = Array.from(
    290      this.#controlPointGroupEl.querySelectorAll(
    291        `.${LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME}`
    292      )
    293    );
    294 
    295    if (
    296      event.target.classList.contains(
    297        LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME
    298      )
    299    ) {
    300      // The function is only valid when it has at least 2 points, so don't allow to
    301      // produce invalid value.
    302      if (existingPoints.length <= 2) {
    303        return;
    304      }
    305 
    306      event.target.remove();
    307      this.#updateFunctionPointsFromControlPoints();
    308      this.#redrawFromFunctionPoints();
    309    } else {
    310      let { x, y } = this.#getPositionInSvgFromEvent(event);
    311 
    312      // Enable "Snap to grid" when the user holds the shift key
    313      if (event.shiftKey) {
    314        x = clamp(0, 1, Math.round(x * 10) / 10);
    315        y = clamp(0, 1, Math.round(y * 10) / 10);
    316      }
    317 
    318      // Add a control point at specified x and y in svg coords
    319      // We need to loop through existing control points to insert it at the correct index.
    320      const nextSibling = existingPoints.find(
    321        el => parseFloat(el.getAttribute("cx")) >= x
    322      );
    323 
    324      this.#controlPointGroupEl.insertBefore(
    325        this.#createSvgControlPointEl(x, y),
    326        nextSibling
    327      );
    328      this.#updateFunctionPointsFromControlPoints();
    329      this.#redrawLineFromFunctionPoints();
    330    }
    331  }
    332 
    333  /**
    334   * Update this.#functionPoints based on the control points in the svg
    335   */
    336  #updateFunctionPointsFromControlPoints() {
    337    // We ensure to order the control points based on their x position within the group,
    338    // so here, we can iterate through them without any need to sort them.
    339    this.#functionPoints = Array.from(
    340      this.#controlPointGroupEl.querySelectorAll(
    341        `.${LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME}`
    342      )
    343    ).map(el => {
    344      const input = parseFloat(el.getAttribute("cx"));
    345      // Since svg coords start from the top-left corner, we need to translate cy
    346      // to have the actual value we want for the function.
    347      const output = 1 - parseFloat(el.getAttribute("cy"));
    348 
    349      return {
    350        input,
    351        output,
    352      };
    353    });
    354  }
    355 
    356  /**
    357   * Redraw the control points and the linear() line in the svg,
    358   * based on the value of this.functionPoints.
    359   */
    360  #redrawFromFunctionPoints() {
    361    // Remove previous control points
    362    this.#controlPointGroupEl
    363      .querySelectorAll(
    364        `.${LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME}`
    365      )
    366      .forEach(el => el.remove());
    367 
    368    if (this.#functionPoints) {
    369      // Add controls for each function points
    370      this.#functionPoints.forEach(({ input, output }) => {
    371        this.#controlPointGroupEl.append(
    372          // Since svg coords start from the top-left corner, we need to translate output
    373          // to properly place it on the graph.
    374          this.#createSvgControlPointEl(input, 1 - output)
    375        );
    376      });
    377    }
    378 
    379    this.#redrawLineFromFunctionPoints();
    380  }
    381 
    382  /**
    383   * Redraw linear() line in the svg based on the value of this.functionPoints.
    384   */
    385  #redrawLineFromFunctionPoints() {
    386    // Set the line points
    387    this.#linearLineEl.setAttribute(
    388      "points",
    389      (this.#functionPoints || [])
    390        .map(
    391          ({ input, output }) =>
    392            // Since svg coords start from the top-left corner, we need to translate output
    393            // to properly place it on the graph.
    394            `${input},${1 - output}`
    395        )
    396        .join(" ")
    397    );
    398 
    399    const cssLinearValue = this.getCssLinearValue();
    400    if (this.#timingPreview) {
    401      this.#timingPreview.preview(cssLinearValue);
    402    }
    403 
    404    this.emit("updated", cssLinearValue);
    405  }
    406 
    407  /**
    408   * Create a control points for the svg line.
    409   *
    410   * @param {number} cx
    411   * @param {number} cy
    412   * @returns {SVGCircleElement}
    413   */
    414  #createSvgControlPointEl(cx, cy) {
    415    const controlEl = this.parent.ownerDocument.createElementNS(
    416      SVG_NS,
    417      "circle"
    418    );
    419    controlEl.classList.add("control-point");
    420    controlEl.setAttribute("cx", cx);
    421    controlEl.setAttribute("cy", cy);
    422    controlEl.setAttribute("r", 0.025);
    423    controlEl.setAttribute("fill", "context-fill");
    424    controlEl.setAttribute("stroke-width", 0);
    425    return controlEl;
    426  }
    427 
    428  /**
    429   * Return the position in the SVG viewbox from mouse event.
    430   *
    431   * @param {MouseEvent} event
    432   * @returns {object} An object with x and y properties
    433   */
    434  #getPositionInSvgFromEvent(event) {
    435    const position = this.#svgEl.createSVGPoint();
    436    position.x = event.clientX;
    437    position.y = event.clientY;
    438 
    439    const matrix = this.#svgEl.getScreenCTM();
    440    const inverseSvgMatrix = matrix.inverse();
    441    const transformedPosition = position.matrixTransform(inverseSvgMatrix);
    442 
    443    return { x: transformedPosition.x, y: transformedPosition.y };
    444  }
    445 
    446  /**
    447   * Provide the value of the linear() function we want to visualize here.
    448   * Called from the tooltip with the value of the function in the rule view.
    449   *
    450   * @param {string} linearFunctionValue: e.g. `linear(0, 0.5, 1)`.
    451   */
    452  setCssLinearValue(linearFunctionValue) {
    453    if (!linearFunctionValue) {
    454      return;
    455    }
    456 
    457    // Parse the string to extract all the points
    458    const points = parseTimingFunction(linearFunctionValue);
    459    this.#functionPoints = points;
    460 
    461    // And draw the line and points
    462    this.#redrawFromFunctionPoints();
    463  }
    464 
    465  /**
    466   * Return the value of the linear() function based on the state of the graph.
    467   * The resulting value is what we emit in the "updated" event.
    468   *
    469   * @return {string | null} e.g. `linear(0 0%, 0.5 50%, 1 100%)`.
    470   */
    471  getCssLinearValue() {
    472    if (!this.#functionPoints) {
    473      return null;
    474    }
    475 
    476    return `linear(${this.#functionPoints
    477      .map(
    478        ({ input, output }) =>
    479          `${numberFormatter.format(output)} ${percentFormatter.format(input)}`
    480      )
    481      .join(", ")})`;
    482  }
    483 
    484  destroy() {
    485    this.#abortController.abort();
    486    this.#dragAbortController?.abort();
    487    this.#removeMarkup();
    488    this.#reducedMotion = null;
    489 
    490    if (this.#timingPreview) {
    491      this.#timingPreview.destroy();
    492      this.#timingPreview = null;
    493    }
    494  }
    495 }
    496 
    497 exports.LinearEasingFunctionWidget = LinearEasingFunctionWidget;
    498 
    499 /**
    500 * The TimingFunctionPreviewWidget animates a dot on a scale with a given
    501 * timing-function
    502 */
    503 class TimingFunctionPreviewWidget {
    504  /**
    505   * @param {DOMNode} parent The container where this widget should go
    506   */
    507  constructor(parent) {
    508    this.#initMarkup(parent);
    509  }
    510 
    511  #PREVIEW_DURATION = 1000;
    512  #dotEl;
    513  #previousValue;
    514 
    515  #initMarkup(parent) {
    516    const doc = parent.ownerDocument;
    517 
    518    const container = doc.createElementNS(XHTML_NS, "div");
    519    container.className = "timing-function-preview";
    520 
    521    this.#dotEl = doc.createElementNS(XHTML_NS, "div");
    522    this.#dotEl.className = "dot";
    523    container.appendChild(this.#dotEl);
    524    parent.appendChild(container);
    525  }
    526 
    527  destroy() {
    528    this.#dotEl.getAnimations().forEach(anim => anim.cancel());
    529    this.#dotEl.parentElement.remove();
    530  }
    531 
    532  /**
    533   * Preview a new timing function. The current preview will only be stopped if
    534   * the supplied function value is different from the previous one. If the
    535   * supplied function is invalid, the preview will stop.
    536   *
    537   * @param {Array} value
    538   */
    539  preview(timingFunction) {
    540    if (this.#previousValue == timingFunction) {
    541      return;
    542    }
    543    this.#restartAnimation(timingFunction);
    544    this.#previousValue = timingFunction;
    545  }
    546 
    547  /**
    548   * Re-start the preview animation from the beginning.
    549   *
    550   * @param {Array} points
    551   */
    552  #restartAnimation = throttle(timingFunction => {
    553    // Cancel the previous animation if there was any.
    554    this.#dotEl.getAnimations().forEach(anim => anim.cancel());
    555 
    556    // And start the new one.
    557    // The animation consists of a few keyframes that move the dot to the right of the
    558    // container, and then move it back to the left.
    559    // It also contains some pause where the dot is greyed-out, before it moves to
    560    // the right, and once again, before it comes back to the left.
    561    // The timing function passed to this function is applied to the keyframes that
    562    // actually move the dot. This way it can be previewed in both direction, instead of
    563    // being spread over the whole animation.
    564    const grayscaleFilter = "grayscale(100%)";
    565 
    566    this.#dotEl.animate(
    567      [
    568        { translate: "0%", filter: grayscaleFilter, offset: 0 },
    569        { translate: "0%", filter: grayscaleFilter, offset: 0.19 },
    570        {
    571          translate: "0%",
    572          filter: "none",
    573          offset: 0.2,
    574          easing: timingFunction,
    575        },
    576        { translate: "100%", filter: "none", offset: 0.5 },
    577        { translate: "100%", filter: grayscaleFilter, offset: 0.51 },
    578        { translate: "100%", filter: grayscaleFilter, offset: 0.7 },
    579        {
    580          translate: "100%",
    581          filter: "none",
    582          offset: 0.71,
    583          easing: timingFunction,
    584        },
    585        { translate: "0%", filter: "none", offset: 1 },
    586      ],
    587      {
    588        duration: this.#PREVIEW_DURATION * 2,
    589        iterations: Infinity,
    590      }
    591    );
    592  }, 250);
    593 }
    594 
    595 /**
    596 * Parse a linear() string to collect the different values.
    597 * https://drafts.csswg.org/css-easing-2/#the-linear-easing-function
    598 *
    599 * @param {string} value
    600 * @return {Array<object> | undefined} returns undefined if value isn't a valid linear() value.
    601 *                                   the items of the array are objects with {Number} `input`
    602 *                                   and {Number} `output` properties.
    603 */
    604 function parseTimingFunction(value) {
    605  value = value.trim();
    606  const tokenStream = new InspectorCSSParserWrapper(value);
    607  const getNextToken = () => {
    608    while (true) {
    609      const token = tokenStream.nextToken();
    610      if (
    611        !token ||
    612        (token.tokenType !== "WhiteSpace" && token.tokenType !== "Comment")
    613      ) {
    614        return token;
    615      }
    616    }
    617  };
    618 
    619  let token = getNextToken();
    620  if (!token || token.tokenType !== "Function" || token.value !== "linear") {
    621    return undefined;
    622  }
    623 
    624  // Let's follow the spec parsing algorithm https://drafts.csswg.org/css-easing-2/#linear-easing-function-parsing
    625  const points = [];
    626  let largestInput = -Infinity;
    627 
    628  while ((token = getNextToken())) {
    629    if (token.tokenType === "CloseParenthesis") {
    630      break;
    631    }
    632 
    633    if (token.tokenType === "Number") {
    634      // [parsing step 4.1]
    635      const point = { input: null, output: token.number };
    636      // [parsing step 4.2]
    637      points.push(point);
    638 
    639      // get nextToken to see if there's a linear stop length
    640      token = getNextToken();
    641      // [parsing step 4.3]
    642      if (token && token.tokenType === "Percentage") {
    643        // [parsing step 4.3.1]
    644        point.input = Math.max(token.number, largestInput);
    645        // [parsing step 4.3.2]
    646        largestInput = point.input;
    647 
    648        // get nextToken to see if there's a second linear stop length
    649        token = getNextToken();
    650 
    651        // [parsing step 4.3.3]
    652        if (token && token.tokenType === "Percentage") {
    653          // [parsing step 4.3.3.1]
    654          const extraPoint = { input: null, output: point.output };
    655          // [parsing step 4.3.3.2]
    656          points.push(extraPoint);
    657 
    658          // [parsing step 4.3.3.3]
    659          extraPoint.input = Math.max(token.number, largestInput);
    660          // [parsing step 4.3.3.4]
    661          largestInput = extraPoint.input;
    662        }
    663      } else if (points.length == 1) {
    664        // [parsing step 4.4]
    665        // [parsing step 4.4.1]
    666        point.input = 0;
    667        // [parsing step 4.4.2]
    668        largestInput = 0;
    669      }
    670    }
    671  }
    672 
    673  if (points.length < 2) {
    674    return undefined;
    675  }
    676 
    677  // [parsing step 4.5]
    678  if (points.at(-1).input === null) {
    679    points.at(-1).input = Math.max(largestInput, 1);
    680  }
    681 
    682  // [parsing step 5]
    683 
    684  // We want to retrieve ranges ("runs" in the spec) of items with null inputs so we
    685  // can compute their input using linear interpolation.
    686  const nullInputPoints = [];
    687  points.forEach((point, index, array) => {
    688    if (point.input == null) {
    689      // since the first point is guaranteed to have an non-null input, and given that
    690      // we iterate through the points in regular order, we are guaranteed to find a previous
    691      // non null point.
    692      const previousNonNull = array.findLast(
    693        (item, i) => i < index && item.input !== null
    694      ).input;
    695      // since the last point is guaranteed to have an non-null input, and given that
    696      // we iterate through the points in regular order, we are guaranteed to find a next
    697      // non null point.
    698      const nextNonNull = array.find(
    699        (item, i) => i > index && item.input !== null
    700      ).input;
    701 
    702      if (nullInputPoints.at(-1)?.indexes?.at(-1) == index - 1) {
    703        nullInputPoints.at(-1).indexes.push(index);
    704      } else {
    705        nullInputPoints.push({
    706          indexes: [index],
    707          previousNonNull,
    708          nextNonNull,
    709        });
    710      }
    711    }
    712  });
    713 
    714  // For each range of consecutive null-input indexes
    715  nullInputPoints.forEach(({ indexes, previousNonNull, nextNonNull }) => {
    716    // For each null-input points, compute their input by linearly interpolating between
    717    // the closest previous and next points that have a non-null input.
    718    indexes.forEach((index, i) => {
    719      points[index].input = lerp(
    720        previousNonNull,
    721        nextNonNull,
    722        (i + 1) / (indexes.length + 1)
    723      );
    724    });
    725  });
    726 
    727  return points;
    728 }
    729 
    730 /**
    731 * Linearly interpolate between 2 numbers.
    732 *
    733 * @param {number} x
    734 * @param {number} y
    735 * @param {number} a
    736 *        A value of 0 returns x, and 1 returns y
    737 * @return {number}
    738 */
    739 function lerp(x, y, a) {
    740  return x * (1 - a) + y * a;
    741 }
    742 
    743 /**
    744 * Clamp value in a range, meaning the result won't be smaller than min
    745 * and no bigger than max.
    746 *
    747 * @param {number} min
    748 * @param {number} max
    749 * @param {number} value
    750 * @returns {number}
    751 */
    752 function clamp(min, max, value) {
    753  return Math.max(min, Math.min(value, max));
    754 }
    755 
    756 exports.parseTimingFunction = parseTimingFunction;