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(() => {