Skip to content

Commit 81757bf

Browse files
committed
feat(Labels): Add middle placement and change center to center within the bar body. Improve docs. Resolves #378
1 parent c8a7c4d commit 81757bf

4 files changed

Lines changed: 116 additions & 15 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'layerchart': minor
3+
---
4+
5+
feat(Labels): Add `middle` placement and change `center` to center within the bar body
6+
7+
`placement="center"` now positions the label at the center of the bar body (between the value edge and the baseline). The previous `center` behavior (label aligned to the value edge with a middle anchor) is now available as the new `placement="middle"`.

docs/src/content/components/Labels.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,44 @@ By default labels will be on the outside of bars, above for positive values and
1515

1616
:example{ component="Bars" name="vertical-outside-labels-default" showCode }
1717

18-
You can also use `placement="inside"` to place within the bars (near the value)
18+
You can also use `placement="inside"` to place within the bars (near the value edge)
1919

2020
:example{ component="Bars" name="vertical-inside-labels" }
2121

22+
Use `placement="middle"` to align the label to the value edge with a centered anchor, or `placement="center"` to center the label within the bar body (between the value edge and the baseline).
23+
24+
:example{ component="BarChart" name="labels-placement" }
25+
26+
Labels work with horizontal bar charts, with placement respecting the value edge direction.
27+
28+
:example{ component="BarChart" name="series-horizontal-labels" }
29+
30+
Labels on grouped series bar charts will be placed relative to each series' bar.
31+
32+
:example{ component="BarChart" name="group-series-labels" }
33+
34+
Labels are also supported on duration (range) bars, showing the span value inside or outside each bar.
35+
36+
:example{ component="BarChart" name="duration-labels" }
37+
2238
### Line charts
2339

2440
:example{ component="Spline" name="with-labels" }
2541

26-
### Scatter charts
42+
Use `placement="smart"` to dynamically position labels based on neighboring point values (peaks, troughs, rising, and falling) to reduce overlap.
43+
44+
:example{ component="LineChart" name="smart-labels-with-points" }
45+
46+
Labels can also be rendered within enlarged points for a compact inline annotation style.
47+
48+
:example{ component="LineChart" name="labels-within-points" }
49+
50+
Series end labels can be shown for multi-series line charts, and highlighted on hover.
2751

28-
:example{ component="Points" name="with-labels" }
52+
:example{ component="LineChart" name="series-labels-hover" }
2953

3054
### Simplified charts
3155

3256
Labels are also integrated in simplified charts via the `labels` prop
3357

34-
:example{ component="BarChart" name="labels" }
58+
:example{ component="BarChart" name="labels" showCode }
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<script lang="ts">
2+
import { BarChart } from 'layerchart';
3+
import { MenuField } from 'svelte-ux';
4+
import { createDateSeries } from '$lib/utils/data.js';
5+
6+
type Placement = 'inside' | 'outside' | 'middle' | 'center' | 'smart';
7+
8+
const placements: { label: string; value: Placement }[] = [
9+
{ label: 'Inside', value: 'inside' },
10+
{ label: 'Outside', value: 'outside' },
11+
{ label: 'Middle', value: 'middle' },
12+
{ label: 'Center', value: 'center' }
13+
];
14+
15+
let placement: Placement = $state('outside');
16+
17+
const data = createDateSeries({
18+
count: 10,
19+
min: -20,
20+
max: 100,
21+
value: 'integer',
22+
keys: ['value', 'baseline']
23+
});
24+
export { data };
25+
</script>
26+
27+
<MenuField
28+
label="Placement"
29+
options={placements}
30+
bind:value={placement}
31+
stepper
32+
classes={{ root: 'mb-4', menuIcon: 'hidden' }}
33+
/>
34+
35+
<BarChart {data} x="date" y="value" labels={{ placement }} yPadding={[20, 20]} height={300} />

