From 5e7870b22c9f9d2afc0a74944500c1a6819d2d08 Mon Sep 17 00:00:00 2001 From: Scott Rhamy Date: Mon, 2 Mar 2026 18:41:48 -0500 Subject: [PATCH 1/3] dynamic-smart-labels --- .../src/lib/components/Labels.svelte | 57 +++++++++++++++++-- .../lib/components/charts/LineChart.svelte | 2 +- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/layerchart/src/lib/components/Labels.svelte b/packages/layerchart/src/lib/components/Labels.svelte index ba86a99fb..7b23c8ba9 100644 --- a/packages/layerchart/src/lib/components/Labels.svelte +++ b/packages/layerchart/src/lib/components/Labels.svelte @@ -36,10 +36,11 @@ y?: Accessor; /** - * 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 @@ -172,13 +173,61 @@ } } } + + function getDynamicTextProps( + point: Point, + points: Point[], + i: number + ): ComponentProps { + const baseProps = getTextProps(point); + + 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 { + ...baseProps, + 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', + }; + } {#snippet children({ points })} {#each points as point, i (key(point.data, i))} - {@const textProps = extractLayerProps(getTextProps(point), 'lc-labels-text')} + {@const baseProps = + placement === 'smart' ? getDynamicTextProps(point, points, i) : getTextProps(point)} + {@const textProps = extractLayerProps(baseProps, 'lc-labels-text')} {#if childrenProp} {@render childrenProp({ data: point, textProps })} {:else} @@ -186,7 +235,7 @@ data-placement={placement} {...textProps} {...restProps} - {...extractLayerProps(getTextProps(point), 'lc-labels-text', className ?? '')} + {...extractLayerProps(baseProps, 'lc-labels-text', className ?? '')} /> {/if} {/each} diff --git a/packages/layerchart/src/lib/components/charts/LineChart.svelte b/packages/layerchart/src/lib/components/charts/LineChart.svelte index 0b646410e..c53a541e5 100644 --- a/packages/layerchart/src/lib/components/charts/LineChart.svelte +++ b/packages/layerchart/src/lib/components/charts/LineChart.svelte @@ -452,7 +452,7 @@ {@render labels(snippetProps)} {:else if labels} {#each seriesState.visibleSeries as s, i (s.key)} - + {/each} {/if} From c26a2f75b354f217eb0b2995c24f03f87a34d671 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 1 Apr 2026 09:54:16 -0400 Subject: [PATCH 2/3] Merge getDynamicTextProps into getTextProps. Add example --- .../LineChart/smart-labels-with-points.svelte | 18 +++ .../src/lib/components/Labels.svelte | 104 +++++++++--------- 2 files changed, 68 insertions(+), 54 deletions(-) create mode 100644 docs/src/examples/components/LineChart/smart-labels-with-points.svelte 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 5dd222f3c..9a3af9082 100644 --- a/packages/layerchart/src/lib/components/Labels.svelte +++ b/packages/layerchart/src/lib/components/Labels.svelte @@ -115,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 @@ -143,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), @@ -158,7 +159,7 @@ }; } else { // right - return { + result = { value: formattedValue, fill: fillValue, x: point.x + (placement === 'outside' ? offset : -offset), @@ -172,7 +173,7 @@ // Position label top/bottom on vertical bars if (isLowEdge) { // bottom - return { + result = { value: formattedValue, fill: fillValue, x: point.x, @@ -184,7 +185,7 @@ }; } else { // top - return { + result = { value: formattedValue, fill: fillValue, x: point.x, @@ -196,52 +197,48 @@ }; } } - } - function getDynamicTextProps( - point: Point, - points: Point[], - i: number - ): ComponentProps { - const baseProps = getTextProps(point); - - 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 { - ...baseProps, - 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', - }; + 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; } @@ -249,8 +246,7 @@ {#snippet children({ points })} {#each points as point, i (key(point.data, i))} - {@const baseProps = - placement === 'smart' ? getDynamicTextProps(point, points, i) : getTextProps(point)} + {@const baseProps = getTextProps(point, points, i)} {@const textProps = extractLayerProps(baseProps, 'lc-labels-text')} {#if childrenProp} {@render childrenProp({ data: point, textProps })} From 3d29e24b84f1c5e4e7126945d6bdabeb16e545d0 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 1 Apr 2026 09:55:49 -0400 Subject: [PATCH 3/3] Add changeset --- .changeset/add-smart-label-placement.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/add-smart-label-placement.md 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.