|
5 | 5 | import type { SingleDomainType } from '$lib/utils/scales.svelte.js'; |
6 | 6 |
|
7 | 7 | export type AnnotationLinePropsWithoutHTML = { |
8 | | - /** x value of the point */ |
| 8 | + /** x value of the line (draws vertically across the full y range) */ |
9 | 9 | x?: SingleDomainType; |
10 | 10 |
|
11 | | - /** y value of the point */ |
| 11 | + /** y value of the line (draws horizontally across the full x range) */ |
12 | 12 | y?: SingleDomainType; |
13 | 13 |
|
| 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 | +
|
14 | 26 | /** Label to display for line*/ |
15 | 27 | label?: string; |
16 | 28 |
|
|
45 | 57 | const { |
46 | 58 | x, |
47 | 59 | y, |
| 60 | + x1: x1Prop, |
| 61 | + y1: y1Prop, |
| 62 | + x2: x2Prop, |
| 63 | + y2: y2Prop, |
48 | 64 | label, |
49 | 65 | labelPlacement = 'top-right', |
50 | 66 | labelXOffset = 0, |
|
54 | 70 |
|
55 | 71 | const ctx = getChartContext(); |
56 | 72 |
|
57 | | - const isVertical = $derived(x != null); |
| 73 | + const isVertical = $derived( |
| 74 | + x != null || (x1Prop != null && x2Prop != null && x1Prop === x2Prop) |
| 75 | + ); |
58 | 76 |
|
59 | 77 | 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], |
64 | 83 | }); |
65 | 84 |
|
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 | + }); |
125 | 171 | </script> |
126 | 172 |
|
127 | 173 | <Line |
|
0 commit comments