diff --git a/packages/ui/client/src/components/CompareDelta.test.ts b/packages/ui/client/src/components/CompareDelta.test.ts new file mode 100644 index 0000000..183a478 --- /dev/null +++ b/packages/ui/client/src/components/CompareDelta.test.ts @@ -0,0 +1,32 @@ +import type { CompareAxis, Interval, PairedDelta } from '../comparison' + +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-vue' + +import CompareDelta from './CompareDelta.vue' + +const axis: CompareAxis = { better: 'higher', kind: 'score', label: 'Overall', name: 'overall' } +const value: Interval = { high: 0.8, low: 0.6, mean: 0.7, n: 6 } +const delta: PairedDelta = { columnId: 'b', differences: [{ key: 'case-0', value: 0.1 }], interval: { high: 0.2, low: -0.05, mean: 0.1, n: 6 }, losses: 1, ties: 0, wins: 5 } +const format = (number: number) => number.toFixed(2) + +describe('compareDelta', () => { + /** + * @example + * expect(screen.container.querySelectorAll('[data-testid=compare-delta-running]')).toHaveLength(1) + */ + it('marks a column whose newest run is still running, and only that one', async () => { + const screen = await render(CompareDelta, { + props: { + axis, + baseline: { label: 'a', value }, + caseLabels: new Map([['case-0', 'case 0']]), + formatDelta: format, + formatValue: format, + rows: [{ delta, label: 'b', running: true, value }, { delta: { ...delta, columnId: 'c' }, label: 'c', value }], + }, + }) + + expect(screen.container.querySelectorAll('[data-testid=compare-delta-running]')).toHaveLength(1) + }) +}) diff --git a/packages/ui/client/src/components/CompareDelta.vue b/packages/ui/client/src/components/CompareDelta.vue index d77b40f..c3a344b 100644 --- a/packages/ui/client/src/components/CompareDelta.vue +++ b/packages/ui/client/src/components/CompareDelta.vue @@ -15,6 +15,8 @@ export interface DeltaRow { hidden?: boolean href?: string label: string + /** The column's newest run is still running: a spinning ring beside its name. */ + running?: boolean /** The column's own mean on the axis, shown next to its difference. */ value: Interval } @@ -32,7 +34,7 @@ export interface DeltaRow { const props = defineProps<{ axis: CompareAxis /** The reference row, drawn first on the zero line with its own mean. */ - baseline: { href?: string, label: string, value: Interval } + baseline: { href?: string, label: string, running?: boolean, value: Interval } /** Case name of each row key, for the case dots' tooltips. */ caseLabels: ReadonlyMap formatDelta: (value: number) => string @@ -61,7 +63,10 @@ const ticks = computed(() => Math.max(2, Math.min(6, Math.floor((plotRight.value const scale = computed(() => { const reach = Math.max(1e-9, ...props.rows.filter(row => !row.hidden).flatMap(row => [Math.abs(row.delta.interval.low), Math.abs(row.delta.interval.high), ...row.delta.differences.map(difference => Math.abs(difference.value))])) // Clamped: a hidden outlier left out of the domain stays pinned at the edge instead of running off the chart. - return scaleLinear([-reach * 1.05, reach * 1.05], [plotLeft.value, plotRight.value]).nice(ticks.value).clamp(true) + // NOTICE: the domain is rounded for a fixed 6 ticks, not for the ticks this width shows: rounding for fewer ticks + // widened it (±40 pt became ±60 pt on a phone), so one difference looked smaller on a narrow screen. Only the tick + // labels thin out with the width. + return scaleLinear([-reach * 1.05, reach * 1.05], [plotLeft.value, plotRight.value]).nice(6).clamp(true) }) /** Whether the interval is clear of zero, and if so whether the column is better by the axis's direction. */ @@ -95,6 +100,8 @@ function hoverCase(key: string, value: number, x: number, y: number): void { {{ truncate(props.baseline.label) }}{{ props.baseline.label }} + + {{ props.formatValue(props.baseline.value.mean) }} @@ -107,6 +114,8 @@ function hoverCase(key: string, value: number, x: number, y: number): void { {{ truncate(row.label) }}{{ row.label }} + + Math.max(2, Math.min(6, Math.floor((width.value - margin.left - margin.right) / 90)))) const xKind = computed(() => props.xScaleKind ?? 'linear') const yKind = computed(() => props.yScaleKind ?? 'linear') -/** Domain each axis settles on: the nice linear domain, or the log domain. */ +/** The part of an interval the axes must show: all of it with whiskers, the mean alone without. */ +const fitted = (interval: Interval): Interval => (props.whiskers === false ? { ...interval, high: interval.mean, low: interval.mean } : interval) +/** + * Domain each axis settles on: the nice linear domain, or the log domain. It is rounded for a fixed tick count, so it is + * the same on every screen; a narrow plane only shows fewer tick labels (see the domain NOTICE in `CompareDelta.vue`). + */ const xTarget = computed(() => { - const intervals = props.points.map(point => point.x) - return { domain: xKind.value === 'log' ? logDomainOf(intervals, props.x) : scaleLinear(domainOf(intervals, props.x), [0, 1]).nice(xTicks.value).domain() as [number, number], kind: xKind.value } + const intervals = props.points.map(point => fitted(point.x)) + return { domain: xKind.value === 'log' ? logDomainOf(intervals, props.x) : scaleLinear(domainOf(intervals, props.x), [0, 1]).nice(6).domain() as [number, number], kind: xKind.value } }) const yTarget = computed(() => { - const intervals = props.points.map(point => point.y) + const intervals = props.points.map(point => fitted(point.y)) return { domain: yKind.value === 'log' ? logDomainOf(intervals, props.y) : scaleLinear(domainOf(intervals, props.y), [0, 1]).nice(5).domain() as [number, number], kind: yKind.value } }) const xDomain = useDomainTween(xTarget) @@ -322,7 +334,7 @@ const crosshair = computed(() => { */ const whiskerLabels = computed(() => { const mark = focused.value - if (mark == null) + if (mark == null || props.whiskers === false) return [] const ends = [ { key: 'x-low', left: xScale.value(mark.x.low), text: props.formatX(mark.x.low), top: mark.cy + 8, transform: 'translate(-50%, 0)' }, @@ -352,7 +364,7 @@ const pill = 'absolute rounded px-1 text-xs tabular-nums whitespace-nowrap point - + @@ -362,6 +374,7 @@ const pill = 'absolute rounded px-1 text-xs tabular-nums whitespace-nowrap point + @@ -432,7 +445,7 @@ const pill = 'absolute rounded px-1 text-xs tabular-nums whitespace-nowrap point
diff --git a/packages/ui/client/src/components/ComparePointCard.vue b/packages/ui/client/src/components/ComparePointCard.vue index 46fa0a0..5fd52b2 100644 --- a/packages/ui/client/src/components/ComparePointCard.vue +++ b/packages/ui/client/src/components/ComparePointCard.vue @@ -5,6 +5,8 @@ import { TooltipContent, TooltipPortal, TooltipRoot, TooltipTrigger } from 'reka import { computed } from 'vue' import { useI18n } from 'vue-i18n' +import StateBadge from './StateBadge.vue' + /** One axis of a compared column, as the card reads it. */ export interface PointAxisReading { axis: CompareAxis @@ -31,6 +33,8 @@ const props = defineProps<{ label: string pinned: boolean readings: readonly PointAxisReading[] + /** The column's newest run is still running: its numbers move as cases settle. */ + running?: boolean }>() const emit = defineEmits<{ baseline: [], hide: [], unpin: [] }>() const { t } = useI18n() @@ -63,6 +67,7 @@ function barPosition(delta: PairedDelta, value: number): number {