• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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