From db77d9281dbd7db3312efb2278cb2a2e0ea0bf19 Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Tue, 11 Aug 2026 14:48:49 -0700 Subject: [PATCH 1/4] feat(studio): Comparison Line Chart common component Signed-off-by: Sean Teramae --- web/.gitignore | 1 + web/packages/common/package.json | 1 + .../ComparisonAnnotationLabel.tsx | 74 ++++ .../ComparisonLineChart/ComparisonLegend.tsx | 69 ++++ .../ComparisonLineChart.stories.tsx | 357 ++++++++++++++++++ .../ComparisonLineChartEmpty.tsx | 91 +++++ .../ComparisonLineChartSkeleton.tsx | 23 ++ .../ComparisonLineChart/ComparisonTooltip.tsx | 45 +++ .../components/ComparisonLineChart/consts.ts | 22 ++ .../ComparisonLineChart/index.test.tsx | 222 +++++++++++ .../components/ComparisonLineChart/index.tsx | 295 +++++++++++++++ .../components/ComparisonLineChart/types.ts | 91 +++++ .../components/ComparisonLineChart/utils.ts | 142 +++++++ web/packages/common/src/components/index.ts | 1 + web/pnpm-lock.yaml | 27 +- 15 files changed, 1450 insertions(+), 11 deletions(-) create mode 100644 web/packages/common/src/components/ComparisonLineChart/ComparisonAnnotationLabel.tsx create mode 100644 web/packages/common/src/components/ComparisonLineChart/ComparisonLegend.tsx create mode 100644 web/packages/common/src/components/ComparisonLineChart/ComparisonLineChart.stories.tsx create mode 100644 web/packages/common/src/components/ComparisonLineChart/ComparisonLineChartEmpty.tsx create mode 100644 web/packages/common/src/components/ComparisonLineChart/ComparisonLineChartSkeleton.tsx create mode 100644 web/packages/common/src/components/ComparisonLineChart/ComparisonTooltip.tsx create mode 100644 web/packages/common/src/components/ComparisonLineChart/consts.ts create mode 100644 web/packages/common/src/components/ComparisonLineChart/index.test.tsx create mode 100644 web/packages/common/src/components/ComparisonLineChart/index.tsx create mode 100644 web/packages/common/src/components/ComparisonLineChart/types.ts create mode 100644 web/packages/common/src/components/ComparisonLineChart/utils.ts diff --git a/web/.gitignore b/web/.gitignore index 117628070c..0ac439b68a 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -10,6 +10,7 @@ optimization_results/ optimizations/ filestorage/ .agents/skills/kaizen-ui/ +storybook-static # SDK output is generated on `pnpm install` (see packages/sdk/generateAll.ts). packages/sdk/generated/ \ No newline at end of file diff --git a/web/packages/common/package.json b/web/packages/common/package.json index bfa768d37b..8ac15d6a90 100644 --- a/web/packages/common/package.json +++ b/web/packages/common/package.json @@ -35,6 +35,7 @@ "react-dom": "catalog:", "react-oidc-context": "catalog:", "react-router": "catalog:", + "recharts": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/web/packages/common/src/components/ComparisonLineChart/ComparisonAnnotationLabel.tsx b/web/packages/common/src/components/ComparisonLineChart/ComparisonAnnotationLabel.tsx new file mode 100644 index 0000000000..d81a4a0109 --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/ComparisonAnnotationLabel.tsx @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + ANNOTATION_COLOR, + ANNOTATION_TEXT_COLOR, +} from '@nemo/common/src/components/ComparisonLineChart/consts'; +import type { FC } from 'react'; + +interface Props { + label: string; + description?: string; + color?: string; + pointsUp: boolean; + /** Which side of the arrow the text sits on, so callouts near the right edge stay in frame. */ + labelSide: 'left' | 'right'; + /** Injected by recharts when this is passed as a ``. */ + viewBox?: { x?: number; y?: number; width?: number; height?: number }; +} + +const ARROW_HALF_WIDTH = 5; +const ARROW_LENGTH = 9; +const TEXT_OFFSET = 12; + +/** + * Draws the arrowhead and callout text for a `ComparisonAnnotation`. The dashed shaft is the + * `ReferenceLine` itself; this only renders what recharts has no primitive for. + */ +export const ComparisonAnnotationLabel: FC = ({ + label, + description, + color = ANNOTATION_COLOR, + pointsUp, + labelSide, + viewBox, +}) => { + const { x = 0, y = 0, height = 0 } = viewBox ?? {}; + const tipY = pointsUp ? y : y + height; + const baseY = pointsUp ? tipY + ARROW_LENGTH : tipY - ARROW_LENGTH; + const onLeft = labelSide === 'left'; + const textX = onLeft ? x - TEXT_OFFSET : x + TEXT_OFFSET; + const textAnchor = onLeft ? 'end' : 'start'; + const textY = y + height / 2; + + return ( + + + + {label} + + {description && ( + + {description} + + )} + + ); +}; diff --git a/web/packages/common/src/components/ComparisonLineChart/ComparisonLegend.tsx b/web/packages/common/src/components/ComparisonLineChart/ComparisonLegend.tsx new file mode 100644 index 0000000000..5cdcf542ff --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/ComparisonLegend.tsx @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flex, Text } from '@nvidia/foundations-react-core'; +import classNames from 'classnames'; +import type { FC } from 'react'; + +export interface ComparisonLegendItem { + id: string; + label: string; + color: string; + dashed?: boolean; + hidden?: boolean; +} + +interface Props { + items: ComparisonLegendItem[]; + interactive?: boolean; + justify?: 'center' | 'end'; + onToggle?: (id: string) => void; + onHover?: (id: string | null) => void; +} + +const DOT_SIZE = 10; +const DOT_RADIUS = 4; + +export const ComparisonLegend: FC = ({ + items, + interactive = true, + justify = 'end', + onToggle, + onHover, +}) => ( + + {items.map((item) => ( + + ))} + +); diff --git a/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChart.stories.tsx b/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChart.stories.tsx new file mode 100644 index 0000000000..8fcf5a8935 --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChart.stories.tsx @@ -0,0 +1,357 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ComparisonLineChart } from '@nemo/common/src/components/ComparisonLineChart/index'; +import type { ComparisonSeries } from '@nemo/common/src/components/ComparisonLineChart/types'; +import { formatNumericValue } from '@nemo/common/src/components/ComparisonLineChart/utils'; +import type { Meta, StoryObj } from '@storybook/react'; + +const STEPS = ['Step 1', 'Step 2', 'Step 3', 'Step 4', 'Step 5', 'Step 6']; + +const ACCURACY_SERIES: ComparisonSeries[] = [ + { id: 'baseline', label: 'Baseline', data: [0.41, 0.44, 0.46, 0.47, 0.47, 0.48] }, + { id: 'candidate', label: 'Candidate v2', data: [0.39, 0.48, 0.57, 0.63, 0.68, 0.71] }, +]; + +const percent = (value: number) => `${(value * 100).toFixed(0)}%`; + +const meta: Meta = { + component: ComparisonLineChart, + title: 'Studio Common/ComparisonLineChart', + args: { + series: ACCURACY_SERIES, + xAxis: STEPS, + yAxisLabel: 'Accuracy', + formatYValue: percent, + }, + argTypes: { + curve: { control: 'select', options: ['linear', 'monotone', 'step', 'natural'] }, + xAxisType: { control: 'select', options: ['category', 'number', 'time'] }, + height: { control: { type: 'range', min: 160, max: 600, step: 20 } }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +/** Click a legend entry to hide a run; hover one to fade the others. */ +export const ManySeries: Story = { + args: { + series: [ + { id: 'run-a', label: 'gpt-oss-120b', data: [0.41, 0.5, 0.56, 0.6, 0.63, 0.65] }, + { id: 'run-b', label: 'nemotron-super', data: [0.38, 0.47, 0.58, 0.66, 0.71, 0.74] }, + { id: 'run-c', label: 'llama-3.3-70b', data: [0.44, 0.49, 0.52, 0.55, 0.57, 0.58] }, + { id: 'run-d', label: 'qwen3-32b', data: [0.35, 0.42, 0.5, 0.54, 0.59, 0.62] }, + { id: 'run-e', label: 'mistral-small', data: [0.3, 0.36, 0.41, 0.45, 0.48, 0.5] }, + ], + }, +}; + +/** `dashed` marks the reference run; `referenceLines` marks the ship threshold. */ +export const BaselineAndTarget: Story = { + args: { + series: [{ ...ACCURACY_SERIES[0], dashed: true }, ACCURACY_SERIES[1]], + referenceLines: [{ y: 0.7, label: 'Ship target' }], + yAxisMin: 0, + yAxisMax: 1, + }, +}; + +/** `Date` x values switch the axis to time scaling automatically. */ +export const TimeAxis: Story = { + args: { + xAxis: [ + new Date('2026-08-01T09:00:00Z'), + new Date('2026-08-01T12:00:00Z'), + new Date('2026-08-02T09:00:00Z'), + new Date('2026-08-04T09:00:00Z'), + new Date('2026-08-07T09:00:00Z'), + new Date('2026-08-11T09:00:00Z'), + ], + xAxisLabel: 'Run started', + }, +}; + +/** A numeric axis with per-series value formatting for mixed units. */ +export const NumericAxis: Story = { + args: { + xAxis: [1, 2, 4, 8, 16, 32], + xAxisLabel: 'Concurrency', + yAxisLabel: 'Latency', + formatYValue: (value: number) => `${value.toFixed(0)}ms`, + series: [ + { id: 'p50', label: 'p50', data: [420, 435, 470, 560, 790, 1400] }, + { id: 'p95', label: 'p95', data: [810, 860, 950, 1180, 1760, 3200] }, + ], + }, +}; + +/** `null` values break the line rather than interpolating across a missing run. */ +export const WithGaps: Story = { + args: { + series: [ + { id: 'baseline', label: 'Baseline', data: [0.41, 0.44, null, 0.47, 0.47, 0.48] }, + { id: 'candidate', label: 'Candidate v2', data: [0.39, null, null, 0.63, 0.68, 0.71] }, + ], + showMarks: true, + }, +}; + +export const SeriesHiddenByDefault: Story = { + args: { initialHiddenSeriesIds: ['baseline'] }, +}; + +export const StaticLegend: Story = { + args: { legendInteractive: false }, +}; + +/** Title on the left, legend on the right — the default header layout. */ +export const WithTitle: Story = { + args: { + title: 'Daily averages over time', + xAxis: ['7/1', '7/7', '7/13', '7/19', '7/25', '7/31'], + yAxisLabel: undefined, + formatYValue: formatNumericValue, + series: [ + { id: 'cost', label: 'Cost', data: [12, 14, 11, 15, 13, 12] }, + { id: 'tokens', label: 'Tokens', data: [2980, 2380, 3810, 1650, 3520, 2210] }, + { id: 'latency', label: 'Latency', data: [210, 240, 190, 260, 230, 250] }, + ], + }, +}; + +/** Legend below the plot, centered — the layout to use when the header row is already busy. */ +export const LegendBelow: Story = { + args: { legendPosition: 'bottom' }, +}; + +export const Loading: Story = { + args: { loading: true }, +}; + +/** The frame, axis labels, and legend stay put so the chart doesn't collapse while it waits for data. */ +export const Empty: Story = { + args: { + series: [ + { id: 'baseline', label: 'Baseline', data: [] }, + { id: 'candidate', label: 'Candidate v2', data: [] }, + ], + xAxis: [], + xAxisLabel: 'Step', + emptyMessage: 'No runs to compare yet', + }, +}; + +/** An annotation between two series, with the multiplier computed from the data. */ +export const WithAnnotation: Story = { + args: { + series: [{ ...ACCURACY_SERIES[0], dashed: true }, ACCURACY_SERIES[1]], + yAxisMin: 0, + yAxisMax: 1, + annotations: [ + { + x: 'Step 6', + betweenSeriesIds: ['baseline', 'candidate'], + description: 'Higher accuracy', + }, + ], + }, +}; + +/** + * Shared x grid for the platform-comparison stories below. Each platform only covers part of the + * interactivity range, so values outside its range are `null` — leading and trailing nulls shorten + * a line without breaking it. + */ +const INTERACTIVITY = [ + 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, +]; + +const onInteractivityGrid = (points: Record): (number | null)[] => + INTERACTIVITY.map((x) => points[x] ?? null); + +const PLATFORM_COLORS = { + gb300: 'var(--text-color-accent-yellow)', + h200: 'var(--text-color-accent-green)', + competition: 'var(--text-color-accent-gray)', +} as const; + +/** Throughput-per-watt curves, where each platform is measured over a different interactivity band. */ +export const TokensPerWattByInteractivity: Story = { + args: { + xAxis: INTERACTIVITY, + xAxisLabel: 'Interactivity (TPS/User)', + yAxisLabel: 'Tokens per watt', + formatYValue: formatNumericValue, + curve: 'linear', + showMarks: true, + yAxisMin: 0, + height: 380, + annotations: [ + { + x: 120, + betweenSeriesIds: ['h200', 'gb300'], + label: '50X', + description: 'Higher perf / watt', + }, + ], + series: [ + { + id: 'gb300', + label: 'GB300 NVL72', + color: PLATFORM_COLORS.gb300, + data: onInteractivityGrid({ + 20: 8_300_000, + 30: 7_550_000, + 40: 6_800_000, + 50: 6_300_000, + 60: 5_900_000, + 70: 5_500_000, + 80: 4_900_000, + 90: 4_300_000, + 100: 3_700_000, + 110: 3_100_000, + 120: 2_550_000, + 130: 2_050_000, + 140: 1_600_000, + 150: 1_150_000, + 160: 800_000, + 170: 550_000, + 180: 350_000, + 190: 200_000, + 200: 120_000, + }), + }, + { + id: 'h200', + label: 'H200 NVL8', + color: PLATFORM_COLORS.h200, + data: onInteractivityGrid({ + 30: 1_250_000, + 40: 700_000, + 50: 480_000, + 60: 330_000, + 70: 240_000, + 80: 175_000, + 90: 130_000, + 100: 95_000, + 110: 70_000, + 120: 55_000, + }), + }, + { + id: 'competition', + label: 'Competition', + color: PLATFORM_COLORS.competition, + dashed: true, + data: onInteractivityGrid({ + 80: 500_000, + 90: 420_000, + 100: 350_000, + 110: 300_000, + 120: 240_000, + 130: 180_000, + 140: 120_000, + 150: 90_000, + 160: 70_000, + 170: 50_000, + 180: 35_000, + 190: 25_000, + 200: 20_000, + }), + }, + ], + }, +}; + +/** The same comparison inverted: cost per token, where lower and flatter wins. */ +export const TokenCostByInteractivity: Story = { + args: { + xAxis: INTERACTIVITY, + xAxisLabel: 'Interactivity (TPS/User)', + yAxisLabel: 'Cost per 1M tokens', + formatYValue: (value: number) => `$${value.toFixed(2)}`, + curve: 'linear', + showMarks: true, + annotations: [ + { x: 120, betweenSeriesIds: ['h200', 'gb300'], label: '35X', description: 'Lower cost' }, + ], + yAxisMin: 0, + yAxisMax: 5, + height: 380, + series: [ + { + id: 'gb300', + label: 'GB300 NVL72', + color: PLATFORM_COLORS.gb300, + data: onInteractivityGrid({ + 20: 0.02, + 30: 0.02, + 40: 0.03, + 50: 0.03, + 60: 0.04, + 70: 0.04, + 80: 0.05, + 90: 0.05, + 100: 0.06, + 110: 0.07, + 120: 0.08, + 130: 0.1, + 140: 0.13, + 150: 0.17, + 160: 0.3, + 170: 0.55, + 180: 0.85, + 190: 1.15, + 200: 1.45, + }), + }, + { + id: 'h200', + label: 'H200 NVL8', + color: PLATFORM_COLORS.h200, + data: onInteractivityGrid({ + 20: 0.02, + 30: 0.08, + 40: 0.25, + 50: 0.45, + 60: 0.72, + 70: 0.95, + 80: 1.5, + 90: 2.5, + 100: 3.3, + 110: 3.9, + 120: 4.15, + }), + }, + { + id: 'competition', + label: 'Competition', + color: PLATFORM_COLORS.competition, + dashed: true, + data: onInteractivityGrid({ + 70: 0.18, + 80: 0.2, + 90: 0.25, + 100: 0.3, + 110: 0.4, + 120: 0.48, + 130: 1.05, + 140: 2.1, + 150: 2.85, + 160: 3.65, + }), + }, + ], + }, +}; diff --git a/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChartEmpty.tsx b/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChartEmpty.tsx new file mode 100644 index 0000000000..690747ee2c --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChartEmpty.tsx @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AXIS_COLOR, + AXIS_TEXT_COLOR, +} from '@nemo/common/src/components/ComparisonLineChart/consts'; +import { Text } from '@nvidia/foundations-react-core'; +import type { FC } from 'react'; +import { CartesianGrid, LineChart, ResponsiveContainer, XAxis, YAxis } from 'recharts'; + +interface Props { + message: string; + height: number; + xAxisLabel?: string; + yAxisLabel?: string; + showGrid?: boolean; +} + +/** Two rows are enough to give the axes a domain to draw against. */ +const PLACEHOLDER_ROWS = [{ x: 0 }, { x: 1 }]; +const PLACEHOLDER_DOMAIN: [number, number] = [0, 1]; +const AXIS_LABEL_STYLE = { fontSize: 12, fill: AXIS_TEXT_COLOR } as const; + +/** + * The chart frame — axes, labels, and grid — with the empty message centered in the plot area. + * Keeping the frame means the component holds its size and the reader can see what the chart + * *would* show once data arrives. Tick labels stay off so no scale is implied. + */ +export const ComparisonLineChartEmpty: FC = ({ + message, + height, + xAxisLabel, + yAxisLabel, + showGrid = true, +}) => ( +
+ + + {showGrid && ( + + )} + + + + +
+ + {message} + +
+
+); diff --git a/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChartSkeleton.tsx b/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChartSkeleton.tsx new file mode 100644 index 0000000000..23fe796240 --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChartSkeleton.tsx @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flex, Skeleton, Stack } from '@nvidia/foundations-react-core'; +import type { FC } from 'react'; + +interface Props { + height: number; +} + +export const ComparisonLineChartSkeleton: FC = ({ height }) => ( + + {/* eslint-disable-next-line no-restricted-syntax */} +
+ +
+ + {[0, 1, 2].map((index) => ( + + ))} + +
+); diff --git a/web/packages/common/src/components/ComparisonLineChart/ComparisonTooltip.tsx b/web/packages/common/src/components/ComparisonLineChart/ComparisonTooltip.tsx new file mode 100644 index 0000000000..589e32c248 --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/ComparisonTooltip.tsx @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flex, Stack, Text } from '@nvidia/foundations-react-core'; +import type { FC } from 'react'; +import type { TooltipProps } from 'recharts'; + +interface Props extends TooltipProps { + /** Formats the hovered x value; receives the raw plot value (timestamp for time axes). */ + formatLabel: (value: string | number) => string; + /** Formats a series value, resolved per series id by the chart. */ + formatValue: (seriesId: string, value: number | null) => string; +} + +export const ComparisonTooltip: FC = ({ + active, + payload, + label, + formatLabel, + formatValue, +}) => { + if (!active || !payload?.length) return null; + + return ( + + {formatLabel(label as string | number)} + {payload.map((entry) => ( + + + + + + {entry.name} + + + {formatValue(String(entry.dataKey), entry.value ?? null)} + + + ))} + + ); +}; diff --git a/web/packages/common/src/components/ComparisonLineChart/consts.ts b/web/packages/common/src/components/ComparisonLineChart/consts.ts new file mode 100644 index 0000000000..124b8ee96f --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/consts.ts @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Ordered so adjacent series stay distinguishable in both light and dark themes. */ +export const COMPARISON_SERIES_COLORS = [ + 'var(--text-color-accent-blue)', + 'var(--text-color-accent-green)', + 'var(--text-color-accent-purple)', + 'var(--text-color-accent-yellow)', + 'var(--text-color-accent-teal)', + 'var(--text-color-accent-red)', + 'var(--text-color-accent-gray)', +] as const; + +export const AXIS_COLOR = 'var(--border-color-base)'; +export const AXIS_TEXT_COLOR = 'var(--text-color-placeholder)'; +export const REFERENCE_LINE_COLOR = 'var(--border-color-accent-gray)'; +export const ANNOTATION_COLOR = 'var(--border-color-accent-gray)'; +export const ANNOTATION_TEXT_COLOR = 'var(--text-color-base)'; + +export const DEFAULT_CHART_HEIGHT = 320; +export const FADED_SERIES_OPACITY = 0.15; diff --git a/web/packages/common/src/components/ComparisonLineChart/index.test.tsx b/web/packages/common/src/components/ComparisonLineChart/index.test.tsx new file mode 100644 index 0000000000..b9cdd3ebca --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/index.test.tsx @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ComparisonLineChart } from '@nemo/common/src/components/ComparisonLineChart'; +import type { ComparisonSeries } from '@nemo/common/src/components/ComparisonLineChart/types'; +import { + buildChartRows, + formatNumericValue, + hasPlottableData, + inferXAxisType, + resolveAnnotation, + seriesColor, +} from '@nemo/common/src/components/ComparisonLineChart/utils'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const series: ComparisonSeries[] = [ + { id: 'baseline', label: 'Baseline', data: [0.4, 0.5, 0.55], dashed: true }, + { id: 'candidate', label: 'Candidate', data: [0.45, 0.6, 0.72] }, +]; + +const xAxis = ['Step 1', 'Step 2', 'Step 3']; + +describe('ComparisonLineChart', () => { + it('renders a legend entry per series', () => { + render(); + + expect(screen.getByRole('button', { name: 'Baseline' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Candidate' })).toBeInTheDocument(); + }); + + it('toggles a series off and reports the remaining visible ids', async () => { + const user = userEvent.setup(); + const onVisibleSeriesChange = vi.fn(); + render( + + ); + + const baseline = screen.getByRole('button', { name: 'Baseline' }); + expect(baseline).toHaveAttribute('aria-pressed', 'true'); + + await user.click(baseline); + + expect(baseline).toHaveAttribute('aria-pressed', 'false'); + expect(onVisibleSeriesChange).toHaveBeenCalledWith(['candidate']); + }); + + it('keeps hidden series in the legend so they can be restored', async () => { + const user = userEvent.setup(); + render( + + ); + + const candidate = screen.getByRole('button', { name: 'Candidate' }); + expect(candidate).toHaveAttribute('aria-pressed', 'false'); + + await user.click(candidate); + + expect(candidate).toHaveAttribute('aria-pressed', 'true'); + }); + + it('renders the title alongside the legend', () => { + render(); + + expect(screen.getByText('Daily averages')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Baseline' })).toBeInTheDocument(); + }); + + it('still renders the legend when positioned below the plot', async () => { + const user = userEvent.setup(); + render(); + + const baseline = screen.getByRole('button', { name: 'Baseline' }); + await user.click(baseline); + + expect(baseline).toHaveAttribute('aria-pressed', 'false'); + }); + + it('hides the legend when disabled', () => { + render(); + + expect(screen.queryByRole('button', { name: 'Baseline' })).not.toBeInTheDocument(); + }); + + it('renders the empty message when every value is null', () => { + render( + + ); + + expect(screen.getByText('Nothing to compare yet')).toBeInTheDocument(); + }); + + it('keeps the axis labels and a static legend in the empty state', () => { + render( + + ); + + expect(screen.getByText('No runs yet')).toBeInTheDocument(); + const baseline = screen.getByRole('button', { name: 'Baseline' }); + expect(baseline).toBeDisabled(); + }); + + it('renders a skeleton while loading', () => { + render(); + + expect(screen.getByTestId('comparison-line-chart-skeleton')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Baseline' })).not.toBeInTheDocument(); + }); +}); + +describe('ComparisonLineChart utils', () => { + it('infers the axis type from the first x value', () => { + expect(inferXAxisType(['a', 'b'])).toBe('category'); + expect(inferXAxisType([1, 2])).toBe('number'); + expect(inferXAxisType([new Date(0)])).toBe('time'); + }); + + it('pivots parallel series data into recharts rows', () => { + expect(buildChartRows(series, xAxis)).toEqual([ + { x: 'Step 1', baseline: 0.4, candidate: 0.45 }, + { x: 'Step 2', baseline: 0.5, candidate: 0.6 }, + { x: 'Step 3', baseline: 0.55, candidate: 0.72 }, + ]); + }); + + it('nulls out gaps, non-finite values, and short series in rows', () => { + expect( + buildChartRows([{ id: 'a', label: 'A', data: [1, Number.NaN] }], [new Date(0), 'x', 'y']) + ).toEqual([ + { x: 0, a: 1 }, + { x: 'x', a: null }, + { x: 'y', a: null }, + ]); + }); + + it('treats null-only and empty input as unplottable', () => { + expect(hasPlottableData(series, xAxis)).toBe(true); + expect(hasPlottableData([{ id: 'a', label: 'A', data: [null] }], ['x'])).toBe(false); + expect(hasPlottableData(series, [])).toBe(false); + }); + + it('prefers an explicit series color over the palette', () => { + expect(seriesColor({ id: 'a', label: 'A', data: [], color: '#fff' }, 0)).toBe('#fff'); + expect(seriesColor({ id: 'a', label: 'A', data: [] }, 0)).toBe('var(--text-color-accent-blue)'); + }); + + it('resolves an annotation between two series and derives the multiplier', () => { + expect( + resolveAnnotation({ x: 'Step 3', betweenSeriesIds: ['baseline', 'candidate'] }, series, xAxis) + ).toEqual({ + x: 'Step 3', + fromY: 0.55, + toY: 0.72, + label: '1.3X', + description: undefined, + color: undefined, + pointsUp: true, + labelSide: 'left', + }); + }); + + it('flips the callout text inward for annotations near the right edge', () => { + const between: [string, string] = ['baseline', 'candidate']; + const sideAt = (x: string) => + resolveAnnotation({ x, betweenSeriesIds: between }, series, xAxis)?.labelSide; + + expect(sideAt('Step 1')).toBe('right'); + expect(sideAt('Step 3')).toBe('left'); + expect( + resolveAnnotation( + { x: 'Step 3', betweenSeriesIds: between, labelSide: 'right' }, + series, + xAxis + )?.labelSide + ).toBe('right'); + }); + + it('points the annotation down when the target series is lower', () => { + expect( + resolveAnnotation({ x: 'Step 1', betweenSeriesIds: ['candidate', 'baseline'] }, series, xAxis) + ?.pointsUp + ).toBe(false); + }); + + it('rounds large multipliers to whole numbers', () => { + const wide = [ + { id: 'low', label: 'Low', data: [55_000] }, + { id: 'high', label: 'High', data: [2_550_000] }, + ]; + expect(resolveAnnotation({ x: 0, betweenSeriesIds: ['low', 'high'] }, wide, [0])?.label).toBe( + '46X' + ); + }); + + it('drops annotations whose x value or endpoint is missing', () => { + expect( + resolveAnnotation({ x: 'Step 9', betweenSeriesIds: ['baseline', 'candidate'] }, series, xAxis) + ).toBeNull(); + expect( + resolveAnnotation({ x: 'Step 1', betweenSeriesIds: ['baseline', 'ghost'] }, series, xAxis) + ).toBeNull(); + }); + + it('compacts large values and keeps small ones precise', () => { + expect(formatNumericValue(16000)).toBe('16K'); + expect(formatNumericValue(0.1234)).toBe('0.123'); + }); +}); diff --git a/web/packages/common/src/components/ComparisonLineChart/index.tsx b/web/packages/common/src/components/ComparisonLineChart/index.tsx new file mode 100644 index 0000000000..47c19ebbbe --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/index.tsx @@ -0,0 +1,295 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ComparisonAnnotationLabel } from '@nemo/common/src/components/ComparisonLineChart/ComparisonAnnotationLabel'; +import { ComparisonLegend } from '@nemo/common/src/components/ComparisonLineChart/ComparisonLegend'; +import { ComparisonLineChartEmpty } from '@nemo/common/src/components/ComparisonLineChart/ComparisonLineChartEmpty'; +import { ComparisonLineChartSkeleton } from '@nemo/common/src/components/ComparisonLineChart/ComparisonLineChartSkeleton'; +import { ComparisonTooltip } from '@nemo/common/src/components/ComparisonLineChart/ComparisonTooltip'; +import { + ANNOTATION_COLOR, + AXIS_COLOR, + AXIS_TEXT_COLOR, + DEFAULT_CHART_HEIGHT, + FADED_SERIES_OPACITY, + REFERENCE_LINE_COLOR, +} from '@nemo/common/src/components/ComparisonLineChart/consts'; +import type { ComparisonLineChartProps } from '@nemo/common/src/components/ComparisonLineChart/types'; +import { + buildChartRows, + formatNumericValue, + formatXValueDefault, + hasPlottableData, + inferXAxisType, + resolveAnnotation, + seriesColor, +} from '@nemo/common/src/components/ComparisonLineChart/utils'; +import { Flex, Stack, Text } from '@nvidia/foundations-react-core'; +import { useCallback, useMemo, useState } from 'react'; +import { + CartesianGrid, + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +export * from '@nemo/common/src/components/ComparisonLineChart/consts'; +export * from '@nemo/common/src/components/ComparisonLineChart/types'; +export * from '@nemo/common/src/components/ComparisonLineChart/utils'; + +const TICK_STYLE = { fontSize: 11, fill: AXIS_TEXT_COLOR } as const; +const AXIS_LABEL_STYLE = { fontSize: 12, fill: AXIS_TEXT_COLOR } as const; + +/** + * Multi-series line chart for comparing runs, models, or variants over a shared x axis. + * Series are colored from the shared palette, the legend toggles them on and off, and hovering a + * legend entry fades the others so a single line can be read out of a crowded chart. + */ +export const ComparisonLineChart = ({ + series, + xAxis, + xAxisLabel, + yAxisLabel, + xAxisType, + yAxisMin, + yAxisMax, + height = DEFAULT_CHART_HEIGHT, + curve = 'monotone', + showGrid = true, + showLegend = true, + legendPosition = 'top', + title, + legendInteractive = true, + showMarks, + referenceLines, + annotations, + formatXValue = formatXValueDefault, + formatYValue = formatNumericValue, + loading = false, + emptyMessage = 'No data to compare', + initialHiddenSeriesIds, + onVisibleSeriesChange, + className, +}: ComparisonLineChartProps) => { + const [hiddenIds, setHiddenIds] = useState>( + () => new Set(initialHiddenSeriesIds ?? []) + ); + const [hoveredId, setHoveredId] = useState(null); + + const colored = useMemo( + () => series.map((entry, index) => ({ ...entry, resolvedColor: seriesColor(entry, index) })), + [series] + ); + + const rows = useMemo(() => buildChartRows(series, xAxis), [series, xAxis]); + const resolvedAnnotations = useMemo( + () => + (annotations ?? []) + .map((annotation) => resolveAnnotation(annotation, series, xAxis)) + .filter((annotation) => annotation !== null), + [annotations, series, xAxis] + ); + const resolvedXAxisType = xAxisType ?? inferXAxisType(xAxis); + const isTimeAxis = resolvedXAxisType === 'time'; + + const toggleSeries = useCallback( + (id: string) => { + setHiddenIds((current) => { + const next = new Set(current); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + onVisibleSeriesChange?.(series.filter((s) => !next.has(s.id)).map((s) => s.id)); + return next; + }); + }, + [onVisibleSeriesChange, series] + ); + + /** Time axes plot timestamps, so restore the `Date` before handing values to the formatter. */ + const formatPlotValue = useCallback( + (value: string | number) => formatXValue(isTimeAxis ? new Date(value) : value), + [formatXValue, isTimeAxis] + ); + + const formatSeriesValue = useCallback( + (seriesId: string, value: number | null) => { + const entry = series.find((s) => s.id === seriesId); + return entry?.valueFormatter?.(value) ?? (value === null ? '—' : formatYValue(value)); + }, + [series, formatYValue] + ); + + const legendItems = colored.map((entry) => ({ + id: entry.id, + label: entry.label, + color: entry.resolvedColor, + dashed: entry.dashed, + hidden: hiddenIds.has(entry.id), + })); + + const renderLegend = (interactive: boolean) => ( + + ); + + const showTopLegend = showLegend && legendPosition === 'top' && series.length > 0; + + /** Title on the left, legend on the right; renders when either is present. */ + const renderHeader = (interactive: boolean) => + title || showTopLegend ? ( + + {title && {title}} + {showTopLegend && renderLegend(interactive)} + + ) : null; + + if (loading) { + return ; + } + + if (!hasPlottableData(series, xAxis)) { + return ( + + {renderHeader(false)} + + {showLegend && legendPosition === 'bottom' && series.length > 0 && renderLegend(false)} + + ); + } + + return ( + + {renderHeader(legendInteractive)} + + + {showGrid && ( + + )} + + + + } + /> + {referenceLines?.map((line) => ( + + ))} + {resolvedAnnotations.map((annotation) => ( + + } + /> + ))} + {colored + .filter((entry) => !hiddenIds.has(entry.id)) + .map((entry) => ( + + ))} + + + {showLegend && legendPosition === 'bottom' && ( +
{renderLegend(legendInteractive)}
+ )} +
+ ); +}; diff --git a/web/packages/common/src/components/ComparisonLineChart/types.ts b/web/packages/common/src/components/ComparisonLineChart/types.ts new file mode 100644 index 0000000000..863cdf0b35 --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/types.ts @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ReactNode } from 'react'; + +export type ComparisonXValue = string | number | Date; + +/** Maps to recharts' ``; `monotone` keeps comparison lines smooth without overshooting. */ +export type ComparisonCurve = 'linear' | 'monotone' | 'step' | 'natural'; + +/** `time` is a numeric axis with time-spaced ticks — pick it when `xAxis` holds `Date`s. */ +export type ComparisonXAxisType = 'category' | 'number' | 'time'; + +export interface ComparisonSeries { + /** Stable identifier; also the row key in the chart data and the legend toggle key. */ + id: string; + label: string; + /** One entry per x value. `null` renders a gap. */ + data: (number | null)[]; + /** CSS color. Defaults to the shared palette, assigned by index. */ + color?: string; + /** Renders the line dashed — use for baselines and targets. */ + dashed?: boolean; + /** Formats this series' values in the tooltip. Falls back to the chart-level formatter. */ + valueFormatter?: (value: number | null) => string; +} + +export interface ComparisonReferenceLine { + /** Horizontal line at this y value. */ + y: number; + label?: string; + color?: string; +} + +/** + * A callout arrow drawn at one x position, typically spanning the gap between two series to call + * out how far apart they are ("50X Higher Perf / Watt"). + */ +export interface ComparisonAnnotation { + /** Where on the x axis the callout sits. Must be one of the `xAxis` values. */ + x: ComparisonXValue; + /** `[fromSeriesId, toSeriesId]` — the arrow runs from the first series' value to the second's. */ + betweenSeriesIds?: [string, string]; + /** Explicit endpoints, for callouts not tied to two series. Ignored when `betweenSeriesIds` is set. */ + fromY?: number; + toY?: number; + /** Headline text. Defaults to the ratio between the endpoints, e.g. `50X`. */ + label?: string; + /** Smaller supporting text under the headline. */ + description?: string; + color?: string; + /** + * Which side of the arrow the text sits on. Defaults to `right`, flipping to `left` for + * annotations in the last third of the x axis so the text stays inside the chart. + */ + labelSide?: 'left' | 'right'; +} + +export interface ComparisonLineChartProps { + series: ComparisonSeries[]; + /** Shared x values. Length should match each series' `data`. */ + xAxis: ComparisonXValue[]; + xAxisLabel?: string; + yAxisLabel?: string; + /** Overrides the axis type inferred from the first `xAxis` entry. */ + xAxisType?: ComparisonXAxisType; + yAxisMin?: number; + yAxisMax?: number; + height?: number; + curve?: ComparisonCurve; + showGrid?: boolean; + showLegend?: boolean; + /** `top` puts the legend right-aligned in a header row above the plot, opposite `title`. */ + legendPosition?: 'top' | 'bottom'; + /** Optional heading rendered at the left of the legend row. */ + title?: ReactNode; + /** Legend entries stay clickable; toggling hides the series without unmounting the chart. */ + legendInteractive?: boolean; + /** Forces point markers on or off. Defaults to on only for very short series. */ + showMarks?: boolean; + referenceLines?: ComparisonReferenceLine[]; + annotations?: ComparisonAnnotation[]; + formatXValue?: (value: ComparisonXValue) => string; + formatYValue?: (value: number) => string; + loading?: boolean; + emptyMessage?: string; + /** Series hidden on first render; the user can re-enable them from the legend. */ + initialHiddenSeriesIds?: string[]; + onVisibleSeriesChange?: (visibleIds: string[]) => void; + className?: string; +} diff --git a/web/packages/common/src/components/ComparisonLineChart/utils.ts b/web/packages/common/src/components/ComparisonLineChart/utils.ts new file mode 100644 index 0000000000..82777ea00f --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/utils.ts @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { COMPARISON_SERIES_COLORS } from '@nemo/common/src/components/ComparisonLineChart/consts'; +import type { + ComparisonAnnotation, + ComparisonSeries, + ComparisonXAxisType, + ComparisonXValue, +} from '@nemo/common/src/components/ComparisonLineChart/types'; + +/** A recharts row: the shared x value plus one entry per series, keyed by series id. */ +export interface ComparisonChartRow { + x: string | number; + [seriesId: string]: string | number | null; +} + +export const seriesColor = (series: ComparisonSeries, index: number): string => + series.color ?? COMPARISON_SERIES_COLORS[index % COMPARISON_SERIES_COLORS.length]; + +export const inferXAxisType = (xAxis: ComparisonXValue[]): ComparisonXAxisType => { + const first = xAxis.find((value) => value !== undefined && value !== null); + if (first instanceof Date) return 'time'; + if (typeof first === 'number') return 'number'; + return 'category'; +}; + +/** Dates become timestamps so recharts can place them on a numeric axis. */ +export const toPlotValue = (value: ComparisonXValue): string | number => + value instanceof Date ? value.getTime() : value; + +/** + * Pivots the parallel `series[].data` arrays into the row-per-x-value shape recharts expects. + * Series are keyed by id, so ids must not collide with the reserved `x` key. + */ +export const buildChartRows = ( + series: ComparisonSeries[], + xAxis: ComparisonXValue[] +): ComparisonChartRow[] => + xAxis.map((xValue, index) => { + const row: ComparisonChartRow = { x: toPlotValue(xValue) }; + for (const entry of series) { + const value = entry.data[index]; + row[entry.id] = typeof value === 'number' && Number.isFinite(value) ? value : null; + } + return row; + }); + +export interface ResolvedAnnotation { + x: string | number; + fromY: number; + toY: number; + label: string; + description?: string; + color?: string; + /** The arrow points up the y axis, so the head sits at the top of the segment. */ + pointsUp: boolean; + labelSide: 'left' | 'right'; +} + +/** How far along the x axis a point sits, 0 at the left edge and 1 at the right. */ +const axisFraction = (xAxis: ComparisonXValue[], index: number, plotX: string | number): number => { + const plotted = xAxis.map(toPlotValue); + const numeric = plotted.filter((value): value is number => typeof value === 'number'); + if (typeof plotX === 'number' && numeric.length === plotted.length && numeric.length > 1) { + const min = Math.min(...numeric); + const max = Math.max(...numeric); + return max === min ? 0 : (plotX - min) / (max - min); + } + return plotted.length > 1 ? index / (plotted.length - 1) : 0; +}; + +/** Past this point the callout text would run off the right edge, so it flips to the other side. */ +const LABEL_FLIP_FRACTION = 0.65; + +const ratioLabel = (fromY: number, toY: number): string => { + const [low, high] = [Math.abs(fromY), Math.abs(toY)].sort((a, b) => a - b); + if (low === 0 || !Number.isFinite(high / low)) return ''; + const ratio = high / low; + return `${ratio >= 10 ? Math.round(ratio) : ratio.toFixed(1)}X`; +}; + +/** + * Resolves an annotation's endpoints against the data, looking up the two named series at `x`. + * Returns `null` when the x value or either endpoint is missing, so a callout silently drops + * rather than rendering at a bogus position. + */ +export const resolveAnnotation = ( + annotation: ComparisonAnnotation, + series: ComparisonSeries[], + xAxis: ComparisonXValue[] +): ResolvedAnnotation | null => { + const plotX = toPlotValue(annotation.x); + const index = xAxis.findIndex((value) => toPlotValue(value) === plotX); + if (index === -1) return null; + + const valueAt = (seriesId: string): number | undefined => { + const value = series.find((s) => s.id === seriesId)?.data[index]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; + }; + + const [fromId, toId] = annotation.betweenSeriesIds ?? []; + const fromY = fromId ? valueAt(fromId) : annotation.fromY; + const toY = toId ? valueAt(toId) : annotation.toY; + if (fromY === undefined || toY === undefined) return null; + + return { + x: plotX, + fromY, + toY, + label: annotation.label ?? ratioLabel(fromY, toY), + description: annotation.description, + color: annotation.color, + pointsUp: toY > fromY, + labelSide: + annotation.labelSide ?? + (axisFraction(xAxis, index, plotX) > LABEL_FLIP_FRACTION ? 'left' : 'right'), + }; +}; + +/** True when at least one series has a finite value to draw; an all-null chart reads as empty. */ +export const hasPlottableData = (series: ComparisonSeries[], xAxis: ComparisonXValue[]): boolean => + xAxis.length > 0 && + series.some((s) => s.data.some((value) => typeof value === 'number' && Number.isFinite(value))); + +/** Compacts large magnitudes (16000 -> "16K") while keeping small values precise. */ +export const formatNumericValue = (value: number): string => + Math.abs(value) >= 1000 + ? value.toLocaleString(undefined, { notation: 'compact', maximumFractionDigits: 1 }) + : value.toLocaleString(undefined, { maximumFractionDigits: 3 }); + +export const formatXValueDefault = (value: ComparisonXValue): string => { + if (value instanceof Date) { + return value.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } + return typeof value === 'number' ? formatNumericValue(value) : String(value); +}; diff --git a/web/packages/common/src/components/index.ts b/web/packages/common/src/components/index.ts index 61fd264e08..fd8d0e2db5 100644 --- a/web/packages/common/src/components/index.ts +++ b/web/packages/common/src/components/index.ts @@ -7,6 +7,7 @@ export * from '@nemo/common/src/components/buttons/VariableButton'; export * from '@nemo/common/src/components/Chat/ChatEmptyState'; export * from '@nemo/common/src/components/Chat/MessageContent'; export * from '@nemo/common/src/components/CodeEditor'; +export * from '@nemo/common/src/components/ComparisonLineChart'; export * from '@nemo/common/src/components/FilterFields'; export * from '@nemo/common/src/components/form/VariableTextArea'; export * from '@nemo/common/src/components/form/ControlledVariableTextArea'; diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 9149347d16..1fbba85b43 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -452,6 +452,9 @@ importers: react-hook-form: specifier: 'catalog:' version: 7.71.2(react@19.2.7) + recharts: + specifier: 'catalog:' + version: 2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tsx: specifier: 'catalog:' version: 4.22.4 @@ -8875,7 +8878,7 @@ snapshots: '@mui/types@7.4.12(@types/react@19.2.14)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 optionalDependencies: '@types/react': 19.2.14 @@ -8893,7 +8896,7 @@ snapshots: '@mui/utils@7.3.9(@types/react@19.2.14)(react@19.2.7)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@mui/types': 7.4.12(@types/react@19.2.14) '@types/prop-types': 15.7.15 clsx: 2.1.1 @@ -8905,7 +8908,7 @@ snapshots: '@mui/x-charts-vendor@7.20.0': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@types/d3-color': 3.1.3 '@types/d3-delaunay': 6.0.4 '@types/d3-interpolate': 3.0.4 @@ -8923,10 +8926,10 @@ snapshots: '@mui/x-charts@7.29.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.7))(@types/react@19.2.14)(react@19.2.7))(@mui/material@7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.7))(@types/react@19.2.14)(react@19.2.7))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mui/system@7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.7))(@types/react@19.2.14)(react@19.2.7))(@types/react@19.2.14)(react@19.2.7))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@mui/material': 7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.7))(@types/react@19.2.14)(react@19.2.7))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@mui/system': 7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.7))(@types/react@19.2.14)(react@19.2.7))(@types/react@19.2.14)(react@19.2.7) - '@mui/utils': 7.3.9(@types/react@19.2.14)(react@19.2.7) + '@mui/utils': 7.3.11(@types/react@19.2.14)(react@19.2.7) '@mui/x-charts-vendor': 7.20.0 '@mui/x-internals': 7.29.0(@types/react@19.2.14)(react@19.2.7) '@react-spring/rafz': 9.7.5 @@ -8962,8 +8965,8 @@ snapshots: '@mui/x-internals@7.29.0(@types/react@19.2.14)(react@19.2.7)': dependencies: - '@babel/runtime': 7.29.2 - '@mui/utils': 7.3.9(@types/react@19.2.14)(react@19.2.7) + '@babel/runtime': 7.29.7 + '@mui/utils': 7.3.11(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 transitivePeerDependencies: - '@types/react' @@ -11472,7 +11475,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.10.6)(jsdom@29.1.1)(msw@2.13.3(@types/node@24.12.0)(@typescript/typescript6@6.0.2))(vite@8.0.16(@types/node@24.12.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.10.6)(jsdom@29.1.1)(msw@2.13.3(@types/node@25.5.0)(typescript@7.0.2))(vite@8.0.16(@types/node@25.5.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3)) '@vitest/eslint-plugin@1.6.16(@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.6.1)))(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.6.1)))(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.6.1))(vitest@4.1.10)': dependencies: @@ -11511,6 +11514,7 @@ snapshots: optionalDependencies: msw: 2.13.3(@types/node@24.12.0)(@typescript/typescript6@6.0.2) vite: 8.0.16(@types/node@24.12.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3) + optional: true '@vitest/mocker@4.1.10(msw@2.13.3(@types/node@24.12.0)(typescript@7.0.2))(vite@8.0.16(@types/node@24.12.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3))': dependencies: @@ -11565,7 +11569,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.10.6)(jsdom@29.1.1)(msw@2.13.3(@types/node@24.12.0)(@typescript/typescript6@6.0.2))(vite@8.0.16(@types/node@24.12.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.10.6)(jsdom@29.1.1)(msw@2.13.3(@types/node@25.5.0)(typescript@7.0.2))(vite@8.0.16(@types/node@25.5.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3)) '@vitest/utils@3.2.4': dependencies: @@ -12171,7 +12175,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 csstype: 3.2.3 dom-serializer@2.0.0: @@ -14489,7 +14493,7 @@ snapshots: react-transition-group@4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -15501,6 +15505,7 @@ snapshots: jsdom: 29.1.1 transitivePeerDependencies: - msw + optional: true vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.10.6)(jsdom@29.1.1)(msw@2.13.3(@types/node@24.12.0)(typescript@7.0.2))(vite@8.0.16(@types/node@24.12.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.8.3)): dependencies: From fc26a5678da83e206b49ca5b4b35345fd646a637 Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Tue, 11 Aug 2026 14:50:57 -0700 Subject: [PATCH 2/4] lock file Signed-off-by: Sean Teramae --- web/pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1fbba85b43..6fb8c2e28c 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -388,6 +388,9 @@ importers: react-router: specifier: 'catalog:' version: 8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + recharts: + specifier: 'catalog:' + version: 2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -452,9 +455,6 @@ importers: react-hook-form: specifier: 'catalog:' version: 7.71.2(react@19.2.7) - recharts: - specifier: 'catalog:' - version: 2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tsx: specifier: 'catalog:' version: 4.22.4 From d3252d3a673ee9f8f0f3a0ebfee6e631d8814c42 Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Tue, 11 Aug 2026 15:26:18 -0700 Subject: [PATCH 3/4] fix pr comments Signed-off-by: Sean Teramae --- .../ComparisonLineChart/index.test.tsx | 36 +++++++++++++++++++ .../components/ComparisonLineChart/index.tsx | 20 +++++------ .../components/ComparisonLineChart/utils.ts | 18 ++++++++-- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/web/packages/common/src/components/ComparisonLineChart/index.test.tsx b/web/packages/common/src/components/ComparisonLineChart/index.test.tsx index b9cdd3ebca..7a86c60378 100644 --- a/web/packages/common/src/components/ComparisonLineChart/index.test.tsx +++ b/web/packages/common/src/components/ComparisonLineChart/index.test.tsx @@ -13,6 +13,7 @@ import { } from '@nemo/common/src/components/ComparisonLineChart/utils'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { StrictMode } from 'react'; const series: ComparisonSeries[] = [ { id: 'baseline', label: 'Baseline', data: [0.4, 0.5, 0.55], dashed: true }, @@ -49,6 +50,25 @@ describe('ComparisonLineChart', () => { expect(onVisibleSeriesChange).toHaveBeenCalledWith(['candidate']); }); + it('notifies once for a toggle in Strict Mode', async () => { + const user = userEvent.setup(); + const onVisibleSeriesChange = vi.fn(); + render( + + + + ); + + await user.click(screen.getByRole('button', { name: 'Baseline' })); + + expect(onVisibleSeriesChange).toHaveBeenCalledTimes(1); + expect(onVisibleSeriesChange).toHaveBeenCalledWith(['candidate']); + }); + it('keeps hidden series in the legend so they can be restored', async () => { const user = userEvent.setup(); render( @@ -147,9 +167,25 @@ describe('ComparisonLineChart utils', () => { ]); }); + it('rejects reserved and duplicate series ids before constructing rows', () => { + expect(() => buildChartRows([{ id: 'x', label: 'X', data: [1] }], ['a'])).toThrow( + 'Series id "x" is reserved for the x axis.' + ); + expect(() => + buildChartRows( + [ + { id: 'a', label: 'First', data: [1] }, + { id: 'a', label: 'Second', data: [2] }, + ], + ['a'] + ) + ).toThrow('Duplicate series id: a'); + }); + it('treats null-only and empty input as unplottable', () => { expect(hasPlottableData(series, xAxis)).toBe(true); expect(hasPlottableData([{ id: 'a', label: 'A', data: [null] }], ['x'])).toBe(false); + expect(hasPlottableData([{ id: 'a', label: 'A', data: [null, 1] }], ['x'])).toBe(false); expect(hasPlottableData(series, [])).toBe(false); }); diff --git a/web/packages/common/src/components/ComparisonLineChart/index.tsx b/web/packages/common/src/components/ComparisonLineChart/index.tsx index 47c19ebbbe..225c439a64 100644 --- a/web/packages/common/src/components/ComparisonLineChart/index.tsx +++ b/web/packages/common/src/components/ComparisonLineChart/index.tsx @@ -98,18 +98,16 @@ export const ComparisonLineChart = ({ const toggleSeries = useCallback( (id: string) => { - setHiddenIds((current) => { - const next = new Set(current); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - onVisibleSeriesChange?.(series.filter((s) => !next.has(s.id)).map((s) => s.id)); - return next; - }); + const next = new Set(hiddenIds); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + setHiddenIds(next); + onVisibleSeriesChange?.(series.filter((s) => !next.has(s.id)).map((s) => s.id)); }, - [onVisibleSeriesChange, series] + [hiddenIds, onVisibleSeriesChange, series] ); /** Time axes plot timestamps, so restore the `Date` before handing values to the formatter. */ diff --git a/web/packages/common/src/components/ComparisonLineChart/utils.ts b/web/packages/common/src/components/ComparisonLineChart/utils.ts index 82777ea00f..7c59fad91b 100644 --- a/web/packages/common/src/components/ComparisonLineChart/utils.ts +++ b/web/packages/common/src/components/ComparisonLineChart/utils.ts @@ -36,8 +36,15 @@ export const toPlotValue = (value: ComparisonXValue): string | number => export const buildChartRows = ( series: ComparisonSeries[], xAxis: ComparisonXValue[] -): ComparisonChartRow[] => - xAxis.map((xValue, index) => { +): ComparisonChartRow[] => { + const ids = new Set(); + for (const { id } of series) { + if (id === 'x') throw new Error('Series id "x" is reserved for the x axis.'); + if (ids.has(id)) throw new Error(`Duplicate series id: ${id}`); + ids.add(id); + } + + return xAxis.map((xValue, index) => { const row: ComparisonChartRow = { x: toPlotValue(xValue) }; for (const entry of series) { const value = entry.data[index]; @@ -45,6 +52,7 @@ export const buildChartRows = ( } return row; }); +}; export interface ResolvedAnnotation { x: string | number; @@ -121,7 +129,11 @@ export const resolveAnnotation = ( /** True when at least one series has a finite value to draw; an all-null chart reads as empty. */ export const hasPlottableData = (series: ComparisonSeries[], xAxis: ComparisonXValue[]): boolean => xAxis.length > 0 && - series.some((s) => s.data.some((value) => typeof value === 'number' && Number.isFinite(value))); + series.some((s) => + s.data + .slice(0, xAxis.length) + .some((value) => typeof value === 'number' && Number.isFinite(value)) + ); /** Compacts large magnitudes (16000 -> "16K") while keeping small values precise. */ export const formatNumericValue = (value: number): string => From 80eae8c493a0e73d86ccffa6abe53b213003db4b Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Wed, 12 Aug 2026 09:48:37 -0700 Subject: [PATCH 4/4] split up comparison line chart component Signed-off-by: Sean Teramae --- .../ComparisonChartHeader.tsx | 19 ++ .../ComparisonLineChartEmpty.tsx | 36 +-- .../ComparisonLineChart/chartFrame.ts | 31 +++ .../ComparisonLineChart/chartLayers.tsx | 90 +++++++ .../components/ComparisonLineChart/index.tsx | 245 +++++------------- .../useComparisonChartModel.ts | 131 ++++++++++ 6 files changed, 340 insertions(+), 212 deletions(-) create mode 100644 web/packages/common/src/components/ComparisonLineChart/ComparisonChartHeader.tsx create mode 100644 web/packages/common/src/components/ComparisonLineChart/chartFrame.ts create mode 100644 web/packages/common/src/components/ComparisonLineChart/chartLayers.tsx create mode 100644 web/packages/common/src/components/ComparisonLineChart/useComparisonChartModel.ts diff --git a/web/packages/common/src/components/ComparisonLineChart/ComparisonChartHeader.tsx b/web/packages/common/src/components/ComparisonLineChart/ComparisonChartHeader.tsx new file mode 100644 index 0000000000..fec9ad4501 --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/ComparisonChartHeader.tsx @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flex, Text } from '@nvidia/foundations-react-core'; +import type { FC, ReactNode } from 'react'; + +interface Props { + title?: ReactNode; + legend?: ReactNode; +} + +/** Title on the left, legend on the right; renders when either is present. */ +export const ComparisonChartHeader: FC = ({ title, legend }) => + title || legend ? ( + + {title && {title}} + {legend} + + ) : null; diff --git a/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChartEmpty.tsx b/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChartEmpty.tsx index 690747ee2c..70a1f3efb1 100644 --- a/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChartEmpty.tsx +++ b/web/packages/common/src/components/ComparisonLineChart/ComparisonLineChartEmpty.tsx @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { - AXIS_COLOR, - AXIS_TEXT_COLOR, -} from '@nemo/common/src/components/ComparisonLineChart/consts'; + chartMargin, + xAxisLabelProps, + yAxisLabelProps, +} from '@nemo/common/src/components/ComparisonLineChart/chartFrame'; +import { AXIS_COLOR } from '@nemo/common/src/components/ComparisonLineChart/consts'; import { Text } from '@nvidia/foundations-react-core'; import type { FC } from 'react'; import { CartesianGrid, LineChart, ResponsiveContainer, XAxis, YAxis } from 'recharts'; @@ -20,7 +22,6 @@ interface Props { /** Two rows are enough to give the axes a domain to draw against. */ const PLACEHOLDER_ROWS = [{ x: 0 }, { x: 1 }]; const PLACEHOLDER_DOMAIN: [number, number] = [0, 1]; -const AXIS_LABEL_STYLE = { fontSize: 12, fill: AXIS_TEXT_COLOR } as const; /** * The chart frame — axes, labels, and grid — with the empty message centered in the plot area. @@ -36,10 +37,7 @@ export const ComparisonLineChartEmpty: FC = ({ }) => (
- + {showGrid && ( = ({ domain={PLACEHOLDER_DOMAIN} tick={false} stroke={AXIS_COLOR} - label={ - xAxisLabel - ? { - value: xAxisLabel, - position: 'insideBottom', - offset: -16, - style: AXIS_LABEL_STYLE, - } - : undefined - } + label={xAxisLabelProps(xAxisLabel)} /> diff --git a/web/packages/common/src/components/ComparisonLineChart/chartFrame.ts b/web/packages/common/src/components/ComparisonLineChart/chartFrame.ts new file mode 100644 index 0000000000..ad026c3978 --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/chartFrame.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AXIS_TEXT_COLOR } from '@nemo/common/src/components/ComparisonLineChart/consts'; +import type { LabelProps } from 'recharts'; + +export const TICK_STYLE = { fontSize: 11, fill: AXIS_TEXT_COLOR } as const; +export const AXIS_LABEL_STYLE = { fontSize: 12, fill: AXIS_TEXT_COLOR } as const; + +/** Axis labels sit outside the plot, so the margin has to grow to make room for them. */ +export const chartMargin = (xAxisLabel?: string, yAxisLabel?: string) => ({ + top: 8, + right: 16, + bottom: xAxisLabel ? 24 : 0, + left: yAxisLabel ? 8 : 0, +}); + +export const xAxisLabelProps = (label?: string): LabelProps | undefined => + label + ? { value: label, position: 'insideBottom', offset: -16, style: AXIS_LABEL_STYLE } + : undefined; + +export const yAxisLabelProps = (label?: string): LabelProps | undefined => + label + ? { + value: label, + angle: -90, + position: 'insideLeft', + style: { ...AXIS_LABEL_STYLE, textAnchor: 'middle' }, + } + : undefined; diff --git a/web/packages/common/src/components/ComparisonLineChart/chartLayers.tsx b/web/packages/common/src/components/ComparisonLineChart/chartLayers.tsx new file mode 100644 index 0000000000..abf8285f87 --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/chartLayers.tsx @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TICK_STYLE } from '@nemo/common/src/components/ComparisonLineChart/chartFrame'; +import { ComparisonAnnotationLabel } from '@nemo/common/src/components/ComparisonLineChart/ComparisonAnnotationLabel'; +import { + ANNOTATION_COLOR, + FADED_SERIES_OPACITY, + REFERENCE_LINE_COLOR, +} from '@nemo/common/src/components/ComparisonLineChart/consts'; +import type { + ComparisonCurve, + ComparisonReferenceLine, +} from '@nemo/common/src/components/ComparisonLineChart/types'; +import type { ColoredSeries } from '@nemo/common/src/components/ComparisonLineChart/useComparisonChartModel'; +import type { ResolvedAnnotation } from '@nemo/common/src/components/ComparisonLineChart/utils'; +import type { ReactElement } from 'react'; +import { Line, ReferenceLine } from 'recharts'; + +/** + * These build the plot's recharts children. They are plain functions rather than components + * because recharts inspects the element type of each child of `` — a wrapper component + * would hide the ``/`` elements from it. + */ + +export const renderReferenceLines = (lines: ComparisonReferenceLine[] = []): ReactElement[] => + lines.map((line) => ( + + )); + +export const renderAnnotations = (annotations: ResolvedAnnotation[]): ReactElement[] => + annotations.map((annotation) => ( + + } + /> + )); + +interface SeriesLineOptions { + curve: ComparisonCurve; + /** Fades every other line so a single series can be read out of a crowded chart. */ + hoveredId: string | null; + showMarks?: boolean; +} + +export const renderSeriesLines = ( + series: ColoredSeries[], + { curve, hoveredId, showMarks }: SeriesLineOptions +): ReactElement[] => + series.map((entry) => ( + + )); diff --git a/web/packages/common/src/components/ComparisonLineChart/index.tsx b/web/packages/common/src/components/ComparisonLineChart/index.tsx index 225c439a64..8e540cb53a 100644 --- a/web/packages/common/src/components/ComparisonLineChart/index.tsx +++ b/web/packages/common/src/components/ComparisonLineChart/index.tsx @@ -1,49 +1,36 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { ComparisonAnnotationLabel } from '@nemo/common/src/components/ComparisonLineChart/ComparisonAnnotationLabel'; +import { + TICK_STYLE, + chartMargin, + xAxisLabelProps, + yAxisLabelProps, +} from '@nemo/common/src/components/ComparisonLineChart/chartFrame'; +import { + renderAnnotations, + renderReferenceLines, + renderSeriesLines, +} from '@nemo/common/src/components/ComparisonLineChart/chartLayers'; +import { ComparisonChartHeader } from '@nemo/common/src/components/ComparisonLineChart/ComparisonChartHeader'; import { ComparisonLegend } from '@nemo/common/src/components/ComparisonLineChart/ComparisonLegend'; import { ComparisonLineChartEmpty } from '@nemo/common/src/components/ComparisonLineChart/ComparisonLineChartEmpty'; import { ComparisonLineChartSkeleton } from '@nemo/common/src/components/ComparisonLineChart/ComparisonLineChartSkeleton'; import { ComparisonTooltip } from '@nemo/common/src/components/ComparisonLineChart/ComparisonTooltip'; import { - ANNOTATION_COLOR, AXIS_COLOR, - AXIS_TEXT_COLOR, DEFAULT_CHART_HEIGHT, - FADED_SERIES_OPACITY, - REFERENCE_LINE_COLOR, } from '@nemo/common/src/components/ComparisonLineChart/consts'; import type { ComparisonLineChartProps } from '@nemo/common/src/components/ComparisonLineChart/types'; -import { - buildChartRows, - formatNumericValue, - formatXValueDefault, - hasPlottableData, - inferXAxisType, - resolveAnnotation, - seriesColor, -} from '@nemo/common/src/components/ComparisonLineChart/utils'; -import { Flex, Stack, Text } from '@nvidia/foundations-react-core'; -import { useCallback, useMemo, useState } from 'react'; -import { - CartesianGrid, - Line, - LineChart, - ReferenceLine, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; +import { useComparisonChartModel } from '@nemo/common/src/components/ComparisonLineChart/useComparisonChartModel'; +import { hasPlottableData } from '@nemo/common/src/components/ComparisonLineChart/utils'; +import { Stack } from '@nvidia/foundations-react-core'; +import { CartesianGrid, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; export * from '@nemo/common/src/components/ComparisonLineChart/consts'; export * from '@nemo/common/src/components/ComparisonLineChart/types'; export * from '@nemo/common/src/components/ComparisonLineChart/utils'; -const TICK_STYLE = { fontSize: 11, fill: AXIS_TEXT_COLOR } as const; -const AXIS_LABEL_STYLE = { fontSize: 12, fill: AXIS_TEXT_COLOR } as const; - /** * Multi-series line chart for comparing runs, models, or variants over a shared x axis. * Series are colored from the shared palette, the legend toggles them on and off, and hovering a @@ -67,91 +54,43 @@ export const ComparisonLineChart = ({ showMarks, referenceLines, annotations, - formatXValue = formatXValueDefault, - formatYValue = formatNumericValue, + formatXValue, + formatYValue, loading = false, emptyMessage = 'No data to compare', initialHiddenSeriesIds, onVisibleSeriesChange, className, }: ComparisonLineChartProps) => { - const [hiddenIds, setHiddenIds] = useState>( - () => new Set(initialHiddenSeriesIds ?? []) - ); - const [hoveredId, setHoveredId] = useState(null); - - const colored = useMemo( - () => series.map((entry, index) => ({ ...entry, resolvedColor: seriesColor(entry, index) })), - [series] - ); - - const rows = useMemo(() => buildChartRows(series, xAxis), [series, xAxis]); - const resolvedAnnotations = useMemo( - () => - (annotations ?? []) - .map((annotation) => resolveAnnotation(annotation, series, xAxis)) - .filter((annotation) => annotation !== null), - [annotations, series, xAxis] - ); - const resolvedXAxisType = xAxisType ?? inferXAxisType(xAxis); - const isTimeAxis = resolvedXAxisType === 'time'; - - const toggleSeries = useCallback( - (id: string) => { - const next = new Set(hiddenIds); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - setHiddenIds(next); - onVisibleSeriesChange?.(series.filter((s) => !next.has(s.id)).map((s) => s.id)); - }, - [hiddenIds, onVisibleSeriesChange, series] - ); - - /** Time axes plot timestamps, so restore the `Date` before handing values to the formatter. */ - const formatPlotValue = useCallback( - (value: string | number) => formatXValue(isTimeAxis ? new Date(value) : value), - [formatXValue, isTimeAxis] - ); - - const formatSeriesValue = useCallback( - (seriesId: string, value: number | null) => { - const entry = series.find((s) => s.id === seriesId); - return entry?.valueFormatter?.(value) ?? (value === null ? '—' : formatYValue(value)); - }, - [series, formatYValue] - ); - - const legendItems = colored.map((entry) => ({ - id: entry.id, - label: entry.label, - color: entry.resolvedColor, - dashed: entry.dashed, - hidden: hiddenIds.has(entry.id), - })); + const model = useComparisonChartModel({ + series, + xAxis, + xAxisType, + annotations, + formatXValue, + formatYValue, + initialHiddenSeriesIds, + onVisibleSeriesChange, + }); const renderLegend = (interactive: boolean) => ( ); - const showTopLegend = showLegend && legendPosition === 'top' && series.length > 0; - - /** Title on the left, legend on the right; renders when either is present. */ - const renderHeader = (interactive: boolean) => - title || showTopLegend ? ( - - {title && {title}} - {showTopLegend && renderLegend(interactive)} - - ) : null; + const hasLegend = showLegend && series.length > 0; + const renderHeader = (interactive: boolean) => ( + + ); + const showBottomLegend = hasLegend && legendPosition === 'bottom'; if (loading) { return ; @@ -168,7 +107,7 @@ export const ComparisonLineChart = ({ yAxisLabel={yAxisLabel} showGrid={showGrid} /> - {showLegend && legendPosition === 'bottom' && series.length > 0 && renderLegend(false)} + {showBottomLegend && renderLegend(false)} ); } @@ -179,8 +118,8 @@ export const ComparisonLineChart = ({ {showGrid && ( + } /> - {referenceLines?.map((line) => ( - - ))} - {resolvedAnnotations.map((annotation) => ( - - } - /> - ))} - {colored - .filter((entry) => !hiddenIds.has(entry.id)) - .map((entry) => ( - - ))} + {renderReferenceLines(referenceLines)} + {renderAnnotations(model.resolvedAnnotations)} + {renderSeriesLines(model.visibleSeries, { + curve, + hoveredId: model.hoveredId, + showMarks, + })} - {showLegend && legendPosition === 'bottom' && ( -
{renderLegend(legendInteractive)}
- )} + {showBottomLegend &&
{renderLegend(legendInteractive)}
} ); }; diff --git a/web/packages/common/src/components/ComparisonLineChart/useComparisonChartModel.ts b/web/packages/common/src/components/ComparisonLineChart/useComparisonChartModel.ts new file mode 100644 index 0000000000..c8ca7b8d79 --- /dev/null +++ b/web/packages/common/src/components/ComparisonLineChart/useComparisonChartModel.ts @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ComparisonLegendItem } from '@nemo/common/src/components/ComparisonLineChart/ComparisonLegend'; +import type { + ComparisonLineChartProps, + ComparisonSeries, +} from '@nemo/common/src/components/ComparisonLineChart/types'; +import { + buildChartRows, + formatNumericValue, + formatXValueDefault, + inferXAxisType, + resolveAnnotation, + seriesColor, +} from '@nemo/common/src/components/ComparisonLineChart/utils'; +import { useCallback, useMemo, useState } from 'react'; + +export interface ColoredSeries extends ComparisonSeries { + resolvedColor: string; +} + +type ModelOptions = Pick< + ComparisonLineChartProps, + | 'series' + | 'xAxis' + | 'xAxisType' + | 'annotations' + | 'formatXValue' + | 'formatYValue' + | 'initialHiddenSeriesIds' + | 'onVisibleSeriesChange' +>; + +/** + * Derives everything the chart draws from its props: pivoted rows, resolved colors and + * annotations, plus the legend visibility/hover state the chart and legend share. + */ +export const useComparisonChartModel = ({ + series, + xAxis, + xAxisType, + annotations, + formatXValue = formatXValueDefault, + formatYValue = formatNumericValue, + initialHiddenSeriesIds, + onVisibleSeriesChange, +}: ModelOptions) => { + const [hiddenIds, setHiddenIds] = useState>( + () => new Set(initialHiddenSeriesIds ?? []) + ); + const [hoveredId, setHoveredId] = useState(null); + + const colored = useMemo( + () => series.map((entry, index) => ({ ...entry, resolvedColor: seriesColor(entry, index) })), + [series] + ); + + const rows = useMemo(() => buildChartRows(series, xAxis), [series, xAxis]); + + const resolvedAnnotations = useMemo( + () => + (annotations ?? []) + .map((annotation) => resolveAnnotation(annotation, series, xAxis)) + .filter((annotation) => annotation !== null), + [annotations, series, xAxis] + ); + + const resolvedXAxisType = xAxisType ?? inferXAxisType(xAxis); + const isTimeAxis = resolvedXAxisType === 'time'; + + const toggleSeries = useCallback( + (id: string) => { + const next = new Set(hiddenIds); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + setHiddenIds(next); + onVisibleSeriesChange?.(series.filter((s) => !next.has(s.id)).map((s) => s.id)); + }, + [hiddenIds, onVisibleSeriesChange, series] + ); + + /** Time axes plot timestamps, so restore the `Date` before handing values to the formatter. */ + const formatPlotValue = useCallback( + (value: string | number) => formatXValue(isTimeAxis ? new Date(value) : value), + [formatXValue, isTimeAxis] + ); + + const formatSeriesValue = useCallback( + (seriesId: string, value: number | null) => { + const entry = series.find((s) => s.id === seriesId); + return entry?.valueFormatter?.(value) ?? (value === null ? '—' : formatYValue(value)); + }, + [series, formatYValue] + ); + + const legendItems = useMemo( + () => + colored.map((entry) => ({ + id: entry.id, + label: entry.label, + color: entry.resolvedColor, + dashed: entry.dashed, + hidden: hiddenIds.has(entry.id), + })), + [colored, hiddenIds] + ); + + const visibleSeries = useMemo( + () => colored.filter((entry) => !hiddenIds.has(entry.id)), + [colored, hiddenIds] + ); + + return { + rows, + resolvedAnnotations, + resolvedXAxisType, + isTimeAxis, + hoveredId, + setHoveredId, + toggleSeries, + formatPlotValue, + formatSeriesValue, + formatYValue, + legendItems, + visibleSeries, + }; +};