diff --git a/.changeset/add-smart-label-placement.md b/.changeset/add-smart-label-placement.md new file mode 100644 index 000000000..3a45f1c52 --- /dev/null +++ b/.changeset/add-smart-label-placement.md @@ -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. diff --git a/docs/src/examples/components/LineChart/smart-labels-with-points.svelte b/docs/src/examples/components/LineChart/smart-labels-with-points.svelte new file mode 100644 index 000000000..be4f4b708 --- /dev/null +++ b/docs/src/examples/components/LineChart/smart-labels-with-points.svelte @@ -0,0 +1,18 @@ + + + diff --git a/packages/layerchart/src/lib/components/Labels.svelte b/packages/layerchart/src/lib/components/Labels.svelte index 445498f1c..9a3af9082 100644 --- a/packages/layerchart/src/lib/components/Labels.svelte +++ b/packages/layerchart/src/lib/components/Labels.svelte @@ -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 @@ -114,12 +115,11 @@ : 0.1) ); - function getTextProps(point: Point): ComponentProps { + function getTextProps(point: Point, points?: Point[], i?: number): ComponentProps { // 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 @@ -142,11 +142,13 @@ : ctx.yScale.tickFormat?.()) ); + let result: ComponentProps; + 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), @@ -157,7 +159,7 @@ }; } else { // right - return { + result = { value: formattedValue, fill: fillValue, x: point.x + (placement === 'outside' ? offset : -offset), @@ -171,7 +173,7 @@ // Position label top/bottom on vertical bars if (isLowEdge) { // bottom - return { + result = { value: formattedValue, fill: fillValue, x: point.x, @@ -183,7 +185,7 @@ }; } else { // top - return { + result = { value: formattedValue, fill: fillValue, x: point.x, @@ -195,6 +197,48 @@ }; } } + + 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; } @@ -202,7 +246,8 @@ {#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} @@ -210,7 +255,7 @@ data-placement={placement} {...textProps} {...restProps} - {...extractLayerProps(getTextProps(point), 'lc-labels-text', className ?? '')} + {...extractLayerProps(baseProps, 'lc-labels-text', className ?? '')} /> {/if} {/each}