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: {} }));