@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.
Files changed (47) hide show
  1. package/dist/Plot.svelte +25 -9
  2. package/dist/PlotState.svelte.d.ts +1 -0
  3. package/dist/PlotState.svelte.js +19 -8
  4. package/dist/Spark.svelte +165 -0
  5. package/dist/Spark.svelte.d.ts +31 -0
  6. package/dist/SparkState.svelte.d.ts +138 -0
  7. package/dist/SparkState.svelte.js +331 -0
  8. package/dist/Sparkline.svelte +62 -237
  9. package/dist/Sparkline.svelte.d.ts +2 -0
  10. package/dist/charts/RadarChart.svelte +106 -0
  11. package/dist/charts/RadarChart.svelte.d.ts +43 -0
  12. package/dist/geoms/Area.svelte +24 -9
  13. package/dist/geoms/Area.svelte.d.ts +2 -0
  14. package/dist/geoms/Bar.svelte +9 -16
  15. package/dist/geoms/Highlight.svelte +61 -40
  16. package/dist/geoms/Line.svelte +10 -9
  17. package/dist/geoms/Point.svelte +9 -15
  18. package/dist/geoms/Radar.svelte +352 -0
  19. package/dist/geoms/Radar.svelte.d.ts +42 -0
  20. package/dist/geoms/Trend.svelte +46 -23
  21. package/dist/geoms/lib/aesthetics.d.ts +14 -0
  22. package/dist/geoms/lib/aesthetics.js +21 -0
  23. package/dist/geoms/lib/areas.d.ts +9 -2
  24. package/dist/geoms/lib/areas.js +53 -8
  25. package/dist/geoms/lib/marks/area.d.ts +3 -0
  26. package/dist/geoms/lib/marks/area.js +11 -2
  27. package/dist/geoms/lib/marks/radar.d.ts +68 -0
  28. package/dist/geoms/lib/marks/radar.js +153 -0
  29. package/dist/index.d.ts +6 -0
  30. package/dist/index.js +7 -1
  31. package/dist/lib/brewing/marks/boxes.js +19 -30
  32. package/dist/lib/brewing/polar.d.ts +444 -0
  33. package/dist/lib/brewing/polar.js +645 -0
  34. package/dist/lib/highlight.js +84 -33
  35. package/dist/lib/plot/scales.js +8 -5
  36. package/dist/lib/preset.d.ts +1 -0
  37. package/dist/lib/preset.js +2 -1
  38. package/dist/lib/select.d.ts +25 -11
  39. package/dist/lib/select.js +14 -7
  40. package/dist/lib/trend.js +55 -39
  41. package/package.json +1 -1
  42. package/dist/examples/BarChartExample.svelte +0 -78
  43. package/dist/examples/BarChartExample.svelte.d.ts +0 -18
  44. package/dist/lib/brewing/types.d.ts +0 -162
  45. package/dist/lib/brewing/types.js +0 -73
  46. package/dist/lib/xscale.d.ts +0 -11
  47. package/dist/lib/xscale.js +0 -31
@@ -21,6 +21,8 @@ type Props = {
21
21
  baseline?: number;
22
22
  highlight?: HighlightSelector | HighlightSelector[];
23
23
  trend?: TrendMethod | TrendMethod[];
24
+ /** Accessible name, forwarded to `Spark`. Omitted → generated from the series. */
25
+ label?: string;
24
26
  };
25
27
  declare const Sparkline: import("svelte").Component<Props, {}, "">;
26
28
  type Sparkline = ReturnType<typeof Sparkline>;
