diff --git a/.changeset/labels-glyph-edge-spacing.md b/.changeset/labels-glyph-edge-spacing.md new file mode 100644 index 000000000..9d9ffe8ae --- /dev/null +++ b/.changeset/labels-glyph-edge-spacing.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Text|Labels): Anchor `verticalAnchor` by cap-height so text sits a consistent distance from marks and aligns across the Svg, Canvas, and Html layers. `placement="smart"` labels now clear the point marker on all sides. diff --git a/docs/src/examples/components/Bars/vertical-average-annotation-rule.svelte b/docs/src/examples/components/Bars/vertical-average-annotation-rule.svelte index 52a64464c..efd911f99 100644 --- a/docs/src/examples/components/Bars/vertical-average-annotation-rule.svelte +++ b/docs/src/examples/components/Bars/vertical-average-annotation-rule.svelte @@ -39,7 +39,7 @@ - + diff --git a/docs/src/examples/components/Link/bended-arrows.svelte b/docs/src/examples/components/Link/bended-arrows.svelte index 494c5205c..d87045128 100644 --- a/docs/src/examples/components/Link/bended-arrows.svelte +++ b/docs/src/examples/components/Link/bended-arrows.svelte @@ -77,7 +77,7 @@ y="R90_10_2015" value="nyt_display" textAnchor="middle" - dy={-6} + dy={-8} class="text-xs text-current stroke-2 stroke-surface-100 font-semibold pointer-events-none" /> diff --git a/docs/src/examples/components/Text/color-via-ordinal-scale.svelte b/docs/src/examples/components/Text/color-via-ordinal-scale.svelte index 41423efda..ac3fb14cb 100644 --- a/docs/src/examples/components/Text/color-via-ordinal-scale.svelte +++ b/docs/src/examples/components/Text/color-via-ordinal-scale.svelte @@ -30,7 +30,7 @@ y="value" value="label" textAnchor="middle" - dy={-2} + dy={-8} fill="category" class="text-xs" /> diff --git a/docs/src/examples/components/Text/color-via-threshold-scale.svelte b/docs/src/examples/components/Text/color-via-threshold-scale.svelte index 56dd397c1..f3f17b159 100644 --- a/docs/src/examples/components/Text/color-via-threshold-scale.svelte +++ b/docs/src/examples/components/Text/color-via-threshold-scale.svelte @@ -45,7 +45,7 @@ y="value" value="label" textAnchor="middle" - dy={-2} + dy={-8} fill="value" class="text-xs" /> diff --git a/docs/src/examples/components/Text/data-mode.svelte b/docs/src/examples/components/Text/data-mode.svelte index 60117f4a7..63487bda1 100644 --- a/docs/src/examples/components/Text/data-mode.svelte +++ b/docs/src/examples/components/Text/data-mode.svelte @@ -28,7 +28,7 @@ y="value" value="label" textAnchor="middle" - dy={-2} + dy={-8} class="text-xs fill-surface-content" /> diff --git a/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts b/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts index 708cbf012..ec315e9b4 100644 --- a/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts @@ -195,7 +195,10 @@ export class AxisState { scale = $derived.by(() => { const scaleProp = this.#getProps().scale; - return scaleProp ?? (['horizontal', 'angle'].includes(this.orientation) ? this.ctx.xScale : this.ctx.yScale); + return ( + scaleProp ?? + (['horizontal', 'angle'].includes(this.orientation) ? this.ctx.xScale : this.ctx.yScale) + ); }); interval = $derived( @@ -259,14 +262,16 @@ export class AxisState { tickCount = $derived.by(() => { const ticks = this.#getProps().ticks; if (typeof ticks === 'number') return ticks; - if (this.tickSpacing && this.effectiveSize) return Math.round(this.effectiveSize / this.tickSpacing); + if (this.tickSpacing && this.effectiveSize) + return Math.round(this.effectiveSize / this.tickSpacing); return undefined; }); formatCount = $derived.by(() => { const ticks = this.#getProps().ticks; if (typeof ticks === 'number') return ticks; - if (this.defaultTickSpacing && this.effectiveSize) return Math.round(this.effectiveSize / this.defaultTickSpacing); + if (this.defaultTickSpacing && this.effectiveSize) + return Math.round(this.effectiveSize / this.defaultTickSpacing); return undefined; }); @@ -366,19 +371,24 @@ export class AxisState { getDefaultTickLabelProps(tick: any): Partial { const { placement, tickLength = 4 } = this.#getProps(); + // Cap-height anchoring (`verticalAnchor` start/end, see Text `startDy`) places the label + // edge exactly `tickLength` from the axis, leaving no gap to the tick. Add a little padding + // above/below so the label clears the tick — matching the `left`/`right` visual, whose + // horizontal `textAnchor` already sits a comfortable distance out. + const labelPadding = 2; switch (placement) { case 'top': return { textAnchor: 'middle', verticalAnchor: 'end', - dy: -tickLength, + dy: -(tickLength + labelPadding), }; case 'bottom': return { textAnchor: 'middle', verticalAnchor: 'start', - dy: tickLength, + dy: tickLength + labelPadding, }; case 'left': @@ -493,13 +503,7 @@ export class AxisState { }); tickItems = $derived.by(() => { - const { - motion, - stroke, - fill, - tickLabelProps, - classes = {}, - } = this.#getProps(); + const { motion, stroke, fill, tickLabelProps, classes = {} } = this.#getProps(); return this.tickVals.map((tick, index) => { const tickCoords = this.getCoords(tick); const [radialTickCoordsX, radialTickCoordsY] = pointRadial(tickCoords.x, tickCoords.y); diff --git a/packages/layerchart/src/lib/components/Labels/Labels.shared.svelte.ts b/packages/layerchart/src/lib/components/Labels/Labels.shared.svelte.ts index f6a6a6cc7..0a4c90618 100644 --- a/packages/layerchart/src/lib/components/Labels/Labels.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Labels/Labels.shared.svelte.ts @@ -219,20 +219,25 @@ export class LabelsState { const isRising = !isPeak && !isTrough && prev < curr; const isFalling = !isPeak && !isTrough && prev >= curr; + // Place the label edge `offset` past the point marker, so a dot of radius `r` is cleared by + // exactly `offset` on whichever side the label sits (`textAnchor`/`verticalAnchor` positions + // the label edge at `point ± markOffset`). + const markOffset = (point.r ?? 0) + offset; + return { ...result, x: point.x, y: point.y, dx: isRising ? xPrevTight - ? offset - : -offset + ? markOffset + : -markOffset : isFalling ? xNextTight - ? -offset - : offset + ? -markOffset + : markOffset : 0, - dy: isPeak ? -offset : isTrough ? offset : 0, + dy: isPeak ? -markOffset : isTrough ? markOffset : 0, textAnchor: isRising ? xPrevTight ? 'start' diff --git a/packages/layerchart/src/lib/components/Text/Text.html.svelte b/packages/layerchart/src/lib/components/Text/Text.html.svelte index 5e092ad6e..00aea8538 100644 --- a/packages/layerchart/src/lib/components/Text/Text.html.svelte +++ b/packages/layerchart/src/lib/components/Text/Text.html.svelte @@ -102,6 +102,13 @@ } /* Html layers */ + :global(:where(.lc-layout-html .lc-text)) { + /* Trim the CSS line box down to the cap-height/baseline box so `verticalAnchor` + anchors the same glyph edges (cap-top / center / baseline) as the SVG and Canvas + layers, which position by cap height rather than the full line box. Browsers + without `text-box` support fall back to line-box anchoring (a few px looser). */ + text-box: trim-both cap alphabetic; + } :global(:where(.lc-layout-html .lc-text):not([background-color])) { color: var(--fill-color); } diff --git a/packages/layerchart/src/lib/components/Text/Text.shared.svelte.ts b/packages/layerchart/src/lib/components/Text/Text.shared.svelte.ts index 132522786..bb34cb778 100644 --- a/packages/layerchart/src/lib/components/Text/Text.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Text/Text.shared.svelte.ts @@ -513,21 +513,24 @@ export class TextState { const lineHeight = props.lineHeight ?? '1em'; const capHeight = resolveCapHeight(props.capHeight, props.fontSize); if (verticalAnchor === 'start') { - return getPixelValue(lineHeight); + // Align the cap-height top of the first line to `y`. + return getPixelValue(capHeight); } else if (verticalAnchor === 'middle') { return ((this.lineCount - 1) / 2) * -getPixelValue(lineHeight) + getPixelValue(capHeight) / 2; } - return (this.lineCount - 1) * -getPixelValue(lineHeight) - getPixelValue(capHeight) / 2; + // `end`: align the baseline (cap-height bottom) of the last line to `y`. + return (this.lineCount - 1) * -getPixelValue(lineHeight); }); dataModeStartDy = $derived.by(() => { const props = this.#getProps(); const verticalAnchor = props.verticalAnchor ?? 'end'; - const lineHeight = props.lineHeight ?? '1em'; const capHeight = resolveCapHeight(props.capHeight, props.fontSize); - if (verticalAnchor === 'start') return getPixelValue(lineHeight); + // Match `startDy`, but single-line (data mode renders one tspan per item): + // `start` → cap-height top at `y`, `middle` → cap-height center, `end` → baseline. + if (verticalAnchor === 'start') return getPixelValue(capHeight); if (verticalAnchor === 'middle') return getPixelValue(capHeight) / 2; - return -getPixelValue(capHeight) / 2; + return 0; }); scaleTransform = $derived.by(() => {