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
20 changes: 15 additions & 5 deletions lightly_studio_view/src/lib/components/BarChart/BarChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@
import { onDestroy, type Snippet } from 'svelte';
import * as echarts from 'echarts/core';
import { BarChart as EchartsBarChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { buildEchartsOption, type BarChartOrientation } from './buildEchartsOption';
import type { CategoryCount } from './types';
import type { CategoryCount, CategoryCountSeries } from './types';

echarts.use([EchartsBarChart, GridComponent, TooltipComponent, CanvasRenderer]);
echarts.use([
EchartsBarChart,
GridComponent,
LegendComponent,
TooltipComponent,
CanvasRenderer
]);

interface Props {
/** Categories rendered in the given order. */
data: CategoryCount[];
/** Optional named series rendered as grouped bars on the `data` category axis. */
series?: CategoryCountSeries[];
/** Bar orientation (default 'vertical'). */
orientation?: BarChartOrientation;
/**
Expand All @@ -38,6 +46,7 @@

const {
data,
series = [],
orientation = 'vertical',
maxWidthPx,
maxHeightPx,
Expand All @@ -56,7 +65,8 @@
// panel). This extent sizes the category axis (width when vertical, height
// when horizontal); the +40/+60px covers the axis gutters.
const BAR_THICKNESS_PX = 28;
const barsExtentPx = $derived(data.length * BAR_THICKNESS_PX + (isHorizontal ? 40 : 60));
const categoryThicknessPx = $derived(Math.max(BAR_THICKNESS_PX, series.length * 14));
const barsExtentPx = $derived(data.length * categoryThicknessPx + (isHorizontal ? 40 : 60));

// Height budget in px: the measured container height, or a fallback so the
// chart still renders when the parent is unconstrained (standalone/Storybook).
Expand Down Expand Up @@ -102,7 +112,7 @@

$effect(() => {
if (!chart) return;
chart.setOption(buildEchartsOption(data, { totalCount, orientation }), true);
chart.setOption(buildEchartsOption(data, { totalCount, orientation, series }), true);
});

onDestroy(() => chart?.dispose());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ vi.mock('echarts/core', () => ({
vi.mock('echarts/charts', () => ({ BarChart: {} }));
vi.mock('echarts/components', () => ({
GridComponent: {},
LegendComponent: {},
TooltipComponent: {}
}));
vi.mock('echarts/renderers', () => ({ CanvasRenderer: {} }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,28 @@ import {
CHART_TEXT_COLOR,
formatPercent
} from '$lib/utils';
import type { CategoryCount } from './';
import type { CategoryCount, CategoryCountSeries } from './';

// Single accent color (the Lightly primary green, --color-lightly-primary #3bd99f):
// per-class colors carry no meaning in a count distribution.
const BAR_COLOR = 'rgba(59,217,159,0.85)';
const SERIES_COLORS = [
'#4E79A7',
'#F28E2B',
'#59A14F',
'#E15759',
'#B07AA1',
'#76B7B2',
'#EDC948',
'#FF9DA7',
'#9C755F'
];

/** Maps a stable series ID to the same accessible chart colour across renders. */
export function colorForSeries(id: string): string {
const hash = [...id].reduce((value, character) => value * 31 + character.charCodeAt(0), 0);
return SERIES_COLORS[Math.abs(hash) % SERIES_COLORS.length];
}

/** Bar layout: 'vertical' bars grow upward, 'horizontal' bars grow rightward. */
export type BarChartOrientation = 'vertical' | 'horizontal';
Expand All @@ -25,6 +42,8 @@ interface BuildEchartsOptionOptions {
totalCount?: number;
/** Bar orientation (default 'vertical'). */
orientation?: BarChartOrientation;
/** Named grouped-bar series rendered instead of the single `data` series. */
series?: CategoryCountSeries[];
}

/** Builds the ECharts option for a category-count bar chart (pass to `setOption`). */
Expand All @@ -35,6 +54,8 @@ export function buildEchartsOption(
const totalCount = options.totalCount ?? data.reduce((sum, item) => sum + item.count, 0);
const orientation = options.orientation ?? 'vertical';
const isHorizontal = orientation === 'horizontal';
const groupedSeries = options.series ?? [];
const isGrouped = groupedSeries.length > 0;

const labels = data.map((item) => item.label);

Expand Down Expand Up @@ -73,24 +94,52 @@ export function buildEchartsOption(
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: { name: string; value: number }[]) => {
formatter: (
params: { name: string; value: number; seriesName?: string; marker?: string }[]
) => {
const [{ name, value }] = params;
if (isGrouped) {
const values = params
.map(
(item) => `${item.marker ?? ''}${item.seriesName}: <b>${item.value}</b>`
)
.join('<br/>');
return `<b>${name}</b><br/>${values}`;
}
const percent = totalCount > 0 ? ` (${formatPercent(value / totalCount)})` : '';
return `<b>${name}</b><br/>Count: <b>${value}</b>${percent}`;
}
},
grid: { left: 8, right: 8, top: 16, bottom: 8, containLabel: true },
legend: isGrouped
? { type: 'scroll', top: 0, textStyle: { color: CHART_TEXT_COLOR } }
: undefined,
grid: { left: 8, right: 8, top: isGrouped ? 48 : 16, bottom: 8, containLabel: true },
// Swap which axis holds the categories so bars grow rightward when horizontal.
xAxis: isHorizontal ? valueAxis : categoryAxis,
yAxis: isHorizontal ? categoryAxis : valueAxis,
series: [
{
type: 'bar',
data: data.map((item) => item.count),
itemStyle: { color: BAR_COLOR },
barCategoryGap: '25%',
emphasis: CHART_EMPHASIS
}
]
series: isGrouped
? groupedSeries.map((item) => {
const countsByLabel = new Map(
item.data.map((count) => [count.label, count.count])
);
return {
id: item.id,
name: item.label,
type: 'bar',
data: labels.map((label) => countsByLabel.get(label) ?? 0),
itemStyle: { color: colorForSeries(item.id) },
barCategoryGap: '25%',
emphasis: CHART_EMPHASIS
};
})
: [
{
type: 'bar',
data: data.map((item) => item.count),
itemStyle: { color: BAR_COLOR },
barCategoryGap: '25%',
emphasis: CHART_EMPHASIS
}
]
};
}
2 changes: 1 addition & 1 deletion lightly_studio_view/src/lib/components/BarChart/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { default as BarChart } from './BarChart.svelte';
export type { CategoryCount } from './types';
export type { CategoryCount, CategoryCountSeries } from './types';
export type { BarChartOrientation } from './buildEchartsOption';
8 changes: 8 additions & 0 deletions lightly_studio_view/src/lib/components/BarChart/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@ export interface CategoryCount {
label: string;
count: number;
}

/** One named set of counts rendered against a shared category axis. */
export interface CategoryCountSeries {
/** Stable identity used to derive the series colour. */
id: string;
label: string;
data: CategoryCount[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ vi.mock('echarts/core', () => ({
vi.mock('echarts/charts', () => ({ BarChart: {}, CustomChart: {} }));
vi.mock('echarts/components', () => ({
GridComponent: {},
LegendComponent: {},
TooltipComponent: {}
}));
vi.mock('echarts/renderers', () => ({ CanvasRenderer: {} }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ vi.mock('echarts/core', () => ({
vi.mock('echarts/charts', () => ({ BarChart: {} }));
vi.mock('echarts/components', () => ({
GridComponent: {},
LegendComponent: {},
TooltipComponent: {}
}));
vi.mock('echarts/renderers', () => ({ CanvasRenderer: {} }));
Expand Down