@@ -0,0 +1,106 @@
1
+ <script lang="ts">
2
+ import Plot from '../Plot.svelte'
3
+ import Radar from '../geoms/Radar.svelte'
4
+
5
+ type Row = Record<string, unknown>
6
+
7
+ type AxisSpec = {
8
+ key: string
9
+ label?: string
10
+ unit?: string
11
+ domain?: [number, number]
12
+ ticks?: number
13
+ tickLabels?: string[]
14
+ format?: (value: number) => unknown
15
+ weight?: number
16
+ }
17
+
18
+ type Props = {
19
+ data?: Row[]
20
+ /** Field naming which axis each row belongs to. */
21
+ axis?: string
22
+ /** Field holding the plotted magnitude. */
23
+ value?: string
24
+ /** Field distinguishing one polygon from another. */
25
+ series?: string
26
+ /** Axis order and per-axis config — bare names, full specs, or a mix. */
27
+ axes?: (string | AxisSpec)[]
28
+ pattern?: string
29
+ grid?: boolean
30
+ rings?: number
31
+ sharedDomain?: boolean
32
+ radiusScale?: 'linear' | 'sqrt' | 'auto'
33
+ alpha?: number
34
+ stat?: string
35
+ width?: number
36
+ height?: number
37
+ mode?: 'light' | 'dark'
38
+ title?: string
39
+ tooltip?: boolean | ((data: Row) => string)
40
+ /** Omit to let it default: on for 2+ series, off for one. */
41
+ legend?: boolean
42
+ /** Plot-level selection: receives the `buildSelectDetail` shape, not a bare row —
43
+ * same as AreaChart/BarChart. The geom's own `onselect` (a row) is reached by
44
+ * composing `<Plot><Radar onselect /></Plot>` directly. */
45
+ onselect?: (detail: unknown) => void
46
+ }
47
+
48
+ let {
49
+ data = [],
50
+ axis = undefined,
51
+ value = undefined,
52
+ series = undefined,
53
+ axes = undefined,
54
+ pattern = undefined,
55
+ grid = true,
56
+ rings = undefined,
57
+ sharedDomain = undefined,
58
+ radiusScale = undefined,
59
+ alpha = undefined,
60
+ stat = 'identity',
61
+ width = 400,
62
+ height = 400,
63
+ mode = undefined,
64
+ title = undefined,
65
+ tooltip = false,
66
+ legend = undefined,
67
+ onselect = undefined
68
+ }: Props = $props()
69
+
70
+ // ⚠ Diverges from PieChart, which defaults `legend` to false. Radar fills are washed out
71
+ // (preset alpha 0.25) and they overlap, so where they intersect the blend is a colour
72
+ // that belongs to neither series — matching a polygon to its series by colour alone is
73
+ // unreliable. A legend is therefore on by default as soon as there is more than one
74
+ // series. Still fully overridable in both directions.
75
+ //
76
+ // With no `series` channel there is exactly one polygon by construction, so the count
77
+ // cannot exceed one and the legend is simply off — no need to distinguish empty data
78
+ // from one implicit series.
79
+ const multiSeries = $derived(series ? new Set(data.map((row) => row[series])).size > 1 : false)
80
+ const showLegend = $derived(legend ?? multiSeries)
81
+ </script>
82
+
83
+ <Plot
84
+ {data}
85
+ {width}
86
+ {height}
87
+ {mode}
88
+ {title}
89
+ grid={false}
90
+ axes={false}
91
+ margin={{ top: 10, right: 10, bottom: 10, left: 10 }}
92
+ legend={showLegend}
93
+ {tooltip}
94
+ {onselect}
95
+ >
96
+ <Radar
97
+ {axis}
98
+ {value}
99
+ {series}
100
+ {axes}
101
+ {pattern}
102
+ {alpha}
103
+ {stat}
104
+ options={{ grid, rings, sharedDomain, radiusScale }}
105
+ />
106
+ </Plot>
@@ -0,0 +1,43 @@
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 Props = {
13
+ data?: Row[];
14
+ /** Field naming which axis each row belongs to. */
15
+ axis?: string;
16
+ /** Field holding the plotted magnitude. */
17
+ value?: string;
18
+ /** Field distinguishing one polygon from another. */
19
+ series?: string;
20
+ /** Axis order and per-axis config — bare names, full specs, or a mix. */
21
+ axes?: (string | AxisSpec)[];
22
+ pattern?: string;
23
+ grid?: boolean;
24
+ rings?: number;
25
+ sharedDomain?: boolean;
26
+ radiusScale?: 'linear' | 'sqrt' | 'auto';
27
+ alpha?: number;
28
+ stat?: string;
29
+ width?: number;
30
+ height?: number;
31
+ mode?: 'light' | 'dark';
32
+ title?: string;
33
+ tooltip?: boolean | ((data: Row) => string);
34
+ /** Omit to let it default: on for 2+ series, off for one. */
35
+ legend?: boolean;
36
+ /** Plot-level selection: receives the `buildSelectDetail` shape, not a bare row —
37
+ * same as AreaChart/BarChart. The geom's own `onselect` (a row) is reached by
38
+ * composing `<Plot><Radar onselect /></Plot>` directly. */
39
+ onselect?: (detail: unknown) => void;
40
+ };
41
+ declare const RadarChart: import("svelte").Component<Props, {}, "">;
42
+ type RadarChart = ReturnType<typeof RadarChart>;
43
+ export default RadarChart;
@@ -10,6 +10,8 @@
10
10
  /** @deprecated use `position="stack"` — kept as an alias. */
