Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
5 changes: 5 additions & 0 deletions .changeset/dark-trams-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lg-charts/core': minor
---

Small values including zero will show at least a thin bar (1px line minimum) on Bar charts for visibility. Bars with zero values are rendered with 30% opacity to visually differentiate them from bars with small non-zero values.
66 changes: 66 additions & 0 deletions charts/core/src/BarChart.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,69 @@ export const BarWithCategoryAxisLabel: StoryObj<{
);
},
};

export const BarWithMinimumHeight: StoryObj<{
barMinHeight: number;
}> = {
args: {
barMinHeight: 1,
},
argTypes: {
barMinHeight: {
control: { type: 'number', min: 0, max: 10, step: 1 },
description: 'Minimum height of bars in pixels',
},
},
render: ({ barMinHeight }) => {
// Create data with extreme value differences to demonstrate the feature
const extremeValueData = [
{
name: 'Large Values',
data: [
[new Date('2024-01-01').getTime(), 1000000000],
[new Date('2024-01-02').getTime(), 950000000],
[new Date('2024-01-03').getTime(), 1100000000],
[new Date('2024-01-04').getTime(), 1050000000],
] as Array<[number, number]>,
},
{
name: 'Small Values',
data: [
[new Date('2024-01-01').getTime(), 100],
[new Date('2024-01-02').getTime(), 50],
[new Date('2024-01-03').getTime(), 200],
[new Date('2024-01-04').getTime(), 0],
] as Array<[number, number]>,
},
{
name: 'Medium Values',
data: [
[new Date('2024-01-01').getTime(), 500000],
[new Date('2024-01-02').getTime(), 450000],
[new Date('2024-01-03').getTime(), 550000],
[new Date('2024-01-04').getTime(), 10],
] as Array<[number, number]>,
},
];

return (
<Chart>
<XAxis
type="time"
formatter={value => {
const date = new Date(value);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}}
/>
<YAxis type="value" />
<ChartTooltip />
{extremeValueData.map(({ name, data }) => (
<Bar name={name} data={data} key={name} barMinHeight={barMinHeight} />
))}
</Chart>
);
},
};
41 changes: 40 additions & 1 deletion charts/core/src/ChartTooltip/ChartTooltip.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,41 @@ import { type CallbackDataParams } from 'echarts/types/dist/shared';
*/
export type OptionDataValue = string | number | Date;

/**
* Data can be in array format [x, y] or object format { value: [x, y], itemStyle: {...} }
* when individual data points have custom styling (e.g., zero values with reduced opacity).
*/
export type SeriesDataItem =
| Array<OptionDataValue>
| { value: Array<OptionDataValue>; itemStyle?: Record<string, unknown> };

/**
* Helper function to extract the data array from either format.
* ECharts passes data differently depending on whether it's a simple array or an object with itemStyle.
*/
export function getDataArray(
data: SeriesDataItem | undefined,
): Array<OptionDataValue> | undefined {
if (!data) return undefined;

// Check if data is an object with a 'value' property (object format)
if (
typeof data === 'object' &&
!Array.isArray(data) &&
'value' in data &&
Array.isArray(data.value)
) {
return data.value;
}

// Otherwise, assume it's already an array
if (Array.isArray(data)) {
return data;
}

return undefined;
}

