Skip to content

Commit 8ffc5da

Browse files
dynamic-smart-labels (#799)
* dynamic-smart-labels * Merge getDynamicTextProps into getTextProps. Add example * Add changeset --------- Co-authored-by: Sean Lynch <techniq35@gmail.com>
1 parent 3186880 commit 8ffc5da

3 files changed

Lines changed: 81 additions & 11 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 `smart` placement option
6+
7+
New `placement="smart"` mode that dynamically positions labels based on neighboring point values (peak, trough, rising, falling) to reduce overlapping.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script lang="ts">
2+
import { LineChart, defaultChartPadding } from 'layerchart';
3+
import { createDateSeries } from '$lib/utils/data.js';
4+
5+
const data = createDateSeries({ count: 30, min: 50, max: 100, value: 'integer' });
6+
export { data };
7+
</script>
8+
9+
<LineChart
10+
{data}
11+
x="date"
12+
y="value"
13+
xNice
14+
padding={defaultChartPadding({ top: 25, right: 10 })}
15+
height={300}
16+
points
17+
labels={{ placement: 'smart' }}
18+
/>

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

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@
4141
seriesKey?: string;
4242
4343
/**
44-
* The placement of the label relative to the point
44+
* The placement of the label relative to the point.
45+
* `smart` dynamically positions labels based on neighboring point values (peak, trough, rising, falling).
4546
* @default 'outside'
4647
*/
47-
placement?: 'inside' | 'outside' | 'center';
48+
placement?: 'inside' | 'outside' | 'center' | 'smart';
4849
4950
/**
5051
* The offset of the label from the point
@@ -114,12 +115,11 @@
114115
: 0.1)
115116
);
116117
117-
function getTextProps(point: Point): ComponentProps<typeof Text> {
118+
function getTextProps(point: Point, points?: Point[], i?: number): ComponentProps<typeof Text> {
118119
// Used for positioning direction.
119120
// For array accessors (edgeIndex defined), use edge position: 0 = start/low, 1 = end/high
120121
const pointValue = isScaleBand(ctx.yScale) ? point.xValue : point.yValue;
121-
const isLowEdge =
122-
point.edgeIndex != null ? point.edgeIndex === 0 : pointValue < 0;
122+
const isLowEdge = point.edgeIndex != null ? point.edgeIndex === 0 : pointValue < 0;
123123
124124
// extract the true fill value from `fill` which could be an
125125
// accessor function or string/undefined
@@ -142,11 +142,13 @@
142142
: ctx.yScale.tickFormat?.())
143143
);
144144
145+
let result: ComponentProps<typeof Text>;
146+
145147
if (isScaleBand(ctx.yScale)) {
146148
// Position label left/right on horizontal bars
147149
if (isLowEdge) {
148150
// left
149-
return {
151+
result = {
150152
value: formattedValue,
151153
fill: fillValue,
152154
x: point.x + (placement === 'outside' ? -offset : offset),
@@ -157,7 +159,7 @@
157159
};
158160
} else {
159161
// right
160-
return {
162+
result = {
161163
value: formattedValue,
162164
fill: fillValue,
163165
x: point.x + (placement === 'outside' ? offset : -offset),
@@ -171,7 +173,7 @@
171173
// Position label top/bottom on vertical bars
172174
if (isLowEdge) {
173175
// bottom
174-
return {
176+
result = {
175177
value: formattedValue,
176178
fill: fillValue,
177179
x: point.x,
@@ -183,7 +185,7 @@
183185
};
184186
} else {
185187
// top
186-
return {
188+
result = {
187189
value: formattedValue,
188190
fill: fillValue,
189191
x: point.x,
@@ -195,22 +197,65 @@
195197
};
196198
}
197199
}
200+
201+
if (placement === 'smart' && points != null && i != null) {
202+
const getValue = (p: Point): number => (isScaleBand(ctx.yScale) ? p.xValue : p.yValue);
203+
const curr = getValue(point);
204+
const prev = i > 0 ? getValue(points[i - 1]) : curr;
205+
const next = i < points.length - 1 ? getValue(points[i + 1]) : curr;
206+
207+
const xPrevTight = Math.abs(prev - curr) < offset;
208+
const xNextTight = Math.abs(curr - next) < offset;
209+
const isPeak = (prev <= curr && curr >= next) || (xPrevTight && xNextTight);
210+
const isTrough = (prev >= curr && curr <= next) || (xPrevTight && xNextTight);
211+
const isRising = !isPeak && !isTrough && prev < curr;
212+
const isFalling = !isPeak && !isTrough && prev >= curr;
213+
214+
return {
215+
...result,
216+
x: point.x,
217+
y: point.y,
218+
dx: isRising
219+
? xPrevTight
220+
? offset
221+
: -offset
222+
: isFalling
223+
? xNextTight
224+
? -offset
225+
: offset
226+
: 0,
227+
dy: isPeak ? -offset : isTrough ? offset : 0,
228+
textAnchor: isRising
229+
? xPrevTight
230+
? 'start'
231+
: 'end'
232+
: isFalling
233+
? xNextTight
234+
? 'end'
235+
: 'start'
236+
: 'middle',
237+
verticalAnchor: isPeak ? 'end' : isTrough ? 'start' : 'middle',
238+
};
239+
}
240+
241+
return result;
198242
}
199243
</script>
200244

201245
<Group class="lc-labels-g" opacity={derivedOpacity as number}>
202246
<Points {data} {x} {y} {seriesKey}>
203247
{#snippet children({ points })}
204248
{#each points as point, i (key(point.data, i))}
205-
{@const textProps = extractLayerProps(getTextProps(point), 'lc-labels-text')}
249+
{@const baseProps = getTextProps(point, points, i)}
250+
{@const textProps = extractLayerProps(baseProps, 'lc-labels-text')}
206251
{#if childrenProp}
207252
{@render childrenProp({ data: point, textProps })}
208253
{:else}
209254
<Text
210255
data-placement={placement}
211256
{...textProps}
212257
{...restProps}
213-
{...extractLayerProps(getTextProps(point), 'lc-labels-text', className ?? '')}
258+
{...extractLayerProps(baseProps, 'lc-labels-text', className ?? '')}
214259
/>
215260
{/if}
216261
{/each}

0 commit comments

Comments
 (0)