1// Copyright (C) 2023 The Android Open Source Project 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 15import m from 'mithril'; 16import {searchSegment} from '../../base/binary_search'; 17import {assertTrue, assertUnreachable} from '../../base/logging'; 18import {Time, time} from '../../base/time'; 19import {uuidv4Sql} from '../../base/uuid'; 20import {drawTrackHoverTooltip} from '../../base/canvas_utils'; 21import {raf} from '../../core/raf_scheduler'; 22import {CacheKey} from './timeline_cache'; 23import {Track, TrackMouseEvent, TrackRenderContext} from '../../public/track'; 24import {Button} from '../../widgets/button'; 25import {MenuDivider, MenuItem, PopupMenu2} from '../../widgets/menu'; 26import {LONG, NUM} from '../../trace_processor/query_result'; 27import {checkerboardExcept} from '../checkerboard'; 28import {AsyncDisposableStack} from '../../base/disposable_stack'; 29import {Trace} from '../../public/trace'; 30 31function roundAway(n: number): number { 32 const exp = Math.ceil(Math.log10(Math.max(Math.abs(n), 1))); 33 const pow10 = Math.pow(10, exp); 34 return Math.sign(n) * (Math.ceil(Math.abs(n) / (pow10 / 20)) * (pow10 / 20)); 35} 36 37function toLabel(n: number): string { 38 if (n === 0) { 39 return '0'; 40 } 41 const units: [number, string][] = [ 42 [0.000000001, 'n'], 43 [0.000001, 'u'], 44 [0.001, 'm'], 45 [1, ''], 46 [1000, 'K'], 47 [1000 * 1000, 'M'], 48 [1000 * 1000 * 1000, 'G'], 49 [1000 * 1000 * 1000 * 1000, 'T'], 50 ]; 51 let largestMultiplier; 52 let largestUnit; 53 [largestMultiplier, largestUnit] = units[0]; 54 const absN = Math.abs(n); 55 for (const [multiplier, unit] of units) { 56 if (multiplier > absN) { 57 break; 58 } 59 [largestMultiplier, largestUnit] = [multiplier, unit]; 60 } 61 return `${Math.round(n / largestMultiplier)}${largestUnit}`; 62} 63 64class RangeSharer { 65 static singleton?: RangeSharer; 66 67 static get(): RangeSharer { 68 if (RangeSharer.singleton === undefined) { 69 RangeSharer.singleton = new RangeSharer(); 70 } 71 return RangeSharer.singleton; 72 } 73 74 private tagToRange: Map<string, [number, number]>; 75 private keyToEnabled: Map<string, boolean>; 76 77 constructor() { 78 this.tagToRange = new Map(); 79 this.keyToEnabled = new Map(); 80 } 81 82 isEnabled(key: string): boolean { 83 const value = this.keyToEnabled.get(key); 84 if (value === undefined) { 85 return true; 86 } 87 return value; 88 } 89 90 setEnabled(key: string, enabled: boolean): void { 91 this.keyToEnabled.set(key, enabled); 92 } 93 94 share( 95 options: CounterOptions, 96 [min, max]: [number, number], 97 ): [number, number] { 98 const key = options.yRangeSharingKey; 99 if (key === undefined || !this.isEnabled(key)) { 100 return [min, max]; 101 } 102 103 const tag = `${options.yRangeSharingKey}-${options.yMode}-${ 104 options.yDisplay 105 }-${!!options.enlarge}`; 106 const cachedRange = this.tagToRange.get(tag); 107 if (cachedRange === undefined) { 108 this.tagToRange.set(tag, [min, max]); 109 return [min, max]; 110 } 111 112 cachedRange[0] = Math.min(min, cachedRange[0]); 113 cachedRange[1] = Math.max(max, cachedRange[1]); 114 115 return [cachedRange[0], cachedRange[1]]; 116 } 117} 118 119interface CounterData { 120 timestamps: BigInt64Array; 121 minDisplayValues: Float64Array; 122 maxDisplayValues: Float64Array; 123 lastDisplayValues: Float64Array; 124 displayValueRange: [number, number]; 125} 126 127// 0.5 Makes the horizontal lines sharp. 128const MARGIN_TOP = 3.5; 129 130interface CounterLimits { 131 maxDisplayValue: number; 132 minDisplayValue: number; 133} 134 135interface CounterTooltipState { 136 lastDisplayValue: number; 137 ts: time; 138 tsEnd?: time; 139} 140 141export interface CounterOptions { 142 // Mode for computing the y value. Options are: 143 // value = v[t] directly the value of the counter at time t 144 // delta = v[t] - v[t-1] delta between value and previous value 145 // rate = (v[t] - v[t-1]) / dt as delta but normalized for time 146 yMode: 'value' | 'delta' | 'rate'; 147 148 // Whether Y scale should cover all of the possible values (and therefore, be 149 // static) or whether it should be dynamic and cover only the visible values. 150 yRange: 'all' | 'viewport'; 151 152 // Whether the Y scale should: 153 // zero = y-axis scale should cover the origin (zero) 154 // minmax = y-axis scale should cover just the range of yRange 155 // log = as minmax but also use a log scale 156 yDisplay: 'zero' | 'minmax' | 'log'; 157 158 // Whether the range boundaries should be strict and use the precise min/max 159 // values or whether they should be rounded down/up to the nearest human 160 // readable value. 161 yRangeRounding: 'strict' | 'human_readable'; 162 163 // Allows *extending* the range of the y-axis counter increasing 164 // the maximum (via yOverrideMaximum) or decreasing the minimum 165 // (via yOverrideMinimum). This is useful for percentage counters 166 // where the range (0-100) is known statically upfront and even if 167 // the trace only includes smaller values. 168 yOverrideMaximum?: number; 169 yOverrideMinimum?: number; 170 171 // If set all counters with the same key share a range. 172 yRangeSharingKey?: string; 173 174 // Show the chart as 4x the height. 175 enlarge?: boolean; 176 177 // unit for the counter. This is displayed in the tooltip and 178 // legend. 179 unit?: string; 180} 181 182export abstract class BaseCounterTrack implements Track { 183 protected trackUuid = uuidv4Sql(); 184 185 // This is the over-skirted cached bounds: 186 private countersKey: CacheKey = CacheKey.zero(); 187 188 private counters: CounterData = { 189 timestamps: new BigInt64Array(0), 190 minDisplayValues: new Float64Array(0), 191 maxDisplayValues: new Float64Array(0), 192 lastDisplayValues: new Float64Array(0), 193 displayValueRange: [0, 0], 194 }; 195 196 private limits?: CounterLimits; 197 198 private mousePos = {x: 0, y: 0}; 199 private hover?: CounterTooltipState; 200 private options?: CounterOptions; 201 202 private readonly trash: AsyncDisposableStack; 203 204 private getCounterOptions(): CounterOptions { 205 if (this.options === undefined) { 206 const options = this.getDefaultCounterOptions(); 207 for (const [key, value] of Object.entries(this.defaultOptions)) { 208 if (value !== undefined) { 209 // eslint-disable-next-line @typescript-eslint/no-explicit-any 210 (options as any)[key] = value; 211 } 212 } 213 this.options = options; 214 } 215 return this.options; 216 } 217 218 // Extension points. 219 220 // onInit hook lets you do asynchronous set up e.g. creating a table 221 // etc. We guarantee that this will be resolved before doing any 222 // queries using the result of getSqlSource(). All persistent 223 // state in trace_processor should be cleaned up when dispose is 224 // called on the returned hook. 225 async onInit(): Promise<AsyncDisposable | void> {} 226 227 // This should be an SQL expression returning the columns `ts` and `value`. 228 abstract getSqlSource(): string; 229 230 protected getDefaultCounterOptions(): CounterOptions { 231 return { 232 yRange: 'all', 233 yRangeRounding: 'human_readable', 234 yMode: 'value', 235 yDisplay: 'zero', 236 }; 237 } 238 239 constructor( 240 protected readonly trace: Trace, 241 protected readonly uri: string, 242 protected readonly defaultOptions: Partial<CounterOptions> = {}, 243 ) { 244 this.trash = new AsyncDisposableStack(); 245 } 246 247 getHeight() { 248 const height = 40; 249 return this.getCounterOptions().enlarge ? height * 4 : height; 250 } 251 252 // A method to render menu items for switching the defualt 253 // rendering options. Useful if a subclass wants to incorporate it 254 // as a submenu. 255 protected getCounterContextMenuItems(): m.Children { 256 const options = this.getCounterOptions(); 257 258 return [ 259 m( 260 MenuItem, 261 { 262 label: `Display (currently: ${options.yDisplay})`, 263 }, 264 265 m(MenuItem, { 266 label: 'Zero-based', 267 icon: 268 options.yDisplay === 'zero' 269 ? 'radio_button_checked' 270 : 'radio_button_unchecked', 271 onclick: () => { 272 options.yDisplay = 'zero'; 273 this.invalidate(); 274 }, 275 }), 276 277 m(MenuItem, { 278 label: 'Min/Max', 279 icon: 280 options.yDisplay === 'minmax' 281 ? 'radio_button_checked' 282 : 'radio_button_unchecked', 283 onclick: () => { 284 options.yDisplay = 'minmax'; 285 this.invalidate(); 286 }, 287 }), 288 289 m(MenuItem, { 290 label: 'Log', 291 icon: 292 options.yDisplay === 'log' 293 ? 'radio_button_checked' 294 : 'radio_button_unchecked', 295 onclick: () => { 296 options.yDisplay = 'log'; 297 this.invalidate(); 298 }, 299 }), 300 ), 301 302 m(MenuItem, { 303 label: 'Zoom on scroll', 304 icon: 305 options.yRange === 'viewport' 306 ? 'check_box' 307 : 'check_box_outline_blank', 308 onclick: () => { 309 options.yRange = options.yRange === 'viewport' ? 'all' : 'viewport'; 310 this.invalidate(); 311 }, 312 }), 313 314 m(MenuItem, { 315 label: `Enlarge`, 316 icon: options.enlarge ? 'check_box' : 'check_box_outline_blank', 317 onclick: () => { 318 options.enlarge = !options.enlarge; 319 this.invalidate(); 320 }, 321 }), 322 323 options.yRangeSharingKey && 324 m(MenuItem, { 325 label: `Share y-axis scale (group: ${options.yRangeSharingKey})`, 326 icon: RangeSharer.get().isEnabled(options.yRangeSharingKey) 327 ? 'check_box' 328 : 'check_box_outline_blank', 329 onclick: () => { 330 const key = options.yRangeSharingKey; 331 if (key === undefined) { 332 return; 333 } 334 const sharer = RangeSharer.get(); 335 sharer.setEnabled(key, !sharer.isEnabled(key)); 336 this.invalidate(); 337 }, 338 }), 339 340 m(MenuDivider), 341 m( 342 MenuItem, 343 { 344 label: `Mode (currently: ${options.yMode})`, 345 }, 346 347 m(MenuItem, { 348 label: 'Value', 349 icon: 350 options.yMode === 'value' 351 ? 'radio_button_checked' 352 : 'radio_button_unchecked', 353 onclick: () => { 354 options.yMode = 'value'; 355 this.invalidate(); 356 }, 357 }), 358 359 m(MenuItem, { 360 label: 'Delta', 361 icon: 362 options.yMode === 'delta' 363 ? 'radio_button_checked' 364 : 'radio_button_unchecked', 365 onclick: () => { 366 options.yMode = 'delta'; 367 this.invalidate(); 368 }, 369 }), 370 371 m(MenuItem, { 372 label: 'Rate', 373 icon: 374 options.yMode === 'rate' 375 ? 'radio_button_checked' 376 : 'radio_button_unchecked', 377 onclick: () => { 378 options.yMode = 'rate'; 379 this.invalidate(); 380 }, 381 }), 382 ), 383 m(MenuItem, { 384 label: 'Round y-axis scale', 385 icon: 386 options.yRangeRounding === 'human_readable' 387 ? 'check_box' 388 : 'check_box_outline_blank', 389 onclick: () => { 390 options.yRangeRounding = 391 options.yRangeRounding === 'human_readable' 392 ? 'strict' 393 : 'human_readable'; 394 this.invalidate(); 395 }, 396 }), 397 ]; 398 } 399 400 protected invalidate() { 401 this.limits = undefined; 402 this.countersKey = CacheKey.zero(); 403 this.counters = { 404 timestamps: new BigInt64Array(0), 405 minDisplayValues: new Float64Array(0), 406 maxDisplayValues: new Float64Array(0), 407 lastDisplayValues: new Float64Array(0), 408 displayValueRange: [0, 0], 409 }; 410 this.hover = undefined; 411 412 raf.scheduleFullRedraw(); 413 } 414 415 // A method to render a context menu corresponding to switching the rendering 416 // modes. By default, getTrackShellButtons renders it, but a subclass can call 417 // it manually, if they want to customise rendering track buttons. 418 protected getCounterContextMenu(): m.Child { 419 return m( 420 PopupMenu2, 421 { 422 trigger: m(Button, {icon: 'show_chart', compact: true}), 423 }, 424 this.getCounterContextMenuItems(), 425 ); 426 } 427 428 getTrackShellButtons(): m.Children { 429 return this.getCounterContextMenu(); 430 } 431 432 async onCreate(): Promise<void> { 433 const result = await this.onInit(); 434 result && this.trash.use(result); 435 this.limits = await this.createTableAndFetchLimits(false); 436 } 437 438 async onUpdate({visibleWindow, size}: TrackRenderContext): Promise<void> { 439 const windowSizePx = Math.max(1, size.width); 440 const timespan = visibleWindow.toTimeSpan(); 441 const rawCountersKey = CacheKey.create( 442 timespan.start, 443 timespan.end, 444 windowSizePx, 445 ); 446 447 // If the visible time range is outside the cached area, requests 448 // asynchronously new data from the SQL engine. 449 await this.maybeRequestData(rawCountersKey); 450 } 451 452 render({ctx, size, timescale}: TrackRenderContext): void { 453 // In any case, draw whatever we have (which might be stale/incomplete). 454 const limits = this.limits; 455 const data = this.counters; 456 457 if (data.timestamps.length === 0 || limits === undefined) { 458 checkerboardExcept( 459 ctx, 460 this.getHeight(), 461 0, 462 size.width, 463 timescale.timeToPx(this.countersKey.start), 464 timescale.timeToPx(this.countersKey.end), 465 ); 466 return; 467 } 468 469 assertTrue(data.timestamps.length === data.minDisplayValues.length); 470 assertTrue(data.timestamps.length === data.maxDisplayValues.length); 471 assertTrue(data.timestamps.length === data.lastDisplayValues.length); 472 473 const options = this.getCounterOptions(); 474 475 const timestamps = data.timestamps; 476 const minValues = data.minDisplayValues; 477 const maxValues = data.maxDisplayValues; 478 const lastValues = data.lastDisplayValues; 479 480 // Choose a range for the y-axis 481 const {yRange, yMin, yMax, yLabel} = this.computeYRange( 482 limits, 483 data.displayValueRange, 484 ); 485 486 const effectiveHeight = this.getHeight() - MARGIN_TOP; 487 const endPx = size.width; 488 489 // Use hue to differentiate the scale of the counter value 490 const exp = Math.ceil(Math.log10(Math.max(yMax, 1))); 491 const expCapped = Math.min(exp - 3, 9); 492 const hue = (180 - Math.floor(expCapped * (180 / 6)) + 360) % 360; 493 494 ctx.fillStyle = `hsl(${hue}, 45%, 75%)`; 495 ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`; 496 497 const calculateX = (ts: time) => { 498 return Math.floor(timescale.timeToPx(ts)); 499 }; 500 const calculateY = (value: number) => { 501 return ( 502 MARGIN_TOP + 503 effectiveHeight - 504 Math.round(((value - yMin) / yRange) * effectiveHeight) 505 ); 506 }; 507 let zeroY; 508 if (yMin >= 0) { 509 zeroY = effectiveHeight + MARGIN_TOP; 510 } else if (yMax < 0) { 511 zeroY = MARGIN_TOP; 512 } else { 513 zeroY = effectiveHeight * (yMax / (yMax - yMin)) + MARGIN_TOP; 514 } 515 516 ctx.beginPath(); 517 const timestamp = Time.fromRaw(timestamps[0]); 518 ctx.moveTo(Math.max(0, calculateX(timestamp)), zeroY); 519 let lastDrawnY = zeroY; 520 for (let i = 0; i < timestamps.length; i++) { 521 const timestamp = Time.fromRaw(timestamps[i]); 522 const x = Math.max(0, calculateX(timestamp)); 523 const minY = calculateY(minValues[i]); 524 const maxY = calculateY(maxValues[i]); 525 const lastY = calculateY(lastValues[i]); 526 527 ctx.lineTo(x, lastDrawnY); 528 if (minY === maxY) { 529 assertTrue(lastY === minY); 530 ctx.lineTo(x, lastY); 531 } else { 532 ctx.lineTo(x, minY); 533 ctx.lineTo(x, maxY); 534 ctx.lineTo(x, lastY); 535 } 536 lastDrawnY = lastY; 537 } 538 ctx.lineTo(endPx, lastDrawnY); 539 ctx.lineTo(endPx, zeroY); 540 ctx.closePath(); 541 ctx.fill(); 542 ctx.stroke(); 543 544 if (yMin < 0 && yMax > 0) { 545 // Draw the Y=0 dashed line. 546 ctx.strokeStyle = `hsl(${hue}, 10%, 71%)`; 547 ctx.beginPath(); 548 ctx.setLineDash([2, 4]); 549 ctx.moveTo(0, zeroY); 550 ctx.lineTo(endPx, zeroY); 551 ctx.closePath(); 552 ctx.stroke(); 553 ctx.setLineDash([]); 554 } 555 ctx.font = '10px Roboto Condensed'; 556 557 const hover = this.hover; 558 if (hover !== undefined) { 559 let text = `${hover.lastDisplayValue.toLocaleString()}`; 560 561 const unit = this.unit; 562 switch (options.yMode) { 563 case 'value': 564 text = `${text} ${unit}`; 565 break; 566 case 'delta': 567 text = `${text} \u0394${unit}`; 568 break; 569 case 'rate': 570 text = `${text} \u0394${unit}/s`; 571 break; 572 default: 573 assertUnreachable(options.yMode); 574 break; 575 } 576 577 ctx.fillStyle = `hsl(${hue}, 45%, 75%)`; 578 ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`; 579 580 const rawXStart = calculateX(hover.ts); 581 const xStart = Math.max(0, rawXStart); 582 const xEnd = 583 hover.tsEnd === undefined 584 ? endPx 585 : Math.floor(timescale.timeToPx(hover.tsEnd)); 586 const y = 587 MARGIN_TOP + 588 effectiveHeight - 589 Math.round( 590 ((hover.lastDisplayValue - yMin) / yRange) * effectiveHeight, 591 ); 592 593 // Highlight line. 594 ctx.beginPath(); 595 ctx.moveTo(xStart, y); 596 ctx.lineTo(xEnd, y); 597 ctx.lineWidth = 3; 598 ctx.stroke(); 599 ctx.lineWidth = 1; 600 601 // Draw change marker if it would be visible. 602 if (rawXStart >= -6) { 603 ctx.beginPath(); 604 ctx.arc( 605 xStart, 606 y, 607 3 /* r*/, 608 0 /* start angle*/, 609 2 * Math.PI /* end angle*/, 610 ); 611 ctx.fill(); 612 ctx.stroke(); 613 } 614 615 // Draw the tooltip. 616 drawTrackHoverTooltip(ctx, this.mousePos, size, text); 617 } 618 619 // Write the Y scale on the top left corner. 620 ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; 621 ctx.fillRect(0, 0, 42, 13); 622 ctx.fillStyle = '#666'; 623 ctx.textAlign = 'left'; 624 ctx.textBaseline = 'alphabetic'; 625 ctx.fillText(`${yLabel}`, 5, 11); 626 627 // TODO(hjd): Refactor this into checkerboardExcept 628 { 629 const counterEndPx = Infinity; 630 // Grey out RHS. 631 if (counterEndPx < endPx) { 632 ctx.fillStyle = '#0000001f'; 633 ctx.fillRect(counterEndPx, 0, endPx - counterEndPx, this.getHeight()); 634 } 635 } 636 637 // If the cached trace slices don't fully cover the visible time range, 638 // show a gray rectangle with a "Loading..." label. 639 checkerboardExcept( 640 ctx, 641 this.getHeight(), 642 0, 643 size.width, 644 timescale.timeToPx(this.countersKey.start), 645 timescale.timeToPx(this.countersKey.end), 646 ); 647 } 648 649 onMouseMove({x, y, timescale}: TrackMouseEvent) { 650 const data = this.counters; 651 if (data === undefined) return; 652 this.mousePos = {x, y}; 653 const time = timescale.pxToHpTime(x); 654 655 const [left, right] = searchSegment(data.timestamps, time.toTime()); 656 657 if (left === -1) { 658 this.hover = undefined; 659 return; 660 } 661 662 const ts = Time.fromRaw(data.timestamps[left]); 663 const tsEnd = 664 right === -1 ? undefined : Time.fromRaw(data.timestamps[right]); 665 const lastDisplayValue = data.lastDisplayValues[left]; 666 this.hover = { 667 ts, 668 tsEnd, 669 lastDisplayValue, 670 }; 671 } 672 673 onMouseOut() { 674 this.hover = undefined; 675 } 676 677 async onDestroy(): Promise<void> { 678 await this.trash.asyncDispose(); 679 } 680 681 // Compute the range of values to display and range label. 682 private computeYRange( 683 limits: CounterLimits, 684 dataLimits: [number, number], 685 ): { 686 yMin: number; 687 yMax: number; 688 yRange: number; 689 yLabel: string; 690 } { 691 const options = this.getCounterOptions(); 692 693 let yMin = limits.minDisplayValue; 694 let yMax = limits.maxDisplayValue; 695 696 if (options.yRange === 'viewport') { 697 [yMin, yMax] = dataLimits; 698 } 699 700 if (options.yDisplay === 'zero') { 701 yMin = Math.min(0, yMin); 702 yMax = Math.max(0, yMax); 703 } 704 705 if (options.yOverrideMaximum !== undefined) { 706 yMax = Math.max(options.yOverrideMaximum, yMax); 707 } 708 709 if (options.yOverrideMinimum !== undefined) { 710 yMin = Math.min(options.yOverrideMinimum, yMin); 711 } 712 713 if (options.yRangeRounding === 'human_readable') { 714 if (options.yDisplay === 'log') { 715 yMax = Math.log(roundAway(Math.exp(yMax))); 716 yMin = Math.log(roundAway(Math.exp(yMin))); 717 } else { 718 yMax = roundAway(yMax); 719 yMin = roundAway(yMin); 720 } 721 } 722 723 const sharer = RangeSharer.get(); 724 [yMin, yMax] = sharer.share(options, [yMin, yMax]); 725 726 let yLabel: string; 727 728 if (options.yDisplay === 'minmax') { 729 yLabel = 'min - max'; 730 } else { 731 let max = yMax; 732 let min = yMin; 733 if (options.yDisplay === 'log') { 734 max = Math.exp(max); 735 min = Math.exp(min); 736 } 737 if (max < 0) { 738 yLabel = toLabel(min - max); 739 } else { 740 yLabel = toLabel(max - min); 741 } 742 } 743 744 const unit = this.unit; 745 switch (options.yMode) { 746 case 'value': 747 yLabel += ` ${unit}`; 748 break; 749 case 'delta': 750 yLabel += `\u0394${unit}`; 751 break; 752 case 'rate': 753 yLabel += `\u0394${unit}/s`; 754 break; 755 default: 756 assertUnreachable(options.yMode); 757 } 758 759 if (options.yDisplay === 'log') { 760 yLabel = `log(${yLabel})`; 761 } 762 763 return { 764 yMin, 765 yMax, 766 yLabel, 767 yRange: yMax - yMin, 768 }; 769 } 770 771 // The underlying table has `ts` and `value` columns. 772 private getValueExpression(): string { 773 const options = this.getCounterOptions(); 774 775 let valueExpr; 776 switch (options.yMode) { 777 case 'value': 778 valueExpr = 'value'; 779 break; 780 case 'delta': 781 valueExpr = 'lead(value, 1, value) over (order by ts) - value'; 782 break; 783 case 'rate': 784 valueExpr = 785 '(lead(value, 1, value) over (order by ts) - value) / ((lead(ts, 1, 100) over (order by ts) - ts) / 1e9)'; 786 break; 787 default: 788 assertUnreachable(options.yMode); 789 } 790 791 if (options.yDisplay === 'log') { 792 return `ifnull(ln(${valueExpr}), 0)`; 793 } else { 794 return valueExpr; 795 } 796 } 797 798 private getTableName(): string { 799 return `counter_${this.trackUuid}`; 800 } 801 802 private async maybeRequestData(rawCountersKey: CacheKey) { 803 if (rawCountersKey.isCoveredBy(this.countersKey)) { 804 return; // We have the data already, no need to re-query. 805 } 806 807 const countersKey = rawCountersKey.normalize(); 808 if (!rawCountersKey.isCoveredBy(countersKey)) { 809 throw new Error( 810 `Normalization error ${countersKey.toString()} ${rawCountersKey.toString()}`, 811 ); 812 } 813 814 if (this.limits === undefined) { 815 this.limits = await this.createTableAndFetchLimits(true); 816 } 817 818 const queryRes = await this.engine.query(` 819 SELECT 820 min_value as minDisplayValue, 821 max_value as maxDisplayValue, 822 last_ts as ts, 823 last_value as lastDisplayValue 824 FROM ${this.getTableName()}( 825 ${countersKey.start}, 826 ${countersKey.end}, 827 ${countersKey.bucketSize} 828 ); 829 `); 830 831 const it = queryRes.iter({ 832 ts: LONG, 833 minDisplayValue: NUM, 834 maxDisplayValue: NUM, 835 lastDisplayValue: NUM, 836 }); 837 838 const numRows = queryRes.numRows(); 839 const data: CounterData = { 840 timestamps: new BigInt64Array(numRows), 841 minDisplayValues: new Float64Array(numRows), 842 maxDisplayValues: new Float64Array(numRows), 843 lastDisplayValues: new Float64Array(numRows), 844 displayValueRange: [0, 0], 845 }; 846 847 let min = 0; 848 let max = 0; 849 for (let row = 0; it.valid(); it.next(), row++) { 850 data.timestamps[row] = Time.fromRaw(it.ts); 851 data.minDisplayValues[row] = it.minDisplayValue; 852 data.maxDisplayValues[row] = it.maxDisplayValue; 853 data.lastDisplayValues[row] = it.lastDisplayValue; 854 min = Math.min(min, it.minDisplayValue); 855 max = Math.max(max, it.maxDisplayValue); 856 } 857 858 data.displayValueRange = [min, max]; 859 860 this.countersKey = countersKey; 861 this.counters = data; 862 863 raf.scheduleCanvasRedraw(); 864 } 865 866 private async createTableAndFetchLimits( 867 dropTable: boolean, 868 ): Promise<CounterLimits> { 869 const dropQuery = dropTable ? `drop table ${this.getTableName()};` : ''; 870 const displayValueQuery = await this.engine.query(` 871 ${dropQuery} 872 create virtual table ${this.getTableName()} 873 using __intrinsic_counter_mipmap(( 874 select 875 ts, 876 ${this.getValueExpression()} as value 877 from (${this.getSqlSource()}) 878 )); 879 select 880 min_value as minDisplayValue, 881 max_value as maxDisplayValue 882 from ${this.getTableName()}( 883 trace_start(), trace_end(), trace_dur() 884 ); 885 `); 886 887 this.trash.defer(async () => { 888 this.engine.tryQuery(`drop table if exists ${this.getTableName()}`); 889 }); 890 891 const {minDisplayValue, maxDisplayValue} = displayValueQuery.firstRow({ 892 minDisplayValue: NUM, 893 maxDisplayValue: NUM, 894 }); 895 896 return { 897 minDisplayValue, 898 maxDisplayValue, 899 }; 900 } 901 902 get unit(): string { 903 return this.getCounterOptions().unit ?? ''; 904 } 905 906 protected get engine() { 907 return this.trace.engine; 908 } 909} 910