Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/ui/client/src/components/CompareDelta.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
13 changes: 11 additions & 2 deletions packages/ui/client/src/components/CompareDelta.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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<string, string>
formatDelta: (value: number) => string
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -95,6 +100,8 @@ function hoverCase(key: string, value: number, x: number, y: number): void {
<a :href="props.baseline.href">
<text :x="labelWidth" :y="rowHeight / 2 + 4" font-size="11" text-anchor="end" fill="var(--chart-text)" font-weight="600">{{ truncate(props.baseline.label) }}<title>{{ props.baseline.label }}</title></text>
</a>
<!-- Still running: a spinning ring between the name and the plot, as the run state badges spin. -->
<circle v-if="props.baseline.running" :cx="labelWidth + 6" :cy="rowHeight / 2" r="3.5" fill="none" stroke="var(--mark-running)" stroke-width="1.5" stroke-dasharray="14 8" :class="['animate-spin origin-center [transform-box:fill-box]']" data-testid="compare-delta-running" />
<circle :cx="scale(0)" :cy="rowHeight / 2" r="5" fill="var(--chart-surface)" stroke="var(--chart-text)" stroke-width="2" />
<text :x="width - summaryWidth" :y="rowHeight / 2 + 4" font-size="11" fill="var(--chart-text)">
<tspan font-weight="600">{{ props.formatValue(props.baseline.value.mean) }}</tspan>
Expand All @@ -107,6 +114,8 @@ function hoverCase(key: string, value: number, x: number, y: number): void {
<a :href="row.href">
<text :x="labelWidth" :y="rowHeight / 2 + 4" font-size="11" text-anchor="end" fill="var(--chart-text)" :class="[row.href && 'underline']">{{ truncate(row.label) }}<title>{{ row.label }}</title></text>
</a>
<!-- Still running: a spinning ring between the name and the plot, as the run state badges spin. -->
<circle v-if="row.running" :cx="labelWidth + 6" :cy="rowHeight / 2" r="3.5" fill="none" stroke="var(--mark-running)" stroke-width="1.5" stroke-dasharray="14 8" :class="['animate-spin origin-center [transform-box:fill-box]']" data-testid="compare-delta-running" />
<!-- Every case's difference, spread vertically by a stable offset so equal differences stay countable. -->
<circle
v-for="difference in row.delta.differences" :key="difference.key"
Expand Down
29 changes: 21 additions & 8 deletions packages/ui/client/src/components/ComparePlane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface PlanePoint {
href?: string
id: string
label: string
/** The column's newest run is still running: a pulsing ring around its dot, and its card says so. */
running?: boolean
x: Interval
y: Interval
}
Expand Down Expand Up @@ -50,6 +52,11 @@ const props = defineProps<{
/** Ids on the Pareto front, ordered along x. */
front: readonly string[]
points: readonly PlanePoint[]
/**
* Draw the 95% interval of each mean. Without them the axes fit the means alone, so close columns spread out.
* @default true
*/
whiskers?: boolean
x: CompareAxis
/**
* `log` spreads values that span orders of magnitude (a 4 ms baseline beside 2.5 s rewrites); only for an axis whose
Expand Down Expand Up @@ -150,13 +157,18 @@ function useDomainTween(target: Ref<{ domain: [number, number], kind: ScaleKind
const xTicks = computed(() => Math.max(2, Math.min(6, Math.floor((width.value - margin.left - margin.right) / 90))))
const xKind = computed<ScaleKind>(() => props.xScaleKind ?? 'linear')
const yKind = computed<ScaleKind>(() => 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)
Expand Down Expand Up @@ -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)' },
Expand Down Expand Up @@ -352,7 +364,7 @@ const pill = 'absolute rounded px-1 text-xs tabular-nums whitespace-nowrap point
<path v-if="props.front.length > 1" :d="frontPath" fill="none" stroke="var(--series-1)" stroke-width="2" stroke-dasharray="4 4" opacity="0.55" pointer-events="none" />

<!-- Whiskers first, so every dot and label is drawn over every whisker. -->
<g v-for="mark in marks" :key="`w${mark.id}`" :stroke="colorOf(mark.id)" stroke-width="1.5" pointer-events="none" :opacity="focusId == null || focusId === mark.id ? 0.35 : 0.1" :class="['transition-opacity']">
<g v-for="mark in props.whiskers === false ? [] : marks" :key="`w${mark.id}`" :stroke="colorOf(mark.id)" stroke-width="1.5" pointer-events="none" :opacity="focusId == null || focusId === mark.id ? 0.35 : 0.1" :class="['transition-opacity']">
<line :x1="xScale(mark.x.low)" :x2="xScale(mark.x.high)" :y1="mark.cy" :y2="mark.cy" />
<line :x1="xScale(mark.x.low)" :x2="xScale(mark.x.low)" :y1="mark.cy - 3" :y2="mark.cy + 3" />
<line :x1="xScale(mark.x.high)" :x2="xScale(mark.x.high)" :y1="mark.cy - 3" :y2="mark.cy + 3" />
Expand All @@ -362,6 +374,7 @@ const pill = 'absolute rounded px-1 text-xs tabular-nums whitespace-nowrap point
</g>

<g v-for="mark in marks" :key="mark.id" :opacity="focusId == null || focusId === mark.id ? 1 : 0.35" :class="['transition-opacity cursor-pointer']" @mouseenter="isDesktop && (hovered = mark.id)" @mouseleave="hovered = undefined" @click="pin(mark.id)">
<circle v-if="mark.running" :cx="mark.cx" :cy="mark.cy" r="9" fill="none" stroke="var(--mark-running)" stroke-width="1.5" :class="['vv-pulse']" data-testid="compare-plane-running" />
<circle v-if="mark.id === pinned" :cx="mark.cx" :cy="mark.cy" r="10" fill="none" :stroke="colorOf(mark.id)" stroke-width="2" opacity="0.5" />
<!-- Front and baseline dots are filled, beaten ones hollow (filled with the card surface). -->
<circle
Expand Down Expand Up @@ -408,7 +421,7 @@ const pill = 'absolute rounded px-1 text-xs tabular-nums whitespace-nowrap point
<AnimatedContent>
<div v-if="shown" :class="['w-72 max-w-[calc(100vw-2rem)]']">
<ComparePointCard
:label="shown.mark.label" :href="shown.mark.href" :pinned="shown.pinned" :is-baseline="shown.mark.id === props.baseline"
:label="shown.mark.label" :running="shown.mark.running" :href="shown.mark.href" :pinned="shown.pinned" :is-baseline="shown.mark.id === props.baseline"
:baseline-label="props.baselineLabel" :readings="readingsOf(shown.mark)"
@unpin="pinned = undefined" @baseline="makeBaseline(shown.mark.id)" @hide="hide(shown.mark.id)"
/>
Expand All @@ -432,7 +445,7 @@ const pill = 'absolute rounded px-1 text-xs tabular-nums whitespace-nowrap point
</DrawerDescription>
<div v-if="pinnedPoint" :class="['overflow-y-auto min-h-0']">
<ComparePointCard
:label="pinnedPoint.label" :href="pinnedPoint.href" pinned :is-baseline="pinnedPoint.id === props.baseline"
:label="pinnedPoint.label" :running="pinnedPoint.running" :href="pinnedPoint.href" pinned :is-baseline="pinnedPoint.id === props.baseline"
:baseline-label="props.baselineLabel" :readings="readingsOf(pinnedPoint)"
@unpin="pinned = undefined" @baseline="makeBaseline(pinnedPoint.id)" @hide="hide(pinnedPoint.id)"
/>
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/client/src/components/ComparePointCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -63,6 +67,7 @@ function barPosition(delta: PairedDelta, value: number): number {
<template>
<div :class="['grid gap-2.5 text-xs']" data-testid="compare-point-card">
<div :class="['flex items-center gap-1 min-w-0']">
<StateBadge v-if="props.running" state="running" icon-only />
<span :class="['font-semibold text-sm truncate']">{{ props.label }}</span>
<span v-if="props.isBaseline" :class="['muted shrink-0']">· {{ t('compare.chart.baselineTag') }}</span>
<template v-if="props.pinned">
Expand Down
43 changes: 43 additions & 0 deletions packages/ui/client/src/components/CompareScoreTable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { CompareAxis, Interval } from '../comparison'

import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-vue'

import CompareScoreTable from './CompareScoreTable.vue'

/** `count` score axes and `rows` columns, every column at 50% on every axis, without a baseline. */
function table(count: number, rows: number) {
const axes: CompareAxis[] = Array.from({ length: count }, (_, index) => ({ better: 'higher', kind: 'score', label: `score ${index}`, name: `score-${index}` }))
const columns = Array.from({ length: rows }, (_, index) => ({ id: `column-${index}`, label: `column ${index}` }))
const interval: Interval = { high: 0.6, low: 0.4, mean: 0.5, n: 10 }
const intervals = new Map(axes.map(axis => [axis.name, new Map(columns.map(column => [column.id, interval]))]))
return { axes, columns, deltas: new Map(), formatDelta: String, formatValue: String, intervals, sortBy: 'score-0' }
}

describe('compareScoreTable', () => {
/**
* @example
* expect(screen.container.querySelectorAll('tbody tr[data-hidden], tbody tr:not([aria-hidden])').length).toBeLessThan(120)
*/
it('renders only the rows in view once a comparison has many columns, and scrolls both ways', async () => {
const screen = await render(CompareScoreTable, { props: table(30, 120) })
const scroller = screen.container.querySelector<HTMLElement>('[data-testid=compare-score-table]')!

expect(scroller.dataset.virtual).toBe('true')
expect(screen.container.querySelectorAll('tbody tr:not([aria-hidden])').length).toBeLessThan(120)
expect(getComputedStyle(scroller).overflow).toBe('auto')
expect(scroller.scrollWidth).toBeGreaterThan(scroller.clientWidth)
})

/**
* @example
* expect(scroller.dataset.virtual).toBeUndefined()
*/
it('renders every row of a small comparison', async () => {
const screen = await render(CompareScoreTable, { props: table(8, 7) })
const scroller = screen.container.querySelector<HTMLElement>('[data-testid=compare-score-table]')!

expect(scroller.dataset.virtual).toBeUndefined()
expect(screen.container.querySelectorAll('tbody tr:not([aria-hidden])')).toHaveLength(7)
})
})
Loading
Loading