diff --git a/packages/charts/src/components/bar-chart/bar-chart.tsx b/packages/charts/src/components/bar-chart/bar-chart.tsx index 5acf99c452..5b9f7fe4cd 100644 --- a/packages/charts/src/components/bar-chart/bar-chart.tsx +++ b/packages/charts/src/components/bar-chart/bar-chart.tsx @@ -3,6 +3,7 @@ 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, @@ -10,10 +11,9 @@ import type { 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 @@ -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 diff --git a/packages/charts/src/components/bar-chart/bar-option.ts b/packages/charts/src/components/bar-chart/bar-option.ts index 93ec547b9d..cfc44b908d 100644 --- a/packages/charts/src/components/bar-chart/bar-option.ts +++ b/packages/charts/src/components/bar-chart/bar-option.ts @@ -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. @@ -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((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' @@ -151,7 +100,7 @@ function barBorderRadius(args: { * their own end. */ function isStackEnd(args: { - series: BarChartSeries[] + series: ChartSeries[] seriesIndex: number categoryIndex: number grouping: BarChartGrouping @@ -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', diff --git a/packages/charts/src/components/bar-chart/messages/index.ts b/packages/charts/src/components/bar-chart/messages/index.ts deleted file mode 100644 index 7093d13cdd..0000000000 --- a/packages/charts/src/components/bar-chart/messages/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -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' - -export const messages = { - '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, -} diff --git a/packages/charts/src/components/bar-chart/messages/bg.json b/packages/charts/src/internal/messages/bg.json similarity index 100% rename from packages/charts/src/components/bar-chart/messages/bg.json rename to packages/charts/src/internal/messages/bg.json diff --git a/packages/charts/src/components/bar-chart/messages/de.json b/packages/charts/src/internal/messages/de.json similarity index 100% rename from packages/charts/src/components/bar-chart/messages/de.json rename to packages/charts/src/internal/messages/de.json diff --git a/packages/charts/src/components/bar-chart/messages/en.json b/packages/charts/src/internal/messages/en.json similarity index 100% rename from packages/charts/src/components/bar-chart/messages/en.json rename to packages/charts/src/internal/messages/en.json diff --git a/packages/charts/src/components/bar-chart/messages/es.json b/packages/charts/src/internal/messages/es.json similarity index 100% rename from packages/charts/src/components/bar-chart/messages/es.json rename to packages/charts/src/internal/messages/es.json diff --git a/packages/charts/src/components/bar-chart/messages/fr.json b/packages/charts/src/internal/messages/fr.json similarity index 100% rename from packages/charts/src/components/bar-chart/messages/fr.json rename to packages/charts/src/internal/messages/fr.json diff --git a/packages/charts/src/internal/messages/index.ts b/packages/charts/src/internal/messages/index.ts new file mode 100644 index 0000000000..f4cc519c48 --- /dev/null +++ b/packages/charts/src/internal/messages/index.ts @@ -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> diff --git a/packages/charts/src/components/bar-chart/messages/it.json b/packages/charts/src/internal/messages/it.json similarity index 100% rename from packages/charts/src/components/bar-chart/messages/it.json rename to packages/charts/src/internal/messages/it.json diff --git a/packages/charts/src/components/bar-chart/messages/ja.json b/packages/charts/src/internal/messages/ja.json similarity index 100% rename from packages/charts/src/components/bar-chart/messages/ja.json rename to packages/charts/src/internal/messages/ja.json diff --git a/packages/charts/src/components/bar-chart/messages/ko.json b/packages/charts/src/internal/messages/ko.json similarity index 100% rename from packages/charts/src/components/bar-chart/messages/ko.json rename to packages/charts/src/internal/messages/ko.json diff --git a/packages/charts/src/components/bar-chart/messages/nl.json b/packages/charts/src/internal/messages/nl.json similarity index 100% rename from packages/charts/src/components/bar-chart/messages/nl.json rename to packages/charts/src/internal/messages/nl.json diff --git a/packages/charts/src/components/bar-chart/messages/pt.json b/packages/charts/src/internal/messages/pt.json similarity index 100% rename from packages/charts/src/components/bar-chart/messages/pt.json rename to packages/charts/src/internal/messages/pt.json diff --git a/packages/charts/src/components/bar-chart/messages/ro.json b/packages/charts/src/internal/messages/ro.json similarity index 100% rename from packages/charts/src/components/bar-chart/messages/ro.json rename to packages/charts/src/internal/messages/ro.json diff --git a/packages/charts/src/components/bar-chart/messages/th.json b/packages/charts/src/internal/messages/th.json similarity index 100% rename from packages/charts/src/components/bar-chart/messages/th.json rename to packages/charts/src/internal/messages/th.json diff --git a/packages/charts/src/internal/option/index.ts b/packages/charts/src/internal/option/index.ts new file mode 100644 index 0000000000..add27fba9e --- /dev/null +++ b/packages/charts/src/internal/option/index.ts @@ -0,0 +1,8 @@ +export type { ChartAxisPointer } from './option' +export { + buildAxisTooltip, + buildCategoryAxis, + buildGrid, + buildLegend, + buildValueAxis, +} from './option' diff --git a/packages/charts/src/internal/option/option.ts b/packages/charts/src/internal/option/option.ts new file mode 100644 index 0000000000..49bc1cc5a6 --- /dev/null +++ b/packages/charts/src/internal/option/option.ts @@ -0,0 +1,103 @@ +import type { ChartTokens } from '../theme' +import type { ChartTooltipDelta } from '../tooltip' +import { createAxisTooltipFormatter, createTooltipPositioner } from '../tooltip' + +// Shared design-token constants used by every chart's option. +const spaceSmall = '--sl-space-2' +const spaceLegend = '--sl-space-10' +// Design spec, positioning: 8px between the hovered point and the tooltip +const tooltipOffsetToken = '--sl-space-2' +const tooltipOffsetFallback = 8 + +/** + * The axis pointer shape our charts use: a shadow band (bar) or a guide line + * (line/area). Typed narrowly so the engine's option union doesn't leak into + * the shared builders. + */ +export type ChartAxisPointer = + | { type: 'shadow'; shadowStyle?: { color?: string } } + | { type: 'line'; lineStyle?: { color?: string } } + +/** + * Legend shown only for multiple series, bottom left, per the design spec. + */ +export function buildLegend(seriesCount: number) { + return { show: seriesCount > 1, left: 0, bottom: 0 } +} + +/** + * Chart grid with label containment and token-driven padding. The bottom + * padding grows to make room for the legend when one is shown. + */ +export function buildGrid(args: { tokens: ChartTokens; showLegend: boolean }) { + const { tokens, showLegend } = args + + return { + containLabel: true, + left: tokens.px(spaceSmall), + right: tokens.px(spaceSmall), + top: tokens.px(spaceSmall), + bottom: tokens.px(showLegend ? spaceLegend : spaceSmall), + } +} + +/** + * Category axis carrying the chart's labels in render order. + */ +export function buildCategoryAxis(categories: string[]) { + return { type: 'category', data: categories } +} + +/** + * Value axis for the numeric dimension. + */ +export function buildValueAxis() { + return { type: 'value' } +} + +/** + * Shared axis-trigger tooltip envelope: the formatter, position, and DOM + * rendering are the same across charts; only the axis pointer (shadow vs line) + * and the row order vary, so those are the caller's to supply. Visuals come + * entirely from the formatter's markup + `tooltip.css` + * (`data-sl-chart-tooltip*`), not from the engine's tooltip container. + */ +export function buildAxisTooltip(args: { + tokens: ChartTokens + axisPointer: ChartAxisPointer + /** + * Lists the rows in reverse series order. Set it when the chart paints later + * series before earlier ones along the reader's axis. + * @default false + */ + reverse?: boolean + /** + * Resolves a row's delta from the position of its series and category. + * @default undefined + */ + getDelta?: ( + seriesIndex: number, + dataIndex: number + ) => ChartTooltipDelta | undefined +}) { + const { tokens, axisPointer, reverse, getDelta } = args + + return { + trigger: 'axis', + axisPointer, + // 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', + backgroundColor: 'transparent', + borderWidth: 0, + padding: 0, + extraCssText: 'box-shadow: none;', + formatter: createAxisTooltipFormatter({ reverse, getDelta }), + position: createTooltipPositioner( + tokens.px(tooltipOffsetToken) ?? tooltipOffsetFallback + ), + } +} diff --git a/packages/charts/src/internal/option/tests/option.test.ts b/packages/charts/src/internal/option/tests/option.test.ts new file mode 100644 index 0000000000..5dfb184450 --- /dev/null +++ b/packages/charts/src/internal/option/tests/option.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'vitest' + +import type { ChartTokens } from '../../theme' +import { + buildAxisTooltip, + buildCategoryAxis, + buildGrid, + buildLegend, + buildValueAxis, +} from '../option' + +const tokens: ChartTokens = { + get: () => undefined, + px: (token) => (token === '--sl-space-2' ? 8 : 40), +} + +describe('buildLegend', () => { + test('hides for a single series, shows bottom left for multiple', () => { + expect(buildLegend(1)).toEqual({ show: false, left: 0, bottom: 0 }) + expect(buildLegend(2)).toEqual({ show: true, left: 0, bottom: 0 }) + }) +}) + +describe('buildGrid', () => { + test('contains labels and pads from tokens', () => { + expect(buildGrid({ tokens, showLegend: false })).toEqual({ + containLabel: true, + left: 8, + right: 8, + top: 8, + bottom: 8, + }) + }) + + test('grows the bottom padding to fit the legend', () => { + const grid = buildGrid({ tokens, showLegend: true }) + + expect(grid.bottom).toBe(40) + expect(grid.top).toBe(8) + }) +}) + +describe('buildCategoryAxis / buildValueAxis', () => { + test('category axis carries the labels, value axis is plain', () => { + expect(buildCategoryAxis(['Jan', 'Feb'])).toEqual({ + type: 'category', + data: ['Jan', 'Feb'], + }) + expect(buildValueAxis()).toEqual({ type: 'value' }) + }) +}) + +describe('buildAxisTooltip', () => { + test('builds an axis-trigger tooltip envelope around the given axis pointer', () => { + const tooltip = buildAxisTooltip({ + tokens, + axisPointer: { type: 'line', lineStyle: { color: '#ccc' } }, + }) + + expect(tooltip.trigger).toBe('axis') + expect(tooltip.axisPointer).toEqual({ + type: 'line', + lineStyle: { color: '#ccc' }, + }) + expect(tooltip.confine).toBe(false) + expect(tooltip.appendTo).toBe('body') + expect(tooltip.backgroundColor).toBe('transparent') + expect(typeof tooltip.formatter).toBe('function') + expect(typeof tooltip.position).toBe('function') + }) + + test('passes the shadow axis pointer through unchanged', () => { + const tooltip = buildAxisTooltip({ + tokens, + axisPointer: { type: 'shadow', shadowStyle: { color: '#eee' } }, + }) + + expect(tooltip.axisPointer).toEqual({ + type: 'shadow', + shadowStyle: { color: '#eee' }, + }) + }) +}) diff --git a/packages/charts/src/internal/series/index.ts b/packages/charts/src/internal/series/index.ts new file mode 100644 index 0000000000..f373527d14 --- /dev/null +++ b/packages/charts/src/internal/series/index.ts @@ -0,0 +1,7 @@ +export type { ChartSeries } from './series' +export { + collapseSeries, + createDeltaLookup, + defaultMaxSeries, + seriesLimit, +} from './series' diff --git a/packages/charts/src/internal/series/series.ts b/packages/charts/src/internal/series/series.ts new file mode 100644 index 0000000000..8786378846 --- /dev/null +++ b/packages/charts/src/internal/series/series.ts @@ -0,0 +1,94 @@ +import type { ChartTooltipDelta } from '../tooltip' +import { chartSeriesTokens } from '../theme' + +/** + * The series shape every chart shares: a named array of values with optional + * tooltip deltas. Each public chart owns its own series type + * (`BarChartSeries`, `LineChartSeries`, …) with its own JSDoc, and each is + * structurally compatible with this one so the shared logic below applies to + * all of them. + */ +export interface ChartSeries { + name: string + data: Array + deltas?: Array +} + +/** + * 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: ChartSeries[], + othersLabel: string, + maxSeries: number +): ChartSeries[] { + // 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((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 }] +} + +/** + * Builds a positional delta lookup from a series list: the tooltip calls it + * with the series and category positions the engine reports, and it returns + * the delta for that data point or `undefined` when there is nothing to + * compare against. + * + * Returns `undefined` entirely when no series carries deltas, so the tooltip + * skips the lookup for the common case. Identity is positional rather than by + * name, because names are not required to be unique and two series sharing one + * would otherwise resolve to the same delta. + */ +export function createDeltaLookup( + series: ChartSeries[] +): + | ((seriesIndex: number, dataIndex: number) => ChartTooltipDelta | undefined) + | undefined { + const deltasBySeries = series.map((item) => item.deltas) + + if (!deltasBySeries.some(Boolean)) return undefined + + return (seriesIndex, dataIndex) => + deltasBySeries[seriesIndex]?.[dataIndex] ?? undefined +} diff --git a/packages/charts/src/internal/series/tests/series.test.ts b/packages/charts/src/internal/series/tests/series.test.ts new file mode 100644 index 0000000000..78c894564e --- /dev/null +++ b/packages/charts/src/internal/series/tests/series.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'vitest' + +import type { ChartSeries } from '../series' +import { + collapseSeries, + createDeltaLookup, + defaultMaxSeries, + seriesLimit, +} from '../series' + +describe('collapseSeries', () => { + test('leaves the series untouched when they fit within maxSeries', () => { + const series: ChartSeries[] = [ + { name: 'A', data: [1] }, + { name: 'B', data: [2] }, + { name: 'C', data: [3] }, + ] + + expect(collapseSeries(series, 'Others', 3)).toBe(series) + }) + + test('folds the tail into a single aggregate named from othersLabel', () => { + const result = collapseSeries( + [ + { name: 'A', data: [1, 2] }, + { name: 'B', data: [3, 4] }, + { name: 'C', data: [5, 6] }, + { name: 'D', data: [7, 8] }, + ], + 'Others', + 3 + ) + + expect(result.map((s) => s.name)).toEqual(['A', 'B', 'Others']) + expect(result[2]?.data).toEqual([12, 14]) + }) + + test('clamps maxSeries to the palette limit', () => { + expect(seriesLimit).toBe(6) + + const result = collapseSeries( + Array.from({ length: 10 }, (_, i) => ({ name: `S${i}`, data: [1] })), + 'Others', + 99 + ) + + expect(result).toHaveLength(seriesLimit) + expect(result[seriesLimit - 1]?.name).toBe('Others') + }) + + test('keeps the aggregate null where every folded series is null', () => { + const result = collapseSeries( + [ + { name: 'A', data: [1, 1, 1] }, + { name: 'B', data: [1, 1, 1] }, + { name: 'C', data: [null, 5, null] }, + { name: 'D', data: [null, null, 7] }, + ], + 'Others', + defaultMaxSeries + ) + + expect(result[2]?.data).toEqual([null, 5, 7]) + }) +}) + +describe('createDeltaLookup', () => { + test('returns undefined when no series carries deltas', () => { + expect( + createDeltaLookup([ + { name: 'A', data: [1] }, + { name: 'B', data: [2] }, + ]) + ).toBeUndefined() + }) + + test('resolves a delta by series and category position', () => { + const lookup = createDeltaLookup([ + { + name: 'A', + data: [10, 20], + deltas: [ + { value: '12%', direction: 'up' }, + { value: '4%', direction: 'down' }, + ], + }, + { name: 'B', data: [30] }, + ]) + + expect(lookup?.(0, 0)).toEqual({ value: '12%', direction: 'up' }) + expect(lookup?.(0, 1)).toEqual({ value: '4%', direction: 'down' }) + // B has no deltas. + expect(lookup?.(1, 0)).toBeUndefined() + // Out-of-range positions resolve to undefined, not a thrown lookup. + expect(lookup?.(5, 5)).toBeUndefined() + }) + + test('keeps deltas apart for two series sharing a name', () => { + const lookup = createDeltaLookup([ + { + name: 'Sales', + data: [10], + deltas: [{ value: 'first', direction: 'up' }], + }, + { + name: 'Sales', + data: [20], + deltas: [{ value: 'second', direction: 'up' }], + }, + ]) + + expect(lookup?.(0, 0)?.value).toBe('first') + expect(lookup?.(1, 0)?.value).toBe('second') + }) +}) diff --git a/packages/charts/src/internal/tooltip/tooltip-echarts.ts b/packages/charts/src/internal/tooltip/tooltip-echarts.ts index 076fb757ee..a04d7811f8 100644 --- a/packages/charts/src/internal/tooltip/tooltip-echarts.ts +++ b/packages/charts/src/internal/tooltip/tooltip-echarts.ts @@ -8,7 +8,7 @@ import { renderChartTooltip } from './tooltip-render' * locally because the engine doesn't re-export that type from `echarts/core` * or `echarts/components`. */ -interface EChartsAxisTooltipParam { +export interface EChartsAxisTooltipParam { name?: string seriesName?: string value: unknown @@ -17,7 +17,7 @@ interface EChartsAxisTooltipParam { dataIndex?: unknown } -interface EChartsTooltipPositionSize { +export interface EChartsTooltipPositionSize { contentSize: [number, number] viewSize: [number, number] }