11
11
  stack?: boolean
12
12
  curve?: 'linear' | 'smooth' | 'step'
13
+ /** Data-space value to anchor/split the fill at; splits the area into above/below segments. */
14
+ baseline?: number
13
15
  }
14
16
 
15
17
  type Props = {
@@ -40,7 +42,12 @@
40
42
  type: 'area',
41
43
  channels: { x, y, color, fill, pattern },
42
44
  stat,
43
- options: { stack: options?.stack ?? false, position: resolvedPosition, curve: options?.curve },
45
+ options: {
46
+ stack: options?.stack ?? false,
47
+ position: resolvedPosition,
48
+ curve: options?.curve,
49
+ baseline: options?.baseline
50
+ },
44
51
  alpha,
45
52
  build: buildAreaMarks
46
53
  }))
@@ -59,14 +66,14 @@
59
66
  function selectPoint(d: Row, event: MouseEvent | KeyboardEvent) {
60
67
  if (plotState.interactive)
61
68
  plotState.handleSelect(
62
- buildSelectDetail(
63
- d,
64
- plotState.data.indexOf(d),
65
- { x, y },
66
- 'area',
67
- seriesField ? d[seriesField] : undefined,
69
+ buildSelectDetail({
70
+ datum: d,
71
+ index: plotState.data.indexOf(d),
72
+ channels: { x, y },
73
+ geom: 'area',
74
+ series: seriesField ? d[seriesField] : undefined,
68
75
  event
69
- )
76
+ })
70
77
  )
71
78
  }
72
79
  </script>
@@ -80,9 +87,17 @@
80
87
  fill-opacity={seg.patternId ? 1 : seg.alpha}
81
88
  stroke={seg.stroke ?? 'none'}
82
89
  data-plot-element="area"
90
+ data-plot-area
91
+ data-plot-area-sign={seg.sign}
83
92
  />
