|
41 | 41 | seriesKey?: string; |
42 | 42 |
|
43 | 43 | /** |
44 | | - * The placement of the label relative to the point |
| 44 | + * The placement of the label relative to the point. |
| 45 | + * `smart` dynamically positions labels based on neighboring point values (peak, trough, rising, falling). |
45 | 46 | * @default 'outside' |
46 | 47 | */ |
47 | | - placement?: 'inside' | 'outside' | 'center'; |
| 48 | + placement?: 'inside' | 'outside' | 'center' | 'smart'; |
48 | 49 |
|
49 | 50 | /** |
50 | 51 | * The offset of the label from the point |
|
114 | 115 | : 0.1) |
115 | 116 | ); |
116 | 117 |
|
117 | | - function getTextProps(point: Point): ComponentProps<typeof Text> { |
| 118 | + function getTextProps(point: Point, points?: Point[], i?: number): ComponentProps<typeof Text> { |
118 | 119 | // Used for positioning direction. |
119 | 120 | // For array accessors (edgeIndex defined), use edge position: 0 = start/low, 1 = end/high |
120 | 121 | const pointValue = isScaleBand(ctx.yScale) ? point.xValue : point.yValue; |
121 | | - const isLowEdge = |
122 | | - point.edgeIndex != null ? point.edgeIndex === 0 : pointValue < 0; |
| 122 | + const isLowEdge = point.edgeIndex != null ? point.edgeIndex === 0 : pointValue < 0; |
123 | 123 |
|
124 | 124 | // extract the true fill value from `fill` which could be an |
125 | 125 | // accessor function or string/undefined |
|
142 | 142 | : ctx.yScale.tickFormat?.()) |
143 | 143 | ); |
144 | 144 |
|
| 145 | + let result: ComponentProps<typeof Text>; |
| 146 | +
|
145 | 147 | if (isScaleBand(ctx.yScale)) { |
146 | 148 | // Position label left/right on horizontal bars |
147 | 149 | if (isLowEdge) { |
148 | 150 | // left |
149 | | - return { |
| 151 | + result = { |
150 | 152 | value: formattedValue, |
151 | 153 | fill: fillValue, |
152 | 154 | x: point.x + (placement === 'outside' ? -offset : offset), |
|
157 | 159 | }; |
158 | 160 | } else { |
159 | 161 | // right |
160 | | - return { |
| 162 | + result = { |
161 | 163 | value: formattedValue, |
162 | 164 | fill: fillValue, |
163 | 165 | x: point.x + (placement === 'outside' ? offset : -offset), |
|
171 | 173 | // Position label top/bottom on vertical bars |
172 | 174 | if (isLowEdge) { |
173 | 175 | // bottom |
174 | | - return { |
| 176 | + result = { |
175 | 177 | value: formattedValue, |
176 | 178 | fill: fillValue, |
177 | 179 | x: point.x, |
|
183 | 185 | }; |
184 | 186 | } else { |
185 | 187 | // top |
186 | | - return { |
| 188 | + result = { |
187 | 189 | value: formattedValue, |
188 | 190 | fill: fillValue, |
189 | 191 | x: point.x, |
|
195 | 197 | }; |
196 | 198 | } |
197 | 199 | } |
| 200 | +
|
| 201 | + if (placement === 'smart' && points != null && i != null) { |
| 202 | + const getValue = (p: Point): number => (isScaleBand(ctx.yScale) ? p.xValue : p.yValue); |
| 203 | + const curr = getValue(point); |
| 204 | + const prev = i > 0 ? getValue(points[i - 1]) : curr; |
| 205 | + const next = i < points.length - 1 ? getValue(points[i + 1]) : curr; |
| 206 | +
|
| 207 | + const xPrevTight = Math.abs(prev - curr) < offset; |
| 208 | + const xNextTight = Math.abs(curr - next) < offset; |
| 209 | + const isPeak = (prev <= curr && curr >= next) || (xPrevTight && xNextTight); |
| 210 | + const isTrough = (prev >= curr && curr <= next) || (xPrevTight && xNextTight); |
| 211 | + const isRising = !isPeak && !isTrough && prev < curr; |
| 212 | + const isFalling = !isPeak && !isTrough && prev >= curr; |
| 213 | +
|
| 214 | + return { |
| 215 | + ...result, |
| 216 | + x: point.x, |
| 217 | + y: point.y, |
| 218 | + dx: isRising |
| 219 | + ? xPrevTight |
| 220 | + ? offset |
| 221 | + : -offset |
| 222 | + : isFalling |
| 223 | + ? xNextTight |
| 224 | + ? -offset |
| 225 | + : offset |
| 226 | + : 0, |
| 227 | + dy: isPeak ? -offset : isTrough ? offset : 0, |
| 228 | + textAnchor: isRising |
| 229 | + ? xPrevTight |
| 230 | + ? 'start' |
| 231 | + : 'end' |
| 232 | + : isFalling |
| 233 | + ? xNextTight |
| 234 | + ? 'end' |
| 235 | + : 'start' |
| 236 | + : 'middle', |
| 237 | + verticalAnchor: isPeak ? 'end' : isTrough ? 'start' : 'middle', |
| 238 | + }; |
| 239 | + } |
| 240 | +
|
| 241 | + return result; |
198 | 242 | } |
199 | 243 | </script> |
200 | 244 |
|
201 | 245 | <Group class="lc-labels-g" opacity={derivedOpacity as number}> |
202 | 246 | <Points {data} {x} {y} {seriesKey}> |
203 | 247 | {#snippet children({ points })} |
204 | 248 | {#each points as point, i (key(point.data, i))} |
205 | | - {@const textProps = extractLayerProps(getTextProps(point), 'lc-labels-text')} |
| 249 | + {@const baseProps = getTextProps(point, points, i)} |
| 250 | + {@const textProps = extractLayerProps(baseProps, 'lc-labels-text')} |
206 | 251 | {#if childrenProp} |
207 | 252 | {@render childrenProp({ data: point, textProps })} |
208 | 253 | {:else} |
209 | 254 | <Text |
210 | 255 | data-placement={placement} |
211 | 256 | {...textProps} |
212 | 257 | {...restProps} |
213 | | - {...extractLayerProps(getTextProps(point), 'lc-labels-text', className ?? '')} |
| 258 | + {...extractLayerProps(baseProps, 'lc-labels-text', className ?? '')} |
214 | 259 | /> |
215 | 260 | {/if} |
216 | 261 | {/each} |
|
0 commit comments