diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/client/inspector/animation/utils/graph-helper.js | 332 | ||||
-rw-r--r-- | devtools/client/inspector/animation/utils/l10n.js | 46 | ||||
-rw-r--r-- | devtools/client/inspector/animation/utils/moz.build | 10 | ||||
-rw-r--r-- | devtools/client/inspector/animation/utils/timescale.js | 145 | ||||
-rw-r--r-- | devtools/client/inspector/animation/utils/utils.js | 70 |
5 files changed, 603 insertions, 0 deletions
diff --git a/devtools/client/inspector/animation/utils/graph-helper.js b/devtools/client/inspector/animation/utils/graph-helper.js new file mode 100644 index 0000000000..cca2713254 --- /dev/null +++ b/devtools/client/inspector/animation/utils/graph-helper.js @@ -0,0 +1,332 @@ +/* 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 <path> 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 <path> 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; diff --git a/devtools/client/inspector/animation/utils/l10n.js b/devtools/client/inspector/animation/utils/l10n.js new file mode 100644 index 0000000000..6fffa98b65 --- /dev/null +++ b/devtools/client/inspector/animation/utils/l10n.js @@ -0,0 +1,46 @@ +/* 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("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/animationinspector.properties" +); +const INSPECTOR_L10N = new LocalizationHelper( + "devtools/client/locales/inspector.properties" +); + +/** + * Get a formatted title for this animation. This will be either: + * "%S", "%S : CSS Transition", "%S : CSS Animation", + * "%S : Script Animation", or "Script Animation", depending + * if the server provides the type, what type it is and if the animation + * has a name. + * + * @param {Object} state + */ +function getFormattedTitle(state) { + // Older servers don't send a type, and only know about + // CSSAnimations and CSSTransitions, so it's safe to use + // just the name. + if (!state.type) { + return state.name; + } + + // Script-generated animations may not have a name. + if (state.type === "scriptanimation" && !state.name) { + return L10N.getStr("timeline.scriptanimation.unnamedLabel"); + } + + return L10N.getFormatStr(`timeline.${state.type}.nameLabel`, state.name); +} + +module.exports = { + getFormatStr: (...args) => L10N.getFormatStr(...args), + getFormattedTitle, + getInspectorStr: (...args) => INSPECTOR_L10N.getStr(...args), + getStr: (...args) => L10N.getStr(...args), + numberWithDecimals: (...args) => L10N.numberWithDecimals(...args), +}; diff --git a/devtools/client/inspector/animation/utils/moz.build b/devtools/client/inspector/animation/utils/moz.build new file mode 100644 index 0000000000..ae73627a29 --- /dev/null +++ b/devtools/client/inspector/animation/utils/moz.build @@ -0,0 +1,10 @@ +# 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( + "graph-helper.js", + "l10n.js", + "timescale.js", + "utils.js", +) diff --git a/devtools/client/inspector/animation/utils/timescale.js b/devtools/client/inspector/animation/utils/timescale.js new file mode 100644 index 0000000000..a831f1267e --- /dev/null +++ b/devtools/client/inspector/animation/utils/timescale.js @@ -0,0 +1,145 @@ +/* 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 { + getFormatStr, +} = require("resource://devtools/client/inspector/animation/utils/l10n.js"); + +// If total duration for all animations is eqaul to or less than +// TIME_FORMAT_MAX_DURATION_IN_MS, the text which expresses time is in milliseconds, +// and seconds otherwise. Use in formatTime function. +const TIME_FORMAT_MAX_DURATION_IN_MS = 4000; + +/** + * TimeScale object holds the total duration, start time and end time and zero position + * time information for all animations which should be displayed, and is used to calculate + * the displayed area for each animation. + */ +class TimeScale { + constructor(animations) { + let resultCurrentTime = -Number.MAX_VALUE; + let resultMinStartTime = Infinity; + let resultMaxEndTime = 0; + let resultZeroPositionTime = 0; + + for (const animation of animations) { + const { + currentTime, + currentTimeAtCreated, + delay, + endTime, + startTimeAtCreated, + } = animation.state.absoluteValues; + let { startTime } = animation.state.absoluteValues; + + const negativeDelay = Math.min(delay, 0); + let zeroPositionTime = 0; + + // To shift the zero position time is the following two patterns. + // * Animation has negative current time which is smaller than negative dleay. + // * Animation has negative delay. + // Furthermore, we should override the zero position time if we will need to + // expand the duration due to this negative current time or negative delay of + // this target animation. + if (currentTimeAtCreated < negativeDelay) { + startTime = startTimeAtCreated; + zeroPositionTime = Math.abs(currentTimeAtCreated); + } else if (negativeDelay < 0) { + zeroPositionTime = Math.abs(negativeDelay); + } + + if (startTime < resultMinStartTime) { + resultMinStartTime = startTime; + // Override the previous calculated zero position only if the duration will be + // expanded. + resultZeroPositionTime = zeroPositionTime; + } else { + resultZeroPositionTime = Math.max( + resultZeroPositionTime, + zeroPositionTime + ); + } + + resultMaxEndTime = Math.max(resultMaxEndTime, endTime); + resultCurrentTime = Math.max(resultCurrentTime, currentTime); + } + + this.minStartTime = resultMinStartTime; + this.maxEndTime = resultMaxEndTime; + this.currentTime = resultCurrentTime; + this.zeroPositionTime = resultZeroPositionTime; + } + + /** + * Convert a distance in % to a time, in the current time scale. The time + * will be relative to the zero position time. + * i.e., If zeroPositionTime will be negative and specified time is shorter + * than the absolute value of zero position time, relative time will be + * negative time. + * + * @param {Number} distance + * @return {Number} + */ + distanceToRelativeTime(distance) { + return (this.getDuration() * distance) / 100 - this.zeroPositionTime; + } + + /** + * Depending on the time scale, format the given time as milliseconds or + * seconds. + * + * @param {Number} time + * @return {String} The formatted time string. + */ + formatTime(time) { + // Ignore negative zero + if (Math.abs(time) < 1 / 1000) { + time = 0.0; + } + + // Format in milliseconds if the total duration is short enough. + if (this.getDuration() <= TIME_FORMAT_MAX_DURATION_IN_MS) { + return getFormatStr("timeline.timeGraduationLabel", time.toFixed(0)); + } + + // Otherwise format in seconds. + return getFormatStr("player.timeLabel", (time / 1000).toFixed(1)); + } + + /** + * Return entire animations duration. + * + * @return {Number} duration + */ + getDuration() { + return this.maxEndTime - this.minStartTime; + } + + /** + * Return current time of this time scale represents. + * + * @return {Number} + */ + getCurrentTime() { + return this.currentTime - this.minStartTime; + } + + /** + * Return end time of given animation. + * This time does not include playbackRate and cratedTime. + * Also, if the animation has infinite iterations, this returns Infinity. + * + * @param {Object} animation + * @return {Numbber} end time + */ + getEndTime({ state }) { + return state.iterationCount + ? state.delay + state.duration * state.iterationCount + state.endDelay + : Infinity; + } +} + +module.exports = TimeScale; diff --git a/devtools/client/inspector/animation/utils/utils.js b/devtools/client/inspector/animation/utils/utils.js new file mode 100644 index 0000000000..9040c27213 --- /dev/null +++ b/devtools/client/inspector/animation/utils/utils.js @@ -0,0 +1,70 @@ +/* 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"; + +// The maximum number of times we can 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 whether or not the given list of animations has an iteration count of infinite. + * + * @param {Array} animations. + * @return {Boolean} true if there is an animation in the list of animations + * whose animation iteration count is infinite. + */ +function hasAnimationIterationCountInfinite(animations) { + return animations.some(({ state }) => !state.iterationCount); +} + +/** + * Check wether the animations are running at least one. + * + * @param {Array} animations. + * @return {Boolean} true: running + */ +function hasRunningAnimation(animations) { + return animations.some(({ state }) => state.playState === "running"); +} + +exports.findOptimalTimeInterval = findOptimalTimeInterval; +exports.hasAnimationIterationCountInfinite = hasAnimationIterationCountInfinite; +exports.hasRunningAnimation = hasRunningAnimation; |