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