/* 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"; // BOUND_EXCLUDING_TIME should be less than 1ms and is used to exclude start // and end bounds when dividing duration in createPathSegments. const BOUND_EXCLUDING_TIME = 0.001; // We define default graph height since if the height of viewport in SVG is // too small (e.g. 1), vector-effect may not be able to calculate correctly. const DEFAULT_GRAPH_HEIGHT = 100; // Default animation duration for keyframes graph. const DEFAULT_KEYFRAMES_GRAPH_DURATION = 1000; // DEFAULT_MIN_PROGRESS_THRESHOLD shoud be between more than 0 to 1. const DEFAULT_MIN_PROGRESS_THRESHOLD = 0.1; // In the createPathSegments function, an animation duration is divided by // DEFAULT_DURATION_RESOLUTION in order to draw the way the animation progresses. // But depending on the timing-function, we may be not able to make the graph // smoothly progress if this resolution is not high enough. // So, if the difference of animation progress between 2 divisions is more than // DEFAULT_MIN_PROGRESS_THRESHOLD * DEFAULT_GRAPH_HEIGHT, then createPathSegments // re-divides by DEFAULT_DURATION_RESOLUTION. // DEFAULT_DURATION_RESOLUTION shoud be integer and more than 2. const DEFAULT_DURATION_RESOLUTION = 4; // Stroke width for easing hint. const DEFAULT_EASING_HINT_STROKE_WIDTH = 5; /** * The helper class for creating summary graph. */ class SummaryGraphHelper { /** * Constructor. * * @param {Object} state * State of animation. * @param {Array} keyframes * Array of keyframe. * @param {Number} totalDuration * Total displayable duration. * @param {Number} minSegmentDuration * Minimum segment duration. * @param {Function} getValueFunc * Which returns graph value of given time. * The function should return a number value between 0 - 1. * e.g. time => { return 1.0 }; * @param {Function} toPathStringFunc * Which returns a path string for 'd' attribute for from given segments. */ constructor( state, keyframes, totalDuration, minSegmentDuration, getValueFunc, toPathStringFunc ) { this.totalDuration = totalDuration; this.minSegmentDuration = minSegmentDuration; this.minProgressThreshold = getPreferredProgressThreshold(state, keyframes) * DEFAULT_GRAPH_HEIGHT; this.durationResolution = getPreferredDurationResolution(keyframes); this.getValue = getValueFunc; this.toPathString = toPathStringFunc; this.getSegment = this.getSegment.bind(this); } /** * Create the path segments from given parameters. * * @param {Number} startTime * Starting time of animation. * @param {Number} endTime * Ending time of animation. * @return {Array} * Array of path segment. * e.g.[{x: {Number} time, y: {Number} progress}, ...] */ createPathSegments(startTime, endTime) { return createPathSegments( startTime, endTime, this.minSegmentDuration, this.minProgressThreshold, this.durationResolution, this.getSegment ); } /** * Return a coordinate as a graph segment at given time. * * @param {Number} time * @return {Object} * { x: Number, y: Number } */ getSegment(time) { const value = this.getValue(time); return { x: time, y: value * DEFAULT_GRAPH_HEIGHT }; } } /** * Create the path segments from given parameters. * * @param {Number} startTime * Starting time of animation. * @param {Number} endTime * Ending time of animation. * @param {Number} minSegmentDuration * Minimum segment duration. * @param {Number} minProgressThreshold * Minimum progress threshold. * @param {Number} resolution * Duration resolution for first time. * @param {Function} getSegment * A function that calculate the graph segment. * @return {Array} * Array of path segment. * e.g.[{x: {Number} time, y: {Number} progress}, ...] */ function createPathSegments( startTime, endTime, minSegmentDuration, minProgressThreshold, resolution, getSegment ) { // If the duration is too short, early return. if (endTime - startTime < minSegmentDuration) { return [getSegment(startTime), getSegment(endTime)]; } // Otherwise, start creating segments. let pathSegments = []; // Append the segment for the startTime position. const startTimeSegment = getSegment(startTime); pathSegments.push(startTimeSegment); let previousSegment = startTimeSegment; // Split the duration in equal intervals, and iterate over them. // See the definition of DEFAULT_DURATION_RESOLUTION for more information about this. const interval = (endTime - startTime) / resolution; for (let index = 1; index <= resolution; index++) { // Create a segment for this interval. const currentSegment = getSegment(startTime + index * interval); // If the distance between the Y coordinate (the animation's progress) of // the previous segment and the Y coordinate of the current segment is too // large, then recurse with a smaller duration to get more details // in the graph. if (Math.abs(currentSegment.y - previousSegment.y) > minProgressThreshold) { // Divide the current interval (excluding start and end bounds // by adding/subtracting BOUND_EXCLUDING_TIME). const nextStartTime = previousSegment.x + BOUND_EXCLUDING_TIME; const nextEndTime = currentSegment.x - BOUND_EXCLUDING_TIME; const segments = createPathSegments( nextStartTime, nextEndTime, minSegmentDuration, minProgressThreshold, DEFAULT_DURATION_RESOLUTION, getSegment ); pathSegments = pathSegments.concat(segments); } pathSegments.push(currentSegment); previousSegment = currentSegment; } return pathSegments; } /** * Create a function which is used as parameter (toPathStringFunc) in constructor * of SummaryGraphHelper. * * @param {Number} endTime * end time of animation * e.g. 200 * @param {Number} playbackRate * playback rate of animation * e.g. -1 * @return {Function} */ function createSummaryGraphPathStringFunction(endTime, playbackRate) { return segments => { segments = mapSegmentsToPlaybackRate(segments, endTime, playbackRate); const firstSegment = segments[0]; let pathString = `M${firstSegment.x},0 `; pathString += toPathString(segments); const lastSegment = segments[segments.length - 1]; pathString += `L${lastSegment.x},0 Z`; return pathString; }; } /** * Return preferred duration resolution. * This corresponds to narrow interval keyframe offset. * * @param {Array} keyframes * Array of keyframe. * @return {Number} * Preferred duration resolution. */ function getPreferredDurationResolution(keyframes) { if (!keyframes) { return DEFAULT_DURATION_RESOLUTION; } let durationResolution = DEFAULT_DURATION_RESOLUTION; let previousOffset = 0; for (const keyframe of keyframes) { if (previousOffset && previousOffset != keyframe.offset) { const interval = keyframe.offset - previousOffset; durationResolution = Math.max( durationResolution, Math.ceil(1 / interval) ); } previousOffset = keyframe.offset; } return durationResolution; } /** * Return preferred progress threshold to render summary graph. * * @param {Object} state * State of animation. * @param {Array} keyframes * Array of keyframe. * @return {float} * Preferred threshold. */ function getPreferredProgressThreshold(state, keyframes) { const steps = getStepsCount(state.easing); const threshold = Math.min(DEFAULT_MIN_PROGRESS_THRESHOLD, 1 / (steps + 1)); if (!keyframes) { return threshold; } return Math.min( threshold, getPreferredProgressThresholdByKeyframes(keyframes) ); } /** * Return preferred progress threshold by keyframes. * * @param {Array} keyframes * Array of keyframe. * @return {float} * Preferred threshold. */ function getPreferredProgressThresholdByKeyframes(keyframes) { let threshold = DEFAULT_MIN_PROGRESS_THRESHOLD; for (let i = 0; i < keyframes.length - 1; i++) { const keyframe = keyframes[i]; if (!keyframe.easing) { continue; } const steps = getStepsCount(keyframe.easing); if (steps) { const nextKeyframe = keyframes[i + 1]; threshold = Math.min( threshold, (1 / (steps + 1)) * (nextKeyframe.offset - keyframe.offset) ); } } return threshold; } function getStepsCount(easing) { const stepsFunction = easing.match(/(steps)\((\d+)/); return stepsFunction ? parseInt(stepsFunction[2], 10) : 0; } function mapSegmentsToPlaybackRate(segments, endTime, playbackRate) { if (playbackRate > 0) { return segments; } return segments.map(segment => { segment.x = endTime - segment.x; return segment; }); } /** * Return path string for 'd' attribute for from given segments. * * @param {Array} segments * e.g. [{ x: 100, y: 0 }, { x: 200, y: 1 }] * @return {String} * Path string. * e.g. "L100,0 L200,1" */ function toPathString(segments) { let pathString = ""; segments.forEach(segment => { pathString += `L${segment.x},${segment.y} `; }); return pathString; } exports.createPathSegments = createPathSegments; exports.createSummaryGraphPathStringFunction = createSummaryGraphPathStringFunction; exports.DEFAULT_DURATION_RESOLUTION = DEFAULT_DURATION_RESOLUTION; exports.DEFAULT_EASING_HINT_STROKE_WIDTH = DEFAULT_EASING_HINT_STROKE_WIDTH; exports.DEFAULT_GRAPH_HEIGHT = DEFAULT_GRAPH_HEIGHT; exports.DEFAULT_KEYFRAMES_GRAPH_DURATION = DEFAULT_KEYFRAMES_GRAPH_DURATION; exports.getPreferredProgressThresholdByKeyframes = getPreferredProgressThresholdByKeyframes; exports.SummaryGraphHelper = SummaryGraphHelper; exports.toPathString = toPathString;