84
93
  {#if seg.patternId}
85
- <path d={seg.d} fill="url(#{seg.patternId})" data-plot-element="area" />
94
+ <path
95
+ d={seg.d}
96
+ fill="url(#{seg.patternId})"
97
+ data-plot-element="area"
98
+ data-plot-area
99
+ data-plot-area-sign={seg.sign}
100
+ />
86
101
  {/if}
87
102
  {/each}
88
103
  <!-- Invisible hit circles for tooltip: one per data point -->
@@ -2,6 +2,8 @@ type Options = {
2
2
  /** @deprecated use `position="stack"` — kept as an alias. */
3
3
  stack?: boolean;
4
4
  curve?: 'linear' | 'smooth' | 'step';
5
+ /** Data-space value to anchor/split the fill at; splits the area into above/below segments. */
6
+ baseline?: number;
5
7
  };
6
8
  type Props = {
7
9
  x?: string;
@@ -4,6 +4,7 @@
4
4
  import type { createCrossFilter } from '../crossfilter/createCrossFilter.svelte.js'
5
5
  import { GeomState } from './lib/GeomState.svelte.js'
6
6
  import { buildBarMarks } from './lib/marks/bar.js'
7
+ import { resolveLabel } from './lib/aesthetics.js'
7
8
  import { keyboardNav } from '../lib/keyboard-nav.js'
8
9
  import { buildSelectDetail } from '../lib/select.js'
9
10
  import LabelPill from './LabelPill.svelte'
@@ -60,14 +61,6 @@
60
61
  // `options.stack: true` is the back-compat alias for position='stack'.
61
62
  const resolvedPosition = $derived(position ?? (options?.stack ? 'stack' : 'dodge'))
62
63
 
63
- function resolveLabel(data: Row, defaultField: string | undefined): string | null {
64
- if (!label) return null
65
- if (label === true) return String((defaultField ? data[defaultField] : undefined) ?? '')
66
- if (typeof label === 'function') return String(label(data) ?? '')
67
- if (typeof label === 'string') return String(data[label] ?? '')
68
- return null
69
- }
70
-
71
64
  /**
72
65
  * Pick white or dark text based on perceived luminance of a hex fill color.
73
66
  */
@@ -126,14 +119,14 @@
126
119
  function selectBar(bar: { data: Row }, event: MouseEvent | KeyboardEvent) {
127
120
  if (plotState.interactive)
128
121
  plotState.handleSelect(
129
- buildSelectDetail(
130
- bar.data,
131
- plotState.data.indexOf(bar.data),
132
- { x, y },
133
- 'bar',
134
- seriesField ? bar.data[seriesField] : undefined,
122
+ buildSelectDetail({
123
+ datum: bar.data,
124
+ index: plotState.data.indexOf(bar.data),
125
+ channels: { x, y },
126
+ geom: 'bar',
127
+ series: seriesField ? bar.data[seriesField] : undefined,
135
128
  event
136
- )
129
+ })
137
130
  )
138
131
  }
139
132
 
@@ -198,7 +191,7 @@
198
191
  />
199
192
  {/if}
200
193
  {#if label}
201
- {@const text = resolveLabel(bar.data, effectiveOrientation === 'horizontal' ? x : y)}
194
+ {@const text = resolveLabel(label, bar.data, effectiveOrientation === 'horizontal' ? x : y)}
202
195
  {#if text}
203
196
  {#if effectiveOrientation === 'horizontal'}
204
197
  {#if options.labelInside}
@@ -3,6 +3,7 @@
3
3
  import type { PlotState } from '../PlotState.svelte.js'
4
4
  import { resolveHighlight } from '../lib/highlight.js'
5
5
  import { scalePos } from '../lib/scale.js'
6
+ import { resolveLabel } from './lib/aesthetics.js'
6
7
 
7
8
  type Row = Record<string, unknown>
8
9
  type Props = {
@@ -15,49 +16,69 @@
15
16
 
16
17
  const state = getContext<PlotState>('plot-state')
17
18
 
18
- const marks = $derived.by(() => {
19
- const rows = state?.data ?? []
20
- const xs = state?.xScale
21
- const ys = state?.yScale
22
- if (!xs || !ys || !x || !y) return []
23
- const selectedRows = state?.selectedRows ?? []
24
- const selectedSet = new Set(selectedRows)
25
- const out = []
26
- const seen = new Set()
27
- if (highlight !== null && highlight !== undefined && rows.length) {
28
- for (const i of resolveHighlight(rows, highlight, { y })) {
29
- const row = rows[i]
30
- out.push({
31
- key: `h${i}`,
32
- cx: scalePos(xs, row[x]),
33
- cy: ys(Number(row[y])),
34
- row,
35
- // A statically-highlighted point that is also selected renders as
36
- // selected — selection is the stronger, interactive state.
37
- selected: selectedSet.has(row)
38
- })
39
- seen.add(row)
40
- }
19
+ type Mark = { key: string; cx: number; cy: number; row: Row; selected: boolean }
20
+ /** Everything both mark builders need, resolved once per derivation. */
21
+ type Ctx = {
22
+ rows: Row[]
23
+ selectedRows: Row[]
24
+ // Scales come from PlotState (untyped JS), so they stay loose here.
25
+ xs: (v: unknown) => number
26
+ ys: (v: number) => number
27
+ x: string
28
+ y: string
29
+ }
30
+
31
+ const hasScales = (s: PlotState | undefined) => Boolean(s?.xScale && s?.yScale)
32
+
33
+ /** Null until the plot has both scales and both channels — nothing can be placed before then. */
34
+ const ctx = $derived.by((): Ctx | null => {
35
+ if (!hasScales(state) || !x || !y) return null
36
+ return {
37
+ rows: state.data ?? [],
38
+ selectedRows: state.selectedRows ?? [],
39
+ xs: state.xScale,
40
+ ys: state.yScale,
41
+ x,
42
+ y
41
43
  }
42
- selectedRows.forEach((row, j) => {
43
- if (seen.has(row)) return
44
- out.push({
45
- key: `s${j}`,
46
- cx: scalePos(xs, row[x]),
47
- cy: ys(Number(row[y])),
48
- row,
49
- selected: true
50
- })
51
- })
52
- return out.filter((m) => Number.isFinite(m.cx) && Number.isFinite(m.cy))
53
44
  })
54
45
 
55
- function resolveLabel(row: Row): string | null {
56
- if (!label) return null
57
- if (label === true) return String((y ? row[y] : '') ?? '')
58
- if (typeof label === 'function') return String(label(row) ?? '')
59
- return String(row[label] ?? '')
46
+ const place = (c: Ctx, row: Row) => ({
47
+ cx: scalePos(c.xs, row[c.x]),
48
+ cy: c.ys(Number(row[c.y]))
49
+ })
50
+
51
+ /** Marks for the declarative `highlight` selector. */
52
+ function highlightMarks(c: Ctx, selector: Props['highlight']): Mark[] {
53
+ if (selector === null || selector === undefined) return []
54
+ const selected = new Set(c.selectedRows)
55
+ return resolveHighlight(c.rows, selector, { y: c.y }).map((i) => ({
56
+ key: `h${i}`,
57
+ ...place(c, c.rows[i]),
58
+ row: c.rows[i],
59
+ // A statically-highlighted point that is also selected renders as
60
+ // selected — selection is the stronger, interactive state.
61
+ selected: selected.has(c.rows[i])
62
+ }))
60
63
  }
64
+
65
+ /** Marks for interactive selection, minus any row a highlight mark already covers. */
66
+ function selectionMarks(c: Ctx, covered: Set<Row>): Mark[] {
67
+ return c.selectedRows
68
+ .map((row, j) => ({ row, j }))
69
+ .filter(({ row }) => !covered.has(row))
70
+ .map(({ row, j }) => ({ key: `s${j}`, ...place(c, row), row, selected: true }))
71
+ }
72
+
73
+ const isPlaced = (m: Mark) => Number.isFinite(m.cx) && Number.isFinite(m.cy)
74
+
75
+ const marks = $derived.by(() => {
76
+ if (!ctx) return []
77
+ const highlighted = highlightMarks(ctx, highlight)
78
+ // Derived from the marks just built, so "already emitted" has one source of truth.
79
+ const selected = selectionMarks(ctx, new Set(highlighted.map((m) => m.row)))
80
+ return [...highlighted, ...selected].filter(isPlaced)
81
+ })
61
82
  </script>
62
83
 
63
84
  {#if marks.length}
@@ -70,7 +91,7 @@
70
91
  data-plot-selected={m.selected ? 'true' : undefined}
71
92
  />
72
93
  {#if label}
73
- {@const text = resolveLabel(m.row)}
94
+ {@const text = resolveLabel(label, m.row, y)}
74
95
  {#if text}
75
96
  <text x={m.cx} y={m.cy} dy="-8" text-anchor="middle" data-plot-highlight-label
76
97
  >{text}</text
@@ -3,6 +3,7 @@
3
3
  import type { PlotState } from '../PlotState.svelte.js'
4
4
  import { GeomState } from './lib/GeomState.svelte.js'
5
5
  import { buildLineMarks } from './lib/marks/line.js'
6
+ import { resolveLabel } from './lib/aesthetics.js'
6
7
  import { buildSymbolPath } from '../lib/brewing/marks/points.js'
7
8
  import { keyboardNav } from '../lib/keyboard-nav.js'
8
9
  import { buildSelectDetail } from '../lib/select.js'
@@ -47,13 +48,6 @@
47
48
  keyboard = false
48
49
  }: Props = $props()
49
50
 
50
- function resolveLabel(data: Row): string | null {
51
- if (!label) return null
52
- if (label === true) return String((y ? data[y] : undefined) ?? '')
53
- if (typeof label === 'function') return String(label(data) ?? '')
54
- return typeof label === 'string' ? String(data[label] ?? '') : null
55
- }
56
-
57
51
  const plotState = getContext<PlotState>('plot-state')
58
52
 
59
53
  const geom = new GeomState(plotState, () => ({
@@ -81,7 +75,14 @@
81
75
  onselect?.(pt.data)
82
76
  if (plotState.interactive)
83
77
  plotState.handleSelect(
84
- buildSelectDetail(pt.data, plotState.data.indexOf(pt.data), { x, y }, 'line', seg?.key, event)
78
+ buildSelectDetail({
79
+ datum: pt.data,
80
+ index: plotState.data.indexOf(pt.data),
81
+ channels: { x, y },
82
+ geom: 'line',
83
+ series: seg?.key,
84
+ event
85
+ })
85
86
  )
86
87
  }
87
88
  </script>
@@ -113,7 +114,7 @@
113
114
  {/if}
114
115
  {#if label}
115
116
  {#each seg.points as pt (`label::${pt.i}`)}
116
- {@const text = resolveLabel(pt.data)}
117
+ {@const text = resolveLabel(label, pt.data, y)}
117
118
  {#if text}
118
119
  <LabelPill
119
120
  x={pt.x + (options.labelOffset?.x ?? 0)}
@@ -3,6 +3,7 @@
3
3
  import type { PlotState } from '../PlotState.svelte.js'
4
4
  import { GeomState } from './lib/GeomState.svelte.js'
5
5
  import { buildPointMarks } from './lib/marks/point.js'
6
+ import { resolveLabel } from './lib/aesthetics.js'
6
7
  import { keyboardNav } from '../lib/keyboard-nav.js'
7
8
  import { buildSelectDetail } from '../lib/select.js'
8
9
  import LabelPill from './LabelPill.svelte'
@@ -49,13 +50,6 @@
49
50
  keyboard = false
50
51
  }: Props = $props()
51
52
 
52
- function resolveLabel(data: Row): string | null {
53
- if (!label) return null
54
- if (label === true) return String((y ? data[y] : undefined) ?? '')
55
- if (typeof label === 'function') return String(label(data) ?? '')
56
- return typeof label === 'string' ? String(data[label] ?? '') : null
57
- }
58
-
59
53
  const plotState = getContext<PlotState>('plot-state')
60
54
 
61
55
  // All plumbing (register/data/scales/colors) + mark building (geometry + fill/color/alpha)
@@ -80,14 +74,14 @@
80
74
  onselect?.(pt.data)
81
75
  if (plotState.interactive)
82
76
  plotState.handleSelect(
83
- buildSelectDetail(
84
- pt.data,
85
- plotState.data.indexOf(pt.data),
86
- { x, y },
87
- 'point',
88
- seriesField ? pt.data[seriesField] : undefined,
77
+ buildSelectDetail({
78
+ datum: pt.data,
79
+ index: plotState.data.indexOf(pt.data),
80
+ channels: { x, y },
81
+ geom: 'point',
82
+ series: seriesField ? pt.data[seriesField] : undefined,
89
83
  event
90
- )
84
+ })
91
85
  )
92
86
  }
93
87
  </script>
@@ -141,7 +135,7 @@
141
135
  />
142
136
  {/if}
143
137
  {#if label}
144
- {@const text = resolveLabel(pt.data)}
138
+ {@const text = resolveLabel(label, pt.data, y)}
145
139
  {#if text}
146
140
  <LabelPill
147
141
  x={pt.cx + (options.labelOffset?.x ?? 0)}