Skip to content

Commit 6c58c01

Browse files
fix(charts): size grouped bar rows and keep the row highlight through resizes
Unstacked bar series sit side by side in a category row, so grouped charts keep the ECharts label column and their rows fit every bar slot. The row highlight redraws the active row after each render, so a resize moves it with the plot. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S6aTRnkiu7PxYZPNPXYEMV
1 parent 26a9dd2 commit 6c58c01

4 files changed

Lines changed: 85 additions & 11 deletions

File tree

‎apps/sim/lib/charts/bar-row-highlight.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ it('highlights the hovered row around its label and bar without touching neighbo
4343
expect(area.y + area.height).toBeGreaterThanOrEqual(barCenter(hovered) + 8)
4444
expect(area.y).toBeGreaterThanOrEqual(barCenter(hovered - 1) + 8)
4545
expect(area.y + area.height).toBeLessThanOrEqual(labelTop(categories[hovered + 1]))
46+
47+
chart.resize({ width: 720, height: height * 2 })
48+
chart.getZr().flush()
49+
chart.getZr().flush()
50+
const resized = chart
51+
.getZr()
52+
.storage.getDisplayList(true)
53+
.find((node) => node.type === 'rect' && !node.invisible && node.style.opacity === 0.06)
54+
if (!resized) throw new Error('Missing row highlight after resize')
55+
const moved = resized.getBoundingRect()
56+
expect(moved.y + moved.height).toBeGreaterThanOrEqual(barCenter(hovered) + 8)
57+
expect(moved.y).toBeGreaterThanOrEqual(barCenter(hovered - 1) + 8)
4658
dispose()
4759
} finally {
4860
chart.dispose()

‎apps/sim/lib/charts/bar-row-highlight.ts‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,28 @@ export function installBarRowHighlight(
3030
}
3131
const color = getComputedStyle(chart.getDom()).getPropertyValue('--text-body').trim()
3232
let current: number | null = null
33+
/** Geometry last drawn; a resize changes it for the same row, so rows alone cannot dedupe. */
34+
let drawn = ''
3335

3436
const render = (row: number | null) => {
35-
if (row === current) return
3637
current = row
3738
if (row === null) {
39+
if (drawn === 'hidden') return
40+
drawn = 'hidden'
3841
chart.setOption({ graphic: [{ id: HIGHLIGHT_ID, type: 'rect', invisible: true }] })
3942
return
4043
}
4144
const center = chart.convertToPixel({ yAxisIndex: 0 }, row)
4245
const left = inset(grid.left)
43-
const bottom = center + barWidth / 2 + BELOW_BAR_MARGIN
46+
const shape = {
47+
x: left,
48+
y: center + barWidth / 2 + BELOW_BAR_MARGIN - rowHeight,
49+
width: chart.getWidth() - left - inset(grid.right),
50+
height: rowHeight,
51+
}
52+
const geometry = JSON.stringify(shape)
53+
if (geometry === drawn) return
54+
drawn = geometry
4455
chart.setOption({
4556
graphic: [
4657
{
@@ -49,12 +60,7 @@ export function installBarRowHighlight(
4960
invisible: false,
5061
silent: true,
5162
z: 0,
52-
shape: {
53-
x: left,
54-
y: bottom - rowHeight,
55-
width: chart.getWidth() - left - inset(grid.right),
56-
height: rowHeight,
57-
},
63+
shape,
5864
style: { fill: color, opacity: 0.06 },
5965
},
6066
],
@@ -68,10 +74,16 @@ export function installBarRowHighlight(
6874
render(typeof category?.value === 'number' ? category.value : null)
6975
}
7076
const onLeave = () => render(null)
77+
/** Resizes re-render the chart; redraw the active row so it follows the new layout. */
78+
const onRendered = () => {
79+
if (current !== null) render(current)
80+
}
7181
chart.on('updateAxisPointer', onPointer)
7282
chart.on('globalout', onLeave)
83+
chart.on('finished', onRendered)
7384
return () => {
7485
chart.off('updateAxisPointer', onPointer)
7586
chart.off('globalout', onLeave)
87+
chart.off('finished', onRendered)
7688
}
7789
}

‎apps/sim/lib/charts/option.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,31 @@ describe('horizontal bar label layout', () => {
4545
expect(authored.yAxis[0]).not.toHaveProperty('axisLabel')
4646
})
4747

48+
it('keeps the label column for grouped bars and sizes rows for every bar in the group', () => {
49+
const grouped = {
50+
xAxis: { type: 'value' },
51+
yAxis: { type: 'category' },
52+
series: [{ type: 'bar' }, { type: 'bar' }],
53+
}
54+
expect(buildChartRenderOption({ option: grouped }).yAxis).not.toHaveProperty('axisLabel')
55+
const single = { ...grouped, series: [{ type: 'bar' }] }
56+
const rows = 20
57+
expect(horizontalBarChartHeight(grouped, rows)).toBeGreaterThanOrEqual(
58+
(horizontalBarChartHeight({ ...single, grid: { left: 0 } }, rows) ?? 0) +
59+
rows * CHART_BAR_MAX_WIDTH
60+
)
61+
const stacked = {
62+
...grouped,
63+
series: [
64+
{ type: 'bar', stack: 'total' },
65+
{ type: 'bar', stack: 'total' },
66+
],
67+
}
68+
expect(buildChartRenderOption({ option: stacked }).yAxis).toMatchObject({
69+
axisLabel: { inside: true },
70+
})
71+
})
72+
4873
it('keeps the ECharts label column for percentage bar widths it cannot size per row', () => {
4974
const option = {
5075
xAxis: { type: 'value' },

‎apps/sim/lib/charts/option.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,40 @@ export function isHorizontalBarOption(option: Record<string, unknown>): boolean
3636

3737
const CATEGORY_LABEL_LAYOUT_KEYS = ['inside', 'width', 'margin'] as const
3838

39+
const BAR_GROUP_GAP = 4
40+
41+
/**
42+
* Bar thickness per slot in one category row: stacked series share a slot, every other series
43+
* gets its own, and slots sit side by side within the row.
44+
*/
45+
function barSlotWidths(option: Record<string, unknown>): number[] {
46+
const series = Array.isArray(option.series) ? option.series : [option.series]
47+
const slots = new Map<unknown, number>()
48+
series.forEach((entry, index) => {
49+
const bar = toRecord(entry)
50+
const width = bar.barWidth ?? bar.barMaxWidth
51+
const key = bar.stack ?? Symbol(index)
52+
slots.set(
53+
key,
54+
Math.max(slots.get(key) ?? 0, typeof width === 'number' ? width : CHART_BAR_MAX_WIDTH)
55+
)
56+
})
57+
return [...slots.values()]
58+
}
59+
3960
/**
4061
* Labels above the bars are a default layout, not a blend: an option that places its own
4162
* category labels or reserves a left inset keeps the standard ECharts left column intact. So
42-
* does a percentage bar width, which scales with the plot and cannot be sized per row.
63+
* does a percentage bar width, which scales with the plot and cannot be sized per row, and a
64+
* grouped chart, whose side-by-side bars leave no single bar to place a label above.
4365
*/
4466
function authorsCategoryLabelColumn(option: Record<string, unknown>): boolean {
4567
const axis = toRecord(Array.isArray(option.yAxis) ? option.yAxis[0] : option.yAxis)
4668
const axisLabel = toRecord(axis.axisLabel)
4769
const grids = Array.isArray(option.grid) ? option.grid : [option.grid]
4870
const series = Array.isArray(option.series) ? option.series : [option.series]
4971
return (
72+
barSlotWidths(option).length > 1 ||
5073
CATEGORY_LABEL_LAYOUT_KEYS.some((key) => axisLabel[key] !== undefined) ||
5174
series.some((entry) => {
5275
const bar = toRecord(entry)
@@ -176,9 +199,11 @@ export function horizontalBarChartHeight(
176199
rowCount: number
177200
): number | null {
178201
if (!isHorizontalBarOption(option)) return null
202+
const slots = barSlotWidths(option)
203+
const barsHeight =
204+
slots.reduce((total, width) => total + width, 0) + BAR_GROUP_GAP * (slots.length - 1)
179205
const rowHeight =
180-
horizontalBarWidth(option) +
181-
(authorsCategoryLabelColumn(option) ? LEFT_LABEL_ROW_GAP : ABOVE_BAR_LABEL_SPACE)
206+
barsHeight + (authorsCategoryLabelColumn(option) ? LEFT_LABEL_ROW_GAP : ABOVE_BAR_LABEL_SPACE)
182207
return Math.max(MIN_CHART_HEIGHT, HORIZONTAL_BAR_CHROME_HEIGHT + rowCount * rowHeight)
183208
}
184209

0 commit comments

Comments
 (0)