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
65 changes: 65 additions & 0 deletions packages/ui/client/src/components/CompletionRing.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<script setup lang="ts">
import type { CellCompletion } from '../matrixGrid'

import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

import { formatCount } from '../format'

/**
* Small ring of how many cases of a matrix cell have settled, in the state colors of the case bars (`CountsBar`):
* passed, then failed, then no result (`errored`, `timeout`), on a faint track for the cases still missing. A full
* ring is a complete cell; a running cell pulses.
*/
const props = withDefaults(defineProps<{
completion: CellCompletion
/** Outer size in pixels. */
size?: number
}>(), { size: 16 })
const { t } = useI18n()

const stroke = computed(() => (props.size < 12 ? 2 : 2.5))
const radius = computed(() => (props.size - stroke.value) / 2)
const length = computed(() => 2 * Math.PI * radius.value)
/** Arcs one after another around the ring, each a share of the expected cases (the whole ring at most). */
const arcs = computed(() => {
const expected = Math.max(1, props.completion.expected)
const parts = [
{ color: 'var(--mark-passed)', count: props.completion.scored - props.completion.failed },
{ color: 'var(--mark-failed)', count: props.completion.failed },
{ color: 'var(--mark-errored)', count: props.completion.errored },
]
let start = 0
return parts.flatMap((part) => {
const share = Math.min(1 - start, part.count / expected)
if (share <= 0)
return []
const arc = { color: part.color, share, start }
start += share
return [arc]
})
})
const label = computed(() => t(props.completion.errored > 0 ? 'matrix.ring.errored' : 'matrix.ring.label', {
errored: formatCount(props.completion.errored),
expected: formatCount(props.completion.expected),
scored: formatCount(props.completion.scored),
}) + (props.completion.running ? ` · ${t('matrix.ring.running')}` : ''))
</script>

