Skip to content

Commit 646d834

Browse files
committed
feat(AnnotationLine): Add x1/y1/x2/y2 props for sloped lines
1 parent eb6ff18 commit 646d834

4 files changed

Lines changed: 156 additions & 68 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'layerchart': minor
3+
---
4+
5+
feat(AnnotationLine): Add `x1`/`y1`/`x2`/`y2` props for sloped lines
6+
7+
- Pass any combination of `x1`, `y1`, `x2`, `y2` to draw a line between arbitrary points. Missing coordinates fall back to the corresponding axis range (so `x1`/`x2` alone still span the y range, etc.). The existing `x` / `y` shorthand for full-span vertical/horizontal lines is unchanged.
8+
- Labels on sloped lines automatically rotate to follow the line angle (normalized to stay upright), with `labelPlacement`, `labelXOffset`, and `labelYOffset` applied along and perpendicular to the line.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<script module lang="ts">
2+
import { getAppleStock } from '$lib/data.remote';
3+
const data = await getAppleStock();
4+
</script>
5+
6+
<script lang="ts">
7+
import { AnnotationLine, LineChart, type Placement } from 'layerchart';
8+
import AnnotationLineControls from '$lib/components/controls/AnnotationRangePointLineControls.svelte';
9+
10+
let placement: Placement = $state('top');
11+
let xOffset = $state(0);
12+
let yOffset = $state(0);
13+
14+
export { data };
15+
</script>
16+
17+
<AnnotationLineControls bind:placement bind:xOffset bind:yOffset />
18+
19+
<LineChart {data} x="date" y="value" height={300} padding={{ top: 10, bottom: 20, left: 25 }}>
20+
{#snippet aboveMarks()}
21+
<AnnotationLine
22+
y1={200}
23+
y2={600}
24+
label={placement}
25+
labelPlacement={placement}
26+
labelXOffset={xOffset}
27+
labelYOffset={yOffset}
28+
props={{
29+
line: { dashArray: [2, 2], stroke: 'var(--color-danger)' },
30+
label: { fill: 'var(--color-danger)' }
31+
}}
32+
/>
33+
{/snippet}
34+
</LineChart>

docs/src/lib/components/controls/AnnotationRangePointLineControls.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@
5757
</div>
5858
</Menu>
5959
</Toggle>
60-
<RangeField label="X offset" bind:value={xOffset} max={10} />
61-
<RangeField label="Y offset" bind:value={yOffset} max={10} />
60+
<RangeField label="X offset" bind:value={xOffset} min={-20} max={20} />
61+
<RangeField label="Y offset" bind:value={yOffset} min={-20} max={20} />
6262
{#if radius !== undefined}
6363
<RangeField label="Radius" bind:value={radius} max={10} />
6464
{/if}

packages/layerchart/src/lib/components/AnnotationLine.svelte

Lines changed: 112 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,24 @@
55
import type { SingleDomainType } from '$lib/utils/scales.svelte.js';
66
77
export type AnnotationLinePropsWithoutHTML = {
8-
/** x value of the point */
8+
/** x value of the line (draws vertically across the full y range) */
99
x?: SingleDomainType;
1010
11-
/** y value of the point */
11+
/** y value of the line (draws horizontally across the full x range) */
1212
y?: SingleDomainType;
1313
14+
/** x value of the line's start point. Takes precedence over `x`. Defaults to the start of the x range. */
15+
x1?: SingleDomainType;
16+
17+
/** y value of the line's start point. Takes precedence over `y`. Defaults to the start of the y range. */
18+
y1?: SingleDomainType;
19+
20+
/** x value of the line's end point. Takes precedence over `x`. Defaults to the end of the x range. */
21+
x2?: SingleDomainType;
22+
23+
/** y value of the line's end point. Takes precedence over `y`. Defaults to the end of the y range. */
24+
y2?: SingleDomainType;
25+
1426
/** Label to display for line*/
1527
label?: string;
1628
@@ -45,6 +57,10 @@
4557
const {
4658
x,
4759
y,
60+
x1: x1Prop,
61+
y1: y1Prop,
62+
x2: x2Prop,
63+
y2: y2Prop,
4864
label,
4965
labelPlacement = 'top-right',
5066
labelXOffset = 0,
@@ -54,74 +70,104 @@
5470
5571
const ctx = getChartContext();
5672
57-
const isVertical = $derived(x != null);
73+
const isVertical = $derived(
74+
x != null || (x1Prop != null && x2Prop != null && x1Prop === x2Prop)
75+
);
5876
5977
const line = $derived({
60-
x1: x ? ctx.xScale(x) : ctx.xRange[0],
61-
y1: y && !x ? ctx.yScale(y) : ctx.yRange[0],
62-
x2: x ? ctx.xScale(x) : ctx.xRange[1],
63-
y2: y ? ctx.yScale(y) : ctx.yRange[1],
78+
x1: x1Prop != null ? ctx.xScale(x1Prop) : x != null ? ctx.xScale(x) : ctx.xRange[0],
79+
y1:
80+
y1Prop != null ? ctx.yScale(y1Prop) : y != null && x == null ? ctx.yScale(y) : ctx.yRange[0],
81+
x2: x2Prop != null ? ctx.xScale(x2Prop) : x != null ? ctx.xScale(x) : ctx.xRange[1],
82+
y2: y2Prop != null ? ctx.yScale(y2Prop) : y != null ? ctx.yScale(y) : ctx.yRange[1],
6483
});
6584
66-
const labelProps = $derived<ComponentProps<typeof Text>>(
67-
isVertical
68-
? {
69-
x: line.x1 + (labelPlacement.includes('left') ? -labelXOffset : labelXOffset),
70-
y:
71-
(labelPlacement.includes('top')
72-
? line.y2
73-
: labelPlacement.includes('bottom')
74-
? line.y1
75-
: (line.y1 - line.y2) / 2) +
76-
(['top', 'bottom-left', 'bottom-right'].includes(labelPlacement)
77-
? -labelYOffset
78-
: labelYOffset),
79-
dy: -2, // adjust for smaller font size
80-
textAnchor: labelPlacement.includes('left')
81-
? 'end'
82-
: labelPlacement.includes('right')
83-
? 'start'
84-
: 'middle',
85-
verticalAnchor:
86-
labelPlacement === 'top'
87-
? 'end' // place above line
88-
: labelPlacement === 'bottom'
89-
? 'start' // place below line
90-
: labelPlacement.includes('top')
91-
? 'start'
92-
: labelPlacement.includes('bottom')
93-
? 'end'
94-
: 'middle',
95-
}
96-
: {
97-
x:
98-
(labelPlacement.includes('left')
99-
? line.x1
100-
: labelPlacement.includes('right')
101-
? line.x2
102-
: (line.x2 - line.x1) / 2) +
103-
(['left', 'top-right', 'bottom-right'].includes(labelPlacement)
104-
? -labelXOffset
105-
: labelXOffset),
106-
y: line.y1 + (labelPlacement.includes('top') ? -labelYOffset : labelYOffset),
107-
dy: -2, // adjust for smaller font size
108-
textAnchor:
109-
labelPlacement === 'left'
110-
? 'end' // place beside line
111-
: labelPlacement === 'right'
112-
? 'start' // place beside line
113-
: labelPlacement.includes('left')
114-
? 'start'
115-
: labelPlacement.includes('right')
116-
? 'end'
117-
: 'middle',
118-
verticalAnchor: labelPlacement.includes('top')
119-
? 'end'
120-
: labelPlacement.includes('bottom')
121-
? 'start'
122-
: 'middle',
123-
}
124-
);
85+
const isSloped = $derived(!isVertical && line.x1 !== line.x2 && line.y1 !== line.y2);
86+
87+
// Angle of the line in degrees, normalized to [-90, 90] so text stays upright
88+
const slopeAngle = $derived.by(() => {
89+
let angle = Math.atan2(line.y2 - line.y1, line.x2 - line.x1) * (180 / Math.PI);
90+
if (angle > 90) angle -= 180;
91+
else if (angle < -90) angle += 180;
92+
return angle;
93+
});
94+
95+
const labelProps = $derived.by<ComponentProps<typeof Text>>(() => {
96+
const isLeft = labelPlacement.includes('left');
97+
const isRight = labelPlacement.includes('right');
98+
const isTop = labelPlacement.includes('top');
99+
const isBottom = labelPlacement.includes('bottom');
100+
101+
if (isVertical) {
102+
return {
103+
x: line.x1 + (isLeft ? -labelXOffset : labelXOffset),
104+
y:
105+
(isTop ? line.y2 : isBottom ? line.y1 : (line.y1 - line.y2) / 2) +
106+
(['top', 'bottom-left', 'bottom-right'].includes(labelPlacement)
107+
? -labelYOffset
108+
: labelYOffset),
109+
dy: -2, // adjust for smaller font size
110+
textAnchor: isLeft ? 'end' : isRight ? 'start' : 'middle',
111+
verticalAnchor:
112+
labelPlacement === 'top'
113+
? 'end' // place above line
114+
: labelPlacement === 'bottom'
115+
? 'start' // place below line
116+
: isTop
117+
? 'start'
118+
: isBottom
119+
? 'end'
120+
: 'middle',
121+
};
122+
}
123+
124+
const x = isLeft ? line.x1 : isRight ? line.x2 : (line.x1 + line.x2) / 2;
125+
const y = isLeft ? line.y1 : isRight ? line.y2 : (line.y1 + line.y2) / 2;
126+
const textAnchor =
127+
labelPlacement === 'left'
128+
? 'end' // place beside line
129+
: labelPlacement === 'right'
130+
? 'start' // place beside line
131+
: isLeft
132+
? 'start'
133+
: isRight
134+
? 'end'
135+
: 'middle';
136+
const verticalAnchor = isTop ? 'end' : isBottom ? 'start' : 'middle';
137+
138+
if (isSloped) {
139+
// Project along-line and perpendicular offsets onto screen dx/dy so
140+
// labelXOffset/labelYOffset track the slope rather than the viewport axes.
141+
const aSign = ['left', 'top-right', 'bottom-right'].includes(labelPlacement) ? -1 : 1;
142+
const pSign = isTop ? 1 : -1;
143+
const alongLine = aSign * labelXOffset;
144+
const perpAbove = pSign * labelYOffset + 2; // +2 for font baseline
145+
const theta = (slopeAngle * Math.PI) / 180;
146+
const cosT = Math.cos(theta);
147+
const sinT = Math.sin(theta);
148+
return {
149+
x,
150+
y,
151+
rotate: slopeAngle,
152+
dx: alongLine * cosT + perpAbove * sinT,
153+
dy: alongLine * sinT - perpAbove * cosT,
154+
textAnchor,
155+
verticalAnchor,
156+
};
157+
}
158+
159+
return {
160+
x:
161+
x +
162+
(['left', 'top-right', 'bottom-right'].includes(labelPlacement)
163+
? -labelXOffset
164+
: labelXOffset),
165+
y: y + (isTop ? -labelYOffset : labelYOffset),
166+
dy: -2, // adjust for smaller font size
167+
textAnchor,
168+
verticalAnchor,
169+
};
170+
});
125171
</script>
126172

127173
<Line

0 commit comments

Comments
 (0)