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