<template>
<svg
:class="['shrink-0', props.completion.running && 'animate-pulse']"
:width="props.size" :height="props.size" :viewBox="`0 0 ${props.size} ${props.size}`"
role="img" :aria-label="label" data-testid="completion-ring"
>
<title>{{ label }}</title>
<g :transform="`rotate(-90 ${props.size / 2} ${props.size / 2})`" fill="none" :stroke-width="stroke">
<circle :cx="props.size / 2" :cy="props.size / 2" :r="radius" :class="['stroke-neutral-500/25']" />
<circle
v-for="arc in arcs" :key="arc.color" :cx="props.size / 2" :cy="props.size / 2" :r="radius"
:style="{ stroke: arc.color }"
:stroke-dasharray="`${arc.share * length} ${length}`" :stroke-dashoffset="-arc.start * length"
/>
</g>
</svg>
</template>
4 changes: 3 additions & 1 deletion packages/ui/client/src/components/HeadlineScore.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const props = defineProps<{
align?: 'end' | 'start'
/** `score.defined` of the headline's scores (`headlineDefinitions`, `scoreDefinitions`). */
definitions: readonly ScoreDefinition[]
/** Leaves the completion rate out, for a place that shows it another way (the progress ring of a matrix card). */
hideCompletion?: boolean
/**
* Called when the completion rate below 100 % is clicked; the page shows the cases without a result. Without it, the
* rate is plain text (inside a card that is a link itself, for example).
Expand Down Expand Up @@ -62,7 +64,7 @@ const column = computed<ColumnDefinition | undefined>(() => {
})
const unscoredCount = computed(() => Object.values(view.value?.summary.unscored ?? {}).reduce((sum, count) => sum + count, 0))
const incomplete = computed(() => view.value != null && view.value.completion.mean != null && view.value.completion.mean < 1)
const showCompletion = computed(() => view.value != null && view.value.kind !== 'per-task' && (props.size !== 'sm' || incomplete.value))
const showCompletion = computed(() => view.value != null && view.value.kind !== 'per-task' && !props.hideCompletion && (props.size !== 'sm' || incomplete.value))
const valueClass = computed(() => ({ lg: 'text-2xl', md: 'text-xl', sm: 'text-sm' })[props.size ?? 'md'])
</script>

Expand Down
38 changes: 38 additions & 0 deletions packages/ui/client/src/components/MarginBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'

/**
* Bar of a row's or column's summary on the margin of a matrix: a mean measured from 0, or a difference measured from
* a center line (gains up or right in the score hue, losses down or left in teal, as in `PairsHeatmap.vue`).
*
* The end on the baseline stays square and only the far end is rounded, so the bar reads as growing out of the
* baseline and its length is measured from there.
*/
const props = defineProps<{
/** `true`: a difference around a center line; `false`: a value from 0. */
diverging: boolean
/** Largest |value| on this margin, which fills the bar (or half of it when diverging). */
scale: number
value: number
vertical?: boolean
}>()

const extent = computed(() => `${Math.min(1, Math.abs(props.value) / (props.scale || 1)) * (props.diverging ? 50 : 100)}%`)
const negative = computed(() => props.diverging && props.value < 0)
const fill = computed(() => {
// Where the bar starts (the baseline) and which end is rounded.
if (props.vertical) {
const from = !props.diverging ? { bottom: '0' } : negative.value ? { top: '50%' } : { bottom: '50%' }
return { ...from, borderRadius: negative.value ? '0 0 2px 2px' : '2px 2px 0 0', height: extent.value, left: '0', right: '0' }
}
const from = !props.diverging ? { left: '0' } : negative.value ? { right: '50%' } : { left: '50%' }
return { ...from, borderRadius: negative.value ? '2px 0 0 2px' : '0 2px 2px 0', bottom: '0', top: '0', width: extent.value }
})
</script>

<template>
<span :class="['relative block shrink-0 rounded-sm bg-neutral-500/12', props.vertical ? 'w-2.5 h-9' : 'w-14 h-2']" aria-hidden="true">
<span v-if="props.diverging" :class="['absolute rounded-full bg-neutral-500/60', props.vertical ? 'inset-x-[-2px] top-1/2 h-0.5 -translate-y-1/2' : 'inset-y-[-2px] left-1/2 w-0.5 -translate-x-1/2']" />
<span :class="['absolute']" :style="{ ...fill, background: negative ? 'var(--series-3)' : 'var(--series-1)' }" />
</span>
</template>
170 changes: 170 additions & 0 deletions packages/ui/client/src/components/MatrixCellCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<script setup lang="ts">
import type { MatrixCell, ScoreDefinition } from '../../../src/shared/contract'
import type { CellComparison, GridCell } from '../matrixGrid'

import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

import CompletionRing from './CompletionRing.vue'
import ScoreBar from './ScoreBar.vue'
import ScoreCard from './ScoreCard.vue'
import Sparkline from './Sparkline.vue'

import { useCatalog } from '../composables/useCatalog'
import { formatCount, formatScore } from '../format'
import { attemptLabel, formatShortDateTime } from '../labels'
import { href } from '../router'
import { headlineOf, headlineValue, headlineView } from '../scores'

/**
* The card of one matrix cell: its headline, how the case scores spread, when it ran, and its trend across runs. The
* card view of the matrix lays these out in the grid; the compact view opens the same card on hover, so both views
* say the same things about a cell.
*
* A cell averaged over repeats (`seed`, `repeat`) shows the pooled score and each repeat's value in place of one run's
* time and trend.
*/
const props = defineProps<{
/** Names of the averaged axes, for the line of each repeat's value. */
averagedNames: readonly string[]
cell: GridCell
/** The cell against the reader's baseline; its difference replaces the change from the previous run. */
comparison?: CellComparison
definitions: readonly ScoreDefinition[]
}>()
const { t } = useI18n()
const catalog = useCatalog()

const single = computed<MatrixCell | undefined>(() => (props.cell.members.length === 1 ? props.cell.members[0] : undefined))
const lead = computed(() => props.cell.members[0]!)
const headline = computed(() => headlineOf(props.cell.scoring))
const label = computed(() => headlineView(headline.value, props.definitions).label)
const running = computed(() => props.cell.members.some(member => member.latest.state === 'running' || member.latest.state === 'queued'))
/**
* The catalog entry of the run still running in this cell (of an averaged cell, the repeat that runs), whose event
* sequence drives the progress and the expected end of the card.
*/
const run = computed(() => {
const active = props.cell.members.find(member => member.latest.state === 'running' || member.latest.state === 'queued') ?? lead.value
return catalog.runs.value.find(item => item.runKey === active.latest.runKey)
})
const caseTotal = computed(() => props.cell.members.reduce((total, member) => total + member.latest.counts.total, 0))
/** When the cell's runs started: one time, or the first and the last of an averaged cell's repeats. */
const startedText = computed(() => {
const times = props.cell.members.map(member => member.latest.startedAt).filter((time): time is string => time != null).toSorted()
if (times.length === 0)
return attemptLabel(lead.value.latest.attemptId)
const first = formatShortDateTime(new Date(times[0]!))
const last = formatShortDateTime(new Date(times.at(-1)!))
return first === last ? first : `${first} – ${last}`
})
/** Members whose newest run has not measured every case yet, for the line that says so. */
const partial = computed(() => props.cell.members.filter(member => member.latest.coverage !== 'full' && member.latest.state !== 'running' && member.latest.state !== 'queued'))
const counts = computed(() => {
const sum = (pick: (member: MatrixCell) => number) => props.cell.members.reduce((total, member) => total + pick(member), 0)
return {
errored: sum(member => member.latest.counts.errored),
failed: sum(member => member.latest.counts.failed),
passed: sum(member => member.latest.counts.passed),
requeued: sum(member => member.latest.counts.requeued),
running: sum(member => member.latest.counts.running),
skipped: sum(member => member.latest.counts.skipped),
timeout: sum(member => member.latest.counts.timeout),
total: sum(member => Math.max(member.latest.counts.total, member.latest.caseTotal ?? 0)),
}
})
/**
* Whether the cell is read by its score: it has a primary score and no case failed an assertion, so the case counts
* are all green and say nothing; how the scores spread does.
*/
const scored = computed(() => props.cell.scoring.primary != null && counts.value.failed === 0 && !running.value)
const state = computed(() => {
const states = [...new Set(props.cell.members.map(member => member.latest.state))]
const shown = running.value ? 'running' : states.length === 1 ? states[0] : undefined
return scored.value && shown === 'passed' ? undefined : shown
})

/**
* Change of the primary score from the previous full run, in percentage points; `undefined` unless the cell's run and
* an earlier run both covered the cell fully (a partial run or one without a result is no baseline).
*/
const previousDelta = computed(() => {
const cell = single.value
const latest = props.cell.value
if (cell == null || cell.latest.coverage !== 'full' || latest == null)
return undefined
const index = cell.history.findIndex(point => point.runKey === cell.latest.runKey)
const previous = cell.history.slice(0, Math.max(0, index)).findLast(point => point.coverage === 'full' && point.score != null)
return previous == null ? undefined : (latest - previous.score!) * 100
})
const baselineDelta = computed(() => (props.comparison == null ? undefined : props.comparison.delta * 100))

/** When a point of a cell's history started: its run's start time, else the attempt label. */
function pointTime(point: MatrixCell['history'][number]): string {
return point.startedAt == null ? attemptLabel(point.attemptId) : formatShortDateTime(new Date(point.startedAt))
}
/** Tooltip of one history point: when, how much it measured, its score, and its completion rate. */
function pointTitle(cell: MatrixCell, point: MatrixCell['history'][number]): string {
return t('matrix.view.pointTitle', {
completion: formatScore(point.completion),
coverage: t(`matrix.view.coverage.${point.coverage}`),
measured: formatCount(point.counts.passed + point.counts.failed),
score: label.value,
time: pointTime(point),
total: formatCount(cell.caseCount),
value: formatScore(point.score),
})
}
</script>

<template>
<ScoreCard
compact hide-completion
:href="href({ name: 'task', runKey: lead.latest.runKey, taskId: lead.latest.taskId })"
:headline="headline"
:definitions="props.definitions"
:state="state"
:counts="scored ? undefined : counts"
:run="running ? run : undefined"
data-testid="matrix-cell"
>
<template #score>
<span
v-if="baselineDelta !== undefined"
:class="['text-xs font-medium tabular-nums', props.comparison!.clear ? (baselineDelta >= 0 ? 'text-primary-700 dark:text-primary-300' : '[color:var(--series-3)]') : 'muted']"
:title="t(props.comparison!.clear ? 'matrix.baseline.clear' : 'matrix.baseline.noise')"
data-testid="matrix-cell-baseline-delta"
>{{ baselineDelta > 0 ? '+' : baselineDelta < 0 ? '−' : '±' }}{{ Math.abs(baselineDelta).toFixed(1) }}</span>
<span
v-else-if="previousDelta !== undefined && Math.abs(previousDelta) >= 0.05"
:class="['inline-flex items-center text-xs font-medium tabular-nums', previousDelta > 0 ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400']"
:title="t('matrix.view.deltaTitle', { delta: `${previousDelta > 0 ? '+' : ''}${previousDelta.toFixed(1)}` })"
data-testid="matrix-cell-delta"
>
<span :class="[previousDelta > 0 ? 'i-mingcute-arrow-up-line' : 'i-mingcute-arrow-down-line']" aria-hidden="true" />{{ Math.abs(previousDelta).toFixed(1) }}
</span>
</template>
<!-- In place of "completed 96.8%": a full ring when every case settled, a gap when some are missing. -->
<template #corner>
<CompletionRing :completion="props.cell.progress" :size="18" />
</template>
<template v-if="scored" #bar>
<ScoreBar :class="['w-full']" :scores="props.cell.caseScores" />
</template>
<span :class="['flex items-center gap-1.5 min-w-0']" :title="props.cell.members.map(member => `${attemptLabel(member.latest.attemptId)} · ${member.latest.runKey}`).join('\n')">
<span :class="['truncate']">{{ startedText }} · {{ t('matrix.view.caseCount', { n: formatCount(caseTotal) }, caseTotal) }}<template v-if="single"> · {{ t('matrix.view.runCount', { n: formatCount(single.history.length) }, single.history.length) }}</template></span>
<Sparkline
v-if="single"
:class="['ml-auto']" :values="single.history.map(point => point.score)" :coverage="single.history.map(point => point.coverage)" :titles="single.history.map(point => pointTitle(single!, point))"
:label="t('matrix.view.sparkline', { n: formatCount(single.history.length), score: label }, single.history.length)" :width="48" :height="16"
/>
</span>
<span v-if="!single" :class="['truncate tabular-nums']" :title="props.cell.members.map(member => `${props.averagedNames.map(name => member.run[name] ?? member.eval[name] ?? '—').join(' · ')}: ${formatScore(headlineValue(headlineOf(member.latest.scoring)))}`).join('\n')" data-testid="matrix-cell-members">
{{ t('matrix.averaged.members', { axes: props.averagedNames.join(' · '), n: formatCount(props.cell.members.length) }, props.cell.members.length) }}
{{ props.cell.members.map(member => formatScore(headlineValue(headlineOf(member.latest.scoring)))).join(' / ') }}
</span>
<span v-for="member in partial" :key="member.runRowId" :class="['flex items-center gap-1.5 [color:var(--ink-errored)]']" data-testid="matrix-cell-partial">
<span class="i-mingcute-alert-octagon-line" aria-hidden="true" />{{ t('matrix.view.partial', { measured: formatCount(member.latest.counts.passed + member.latest.counts.failed), total: formatCount(member.caseCount) }) }}
</span>
</ScoreCard>
</template>
Loading
Loading