From 3b8f4a564f9655e311741d90749bfca337d675d5 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 8 Oct 2026 20:18:54 -0700 Subject: [PATCH 1/7] Add logarithmic and ranked coloring strategies This is naive at the moment. It only shows based on whether the first table is a set of heatmaps, and it also just inlines the radio for picking your strategy. --- app/components/Heatmap.tsx | 92 ++++++++++++++++++++-------- app/pages/system/MetricsExplorer.tsx | 46 ++++++++++++-- 2 files changed, 110 insertions(+), 28 deletions(-) diff --git a/app/components/Heatmap.tsx b/app/components/Heatmap.tsx index 7ec19b28b..6e22f4eeb 100644 --- a/app/components/Heatmap.tsx +++ b/app/components/Heatmap.tsx @@ -7,6 +7,7 @@ */ import { useMemo, useState } from 'react' import * as R from 'remeda' +import { match } from 'ts-pattern' import type uPlot from 'uplot' import { ChartTooltip, type TopBottom, type LeftRight } from '~/components/ChartTooltip' @@ -46,10 +47,10 @@ const rampColor = ({ stops }: ColorRamp, t: number): string => { const lastIndex = stops.length - 1 const progress = clamped * lastIndex const start = R.clamp(Math.floor(progress), { max: lastIndex - 1 }) - const lerp = progress - start + const localT = progress - start const from = stops[start] const to = stops[start + 1] - return `color-mix(in oklch, ${from} ${(1 - lerp) * 100}%, ${to})` + return `color-mix(in oklch, ${from} ${(1 - localT) * 100}%, ${to})` } // grabbing style from the chart root instead of document body @@ -63,16 +64,19 @@ const getColorRamp = (el: Element): ColorRamp => { } } -// count -> ramp position. Doing a square root or a log makes for some different -// smoothing. Nothing is truly intuitive, but linearly showing the fraction is -// most easily compared to the legend at the bottom -const rampT = (count: number, maxCount: number) => { - const fraction = count / maxCount - // TODO: pick! - return fraction - // return Math.log10(lerp(1, 10, fraction)) - // return Math.sqrt(fraction) -} +const rampT = + (maxCount: number) => + (count: number): number => + count / maxCount +const lerp = (min: number, max: number, t: number): number => min + t * (max - min) +const rampTLog = + (maxCount: number) => + (count: number): number => + Math.log10(lerp(1, 10, count / maxCount)) +const rampRank = + (ranks: Map) => + (count: number): number => + ranks.size <= 1 ? ranks.size : (ranks.get(count) || 0) / (ranks.size - 1) type Hover = { col: number @@ -93,8 +97,11 @@ type HeatmapProps = { unit?: string /** Drawn rotated alongside the y-axis tick labels, e.g. a unit */ yAxisLabel?: string + saturationStrategy?: SaturationStrategy } +export type SaturationStrategy = 'linear' | 'logarithmic' | 'ranked' + const defaultYAxisTickFormatter = (val: number) => val.toLocaleString() export function Heatmap({ @@ -105,6 +112,7 @@ export function Heatmap({ yAxisTickFormatter = defaultYAxisTickFormatter, unit, yAxisLabel, + saturationStrategy = 'ranked', }: HeatmapProps) { const theme = useChartTheme() const [hover, setHover] = useState(null) @@ -118,10 +126,20 @@ export function Heatmap({ const binCount = bins.length const colCount = distributions.length - const maxSampleCount = useMemo( - () => distributions.reduce((max, d) => (d ? Math.max(max, ...d.counts) : max), 0), - [distributions] - ) + const { minSampleCount, maxSampleCount, ranks } = useMemo(() => { + const allCounts = R.sort( + R.unique(distributions.filter((d) => d !== null).flatMap((x) => x.counts)).filter( + (x) => x !== 0 + ), + (a, b) => a - b + ) + + return { + minSampleCount: R.first(allCounts) || 0, + maxSampleCount: R.last(allCounts) || 0, + ranks: new Map(allCounts.map((n, i) => [n, i])), + } + }, [distributions]) // uPlot's time scale wants seconds; metrics timestamps are milliseconds const rightEdges = useMemo(() => timestamps.map((t) => t / 1000), [timestamps]) @@ -129,8 +147,14 @@ export function Heatmap({ // uPlot doesn't actually have heatmap support, but we can use its position // functions to make this (somewhat) easy - const drawCells = useMemo( - () => (u: uPlot) => { + const drawCells = useMemo(() => { + const progressToMax: (count: number) => number = match(saturationStrategy) + .with('linear', () => rampT(maxSampleCount)) + .with('logarithmic', () => rampTLog(maxSampleCount)) + .with('ranked', () => rampRank(ranks)) + .exhaustive() + + return (u: uPlot) => { if (binCount === 0 || colCount === 0) return const colorRamp = getColorRamp(u.root) const ctx = u.ctx @@ -149,7 +173,7 @@ export function Heatmap({ const topEdge = u.valToPos(rowIndex + 1, 'y', true) const bottomEdge = u.valToPos(rowIndex, 'y', true) - ctx.fillStyle = rampColor(colorRamp, rampT(count, maxSampleCount)) + ctx.fillStyle = rampColor(colorRamp, progressToMax(count)) ctx.fillRect( leftEdge - CELL_OVERFLOW, topEdge - CELL_OVERFLOW, @@ -159,9 +183,17 @@ export function Heatmap({ }) }) ctx.restore() - }, - [distributions, leftEdges, rightEdges, colCount, binCount, maxSampleCount] - ) + } + }, [ + distributions, + leftEdges, + rightEdges, + colCount, + binCount, + saturationStrategy, + maxSampleCount, + ranks, + ]) const tooltipPlugin = useMemo( () => ({ @@ -307,7 +339,17 @@ export function Heatmap({ chartOptions={chartOptions} data={data} uRef={uRef} - legend={} + legend={ + minSampleCount) + .with('linear', () => 0) + .with('logarithmic', () => 1) + .exhaustive()} + maxSampleCount={maxSampleCount} + axisText={theme.axisText} + /> + } > {hover && ( <> @@ -342,16 +384,18 @@ export function Heatmap({ } function HeatmapLegend({ + minSampleCount, maxSampleCount, axisText, }: { + minSampleCount: number maxSampleCount: number axisText: string }) { return (
- 0 + {minSampleCount.toLocaleString()}
{ return { startTime: new Date(min ?? 0), endTime: new Date(max ?? 0) } } -const toDisplays = (groups: ChartGroup[], trim: boolean): ChartDisplay[] => +const toDisplays = ( + groups: ChartGroup[], + trim: boolean, + saturationStrategy: SaturationStrategy +): ChartDisplay[] => groups.flatMap((g, t) => match(g) .returnType() @@ -565,6 +571,7 @@ const toDisplays = (groups: ChartGroup[], trim: boolean): ChartDisplay[] => key: `t${t}.${i}`, name: chart.name, description: chart.description, + saturationStrategy, ...trimHeatmap(trim, { timestamps: chart.timestamps, startTimes: chart.startTimes, @@ -709,6 +716,7 @@ function HeatmapCard({ display }: { display: Extract('ranked') const onSubmit = (body: TimeseriesQuery) => { query.mutate( @@ -894,8 +903,8 @@ export default function MetricsExplorer() { const trim = dropFirstPoint && hasTrimmableCharts const charts = useMemo( - () => (chartGroups ? toDisplays(chartGroups, trim) : []), - [chartGroups, trim] + () => (chartGroups ? toDisplays(chartGroups, trim, saturationStrategy) : []), + [chartGroups, trim, saturationStrategy] ) // Since the whole window is the scroll container, the virtualizer needs to @@ -1024,6 +1033,35 @@ export default function MetricsExplorer() {
)} + {charts[0]?.kind === 'heatmap' && ( +
+ Heatmap coloring: + setSaturationStrategy('ranked')} + > + Ranked + + setSaturationStrategy('logarithmic')} + > + Logarithmic + + setSaturationStrategy('linear')} + > + Linear + +
+ )}
Date: Fri, 9 Oct 2026 13:27:42 -0700 Subject: [PATCH 2/7] Add interface for picking color scales This is the bulk of the work here. It involves extracting these rendering settings to a less-in-the-way popover, along with some explanations of how color scaling works. The happy path experience (in my opinion) is to look at these settings at most once, but if you see them and they don't make sense, that just stinks! So the fair trade for information density is to keep it out of the way, since our defaults probably serve the vast majority of purposes, at least for now. --- app/components/Heatmap.tsx | 124 +++++++------ app/components/heatmap-color-scale.spec.ts | 82 +++++++++ app/components/heatmap-color-scale.ts | 57 ++++++ app/pages/system/MetricsExplorer.tsx | 205 +++++++++++++++------ test/e2e/oxql-queries.ts | 2 + test/e2e/oxql.e2e.ts | 29 ++- 6 files changed, 376 insertions(+), 123 deletions(-) create mode 100644 app/components/heatmap-color-scale.spec.ts create mode 100644 app/components/heatmap-color-scale.ts diff --git a/app/components/Heatmap.tsx b/app/components/Heatmap.tsx index 6e22f4eeb..5b27219a8 100644 --- a/app/components/Heatmap.tsx +++ b/app/components/Heatmap.tsx @@ -12,6 +12,11 @@ import type uPlot from 'uplot' import { ChartTooltip, type TopBottom, type LeftRight } from '~/components/ChartTooltip' import { type UPlotOptions, FramedChart } from '~/components/FramedChart' +import { + getSampleCountMetadata, + getScalingFunction, + type ColorScale, +} from '~/components/heatmap-color-scale' import { timeFormatterForRange, useChartTheme, @@ -41,6 +46,10 @@ const RAMP_STOPS = [ ['--content-accent', 'oklch(0.77 0.1919 163.7)'], ] as const +const CSS_VAR_RAMP: ColorRamp = { + stops: RAMP_STOPS.map(([name, fallback]) => `var(${name}, ${fallback})`), +} + // Interpolates the color between the two closest stops, for 0 <= t <= 1. const rampColor = ({ stops }: ColorRamp, t: number): string => { const clamped = R.clamp(t, { min: 0, max: 1 }) @@ -50,34 +59,9 @@ const rampColor = ({ stops }: ColorRamp, t: number): string => { const localT = progress - start const from = stops[start] const to = stops[start + 1] - return `color-mix(in oklch, ${from} ${(1 - localT) * 100}%, ${to})` + return `color-mix(in oklch, ${from}, ${to} ${localT * 100}%)` } -// grabbing style from the chart root instead of document body -// that way it can be themed e.g. `yellow-theme` -const getColorRamp = (el: Element): ColorRamp => { - const style = getComputedStyle(el) - return { - stops: RAMP_STOPS.map( - ([name, fallback]) => style.getPropertyValue(name).trim() || fallback - ), - } -} - -const rampT = - (maxCount: number) => - (count: number): number => - count / maxCount -const lerp = (min: number, max: number, t: number): number => min + t * (max - min) -const rampTLog = - (maxCount: number) => - (count: number): number => - Math.log10(lerp(1, 10, count / maxCount)) -const rampRank = - (ranks: Map) => - (count: number): number => - ranks.size <= 1 ? ranks.size : (ranks.get(count) || 0) / (ranks.size - 1) - type Hover = { col: number row: number @@ -97,11 +81,9 @@ type HeatmapProps = { unit?: string /** Drawn rotated alongside the y-axis tick labels, e.g. a unit */ yAxisLabel?: string - saturationStrategy?: SaturationStrategy + colorScale?: ColorScale } -export type SaturationStrategy = 'linear' | 'logarithmic' | 'ranked' - const defaultYAxisTickFormatter = (val: number) => val.toLocaleString() export function Heatmap({ @@ -112,7 +94,7 @@ export function Heatmap({ yAxisTickFormatter = defaultYAxisTickFormatter, unit, yAxisLabel, - saturationStrategy = 'ranked', + colorScale = 'ranked', }: HeatmapProps) { const theme = useChartTheme() const [hover, setHover] = useState(null) @@ -126,20 +108,10 @@ export function Heatmap({ const binCount = bins.length const colCount = distributions.length - const { minSampleCount, maxSampleCount, ranks } = useMemo(() => { - const allCounts = R.sort( - R.unique(distributions.filter((d) => d !== null).flatMap((x) => x.counts)).filter( - (x) => x !== 0 - ), - (a, b) => a - b - ) - - return { - minSampleCount: R.first(allCounts) || 0, - maxSampleCount: R.last(allCounts) || 0, - ranks: new Map(allCounts.map((n, i) => [n, i])), - } - }, [distributions]) + const sampleCountMetadata = useMemo( + () => getSampleCountMetadata(distributions.flatMap((d) => d?.counts ?? [])), + [distributions] + ) // uPlot's time scale wants seconds; metrics timestamps are milliseconds const rightEdges = useMemo(() => timestamps.map((t) => t / 1000), [timestamps]) @@ -148,15 +120,20 @@ export function Heatmap({ // uPlot doesn't actually have heatmap support, but we can use its position // functions to make this (somewhat) easy const drawCells = useMemo(() => { - const progressToMax: (count: number) => number = match(saturationStrategy) - .with('linear', () => rampT(maxSampleCount)) - .with('logarithmic', () => rampTLog(maxSampleCount)) - .with('ranked', () => rampRank(ranks)) - .exhaustive() + const scaleCount = getScalingFunction(sampleCountMetadata, colorScale) return (u: uPlot) => { if (binCount === 0 || colCount === 0) return - const colorRamp = getColorRamp(u.root) + + // grabbing style from the nearest available element, since a canvas can't + // resolve CSS variables + const style = getComputedStyle(u.root) + const colorRamp = { + stops: RAMP_STOPS.map( + ([name, fallback]) => style.getPropertyValue(name).trim() || fallback + ), + } + const ctx = u.ctx ctx.save() ctx.beginPath() @@ -173,7 +150,7 @@ export function Heatmap({ const topEdge = u.valToPos(rowIndex + 1, 'y', true) const bottomEdge = u.valToPos(rowIndex, 'y', true) - ctx.fillStyle = rampColor(colorRamp, progressToMax(count)) + ctx.fillStyle = rampColor(colorRamp, scaleCount(count)) ctx.fillRect( leftEdge - CELL_OVERFLOW, topEdge - CELL_OVERFLOW, @@ -190,9 +167,8 @@ export function Heatmap({ rightEdges, colCount, binCount, - saturationStrategy, - maxSampleCount, - ranks, + colorScale, + sampleCountMetadata, ]) const tooltipPlugin = useMemo( @@ -341,12 +317,12 @@ export function Heatmap({ uRef={uRef} legend={ minSampleCount) + minSampleCount={match(colorScale) + .with('ranked', () => sampleCountMetadata.minSampleCount) .with('linear', () => 0) .with('logarithmic', () => 1) .exhaustive()} - maxSampleCount={maxSampleCount} + maxSampleCount={sampleCountMetadata.maxSampleCount} axisText={theme.axisText} /> } @@ -400,9 +376,7 @@ function HeatmapLegend({
`var(${name}, ${fallback})` - ).join(', ')})`, + backgroundImage: `linear-gradient(in oklch to right, ${CSS_VAR_RAMP.stops.join(', ')})`, }} >
@@ -411,3 +385,33 @@ function HeatmapLegend({
) } + +const SAMPLE_VALUES = [1, 5, 10, 50, 95, 100] +const SAMPLE_VALUES_METADATA = getSampleCountMetadata(SAMPLE_VALUES) +const LONGEST_SAMPLE_ITEM = SAMPLE_VALUES.map((n) => n.toLocaleString().length).reduce( + (acc, length) => Math.max(acc, length), + 0 +) + +export function SampleColorStrip({ colorScale }: { colorScale: ColorScale }) { + const scaleCount = getScalingFunction(SAMPLE_VALUES_METADATA, colorScale) + + return ( +
+ {SAMPLE_VALUES.map((n) => ( +
+
+ {n.toLocaleString()} +
+
+
+ ))} +
+ ) +} diff --git a/app/components/heatmap-color-scale.spec.ts b/app/components/heatmap-color-scale.spec.ts new file mode 100644 index 000000000..b271fc417 --- /dev/null +++ b/app/components/heatmap-color-scale.spec.ts @@ -0,0 +1,82 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { describe, expect, it } from 'vitest' + +import { + getSampleCountMetadata, + getScalingFunction, + type ColorScale, +} from './heatmap-color-scale' + +const scale = (counts: number[], colorScale: ColorScale) => + getScalingFunction(getSampleCountMetadata(counts), colorScale) + +describe('getSampleCountMetadata', () => { + it('ignores zeros and duplicates', () => { + const { minSampleCount, maxSampleCount, ranks } = getSampleCountMetadata([ + 50, 0, 5, 50, 0, 100, + ]) + expect(minSampleCount).toBe(5) + expect(maxSampleCount).toBe(100) + expect([...ranks]).toEqual([ + [5, 0], + [50, 1], + [100, 2], + ]) + }) + + it('handles no samples', () => { + const { minSampleCount, maxSampleCount, ranks } = getSampleCountMetadata([0, 0]) + expect(minSampleCount).toBe(0) + expect(maxSampleCount).toBe(0) + expect(ranks.size).toBe(0) + }) +}) + +describe('linear', () => { + it('is the fraction of the max', () => { + const f = scale([10, 50, 200], 'linear') + expect(f(10)).toBe(0.05) + expect(f(50)).toBe(0.25) + expect(f(200)).toBe(1) + }) +}) + +describe('logarithmic', () => { + it('runs from 0 to 1', () => { + const f = scale([10, 200], 'logarithmic') + expect(f(0)).toBe(0) + expect(f(200)).toBe(1) + }) + + it('spreads out low counts more than linear', () => { + const counts = [1, 10, 100] + const log = scale(counts, 'logarithmic') + const linear = scale(counts, 'linear') + expect(log(10) - log(1)).toBeGreaterThan(linear(10) - linear(1)) + }) +}) + +describe('ranked', () => { + it('spaces distinct counts evenly regardless of magnitude', () => { + const f = scale([1, 2, 1000], 'ranked') + expect(f(1)).toBe(0) + expect(f(2)).toBe(0.5) + expect(f(1000)).toBe(1) + }) + + it('gives equal counts the same position', () => { + const f = scale([3, 3, 7, 7, 9], 'ranked') + expect(f(3)).toBe(0) + expect(f(7)).toBe(0.5) + }) + + it('puts a lone distinct count at the top', () => { + expect(scale([4, 4, 0], 'ranked')(4)).toBe(1) + }) +}) diff --git a/app/components/heatmap-color-scale.ts b/app/components/heatmap-color-scale.ts new file mode 100644 index 000000000..cbe34095f --- /dev/null +++ b/app/components/heatmap-color-scale.ts @@ -0,0 +1,57 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import * as R from 'remeda' +import { match } from 'ts-pattern' + +export type ColorScale = 'linear' | 'logarithmic' | 'ranked' + +export const allColorScales: ColorScale[] = ['linear', 'logarithmic', 'ranked'] + +const scaleLinear = + (maxCount: number) => + (count: number): number => + count / maxCount +const lerp = (min: number, max: number, t: number): number => min + t * (max - min) +const scaleLog = + (maxCount: number) => + (count: number): number => + Math.log10(lerp(1, 10, count / maxCount)) +const scaleRank = (ranks: Map) => + ranks.size <= 1 + ? R.constant(1) + : (count: number) => (ranks.get(count) ?? 0) / (ranks.size - 1) + +export type SampleCountMetadata = { + minSampleCount: number + maxSampleCount: number + ranks: Map +} + +export const getSampleCountMetadata = (counts: number[]): SampleCountMetadata => { + const allCounts = R.sort( + R.unique(counts).filter((x) => x !== 0), + (a, b) => a - b + ) + + return { + minSampleCount: R.first(allCounts) ?? 0, + maxSampleCount: R.last(allCounts) ?? 0, + ranks: new Map(allCounts.map((n, i) => [n, i])), + } +} + +/** Maps a sample count to a position on the color ramp, from 0 to 1 */ +export const getScalingFunction = ( + metadata: SampleCountMetadata, + colorScale: ColorScale +): ((n: number) => number) => + match(colorScale) + .with('linear', () => scaleLinear(metadata.maxSampleCount)) + .with('logarithmic', () => scaleLog(metadata.maxSampleCount)) + .with('ranked', () => scaleRank(metadata.ranks)) + .exhaustive() diff --git a/app/pages/system/MetricsExplorer.tsx b/app/pages/system/MetricsExplorer.tsx index 7d076ddfe..27d5ad85c 100644 --- a/app/pages/system/MetricsExplorer.tsx +++ b/app/pages/system/MetricsExplorer.tsx @@ -5,8 +5,10 @@ * * Copyright Oxide Computer Company */ +import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react' import { useQuery } from '@tanstack/react-query' import { useWindowVirtualizer } from '@tanstack/react-virtual' +import cn from 'classnames' import { Fragment, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useController, useForm } from 'react-hook-form' import { useSearchParams } from 'react-router' @@ -34,11 +36,13 @@ import { Info12Icon, Monitoring16Icon, Monitoring24Icon, + Settings16Icon, } from '@oxide/design-system/icons/react' import { timeseries as timeseriesMetadata } from '~/api/__generated__/timeseries-metadata' import { DocsPopover } from '~/components/DocsPopover' -import { Heatmap, type SaturationStrategy } from '~/components/Heatmap' +import { Heatmap, SampleColorStrip } from '~/components/Heatmap' +import { allColorScales, type ColorScale } from '~/components/heatmap-color-scale' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { errorMessageToSegments, @@ -57,7 +61,7 @@ import { } from '~/components/TimeSeriesChart' import { useElementSize } from '~/hooks/use-element-size' import { addToast } from '~/stores/toast' -import { Button } from '~/ui/lib/Button' +import { Button, buttonStyle } from '~/ui/lib/Button' import { CardBlock } from '~/ui/lib/CardBlock' import { Checkbox } from '~/ui/lib/Checkbox' import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' @@ -540,7 +544,6 @@ type ChartDisplay = { key: string } & ( description?: ReactNode timestamps: number[] startTimes: number[] - saturationStrategy: SaturationStrategy data: (Distributiondouble | null)[] } ) @@ -556,11 +559,7 @@ const tableTimeRange = (charts: { timestamps: number[] }[]) => { return { startTime: new Date(min ?? 0), endTime: new Date(max ?? 0) } } -const toDisplays = ( - groups: ChartGroup[], - trim: boolean, - saturationStrategy: SaturationStrategy -): ChartDisplay[] => +const toDisplays = (groups: ChartGroup[], trim: boolean): ChartDisplay[] => groups.flatMap((g, t) => match(g) .returnType() @@ -571,7 +570,6 @@ const toDisplays = ( key: `t${t}.${i}`, name: chart.name, description: chart.description, - saturationStrategy, ...trimHeatmap(trim, { timestamps: chart.timestamps, startTimes: chart.startTimes, @@ -706,7 +704,13 @@ function ChartCard({ display }: { display: Extract }) { +function HeatmapCard({ + display, + colorScale, +}: { + display: Extract + colorScale: ColorScale +}) { const units = tableUnitLabels(display.name) return ( @@ -716,7 +720,7 @@ function HeatmapCard({ display }: { display: Extract ( @@ -747,10 +757,108 @@ function ChartEntry({ display }: { display: ChartDisplay }) { )) .with({ kind: 'chart' }, (r) => ) - .with({ kind: 'heatmap' }, (r) => ) + .with({ kind: 'heatmap' }, (r) => ( + + )) .exhaustive() } +type Setting = { value: T; onChange: (value: T) => void } | 'irrelevant' + +type ChartSettingsProps = { + dropFirstPoint: Setting + colorScale: Setting +} + +function ChartSettings({ dropFirstPoint, colorScale }: ChartSettingsProps) { + if (dropFirstPoint === 'irrelevant' && colorScale === 'irrelevant') return null + + return ( + + +
+ +
+
+ +

Chart settings

+ +
+ {dropFirstPoint !== 'irrelevant' && ( +
+ dropFirstPoint.onChange(e.target.checked)} + > + Drop first data point + + + With deltas and cumulative counters, the initial point is a delta from the + earliest observed value. It's therefore typically much larger, and not worth + comparing to the rest of your data. + +
+ )} + {colorScale !== 'irrelevant' && ( +
+ + Heatmap color scale + +
+ {allColorScales.map((value) => ( + + colorScale.onChange(value)} + > + {match(value) + .with('linear', () => 'Linear') + .with('logarithmic', () => 'Logarithmic') + .with('ranked', () => 'Ranked') + .exhaustive()} + + +

+ {match(value) + .with('linear', () => ( + <> + Shades each cell by its count as a fraction of the highest + measured count. + + )) + .with('logarithmic', () => ( + <> + Compresses the shading scale at higher values, so smaller + counts are easier to distinguish. + + )) + .with('ranked', () => ( + <> + Shades a cell by where its count ranks among all measured + counts. Not a direct representation of magnitude, but helps + emphasize patterns and outliers. + + )) + .exhaustive()} +

+ +
+
+ ))} +
+
+ )} +
+
+
+ ) +} + const copyText = (text: string, toastMessage: string) => { window.navigator.clipboard.writeText(text).then(() => addToast(toastMessage)) } @@ -855,7 +963,7 @@ export default function MetricsExplorer() { }) const [dropFirstPoint, setDropFirstPoint] = useState(true) - const [saturationStrategy, setSaturationStrategy] = useState('ranked') + const [colorScale, setColorScale] = useState('ranked') const onSubmit = (body: TimeseriesQuery) => { query.mutate( @@ -903,10 +1011,13 @@ export default function MetricsExplorer() { const trim = dropFirstPoint && hasTrimmableCharts const charts = useMemo( - () => (chartGroups ? toDisplays(chartGroups, trim, saturationStrategy) : []), - [chartGroups, trim, saturationStrategy] + () => (chartGroups ? toDisplays(chartGroups, trim) : []), + [chartGroups, trim] ) + // a query can return several tables, only some of which are distributions + const hasHeatmaps = charts.some((c) => c.kind === 'heatmap') + // Since the whole window is the scroll container, the virtualizer needs to // know the offset from the top. By reacting to height changes in everything // prior to the virtualized area, we can keep the list's offset height in sync. @@ -1018,50 +1129,22 @@ export default function MetricsExplorer() { )) .with({ status: 'success' }, () => ( - {hasTrimmableCharts && ( -
- setDropFirstPoint(e.target.checked)} - > - Drop first data point - - - With deltas and cumulative counters, the initial point is a delta from the - earliest observed value. It's therefore typically much larger, and not - worth comparing to the rest of your data. - -
- )} - {charts[0]?.kind === 'heatmap' && ( -
- Heatmap coloring: - setSaturationStrategy('ranked')} - > - Ranked - - setSaturationStrategy('logarithmic')} - > - Logarithmic - - setSaturationStrategy('linear')} - > - Linear - -
- )} + {/* By using a consistent height for this settings row, we don't + have to worry about the virtualizer's scroll margin underneath */} +
+ +
- +
))}
diff --git a/test/e2e/oxql-queries.ts b/test/e2e/oxql-queries.ts index ab5cd4e27..804a1282f 100644 --- a/test/e2e/oxql-queries.ts +++ b/test/e2e/oxql-queries.ts @@ -9,6 +9,8 @@ export const oxqlQueries = { basicTctl: `get hardware_component:amd_cpu_tctl | filter timestamp > @now() - 1m`, + diskLatency: `get virtual_disk:io_latency + | filter timestamp > @now() - 1m`, unalignedTables: `{ get hardware_component:temperature; get hardware_component:sensor_error_count diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts index a81459dbc..da6d70180 100644 --- a/test/e2e/oxql.e2e.ts +++ b/test/e2e/oxql.e2e.ts @@ -98,21 +98,46 @@ test('joined query renders a chart per instance with a legend line per metric', }) test('"Drop first point" appears only for cumulative-derived charts', async ({ page }) => { + const settings = page.getByRole('button', { name: 'Chart settings' }) const dropFirst = page.getByLabel('Drop first data point') - // a plain gauge is never cumulative, so there's no giant first point to drop + // a plain gauge is never cumulative, so there's no giant first point to drop. + // and since the only other option is about heatmaps, there should be no + // settings button await runQuery(page, oxqlQueries.basicTctl) - await expect(dropFirst).toBeHidden() + await expect(settings).toBeHidden() // joined/aligned tables may derive from cumulatives, so the option shows up // TODO: if you know the schemas, you can check which tables are cumulative! await runQuery(page, oxqlQueries.multiJoinedTables) + await settings.click() await expect(dropFirst).toBeChecked() await dropFirst.uncheck() await expect(page.getByRole('figure')).toHaveCount(3) }) +test('heatmap color scale appears only for distributions', async ({ page }) => { + const settings = page.getByRole('button', { name: 'Chart settings' }) + const colorScale = page.getByRole('group', { name: 'Heatmap color scale' }) + + await runQuery(page, oxqlQueries.multiJoinedTables) + await settings.click() + await expect(page.getByLabel('Drop first data point')).toBeVisible() + await expect(colorScale).toBeHidden() + await page.keyboard.press('Escape') + + await runQuery(page, oxqlQueries.diskLatency) + await settings.click() + const ranked = colorScale.getByRole('radio', { name: 'Ranked', exact: true }) + const linear = colorScale.getByRole('radio', { name: 'Linear', exact: true }) + await expect(ranked).toBeChecked() + + await linear.check() + await expect(linear).toBeChecked() + await expect(ranked).not.toBeChecked() +}) + test('results list is virtualized', async ({ page }) => { const getFirstRenderedIndex = () => page From b2866eea78607b2d3e2e00729d6dc3bd9dd32085 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 26 Oct 2026 19:42:13 -0700 Subject: [PATCH 3/7] Use an actual logarithmic scale The previous one was more a convenience function _using_ a log, rather than actually spacing out logarithmically. A real function has to have a carveout for max counts <= 1. --- app/components/heatmap-color-scale.spec.ts | 31 +++++++++++++--------- app/components/heatmap-color-scale.ts | 20 +++++++++----- app/pages/system/MetricsExplorer.tsx | 4 +-- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/app/components/heatmap-color-scale.spec.ts b/app/components/heatmap-color-scale.spec.ts index b271fc417..94b2a99b1 100644 --- a/app/components/heatmap-color-scale.spec.ts +++ b/app/components/heatmap-color-scale.spec.ts @@ -30,7 +30,7 @@ describe('getSampleCountMetadata', () => { ]) }) - it('handles no samples', () => { + it('handles lack of samples', () => { const { minSampleCount, maxSampleCount, ranks } = getSampleCountMetadata([0, 0]) expect(minSampleCount).toBe(0) expect(maxSampleCount).toBe(0) @@ -48,35 +48,42 @@ describe('linear', () => { }) describe('logarithmic', () => { - it('runs from 0 to 1', () => { + it("has 1 as 0 and the collection's greatest value as 1", () => { const f = scale([10, 200], 'logarithmic') - expect(f(0)).toBe(0) + expect(f(1)).toBe(0) expect(f(200)).toBe(1) }) - it('spreads out low counts more than linear', () => { - const counts = [1, 10, 100] - const log = scale(counts, 'logarithmic') - const linear = scale(counts, 'linear') - expect(log(10) - log(1)).toBeGreaterThan(linear(10) - linear(1)) + it('gives equal ratios equal steps', () => { + const expectEqualSpacing = (values: number[]) => { + const f = scale(values, 'logarithmic') + values.forEach((value, i) => expect(f(value)).toBeCloseTo(i / (values.length - 1))) + } + + expectEqualSpacing([1, 10, 100, 1000, 10000]) + expectEqualSpacing([1, 7, 49, 343]) + }) + + it("makes an exception when the collection's greatest value is 1", () => { + expect(scale([1, 0], 'logarithmic')(1)).toBe(1) }) }) describe('ranked', () => { - it('spaces distinct counts evenly regardless of magnitude', () => { + it('spaces distinct counts evenly', () => { const f = scale([1, 2, 1000], 'ranked') expect(f(1)).toBe(0) expect(f(2)).toBe(0.5) expect(f(1000)).toBe(1) }) - it('gives equal counts the same position', () => { - const f = scale([3, 3, 7, 7, 9], 'ranked') + it('ignores duplicates', () => { + const f = scale([3, 3, 7, 9], 'ranked') expect(f(3)).toBe(0) expect(f(7)).toBe(0.5) }) - it('puts a lone distinct count at the top', () => { + it('puts a single count at the top', () => { expect(scale([4, 4, 0], 'ranked')(4)).toBe(1) }) }) diff --git a/app/components/heatmap-color-scale.ts b/app/components/heatmap-color-scale.ts index cbe34095f..7cde9e3b0 100644 --- a/app/components/heatmap-color-scale.ts +++ b/app/components/heatmap-color-scale.ts @@ -16,15 +16,17 @@ const scaleLinear = (maxCount: number) => (count: number): number => count / maxCount -const lerp = (min: number, max: number, t: number): number => min + t * (max - min) -const scaleLog = - (maxCount: number) => - (count: number): number => - Math.log10(lerp(1, 10, count / maxCount)) +const scaleLog = (maxCount: number) => + maxCount <= 1 + ? // if the max is 1, a logarithmic scaling function is undefined. but + // since 1 is then the only valid value, in a charting context it makes + // sense to treat that as a scale of 100% + R.constant(1) + : (count: number): number => Math.log(count) / Math.log(maxCount) const scaleRank = (ranks: Map) => ranks.size <= 1 ? R.constant(1) - : (count: number) => (ranks.get(count) ?? 0) / (ranks.size - 1) + : (count: number): number => (ranks.get(count) ?? 0) / (ranks.size - 1) export type SampleCountMetadata = { minSampleCount: number @@ -45,7 +47,11 @@ export const getSampleCountMetadata = (counts: number[]): SampleCountMetadata => } } -/** Maps a sample count to a position on the color ramp, from 0 to 1 */ +/** + * Maps a sample count to a position for a color ramp, from 0 to 1. + * Expects that the count given to the final scaling function is a nonzero + * natural number. + */ export const getScalingFunction = ( metadata: SampleCountMetadata, colorScale: ColorScale diff --git a/app/pages/system/MetricsExplorer.tsx b/app/pages/system/MetricsExplorer.tsx index 27d5ad85c..fd9b9debd 100644 --- a/app/pages/system/MetricsExplorer.tsx +++ b/app/pages/system/MetricsExplorer.tsx @@ -833,8 +833,8 @@ function ChartSettings({ dropFirstPoint, colorScale }: ChartSettingsProps) { )) .with('logarithmic', () => ( <> - Compresses the shading scale at higher values, so smaller - counts are easier to distinguish. + Shades each cell by its count's order of magnitude, so 1, 10, + and 100 are evenly spaced. )) .with('ranked', () => ( From 32cca5af67e9df020035acd3a79bcf8246455399 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 26 Oct 2026 20:34:55 -0700 Subject: [PATCH 4/7] Hide heatmap legend when there's no data, simplify when simple --- app/components/Heatmap.tsx | 49 ++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/app/components/Heatmap.tsx b/app/components/Heatmap.tsx index 5b27219a8..e8142961a 100644 --- a/app/components/Heatmap.tsx +++ b/app/components/Heatmap.tsx @@ -316,15 +316,17 @@ export function Heatmap({ data={data} uRef={uRef} legend={ - sampleCountMetadata.minSampleCount) - .with('linear', () => 0) - .with('logarithmic', () => 1) - .exhaustive()} - maxSampleCount={sampleCountMetadata.maxSampleCount} - axisText={theme.axisText} - /> + sampleCountMetadata.ranks.size > 0 && ( + sampleCountMetadata.minSampleCount) + .with('linear', () => 0) + .with('logarithmic', () => 1) + .exhaustive()} + maxSampleCount={sampleCountMetadata.maxSampleCount} + axisText={theme.axisText} + /> + ) } > {hover && ( @@ -370,15 +372,26 @@ function HeatmapLegend({ }) { return (
- - {minSampleCount.toLocaleString()} - -
+ {minSampleCount === maxSampleCount ? ( +
+ ) : ( + <> + + {minSampleCount.toLocaleString()} + +
+ + )} {maxSampleCount.toLocaleString()} From 6226609b626b2695a244c9bac4b4921236757eaa Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Tue, 27 Oct 2026 11:38:25 -0700 Subject: [PATCH 5/7] Allow settings row to fully collapse, plus small refactors --- app/components/Heatmap.tsx | 5 +- app/components/heatmap-color-scale.ts | 2 - app/pages/system/MetricsExplorer.tsx | 89 ++++++++++++--------------- 3 files changed, 43 insertions(+), 53 deletions(-) diff --git a/app/components/Heatmap.tsx b/app/components/Heatmap.tsx index e8142961a..c90498316 100644 --- a/app/components/Heatmap.tsx +++ b/app/components/Heatmap.tsx @@ -125,8 +125,9 @@ export function Heatmap({ return (u: uPlot) => { if (binCount === 0 || colCount === 0) return - // grabbing style from the nearest available element, since a canvas can't - // resolve CSS variables + // since a canvas can't resolve CSS variables, we simulate it by reading + // the ramp colors off the element furthest down the tree (the chart's + // root element) const style = getComputedStyle(u.root) const colorRamp = { stops: RAMP_STOPS.map( diff --git a/app/components/heatmap-color-scale.ts b/app/components/heatmap-color-scale.ts index 7cde9e3b0..ea26dc43e 100644 --- a/app/components/heatmap-color-scale.ts +++ b/app/components/heatmap-color-scale.ts @@ -10,8 +10,6 @@ import { match } from 'ts-pattern' export type ColorScale = 'linear' | 'logarithmic' | 'ranked' -export const allColorScales: ColorScale[] = ['linear', 'logarithmic', 'ranked'] - const scaleLinear = (maxCount: number) => (count: number): number => diff --git a/app/pages/system/MetricsExplorer.tsx b/app/pages/system/MetricsExplorer.tsx index fd9b9debd..81292866a 100644 --- a/app/pages/system/MetricsExplorer.tsx +++ b/app/pages/system/MetricsExplorer.tsx @@ -42,7 +42,7 @@ import { import { timeseries as timeseriesMetadata } from '~/api/__generated__/timeseries-metadata' import { DocsPopover } from '~/components/DocsPopover' import { Heatmap, SampleColorStrip } from '~/components/Heatmap' -import { allColorScales, type ColorScale } from '~/components/heatmap-color-scale' +import { type ColorScale } from '~/components/heatmap-color-scale' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { errorMessageToSegments, @@ -770,9 +770,25 @@ type ChartSettingsProps = { colorScale: Setting } -function ChartSettings({ dropFirstPoint, colorScale }: ChartSettingsProps) { - if (dropFirstPoint === 'irrelevant' && colorScale === 'irrelevant') return null +const colorScaleInfo = { + linear: { + label: 'Linear', + description: + 'Shades each cell by its count as a fraction of the highest measured count.', + }, + logarithmic: { + label: 'Logarithmic', + description: + "Shades each cell by its count's order of magnitude, so 1, 10, and 100 are evenly spaced.", + }, + ranked: { + label: 'Ranked', + description: + 'Shades a cell by where its count ranks among all measured counts. Not a direct representation of magnitude, but helps emphasize patterns and outliers.', + }, +} satisfies Record +function ChartSettings({ dropFirstPoint, colorScale }: ChartSettingsProps) { return ( @@ -808,7 +824,7 @@ function ChartSettings({ dropFirstPoint, colorScale }: ChartSettingsProps) { Heatmap color scale
- {allColorScales.map((value) => ( + {R.entries(colorScaleInfo).map(([value, { label, description }]) => ( colorScale.onChange(value)} > - {match(value) - .with('linear', () => 'Linear') - .with('logarithmic', () => 'Logarithmic') - .with('ranked', () => 'Ranked') - .exhaustive()} + {label} -

- {match(value) - .with('linear', () => ( - <> - Shades each cell by its count as a fraction of the highest - measured count. - - )) - .with('logarithmic', () => ( - <> - Shades each cell by its count's order of magnitude, so 1, 10, - and 100 are evenly spaced. - - )) - .with('ranked', () => ( - <> - Shades a cell by where its count ranks among all measured - counts. Not a direct representation of magnitude, but helps - emphasize patterns and outliers. - - )) - .exhaustive()} -

+

{description}

@@ -1017,6 +1007,7 @@ export default function MetricsExplorer() { // a query can return several tables, only some of which are distributions const hasHeatmaps = charts.some((c) => c.kind === 'heatmap') + const hasSettings = hasTrimmableCharts || hasHeatmaps // Since the whole window is the scroll container, the virtualizer needs to // know the offset from the top. By reacting to height changes in everything @@ -1028,7 +1019,7 @@ export default function MetricsExplorer() { if (chartsRef.current) { setScrollMargin(chartsRef.current.getBoundingClientRect().top + window.scrollY) } - }, [preChartsSize?.height, charts.length]) + }, [preChartsSize?.height, charts.length, hasSettings]) const virtualizer = useWindowVirtualizer({ count: charts.length, @@ -1129,22 +1120,22 @@ export default function MetricsExplorer() { )) .with({ status: 'success' }, () => ( - {/* By using a consistent height for this settings row, we don't - have to worry about the virtualizer's scroll margin underneath */} -
- -
+ {hasSettings && ( +
+ +
+ )}
Date: Fri, 30 Oct 2026 13:37:46 -0700 Subject: [PATCH 6/7] Add cheap visual tests for point-dropping and color scaling --- test/e2e/oxql.e2e.ts | 47 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts index da6d70180..c2a66e74b 100644 --- a/test/e2e/oxql.e2e.ts +++ b/test/e2e/oxql.e2e.ts @@ -97,7 +97,14 @@ test('joined query renders a chart per instance with a legend line per metric', ]) }) -test('"Drop first point" appears only for cumulative-derived charts', async ({ page }) => { +// Chart contents have no semantic representation, but we can compare canvas +// snapshots within a run. +const chartImage = (canvas: Locator) => + canvas.evaluate((el: HTMLCanvasElement) => el.toDataURL()) + +test('"Drop first point" appears for cumulative-derived charts and updates them', async ({ + page, +}) => { const settings = page.getByRole('button', { name: 'Chart settings' }) const dropFirst = page.getByLabel('Drop first data point') @@ -110,14 +117,26 @@ test('"Drop first point" appears only for cumulative-derived charts', async ({ p // joined/aligned tables may derive from cumulatives, so the option shows up // TODO: if you know the schemas, you can check which tables are cumulative! await runQuery(page, oxqlQueries.multiJoinedTables) + + const canvas = page + .getByRole('figure', { name: 'sled_data_link:bytes_sent,sled_data_link:errors_sent' }) + .first() + .locator('canvas') + await expect(canvas).toBeVisible() + const firstDroppedImage = await chartImage(canvas) await settings.click() await expect(dropFirst).toBeChecked() await dropFirst.uncheck() - await expect(page.getByRole('figure')).toHaveCount(3) + expect(await chartImage(canvas)).not.toEqual(firstDroppedImage) + + await dropFirst.check() + expect(await chartImage(canvas)).toEqual(firstDroppedImage) }) -test('heatmap color scale appears only for distributions', async ({ page }) => { +test('heatmap color scale appears for distributions and updates the chart', async ({ + page, +}) => { const settings = page.getByRole('button', { name: 'Chart settings' }) const colorScale = page.getByRole('group', { name: 'Heatmap color scale' }) @@ -131,11 +150,29 @@ test('heatmap color scale appears only for distributions', async ({ page }) => { await settings.click() const ranked = colorScale.getByRole('radio', { name: 'Ranked', exact: true }) const linear = colorScale.getByRole('radio', { name: 'Linear', exact: true }) + const logarithmic = colorScale.getByRole('radio', { name: 'Logarithmic', exact: true }) await expect(ranked).toBeChecked() + const chart = page.getByRole('figure', { name: 'virtual_disk:io_latency' }).first() + const canvas = chart.locator('canvas') + await expect(canvas).toBeVisible() + + const rankedImage = await chartImage(canvas) + await expect(chart.getByText('1', { exact: true })).toBeVisible() + await linear.check() - await expect(linear).toBeChecked() - await expect(ranked).not.toBeChecked() + const linearImage = await chartImage(canvas) + expect(linearImage).not.toEqual(rankedImage) + await expect(chart.getByText('0', { exact: true })).toBeVisible() + + await logarithmic.check() + const logarithmicImage = await chartImage(canvas) + expect(logarithmicImage).not.toEqual(rankedImage) + expect(logarithmicImage).not.toEqual(linearImage) + await expect(chart.getByText('1', { exact: true })).toBeVisible() + + await ranked.check() + expect(await chartImage(canvas)).toEqual(rankedImage) }) test('results list is virtualized', async ({ page }) => { From f8275499c2eb0cd2a3b6b7eec38c79c54954918f Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 2 Nov 2026 13:29:41 -0800 Subject: [PATCH 7/7] 'irrelevant' -> undefined --- app/pages/system/MetricsExplorer.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/pages/system/MetricsExplorer.tsx b/app/pages/system/MetricsExplorer.tsx index 81292866a..9c938747a 100644 --- a/app/pages/system/MetricsExplorer.tsx +++ b/app/pages/system/MetricsExplorer.tsx @@ -763,7 +763,7 @@ function ChartEntry({ .exhaustive() } -type Setting = { value: T; onChange: (value: T) => void } | 'irrelevant' +type Setting = { value: T; onChange: (value: T) => void } | undefined type ChartSettingsProps = { dropFirstPoint: Setting @@ -803,7 +803,7 @@ function ChartSettings({ dropFirstPoint, colorScale }: ChartSettingsProps) {

Chart settings

- {dropFirstPoint !== 'irrelevant' && ( + {dropFirstPoint && (
)} - {colorScale !== 'irrelevant' && ( + {colorScale && (
Heatmap color scale @@ -1126,12 +1126,10 @@ export default function MetricsExplorer() { dropFirstPoint={ hasTrimmableCharts ? { value: dropFirstPoint, onChange: setDropFirstPoint } - : 'irrelevant' + : undefined } colorScale={ - hasHeatmaps - ? { value: colorScale, onChange: setColorScale } - : 'irrelevant' + hasHeatmaps ? { value: colorScale, onChange: setColorScale } : undefined } />