packages/layerchart/src/lib/components/Labels.svelte

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,19 @@
4242
4343
/**
4444
* The placement of the label relative to the point.
45-
* `smart` dynamically positions labels based on neighboring point values (peak, trough, rising, falling).
45+
* - `outside`: outside the bar/point.
46+
* - `inside`: inside the bar/point near the value edge.
47+
* - `middle`: aligned to the value edge with a middle anchor.
48+
* - `center`: centered within the bar body (between the value edge and baseline).
49+
* - `smart`: dynamically positions labels based on neighboring point values (peak, trough, rising, falling).
4650
* @default 'outside'
4751
*/
48-
placement?: 'inside' | 'outside' | 'center' | 'smart';
52+
placement?: 'inside' | 'outside' | 'middle' | 'center' | 'smart';
4953
5054
/**
5155
* The offset of the label from the point
5256
*
53-
* @default placement === 'center' ? 0 : 4
57+
* @default placement === 'center' || placement === 'middle' ? 0 : 4
5458
*/
5559
offset?: number;
5660
@@ -81,6 +85,7 @@
8185
import { getChartContext } from '$lib/contexts/chart.js';
8286
import Group from './Group.svelte';
8387
import { extractLayerProps } from '$lib/utils/attributes.js';
88+
import { createDimensionGetter } from '$lib/utils/rect.svelte.js';
8489
8590
const ctx = getChartContext();
8691
@@ -94,7 +99,7 @@
9499
y,
95100
seriesKey,
96101
placement = 'outside',
97-
offset = placement === 'center' ? 0 : 4,
102+
offset = placement === 'center' || placement === 'middle' ? 0 : 4,
98103
format,
99104
key = (_: any, i: number) => i,
100105
children: childrenProp,
@@ -104,6 +109,9 @@
104109
...restProps
105110
}: LabelsProps<TData> = $props();
106111
112+
// Used to compute the bar's bounding rect for `center` placement
113+
const getDimensions = $derived(createDimensionGetter(ctx, () => ({ x, y })));
114+
107115
// TODO: Should we let `Points` handle opacity for children snippet as well?
108116
let series = $derived(ctx.series.series.find((s) => s.key === seriesKey));
109117
let derivedOpacity = $derived(
@@ -146,14 +154,27 @@
146154
147155
if (isScaleBand(ctx.yScale)) {
148156
// Position label left/right on horizontal bars
149-
if (isLowEdge) {
157+
if (placement === 'center') {
158+
// Center within the bar body
159+
const dims = getDimensions(point.data) ?? { x: point.x, y: point.y, width: 0, height: 0 };
160+
result = {
161+
value: formattedValue,
162+
fill: fillValue,
163+
x: dims.x + dims.width / 2,
164+
y: dims.y + dims.height / 2,
165+
textAnchor: 'middle',
166+
verticalAnchor: 'middle',
167+
capHeight: '.6rem',
168+
};
169+
} else if (isLowEdge) {
150170
// left
151171
result = {
152172
value: formattedValue,
153173
fill: fillValue,
154174
x: point.x + (placement === 'outside' ? -offset : offset),
155175
y: point.y,
156-
textAnchor: placement === 'outside' ? 'end' : 'start',
176+
textAnchor:
177+
placement === 'middle' ? 'middle' : placement === 'outside' ? 'end' : 'start',
157178
verticalAnchor: 'middle',
158179
capHeight: '.6rem',
159180
};
@@ -164,14 +185,27 @@
164185
fill: fillValue,
165186
x: point.x + (placement === 'outside' ? offset : -offset),
166187
y: point.y,
167-
textAnchor: placement === 'outside' ? 'start' : 'end',
188+
textAnchor:
189+
placement === 'middle' ? 'middle' : placement === 'outside' ? 'start' : 'end',
168190
verticalAnchor: 'middle',
169191
capHeight: '.6rem',
170192
};
171193
}
172194
} else {
173195
// Position label top/bottom on vertical bars
174-
if (isLowEdge) {
196+
if (placement === 'center') {
197+
// Center within the bar body
198+
const dims = getDimensions(point.data) ?? { x: point.x, y: point.y, width: 0, height: 0 };
199+
result = {
200+
value: formattedValue,
201+
fill: fillValue,
202+
x: dims.x + dims.width / 2,
203+
y: dims.y + dims.height / 2,
204+
capHeight: '.6rem',
205+
textAnchor: 'middle',
206+
verticalAnchor: 'middle',
207+
};
208+
} else if (isLowEdge) {
175209
// bottom
176210
result = {
177211
value: formattedValue,
@@ -181,7 +215,7 @@
181215
capHeight: '.6rem',
182216
textAnchor: 'middle',
183217
verticalAnchor:
184-
placement === 'center' ? 'middle' : placement === 'outside' ? 'start' : 'end',
218+
placement === 'middle' ? 'middle' : placement === 'outside' ? 'start' : 'end',
185219
};
186220
} else {
187221
// top
@@ -193,7 +227,7 @@
193227
capHeight: '.6rem',
194228
textAnchor: 'middle',
195229
verticalAnchor:
196-
placement === 'center' ? 'middle' : placement === 'outside' ? 'end' : 'start',
230+
placement === 'middle' ? 'middle' : placement === 'outside' ? 'end' : 'start',
197231
};
198232
}
199233
}
@@ -271,7 +305,8 @@
271305
--fill-color: var(--color-surface-content, currentColor);
272306
--stroke-color: var(--color-surface-100, light-dark(white, black));
273307
274-
&[data-placement='inside'] {
308+
&[data-placement='inside'],
309+
&[data-placement='center'] {
275310
--fill-color: var(--color-surface-100, light-dark(white, black));
276311
--stroke-color: var(--color-surface-content, currentColor);
277312
}

0 commit comments

Comments
 (0)