-
Notifications
You must be signed in to change notification settings - Fork 17
feat(studio): MetricTrendPanel #1269
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import type { Meta, StoryObj } from '@storybook/react'; | ||
| import { | ||
| MetricTrendPanel, | ||
| type MetricTrendPoint, | ||
| } from '@studio/components/charts/MetricTrendPanel'; | ||
|
|
||
| /** Deterministic wobbly upward trend so the story renders the same every run. */ | ||
| const makePoints = (start: number, step: number, count = 30): MetricTrendPoint[] => | ||
| Array.from({ length: count }, (_, i) => ({ | ||
| label: `Day ${i + 1}`, | ||
| value: Number((start + i * step + (i % 3 === 0 ? -step : step) * 0.8).toFixed(1)), | ||
| })); | ||
|
|
||
| const meta: Meta<typeof MetricTrendPanel> = { | ||
| title: 'Charts/MetricTrendPanel', | ||
| component: MetricTrendPanel, | ||
| args: { | ||
| title: 'Primary use cases', | ||
| description: | ||
| 'Continuously evaluate every merge to main against the full Support-Bench v3 benchmark.', | ||
| comparisonLabel: 'vs. 7 days ago', | ||
| series: [ | ||
| { id: 'solved', label: 'Solved', value: 78.4, delta: 3.3, points: makePoints(62, 0.55) }, | ||
| { | ||
| id: 'helpfulness', | ||
| label: 'Helpfulness', | ||
| value: 84.1, | ||
| delta: 1.2, | ||
| points: makePoints(74, 0.35), | ||
| }, | ||
| { id: 'tool-use', label: 'Tool Use', value: 69.2, delta: -2.4, points: makePoints(78, -0.3) }, | ||
| ], | ||
| }, | ||
| decorators: [ | ||
| (Story) => ( | ||
| <div className="max-w-4xl"> | ||
| <Story /> | ||
| </div> | ||
| ), | ||
| ], | ||
| }; | ||
|
|
||
| export default meta; | ||
|
|
||
| type Story = StoryObj<typeof MetricTrendPanel>; | ||
|
|
||
| export const Default: Story = { | ||
| args: { onViewClick: () => {} }, | ||
| }; | ||
|
|
||
| export const SingleSeries: Story = { | ||
| args: { | ||
| series: [ | ||
| { id: 'solved', label: 'Solved', value: 78.4, delta: 3.3, points: makePoints(62, 0.55) }, | ||
| ], | ||
| }, | ||
| }; | ||
|
|
||
| export const NegativeDelta: Story = { | ||
| args: { selectedSeriesId: 'tool-use' }, | ||
| }; | ||
|
|
||
| export const Loading: Story = { | ||
| args: { isPending: true }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { | ||
| MetricTrendPanel, | ||
| type MetricTrendSeries, | ||
| } from '@studio/components/charts/MetricTrendPanel'; | ||
| import { fireEvent, render, screen } from '@studio/tests/util/render'; | ||
|
|
||
| const series: MetricTrendSeries[] = [ | ||
| { | ||
| id: 'solved', | ||
| label: 'Solved', | ||
| value: 78.4, | ||
| delta: 3.3, | ||
| points: [ | ||
| { label: 'Day 1', value: 70 }, | ||
| { label: 'Day 2', value: 78.4 }, | ||
| ], | ||
| }, | ||
| { | ||
| id: 'tool-use', | ||
| label: 'Tool Use', | ||
| value: 69.2, | ||
| delta: -2.4, | ||
| points: [ | ||
| { label: 'Day 1', value: 71 }, | ||
| { label: 'Day 2', value: 69.2 }, | ||
| ], | ||
| }, | ||
| ]; | ||
|
|
||
| describe('MetricTrendPanel', () => { | ||
| it('renders the first series value and delta by default', () => { | ||
| render( | ||
| <MetricTrendPanel | ||
| title="Primary use cases" | ||
| series={series} | ||
| comparisonLabel="vs. 7 days ago" | ||
| /> | ||
| ); | ||
|
|
||
| expect(screen.getByText('78.4%')).toBeInTheDocument(); | ||
| expect(screen.getByText('+3.3')).toBeInTheDocument(); | ||
| expect(screen.getByText('vs. 7 days ago')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('switches series when a pill is clicked', () => { | ||
| render(<MetricTrendPanel title="Primary use cases" series={series} />); | ||
|
|
||
| fireEvent.click(screen.getByRole('button', { name: 'Tool Use' })); | ||
|
|
||
| expect(screen.getByText('69.2%')).toBeInTheDocument(); | ||
| expect(screen.getByText('−2.4')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('stays on the controlled series and reports the change', () => { | ||
| const onSeriesChange = vi.fn(); | ||
| render( | ||
| <MetricTrendPanel | ||
| title="Primary use cases" | ||
| series={series} | ||
| selectedSeriesId="solved" | ||
| onSeriesChange={onSeriesChange} | ||
| /> | ||
| ); | ||
|
|
||
| fireEvent.click(screen.getByRole('button', { name: 'Tool Use' })); | ||
|
|
||
| expect(onSeriesChange).toHaveBeenCalledWith('tool-use'); | ||
| expect(screen.getByText('78.4%')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('calls onViewClick from the header action', () => { | ||
| const onViewClick = vi.fn(); | ||
| render( | ||
| <MetricTrendPanel title="Primary use cases" series={series} onViewClick={onViewClick} /> | ||
| ); | ||
|
|
||
| fireEvent.click(screen.getByRole('button', { name: 'View' })); | ||
|
|
||
| expect(onViewClick).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,215 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { | ||
| Button, | ||
| Flex, | ||
| PanelContent, | ||
| PanelHeader, | ||
| PanelRoot, | ||
| Stack, | ||
| Tag, | ||
| Text, | ||
| } from '@nvidia/foundations-react-core'; | ||
| import { useNvColorMode } from '@studio/components/DagCanvas/useNvColorMode'; | ||
| import { StackedSkeleton } from '@studio/components/StackedSkeleton'; | ||
| import { Triangle } from 'lucide-react'; | ||
| import { FC, useId, useMemo, useState } from 'react'; | ||
| import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; | ||
|
|
||
| export interface MetricTrendPoint { | ||
| /** X-axis label for the point, shown in the tooltip. */ | ||
| label: string; | ||
| value: number; | ||
| } | ||
|
|
||
| export interface MetricTrendSeries { | ||
| id: string; | ||
| /** Pill label, e.g. "Solved". */ | ||
| label: string; | ||
| value: number; | ||
| /** Change over the compared period. Positive renders up, negative down. */ | ||
| delta?: number; | ||
| points: MetricTrendPoint[]; | ||
| } | ||
|
|
||
| interface Props { | ||
| title: string; | ||
| description?: string; | ||
| series: MetricTrendSeries[]; | ||
| /** Qualifies the delta, e.g. "vs. 7 days ago". */ | ||
| comparisonLabel?: string; | ||
| /** Controls the active pill. Leave undefined to let the panel manage it. */ | ||
| selectedSeriesId?: string; | ||
| onSeriesChange?: (seriesId: string) => void; | ||
| onViewClick?: () => void; | ||
| viewLabel?: string; | ||
| formatValue?: (value: number) => string; | ||
| formatDelta?: (delta: number) => string; | ||
| chartHeight?: number; | ||
| isPending?: boolean; | ||
| } | ||
|
|
||
| const DEFAULT_CHART_HEIGHT = 160; | ||
|
|
||
| /** | ||
| * The area fill sits on a near-black surface in dark mode, so the light-mode opacities | ||
| * wash out to almost nothing. Carry more of the accent color, and keep a floor at the | ||
| * bottom of the gradient so the fill stays readable all the way down. | ||
| */ | ||
| const AREA_GRADIENT = { | ||
| dark: { top: 0.65, mid: 0.3, bottom: 0.08 }, | ||
| light: { top: 0.3, mid: 0.12, bottom: 0 }, | ||
| } as const; | ||
|
|
||
| const formatPercent = (value: number): string => `${value.toFixed(1)}%`; | ||
|
|
||
| const formatSignedDelta = (delta: number): string => | ||
| `${delta > 0 ? '+' : delta < 0 ? '−' : ''}${Math.abs(delta).toFixed(1)}`; | ||
|
|
||
| export const MetricTrendPanel: FC<Props> = ({ | ||
| title, | ||
| description, | ||
| series, | ||
| comparisonLabel, | ||
| selectedSeriesId, | ||
| onSeriesChange, | ||
| onViewClick, | ||
| viewLabel = 'View', | ||
| formatValue = formatPercent, | ||
| formatDelta = formatSignedDelta, | ||
| chartHeight = DEFAULT_CHART_HEIGHT, | ||
| isPending = false, | ||
| }) => { | ||
| // useId() emits colons, which are not valid inside an SVG `url(#...)` reference. | ||
| const gradientId = `metric-trend-${useId().replace(/:/g, '')}`; | ||
| const [internalSeriesId, setInternalSeriesId] = useState<string | undefined>(series[0]?.id); | ||
|
|
||
| const activeId = selectedSeriesId ?? internalSeriesId; | ||
| const active = useMemo( | ||
| () => series.find((s) => s.id === activeId) ?? series[0], | ||
| [series, activeId] | ||
| ); | ||
|
|
||
| const selectSeries = (seriesId: string) => { | ||
| if (selectedSeriesId === undefined) { | ||
| setInternalSeriesId(seriesId); | ||
| } | ||
| onSeriesChange?.(seriesId); | ||
| }; | ||
|
|
||
| const delta = active?.delta; | ||
| const isNegative = delta !== undefined && delta < 0; | ||
| const deltaColor = isNegative ? 'red' : 'green'; | ||
| const lineColor = isNegative ? 'var(--text-color-accent-red)' : 'var(--text-color-brand)'; | ||
| const colorMode = useNvColorMode(); | ||
| const gradient = colorMode === 'dark' ? AREA_GRADIENT.dark : AREA_GRADIENT.light; | ||
|
|
||
| return ( | ||
| <PanelRoot elevation="mid"> | ||
| <PanelHeader className="items-start"> | ||
| <Stack gap="density-xs" className="min-w-0 flex-1"> | ||
| <Text kind="label/bold/xl">{title}</Text> | ||
| {description && ( | ||
| <Text kind="body/regular/md" className="text-secondary"> | ||
| {description} | ||
| </Text> | ||
| )} | ||
| </Stack> | ||
| {onViewClick && ( | ||
| <Button kind="tertiary" size="small" className="shrink-0" onClick={onViewClick}> | ||
| {viewLabel} | ||
| </Button> | ||
| )} | ||
| </PanelHeader> | ||
|
|
||
| <PanelContent> | ||
| <Stack gap="density-lg"> | ||
| <Text kind="display/lg">{active ? formatValue(active.value) : '—'}</Text> | ||
|
|
||
| <Flex align="center" gap="density-lg" wrap="wrap"> | ||
| {delta !== undefined && ( | ||
| <Flex align="center" gap="density-sm"> | ||
| <Tag readOnly color={deltaColor} density="compact"> | ||
| <Triangle | ||
| size={12} | ||
| className={`fill-current ${isNegative ? 'rotate-180' : ''}`} | ||
| aria-hidden | ||
| /> | ||
| {formatDelta(delta)} | ||
| </Tag> | ||
| {comparisonLabel && ( | ||
| <Text kind="body/regular/md" className="text-secondary"> | ||
| {comparisonLabel} | ||
| </Text> | ||
| )} | ||
| </Flex> | ||
| )} | ||
|
|
||
| {series.length > 1 && ( | ||
| <Flex align="center" gap="density-sm" wrap="wrap" role="group" aria-label={title}> | ||
| {series.map((s) => { | ||
| const isActive = s.id === active?.id; | ||
| return ( | ||
| <Tag | ||
| key={s.id} | ||
| color={isActive ? 'green' : 'gray'} | ||
| kind={isActive ? 'solid' : 'outline'} | ||
| selected={isActive} | ||
| aria-pressed={isActive} | ||
| onClick={() => selectSeries(s.id)} | ||
| > | ||
| {s.label} | ||
| </Tag> | ||
| ); | ||
| })} | ||
| </Flex> | ||
| )} | ||
| </Flex> | ||
| </Stack> | ||
| </PanelContent> | ||
|
|
||
| <div className="-mx-density-2xl -mb-density-2xl overflow-hidden rounded-b-density-xl"> | ||
| {isPending || !active ? ( | ||
| <StackedSkeleton count={1} height={chartHeight} className="w-full" /> | ||
| ) : ( | ||
|
Comment on lines
+172
to
+175
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Do not show loading for empty data. When Add a test for 🤖 Prompt for AI Agents |
||
| <ResponsiveContainer width="100%" height={chartHeight}> | ||
| <AreaChart data={active.points} margin={{ top: 4, right: 0, bottom: 0, left: 0 }}> | ||
| <defs> | ||
| <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1"> | ||
| <stop offset="0%" stopColor={lineColor} stopOpacity={gradient.top} /> | ||
| <stop offset="55%" stopColor={lineColor} stopOpacity={gradient.mid} /> | ||
| <stop offset="100%" stopColor={lineColor} stopOpacity={gradient.bottom} /> | ||
| </linearGradient> | ||
| </defs> | ||
| <XAxis dataKey="label" hide /> | ||
| <YAxis domain={['dataMin', 'dataMax']} hide /> | ||
| <Tooltip | ||
| cursor={{ stroke: 'var(--border-color-base)', strokeWidth: 1 }} | ||
| formatter={(value: number) => [formatValue(value), active.label]} | ||
| contentStyle={{ | ||
| fontSize: 12, | ||
| backgroundColor: 'var(--background-color-component-tooltip)', | ||
| borderColor: 'var(--border-color-base)', | ||
| color: 'var(--text-color-base)', | ||
| }} | ||
| labelStyle={{ color: 'var(--text-color-base)' }} | ||
| itemStyle={{ color: 'var(--text-color-base)' }} | ||
| /> | ||
| <Area | ||
| type="linear" | ||
| dataKey="value" | ||
| name={active.label} | ||
| stroke={lineColor} | ||
| strokeWidth={2} | ||
| fill={`url(#${gradientId})`} | ||
| dot={active.points.length <= 2} | ||
| isAnimationActive={false} | ||
| /> | ||
| </AreaChart> | ||
| </ResponsiveContainer> | ||
| )} | ||
| </div> | ||
| </PanelRoot> | ||
| ); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Render zero delta as neutral.
When
delta === 0, the panel shows a green upward triangle. This reports no change as an increase. Use a neutral tag and omit the directional icon for zero.Add a zero-delta test.
🤖 Prompt for AI Agents