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
17 changes: 3 additions & 14 deletions packages/charts/src/components/bar-chart/bar-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ 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 {
BarChartDirection,
BarChartGrouping,
BarChartSeries,
} from './bar-option'
import { buildBarOption, defaultMaxSeries } from './bar-option'
import { messages } from './messages'
import './register'

const useMessage = createMessageHook(messages)
const useMessage = createMessageHook(chartMessages)

/**
* Bar charts compare values across categories, in the Shoreline design
Expand Down Expand Up @@ -149,15 +149,4 @@ export type BarChartProps = BarChartOptions & ComponentPropsWithoutRef<'div'>
/**
* Bar chart internal messages
*/
export type BarChartMessages = Partial<{
[key in BarChartMessagesKeys]: string
}>

/**
* Bar chart internal messages intl keys
*/
type BarChartMessagesKeys =
/** Rendered in place of the chart when there is no data to display */
| 'empty'
/** Names the series that aggregates the tail of `series` */
| 'others'
export type BarChartMessages = ChartMessages
148 changes: 33 additions & 115 deletions packages/charts/src/components/bar-chart/bar-option.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import type { EChartsCoreOption } from '../../internal/echarts'
import type { ChartTokens } from '../../internal/theme'
import { chartSeriesTokens } from '../../internal/theme'
import type { ChartTooltipDelta } from '../../internal/tooltip'
import type { ChartAxisPointer } from '../../internal/option'
import {
createAxisTooltipFormatter,
createTooltipPositioner,
} from '../../internal/tooltip'
buildAxisTooltip,
buildCategoryAxis,
buildGrid,
buildLegend,
buildValueAxis,
} from '../../internal/option'
import type { ChartSeries } from '../../internal/series'
import { collapseSeries, createDeltaLookup } from '../../internal/series'

/**
* A single bar series.
Expand Down Expand Up @@ -53,67 +58,11 @@ export interface BuildBarOptionArgs {
tokens: ChartTokens
}

/**
* Hard ceiling on rendered series, set by the palette itself: there are only
* this many designed colors and they are never cycled, so no chart can show
* more distinct series than this regardless of what `maxSeries` asks for.
*/
export const seriesLimit = chartSeriesTokens.length

/**
* Default number of rendered series: the primary and secondary series plus the
* aggregate in the tertiary color.
*/
export const defaultMaxSeries = 3

/**
* Folds the tail of `series` into a single aggregate so that no more than
* `maxSeries` series render, keeping every value represented instead of
* dropping data. Requests above `seriesLimit` clamp to it, because past that
* point the palette has no color left to tell series apart.
*
* The aggregate takes the last rendered slot, so it wears that slot's color —
* the tertiary color at the default `maxSeries`.
*
* Values are summed per category. A null contributes nothing, and a category
* where every folded series is null stays null, so the aggregate renders no
* bar rather than a spurious zero.
*
* The aggregate carries no deltas: they arrive already formatted, so there is
* no sound way to combine those of the series it replaces. Series that keep
* their own slot keep their own deltas.
*/
export function collapseSeries(
series: BarChartSeries[],
othersLabel: string,
maxSeries: number
): BarChartSeries[] {
// A fractional or non-finite request would otherwise slice unpredictably.
const limit = Math.min(Math.max(Math.floor(maxSeries) || 1, 1), seriesLimit)

if (series.length <= limit) return series

// The aggregate occupies the last slot, so one fewer series keeps its name.
const rest = series.slice(limit - 1)
const length = Math.max(...rest.map((item) => item.data.length))

const data = Array.from({ length }, (_, index) =>
rest.reduce<number | null>((sum, item) => {
const value = item.data[index]

return value === null || value === undefined ? sum : (sum ?? 0) + value
}, null)
)

return [...series.slice(0, limit - 1), { name: othersLabel, data }]
}
// Re-exported so the package's public `defaultMaxSeries` / `seriesLimit`
// exports keep flowing from the same path they always have.
export { defaultMaxSeries, seriesLimit } from '../../internal/series'

const radiusToken = '--sl-radius-1'
const spaceSmall = '--sl-space-2'
const spaceLegend = '--sl-space-10'
// Design spec, positioning: 8px between the hovered bar and the tooltip
const tooltipOffsetToken = '--sl-space-2'
const tooltipOffsetFallback = 8
// Design spec, behaviour: hover overlay over the selected category
const hoverOverlayToken = '--sl-bg-muted-plain-hover'

