xref: /aosp_15_r20/external/perfetto/ui/src/components/tracks/base_counter_track.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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