|
3 | 3 | import type { SVGAttributes } from 'svelte/elements'; |
4 | 4 | import { format as formatValue, type FormatType, cls } from 'svelte-ux'; |
5 | 5 | import { extent } from 'd3-array'; |
| 6 | + import { pointRadial } from 'd3-shape'; |
6 | 7 |
|
7 | 8 | import Text from './Text.svelte'; |
8 | 9 | import { isScaleBand } from '$lib/utils/scales'; |
9 | 10 |
|
10 | 11 | const { xScale, yScale, xRange, yRange, width } = getContext('LayerCake'); |
11 | 12 |
|
12 | 13 | /** Location of axis */ |
13 | | - export let placement: 'top' | 'bottom' | 'left' | 'right'; |
| 14 | + export let placement: 'top' | 'bottom' | 'left' | 'right' | 'angle' | 'radius'; |
14 | 15 |
|
15 | 16 | /** Draw a rule line. Use Rule component for greater rendering order control */ |
16 | 17 | export let rule: boolean | SVGAttributes<SVGLineElement> = false; |
|
27 | 28 | export let format: FormatType = undefined; |
28 | 29 | export let labelProps: Partial<ComponentProps<Text>> | undefined = undefined; |
29 | 30 |
|
30 | | - $: orientation = ['top', 'bottom'].includes(placement) ? 'horizontal' : 'vertical'; |
31 | | - $: scale = orientation === 'horizontal' ? $xScale : $yScale; |
| 31 | + $: orientation = |
| 32 | + placement === 'angle' |
| 33 | + ? 'angle' |
| 34 | + : placement === 'radius' |
| 35 | + ? 'radius' |
| 36 | + : ['top', 'bottom'].includes(placement) |
| 37 | + ? 'horizontal' |
| 38 | + : 'vertical'; |
| 39 | + $: scale = ['horizontal', 'angle'].includes(orientation) ? $xScale : $yScale; |
32 | 40 |
|
33 | 41 | $: [xRangeMin, xRangeMax] = extent($xRange); |
34 | 42 | $: [yRangeMin, yRangeMax] = extent($yRange); |
|
64 | 72 | x: xRangeMax, |
65 | 73 | y: $yScale(tick) + (isScaleBand($yScale) ? $yScale.bandwidth() / 2 : 0), |
66 | 74 | }; |
| 75 | +
|
| 76 | + case 'angle': |
| 77 | + return { |
| 78 | + x: $xScale(tick), |
| 79 | + y: yRangeMax, |
| 80 | + }; |
| 81 | +
|
| 82 | + case 'radius': |
| 83 | + return { |
| 84 | + x: xRangeMin, |
| 85 | + y: $yScale(tick), |
| 86 | + }; |
67 | 87 | } |
68 | 88 | } |
69 | 89 |
|
70 | | - function getDefaultLabelProps(): ComponentProps<Text> { |
| 90 | + function getDefaultLabelProps(tick: any): ComponentProps<Text> { |
71 | 91 | switch (placement) { |
72 | 92 | case 'top': |
73 | 93 | return { |
|
98 | 118 | dx: 4, |
99 | 119 | dy: -2, // manually adjusted until Text supports custom styles |
100 | 120 | }; |
| 121 | +
|
| 122 | + case 'angle': |
| 123 | + const xValue = $xScale(tick); |
| 124 | + return { |
| 125 | + textAnchor: |
| 126 | + xValue === 0 || xValue === Math.PI ? 'middle' : xValue > Math.PI ? 'end' : 'start', |
| 127 | + verticalAnchor: 'middle', |
| 128 | + dx: 0, |
| 129 | + dy: -2, // manually adjusted until Text supports custom styles |
| 130 | + }; |
| 131 | +
|
| 132 | + case 'radius': |
| 133 | + return { |
| 134 | + textAnchor: 'middle', |
| 135 | + verticalAnchor: 'middle', |
| 136 | + dx: 2, |
| 137 | + dy: -2, // manually adjusted until Text supports custom styles |
| 138 | + }; |
101 | 139 | } |
102 | 140 | } |
103 | 141 | </script> |
|
126 | 164 | class={cls('rule stroke-surface-content/50', lineProps?.class)} |
127 | 165 | /> |
128 | 166 | {/if} |
| 167 | + |
| 168 | + <!-- TODO: angle rule? --> |
| 169 | + |
| 170 | + {#if orientation === 'radius'} |
| 171 | + <circle |
| 172 | + r={$yRange[0] || 0} |
| 173 | + {...lineProps} |
| 174 | + class={cls('rule stroke-surface-content/20 fill-none', lineProps?.class)} |
| 175 | + /> |
| 176 | + {/if} |
129 | 177 | {/if} |
130 | 178 |
|
131 | 179 | {#each tickVals as tick, i} |
132 | 180 | {@const tickCoords = getCoords(tick)} |
| 181 | + {@const radialTickCoords = pointRadial(tickCoords.x, tickCoords.y)} |
| 182 | + |
133 | 183 | <g> |
134 | 184 | {#if grid !== false} |
135 | 185 | {@const lineProps = typeof grid === 'object' ? grid : null} |
|
151 | 201 | {...lineProps} |
152 | 202 | class={cls('grid stroke-surface-content/10', lineProps?.class)} |
153 | 203 | /> |
| 204 | + {:else if orientation === 'angle'} |
| 205 | + {@const [x1, y1] = pointRadial(tickCoords.x, yRangeMin)} |
| 206 | + {@const [x2, y2] = pointRadial(tickCoords.x, yRangeMax)} |
| 207 | + |
| 208 | + <line |
| 209 | + {x1} |
| 210 | + {y1} |
| 211 | + {x2} |
| 212 | + {y2} |
| 213 | + {...lineProps} |
| 214 | + class={cls('test grid stroke-surface-content/10', lineProps?.class)} |
| 215 | + /> |
| 216 | + {:else if orientation === 'radius'} |
| 217 | + <circle |
| 218 | + r={tickCoords.y} |
| 219 | + {...lineProps} |
| 220 | + class={cls('grid stroke-surface-content/10 fill-none', lineProps?.class)} |
| 221 | + /> |
154 | 222 | {/if} |
155 | 223 | {/if} |
156 | 224 |
|
|
174 | 242 | {/if} |
175 | 243 |
|
176 | 244 | <Text |
177 | | - x={tickCoords.x} |
178 | | - y={tickCoords.y} |
179 | | - value={formatValue(tick, format ?? scale.tickFormat?.())} |
180 | | - {...getDefaultLabelProps()} |
| 245 | + x={orientation === 'angle' ? radialTickCoords[0] : tickCoords.x} |
| 246 | + y={orientation === 'angle' ? radialTickCoords[1] : tickCoords.y} |
| 247 | + value={formatValue(tick, format ?? scale.tickFormat?.() ?? ((v) => v))} |
| 248 | + {...getDefaultLabelProps(tick)} |
181 | 249 | {...labelProps} |
182 | 250 | class={cls( |
183 | 251 | 'label text-[10px] stroke-surface-100 [stroke-width:2px] font-light', |
|
0 commit comments