@rokkit/chart 1.3.15 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Plot.svelte +25 -9
- package/dist/PlotState.svelte.d.ts +1 -0
- package/dist/PlotState.svelte.js +19 -8
- package/dist/Spark.svelte +165 -0
- package/dist/Spark.svelte.d.ts +31 -0
- package/dist/SparkState.svelte.d.ts +138 -0
- package/dist/SparkState.svelte.js +331 -0
- package/dist/Sparkline.svelte +62 -237
- package/dist/Sparkline.svelte.d.ts +2 -0
- package/dist/charts/RadarChart.svelte +106 -0
- package/dist/charts/RadarChart.svelte.d.ts +43 -0
- package/dist/geoms/Area.svelte +24 -9
- package/dist/geoms/Area.svelte.d.ts +2 -0
- package/dist/geoms/Bar.svelte +9 -16
- package/dist/geoms/Highlight.svelte +61 -40
- package/dist/geoms/Line.svelte +10 -9
- package/dist/geoms/Point.svelte +9 -15
- package/dist/geoms/Radar.svelte +352 -0
- package/dist/geoms/Radar.svelte.d.ts +42 -0
- package/dist/geoms/Trend.svelte +46 -23
- package/dist/geoms/lib/aesthetics.d.ts +14 -0
- package/dist/geoms/lib/aesthetics.js +21 -0
- package/dist/geoms/lib/areas.d.ts +9 -2
- package/dist/geoms/lib/areas.js +53 -8
- package/dist/geoms/lib/marks/area.d.ts +3 -0
- package/dist/geoms/lib/marks/area.js +11 -2
- package/dist/geoms/lib/marks/radar.d.ts +68 -0
- package/dist/geoms/lib/marks/radar.js +153 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -1
- package/dist/lib/brewing/marks/boxes.js +19 -30
- package/dist/lib/brewing/polar.d.ts +444 -0
- package/dist/lib/brewing/polar.js +645 -0
- package/dist/lib/highlight.js +84 -33
- package/dist/lib/plot/scales.js +8 -5
- package/dist/lib/preset.d.ts +1 -0
- package/dist/lib/preset.js +2 -1
- package/dist/lib/select.d.ts +25 -11
- package/dist/lib/select.js +14 -7
- package/dist/lib/trend.js +55 -39
- package/package.json +1 -1
- package/dist/examples/BarChartExample.svelte +0 -78
- package/dist/examples/BarChartExample.svelte.d.ts +0 -18
- package/dist/lib/brewing/types.d.ts +0 -162
- package/dist/lib/brewing/types.js +0 -73
- package/dist/lib/xscale.d.ts +0 -11
- package/dist/lib/xscale.js +0 -31
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getContext, onMount, onDestroy } from 'svelte'
|
|
3
|
+
import type { PlotState } from '../PlotState.svelte.js'
|
|
4
|
+
import { GeomState } from './lib/GeomState.svelte.js'
|
|
5
|
+
import { buildRadarMarks, resolveRadarRadius } from './lib/marks/radar.js'
|
|
6
|
+
import { buildRadarLayout } from '../lib/brewing/polar.js'
|
|
7
|
+
import { buildSelectDetail } from '../lib/select.js'
|
|
8
|
+
|
|
9
|
+
type Row = Record<string, unknown>
|
|
10
|
+
|
|
11
|
+
type AxisSpec = {
|
|
12
|
+
key: string
|
|
13
|
+
label?: string
|
|
14
|
+
unit?: string
|
|
15
|
+
domain?: [number, number]
|
|
16
|
+
ticks?: number
|
|
17
|
+
tickLabels?: string[]
|
|
18
|
+
format?: (value: number) => unknown
|
|
19
|
+
weight?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The shape `buildRadarMarks` documents in its `@returns`. Other geoms leave `geom.marks`
|
|
23
|
+
// untyped because they only ever spread it in a template; this one also reads it in script
|
|
24
|
+
// (see `strokeBySeries`), which needs a real element type.
|
|
25
|
+
type RadarMark = {
|
|
26
|
+
d: string
|
|
27
|
+
fill: string
|
|
28
|
+
stroke: string
|
|
29
|
+
alpha: number
|
|
30
|
+
key: string
|
|
31
|
+
patternId: string | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Options = {
|
|
35
|
+
grid?: boolean
|
|
36
|
+
rings?: number
|
|
37
|
+
sharedDomain?: boolean
|
|
38
|
+
radiusScale?: 'linear' | 'sqrt' | 'auto'
|
|
39
|
+
/** Alias for the `axes` prop, for callers that can only reach `options` — notably
|
|
40
|
+
* Plot's spec-driven path, whose passthrough is a fixed prop list. */
|
|
41
|
+
axes?: (string | AxisSpec)[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Props = {
|
|
45
|
+
axes?: (string | AxisSpec)[]
|
|
46
|
+
axis?: string
|
|
47
|
+
value?: string
|
|
48
|
+
series?: string
|
|
49
|
+
/** Generic-pipeline aliases for `axis`/`value`/`series`. Radar's own names say what
|
|
50
|
+
* the channels mean here (an axis is not an x position), but Plot's spec-driven
|
|
51
|
+
* path passes only the generic set, so these let a spec drive radar too. The
|
|
52
|
+
* specific names win when both are given. */
|
|
53
|
+
x?: string
|
|
54
|
+
y?: string
|
|
55
|
+
color?: string
|
|
56
|
+
pattern?: string
|
|
57
|
+
/** Fixed area opacity 0–1; defaults to the per-geom preset (radar = 0.25). */
|
|
58
|
+
alpha?: number
|
|
59
|
+
stat?: string
|
|
60
|
+
options?: Options
|
|
61
|
+
onselect?: (data: Row) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let {
|
|
65
|
+
axes: axesProp,
|
|
66
|
+
axis: axisProp,
|
|
67
|
+
value: valueProp,
|
|
68
|
+
series: seriesProp,
|
|
69
|
+
x = undefined,
|
|
70
|
+
y = undefined,
|
|
71
|
+
color = undefined,
|
|
72
|
+
pattern,
|
|
73
|
+
alpha,
|
|
74
|
+
stat = 'identity',
|
|
75
|
+
options = {},
|
|
76
|
+
onselect = undefined
|
|
77
|
+
}: Props = $props()
|
|
78
|
+
|
|
79
|
+
// Radar's own channel names take precedence; the generic ones are the fallback so a
|
|
80
|
+
// spec-driven <Plot> (which passes only x/y/color) can drive this geom too.
|
|
81
|
+
const axis = $derived(axisProp ?? x)
|
|
82
|
+
const value = $derived(valueProp ?? y)
|
|
83
|
+
const series = $derived(seriesProp ?? color)
|
|
84
|
+
const axes = $derived(axesProp ?? options.axes)
|
|
85
|
+
|
|
86
|
+
const LABEL_GAP = 12
|
|
87
|
+
const ZERO_MARKER_HALF_SPAN_DEG = 6
|
|
88
|
+
const VERTEX_RADIUS = 3
|
|
89
|
+
|
|
90
|
+
const plotState = getContext<PlotState>('plot-state')
|
|
91
|
+
|
|
92
|
+
const geom = new GeomState(plotState, () => ({
|
|
93
|
+
type: 'radar',
|
|
94
|
+
channels: { x: axis, y: value, color: series, pattern },
|
|
95
|
+
stat,
|
|
96
|
+
options: {
|
|
97
|
+
axes,
|
|
98
|
+
sharedDomain: options.sharedDomain,
|
|
99
|
+
rings: options.rings,
|
|
100
|
+
radiusScale: options.radiusScale
|
|
101
|
+
},
|
|
102
|
+
alpha,
|
|
103
|
+
build: buildRadarMarks
|
|
104
|
+
}))
|
|
105
|
+
onMount(geom.register)
|
|
106
|
+
onDestroy(geom.destroy)
|
|
107
|
+
$effect(geom.sync)
|
|
108
|
+
|
|
109
|
+
const marks: RadarMark[] = $derived(geom.marks)
|
|
110
|
+
const w = $derived(plotState.innerWidth)
|
|
111
|
+
const h = $derived(plotState.innerHeight)
|
|
112
|
+
|
|
113
|
+
// One shared definition of the radius AND of which form we're drawing — the adapter
|
|
114
|
+
// builds its polygons from the very same call, so the grid can no longer disagree
|
|
115
|
+
// with them. `micro` is derived from the space actually available rather than from
|
|
116
|
+
// "am I inside a Spark": that keeps the geom free of any container-specific branch
|
|
117
|
+
// (the spec's requirement) and correctly also covers a Plot too small for chrome.
|
|
118
|
+
//
|
|
119
|
+
// Deliberately NOT `plotState.interactive`, which the plan proposed: that is
|
|
120
|
+
// `Boolean(onselect) || selectable`, so an ordinary static <Plot> reads false too and
|
|
121
|
+
// would silently lose its rings, spokes and axis labels.
|
|
122
|
+
const geometry = $derived(resolveRadarRadius(w, h))
|
|
123
|
+
const R = $derived(geometry.R)
|
|
124
|
+
const micro = $derived(geometry.micro)
|
|
125
|
+
|
|
126
|
+
// Radar takes its own channel props — like `GeomState.marks`, this never falls back to the
|
|
127
|
+
// container's `x`/`y`. Without both `axis` and `value` there is nothing coherent to lay a
|
|
128
|
+
// polar grid out against, so no layout is computed at all (mirrors `buildRadarMarks`'s own
|
|
129
|
+
// `!channels?.x || !channels?.y` guard for the polygons).
|
|
130
|
+
const layout = $derived(
|
|
131
|
+
axis !== undefined && value !== undefined
|
|
132
|
+
? buildRadarLayout(
|
|
133
|
+
geom.data,
|
|
134
|
+
{ x: axis, y: value, color: series },
|
|
135
|
+
{
|
|
136
|
+
axes,
|
|
137
|
+
sharedDomain: options.sharedDomain,
|
|
138
|
+
rings: options.rings,
|
|
139
|
+
radiusScale: options.radiusScale,
|
|
140
|
+
R
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
: null
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
function toXY(angleDeg: number, radius: number) {
|
|
147
|
+
const rad = (angleDeg * Math.PI) / 180
|
|
148
|
+
return { x: radius * Math.cos(rad), y: radius * Math.sin(rad) }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// cos(angle) is within float error of 0 only at true vertical (±90°, top/bottom); every
|
|
152
|
+
// other axis angle sits comfortably to one side, so a tight epsilon can't misclassify a
|
|
153
|
+
// real left/right axis as 'middle'.
|
|
154
|
+
function anchorFor(angleDeg: number): 'start' | 'middle' | 'end' {
|
|
155
|
+
const cosA = Math.cos((angleDeg * Math.PI) / 180)
|
|
156
|
+
if (Math.abs(cosA) < 1e-6) return 'middle'
|
|
157
|
+
return cosA > 0 ? 'start' : 'end'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// `buildRadarMarks` keys every mark `${seriesKey}::fill` / `${seriesKey}::stroke` so paint
|
|
161
|
+
// order (all fills, then all strokes) is inspectable from the key alone; this recovers the
|
|
162
|
+
// series value from that key for the `data-plot-series` hook.
|
|
163
|
+
function seriesOf(key: string) {
|
|
164
|
+
return key.slice(0, key.lastIndexOf('::'))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Series stroke colour, recovered from the adapter's stroke marks (the ones with no fill)
|
|
168
|
+
// rather than re-resolving the palette here. A vertex dot has to match its own polygon's
|
|
169
|
+
// outline, and the only way to guarantee that is to read the same value the outline used.
|
|
170
|
+
const strokeBySeries = $derived(
|
|
171
|
+
new Map(
|
|
172
|
+
marks.filter((mark) => mark.fill === 'none').map((mark) => [seriesOf(mark.key), mark.stroke])
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
// A vertex is reachable when either the geom was handed its own callback or the plot is
|
|
177
|
+
// running interactively. Deliberately NOT the `keyboardNav` action: that walks a 1-D
|
|
178
|
+
// category list in DOM order, and radar's vertices are a 2-D series × axis grid, so
|
|
179
|
+
// left/right traversal has no meaning here. Tab reach plus Enter/Space, like Point/Arc.
|
|
180
|
+
const reachable = $derived(Boolean(onselect) || plotState.interactive)
|
|
181
|
+
|
|
182
|
+
function selectVertex(row: Row, seriesKey: unknown, event: MouseEvent | KeyboardEvent) {
|
|
183
|
+
onselect?.(row)
|
|
184
|
+
if (plotState.interactive)
|
|
185
|
+
plotState.handleSelect(
|
|
186
|
+
buildSelectDetail({
|
|
187
|
+
datum: row,
|
|
188
|
+
// `row` is one of the container's original row objects — averaging happens
|
|
189
|
+
// inside polar.js precisely so this lookup resolves instead of returning -1.
|
|
190
|
+
index: plotState.data.indexOf(row),
|
|
191
|
+
channels: { x: axis, y: value },
|
|
192
|
+
geom: 'radar',
|
|
193
|
+
series: seriesKey,
|
|
194
|
+
event
|
|
195
|
+
})
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// A short dashed arc at the radius where value 0 sits on this one spoke — see
|
|
200
|
+
// `zeroRingFor`'s doc comment for why this can't just be a shared ring.
|
|
201
|
+
function zeroMarkerPath(angleDeg: number, radius: number) {
|
|
202
|
+
const p1 = toXY(angleDeg - ZERO_MARKER_HALF_SPAN_DEG, radius)
|
|
203
|
+
const p2 = toXY(angleDeg + ZERO_MARKER_HALF_SPAN_DEG, radius)
|
|
204
|
+
return `M${p1.x},${p1.y} A${radius},${radius} 0 0 1 ${p2.x},${p2.y}`
|
|
205
|
+
}
|
|
206
|
+
</script>
|
|
207
|
+
|
|
208
|
+
<g data-plot-geom="radar" transform="translate({w / 2}, {h / 2})">
|
|
209
|
+
{#if layout && options.grid && !micro}
|
|
210
|
+
{#each layout.rings as ring, i (i)}
|
|
211
|
+
<circle data-plot-element="radar-grid-ring" cx="0" cy="0" r={ring.radius} />
|
|
212
|
+
{/each}
|
|
213
|
+
{#each layout.axes as ax, i (ax.key)}
|
|
214
|
+
{@const { x: sx, y: sy } = toXY(layout.angles[i], layout.radius)}
|
|
215
|
+
<line
|
|
216
|
+
data-plot-element="radar-grid-spoke"
|
|
217
|
+
data-plot-axis={ax.key}
|
|
218
|
+
x1="0"
|
|
219
|
+
y1="0"
|
|
220
|
+
x2={sx}
|
|
221
|
+
y2={sy}
|
|
222
|
+
/>
|
|
223
|
+
{/each}
|
|
224
|
+
{#each layout.zeroRings as zr, i (i)}
|
|
225
|
+
{#if zr !== null}
|
|
226
|
+
<path data-plot-element="radar-zero-ring" d={zeroMarkerPath(layout.angles[i], zr)} />
|
|
227
|
+
{/if}
|
|
228
|
+
{/each}
|
|
229
|
+
{#if layout.axes.length > 0}
|
|
230
|
+
{#each layout.rings as ring, ringIndex (ringIndex)}
|
|
231
|
+
{@const { x: lx, y: ly } = toXY(layout.angles[0], ring.radius)}
|
|
232
|
+
<text class="radar-ring-label" x={lx} y={ly} dy="-2">{ring.labels[0]}</text>
|
|
233
|
+
{/each}
|
|
234
|
+
{/if}
|
|
235
|
+
{/if}
|
|
236
|
+
|
|
237
|
+
{#each marks as mark (mark.key)}
|
|
238
|
+
{#if mark.fill !== 'none'}
|
|
239
|
+
<path
|
|
240
|
+
d={mark.d}
|
|
241
|
+
fill={mark.fill}
|
|
242
|
+
fill-opacity={mark.alpha}
|
|
243
|
+
stroke="none"
|
|
244
|
+
data-plot-element="radar-area"
|
|
245
|
+
data-plot-series={seriesOf(mark.key)}
|
|
246
|
+
/>
|
|
247
|
+
{#if mark.patternId}
|
|
248
|
+
<path
|
|
249
|
+
d={mark.d}
|
|
250
|
+
fill="url(#{mark.patternId})"
|
|
251
|
+
stroke="none"
|
|
252
|
+
pointer-events="none"
|
|
253
|
+
data-plot-element="radar-area"
|
|
254
|
+
data-plot-series={seriesOf(mark.key)}
|
|
255
|
+
/>
|
|
256
|
+
{/if}
|
|
257
|
+
{:else}
|
|
258
|
+
<path
|
|
259
|
+
d={mark.d}
|
|
260
|
+
fill="none"
|
|
261
|
+
stroke={mark.stroke}
|
|
262
|
+
stroke-width="2"
|
|
263
|
+
data-plot-element="radar-outline"
|
|
264
|
+
data-plot-series={seriesOf(mark.key)}
|
|
265
|
+
/>
|
|
266
|
+
{/if}
|
|
267
|
+
{/each}
|
|
268
|
+
|
|
269
|
+
{#if layout && !micro}
|
|
270
|
+
{#each [...layout.series] as [seriesKey, seriesVertices] (seriesKey)}
|
|
271
|
+
{#each seriesVertices as vertex, i (i)}
|
|
272
|
+
<!-- A null vertex is a genuine gap in the data — no reading for this (series, axis)
|
|
273
|
+
cell — so it renders as nothing at all. Plotting it at the centre would invent
|
|
274
|
+
a zero the source never contained. -->
|
|
275
|
+
{#if vertex}
|
|
276
|
+
{@const { x: vx, y: vy } = toXY(vertex.angle, vertex.radius)}
|
|
277
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
278
|
+
<circle
|
|
279
|
+
data-plot-element="radar-vertex"
|
|
280
|
+
data-plot-series={String(seriesKey)}
|
|
281
|
+
data-plot-axis={vertex.axisKey}
|
|
282
|
+
cx={vx}
|
|
283
|
+
cy={vy}
|
|
284
|
+
r={VERTEX_RADIUS}
|
|
285
|
+
fill={strokeBySeries.get(String(seriesKey))}
|
|
286
|
+
role={reachable ? 'button' : 'graphics-symbol'}
|
|
287
|
+
tabindex={reachable ? 0 : undefined}
|
|
288
|
+
style:cursor={reachable ? 'pointer' : undefined}
|
|
289
|
+
aria-label="{layout.axes[i].displayLabel}, {vertex.value}"
|
|
290
|
+
onmouseenter={() => plotState.setHovered(vertex.row)}
|
|
291
|
+
onmouseleave={() => plotState.clearHovered()}
|
|
292
|
+
onclick={reachable ? (e) => selectVertex(vertex.row, seriesKey, e) : undefined}
|
|
293
|
+
onkeydown={reachable
|
|
294
|
+
? (e) =>
|
|
295
|
+
(e.key === 'Enter' || e.key === ' ') && selectVertex(vertex.row, seriesKey, e)
|
|
296
|
+
: undefined}
|
|
297
|
+
/>
|
|
298
|
+
{/if}
|
|
299
|
+
{/each}
|
|
300
|
+
{/each}
|
|
301
|
+
{/if}
|
|
302
|
+
|
|
303
|
+
{#if layout && !micro}
|
|
304
|
+
{#each layout.axes as ax, i (ax.key)}
|
|
305
|
+
{@const { x: lx, y: ly } = toXY(layout.angles[i], layout.radius + LABEL_GAP)}
|
|
306
|
+
<text
|
|
307
|
+
data-plot-element="radar-axis-label"
|
|
308
|
+
data-plot-axis={ax.key}
|
|
309
|
+
x={lx}
|
|
310
|
+
y={ly}
|
|
311
|
+
text-anchor={anchorFor(layout.angles[i])}
|
|
312
|
+
dominant-baseline="central">{ax.displayLabel}</text
|
|
313
|
+
>
|
|
314
|
+
{/each}
|
|
315
|
+
{/if}
|
|
316
|
+
</g>
|
|
317
|
+
|
|
318
|
+
<style>
|
|
319
|
+
[data-plot-element='radar-grid-ring'] {
|
|
320
|
+
fill: none;
|
|
321
|
+
stroke: var(--chart-grid-color, currentColor);
|
|
322
|
+
stroke-width: var(--chart-grid-width, 1);
|
|
323
|
+
stroke-dasharray: var(--chart-grid-dash, 2 4);
|
|
324
|
+
opacity: var(--chart-grid-opacity, 0.15);
|
|
325
|
+
pointer-events: none;
|
|
326
|
+
}
|
|
327
|
+
[data-plot-element='radar-grid-spoke'] {
|
|
328
|
+
stroke: var(--chart-grid-color, currentColor);
|
|
329
|
+
stroke-width: var(--chart-grid-width, 1);
|
|
330
|
+
opacity: var(--chart-grid-opacity, 0.15);
|
|
331
|
+
pointer-events: none;
|
|
332
|
+
}
|
|
333
|
+
[data-plot-element='radar-zero-ring'] {
|
|
334
|
+
fill: none;
|
|
335
|
+
stroke: var(--chart-grid-color, currentColor);
|
|
336
|
+
stroke-width: var(--chart-grid-width, 1);
|
|
337
|
+
stroke-dasharray: 3 3;
|
|
338
|
+
opacity: 0.5;
|
|
339
|
+
pointer-events: none;
|
|
340
|
+
}
|
|
341
|
+
[data-plot-element='radar-axis-label'] {
|
|
342
|
+
font-size: 11px;
|
|
343
|
+
fill: currentColor;
|
|
344
|
+
pointer-events: none;
|
|
345
|
+
}
|
|
346
|
+
.radar-ring-label {
|
|
347
|
+
font-size: 9px;
|
|
348
|
+
fill: currentColor;
|
|
349
|
+
opacity: 0.6;
|
|
350
|
+
pointer-events: none;
|
|
351
|
+
}
|
|
352
|
+
</style>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
type Row = Record<string, unknown>;
|
|
2
|
+
type AxisSpec = {
|
|
3
|
+
key: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
unit?: string;
|
|
6
|
+
domain?: [number, number];
|
|
7
|
+
ticks?: number;
|
|
8
|
+
tickLabels?: string[];
|
|
9
|
+
format?: (value: number) => unknown;
|
|
10
|
+
weight?: number;
|
|
11
|
+
};
|
|
12
|
+
type Options = {
|
|
13
|
+
grid?: boolean;
|
|
14
|
+
rings?: number;
|
|
15
|
+
sharedDomain?: boolean;
|
|
16
|
+
radiusScale?: 'linear' | 'sqrt' | 'auto';
|
|
17
|
+
/** Alias for the `axes` prop, for callers that can only reach `options` — notably
|
|
18
|
+
* Plot's spec-driven path, whose passthrough is a fixed prop list. */
|
|
19
|
+
axes?: (string | AxisSpec)[];
|
|
20
|
+
};
|
|
21
|
+
type Props = {
|
|
22
|
+
axes?: (string | AxisSpec)[];
|
|
23
|
+
axis?: string;
|
|
24
|
+
value?: string;
|
|
25
|
+
series?: string;
|
|
26
|
+
/** Generic-pipeline aliases for `axis`/`value`/`series`. Radar's own names say what
|
|
27
|
+
* the channels mean here (an axis is not an x position), but Plot's spec-driven
|
|
28
|
+
* path passes only the generic set, so these let a spec drive radar too. The
|
|
29
|
+
* specific names win when both are given. */
|
|
30
|
+
x?: string;
|
|
31
|
+
y?: string;
|
|
32
|
+
color?: string;
|
|
33
|
+
pattern?: string;
|
|
34
|
+
/** Fixed area opacity 0–1; defaults to the per-geom preset (radar = 0.25). */
|
|
35
|
+
alpha?: number;
|
|
36
|
+
stat?: string;
|
|
37
|
+
options?: Options;
|
|
38
|
+
onselect?: (data: Row) => void;
|
|
39
|
+
};
|
|
40
|
+
declare const Radar: import("svelte").Component<Props, {}, "">;
|
|
41
|
+
type Radar = ReturnType<typeof Radar>;
|
|
42
|
+
export default Radar;
|
package/dist/geoms/Trend.svelte
CHANGED
|
@@ -20,30 +20,53 @@
|
|
|
20
20
|
return t === 'mean' ? 'avg' : t
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Everything a path builder needs, resolved once per derivation. */
|
|
24
|
+
type Ctx = {
|
|
25
|
+
rows: Record<string, unknown>[]
|
|
26
|
+
// Scales come from PlotState (untyped JS), so they stay loose here.
|
|
27
|
+
xs: (v: unknown) => number
|
|
28
|
+
ys: (v: number) => number
|
|
29
|
+
x: string
|
|
30
|
+
y: string
|
|
31
|
+
innerWidth: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isDrawable = (s: PlotState | undefined) =>
|
|
35
|
+
Boolean(s?.xScale && s?.yScale && s?.data?.length)
|
|
36
|
+
|
|
37
|
+
/** Null until the plot has data, both scales, both channels and at least one method. */
|
|
38
|
+
const ctx = $derived.by((): Ctx | null => {
|
|
39
|
+
if (!isDrawable(state) || !x || !y || !methods.length) return null
|
|
40
|
+
return { rows: state.data, xs: state.xScale, ys: state.yScale, x, y, innerWidth: state.innerWidth }
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/** A flat rule spanning the plot — what every `constant` trend (avg, median, min…) draws. */
|
|
44
|
+
const constantPath = (c: Ctx, value: number) => {
|
|
45
|
+
const yy = c.ys(value)
|
|
46
|
+
return `M0,${yy} L${c.innerWidth},${yy}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A polyline through the trend's per-row values, dropping points that don't place. */
|
|
50
|
+
function seriesPath(c: Ctx, values: number[]): string | null {
|
|
51
|
+
const pts = values
|
|
52
|
+
.map((v, i) => ({ vx: scalePos(c.xs, c.rows[i][c.x]), vy: c.ys(v) }))
|
|
53
|
+
.filter((p) => Number.isFinite(p.vx) && Number.isFinite(p.vy))
|
|
54
|
+
return d3line<{ vx: number; vy: number }>()
|
|
55
|
+
.x((p) => p.vx)
|
|
56
|
+
.y((p) => p.vy)(pts)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One method → one path, or null when the method doesn't resolve for this data. */
|
|
60
|
+
function trendPath(c: Ctx, m: Method, i: number) {
|
|
61
|
+
const res = computeTrend(c.rows, { x: c.x, y: c.y }, m)
|
|
62
|
+
if (!res) return null
|
|
63
|
+
const d = res.kind === 'constant' ? constantPath(c, res.value) : seriesPath(c, res.values)
|
|
64
|
+
return d ? { d, type: typeOf(m), i } : null
|
|
65
|
+
}
|
|
66
|
+
|
|
23
67
|
const paths = $derived.by(() => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const ys = state?.yScale
|
|
27
|
-
if (!rows.length || !xs || !ys || !x || !y || !methods.length) return []
|
|
28
|
-
const out: { d: string; type: string; i: number }[] = []
|
|
29
|
-
methods.forEach((m, idx) => {
|
|
30
|
-
const res = computeTrend(rows, { x, y }, m)
|
|
31
|
-
if (!res) return
|
|
32
|
-
if (res.kind === 'constant') {
|
|
33
|
-
const yy = ys(res.value)
|
|
34
|
-
out.push({ d: `M0,${yy} L${state.innerWidth},${yy}`, type: typeOf(m), i: idx })
|
|
35
|
-
} else {
|
|
36
|
-
const pts = res.values
|
|
37
|
-
.map((v, i) => ({ vx: scalePos(xs, rows[i][x]), vy: ys(v) }))
|
|
38
|
-
.filter((p) => Number.isFinite(p.vx) && Number.isFinite(p.vy))
|
|
39
|
-
const gen = d3line<{ vx: number; vy: number }>()
|
|
40
|
-
.x((p) => p.vx)
|
|
41
|
-
.y((p) => p.vy)
|
|
42
|
-
const d = gen(pts)
|
|
43
|
-
if (d) out.push({ d, type: typeOf(m), i: idx })
|
|
44
|
-
}
|
|
45
|
-
})
|
|
46
|
-
return out
|
|
68
|
+
if (!ctx) return []
|
|
69
|
+
return methods.map((m, i) => trendPath(ctx, m, i)).filter((p) => p !== null)
|
|
47
70
|
})
|
|
48
71
|
</script>
|
|
49
72
|
|
|
@@ -34,3 +34,17 @@ export function resolveFillStroke(row: Record<string, unknown>, channels: {
|
|
|
34
34
|
export function resolveAlpha(alpha: number | undefined, type: string, preset?: {
|
|
35
35
|
opacity?: Record<string, number>;
|
|
36
36
|
}): number;
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a mark's text label from the geom's `label` prop.
|
|
39
|
+
*
|
|
40
|
+
* - falsy → no label
|
|
41
|
+
* - `true` → the row's value in `defaultField` (usually the geom's y channel)
|
|
42
|
+
* - function → its return value, stringified
|
|
43
|
+
* - string → the row's value in that field
|
|
44
|
+
*
|
|
45
|
+
* @param {boolean|string|((row: Record<string, unknown>) => unknown)|undefined} label
|
|
46
|
+
* @param {Record<string, unknown>} row
|
|
47
|
+
* @param {string} [defaultField] - field used when `label` is `true`
|
|
48
|
+
* @returns {string|null}
|
|
49
|
+
*/
|
|
50
|
+
export function resolveLabel(label: boolean | string | ((row: Record<string, unknown>) => unknown) | undefined, row: Record<string, unknown>, defaultField?: string): string | null;
|
|
@@ -43,3 +43,24 @@ export function resolveAlpha(alpha, type, preset) {
|
|
|
43
43
|
if (typeof alpha === 'number') return alpha
|
|
44
44
|
return preset?.opacity?.[type] ?? 1
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a mark's text label from the geom's `label` prop.
|
|
49
|
+
*
|
|
50
|
+
* - falsy → no label
|
|
51
|
+
* - `true` → the row's value in `defaultField` (usually the geom's y channel)
|
|
52
|
+
* - function → its return value, stringified
|
|
53
|
+
* - string → the row's value in that field
|
|
54
|
+
*
|
|
55
|
+
* @param {boolean|string|((row: Record<string, unknown>) => unknown)|undefined} label
|
|
56
|
+
* @param {Record<string, unknown>} row
|
|
57
|
+
* @param {string} [defaultField] - field used when `label` is `true`
|
|
58
|
+
* @returns {string|null}
|
|
59
|
+
*/
|
|
60
|
+
export function resolveLabel(label, row, defaultField) {
|
|
61
|
+
if (!label) return null
|
|
62
|
+
if (label === true) return String((defaultField ? row[defaultField] : undefined) ?? '')
|
|
63
|
+
if (typeof label === 'function') return String(label(row) ?? '')
|
|
64
|
+
if (typeof label === 'string') return String(row[label] ?? '')
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
* @param {'linear'|'smooth'|'step'} [curve]
|
|
10
10
|
* @param {Map<unknown, string>} [patterns]
|
|
11
11
|
* @param {Function} [place] - orientation mapper (x, y) => {x, y}
|
|
12
|
-
* @
|
|
12
|
+
* @param {number} [baselineValue] - data-space value to anchor/split the fill at. When set,
|
|
13
|
+
* each segment is emitted twice (sign: 'above' | 'below'), both anchored at this value's
|
|
14
|
+
* pixel position instead of the chart bottom, so themes can colour the two independently.
|
|
15
|
+
* Fed straight into `yScale()`, so a value outside the scale's domain extrapolates (e.g.
|
|
16
|
+
* 100 against a domain of [-5, 5] gives pixel -475) rather than clamping to the chart edge.
|
|
17
|
+
* @returns {{ d: string, fill: string, stroke: string, key: unknown, groupKey: unknown, patternId: string|null, sign?: 'above'|'below' }[]}
|
|
13
18
|
*/
|
|
14
19
|
export function buildAreas(data: Object[], channels: {
|
|
15
20
|
x: string;
|
|
@@ -19,12 +24,14 @@ export function buildAreas(data: Object[], channels: {
|
|
|
19
24
|
}, xScale: Function, yScale: Function, colors: Map<unknown, {
|
|
20
25
|
fill: string;
|
|
21
26
|
stroke: string;
|
|
22
|
-
}>, curve?: "linear" | "smooth" | "step", patterns?: Map<unknown, string>, place?: Function): {
|
|
27
|
+
}>, curve?: "linear" | "smooth" | "step", patterns?: Map<unknown, string>, place?: Function, baselineValue?: number): {
|
|
23
28
|
d: string;
|
|
24
29
|
fill: string;
|
|
25
30
|
stroke: string;
|
|
26
31
|
key: unknown;
|
|
32
|
+
groupKey: unknown;
|
|
27
33
|
patternId: string | null;
|
|
34
|
+
sign?: "above" | "below";
|
|
28
35
|
}[];
|
|
29
36
|
/**
|
|
30
37
|
* Builds stacked area paths using d3 stack layout.
|
package/dist/geoms/lib/areas.js
CHANGED
|
@@ -13,23 +13,41 @@ import { literalColor, markEntry } from '../../lib/brewing/colors.js'
|
|
|
13
13
|
* @param {'linear'|'smooth'|'step'} [curve]
|
|
14
14
|
* @param {Map<unknown, string>} [patterns]
|
|
15
15
|
* @param {Function} [place] - orientation mapper (x, y) => {x, y}
|
|
16
|
-
* @
|
|
16
|
+
* @param {number} [baselineValue] - data-space value to anchor/split the fill at. When set,
|
|
17
|
+
* each segment is emitted twice (sign: 'above' | 'below'), both anchored at this value's
|
|
18
|
+
* pixel position instead of the chart bottom, so themes can colour the two independently.
|
|
19
|
+
* Fed straight into `yScale()`, so a value outside the scale's domain extrapolates (e.g.
|
|
20
|
+
* 100 against a domain of [-5, 5] gives pixel -475) rather than clamping to the chart edge.
|
|
21
|
+
* @returns {{ d: string, fill: string, stroke: string, key: unknown, groupKey: unknown, patternId: string|null, sign?: 'above'|'below' }[]}
|
|
17
22
|
*/
|
|
18
|
-
export function buildAreas(data, channels, xScale, yScale, colors, curve, patterns, place = (x, y) => ({ x, y })) {
|
|
23
|
+
export function buildAreas(data, channels, xScale, yScale, colors, curve, patterns, place = (x, y) => ({ x, y }), baselineValue = undefined) {
|
|
19
24
|
const { x: xf, y: yf, pattern: pf } = channels
|
|
20
25
|
// A literal color (var()/oklch()/hex/currentColor) isn't a data field — it's the exact
|
|
21
26
|
// fill the caller wants for THIS area. Don't group on it, and don't fall back to the
|
|
22
27
|
// shared color scale (one value across all geoms); use it directly.
|
|
23
28
|
const lit = literalColor(channels.color)
|
|
24
29
|
const cf = lit ? undefined : channels.color
|
|
25
|
-
|
|
30
|
+
// Number.isFinite (not a loose !=null check) also rejects NaN/Infinity — a caller-computed
|
|
31
|
+
// stat (e.g. a mean over an empty group) can easily produce NaN, which would otherwise sail
|
|
32
|
+
// through and emit literal "NaN" path commands.
|
|
33
|
+
const hasBaseline = Number.isFinite(baselineValue)
|
|
34
|
+
const baseline = hasBaseline ? yScale(baselineValue) : yScale.range()[0] // bottom of the chart (y pixel max) unless an explicit baseline value is given
|
|
26
35
|
|
|
27
36
|
const xPos = (d) =>
|
|
28
37
|
typeof xScale.bandwidth === 'function' ? xScale(d[xf]) + xScale.bandwidth() / 2 : xScale(d[xf])
|
|
29
38
|
|
|
30
39
|
// Each area edge (baseline + top) is placed so the area transposes under orientation.
|
|
31
|
-
// `
|
|
32
|
-
|
|
40
|
+
// `sign` clamps the top edge to one side of the baseline — 'above' never dips below it,
|
|
41
|
+
// 'below' never rises above it — the same technique proven in Sparkline (#148). This MUST
|
|
42
|
+
// happen here, in value space, before `place()` runs: `place` is how horizontal/flipped
|
|
43
|
+
// orientation is implemented (it can swap x/y), so clamping the post-place coordinate would
|
|
44
|
+
// clamp whatever axis ends up there — including the category axis on a flipped chart. `v`
|
|
45
|
+
// carries the raw y-value so the generator can break the area at a gap (null).
|
|
46
|
+
const toEdge = (d, sign) => {
|
|
47
|
+
const rawTop = yScale(d[yf])
|
|
48
|
+
const clampedTop = sign === 'above' ? Math.min(rawTop, baseline) : sign === 'below' ? Math.max(rawTop, baseline) : rawTop
|
|
49
|
+
return { base: place(xPos(d), baseline), top: place(xPos(d), clampedTop), v: d[yf] }
|
|
50
|
+
}
|
|
33
51
|
const makeGen = () => {
|
|
34
52
|
const gen = area()
|
|
35
53
|
// A null y-value is a GAP (a missing period): break the area there instead of
|
|
@@ -43,6 +61,29 @@ export function buildAreas(data, channels, xScale, yScale, colors, curve, patter
|
|
|
43
61
|
else if (curve === 'step') gen.curve(curveStep)
|
|
44
62
|
return gen
|
|
45
63
|
}
|
|
64
|
+
// One unsigned segment normally; with a baseline, an above and a below copy so themes can
|
|
65
|
+
// colour positive vs negative fill independently. Mirrors the approach proven in Sparkline
|
|
66
|
+
// (#148). Also owns the "no baseline" `d` computation so both call sites just hand over rows.
|
|
67
|
+
const splitBySign = (segBase, rows, grouped) => {
|
|
68
|
+
if (!hasBaseline) return [{ ...segBase, d: makeGen()(rows.map((d) => toEdge(d))) }]
|
|
69
|
+
return ['above', 'below'].map((sign) => ({
|
|
70
|
+
...segBase,
|
|
71
|
+
d: makeGen()(rows.map((d) => toEdge(d, sign))),
|
|
72
|
+
sign,
|
|
73
|
+
// Only the genuinely ungrouped case (no color/fill channel at all) may use a bare
|
|
74
|
+
// sign — there's exactly one segment set in that case, so 'above'/'below' are
|
|
75
|
+
// already unique. A grouped segment must ALWAYS get a discriminated key, even when
|
|
76
|
+
// its group's value happens to be null/undefined: that's a real, distinct group,
|
|
77
|
+
// not "no grouping" — collapsing the two onto the same bare key is exactly the bug
|
|
78
|
+
// this branches around. `typeof` alone already keeps null (typeof 'object') and
|
|
79
|
+
// undefined (typeof 'undefined') apart, no extra special-casing needed for them.
|
|
80
|
+
// This assumes group values are primitives (string/number/boolean/null/undefined),
|
|
81
|
+
// true for every color/fill channel today; two distinct objects (or two Dates with
|
|
82
|
+
// equal timestamps) stringifying to the same "type:value" could still collide —
|
|
83
|
+
// an accepted, documented bound, not an oversight.
|
|
84
|
+
key: grouped ? `${typeof segBase.key}:${segBase.key}::${sign}` : sign
|
|
85
|
+
}))
|
|
86
|
+
}
|
|
46
87
|
|
|
47
88
|
// For band (categorical) x scales, sort by domain index to preserve intended ordering.
|
|
48
89
|
// For continuous scales, sort numerically so the path draws left-to-right.
|
|
@@ -62,7 +103,11 @@ export function buildAreas(data, channels, xScale, yScale, colors, curve, patter
|
|
|
62
103
|
patternKey !== null && patternKey !== undefined && patterns?.has(patternKey)
|
|
63
104
|
? toPatternId(String(patternKey))
|
|
64
105
|
: null
|
|
65
|
-
|
|
106
|
+
// `groupKey` is the untouched, unsigned group discriminator (here always null): callers
|
|
107
|
+
// that need to look a segment back up in a colors/patterns Map (keyed by the raw group
|
|
108
|
+
// value, not by our render key) should use this instead of `key`, since `key` gets a
|
|
109
|
+
// sign suffix once a baseline is in effect.
|
|
110
|
+
return splitBySign({ fill: entry.fill, stroke: 'none', key: null, groupKey: null, patternId }, sortByX(data), false)
|
|
66
111
|
}
|
|
67
112
|
|
|
68
113
|
// Group by color field
|
|
@@ -75,7 +120,7 @@ export function buildAreas(data, channels, xScale, yScale, colors, curve, patter
|
|
|
75
120
|
// For different-field patterns, assign positionally so each area gets a distinct pattern
|
|
76
121
|
const orderedPatternKeys = pf && pf !== cf ? [...(patterns?.keys() ?? [])] : null
|
|
77
122
|
|
|
78
|
-
return [...groups.entries()].
|
|
123
|
+
return [...groups.entries()].flatMap(([key, rows], i) => {
|
|
79
124
|
const entry = colors?.get(key) ?? { fill: '#888', stroke: '#888' }
|
|
80
125
|
// Same field or no pf: look up by colorKey. Different field: assign positionally.
|
|
81
126
|
const patternKey = !pf
|
|
@@ -87,7 +132,7 @@ export function buildAreas(data, channels, xScale, yScale, colors, curve, patter
|
|
|
87
132
|
patternKey !== null && patternKey !== undefined && patterns?.has(patternKey)
|
|
88
133
|
? toPatternId(String(patternKey))
|
|
89
134
|
: null
|
|
90
|
-
return {
|
|
135
|
+
return splitBySign({ fill: entry.fill, stroke: 'none', key, groupKey: key, patternId }, sortByX(rows), true)
|
|
91
136
|
})
|
|
92
137
|
}
|
|
93
138
|
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* are set — so an old `color`-only area stays border-less (backward compatible); the
|
|
5
5
|
* border uses the interior group's darker stroke shade. Per-field border coloring
|
|
6
6
|
* (fill ≠ color, two legends) is the deferred dual-scale follow-up.
|
|
7
|
+
* `options.baseline`, when set, is passed straight to `buildAreas` (identity-position charts
|
|
8
|
+
* only — stacked/100% areas ignore it) and splits each segment into signed above/below copies
|
|
9
|
+
* (#148).
|
|
7
10
|
* @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
|
|
8
11
|
*/
|
|
9
12
|
export function buildAreaMarks({ data, plot, channels, options, alpha, type }: {
|