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
5 changes: 5 additions & 0 deletions .changeset/labels-glyph-edge-spacing.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
<Text
x={context.width}
y={context.yScale(avg)}
dy={-4}
dy={-6}
value="Avg"
textAnchor="end"
verticalAnchor="end"
Expand Down
2 changes: 1 addition & 1 deletion docs/src/examples/components/Group/data-mode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<Axis placement="left" rule />
<Group x="date" y="value">
<Circle r={4} class="fill-primary" />
<Text value="label" textAnchor="middle" dy={-2} class="text-xs fill-surface-content" />
<Text value="label" textAnchor="middle" dy={-8} class="text-xs fill-surface-content" />
</Group>
</Layer>
</Chart>
2 changes: 1 addition & 1 deletion docs/src/examples/components/Link/bended-arrows.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
</Layer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
y="value"
value="label"
textAnchor="middle"
dy={-2}
dy={-8}
fill="category"
class="text-xs"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
y="value"
value="label"
textAnchor="middle"
dy={-2}
dy={-8}
fill="value"
class="text-xs"
/>
Expand Down
2 changes: 1 addition & 1 deletion docs/src/examples/components/Text/data-mode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
y="value"
value="label"
textAnchor="middle"
dy={-2}
dy={-8}
class="text-xs fill-surface-content"
/>
</Layer>
Expand Down
28 changes: 16 additions & 12 deletions packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
});

Expand Down Expand Up @@ -366,19 +371,24 @@ export class AxisState {

getDefaultTickLabelProps(tick: any): Partial<TextProps> {
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':
Expand Down Expand Up @@ -493,13 +503,7 @@ export class AxisState {
});

tickItems = $derived.by<AxisTickItem[]>(() => {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,20 +219,25 @@ export class LabelsState<T = any> {
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'
Expand Down
7 changes: 7 additions & 0 deletions packages/layerchart/src/lib/components/Text/Text.html.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Loading