diff --git a/packages/charts/src/components/line-chart/index.ts b/packages/charts/src/components/line-chart/index.ts new file mode 100644 index 000000000..dc9f27253 --- /dev/null +++ b/packages/charts/src/components/line-chart/index.ts @@ -0,0 +1,7 @@ +export { LineChart } from './line-chart' +export type { + LineChartMessages, + LineChartOptions, + LineChartProps, +} from './line-chart' +export type { LineChartSeries } from './line-option' diff --git a/packages/charts/src/components/line-chart/line-chart.css b/packages/charts/src/components/line-chart/line-chart.css new file mode 100644 index 000000000..ad767f445 --- /dev/null +++ b/packages/charts/src/components/line-chart/line-chart.css @@ -0,0 +1,15 @@ +[data-sl-line-chart] { + position: relative; + inline-size: 100%; + block-size: var(--sl-line-chart-block-size, 20rem); +} + +[data-sl-line-chart-empty] { + display: flex; + align-items: center; + justify-content: center; + block-size: 100%; + color: var(--sl-fg-muted); + font: var(--sl-text-body-font); + letter-spacing: var(--sl-text-body-letter-spacing); +} diff --git a/packages/charts/src/components/line-chart/line-chart.tsx b/packages/charts/src/components/line-chart/line-chart.tsx new file mode 100644 index 000000000..6c4b163c7 --- /dev/null +++ b/packages/charts/src/components/line-chart/line-chart.tsx @@ -0,0 +1,143 @@ +import { Skeleton, VisuallyHidden, createMessageHook } from '@vtex/shoreline' +import type { ComponentPropsWithoutRef } from 'react' +import { forwardRef, useId, useMemo } from 'react' + +import { ChartContainer } from '../../internal/chart-container' +import { chartMessages, type ChartMessages } from '../../internal/messages' +import type { ChartTokens } from '../../internal/theme' +import type { LineChartSeries } from './line-option' +import { buildLineOption, defaultMaxSeries } from './line-option' +import './register' + +const useMessage = createMessageHook(chartMessages) + +/** + * Line charts show trends over a continuous axis, in the Shoreline design + * language with zero styling effort. + * @status experimental + * @example + * + */ +export const LineChart = forwardRef( + function LineChart(props, ref) { + const { + series, + categories, + label, + description, + loading = false, + messages: messageOverrides, + maxSeries = defaultMaxSeries, + smooth = false, + ...htmlProps + } = props + + const getMessage = useMessage(messageOverrides) + const emptyLabel = getMessage('empty') + const othersLabel = getMessage('others') + + const descriptionId = useId() + const hasData = series.some((item) => + item.data.some((value) => value !== null) + ) + const option = useMemo( + () => (tokens: ChartTokens) => + buildLineOption({ + series, + categories, + othersLabel, + maxSeries, + smooth, + tokens, + }), + [series, categories, othersLabel, maxSeries, smooth] + ) + + return ( +
+ {description ? ( + + {description} + + ) : null} + {loading ? ( + + ) : hasData ? ( + + ) : ( +
{emptyLabel}
+ )} +
+ ) + } +) + +export interface LineChartOptions { + /** + * Chart series. At most `maxSeries` of them render: past that, the tail is + * summed per category into a single aggregate series named after the + * `others` message. + */ + series: LineChartSeries[] + /** + * Labels of the category axis, in render order. Every series provides one + * value per category. + */ + categories: string[] + /** + * Accessible name announced for the chart. + */ + label: string + /** + * Accessible long description of what the chart shows. + * @default undefined + */ + description?: string + /** + * Shows a loading placeholder instead of the chart. + * @default false + */ + loading?: boolean + /** + * Overrides the chart's internal messages, which are otherwise localized from + * the surrounding `LocaleProvider`. + * @default undefined + */ + messages?: LineChartMessages + /** + * How many series render at most. Raise it to give more series their own + * name and color instead of aggregating them; the default keeps the chart + * to the primary and secondary series plus the aggregate. + * + * Capped at 6 — the palette has that many colors and never cycles them. + * Series past the cap still aggregate, so no data is dropped. + * @default 3 + */ + maxSeries?: number + /** + * Draw the lines as smooth curves instead of straight segments. Off by + * default: straight segments read exact values more precisely, smooth + * curves suit trend-forward narratives. + * @default false + */ + smooth?: boolean +} + +export type LineChartProps = LineChartOptions & ComponentPropsWithoutRef<'div'> + +/** + * Line chart internal messages + */ +export type LineChartMessages = ChartMessages diff --git a/packages/charts/src/components/line-chart/line-option.ts b/packages/charts/src/components/line-chart/line-option.ts new file mode 100644 index 000000000..b05c2d68b --- /dev/null +++ b/packages/charts/src/components/line-chart/line-option.ts @@ -0,0 +1,100 @@ +import type { EChartsCoreOption } from '../../internal/echarts' +import type { ChartAxisPointer } from '../../internal/option' +import { + buildAxisTooltip, + buildCategoryAxis, + buildGrid, + buildLegend, + buildValueAxis, +} from '../../internal/option' +import { collapseSeries, createDeltaLookup } from '../../internal/series' +import type { ChartTokens } from '../../internal/theme' +import type { ChartTooltipDelta } from '../../internal/tooltip' + +// Re-exported so the component's `maxSeries` default flows from the shared +// constant without the package re-exporting it a second time (it already ships +// via the bar chart). +export { defaultMaxSeries } from '../../internal/series' + +/** + * A single line series. + */ +export interface LineChartSeries { + /** + * Series name, shown in the legend and tooltip. + */ + name: string + /** + * One value per category, in category order. `null` renders a gap at that + * category — the line is not interpolated across missing points unless a + * future opt-in says otherwise. + */ + data: Array + /** + * How each value changed against whatever period it is being compared to, + * in category order alongside `data`; `null` or a short array leaves that + * category's row without a delta. + * + * Supplied explicitly rather than derived, because only the consumer knows + * what the comparison is and whether a move reads as good or bad. The + * comparison series need not be on the chart at all. + * @default undefined + */ + deltas?: Array +} + +export interface BuildLineOptionArgs { + series: LineChartSeries[] + categories: string[] + othersLabel: string + maxSeries: number + /** + * Draw the lines as smooth curves instead of straight segments. + * @default false + */ + smooth?: boolean + tokens: ChartTokens +} + +// Design spec, tooltip: the guide line + marker that follow the hover across +// the chart. Driven from the same token the axis lines use. +const guideLine = '--sl-color-gray-3' + +/** + * Compiles the designed LineChart props into an engine option. Pure: all + * style values come from the resolved tokens. + */ +export function buildLineOption(args: BuildLineOptionArgs): EChartsCoreOption { + const { categories, othersLabel, maxSeries, smooth = false, tokens } = args + + // Everything downstream — legend, palette order — reads the collapsed list, + // so the aggregate behaves like any other series. + const series = collapseSeries(args.series, othersLabel, maxSeries) + + const showLegend = series.length > 1 + + const axisPointer: ChartAxisPointer = { + type: 'line', + lineStyle: { color: tokens.get(guideLine) }, + } + + return { + legend: buildLegend(series.length), + tooltip: buildAxisTooltip({ + tokens, + axisPointer, + getDelta: createDeltaLookup(series), + }), + grid: buildGrid({ tokens, showLegend }), + xAxis: buildCategoryAxis(categories), + yAxis: buildValueAxis(), + // `connectNulls` defaults to false, so null values render as gaps rather + // than being silently interpolated — the design spec's missing-data rule. + series: series.map((item) => ({ + name: item.name, + type: 'line', + data: item.data, + smooth, + })), + } +} diff --git a/packages/charts/src/components/line-chart/register.ts b/packages/charts/src/components/line-chart/register.ts new file mode 100644 index 000000000..5be4f7181 --- /dev/null +++ b/packages/charts/src/components/line-chart/register.ts @@ -0,0 +1,14 @@ +import { LineChart } from 'echarts/charts' +import { + GridComponent, + LegendComponent, + TooltipComponent, +} from 'echarts/components' + +import { use } from '../../internal/echarts' + +/** + * Engine modules the line chart needs, registered as a side effect of + * importing the component so consumer bundles only carry the charts they use. + */ +use([LineChart, GridComponent, LegendComponent, TooltipComponent]) diff --git a/packages/charts/src/components/line-chart/stories/examples.stories.tsx b/packages/charts/src/components/line-chart/stories/examples.stories.tsx new file mode 100644 index 000000000..eb64c9536 --- /dev/null +++ b/packages/charts/src/components/line-chart/stories/examples.stories.tsx @@ -0,0 +1,275 @@ +import { LocaleProvider } from '@vtex/shoreline' + +import type { ChartTooltipDelta } from '../../../index' +import { LineChart } from '../index' +import '../../../styles.css' + +export default { + title: 'charts/line-chart', + parameters: { + chromatic: { disableSnapshot: true }, + }, +} + +const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'] + +const revenue = [4200, 5100, 4800, 6300, 5900, 7200] +const cost = [2100, 2400, 2600, 2900, 2700, 3100] +const profit = revenue.map((value, i) => value - (cost[i] ?? 0)) + +export function Default() { + return ( + + ) +} + +/** + * `smooth` swaps straight segments for curves — trend-forward narratives + * over precise value reading. + */ +export function Smooth() { + return ( + + ) +} + +export function MultiSeries() { + return ( + + ) +} + +const previousRevenue = [3900, 5300, 4800, 5500, 6100, 6400] +const complaints = [180, 140, 155, 120, 95, 70] +const previousComplaints = [160, 175, 130, 145, 130, 90] + +/** + * Formats one period-over-period change the way a consumer would: the value + * carries whatever unit they want, `direction` follows the actual movement, + * and `tone` says what that movement means — which is why the caller passes + * `goodWhen` instead of it being inferred from the sign. + */ +function percentChange( + current: number[], + previous: number[], + goodWhen: 'up' | 'down' +): Array { + return current.map((value, index) => { + const before = previous[index] + + // No comparable figure for this category, so its row shows no delta. + if (before === undefined || before === 0) return null + + const change = ((value - before) / before) * 100 + + if (change === 0) return { value: '0.0%', direction: 'flat' } + + const direction = change > 0 ? 'up' : 'down' + + return { + value: `${Math.abs(change).toFixed(1)}%`, + direction, + tone: direction === goodWhen ? 'success' : 'critical', + } + }) +} + +/** + * Hover a point: each tooltip row carries the change against the previous + * period. The comparison data need not be plotted — `previousRevenue` is + * nowhere on the chart, it only feeds `deltas`. + */ +export function WithDeltas() { + return ( + + ) +} + +/** + * `direction` and `tone` are independent, so the same downward arrow can read + * either way: revenue falling is critical, complaints falling is a success. + * Only the consumer knows which, so neither is derived from the sign. + */ +export function DeltaToneIsTheConsumers() { + return ( + + ) +} + +/** + * Deltas are per category and optional: only the first three months have a + * comparable figure here, so the rest of the tooltips show plain values. + */ +export function PartialDeltas() { + return ( + + ) +} + +const channels = [ + { name: 'Website', data: [2300, 2900, 2500, 3400, 3100, 3800] }, + { name: 'Marketplace', data: [1200, 1400, 1500, 1800, 1900, 2200] }, + { name: 'Physical store', data: [700, 800, 800, 1100, 900, 1200] }, + { name: 'Social', data: [300, 350, 400, 500, 450, 600] }, + { name: 'Phone', data: [120, 140, 90, 200, 160, 180] }, +] + +/** + * Five series, default `maxSeries`: Website and Marketplace keep their names, + * the other three fold into "Others" in the tertiary color. + */ +export function GroupedIntoOthers() { + return ( + + ) +} + +/** + * The same five series with aggregation opted out, so each gets its own color + * from the extended palette. + */ +export function AllSeries() { + return ( + + ) +} + +/** + * Nine series against a `maxSeries` above the palette limit: five keep their + * names, the remaining four still fold into "Others" so no data is dropped. + */ +export function BeyondTheLimit() { + return ( + ({ + name: `Channel ${i + 1}`, + data: months.map((_, month) => 400 + i * 120 + month * 60), + }))} + /> + ) +} + +export function NegativeValues() { + return ( + + ) +} + +/** + * `null` values render as gaps: the line is not interpolated across the + * missing months, per the design spec's missing-data rule. + */ +export function MissingData() { + return ( + + ) +} + +export function Loading() { + return ( + + ) +} + +export function Empty() { + return ( + + ) +} + +/** + * Internal messages — the empty state and the "Others" aggregate — localize from + * the surrounding `LocaleProvider`. Data supplied through props is not + * translated: series names and categories are the consumer's to localize. + */ +export function Localized() { + return ( + + + + ) +} diff --git a/packages/charts/src/components/line-chart/tests/line-chart.test.tsx b/packages/charts/src/components/line-chart/tests/line-chart.test.tsx new file mode 100644 index 000000000..31f50a162 --- /dev/null +++ b/packages/charts/src/components/line-chart/tests/line-chart.test.tsx @@ -0,0 +1,154 @@ +import { + describe, + expect, + render, + screen, + test, + vi, +} from '@vtex/shoreline-test-utils' +import { LocaleProvider } from '@vtex/shoreline' + +import { LineChart } from '../index' + +class ResizeObserverStub { + observe() {} + + unobserve() {} + + disconnect() {} +} + +vi.stubGlobal('ResizeObserver', ResizeObserverStub) + +// jsdom has no canvas implementation; the engine only uses the 2d context to +// measure text, even when rendering to SVG. +vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + font: '', + measureText: (text: string) => ({ width: text.length * 8 }), +} as unknown as CanvasRenderingContext2D) + +// jsdom does no layout, so give elements a size for the engine to render into +vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400) +vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(300) + +const data = { + label: 'Revenue by month', + categories: ['Jan', 'Feb'], + series: [{ name: 'Revenue', data: [10, 20] }], +} + +describe('line-chart', () => { + test('renders an svg chart with an accessible name', () => { + const { container } = render() + + const chart = container.querySelector('[data-sl-line-chart]') + + expect(chart).toBeInTheDocument() + expect(chart).toHaveAccessibleName('Revenue by month') + expect(chart).toHaveAttribute('role', 'img') + expect(chart?.querySelector('svg')).toBeInTheDocument() + }) + + test('describes the chart when a description is given', () => { + render() + + expect(screen.getByRole('img')).toHaveAccessibleDescription( + 'Revenue grew 20% in February' + ) + }) + + test('renders a loading placeholder while loading', () => { + const { container } = render() + + expect(container.querySelector('[data-sl-skeleton]')).toBeInTheDocument() + expect(container.querySelector('svg')).not.toBeInTheDocument() + expect(screen.getByRole('img')).toHaveAttribute('aria-busy', 'true') + }) + + test('renders the empty state when there is no data', () => { + const { container } = render() + + expect( + container.querySelector('[data-sl-line-chart-empty]') + ).toHaveTextContent('No data') + expect(container.querySelector('svg')).not.toBeInTheDocument() + }) + + test('overrides the empty message', () => { + const { container } = render( + + ) + + expect( + container.querySelector('[data-sl-line-chart-empty]') + ).toHaveTextContent('Nothing to show') + }) + + const channels = [ + { name: 'Website', data: [10, 20] }, + { name: 'Marketplace', data: [5, 6] }, + { name: 'Physical store', data: [3, 4] }, + { name: 'Social', data: [2, 2] }, + { name: 'Phone', data: [1, 1] }, + ] + + test('legends the aggregate as a single entry past the second series', () => { + const { container } = render() + + const legend = container.querySelector('svg')?.textContent + + expect(legend).toContain('Website') + expect(legend).toContain('Marketplace') + expect(legend).toContain('Others') + expect(legend).not.toContain('Physical store') + expect(legend).not.toContain('Social') + expect(legend).not.toContain('Phone') + }) + + test('maxSeries opts out of aggregating', () => { + const { container } = render( + + ) + + const legend = container.querySelector('svg')?.textContent + + for (const { name } of channels) { + expect(legend).toContain(name) + } + + expect(legend).not.toContain('Others') + }) + + test('renders category labels through the engine', () => { + const { container } = render() + + expect(container.querySelector('svg')?.textContent).toContain('Jan') + expect(container.querySelector('svg')?.textContent).toContain('Feb') + }) + + test('localizes the messages from the surrounding locale', () => { + const { container } = render( + + + + ) + + expect(container.querySelector('svg')?.textContent).toContain('Outros') + }) + + test('localizes the empty message from the surrounding locale', () => { + const { container } = render( + + + + ) + + expect( + container.querySelector('[data-sl-line-chart-empty]') + ).toHaveTextContent('Sem dados') + }) +}) diff --git a/packages/charts/src/components/line-chart/tests/line-option.test.ts b/packages/charts/src/components/line-chart/tests/line-option.test.ts new file mode 100644 index 000000000..4fbd27f80 --- /dev/null +++ b/packages/charts/src/components/line-chart/tests/line-option.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from 'vitest' + +import type { ChartTokens } from '../../../internal/theme' +import type { BuildLineOptionArgs } from '../line-option' +import { buildLineOption, defaultMaxSeries } from '../line-option' + +const tokens: ChartTokens = { + get: () => undefined, + px: () => 8, +} + +function build(overrides: Partial = {}) { + return buildLineOption({ + series: [{ name: 'Revenue', data: [10, 20] }], + categories: ['Jan', 'Feb'], + othersLabel: 'Others', + maxSeries: defaultMaxSeries, + tokens, + ...overrides, + }) as { + legend: { show: boolean } + tooltip: { + trigger: string + axisPointer: { type: string; lineStyle?: { color?: string } } + confine: boolean + appendTo: string + formatter: (params: unknown) => string + position: unknown + } + xAxis: { type: string; data?: string[] } + yAxis: { type: string } + series: Array<{ + name: string + type: string + stack?: string + connectNulls?: boolean + smooth?: boolean + data: Array + }> + } +} + +// What the engine passes the tooltip formatter on hover: one item per series +// at the hovered category, always in series order. +const hoveredParams = [ + { name: 'Jan', seriesName: 'Revenue', value: 10, color: '#3993f4' }, + { name: 'Jan', seriesName: 'Cost', value: 20, color: '#9c56f3' }, +] + +describe('buildLineOption', () => { + test('puts categories on the x axis and values on the y axis', () => { + const option = build() + + expect(option.xAxis).toEqual({ type: 'category', data: ['Jan', 'Feb'] }) + expect(option.yAxis).toEqual({ type: 'value' }) + }) + + test('shows the legend only for multiple series, bottom left', () => { + expect(build().legend.show).toBe(false) + + const multi = build({ + series: [ + { name: 'A', data: [1] }, + { name: 'B', data: [2] }, + ], + }) + + expect(multi.legend).toEqual({ show: true, left: 0, bottom: 0 }) + }) + + test('renders every series as a line, never stacked', () => { + const option = build({ + series: [ + { name: 'A', data: [1] }, + { name: 'B', data: [2] }, + ], + }) + + expect(option.series.map((s) => s.type)).toEqual(['line', 'line']) + expect(option.series.every((s) => s.stack === undefined)).toBe(true) + }) + + test('leaves null values in place so the line gaps instead of interpolating', () => { + const option = build({ + series: [{ name: 'A', data: [10, null, 30] }], + }) + + expect(option.series[0]?.data).toEqual([10, null, 30]) + // connectNulls is left unset so the engine default (false) applies. + expect(option.series[0]?.connectNulls).toBeUndefined() + }) + + test('draws straight segments by default and smooth curves when smooth is set', () => { + expect(build().series[0]?.smooth).toBe(false) + + const smooth = build({ smooth: true }) + + expect(smooth.series[0]?.smooth).toBe(true) + }) + + test('configures a line axis pointer for the hover guide', () => { + const option = build() + + expect(option.tooltip.trigger).toBe('axis') + expect(option.tooltip.axisPointer.type).toBe('line') + expect(option.tooltip.confine).toBe(false) + expect(option.tooltip.appendTo).toBe('body') + expect(typeof option.tooltip.formatter).toBe('function') + }) + + test('renders two named series plus the aggregate by default', () => { + const option = build({ + categories: ['Jan', 'Feb'], + series: [ + { name: 'A', data: [1, 2] }, + { name: 'B', data: [3, 4] }, + { name: 'C', data: [5, 6] }, + { name: 'D', data: [10, 20] }, + { name: 'E', data: [100, 200] }, + ], + }) + + expect(option.series.map((s) => s.name)).toEqual(['A', 'B', 'Others']) + expect(option.series[2]?.data).toEqual([115, 226]) + }) + + test('keeps series order in the tooltip (no vertical-stack reversal)', () => { + const option = build({ + series: [ + { name: 'Revenue', data: [10] }, + { name: 'Cost', data: [20] }, + ], + }) + + const html = option.tooltip.formatter(hoveredParams) + + expect(html.indexOf('Revenue')).toBeLessThan(html.indexOf('Cost')) + }) + + test('shows a series delta at the hovered category', () => { + const option = build({ + series: [ + { + name: 'Revenue', + data: [10, 20], + deltas: [ + { value: '12%', direction: 'up', tone: 'success' }, + { value: '4%', direction: 'down', tone: 'critical' }, + ], + }, + ], + }) + + const first = option.tooltip.formatter([ + { + name: 'Jan', + seriesName: 'Revenue', + value: 10, + seriesIndex: 0, + dataIndex: 0, + }, + ]) + + expect(first).toContain('12%') + expect(first).toContain('data-tone="success"') + }) +}) diff --git a/packages/charts/src/index.ts b/packages/charts/src/index.ts index 48edb3364..d1c639674 100644 --- a/packages/charts/src/index.ts +++ b/packages/charts/src/index.ts @@ -11,6 +11,13 @@ export type { BarChartProps, BarChartSeries, } from './components/bar-chart' +export { LineChart } from './components/line-chart' +export type { + LineChartMessages, + LineChartOptions, + LineChartProps, + LineChartSeries, +} from './components/line-chart' // Consumers construct these to fill `BarChartSeries.deltas`, so the shape has // to be nameable outside the package even though the tooltip itself is // internal. diff --git a/packages/charts/src/styles.css b/packages/charts/src/styles.css index d56b7cbec..7794ee0d1 100644 --- a/packages/charts/src/styles.css +++ b/packages/charts/src/styles.css @@ -1,3 +1,4 @@ @import "./internal/chart-container/chart-container.css"; @import "./internal/tooltip/tooltip.css"; @import "./components/bar-chart/bar-chart.css"; +@import "./components/line-chart/line-chart.css";