blob: 4709567659db4c6dea28a6a39e0b719f6eeaac5f [file]
// Copyright (C) 2018 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {HighPrecisionTimeSpan} from '../base/high_precision_time_span';
import {HighPrecisionTime} from '../base/high_precision_time';
import {assertUnreachable} from '../base/assert';
import {Time, type time, timezoneOffsetMap} from '../base/time';
import type {Setting} from '../public/settings';
import {
type DurationPrecision,
type PanInstantIntoViewOptions,
type PanIntoViewOptions,
type Timeline,
TimestampFormat,
} from '../public/timeline';
import type {TraceInfo} from '../public/trace_info';
import {raf} from './raf_scheduler';
/**
* State that is shared between several frontend components, but not the
* controller. This state is updated at 60fps.
*/
export class TimelineImpl implements Timeline {
readonly MIN_DURATION = 10;
private readonly ANIMATION_DURATION_MS = 300;
private readonly SPAM_DETECTION_THRESHOLD_MS = 300;
private _visibleWindow: HighPrecisionTimeSpan;
private _hoverCursorTimestamp?: time;
private _highlightedSliceId?: number;
private _highlightedSliceName?: string;
private _hoveredNoteTimestamp?: time;
private _animationStartTime?: number;
private _animationStartWindow?: HighPrecisionTimeSpan;
private _animationTargetWindow?: HighPrecisionTimeSpan;
private _lastAnimationRequestTime = 0;
// TODO(stevegolton): These are currently only referenced by the cpu slice
// tracks and the process summary tracks. We should just make this a local
// property of the cpu slice tracks and ignore them in the process tracks.
private _hoveredUtid?: number;
private _hoveredPid?: bigint;
// This is used to mark the timeline of the area that is currently being
// selected.
//
// TODO(stevegolton): This shouldn't really be in the global timeline state,
// it's really only a concept of the viewer page and should be moved there
// instead.
selectedSpan?: {start: time; end: time};
get highlightedSliceId() {
return this._highlightedSliceId;
}
set highlightedSliceId(x) {
if (this._highlightedSliceId === x) return;
this._highlightedSliceId = x;
raf.scheduleCanvasRedraw();
}
get highlightedSliceName() {
return this._highlightedSliceName;
}
set highlightedSliceName(x) {
if (this._highlightedSliceName === x) return;
this._highlightedSliceName = x;
raf.scheduleCanvasRedraw();
}
get hoveredNoteTimestamp() {
return this._hoveredNoteTimestamp;
}
set hoveredNoteTimestamp(x) {
if (this._hoveredNoteTimestamp === x) return;
this._hoveredNoteTimestamp = x;
raf.scheduleCanvasRedraw();
}
get hoveredUtid() {
return this._hoveredUtid;
}
set hoveredUtid(x) {
if (this._hoveredUtid === x) return;
this._hoveredUtid = x;
raf.scheduleCanvasRedraw();
}
get hoveredPid() {
return this._hoveredPid;
}
set hoveredPid(x) {
if (this._hoveredPid === x) return;
this._hoveredPid = x;
raf.scheduleCanvasRedraw();
}
constructor(
private readonly traceInfo: TraceInfo,
private readonly _timestampFormat: Setting<TimestampFormat>,
private readonly _durationPrecision: Setting<DurationPrecision>,
readonly timezoneOverride: Setting<string>,
) {
this._visibleWindow = HighPrecisionTimeSpan.fromTime(
traceInfo.start,
traceInfo.end,
);
}
pan(delta: number) {
this.setVisibleWindow(
this._visibleWindow
.translate(delta)
.fitWithin(this.traceInfo.start, this.traceInfo.end),
);
}
zoom(ratio: number, centerPoint: number = 0.5) {
this.setVisibleWindow(
this._visibleWindow
.scale(ratio, centerPoint, this.MIN_DURATION)
.fitWithin(this.traceInfo.start, this.traceInfo.end),
);
}
// Given a timestamp, if |ts| is not currently in view move the view to
// center |ts|, keeping the same zoom level.
panIntoView(timePoint: time, options: PanInstantIntoViewOptions = {}) {
const {
align = 'nearest',
margin = 0.1,
animation = 'ease-in-out',
zoomWidth,
} = options;
const viewportDuration = this._visibleWindow.duration;
const marginNanos = viewportDuration * margin;
// Check if timestamp is already in view with margin
const viewWithMargin = this._visibleWindow.pad(-marginNanos);
if (align === 'nearest' && viewWithMargin.contains(timePoint)) {
// Already visible with margin, no need to pan
return;
}
let newViewport: HighPrecisionTimeSpan;
switch (align) {
case 'center':
newViewport = new HighPrecisionTimeSpan(
new HighPrecisionTime(timePoint).subNumber(viewportDuration / 2),
viewportDuration,
);
break;
case 'nearest':
// Pan the minimum amount to bring timestamp into view
if (timePoint < this._visibleWindow.start.integral) {
// Timestamp is before view, align to left
newViewport = new HighPrecisionTimeSpan(
new HighPrecisionTime(timePoint).subNumber(marginNanos),
viewportDuration,
);
} else {
// Timestamp is after view, align to right
newViewport = new HighPrecisionTimeSpan(
new HighPrecisionTime(timePoint).subNumber(
viewportDuration - marginNanos,
),
viewportDuration,
);
}
break;
case 'zoom':
const newDuration =
zoomWidth !== undefined
? Math.max(this.MIN_DURATION, zoomWidth)
: viewportDuration;
newViewport = new HighPrecisionTimeSpan(
new HighPrecisionTime(timePoint).subNumber(newDuration / 2),
newDuration,
);
break;
default:
assertUnreachable(align);
}
switch (animation) {
case 'ease-in-out':
this.animateToWindow(newViewport);
break;
case 'step':
this.setVisibleWindow(newViewport);
break;
default:
assertUnreachable(animation);
}
}
panSpanIntoView(start: time, end: time, options: PanIntoViewOptions = {}) {
const {
align = 'nearest',
margin = 0.1,
animation = 'ease-in-out',
} = options;
const duration = this._visibleWindow.duration;
const marginNanos = duration * margin;
const spanDuration = Number(end - start);
const spanMidpoint = new HighPrecisionTime(start).addNumber(
spanDuration / 2,
);
let newViewport: HighPrecisionTimeSpan;
switch (align) {
case 'center':
// Center the midpoint of the span
newViewport = new HighPrecisionTimeSpan(
spanMidpoint.subNumber(duration / 2),
duration,
);
break;
case 'nearest':
newViewport = new HighPrecisionTimeSpan(
this.panSpanIntoViewNearest(start, end, marginNanos),
duration,
);
break;
case 'zoom':
// Make it so that the span fits exactly within the viewport with margin
// That is, if the margin is 10% of the viewport, the span should take up
// 80% of the viewport.
const newDuration = Math.max(
this.MIN_DURATION,
spanDuration * (1 / (1 - margin * 2)),
);
newViewport = new HighPrecisionTimeSpan(
spanMidpoint.subNumber(newDuration / 2),
newDuration,
);
}
switch (animation) {
case 'ease-in-out':
this.animateToWindow(newViewport);
break;
case 'step':
this.setVisibleWindow(newViewport);
break;
default:
assertUnreachable(animation);
}
}
private panSpanIntoViewNearest(start: time, end: time, marginNanos: number) {
const viewWithMargin = this._visibleWindow.pad(-marginNanos);
const duration = this._visibleWindow.duration;
const spanDuration = Number(end - start);
// Check if span is already visible with margin
if (viewWithMargin.containsSpan(start, end)) {
// Span fits in safe zone and is already there
return this._visibleWindow.start;
}
// Check if viewport is contained within span
if (viewWithMargin.containedBy(start, end)) {
// Span is larger than the safe zone so there's nothing we can do to show
// more of it - just return current position
return this._visibleWindow.start;
}
// Now the behavior depends on the size of the span relative to the safe
// zone.
if (spanDuration < viewWithMargin.duration) {
if (viewWithMargin.start.gte(start)) {
// Span overlaps start - align start to safe left edge
return new HighPrecisionTime(start).subNumber(marginNanos);
} else {
// Span overlaps end - align end of viewport to the end of the span
return new HighPrecisionTime(end).subNumber(duration - marginNanos);
}
} else {
// Span is wider than (or same width as) safe zone - work out whether to
// align the start of the viewport with the start of the span, or the end
// of the viewport with the end of the span.
const distToAlignStart = Math.abs(
viewWithMargin.start.subTime(start).toNumber(),
);
const distToAlignEnd = Math.abs(
viewWithMargin.end.subTime(end).toNumber(),
);
if (distToAlignEnd < distToAlignStart) {
// Align span end to safe right edge
return new HighPrecisionTime(end).subNumber(duration - marginNanos);
} else {
// Align span start to safe left edge
return new HighPrecisionTime(start).subNumber(marginNanos);
}
}
}
moveStart(start: HighPrecisionTime) {
const endTime = this._visibleWindow.end;
const newDur = endTime.sub(start).toNumber();
this._visibleWindow = new HighPrecisionTimeSpan(start, newDur);
raf.scheduleCanvasRedraw();
}
moveEnd(end: HighPrecisionTime) {
const startTime = this._visibleWindow.start;
const newDuration = end.sub(startTime).toNumber();
this._visibleWindow = new HighPrecisionTimeSpan(startTime, newDuration);
raf.scheduleCanvasRedraw();
}
// Set visible window using a high precision time span
setVisibleWindow(ts: HighPrecisionTimeSpan) {
this._visibleWindow = ts
.clampDuration(this.MIN_DURATION)
.fitWithin(this.traceInfo.start, this.traceInfo.end);
raf.scheduleCanvasRedraw();
}
// Get the bounds of the visible window as a high-precision time span
get visibleWindow(): HighPrecisionTimeSpan {
return this._visibleWindow;
}
get hoverCursorTimestamp(): time | undefined {
return this._hoverCursorTimestamp;
}
set hoverCursorTimestamp(t: time | undefined) {
this._hoverCursorTimestamp = t;
raf.scheduleCanvasRedraw();
}
/**
* The trace time value where the timeline is considered to actually start.
* E.g.
* - Raw: offset = 0
* - Trace: offset = trace.start
* - Realtime: offset = previous midnight before trace.start
*/
getTimeAxisOrigin(): time {
const fmt = this.timestampFormat;
switch (fmt) {
case TimestampFormat.Timecode:
case TimestampFormat.Seconds:
case TimestampFormat.Milliseconds:
case TimestampFormat.Microseconds:
return this.traceInfo.start;
case TimestampFormat.TraceNs:
case TimestampFormat.TraceNsLocale:
return Time.ZERO;
case TimestampFormat.UTC:
return getTraceMidnightInTimezone(
this.traceInfo.start,
this.traceInfo.unixOffset,
0, // UTC
);
case TimestampFormat.CustomTimezone:
return getTraceMidnightInTimezone(
this.traceInfo.start,
this.traceInfo.unixOffset,
timezoneOffsetMap[this.timezoneOverride.get()],
);
case TimestampFormat.TraceTz:
return getTraceMidnightInTimezone(
this.traceInfo.start,
this.traceInfo.unixOffset,
this.traceInfo.tzOffMin,
);
default:
assertUnreachable(fmt);
}
}
// Convert absolute time to domain time.
toDomainTime(ts: time): time {
return Time.sub(ts, this.getTimeAxisOrigin());
}
get timestampFormat() {
return this._timestampFormat.get();
}
set timestampFormat(format: TimestampFormat) {
this._timestampFormat.set(format);
}
get durationPrecision() {
return this._durationPrecision.get();
}
set durationPrecision(precision: DurationPrecision) {
this._durationPrecision.set(precision);
}
get customTimezoneOffset(): number {
return timezoneOffsetMap[this.timezoneOverride.get()];
}
// Animate to a new visible window using ease-in-ease-out
private animateToWindow(targetWindow: HighPrecisionTimeSpan) {
const now = performance.now();
// Detect spam: if an animation request comes within threshold of the last one,
// or if an animation is already in progress, skip animation and use instant update
const isSpamming =
this._animationStartTime !== undefined ||
now - this._lastAnimationRequestTime < this.SPAM_DETECTION_THRESHOLD_MS;
this._lastAnimationRequestTime = now;
if (isSpamming) {
// Cancel any ongoing animation and jump directly to target
if (this._animationStartTime !== undefined) {
raf.stopAnimation(this.onAnimation);
this._animationStartTime = undefined;
this._animationStartWindow = undefined;
this._animationTargetWindow = undefined;
}
// Use instant update instead of animation
this.setVisibleWindow(targetWindow);
return;
}
// Apply clamping to target window
const clampedTarget = targetWindow
.clampDuration(this.MIN_DURATION)
.fitWithin(this.traceInfo.start, this.traceInfo.end);
// Store animation state
this._animationStartWindow = this._visibleWindow;
this._animationTargetWindow = clampedTarget;
this._animationStartTime = now;
// Start the animation
raf.startAnimation(this.onAnimation);
}
// Animation callback using ease-in-ease-out
private onAnimation = (currentTimeMs: number) => {
if (
this._animationStartTime === undefined ||
this._animationStartWindow === undefined ||
this._animationTargetWindow === undefined
) {
return;
}
const elapsed = currentTimeMs - this._animationStartTime;
const progress = Math.min(elapsed / this.ANIMATION_DURATION_MS, 1);
// Ease-in-ease-out function: 3t^2 - 2t^3
const eased = progress * progress * (3 - 2 * progress);
// Interpolate start position
const startDelta =
this._animationTargetWindow.start.toNumber() -
this._animationStartWindow.start.toNumber();
const newStart = this._animationStartWindow.start.addNumber(
startDelta * eased,
);
// Interpolate duration
const durationDelta =
this._animationTargetWindow.duration -
this._animationStartWindow.duration;
const newDuration =
this._animationStartWindow.duration + durationDelta * eased;
this._visibleWindow = new HighPrecisionTimeSpan(newStart, newDuration);
raf.scheduleCanvasRedraw();
if (progress >= 1) {
// Animation complete - clean up state
this._animationStartTime = undefined;
this._animationStartWindow = undefined;
this._animationTargetWindow = undefined;
raf.stopAnimation(this.onAnimation);
}
};
}
/**
* Returns the timestamp of the midnight before the trace starts in trace time
* units.
*
* @param traceStart - The trace-time timestamp of the start of the trace.
* @param unixOffset - The offset between the timestamp and the unix epoch.
* @param tzOffsetMins - The configured timezone offset in minutes.
* @returns The trace-time timestamp at the first midnight before the trace
* starts.
*/
function getTraceMidnightInTimezone(
traceStart: time,
unixOffset: time,
tzOffsetMins: number,
) {
const unixTime = Time.toDate(traceStart, unixOffset);
// Remove the time component of the date, viewed in the specific
// timezone we're looking for.
const midnight = dateOnly(unixTime, tzOffsetMins);
// Convert back to trace time
return Time.fromDate(midnight, unixOffset);
}
function dateOnly(date: Date, tzOffsetMins: number) {
// 1. Get the timestamp in milliseconds from the original date.
const originalTimestamp = date.getTime();
// 2. Calculate the timezone offset in milliseconds.
const timezoneOffsetInMilliseconds = tzOffsetMins * 60 * 1000;
// 3. Create a new Date object representing the time in the target timezone.
// We do this by adding our offset to the UTC time.
const dateInTargetTimezone = new Date(
originalTimestamp + timezoneOffsetInMilliseconds,
);
// 4. Now, working with this new Date object in the UTC frame,
// we can simply set its time components to the start of the day (midnight).
dateInTargetTimezone.setUTCHours(0, 0, 0, 0);
// 5. Finally, we convert this back to a timestamp and create a new Date object.
// This gives us the UTC timestamp of the midnight in the target timezone.
return new Date(
dateInTargetTimezone.getTime() - timezoneOffsetInMilliseconds,
);
}