1/*
2 * Copyright (C) 2022 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import {Color} from 'app/colors';
18import {Point} from 'common/geometry_types';
19import {MouseEventButton} from 'common/mouse_event_button';
20import {Padding} from 'common/padding';
21import {Timestamp} from 'common/time';
22import {Trace} from 'trace/trace';
23import {TRACE_INFO} from 'trace/trace_info';
24import {CanvasMouseHandler} from './canvas_mouse_handler';
25import {CanvasMouseHandlerImpl} from './canvas_mouse_handler_impl';
26import {DraggableCanvasObject} from './draggable_canvas_object';
27import {DraggableCanvasObjectImpl} from './draggable_canvas_object_impl';
28import {
29  MiniCanvasDrawerData,
30  TimelineTrace,
31  TimelineTraces,
32} from './mini_canvas_drawer_data';
33import {MiniTimelineDrawer} from './mini_timeline_drawer';
34import {MiniTimelineDrawerInput} from './mini_timeline_drawer_input';
35
36/**
37 * Mini timeline drawer implementation
38 * @docs-private
39 */
40export class MiniTimelineDrawerImpl implements MiniTimelineDrawer {
41  ctx: CanvasRenderingContext2D;
42  handler: CanvasMouseHandler;
43  private activePointer: DraggableCanvasObject;
44  private lastMousePoint: Point | undefined;
45  private static readonly MARKER_CLICK_REGION_WIDTH = 2;
46
47  constructor(
48    public canvas: HTMLCanvasElement,
49    private inputGetter: () => MiniTimelineDrawerInput,
50    private onPointerPositionDragging: (pos: Timestamp) => void,
51    private onPointerPositionChanged: (pos: Timestamp) => void,
52    private onUnhandledClick: (
53      pos: Timestamp,
54      trace: Trace<object> | undefined,
55    ) => void,
56  ) {
57    const ctx = canvas.getContext('2d');
58
59    if (ctx === null) {
60      throw Error('MiniTimeline canvas context was null!');
61    }
62
63    this.ctx = ctx;
64
65    const onUnhandledClickInternal = async (
66      mousePoint: Point,
67      button: number,
68      trace: Trace<object> | undefined,
69    ) => {
70      if (button === MouseEventButton.SECONDARY) {
71        return;
72      }
73      let pointX = mousePoint.x;
74
75      if (mousePoint.y < this.getMarkerHeight()) {
76        pointX =
77          this.getInput().bookmarks.find((bm) => {
78            const diff = mousePoint.x - bm;
79            return diff > 0 && diff < this.getMarkerMaxWidth();
80          }) ?? mousePoint.x;
81      }
82
83      this.onUnhandledClick(
84        this.getInput().transformer.untransform(pointX),
85        trace,
86      );
87    };
88    this.handler = new CanvasMouseHandlerImpl(
89      this,
90      'pointer',
91      onUnhandledClickInternal,
92    );
93
94    this.activePointer = new DraggableCanvasObjectImpl(
95      this,
96      () => this.getSelectedPosition(),
97      (ctx: CanvasRenderingContext2D, position: number) => {
98        const barWidth = 3;
99        const triangleHeight = this.getMarkerHeight();
100
101        ctx.beginPath();
102        ctx.moveTo(position - triangleHeight, 0);
103        ctx.lineTo(position + triangleHeight, 0);
104        ctx.lineTo(position + barWidth / 2, triangleHeight);
105        ctx.lineTo(position + barWidth / 2, this.getHeight());
106        ctx.lineTo(position - barWidth / 2, this.getHeight());
107        ctx.lineTo(position - barWidth / 2, triangleHeight);
108        ctx.closePath();
109      },
110      {
111        fillStyle: Color.ACTIVE_POINTER,
112        fill: true,
113      },
114      (x) => {
115        const input = this.getInput();
116        input.selectedPosition = x;
117        this.onPointerPositionDragging(input.transformer.untransform(x));
118      },
119      (x) => {
120        const input = this.getInput();
121        input.selectedPosition = x;
122        this.onPointerPositionChanged(input.transformer.untransform(x));
123      },
124      () => this.getUsableRange(),
125    );
126  }
127
128  getXScale() {
129    return this.ctx.getTransform().m11;
130  }
131
132  getYScale() {
133    return this.ctx.getTransform().m22;
134  }
135
136  getWidth() {
137    return this.canvas.width / this.getXScale();
138  }
139
140  getHeight() {
141    return this.canvas.height / this.getYScale();
142  }
143
144  getUsableRange() {
145    const padding = this.getPadding();
146    return {
147      from: padding.left,
148      to: this.getWidth() - padding.left - padding.right,
149    };
150  }
151
152  getInput(): MiniCanvasDrawerData {
153    return this.inputGetter().transform(this.getUsableRange());
154  }
155
156  getClickRange(clickPos: Point) {
157    const markerHeight = this.getMarkerHeight();
158    if (clickPos.y > markerHeight) {
159      return {
160        from: clickPos.x - MiniTimelineDrawerImpl.MARKER_CLICK_REGION_WIDTH,
161        to: clickPos.x + MiniTimelineDrawerImpl.MARKER_CLICK_REGION_WIDTH,
162      };
163    }
164    const markerMaxWidth = this.getMarkerMaxWidth();
165    return {
166      from: clickPos.x - markerMaxWidth,
167      to: clickPos.x + markerMaxWidth,
168    };
169  }
170
171  getSelectedPosition() {
172    return this.getInput().selectedPosition;
173  }
174
175  getBookmarks(): number[] {
176    return this.getInput().bookmarks;
177  }
178
179  async getTimelineTraces(): Promise<TimelineTraces> {
180    return await this.getInput().getTimelineTraces();
181  }
182
183  getPadding(): Padding {
184    const height = this.getHeight();
185    const pointerWidth = this.getPointerWidth();
186    return {
187      top: Math.ceil(height / 10),
188      bottom: Math.ceil(height / 10),
189      left: Math.ceil(pointerWidth / 2),
190      right: Math.ceil(pointerWidth / 2),
191    };
192  }
193
194  getInnerHeight() {
195    const padding = this.getPadding();
196    return this.getHeight() - padding.top - padding.bottom;
197  }
198
199  async draw() {
200    this.ctx.clearRect(0, 0, this.getWidth(), this.getHeight());
201    await this.drawTraceLines();
202    this.drawBookmarks();
203    this.activePointer.draw(this.ctx);
204    this.drawHoverCursor();
205  }
206
207  async updateHover(mousePoint: Point | undefined) {
208    this.lastMousePoint = mousePoint;
209    await this.draw();
210  }
211
212  async getTraceClicked(mousePoint: Point): Promise<Trace<object> | undefined> {
213    const timelineTraces = await this.getTimelineTraces();
214    const innerHeight = this.getInnerHeight();
215    const lineHeight = this.getLineHeight(timelineTraces, innerHeight);
216    let fromTop = this.getPadding().top + innerHeight - lineHeight;
217
218    for (const trace of timelineTraces.keys()) {
219      if (
220        this.pointWithinTimeline(mousePoint.y, fromTop, fromTop + lineHeight)
221      ) {
222        return trace;
223      }
224      fromTop -= this.fromTopStep(lineHeight);
225    }
226
227    return undefined;
228  }
229
230  private getPointerWidth() {
231    return this.getHeight() / 6;
232  }
233
234  private getMarkerMaxWidth() {
235    return (this.getPointerWidth() * 2) / 3;
236  }
237
238  private getMarkerHeight() {
239    return this.getPointerWidth() / 2;
240  }
241
242  private async drawTraceLines() {
243    const timelineTraces = await this.getTimelineTraces();
244    const innerHeight = this.getInnerHeight();
245    const lineHeight = this.getLineHeight(timelineTraces, innerHeight);
246    let fromTop = this.getPadding().top + innerHeight - lineHeight;
247
248    timelineTraces.forEach((timelineTrace, trace) => {
249      if (this.inputGetter().timelineData.getActiveTrace() === trace) {
250        this.fillActiveTimelineBackground(fromTop, lineHeight);
251      } else if (
252        this.lastMousePoint?.y &&
253        this.pointWithinTimeline(this.lastMousePoint?.y, fromTop, lineHeight)
254      ) {
255        this.fillHoverTimelineBackground(fromTop, lineHeight);
256      }
257
258      this.drawTraceEntries(trace, timelineTrace, fromTop, lineHeight);
259
260      fromTop -= this.fromTopStep(lineHeight);
261    });
262  }
263
264  private drawTraceEntries(
265    trace: Trace<object>,
266    timelineTrace: TimelineTrace,
267    fromTop: number,
268    lineHeight: number,
269  ) {
270    this.ctx.globalAlpha = 0.7;
271    this.ctx.fillStyle = TRACE_INFO[trace.type].color;
272    this.ctx.strokeStyle = 'blue';
273
274    for (const entry of timelineTrace.points) {
275      const width = 5;
276      this.ctx.fillRect(entry - width / 2, fromTop, width, lineHeight);
277    }
278
279    for (const entry of timelineTrace.segments) {
280      const width = Math.max(entry.to - entry.from, 3);
281      this.ctx.fillRect(entry.from, fromTop, width, lineHeight);
282    }
283
284    this.ctx.fillStyle = Color.ACTIVE_POINTER;
285    if (timelineTrace.activePoint) {
286      const entry = timelineTrace.activePoint;
287      const width = 5;
288      this.ctx.fillRect(entry - width / 2, fromTop, width, lineHeight);
289    }
290
291    if (timelineTrace.activeSegment) {
292      const entry = timelineTrace.activeSegment;
293      const width = Math.max(entry.to - entry.from, 3);
294      this.ctx.fillRect(entry.from, fromTop, width, lineHeight);
295    }
296
297    this.ctx.globalAlpha = 1.0;
298  }
299
300  private drawHoverCursor() {
301    if (!this.lastMousePoint) {
302      return;
303    }
304    const hoverWidth = 2;
305    this.ctx.beginPath();
306    this.ctx.moveTo(this.lastMousePoint.x - hoverWidth / 2, 0);
307    this.ctx.lineTo(this.lastMousePoint.x + hoverWidth / 2, 0);
308    this.ctx.lineTo(this.lastMousePoint.x + hoverWidth / 2, this.getHeight());
309    this.ctx.lineTo(this.lastMousePoint.x - hoverWidth / 2, this.getHeight());
310    this.ctx.closePath();
311
312    this.ctx.globalAlpha = 0.4;
313    this.ctx.fillStyle = Color.ACTIVE_POINTER;
314    this.ctx.fill();
315    this.ctx.globalAlpha = 1.0;
316  }
317
318  private drawBookmarks() {
319    this.getBookmarks().forEach((position) => {
320      const flagWidth = this.getMarkerMaxWidth();
321      const flagHeight = this.getMarkerHeight();
322      const barWidth = 2;
323
324      this.ctx.beginPath();
325      this.ctx.moveTo(position - barWidth / 2, 0);
326      this.ctx.lineTo(position + flagWidth, 0);
327      this.ctx.lineTo(position + (flagWidth * 5) / 6, flagHeight / 2);
328      this.ctx.lineTo(position + flagWidth, flagHeight);
329      this.ctx.lineTo(position + barWidth / 2, flagHeight);
330      this.ctx.lineTo(position + barWidth / 2, this.getHeight());
331      this.ctx.lineTo(position - barWidth / 2, this.getHeight());
332      this.ctx.closePath();
333
334      this.ctx.fillStyle = Color.BOOKMARK;
335      this.ctx.fill();
336    });
337  }
338
339  private fromTopStep(lineHeight: number): number {
340    return (lineHeight * 4) / 3;
341  }
342
343  private fillActiveTimelineBackground(fromTop: number, lineHeight: number) {
344    this.ctx.globalAlpha = 1.0;
345    this.ctx.fillStyle = this.inputGetter().isDarkMode ? '#696563' : '#eeeff0'; // Keep in sync with var(--drawer-block-primary) in material-theme.scss;
346    this.ctx.fillRect(0, fromTop, this.getUsableRange().to, lineHeight);
347  }
348
349  private fillHoverTimelineBackground(fromTop: number, lineHeight: number) {
350    this.ctx.globalAlpha = 1.0;
351    this.ctx.fillStyle = this.inputGetter().isDarkMode ? '#4e5767' : '#E8F0FE'; // Keep in sync with var(--hover-element-color) in material-theme.scss;
352    this.ctx.fillRect(0, fromTop, this.getUsableRange().to, lineHeight);
353  }
354
355  private getLineHeight(
356    timelineTraces: TimelineTraces,
357    innerHeight: number,
358  ): number {
359    return innerHeight / (Math.max(timelineTraces.size - 10, 0) + 12);
360  }
361
362  private pointWithinTimeline(
363    point: number,
364    from: number,
365    to: number,
366  ): boolean {
367    return point > from && point <= from + to;
368  }
369}
370