• 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';
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