go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/components/timeline/lit_timeline.ts (about) 1 // Copyright 2022 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import { MobxLitElement } from '@adobe/lit-mobx'; 16 import { 17 axisBottom, 18 axisLeft, 19 axisTop, 20 BaseType, 21 scaleLinear, 22 scaleTime, 23 select as d3Select, 24 Selection, 25 timeMillisecond, 26 } from 'd3'; 27 import { css, html, render } from 'lit'; 28 import { customElement } from 'lit/decorators.js'; 29 import { DateTime } from 'luxon'; 30 import { makeObservable, observable } from 'mobx'; 31 32 import { 33 HideTooltipEventDetail, 34 ShowTooltipEventDetail, 35 } from '@/common/components/tooltip'; 36 import { PREDEFINED_TIME_INTERVALS } from '@/common/constants/time'; 37 import { commonStyles } from '@/common/styles/stylesheets'; 38 import { 39 displayDuration, 40 NUMERIC_TIME_FORMAT, 41 } from '@/common/tools/time_utils'; 42 import { enumerate } from '@/generic_libs/tools/iter_utils'; 43 import { roundDown } from '@/generic_libs/tools/utils'; 44 45 const TOP_AXIS_HEIGHT = 35; 46 const BOTTOM_AXIS_HEIGHT = 25; 47 const BORDER_SIZE = 1; 48 const HALF_BORDER_SIZE = BORDER_SIZE / 2; 49 50 const ROW_HEIGHT = 30; 51 const BLOCK_HEIGHT = 24; 52 const BLOCK_MARGIN = (ROW_HEIGHT - BLOCK_HEIGHT) / 2 - HALF_BORDER_SIZE; 53 const BLOCK_EXTRA_WIDTH = 2; 54 55 const TEXT_HEIGHT = 10; 56 const INV_TEXT_OFFSET = ROW_HEIGHT / 2 + TEXT_HEIGHT / 2; 57 const TEXT_MARGIN = 10; 58 59 const SIDE_PANEL_WIDTH = 400; 60 const SIDE_PANEL_RECT_WIDTH = 61 SIDE_PANEL_WIDTH - BLOCK_MARGIN * 2 - BORDER_SIZE * 2; 62 const MIN_GRAPH_WIDTH = 500 + SIDE_PANEL_WIDTH; 63 64 const LIST_ITEM_WIDTH = SIDE_PANEL_RECT_WIDTH - TEXT_MARGIN * 2; 65 const LIST_ITEM_HEIGHT = 16; 66 const LIST_ITEM_X_OFFSET = BLOCK_MARGIN + TEXT_MARGIN + BORDER_SIZE; 67 const LIST_ITEM_Y_OFFSET = BLOCK_MARGIN + (BLOCK_HEIGHT - LIST_ITEM_HEIGHT) / 2; 68 69 const V_GRID_LINE_MAX_GAP = 80; 70 71 export interface TimelineBlock { 72 start?: DateTime; 73 end?: DateTime; 74 text: string; 75 href?: string; 76 } 77 78 @customElement('milo-timeline') 79 export class TimelineElement extends MobxLitElement { 80 // The time to start the timeline. 81 // Blocks that are not between startTime and endTime will be rendered as a blank row. 82 @observable.ref startTime = DateTime.now(); 83 // Label for the start time rendered in the timeline header. 84 @observable.ref startTimeLabel = 'Start Time'; 85 // The time to end the timeline. 86 // Blocks that are not between startTime and endTime will be rendered as a blank row. 87 @observable.ref endTime = DateTime.now(); 88 // Label for the end time rendered in the timeline header. 89 @observable.ref endTimeLabel = 'End Time'; 90 // The blocks of time to render on the timeline. 91 @observable.ref blocks: TimelineBlock[] = []; 92 93 @observable.ref private width = MIN_GRAPH_WIDTH; 94 95 private headerEle!: HTMLDivElement; 96 private footerEle!: HTMLDivElement; 97 private sidePanelEle!: HTMLDivElement; 98 private bodyEle!: HTMLDivElement; 99 private relativeTimeText!: Selection< 100 SVGTextElement, 101 unknown, 102 null, 103 undefined 104 >; 105 106 // Properties shared between render methods. 107 private bodyHeight!: number; 108 private scaleTime!: d3.ScaleTime<number, number, never>; 109 private scaleStep!: d3.ScaleLinear<number, number, never>; 110 private timeInterval!: d3.TimeInterval; 111 // prevTimeout is the id of the timeout set in the previous render call. If it is null there is no pending timeout. 112 private prevTimeout: number | null = null; 113 114 constructor() { 115 super(); 116 makeObservable(this); 117 } 118 119 render() { 120 const startTime = this.startTime.toMillis(); 121 const endTime = this.endTime.toMillis(); 122 this.bodyHeight = this.blocks.length * ROW_HEIGHT - BORDER_SIZE; 123 const bodyWidth = this.width - SIDE_PANEL_WIDTH; 124 const padding = 125 Math.ceil(((endTime - startTime) * BLOCK_EXTRA_WIDTH) / bodyWidth) / 2; 126 127 // Calc attributes shared among components. 128 this.scaleTime = scaleTime() 129 // Add a bit of padding to ensure everything renders in the viewport. 130 .domain([startTime - padding, endTime + padding]) 131 // Ensure the right border is rendered within the viewport, while the left 132 // border overlaps with the right border of the side-panel. 133 .range([-HALF_BORDER_SIZE, bodyWidth - HALF_BORDER_SIZE]); 134 this.scaleStep = scaleLinear() 135 .domain([0, this.blocks.length]) 136 // Ensure the top and bottom borders are not rendered. 137 .range([-HALF_BORDER_SIZE, this.bodyHeight + HALF_BORDER_SIZE]); 138 139 const maxInterval = 140 (endTime - startTime + 2 * padding) / (bodyWidth / V_GRID_LINE_MAX_GAP); 141 142 this.timeInterval = timeMillisecond.every( 143 roundDown(maxInterval, PREDEFINED_TIME_INTERVALS), 144 )!; 145 146 // Render each component. 147 this.renderHeader(); 148 this.renderFooter(); 149 this.renderSidePanel(); 150 this.renderBody(); 151 152 return html`<div id="timeline"> 153 ${this.sidePanelEle}${this.headerEle}${this.bodyEle}${this.footerEle} 154 </div>`; 155 } 156 157 private renderHeader() { 158 this.headerEle = document.createElement('div'); 159 const svg = d3Select(this.headerEle) 160 .attr('id', 'header') 161 .append('svg') 162 .attr('viewport', `0 0 ${this.width} ${TOP_AXIS_HEIGHT}`); 163 164 if (this.startTimeLabel) { 165 svg 166 .append('text') 167 .attr('x', TEXT_MARGIN) 168 .attr('y', TOP_AXIS_HEIGHT - TEXT_MARGIN / 2) 169 .attr('font-weight', '500') 170 .text( 171 `${this.startTimeLabel}: ${this.startTime.toFormat( 172 NUMERIC_TIME_FORMAT, 173 )}`, 174 ); 175 } 176 177 const headerRootGroup = svg 178 .append('g') 179 .attr( 180 'transform', 181 `translate(${SIDE_PANEL_WIDTH}, ${TOP_AXIS_HEIGHT - HALF_BORDER_SIZE})`, 182 ); 183 const topAxis = axisTop(this.scaleTime).ticks(this.timeInterval); 184 headerRootGroup.call(topAxis); 185 186 this.relativeTimeText = headerRootGroup 187 .append('text') 188 .style('opacity', 0) 189 .attr('id', 'relative-time') 190 .attr('fill', 'red') 191 .attr('y', -TEXT_HEIGHT - TEXT_MARGIN) 192 .attr('text-anchor', 'end'); 193 194 // Top border for the side panel. 195 headerRootGroup 196 .append('line') 197 .attr('x1', -SIDE_PANEL_WIDTH) 198 .attr('stroke', 'var(--default-text-color)'); 199 } 200 201 private renderFooter() { 202 this.footerEle = document.createElement('div'); 203 const svg = d3Select(this.footerEle) 204 .attr('id', 'footer') 205 .append('svg') 206 .attr('viewport', `0 0 ${this.width} ${BOTTOM_AXIS_HEIGHT}`); 207 208 if (this.endTime) { 209 svg 210 .append('text') 211 .attr('x', TEXT_MARGIN) 212 .attr('y', TEXT_HEIGHT + TEXT_MARGIN / 2) 213 .attr('font-weight', '500') 214 .text( 215 `${this.endTimeLabel}: ${this.endTime.toFormat(NUMERIC_TIME_FORMAT)}`, 216 ); 217 } 218 219 const footerRootGroup = svg 220 .append('g') 221 .attr('transform', `translate(${SIDE_PANEL_WIDTH}, ${HALF_BORDER_SIZE})`); 222 const bottomAxis = axisBottom(this.scaleTime).ticks(this.timeInterval); 223 footerRootGroup.call(bottomAxis); 224 225 // Bottom border for the side panel. 226 footerRootGroup 227 .append('line') 228 .attr('x1', -SIDE_PANEL_WIDTH) 229 .attr('stroke', 'var(--default-text-color)'); 230 } 231 232 private renderSidePanel() { 233 this.sidePanelEle = document.createElement('div'); 234 const svg = d3Select(this.sidePanelEle) 235 .style('width', SIDE_PANEL_WIDTH + 'px') 236 .style('height', this.bodyHeight + 'px') 237 .attr('id', 'side-panel') 238 .append('svg') 239 .attr('viewport', `0 0 ${SIDE_PANEL_WIDTH} ${this.bodyHeight}`); 240 241 // Grid lines 242 const horizontalGridLines = axisLeft(this.scaleStep) 243 .ticks(this.blocks.length) 244 .tickFormat(() => '') 245 .tickSize(-SIDE_PANEL_WIDTH) 246 .tickFormat(() => ''); 247 svg.append('g').attr('class', 'grid').call(horizontalGridLines); 248 249 for (const [i, block] of enumerate(this.blocks)) { 250 const blockGroup = svg 251 .append('g') 252 .attr('class', block.end ? 'started' : 'success') 253 .attr('transform', `translate(0, ${i * ROW_HEIGHT})`); 254 255 const rect = blockGroup 256 .append('rect') 257 .attr('x', BLOCK_MARGIN + BORDER_SIZE) 258 .attr('y', BLOCK_MARGIN) 259 .attr('width', SIDE_PANEL_RECT_WIDTH) 260 .attr('height', BLOCK_HEIGHT); 261 this.installBlockInteractionHandlers(rect, block); 262 263 const listItem = blockGroup 264 .append('foreignObject') 265 .attr('class', 'not-intractable') 266 .attr('x', LIST_ITEM_X_OFFSET) 267 .attr('y', LIST_ITEM_Y_OFFSET) 268 .attr('height', BLOCK_HEIGHT - LIST_ITEM_Y_OFFSET) 269 .attr('width', LIST_ITEM_WIDTH); 270 const blockText = listItem.append('xhtml:span').text(block.text); 271 272 blockText.attr('class', (block.href ? 'hyperlink' : '') + ' nowrap'); 273 } 274 275 // Left border. 276 svg 277 .append('line') 278 .attr('x1', HALF_BORDER_SIZE) 279 .attr('x2', HALF_BORDER_SIZE) 280 .attr('y2', this.bodyHeight) 281 .attr('stroke', 'var(--default-text-color)'); 282 // Right border. 283 svg 284 .append('line') 285 .attr('x1', SIDE_PANEL_WIDTH - HALF_BORDER_SIZE) 286 .attr('x2', SIDE_PANEL_WIDTH - HALF_BORDER_SIZE) 287 .attr('y2', this.bodyHeight) 288 .attr('stroke', 'var(--default-text-color)'); 289 } 290 291 private renderBody() { 292 const bodyWidth = this.width - SIDE_PANEL_WIDTH; 293 this.bodyEle = document.createElement('div'); 294 const svg = d3Select(this.bodyEle) 295 .attr('id', 'body') 296 .style('width', bodyWidth + 'px') 297 .style('height', this.bodyHeight + 'px') 298 .append('svg') 299 .attr('viewport', `0 0 ${bodyWidth} ${this.bodyHeight}`); 300 301 // Grid lines 302 const verticalGridLines = axisTop(this.scaleTime) 303 .ticks(this.timeInterval) 304 .tickSize(-this.bodyHeight) 305 .tickFormat(() => ''); 306 svg.append('g').attr('class', 'grid').call(verticalGridLines); 307 const horizontalGridLines = axisLeft(this.scaleStep) 308 .ticks(this.blocks.length) 309 .tickFormat(() => '') 310 .tickSize(-bodyWidth) 311 .tickFormat(() => ''); 312 svg.append('g').attr('class', 'grid').call(horizontalGridLines); 313 314 for (const [i, block] of enumerate(this.blocks)) { 315 const start = this.scaleTime( 316 block.start?.toMillis() || this.endTime.toMillis(), 317 ); 318 const end = this.scaleTime( 319 block.end?.toMillis() || this.endTime.toMillis(), 320 ); 321 322 const blockGroup = svg 323 .append('g') 324 .attr('class', block.end ? 'started' : 'success') 325 .attr('transform', `translate(${start}, ${i * ROW_HEIGHT})`); 326 327 // Add extra width so tiny blocks are visible. 328 const width = end - start + BLOCK_EXTRA_WIDTH; 329 330 blockGroup 331 .append('rect') 332 .attr('x', -BLOCK_EXTRA_WIDTH / 2) 333 .attr('y', BLOCK_MARGIN) 334 .attr('width', width) 335 .attr('height', BLOCK_HEIGHT); 336 337 const blockText = blockGroup 338 .append('text') 339 .attr('text-anchor', 'start') 340 .attr('x', TEXT_MARGIN) 341 .attr('y', INV_TEXT_OFFSET) 342 .text(block.text); 343 344 // Wail until the next event cycle so blockText is rendered when we call 345 // this.getBBox(); 346 347 // Cancel timeouts for previous renders so we don't do useless page layouts. 348 if (this.prevTimeout) { 349 clearTimeout(this.prevTimeout); 350 } 351 this.prevTimeout = window.setTimeout(() => { 352 this.prevTimeout = null; 353 // eslint-disable-next-line @typescript-eslint/no-this-alias 354 const self = this; 355 blockText.each(function () { 356 // This is the standard d3 API. 357 // eslint-disable-next-line no-invalid-this 358 const textBBox = this.getBBox(); 359 const x1 = Math.min(textBBox.x, -BLOCK_EXTRA_WIDTH / 2); 360 const x2 = Math.max( 361 textBBox.x + textBBox.width, 362 BLOCK_MARGIN + width, 363 ); 364 365 // This makes the inv text easier to interact with. 366 const eventTargetRect = blockGroup 367 .append('rect') 368 .attr('x', x1) 369 .attr('y', BLOCK_MARGIN) 370 .attr('width', x2 - x1) 371 .attr('height', BLOCK_HEIGHT) 372 .attr('class', 'invisible'); 373 374 self.installBlockInteractionHandlers(eventTargetRect, block); 375 }); 376 }, 10); 377 } 378 379 const yRuler = svg 380 .append('line') 381 .style('opacity', 0) 382 .attr('stroke', 'red') 383 .attr('pointer-events', 'none') 384 .attr('y1', 0) 385 .attr('y2', this.bodyHeight); 386 387 let svgBox: DOMRect | null = null; 388 svg.on('mouseover', () => { 389 this.relativeTimeText.style('opacity', 1); 390 yRuler.style('opacity', 1); 391 }); 392 svg.on('mouseout', () => { 393 this.relativeTimeText.style('opacity', 0); 394 yRuler.style('opacity', 0); 395 }); 396 svg.on('mousemove', (e: MouseEvent) => { 397 if (svgBox === null) { 398 svgBox = svg.node()!.getBoundingClientRect(); 399 } 400 const x = e.pageX - svgBox.x; 401 402 yRuler.attr('x1', x); 403 yRuler.attr('x2', x); 404 405 const time = DateTime.fromJSDate(this.scaleTime.invert(x)); 406 const duration = time.diff(this.startTime!); 407 this.relativeTimeText.attr('x', x); 408 this.relativeTimeText.text(displayDuration(duration) + ' since start'); 409 }); 410 411 // Right border. 412 svg 413 .append('line') 414 .attr('x1', bodyWidth - HALF_BORDER_SIZE) 415 .attr('x2', bodyWidth - HALF_BORDER_SIZE) 416 .attr('y2', this.bodyHeight) 417 .attr('stroke', 'var(--default-text-color)'); 418 } 419 420 /** 421 * Installs handlers for interacting with a inv object. 422 */ 423 private installBlockInteractionHandlers<T extends BaseType>( 424 ele: Selection<T, unknown, null, undefined>, 425 block: TimelineBlock, 426 ) { 427 if (block.href) { 428 ele 429 .attr('class', ele.attr('class') + ' clickable') 430 .on('click', (e: MouseEvent) => { 431 e.stopPropagation(); 432 window.open(block.href, '_blank'); 433 }); 434 } 435 436 ele 437 .on('mouseover', (e: MouseEvent) => { 438 const tooltip = document.createElement('div'); 439 render( 440 html` 441 <table> 442 <tr> 443 <td colspan="2">Click to open invocation.</td> 444 </tr> 445 <tr> 446 <td>Started:</td> 447 <td> 448 ${(block.start || this.endTime).toFormat( 449 NUMERIC_TIME_FORMAT, 450 )} 451 (after ${displayDuration( 452 (block.start || this.endTime).diff(this.startTime), 453 )}) 454 </td> 455 </tr> 456 <tr> 457 <td>Ended:</td> 458 <td> 459 ${ 460 block.end 461 ? block.end.toFormat(NUMERIC_TIME_FORMAT) + 462 ` (after ${displayDuration( 463 block.end.diff(this.startTime), 464 )})` 465 : 'N/A' 466 }</td> 467 </tr> 468 <tr> 469 <td>Duration:</td> 470 <td>${displayDuration( 471 (block.end || this.endTime).diff(this.startTime), 472 )}</td> 473 </tr> 474 </div> 475 `, 476 tooltip, 477 ); 478 479 window.dispatchEvent( 480 new CustomEvent<ShowTooltipEventDetail>('show-tooltip', { 481 detail: { 482 tooltip, 483 targetRect: (e.target as HTMLElement).getBoundingClientRect(), 484 gapSize: 5, 485 }, 486 }), 487 ); 488 }) 489 .on('mouseout', () => { 490 window.dispatchEvent( 491 new CustomEvent<HideTooltipEventDetail>('hide-tooltip', { 492 detail: { delay: 0 }, 493 }), 494 ); 495 }); 496 } 497 static styles = [ 498 commonStyles, 499 css` 500 #timeline { 501 display: grid; 502 grid-template-rows: ${TOP_AXIS_HEIGHT}px 1fr ${BOTTOM_AXIS_HEIGHT}px; 503 grid-template-columns: ${SIDE_PANEL_WIDTH}px 1fr; 504 grid-template-areas: 505 'header header' 506 'side-panel body' 507 'footer footer'; 508 } 509 510 #header { 511 grid-area: header; 512 position: sticky; 513 top: 0; 514 background: white; 515 z-index: 2; 516 } 517 518 #footer { 519 grid-area: footer; 520 position: sticky; 521 bottom: 0; 522 background: white; 523 z-index: 2; 524 } 525 526 #side-panel { 527 grid-area: side-panel; 528 z-index: 1; 529 font-weight: 500; 530 } 531 532 #body { 533 grid-area: body; 534 } 535 536 #body path.domain { 537 stroke: none; 538 } 539 540 svg { 541 width: 100%; 542 height: 100%; 543 } 544 545 text { 546 fill: var(--default-text-color); 547 } 548 549 #relative-time { 550 fill: red; 551 } 552 553 .grid line { 554 stroke: var(--divider-color); 555 } 556 557 .clickable { 558 cursor: pointer; 559 } 560 .not-intractable { 561 pointer-events: none; 562 } 563 .hyperlink { 564 text-decoration: underline; 565 } 566 .nowrap { 567 white-space: nowrap; 568 overflow: hidden; 569 text-overflow: ellipsis; 570 } 571 572 .scheduled > rect { 573 stroke: var(--scheduled-color); 574 fill: var(--scheduled-bg-color); 575 } 576 .started > rect { 577 stroke: var(--started-color); 578 fill: var(--started-bg-color); 579 } 580 .success > rect { 581 stroke: var(--success-color); 582 fill: var(--success-bg-color); 583 } 584 .failure > rect { 585 stroke: var(--failure-color); 586 fill: var(--failure-bg-color); 587 } 588 .infra-failure > rect { 589 stroke: var(--critical-failure-color); 590 fill: var(--critical-failure-bg-color); 591 } 592 .canceled > rect { 593 stroke: var(--canceled-color); 594 fill: var(--canceled-bg-color); 595 } 596 597 .invisible { 598 opacity: 0; 599 } 600 `, 601 ]; 602 }