Expand Down Expand Up @@ -151,7 +100,7 @@ function barBorderRadius(args: {
* their own end.
*/
function isStackEnd(args: {
series: BarChartSeries[]
series: ChartSeries[]
seriesIndex: number
categoryIndex: number
grouping: BarChartGrouping
Expand Down Expand Up @@ -197,59 +146,28 @@ export function buildBarOption(args: BuildBarOptionArgs): EChartsCoreOption {
// the first series where the reader starts.
const reverseTooltipRows = direction === 'vertical' && grouping === 'stacked'

// Deltas are looked up by the series' position, not its name: names are not
// required to be unique, and two series sharing one would otherwise both
// resolve to whichever came last. The collapsed list is exactly the order
// the engine receives its series in, so the index it reports lines up here.
// Left undefined when no series supplied deltas, so the tooltip skips the
// lookup entirely for the common case.
const deltasBySeries = series.map((item) => item.deltas)

const getDelta = deltasBySeries.some(Boolean)
? (seriesIndex: number, categoryIndex: number) =>
deltasBySeries[seriesIndex]?.[categoryIndex] ?? undefined
: undefined

const categoryAxis = { type: 'category', data: categories }
const valueAxis = { type: 'value' }
const axisPointer: ChartAxisPointer = {
type: 'shadow',
shadowStyle: { color: tokens.get(hoverOverlayToken) },
}

return {
legend: { show: showLegend, left: 0, bottom: 0 },
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
shadowStyle: { color: tokens.get(hoverOverlayToken) },
},
// The tooltip is allowed to overflow the chart. `confine` would push it
// back inside the chart bounds, and leaving the element inside the chart
// container lets any `overflow: hidden` ancestor clip it — so it renders
// on `body` instead, above whatever the chart sits in.
confine: false,
appendTo: 'body',
// Visuals come entirely from the formatter's own markup + tooltip.css
// (data-sl-chart-tooltip*), not from the engine's tooltip container.
backgroundColor: 'transparent',
borderWidth: 0,
padding: 0,
extraCssText: 'box-shadow: none;',
formatter: createAxisTooltipFormatter({
reverse: reverseTooltipRows,
getDelta,
}),
position: createTooltipPositioner(
tokens.px(tooltipOffsetToken) ?? tooltipOffsetFallback
),
},
grid: {
containLabel: true,
left: tokens.px(spaceSmall),
right: tokens.px(spaceSmall),
top: tokens.px(spaceSmall),
bottom: tokens.px(showLegend ? spaceLegend : spaceSmall),
},
xAxis: direction === 'vertical' ? categoryAxis : valueAxis,
yAxis: direction === 'vertical' ? valueAxis : categoryAxis,
legend: buildLegend(series.length),
tooltip: buildAxisTooltip({
tokens,
axisPointer,
reverse: reverseTooltipRows,
getDelta: createDeltaLookup(series),
}),
grid: buildGrid({ tokens, showLegend }),
xAxis:
direction === 'vertical'
? buildCategoryAxis(categories)
: buildValueAxis(),
yAxis:
direction === 'vertical'
? buildValueAxis()
: buildCategoryAxis(categories),
series: series.map((item, seriesIndex) => ({
name: item.name,
type: 'bar',
Expand Down
27 changes: 0 additions & 27 deletions packages/charts/src/components/bar-chart/messages/index.ts

This file was deleted.

46 changes: 46 additions & 0 deletions packages/charts/src/internal/messages/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import bg from './bg.json'
import de from './de.json'
import en from './en.json'
import es from './es.json'
import fr from './fr.json'
import it from './it.json'
import ja from './ja.json'
import ko from './ko.json'
import nl from './nl.json'
import pt from './pt.json'
import ro from './ro.json'
import th from './th.json'

/**
* Shared chart internal messages, localized from the surrounding
* `LocaleProvider`. Every chart uses the same set: the empty-state label and
* the name of the series that aggregates the tail past `maxSeries`.
*/
export const chartMessages = {
'en-US': en,
'es-AR': es,
'fr-FR': fr,
'pt-BR': pt,
'ja-JP': ja,
'ko-KR': ko,
'it-IT': it,
'nl-NL': nl,
'ro-RO': ro,
'bg-BG': bg,
'th-TH': th,
'de-DE': de,
}

/**
* Chart internal messages intl keys
*/
export type ChartMessagesKeys =
/** Rendered in place of the chart when there is no data to display */
| 'empty'
/** Names the series that aggregates the tail of `series` */
| 'others'

/**
* Chart internal messages
*/
export type ChartMessages = Partial<Record<ChartMessagesKeys, string>>
8 changes: 8 additions & 0 deletions packages/charts/src/internal/option/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type { ChartAxisPointer } from './option'
export {
buildAxisTooltip,
buildCategoryAxis,
buildGrid,
buildLegend,
buildValueAxis,
} from './option'
Loading
Loading