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