diff --git a/lightly_studio_view/src/lib/components/BarChart/BarChart.svelte b/lightly_studio_view/src/lib/components/BarChart/BarChart.svelte
index bf0d28303..9094e26e5 100644
--- a/lightly_studio_view/src/lib/components/BarChart/BarChart.svelte
+++ b/lightly_studio_view/src/lib/components/BarChart/BarChart.svelte
@@ -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;
/**
@@ -38,6 +46,7 @@
const {
data,
+ series = [],
orientation = 'vertical',
maxWidthPx,
maxHeightPx,
@@ -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).
@@ -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());
diff --git a/lightly_studio_view/src/lib/components/BarChart/BarChart.test.ts b/lightly_studio_view/src/lib/components/BarChart/BarChart.test.ts
index 70e436008..6f9725fed 100644
--- a/lightly_studio_view/src/lib/components/BarChart/BarChart.test.ts
+++ b/lightly_studio_view/src/lib/components/BarChart/BarChart.test.ts
@@ -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: {} }));
diff --git a/lightly_studio_view/src/lib/components/BarChart/buildEchartsOption.ts b/lightly_studio_view/src/lib/components/BarChart/buildEchartsOption.ts
index c9ae91f98..8d5e855e6 100644
--- a/lightly_studio_view/src/lib/components/BarChart/buildEchartsOption.ts
+++ b/lightly_studio_view/src/lib/components/BarChart/buildEchartsOption.ts
@@ -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';
@@ -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`). */
@@ -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);
@@ -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}: ${item.value}`
+ )
+ .join('
');
+ return `${name}
${values}`;
+ }
const percent = totalCount > 0 ? ` (${formatPercent(value / totalCount)})` : '';
return `${name}
Count: ${value}${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
+ }
+ ]
};
}
diff --git a/lightly_studio_view/src/lib/components/BarChart/index.ts b/lightly_studio_view/src/lib/components/BarChart/index.ts
index bc950691d..79684e75f 100644
--- a/lightly_studio_view/src/lib/components/BarChart/index.ts
+++ b/lightly_studio_view/src/lib/components/BarChart/index.ts
@@ -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';
diff --git a/lightly_studio_view/src/lib/components/BarChart/types.ts b/lightly_studio_view/src/lib/components/BarChart/types.ts
index 1b729ad96..74f5bb6bf 100644
--- a/lightly_studio_view/src/lib/components/BarChart/types.ts
+++ b/lightly_studio_view/src/lib/components/BarChart/types.ts
@@ -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[];
+}
diff --git a/lightly_studio_view/src/lib/components/DatasetDistributionPanel/DatasetDistributionPanel.test.ts b/lightly_studio_view/src/lib/components/DatasetDistributionPanel/DatasetDistributionPanel.test.ts
index 760bcdfbf..82a709665 100644
--- a/lightly_studio_view/src/lib/components/DatasetDistributionPanel/DatasetDistributionPanel.test.ts
+++ b/lightly_studio_view/src/lib/components/DatasetDistributionPanel/DatasetDistributionPanel.test.ts
@@ -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: {} }));
diff --git a/lightly_studio_view/src/lib/components/DatasetDistributionPanel/ExpandDialog/ExpandDialog.test.ts b/lightly_studio_view/src/lib/components/DatasetDistributionPanel/ExpandDialog/ExpandDialog.test.ts
index 9c841570c..79b65850c 100644
--- a/lightly_studio_view/src/lib/components/DatasetDistributionPanel/ExpandDialog/ExpandDialog.test.ts
+++ b/lightly_studio_view/src/lib/components/DatasetDistributionPanel/ExpandDialog/ExpandDialog.test.ts
@@ -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: {} }));