export const AxisPointerType = {
None: 'none',
Line: 'line',
Expand Down Expand Up @@ -53,7 +88,11 @@ export interface CallbackSeriesDataPoint extends CallbackDataParams {
axisType: string;
axisValue: string | number;
axisValueLabel: string | number;
data: Array<OptionDataValue>;
/**
* Data can be in array format [x, y] or object format { value: [x, y], itemStyle: {...} }
* Use getDataArray() helper to extract the array from either format.
*/
data: SeriesDataItem;
/**
* Echarts returns a custom color type which doesn't map to string but is one
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { IconButton } from '@leafygreen-ui/icon-button';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
import { isDefined } from '@leafygreen-ui/lib';

import { getDataArray } from '../ChartTooltip.types';

import {
closeButtonStyles,
getHeaderStyles,
Expand Down Expand Up @@ -43,7 +45,10 @@ export function CustomTooltip({
}: CustomTooltipProps) {
const { theme } = useDarkMode(darkMode);

if (seriesData.length === 0 || !isDefined(seriesData[0].data[0])) {
// Extract data array from either format (array or object with value property)
const firstDataArray = getDataArray(seriesData[0]?.data);

if (seriesData.length === 0 || !isDefined(firstDataArray?.[0])) {
return null;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { SeriesName } from '@lg-charts/series-provider';

import { OptionDataValue } from '../../ChartTooltip.types';
import { getDataArray, OptionDataValue } from '../../ChartTooltip.types';
import { SeriesListItem } from '../SeriesListItem';

import { getSeriesListStyles } from './SeriesList.styles';
Expand Down Expand Up @@ -32,8 +32,11 @@ export function SeriesList({
<ul className={getSeriesListStyles({ theme, tooltipPinned })} {...rest}>
{seriesData
.sort((a, b) => {
const [nameA, valueA] = a.data;
const [nameB, valueB] = b.data;
// Extract data arrays from either format (array or object with value property)
const dataArrayA = getDataArray(a.data) || [];
const dataArrayB = getDataArray(b.data) || [];
const [nameA, valueA] = dataArrayA;
const [nameB, valueB] = dataArrayB;

if (sort) {
return sort(
Expand All @@ -44,16 +47,20 @@ export function SeriesList({

return descendingCompareFn(valueA, valueB);
})
.map(({ seriesName, data, color }) => (
<SeriesListItem
key={seriesName}
seriesName={seriesName}
data={data}
color={color}
seriesValueFormatter={seriesValueFormatter}
seriesNameFormatter={seriesNameFormatter}
/>
))}
.map(({ seriesName, data, color }) => {
// Extract data array from either format
const dataArray = getDataArray(data) || [];
return (
<SeriesListItem
key={seriesName}
seriesName={seriesName}
data={dataArray}
color={color}
seriesValueFormatter={seriesValueFormatter}
seriesNameFormatter={seriesNameFormatter}
/>
);
})}
</ul>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { CallbackSeriesDataPoint } from '../../ChartTooltip.types';
import {
CallbackSeriesDataPoint,
OptionDataValue,
} from '../../ChartTooltip.types';
import { SeriesListProps } from '../SeriesList/SeriesList.types';

export interface SeriesListItemProps
Expand All @@ -7,6 +10,7 @@ export interface SeriesListItemProps
'seriesValueFormatter' | 'seriesNameFormatter'
> {
seriesName?: CallbackSeriesDataPoint['seriesName'];
data: CallbackSeriesDataPoint['data'];
/** Data array in [x, y] format (already extracted from object format if needed) */
data: Array<OptionDataValue>;
color: CallbackSeriesDataPoint['color'];
}
38 changes: 36 additions & 2 deletions charts/core/src/Series/Bar/Bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,31 +37,65 @@ export type BarProps = SeriesProps & {
* - `none`: Other bars will not be affected by the hover. (default)
*/
hoverBehavior?: BarHoverBehavior;

/**
* Minimum height of bar in pixels. Ensures small values (including zero) are still visible.
* Useful when charts have large differences in value magnitudes.
*
* Note: Bars with zero values are rendered with 30% opacity to visually differentiate them
* from bars with small non-zero values that are displayed at the minimum height.
* @default 1
*/
barMinHeight?: number;
};

export const Bar = ({
name,
data,
stack,
hoverBehavior = BarHoverBehavior.None,
barMinHeight = 1,
}: BarProps) => {
// Transform data to apply opacity to zero values only
const transformedData = React.useMemo(() => {
return data.map(([x, y]) => {
const value = y as number;

// Apply 30% opacity (0.3) to zero values to differentiate from 1px minimum height
if (value === 0) {
return {
value: [x, y],
itemStyle: {
opacity: 0.3,
},
};
}

// Keep non-zero values in array format for tooltip compatibility
return [x, y];
});
}, [data]);

const options = useCallback<
(stylingContext: StylingContext) => EChartSeriesOptions['bar']['options']
>(
stylingContext => ({
clip: false,
stack,
barMinHeight,
emphasis: {
focus: getEmphasisFocus(hoverBehavior),
},
itemStyle: {
color: stylingContext.seriesColor,
},
}),
[stack, hoverBehavior],
[stack, hoverBehavior, barMinHeight],
);

return <Series type="bar" name={name} data={data} options={options} />;
return (
<Series type="bar" name={name} data={transformedData} options={options} />
);
};

Bar.displayName = 'Bar';
Loading