Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/add-smart-label-placement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'layerchart': minor
---

feat(Labels): Add `smart` placement option

New `placement="smart"` mode that dynamically positions labels based on neighboring point values (peak, trough, rising, falling) to reduce overlapping.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script lang="ts">
import { LineChart, defaultChartPadding } from 'layerchart';
import { createDateSeries } from '$lib/utils/data.js';

const data = createDateSeries({ count: 30, min: 50, max: 100, value: 'integer' });
export { data };
</script>

<LineChart
{data}
x="date"
y="value"
xNice
padding={defaultChartPadding({ top: 25, right: 10 })}
height={300}
points
labels={{ placement: 'smart' }}
/>
67 changes: 56 additions & 11 deletions packages/layerchart/src/lib/components/Labels.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@
seriesKey?: string;

/**
* The placement of the label relative to the point
* The placement of the label relative to the point.
* `smart` dynamically positions labels based on neighboring point values (peak, trough, rising, falling).
* @default 'outside'
*/
placement?: 'inside' | 'outside' | 'center';
placement?: 'inside' | 'outside' | 'center' | 'smart';

/**
* The offset of the label from the point
Expand Down Expand Up @@ -114,12 +115,11 @@
: 0.1)
);

function getTextProps(point: Point): ComponentProps<typeof Text> {
function getTextProps(point: Point, points?: Point[], i?: number): ComponentProps<typeof Text> {
// Used for positioning direction.
// For array accessors (edgeIndex defined), use edge position: 0 = start/low, 1 = end/high
const pointValue = isScaleBand(ctx.yScale) ? point.xValue : point.yValue;
const isLowEdge =
point.edgeIndex != null ? point.edgeIndex === 0 : pointValue < 0;
const isLowEdge = point.edgeIndex != null ? point.edgeIndex === 0 : pointValue < 0;

// extract the true fill value from `fill` which could be an
// accessor function or string/undefined
Expand All @@ -142,11 +142,13 @@
: ctx.yScale.tickFormat?.())
);

let result: ComponentProps<typeof Text>;

if (isScaleBand(ctx.yScale)) {
// Position label left/right on horizontal bars
if (isLowEdge) {
// left
return {
result = {
value: formattedValue,
fill: fillValue,
x: point.x + (placement === 'outside' ? -offset : offset),
Expand All @@ -157,7 +159,7 @@
};
} else {
// right
return {
result = {
value: formattedValue,
fill: fillValue,
x: point.x + (placement === 'outside' ? offset : -offset),
Expand All @@ -171,7 +173,7 @@
// Position label top/bottom on vertical bars
if (isLowEdge) {
// bottom
return {
result = {
value: formattedValue,
fill: fillValue,
x: point.x,
Expand All @@ -183,7 +185,7 @@
};
} else {
// top
return {
result = {
value: formattedValue,
fill: fillValue,
x: point.x,
Expand All @@ -195,22 +197,65 @@
};
}
}

if (placement === 'smart' && points != null && i != null) {
const getValue = (p: Point): number => (isScaleBand(ctx.yScale) ? p.xValue : p.yValue);
const curr = getValue(point);
const prev = i > 0 ? getValue(points[i - 1]) : curr;
const next = i < points.length - 1 ? getValue(points[i + 1]) : curr;

const xPrevTight = Math.abs(prev - curr) < offset;
const xNextTight = Math.abs(curr - next) < offset;
const isPeak = (prev <= curr && curr >= next) || (xPrevTight && xNextTight);
const isTrough = (prev >= curr && curr <= next) || (xPrevTight && xNextTight);
const isRising = !isPeak && !isTrough && prev < curr;
const isFalling = !isPeak && !isTrough && prev >= curr;

return {
...result,
x: point.x,
y: point.y,
dx: isRising
? xPrevTight
? offset
: -offset
: isFalling
? xNextTight
? -offset
: offset
: 0,
dy: isPeak ? -offset : isTrough ? offset : 0,
textAnchor: isRising
? xPrevTight
? 'start'
: 'end'
: isFalling
? xNextTight
? 'end'
: 'start'
: 'middle',
verticalAnchor: isPeak ? 'end' : isTrough ? 'start' : 'middle',
};
}

return result;
}
</script>

<Group class="lc-labels-g" opacity={derivedOpacity as number}>
<Points {data} {x} {y} {seriesKey}>
{#snippet children({ points })}
{#each points as point, i (key(point.data, i))}
{@const textProps = extractLayerProps(getTextProps(point), 'lc-labels-text')}
{@const baseProps = getTextProps(point, points, i)}
{@const textProps = extractLayerProps(baseProps, 'lc-labels-text')}
{#if childrenProp}
{@render childrenProp({ data: point, textProps })}
{:else}
<Text
data-placement={placement}
{...textProps}
{...restProps}
{...extractLayerProps(getTextProps(point), 'lc-labels-text', className ?? '')}
{...extractLayerProps(baseProps, 'lc-labels-text', className ?? '')}
/>
{/if}
{/each}
Expand Down
Loading