Skip to content

Commit bf96b23

Browse files
committed
improvement(emcn): draw each stacked column as one bar split by color
1 parent 78c4a3c commit bf96b23

2 files changed

Lines changed: 59 additions & 19 deletions

File tree

‎packages/emcn/src/components/charts/bar-chart.tsx‎

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,6 @@ interface StackedBarChartProps extends BarChartBaseProps {
7272

7373
export type BarChartProps = SingleSeriesBarChartProps | StackedBarChartProps
7474

75-
/** Surface gap between stacked segments, so adjacent fills never merge. */
76-
const STACK_GAP = 1
77-
7875
/**
7976
* Tick and tooltip text for a bucket's value, in the caller's unit. `exact` is for
8077
* the tooltip, where a figure is read in full rather than fitted to the axis gutter.
@@ -177,7 +174,7 @@ function BarChartComponent(props: BarChartProps) {
177174
cursor -= segmentHeight
178175
return [{ layer, y: cursor, height: segmentHeight }]
179176
})
180-
return { x, top, point, segments }
177+
return { x, top, height: columnHeight, point, segments }
181178
}),
182179
[
183180
buckets,
@@ -323,26 +320,50 @@ function BarChartComponent(props: BarChartProps) {
323320
))}
324321
</g>
325322

326-
<g style={{ mixBlendMode: isDark && !isStacked ? 'screen' : 'normal' }}>
327-
{columns.flatMap((column, index) =>
328-
column.segments.map((segment, segmentIndex) => {
329-
/** Every segment but the topmost gives up a sliver so neighbours stay distinct. */
330-
const gap = segmentIndex < column.segments.length - 1 ? STACK_GAP : 0
331-
return (
323+
{/**
324+
* Each column is one bar: its segments are square and clipped to a single
325+
* rounded outline, so only the bar's ends are rounded and a stack reads as one
326+
* shape split by color. A segment reaches half a pixel below its own bottom so
327+
* the one beneath never shows an anti-aliased seam through the join.
328+
*/}
329+
<defs>
330+
{columns.map((column, index) =>
331+
column.segments.length === 0 ? null : (
332+
<clipPath
333+
key={`${uniqueId}-column-${column.point.timestamp}`}
334+
id={`${uniqueId}-column-${index}`}
335+
>
332336
<rect
333-
key={`${uniqueId}-bar-${column.point.timestamp}-${segment.layer.id}`}
334337
x={column.x}
335-
y={segment.y + gap}
338+
y={column.top}
336339
width={barWidth}
337-
height={Math.max(0, segment.height - gap)}
340+
height={column.height}
338341
rx='2'
342+
/>
343+
</clipPath>
344+
)
345+
)}
346+
</defs>
347+
<g style={{ mixBlendMode: isDark && !isStacked ? 'screen' : 'normal' }}>
348+
{columns.map((column, index) => (
349+
<g
350+
key={`${uniqueId}-bar-${column.point.timestamp}`}
351+
clipPath={`url(#${uniqueId}-column-${index})`}
352+
>
353+
{column.segments.map((segment) => (
354+
<rect
355+
key={segment.layer.id}
356+
x={column.x}
357+
y={segment.y}
358+
width={barWidth}
359+
height={segment.height + 0.5}
339360
fill={isStacked ? segment.layer.color : `url(#bar-${uniqueId})`}
340361
className='transition-opacity duration-150 motion-reduce:transition-none'
341362
opacity={segmentOpacity(segment.layer.id, index)}
342363
/>
343-
)
344-
})
345-
)}
364+
))}
365+
</g>
366+
))}
346367
</g>
347368

348369
{tickIndices.map((index) => {

‎packages/emcn/src/components/charts/chart-layout.test.tsx‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ describe('BarChart rendered geometry', () => {
121121
height={160}
122122
/>
123123
)
124-
const bars = [...svg.querySelectorAll('rect')]
124+
const bars = [...svg.querySelectorAll('rect')].filter((rect) => !rect.closest('clipPath'))
125125
expect(bars.length).toBeGreaterThan(0)
126126
const svgWidth = Number(svg.getAttribute('width'))
127127
for (const bar of bars) {
@@ -349,10 +349,10 @@ describe('BarChart stacked mode', () => {
349349
data: buckets.map((timestamp, index) => ({ timestamp, value: values[index] ?? 0 })),
350350
})
351351

352-
/** Segment rects are the filled ones; tracks are painted with the border token. */
352+
/** Segment rects are the filled ones; tracks are painted with the border token, and a column's clip outline is not drawn. */
353353
function segments(svg: SVGSVGElement) {
354354
return [...svg.querySelectorAll('rect')].filter(
355-
(rect) => rect.getAttribute('fill') !== 'var(--border)'
355+
(rect) => rect.getAttribute('fill') !== 'var(--border)' && !rect.closest('clipPath')
356356
)
357357
}
358358

@@ -372,6 +372,25 @@ describe('BarChart stacked mode', () => {
372372
expect(Number(firstA.getAttribute('y'))).toBeGreaterThan(Number(firstB.getAttribute('y')))
373373
})
374374

375+
it('draws a column as one bar: flush square segments inside one rounded outline', () => {
376+
const svg = mountAtWidth(
377+
680,
378+
<BarChart
379+
label=''
380+
height={200}
381+
series={[layer('a', 'red', [30, 0, 10]), layer('b', 'blue', [10, 5, 0])]}
382+
/>
383+
)
384+
const [bottom, top] = segments(svg)
385+
expect(Number(top.getAttribute('y')) + Number(top.getAttribute('height'))).toBeGreaterThan(
386+
Number(bottom.getAttribute('y'))
387+
)
388+
expect(bottom.hasAttribute('rx')).toBe(false)
389+
const outline = svg.querySelector('clipPath rect')
390+
expect(outline?.getAttribute('rx')).toBe('2')
391+
expect(Number(outline?.getAttribute('y'))).toBeCloseTo(Number(top.getAttribute('y')), 5)
392+
})
393+
375394
it('stacks each column to the height a single bar of its total would reach', () => {
376395
const stacked = mountAtWidth(
377396
680,

0 commit comments

Comments
 (0)