754 lines
22 KiB
JavaScript
754 lines
22 KiB
JavaScript
/* 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";
|
|
|
|
/**
|
|
* This is a chart-like editor for linear() easing function, used in the Rules View.
|
|
*/
|
|
|
|
const EventEmitter = require("devtools/shared/event-emitter");
|
|
const { InspectorCSSParserWrapper } = require("devtools/shared/css/lexer");
|
|
const { throttle } = require("devtools/shared/throttle");
|
|
const XHTML_NS = "http://www.w3.org/1999/xhtml";
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
const numberFormatter = new Intl.NumberFormat("en", {
|
|
maximumFractionDigits: 3,
|
|
});
|
|
const percentFormatter = new Intl.NumberFormat("en", {
|
|
maximumFractionDigits: 2,
|
|
style: "percent",
|
|
});
|
|
|
|
/**
|
|
* Easing function widget. Draw the lines and control points in an svg.
|
|
*
|
|
* XXX: The spec allows input and output values in the [-Infinity,Infinity] range,
|
|
* but this will be hard to have proper visual representation to handle those cases, so we
|
|
* only handle points inside [0,0] [1,1] to represent most common use cases (even though
|
|
* the line will properly link points outside of this range)
|
|
*
|
|
*
|
|
* @emits "updated" events whenever the line is changed, with the updated property value.
|
|
*/
|
|
class LinearEasingFunctionWidget extends EventEmitter {
|
|
/**
|
|
* @param {DOMNode} parent The container where the widget should be created
|
|
*/
|
|
constructor(parent) {
|
|
super();
|
|
|
|
this.parent = parent;
|
|
this.#initMarkup();
|
|
|
|
this.#svgEl.addEventListener(
|
|
"pointerdown",
|
|
this.#onPointerDown.bind(this),
|
|
{
|
|
signal: this.#abortController.signal,
|
|
passive: true,
|
|
}
|
|
);
|
|
this.#svgEl.addEventListener("dblclick", this.#onDoubleClick.bind(this), {
|
|
signal: this.#abortController.signal,
|
|
});
|
|
|
|
// Add the timing function previewer
|
|
// if prefers-reduced-motion is not set
|
|
this.#reducedMotion = parent.ownerGlobal.matchMedia(
|
|
"(prefers-reduced-motion)"
|
|
);
|
|
if (!this.#reducedMotion.matches) {
|
|
this.#timingPreview = new TimingFunctionPreviewWidget(this.#wrapperEl);
|
|
}
|
|
|
|
// add event listener to change prefers-reduced-motion
|
|
// of the timing function preview during runtime
|
|
this.#reducedMotion.addEventListener(
|
|
"change",
|
|
event => {
|
|
// if prefers-reduced-motion is enabled destroy timing function preview
|
|
// else create it if it does not exist
|
|
if (event.matches) {
|
|
if (this.#timingPreview) {
|
|
this.#timingPreview.destroy();
|
|
}
|
|
this.#timingPreview = undefined;
|
|
} else if (!this.#timingPreview) {
|
|
this.#timingPreview = new TimingFunctionPreviewWidget(
|
|
this.#wrapperEl
|
|
);
|
|
}
|
|
},
|
|
{ signal: this.#abortController.signal }
|
|
);
|
|
}
|
|
|
|
static CONTROL_POINTS_CLASSNAME = "control-point";
|
|
|
|
// Handles event listener that are enabled for the whole widget lifetime
|
|
#abortController = new AbortController();
|
|
|
|
// Array<Object>: Object has `input` (plotted on x axis) and `output` (plotted on y axis) properties
|
|
#functionPoints;
|
|
|
|
// MediaQueryList
|
|
#reducedMotion;
|
|
|
|
// TimingFunctionPreviewWidget
|
|
#timingPreview;
|
|
|
|
// current dragged element. null if there's no dragging happening
|
|
#draggedEl = null;
|
|
|
|
// handles event listeners added when user starts dragging an element
|
|
#dragAbortController;
|
|
|
|
// element references
|
|
#wrapperEl;
|
|
#svgEl;
|
|
#linearLineEl;
|
|
#controlPointGroupEl;
|
|
|
|
/**
|
|
* Creates the markup of the widget
|
|
*/
|
|
#initMarkup() {
|
|
const doc = this.parent.ownerDocument;
|
|
|
|
const wrap = doc.createElementNS(XHTML_NS, "div");
|
|
wrap.className = "display-wrap";
|
|
this.#wrapperEl = wrap;
|
|
|
|
const svg = doc.createElementNS(SVG_NS, "svg");
|
|
svg.classList.add("chart");
|
|
|
|
// Add some "padding" to the viewBox so circles near the edges are not clipped.
|
|
const padding = 0.1;
|
|
const length = 1 + padding * 2;
|
|
// XXX: The spec allows input and output values in the [-Infinity,Infinity] range,
|
|
// but this will be hard to have proper visual representation for all cases, so we
|
|
// set the viewBox is basically starting at 0,0 and has a size of 1 (if we don't take the
|
|
// padding into account), to represent most common use cases.
|
|
svg.setAttribute(
|
|
"viewBox",
|
|
`${0 - padding} ${0 - padding} ${length} ${length}`
|
|
);
|
|
|
|
// Create a background grid
|
|
const chartGrid = doc.createElementNS(SVG_NS, "g");
|
|
chartGrid.setAttribute("stroke-width", "0.005");
|
|
chartGrid.classList.add("chart-grid");
|
|
for (let i = 0; i <= 10; i++) {
|
|
const value = i / 10;
|
|
const hLine = doc.createElementNS(SVG_NS, "line");
|
|
hLine.setAttribute("x1", 0);
|
|
hLine.setAttribute("y1", value);
|
|
hLine.setAttribute("x2", 1);
|
|
hLine.setAttribute("y2", value);
|
|
const vLine = doc.createElementNS(SVG_NS, "line");
|
|
vLine.setAttribute("x1", value);
|
|
vLine.setAttribute("y1", 0);
|
|
vLine.setAttribute("x2", value);
|
|
vLine.setAttribute("y2", 1);
|
|
chartGrid.append(hLine, vLine);
|
|
}
|
|
|
|
// Create the actual graph line
|
|
const linearLine = doc.createElementNS(SVG_NS, "polyline");
|
|
linearLine.classList.add("chart-linear");
|
|
linearLine.setAttribute("fill", "none");
|
|
linearLine.setAttribute("stroke", "context-stroke black");
|
|
linearLine.setAttribute("stroke-width", "0.01");
|
|
|
|
// And a group for all the control points
|
|
const controlPointGroup = doc.createElementNS(SVG_NS, "g");
|
|
controlPointGroup.classList.add("control-points-group");
|
|
|
|
this.#linearLineEl = linearLine;
|
|
this.#svgEl = svg;
|
|
this.#controlPointGroupEl = controlPointGroup;
|
|
|
|
svg.append(chartGrid, linearLine, controlPointGroup);
|
|
wrap.append(svg);
|
|
this.parent.append(wrap);
|
|
}
|
|
|
|
/**
|
|
* Remove widget markup, called on destroy
|
|
*/
|
|
#removeMarkup() {
|
|
this.#wrapperEl.remove();
|
|
}
|
|
|
|
/**
|
|
* Handle pointerdown event on the svg
|
|
*
|
|
* @param {PointerEvent} event
|
|
*/
|
|
#onPointerDown(event) {
|
|
if (
|
|
// We want to handle a drag during a mouse button is pressed. So, we can
|
|
// ignore pointer events which are caused by other devices.
|
|
event.pointerType != "mouse" ||
|
|
!event.target.classList.contains(
|
|
LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.#draggedEl = event.target;
|
|
this.#draggedEl.setPointerCapture(event.pointerId);
|
|
|
|
this.#dragAbortController = new AbortController();
|
|
// Note that "pointermove" is also fired when the button state is changed.
|
|
// Therefore, we should listen to "mousemove".
|
|
this.#draggedEl.addEventListener(
|
|
"mousemove",
|
|
this.#onMouseMove.bind(this),
|
|
{ signal: this.#dragAbortController.signal }
|
|
);
|
|
this.#draggedEl.addEventListener(
|
|
"pointerup",
|
|
this.#onPointerUp.bind(this),
|
|
{
|
|
signal: this.#dragAbortController.signal,
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle mousemove event on a control point. Only active when there's a control point
|
|
* being dragged.
|
|
*
|
|
* @param {MouseEvent} event
|
|
*/
|
|
#onMouseMove = throttle(event => {
|
|
if (!this.#draggedEl) {
|
|
return;
|
|
}
|
|
|
|
const { x, y } = this.#getPositionInSvgFromEvent(event);
|
|
|
|
// XXX: The spec allows input and output values in the [-Infinity,Infinity] range,
|
|
// but this will be hard to have proper visual representation for all cases, so we
|
|
// clamp x and y between 0 and 1 as it's more likely the range that will be used.
|
|
let cx = clamp(0, 1, x);
|
|
let cy = clamp(0, 1, y);
|
|
|
|
if (this.#draggedEl.previousSibling) {
|
|
// We don't allow moving the point before the previous point
|
|
cx = Math.max(
|
|
cx,
|
|
parseFloat(this.#draggedEl.previousSibling.getAttribute("cx"))
|
|
);
|
|
}
|
|
if (this.#draggedEl.nextSibling) {
|
|
// We don't allow moving the point after the next point
|
|
cx = Math.min(
|
|
cx,
|
|
parseFloat(this.#draggedEl.nextSibling.getAttribute("cx"))
|
|
);
|
|
}
|
|
|
|
// Enable "Snap to grid" when the user holds the shift key
|
|
if (event.shiftKey) {
|
|
cx = Math.round(cx * 10) / 10;
|
|
cy = Math.round(cy * 10) / 10;
|
|
}
|
|
|
|
this.#draggedEl.setAttribute("cx", cx);
|
|
this.#draggedEl.setAttribute("cy", cy);
|
|
|
|
this.#updateFunctionPointsFromControlPoints();
|
|
this.#redrawLineFromFunctionPoints();
|
|
this.emit("updated", this.getCssLinearValue());
|
|
}, 20);
|
|
|
|
/**
|
|
* Handle pointerup event on a control point. Only active when there's a control point
|
|
* being dragged.
|
|
*/
|
|
#onPointerUp() {
|
|
this.#draggedEl = null;
|
|
this.#dragAbortController.abort();
|
|
this.#dragAbortController = null;
|
|
}
|
|
|
|
/**
|
|
* Handle dblclick event on the svg.
|
|
* If the target is a control point, this will remove it, otherwise this will add
|
|
* a new control point at the clicked position.
|
|
*
|
|
* @param {MouseEvent} event
|
|
*/
|
|
#onDoubleClick(event) {
|
|
const existingPoints = Array.from(
|
|
this.#controlPointGroupEl.querySelectorAll(
|
|
`.${LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME}`
|
|
)
|
|
);
|
|
|
|
if (
|
|
event.target.classList.contains(
|
|
LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME
|
|
)
|
|
) {
|
|
// The function is only valid when it has at least 2 points, so don't allow to
|
|
// produce invalid value.
|
|
if (existingPoints.length <= 2) {
|
|
return;
|
|
}
|
|
|
|
event.target.remove();
|
|
this.#updateFunctionPointsFromControlPoints();
|
|
this.#redrawFromFunctionPoints();
|
|
} else {
|
|
let { x, y } = this.#getPositionInSvgFromEvent(event);
|
|
|
|
// Enable "Snap to grid" when the user holds the shift key
|
|
if (event.shiftKey) {
|
|
x = clamp(0, 1, Math.round(x * 10) / 10);
|
|
y = clamp(0, 1, Math.round(y * 10) / 10);
|
|
}
|
|
|
|
// Add a control point at specified x and y in svg coords
|
|
// We need to loop through existing control points to insert it at the correct index.
|
|
const nextSibling = existingPoints.find(
|
|
el => parseFloat(el.getAttribute("cx")) >= x
|
|
);
|
|
|
|
this.#controlPointGroupEl.insertBefore(
|
|
this.#createSvgControlPointEl(x, y),
|
|
nextSibling
|
|
);
|
|
this.#updateFunctionPointsFromControlPoints();
|
|
this.#redrawLineFromFunctionPoints();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update this.#functionPoints based on the control points in the svg
|
|
*/
|
|
#updateFunctionPointsFromControlPoints() {
|
|
// We ensure to order the control points based on their x position within the group,
|
|
// so here, we can iterate through them without any need to sort them.
|
|
this.#functionPoints = Array.from(
|
|
this.#controlPointGroupEl.querySelectorAll(
|
|
`.${LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME}`
|
|
)
|
|
).map(el => {
|
|
const input = parseFloat(el.getAttribute("cx"));
|
|
// Since svg coords start from the top-left corner, we need to translate cy
|
|
// to have the actual value we want for the function.
|
|
const output = 1 - parseFloat(el.getAttribute("cy"));
|
|
|
|
return {
|
|
input,
|
|
output,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Redraw the control points and the linear() line in the svg,
|
|
* based on the value of this.functionPoints.
|
|
*/
|
|
#redrawFromFunctionPoints() {
|
|
// Remove previous control points
|
|
this.#controlPointGroupEl
|
|
.querySelectorAll(
|
|
`.${LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME}`
|
|
)
|
|
.forEach(el => el.remove());
|
|
|
|
if (this.#functionPoints) {
|
|
// Add controls for each function points
|
|
this.#functionPoints.forEach(({ input, output }) => {
|
|
this.#controlPointGroupEl.append(
|
|
// Since svg coords start from the top-left corner, we need to translate output
|
|
// to properly place it on the graph.
|
|
this.#createSvgControlPointEl(input, 1 - output)
|
|
);
|
|
});
|
|
}
|
|
|
|
this.#redrawLineFromFunctionPoints();
|
|
}
|
|
|
|
/**
|
|
* Redraw linear() line in the svg based on the value of this.functionPoints.
|
|
*/
|
|
#redrawLineFromFunctionPoints() {
|
|
// Set the line points
|
|
this.#linearLineEl.setAttribute(
|
|
"points",
|
|
(this.#functionPoints || [])
|
|
.map(
|
|
({ input, output }) =>
|
|
// Since svg coords start from the top-left corner, we need to translate output
|
|
// to properly place it on the graph.
|
|
`${input},${1 - output}`
|
|
)
|
|
.join(" ")
|
|
);
|
|
|
|
const cssLinearValue = this.getCssLinearValue();
|
|
if (this.#timingPreview) {
|
|
this.#timingPreview.preview(cssLinearValue);
|
|
}
|
|
|
|
this.emit("updated", cssLinearValue);
|
|
}
|
|
|
|
/**
|
|
* Create a control points for the svg line.
|
|
*
|
|
* @param {Number} cx
|
|
* @param {Number} cy
|
|
* @returns {SVGCircleElement}
|
|
*/
|
|
#createSvgControlPointEl(cx, cy) {
|
|
const controlEl = this.parent.ownerDocument.createElementNS(
|
|
SVG_NS,
|
|
"circle"
|
|
);
|
|
controlEl.classList.add("control-point");
|
|
controlEl.setAttribute("cx", cx);
|
|
controlEl.setAttribute("cy", cy);
|
|
controlEl.setAttribute("r", 0.025);
|
|
controlEl.setAttribute("fill", "context-fill");
|
|
controlEl.setAttribute("stroke-width", 0);
|
|
return controlEl;
|
|
}
|
|
|
|
/**
|
|
* Return the position in the SVG viewbox from mouse event.
|
|
*
|
|
* @param {MouseEvent} event
|
|
* @returns {Object} An object with x and y properties
|
|
*/
|
|
#getPositionInSvgFromEvent(event) {
|
|
const position = this.#svgEl.createSVGPoint();
|
|
position.x = event.clientX;
|
|
position.y = event.clientY;
|
|
|
|
const matrix = this.#svgEl.getScreenCTM();
|
|
const inverseSvgMatrix = matrix.inverse();
|
|
const transformedPosition = position.matrixTransform(inverseSvgMatrix);
|
|
|
|
return { x: transformedPosition.x, y: transformedPosition.y };
|
|
}
|
|
|
|
/**
|
|
* Provide the value of the linear() function we want to visualize here.
|
|
* Called from the tooltip with the value of the function in the rule view.
|
|
*
|
|
* @param {String} linearFunctionValue: e.g. `linear(0, 0.5, 1)`.
|
|
*/
|
|
setCssLinearValue(linearFunctionValue) {
|
|
if (!linearFunctionValue) {
|
|
return;
|
|
}
|
|
|
|
// Parse the string to extract all the points
|
|
const points = parseTimingFunction(linearFunctionValue);
|
|
this.#functionPoints = points;
|
|
|
|
// And draw the line and points
|
|
this.#redrawFromFunctionPoints();
|
|
}
|
|
|
|
/**
|
|
* Return the value of the linear() function based on the state of the graph.
|
|
* The resulting value is what we emit in the "updated" event.
|
|
*
|
|
* @return {String|null} e.g. `linear(0 0%, 0.5 50%, 1 100%)`.
|
|
*/
|
|
getCssLinearValue() {
|
|
if (!this.#functionPoints) {
|
|
return null;
|
|
}
|
|
|
|
return `linear(${this.#functionPoints
|
|
.map(
|
|
({ input, output }) =>
|
|
`${numberFormatter.format(output)} ${percentFormatter.format(input)}`
|
|
)
|
|
.join(", ")})`;
|
|
}
|
|
|
|
destroy() {
|
|
this.#abortController.abort();
|
|
this.#dragAbortController?.abort();
|
|
this.#removeMarkup();
|
|
this.#reducedMotion = null;
|
|
|
|
if (this.#timingPreview) {
|
|
this.#timingPreview.destroy();
|
|
this.#timingPreview = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
exports.LinearEasingFunctionWidget = LinearEasingFunctionWidget;
|
|
|
|
/**
|
|
* The TimingFunctionPreviewWidget animates a dot on a scale with a given
|
|
* timing-function
|
|
*/
|
|
class TimingFunctionPreviewWidget {
|
|
/**
|
|
* @param {DOMNode} parent The container where this widget should go
|
|
*/
|
|
constructor(parent) {
|
|
this.#initMarkup(parent);
|
|
}
|
|
|
|
#PREVIEW_DURATION = 1000;
|
|
#dotEl;
|
|
#previousValue;
|
|
|
|
#initMarkup(parent) {
|
|
const doc = parent.ownerDocument;
|
|
|
|
const container = doc.createElementNS(XHTML_NS, "div");
|
|
container.className = "timing-function-preview";
|
|
|
|
this.#dotEl = doc.createElementNS(XHTML_NS, "div");
|
|
this.#dotEl.className = "dot";
|
|
container.appendChild(this.#dotEl);
|
|
parent.appendChild(container);
|
|
}
|
|
|
|
destroy() {
|
|
this.#dotEl.getAnimations().forEach(anim => anim.cancel());
|
|
this.#dotEl.parentElement.remove();
|
|
}
|
|
|
|
/**
|
|
* Preview a new timing function. The current preview will only be stopped if
|
|
* the supplied function value is different from the previous one. If the
|
|
* supplied function is invalid, the preview will stop.
|
|
* @param {Array} value
|
|
*/
|
|
preview(timingFunction) {
|
|
if (this.#previousValue == timingFunction) {
|
|
return;
|
|
}
|
|
this.#restartAnimation(timingFunction);
|
|
this.#previousValue = timingFunction;
|
|
}
|
|
|
|
/**
|
|
* Re-start the preview animation from the beginning.
|
|
* @param {Array} points
|
|
*/
|
|
#restartAnimation = throttle(timingFunction => {
|
|
// Cancel the previous animation if there was any.
|
|
this.#dotEl.getAnimations().forEach(anim => anim.cancel());
|
|
|
|
// And start the new one.
|
|
// The animation consists of a few keyframes that move the dot to the right of the
|
|
// container, and then move it back to the left.
|
|
// It also contains some pause where the dot is greyed-out, before it moves to
|
|
// the right, and once again, before it comes back to the left.
|
|
// The timing function passed to this function is applied to the keyframes that
|
|
// actually move the dot. This way it can be previewed in both direction, instead of
|
|
// being spread over the whole animation.
|
|
const grayscaleFilter = "grayscale(100%)";
|
|
|
|
this.#dotEl.animate(
|
|
[
|
|
{ translate: "0%", filter: grayscaleFilter, offset: 0 },
|
|
{ translate: "0%", filter: grayscaleFilter, offset: 0.19 },
|
|
{
|
|
translate: "0%",
|
|
filter: "none",
|
|
offset: 0.2,
|
|
easing: timingFunction,
|
|
},
|
|
{ translate: "100%", filter: "none", offset: 0.5 },
|
|
{ translate: "100%", filter: grayscaleFilter, offset: 0.51 },
|
|
{ translate: "100%", filter: grayscaleFilter, offset: 0.7 },
|
|
{
|
|
translate: "100%",
|
|
filter: "none",
|
|
offset: 0.71,
|
|
easing: timingFunction,
|
|
},
|
|
{ translate: "0%", filter: "none", offset: 1 },
|
|
],
|
|
{
|
|
duration: this.#PREVIEW_DURATION * 2,
|
|
iterations: Infinity,
|
|
}
|
|
);
|
|
}, 250);
|
|
}
|
|
|
|
/**
|
|
* Parse a linear() string to collect the different values.
|
|
* https://drafts.csswg.org/css-easing-2/#the-linear-easing-function
|
|
*
|
|
* @param {String} value
|
|
* @return {Array<Object>|undefined} returns undefined if value isn't a valid linear() value.
|
|
* the items of the array are objects with {Number} `input`
|
|
* and {Number} `output` properties.
|
|
*/
|
|
function parseTimingFunction(value) {
|
|
value = value.trim();
|
|
const tokenStream = new InspectorCSSParserWrapper(value);
|
|
const getNextToken = () => {
|
|
while (true) {
|
|
const token = tokenStream.nextToken();
|
|
if (
|
|
!token ||
|
|
(token.tokenType !== "WhiteSpace" && token.tokenType !== "Comment")
|
|
) {
|
|
return token;
|
|
}
|
|
}
|
|
};
|
|
|
|
let token = getNextToken();
|
|
if (!token || token.tokenType !== "Function" || token.value !== "linear") {
|
|
return undefined;
|
|
}
|
|
|
|
// Let's follow the spec parsing algorithm https://drafts.csswg.org/css-easing-2/#linear-easing-function-parsing
|
|
const points = [];
|
|
let largestInput = -Infinity;
|
|
|
|
while ((token = getNextToken())) {
|
|
if (token.tokenType === "CloseParenthesis") {
|
|
break;
|
|
}
|
|
|
|
if (token.tokenType === "Number") {
|
|
// [parsing step 4.1]
|
|
const point = { input: null, output: token.number };
|
|
// [parsing step 4.2]
|
|
points.push(point);
|
|
|
|
// get nextToken to see if there's a linear stop length
|
|
token = getNextToken();
|
|
// [parsing step 4.3]
|
|
if (token && token.tokenType === "Percentage") {
|
|
// [parsing step 4.3.1]
|
|
point.input = Math.max(token.number, largestInput);
|
|
// [parsing step 4.3.2]
|
|
largestInput = point.input;
|
|
|
|
// get nextToken to see if there's a second linear stop length
|
|
token = getNextToken();
|
|
|
|
// [parsing step 4.3.3]
|
|
if (token && token.tokenType === "Percentage") {
|
|
// [parsing step 4.3.3.1]
|
|
const extraPoint = { input: null, output: point.output };
|
|
// [parsing step 4.3.3.2]
|
|
points.push(extraPoint);
|
|
|
|
// [parsing step 4.3.3.3]
|
|
extraPoint.input = Math.max(token.number, largestInput);
|
|
// [parsing step 4.3.3.4]
|
|
largestInput = extraPoint.input;
|
|
}
|
|
} else if (points.length == 1) {
|
|
// [parsing step 4.4]
|
|
// [parsing step 4.4.1]
|
|
point.input = 0;
|
|
// [parsing step 4.4.2]
|
|
largestInput = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (points.length < 2) {
|
|
return undefined;
|
|
}
|
|
|
|
// [parsing step 4.5]
|
|
if (points.at(-1).input === null) {
|
|
points.at(-1).input = Math.max(largestInput, 1);
|
|
}
|
|
|
|
// [parsing step 5]
|
|
|
|
// We want to retrieve ranges ("runs" in the spec) of items with null inputs so we
|
|
// can compute their input using linear interpolation.
|
|
const nullInputPoints = [];
|
|
points.forEach((point, index, array) => {
|
|
if (point.input == null) {
|
|
// since the first point is guaranteed to have an non-null input, and given that
|
|
// we iterate through the points in regular order, we are guaranteed to find a previous
|
|
// non null point.
|
|
const previousNonNull = array.findLast(
|
|
(item, i) => i < index && item.input !== null
|
|
).input;
|
|
// since the last point is guaranteed to have an non-null input, and given that
|
|
// we iterate through the points in regular order, we are guaranteed to find a next
|
|
// non null point.
|
|
const nextNonNull = array.find(
|
|
(item, i) => i > index && item.input !== null
|
|
).input;
|
|
|
|
if (nullInputPoints.at(-1)?.indexes?.at(-1) == index - 1) {
|
|
nullInputPoints.at(-1).indexes.push(index);
|
|
} else {
|
|
nullInputPoints.push({
|
|
indexes: [index],
|
|
previousNonNull,
|
|
nextNonNull,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// For each range of consecutive null-input indexes
|
|
nullInputPoints.forEach(({ indexes, previousNonNull, nextNonNull }) => {
|
|
// For each null-input points, compute their input by linearly interpolating between
|
|
// the closest previous and next points that have a non-null input.
|
|
indexes.forEach((index, i) => {
|
|
points[index].input = lerp(
|
|
previousNonNull,
|
|
nextNonNull,
|
|
(i + 1) / (indexes.length + 1)
|
|
);
|
|
});
|
|
});
|
|
|
|
return points;
|
|
}
|
|
|
|
/**
|
|
* Linearly interpolate between 2 numbers.
|
|
*
|
|
* @param {Number} x
|
|
* @param {Number} y
|
|
* @param {Number} a
|
|
* A value of 0 returns x, and 1 returns y
|
|
* @return {Number}
|
|
*/
|
|
function lerp(x, y, a) {
|
|
return x * (1 - a) + y * a;
|
|
}
|
|
|
|
/**
|
|
* Clamp value in a range, meaning the result won't be smaller than min
|
|
* and no bigger than max.
|
|
*
|
|
* @param {Number} min
|
|
* @param {Number} max
|
|
* @param {Number} value
|
|
* @returns {Number}
|
|
*/
|
|
function clamp(min, max, value) {
|
|
return Math.max(min, Math.min(value, max));
|
|
}
|
|
|
|
exports.parseTimingFunction = parseTimingFunction;
|