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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/charts/src/components/line-chart/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { LineChart } from './line-chart'
export type {
LineChartMessages,
LineChartOptions,
LineChartProps,
} from './line-chart'
export type { LineChartSeries } from './line-option'
15 changes: 15 additions & 0 deletions packages/charts/src/components/line-chart/line-chart.css
Original file line number Diff line number Diff line change
@@ -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);
}
143 changes: 143 additions & 0 deletions packages/charts/src/components/line-chart/line-chart.tsx
Original file line number Diff line number Diff line change
@@ -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
* <LineChart
* label="Revenue by month"
* categories={['Jan', 'Feb', 'Mar']}
* series={[{ name: 'Revenue', data: [1200, 2400, 1800] }]}
* />
*/
export const LineChart = forwardRef<HTMLDivElement, LineChartProps>(
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 (
<div
data-sl-line-chart
ref={ref}
role="img"
aria-label={label}
aria-describedby={description ? descriptionId : undefined}
aria-busy={loading || undefined}
{...htmlProps}
>
{description ? (
<span id={descriptionId}>
<VisuallyHidden>{description}</VisuallyHidden>
</span>
) : null}
{loading ? (
<Skeleton />
) : hasData ? (
<ChartContainer option={option} aria-hidden />
) : (
<div data-sl-line-chart-empty>{emptyLabel}</div>
)}
</div>
)
}
)

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
100 changes: 100 additions & 0 deletions packages/charts/src/components/line-chart/line-option.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>
/**
* 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<ChartTooltipDelta | null>
}

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,
})),
}
}
14 changes: 14 additions & 0 deletions packages/charts/src/components/line-chart/register.ts
Original file line number Diff line number Diff line change
@@ -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])
Loading
Loading