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 {Segment} from 'app/components/timeline/segment';
19import {TimelineUtils} from 'app/components/timeline/timeline_utils';
20import {Point} from 'common/geometry/point';
21import {MouseEventButton} from 'common/mouse_event_button';
22import {Padding} from 'common/padding';
23import {Timestamp} from 'common/time';
24import {Trace} from 'trace/trace';
25import {TRACE_INFO} from 'trace/trace_info';
26import {CanvasMouseHandler} from './canvas_mouse_handler';
27import {CanvasMouseHandlerImpl} from './canvas_mouse_handler_impl';
28import {DraggableCanvasObject} from './draggable_canvas_object';
29import {DraggableCanvasObjectImpl} from './draggable_canvas_object_impl';
30import {
31  MiniCanvasDrawerData,
32  TimelineTrace,
33  TimelineTraces,
34} from './mini_canvas_drawer_data';
35import {MiniTimelineDrawer} from './mini_timeline_drawer';
36import {MiniTimelineDrawerInput} from './mini_timeline_drawer_input';
37
38/**
39 * Mini timeline drawer implementation
40 * @docs-private
41 */
42export class MiniTimelineDrawerImpl implements MiniTimelineDrawer {
43  ctx: CanvasRenderingContext2D;
44  handler: CanvasMouseHandler;
45  private activePointer: DraggableCanvasObject;
46  private lastMousePoint: Point | undefined;
47  private static readonly MARKER_CLICK_REGION_WIDTH = 2;
48  private static readonly TRACE_ENTRY_ALPHA = 0.7;
49
50  constructor(
51    public canvas: HTMLCanvasElement,
52    private inputGetter: () => MiniTimelineDrawerInput,
53    private onPointerPositionDragging: (pos: Timestamp) => void,
54    private onPointerPositionChanged: (pos: Timestamp) => void,
55    private onUnhandledClick: (
56      pos: Timestamp,
57      trace: Trace<object> | undefined,
58    ) => void,
59  ) {
60    const ctx = canvas.getContext('2d');
61
62    if (ctx === null) {
63      throw new Error('MiniTimeline canvas context was null!');
64    }
65
66    this.ctx = ctx;
67
68    const onUnhandledClickInternal = async (
69      mousePoint: Point,
70      button: number,
71      trace: Trace<object> | undefined,
72    ) => {
73      if (button === MouseEventButton.SECONDARY) {
74        return;
75      }
76      let pointX = mousePoint.x;
77
78      if (mousePoint.y < this.getMarkerHeight()) {
79        pointX =
80          this.getInput().bookmarks.find((bm) => {
81            const diff = mousePoint.x - bm;
82            return diff > 0 && diff < this.getMarkerMaxWidth();
83          }) ?? mousePoint.x;
84      }
85
86      this.onUnhandledClick(
87        this.getInput().transformer.untransform(pointX),
88        trace,
89      );
90    };
91    this.handler = new CanvasMouseHandlerImpl(
92      this,
93      'pointer',
94      onUnhandledClickInternal,
95    );
96
97    this.activePointer = new DraggableCanvasObjectImpl(
98      this,
99      () => this.getSelectedPosition(),
100      (ctx: CanvasRenderingContext2D, position: number) => {
101        const barWidth = 3;
102        const triangleHeight = this.getMarkerHeight();
103
104        ctx.beginPath();
105        ctx.moveTo(position - triangleHeight, 0);
106        ctx.lineTo(position + triangleHeight, 0);
107        ctx.lineTo(position + barWidth / 2, triangleHeight);
108        ctx.lineTo(position + barWidth / 2, this.getHeight());
109        ctx.lineTo(position - barWidth / 2, this.getHeight());
110        ctx.lineTo(position - barWidth / 2, triangleHeight);
111        ctx.closePath();
112      },
113      {
114        fillStyle: Color.ACTIVE_POINTER,
115        fill: true,
116      },
117      (x) => {
118        const input = this.getInput();
119        input.selectedPosition = x;
120        this.onPointerPositionDragging(input.transformer.untransform(x));
121      },
122      (x) => {
123        const input = this.getInput();
124        input.selectedPosition = x;
125        this.onPointerPositionChanged(input.transformer.untransform(x));
126      },
127      () => this.getUsableRange(),
128    );
129  }
130
131  getXScale() {
132    return this.ctx.getTransform().m11;
133  }
134
135  getYScale() {
136    return this.ctx.getTransform().m22;
137  }
138
139  getWidth() {
140    return this.canvas.width / this.getXScale();
141  }
142
143  getHeight() {
144    return this.canvas.height / this.getYScale();
145  }
146
147  getUsableRange() {
148    const padding = this.getPadding();
149    return {
150      from: padding.left,
151      to: this.getWidth() - padding.left - padding.right,
152    };
153  }
154
155  getInput(): MiniCanvasDrawerData {
156    return this.inputGetter().transform(this.getUsableRange());
157  }
158
159  getClickRange(clickPos: Point) {
160    const markerHeight = this.getMarkerHeight();
161    if (clickPos.y > markerHeight) {
162      return {
163        from: clickPos.x - MiniTimelineDrawerImpl.MARKER_CLICK_REGION_WIDTH,
164        to: clickPos.x + MiniTimelineDrawerImpl.MARKER_CLICK_REGION_WIDTH,
165      };
166    }
167    const markerMaxWidth = this.getMarkerMaxWidth();
168    return {
169      from: clickPos.x - markerMaxWidth,
170      to: clickPos.x + markerMaxWidth,
171    };
172  }
173
174  getSelectedPosition() {
175    return this.getInput().selectedPosition;
176  }
177
178  getBookmarks(): number[] {
179    return this.getInput().bookmarks;
180  }
181
182  async getTimelineTraces(): Promise<TimelineTraces> {
183    return await this.getInput().getTimelineTraces();
184  }
185
186  getPadding(): Padding {
187    const height = this.getHeight();
188    const pointerWidth = this.getPointerWidth();
189    return {
190      top: Math.ceil(height / 10),
191      bottom: Math.ceil(height / 10),
192      left: Math.ceil(pointerWidth / 2),
193      right: Math.ceil(pointerWidth / 2),
194    };
195  }
196
197  getInnerHeight() {
198    const padding = this.getPadding();
199    return this.getHeight() - padding.top - padding.bottom;
200  }
201
202  async draw() {
203    this.ctx.clearRect(0, 0, this.getWidth(), this.getHeight());
204    await this.drawTraceLines();
205    this.drawBookmarks();
206    this.activePointer.draw(this.ctx);
207    this.drawHoverCursor();
208  }
209
210  async updateHover(mousePoint: Point | undefined) {
211    this.lastMousePoint = mousePoint;
212    await this.draw();
213  }
214
215  async getTraceClicked(mousePoint: Point): Promise<Trace<object> | undefined> {
216    const timelineTraces = await this.getTimelineTraces();
217    const innerHeight = this.getInnerHeight();
218    const lineHeight = this.getLineHeight(timelineTraces, innerHeight);
219    let fromTop = this.getPadding().top + innerHeight - lineHeight;
220
221    for (const trace of timelineTraces.keys()) {
222      if (
223        this.pointWithinTimeline(mousePoint.y, fromTop, fromTop + lineHeight)
224      ) {
225        return trace;
226      }
227      fromTop -= this.fromTopStep(lineHeight);
228    }
229
230    return undefined;
231  }
232
233  private getPointerWidth() {
234    return this.getHeight() / 6;
235  }
236
237  private getMarkerMaxWidth() {
238    return (this.getPointerWidth() * 2) / 3;
239  }
240
241  private getMarkerHeight() {
242    return this.getPointerWidth() / 2;
243  }
244
245  private async drawTraceLines() {
246    const timelineTraces = await this.getTimelineTraces();
247    const innerHeight = this.getInnerHeight();
248    const lineHeight = this.getLineHeight(timelineTraces, innerHeight);
249    let fromTop = this.getPadding().top + innerHeight - lineHeight;
250
251    timelineTraces.forEach((timelineTrace, trace) => {
252      if (this.inputGetter().timelineData.getActiveTrace() === trace) {
253        this.fillActiveTimelineBackground(fromTop, lineHeight);
254      } else if (
255        this.lastMousePoint?.y &&
256        this.pointWithinTimeline(this.lastMousePoint?.y, fromTop, lineHeight)
257      ) {
258        this.fillHoverTimelineBackground(fromTop, lineHeight);
259      }
260
261      this.drawTraceEntries(trace, timelineTrace, fromTop, lineHeight);
262
263      fromTop -= this.fromTopStep(lineHeight);
264    });
265  }
266
267  private drawTraceEntries(
268    trace: Trace<object>,
269    timelineTrace: TimelineTrace,
270    fromTop: number,
271    lineHeight: number,
272  ) {
273    this.ctx.globalAlpha = MiniTimelineDrawerImpl.TRACE_ENTRY_ALPHA;
274    this.ctx.fillStyle = TRACE_INFO[trace.type].color;
275    this.ctx.strokeStyle = 'blue';
276
277    for (const entry of timelineTrace.points) {
278      const width = 5;
279      this.ctx.fillRect(entry - width / 2, fromTop, width, lineHeight);
280    }
281
282    for (const entry of timelineTrace.segments) {
283      this.drawTransitionEntry(
284        entry,
285        fromTop,
286        TRACE_INFO[trace.type].color,
287        lineHeight,
288      );
289    }
290
291    this.ctx.fillStyle = Color.ACTIVE_POINTER;
292    if (timelineTrace.activePoint) {
293      const entry = timelineTrace.activePoint;
294      const width = 5;
295      this.ctx.fillRect(entry - width / 2, fromTop, width, lineHeight);
296    }
297
298    if (timelineTrace.activeSegment) {
299      this.drawTransitionEntry(
300        timelineTrace.activeSegment,
301        fromTop,
302        Color.ACTIVE_POINTER,
303        lineHeight,
304      );
305    }
306
307    this.ctx.globalAlpha = 1.0;
308  }
309
310  private drawTransitionEntry(
311    entry: Segment,
312    fromTop: number,
313    hexColor: string,
314    lineHeight: number,
315  ) {
316    const width = Math.max(entry.to - entry.from, 3);
317
318    if (!(entry.unknownStart || entry.unknownEnd)) {
319      this.ctx.globalAlpha = MiniTimelineDrawerImpl.TRACE_ENTRY_ALPHA;
320      this.ctx.fillStyle = hexColor;
321      this.ctx.fillRect(entry.from, fromTop, width, lineHeight);
322      return;
323    }
324
325    const rgbColor = TimelineUtils.convertHexToRgb(hexColor);
326    if (rgbColor === undefined) {
327      throw new Error('Failed to parse provided hex color');
328    }
329    const {r, g, b} = rgbColor;
330    const rgbaColor = `rgba(${r},${g},${b},${MiniTimelineDrawerImpl.TRACE_ENTRY_ALPHA})`;
331    const transparentColor = `rgba(${r},${g},${b},${0})`;
332
333    const gradientWidthOutsideEntry = 12;
334    const gradientWidthInsideEntry = Math.min(6, width);
335
336    const startGradientx0 = entry.from - gradientWidthOutsideEntry;
337    const endGradientx1 = entry.to + gradientWidthOutsideEntry;
338
339    const start = entry.unknownStart ? startGradientx0 : entry.from;
340    const end = entry.unknownEnd ? endGradientx1 : entry.to;
341
342    const gradient = this.ctx.createLinearGradient(start, 0, end, 0);
343    const gradientRatio = Math.max(
344      0,
345      Math.min(
346        (gradientWidthOutsideEntry + gradientWidthInsideEntry) / (end - start),
347        1,
348      ),
349    );
350    gradient.addColorStop(0, entry.unknownStart ? transparentColor : rgbaColor);
351    gradient.addColorStop(1, entry.unknownEnd ? transparentColor : rgbaColor);
352    gradient.addColorStop(gradientRatio, rgbaColor);
353    gradient.addColorStop(1 - gradientRatio, rgbaColor);
354    this.ctx.fillStyle = gradient;
355
356    this.ctx.globalAlpha = 1;
357    this.ctx.fillRect(start, fromTop, end - start, lineHeight);
358
359    if (entry.unknownStart) {
360      this.drawEllipsis(entry.from - 8.5, fromTop, lineHeight);
361    }
362    if (entry.unknownEnd) {
363      this.drawEllipsis(entry.from + width + 1.5, fromTop, lineHeight);
364    }
365  }
366
367  private getEllipsisColor() {
368    return this.inputGetter().isDarkMode ? 'white' : 'black';
369  }
370
371  private drawEllipsis(start: number, fromTop: number, lineHeight: number) {
372    this.ctx.fillStyle = this.getEllipsisColor();
373    let center = start;
374    for (let i = 0; i < 3; i++) {
375      this.ctx.beginPath();
376      this.ctx.arc(center, fromTop + lineHeight / 2, 1, 0, 2 * Math.PI);
377      this.ctx.fill();
378      center += 3.5;
379    }
380  }
381
382  private drawHoverCursor() {
383    if (!this.lastMousePoint) {
384      return;
385    }
386    const hoverWidth = 2;
387    this.ctx.beginPath();
388    this.ctx.moveTo(this.lastMousePoint.x - hoverWidth / 2, 0);
389    this.ctx.lineTo(this.lastMousePoint.x + hoverWidth / 2, 0);
390    this.ctx.lineTo(this.lastMousePoint.x + hoverWidth / 2, this.getHeight());
391    this.ctx.lineTo(this.lastMousePoint.x - hoverWidth / 2, this.getHeight());
392    this.ctx.closePath();
393
394    this.ctx.globalAlpha = 0.4;
395    this.ctx.fillStyle = Color.ACTIVE_POINTER;
396    this.ctx.fill();
397    this.ctx.globalAlpha = 1.0;
398  }
399
400  private drawBookmarks() {
401    this.getBookmarks().forEach((position) => {
402      const flagWidth = this.getMarkerMaxWidth();
403      const flagHeight = this.getMarkerHeight();
404      const barWidth = 2;
405
406      this.ctx.beginPath();
407      this.ctx.moveTo(position - barWidth / 2, 0);
408      this.ctx.lineTo(position + flagWidth, 0);
409      this.ctx.lineTo(position + (flagWidth * 5) / 6, flagHeight / 2);
410      this.ctx.lineTo(position + flagWidth, flagHeight);
411      this.ctx.lineTo(position + barWidth / 2, flagHeight);
412      this.ctx.lineTo(position + barWidth / 2, this.getHeight());
413      this.ctx.lineTo(position - barWidth / 2, this.getHeight());
414      this.ctx.closePath();
415
416      this.ctx.fillStyle = Color.BOOKMARK;
417      this.ctx.fill();
418    });
419  }
420
421  private fromTopStep(lineHeight: number): number {
422    return (lineHeight * 4) / 3;
423  }
424
425  private fillActiveTimelineBackground(fromTop: number, lineHeight: number) {
426    this.ctx.globalAlpha = 1.0;
427    this.ctx.fillStyle = getComputedStyle(this.canvas).getPropertyValue(
428      '--selected-element-color',
429    );
430    this.ctx.fillRect(0, fromTop, this.getUsableRange().to, lineHeight);
431  }
432
433  private fillHoverTimelineBackground(fromTop: number, lineHeight: number) {
434    this.ctx.globalAlpha = 1.0;
435    this.ctx.fillStyle = getComputedStyle(this.canvas).getPropertyValue(
436      '--hover-element-color',
437    );
438    this.ctx.fillRect(0, fromTop, this.getUsableRange().to, lineHeight);
439  }
440
441  private getLineHeight(
442    timelineTraces: TimelineTraces,
443    innerHeight: number,
444  ): number {
445    return innerHeight / (Math.max(timelineTraces.size - 10, 0) + 12);
446  }
447
448  private pointWithinTimeline(
449    point: number,
450    from: number,
451    to: number,
452  ): boolean {
453    return point > from && point <= from + to;
454  }
455}
456