@rokkit/chart 1.3.15 → 1.4.0

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 (44) hide show
  1. package/dist/Plot.svelte +25 -9
  2. package/dist/PlotState.svelte.d.ts +1 -0
  3. package/dist/PlotState.svelte.js +8 -1
  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 +59 -232
  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 +17 -2
  13. package/dist/geoms/Area.svelte.d.ts +2 -0
  14. package/dist/geoms/Bar.svelte +2 -9
  15. package/dist/geoms/Highlight.svelte +2 -7
  16. package/dist/geoms/Line.svelte +2 -8
  17. package/dist/geoms/Point.svelte +2 -8
  18. package/dist/geoms/Radar.svelte +352 -0
  19. package/dist/geoms/Radar.svelte.d.ts +42 -0
  20. package/dist/geoms/lib/aesthetics.d.ts +14 -0
  21. package/dist/geoms/lib/aesthetics.js +21 -0
  22. package/dist/geoms/lib/areas.d.ts +9 -2
  23. package/dist/geoms/lib/areas.js +53 -8
  24. package/dist/geoms/lib/marks/area.d.ts +3 -0
  25. package/dist/geoms/lib/marks/area.js +11 -2
  26. package/dist/geoms/lib/marks/radar.d.ts +68 -0
  27. package/dist/geoms/lib/marks/radar.js +153 -0
  28. package/dist/index.d.ts +6 -0
  29. package/dist/index.js +7 -1
  30. package/dist/lib/brewing/marks/boxes.js +19 -30
  31. package/dist/lib/brewing/polar.d.ts +433 -0
  32. package/dist/lib/brewing/polar.js +584 -0
  33. package/dist/lib/plot/scales.js +8 -5
  34. package/dist/lib/preset.d.ts +1 -0
  35. package/dist/lib/preset.js +2 -1
  36. package/dist/lib/select.d.ts +3 -3
  37. package/dist/lib/select.js +1 -1
  38. package/package.json +1 -1
  39. package/dist/examples/BarChartExample.svelte +0 -78
  40. package/dist/examples/BarChartExample.svelte.d.ts +0 -18
  41. package/dist/lib/brewing/types.d.ts +0 -162
  42. package/dist/lib/brewing/types.js +0 -73
  43. package/dist/lib/xscale.d.ts +0 -11
  44. package/dist/lib/xscale.js +0 -31
@@ -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;
@@ -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
- * @returns {{ d: string, fill: string, stroke: string, key: unknown, patternId: string|null }[]}
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.
@@ -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
- * @returns {{ d: string, fill: string, stroke: string, key: unknown, patternId: string|null }[]}
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
- const baseline = yScale.range()[0] // bottom of the chart (y pixel max)
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
- // `v` carries the raw y-value so the generator can break the area at a gap (null).
32
- const toEdge = (d) => ({ base: place(xPos(d), baseline), top: place(xPos(d), yScale(d[yf])), v: d[yf] })
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
- return [{ d: makeGen()(sortByX(data).map(toEdge)), fill: entry.fill, stroke: 'none', key: null, patternId }]
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()].map(([key, rows], i) => {
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 { d: makeGen()(sortByX(rows).map(toEdge)), fill: entry.fill, stroke: 'none', key, patternId }
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 }: {
@@ -7,6 +7,9 @@ import { resolveAlpha } from '../aesthetics.js'
7
7
  * are set — so an old `color`-only area stays border-less (backward compatible); the
8
8
  * border uses the interior group's darker stroke shade. Per-field border coloring
9
9
  * (fill ≠ color, two legends) is the deferred dual-scale follow-up.
10
+ * `options.baseline`, when set, is passed straight to `buildAreas` (identity-position charts
11
+ * only — stacked/100% areas ignore it) and splits each segment into signed above/below copies
12
+ * (#148).
10
13
  * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
11
14
  */
12
15
  export function buildAreaMarks({ data, plot, channels, options = {}, alpha, type = 'area' }) {
@@ -27,12 +30,18 @@ export function buildAreaMarks({ data, plot, channels, options = {}, alpha, type
27
30
  const stacked = position === 'stack' || position === 'fill'
28
31
  const raw = stacked
29
32
  ? buildStackedAreas(data, areaChannels, xScale, yScale, colors, options.curve, patterns, place, position === 'fill')
30
- : buildAreas(data, areaChannels, xScale, yScale, colors, options.curve, patterns, place)
33
+ : buildAreas(data, areaChannels, xScale, yScale, colors, options.curve, patterns, place, options.baseline)
31
34
 
32
35
  const a = resolveAlpha(alpha, type, plot.chartPreset)
33
36
  return raw.map((seg) => ({
37
+ // `d`/`fill`/`key`/`patternId`/`sign` all flow through unchanged via this spread;
38
+ // only `stroke` and `alpha` are actually recomputed below.
34
39
  ...seg,
35
- stroke: hasBorder ? (colors.get(seg.key)?.stroke ?? 'none') : 'none',
40
+ // `seg.groupKey` (when present) is the raw, unsigned group value — a baseline split
41
+ // gives `seg.key` a sign suffix, so looking the border color up by `seg.key` directly
42
+ // would miss for every signed segment. `buildStackedAreas` segments don't carry a
43
+ // groupKey (they're never signed), so `seg.key` there is already the raw colorKey.
44
+ stroke: hasBorder ? (colors.get(seg.groupKey ?? seg.key)?.stroke ?? 'none') : 'none',
36
45
  alpha: a
37
46
  }))
38
47
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The one definition of radar's outer radius and which form it is drawing.
3
+ *
4
+ * Both `Radar.svelte` (grid, spokes, labels, vertices) and `buildRadarMarks` (the
5
+ * polygons) need this, and they used to derive it separately from the same
6
+ * `innerWidth`/`innerHeight` — two copies of one rule, free to drift apart. A test had to
7
+ * pin the outermost grid ring to the polygon's own radius to catch that drift; this
8
+ * removes the possibility instead of policing it.
9
+ *
10
+ * The micro form draws no labels, so it spends the whole half-extent on the polygon.
11
+ * Reserving the label margin there is what collapsed it: a Spark is 80×24, so the
12
+ * half-extent is 12px against a 32px margin, giving R = 0 and an invisible glyph.
13
+ *
14
+ * @param {number} innerWidth
15
+ * @param {number} innerHeight
16
+ * @returns {{ R: number, micro: boolean }}
17
+ */
18
+ export function resolveRadarRadius(innerWidth: number, innerHeight: number): {
19
+ R: number;
20
+ micro: boolean;
21
+ };
22
+ /**
23
+ * Build renderable radar/spider polygon marks: one closed polygon per series, split into a
24
+ * fill-only copy and a stroke-only copy so every series' fill can be drawn before any series'
25
+ * stroke (see the return-order note below).
26
+ *
27
+ * Geometry comes from the pure `buildRadarLayout` (shared with the `Spark` radar form); this
28
+ * adapter's job is purely the Svelte/Plot-facing plumbing: deriving the outer radius from the
29
+ * plot's actual pixel size, resolving each series' fill/stroke/pattern through the shared
30
+ * palette, and applying the geom's alpha.
31
+ *
32
+ * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
33
+ * @returns {{ d: string, fill: string, stroke: string, alpha: number, key: string, patternId: string|null }[]}
34
+ * Fill marks (stroke: 'none') for every series, followed by stroke marks (fill: 'none') for
35
+ * every series — never interleaved. A smaller series nested inside a larger one would have its
36
+ * outline buried under the next series' fill if fills and strokes were paired up per series
37
+ * instead, so paint order is fills-then-strokes across the WHOLE series set, not per series.
38
+ */
39
+ export function buildRadarMarks({ data, plot, channels, options, alpha, type }: {
40
+ data: any[];
41
+ plot: any;
42
+ channels: any;
43
+ options?: any;
44
+ alpha?: number;
45
+ type?: string;
46
+ }): {
47
+ d: string;
48
+ fill: string;
49
+ stroke: string;
50
+ alpha: number;
51
+ key: string;
52
+ patternId: string | null;
53
+ }[];
54
+ export const LABEL_MARGIN: 32;
55
+ /**
56
+ * Smallest outer radius at which the full form is actually usable: 24px is the minimum
57
+ * reachable hit target, and a vertex sits ON the outer radius, so below this the chrome
58
+ * is unreadable and the targets unreachable.
59
+ */
60
+ export const MIN_PLOT_RADIUS: 24;
61
+ /**
62
+ * Below this extent (the smaller of width/height) there is no room for BOTH the label
63
+ * margin and a usable plot radius, so radar drops to its micro form.
64
+ */
65
+ export const MICRO_THRESHOLD: number;
66
+ /** Legible axis-count range for the micro form — see the warn in `buildRadarMarks`. */
67
+ export const MICRO_MIN_AXES: 3;
68
+ export const MICRO_MAX_AXES: 5;
@@ -0,0 +1,153 @@
1
+ import { lineRadial, curveLinearClosed } from 'd3-shape'
2
+ import { buildRadarLayout } from '../../../lib/brewing/polar.js'
3
+ import { resolveFillStroke, resolveAlpha } from '../aesthetics.js'
4
+ import { toPatternId } from '../../../lib/brewing/patterns.js'
5
+
6
+ // Space reserved outside the outer ring for axis labels, in pixels. `buildArcs` (the pie/donut
7
+ // adapter) reserves none because a pie's labels sit INSIDE the wedge; a radar's axis labels sit
8
+ // OUTSIDE the outer ring (beyond the last vertex), so the plotted radius has to stop short of
9
+ // `min(width, height) / 2` or a spoke's label collides with the plot's own edge. This is a fixed
10
+ // pixel budget (rather than a fraction of R) because label text is a roughly fixed size
11
+ // regardless of how big the plot is.
12
+ export const LABEL_MARGIN = 32
13
+
14
+ /**
15
+ * Smallest outer radius at which the full form is actually usable: 24px is the minimum
16
+ * reachable hit target, and a vertex sits ON the outer radius, so below this the chrome
17
+ * is unreadable and the targets unreachable.
18
+ */
19
+ export const MIN_PLOT_RADIUS = 24
20
+
21
+ /**
22
+ * Below this extent (the smaller of width/height) there is no room for BOTH the label
23
+ * margin and a usable plot radius, so radar drops to its micro form.
24
+ */
25
+ export const MICRO_THRESHOLD = 2 * (LABEL_MARGIN + MIN_PLOT_RADIUS)
26
+
27
+ /** Legible axis-count range for the micro form — see the warn in `buildRadarMarks`. */
28
+ export const MICRO_MIN_AXES = 3
29
+ export const MICRO_MAX_AXES = 5
30
+
31
+ /**
32
+ * The one definition of radar's outer radius and which form it is drawing.
33
+ *
34
+ * Both `Radar.svelte` (grid, spokes, labels, vertices) and `buildRadarMarks` (the
35
+ * polygons) need this, and they used to derive it separately from the same
36
+ * `innerWidth`/`innerHeight` — two copies of one rule, free to drift apart. A test had to
37
+ * pin the outermost grid ring to the polygon's own radius to catch that drift; this
38
+ * removes the possibility instead of policing it.
39
+ *
40
+ * The micro form draws no labels, so it spends the whole half-extent on the polygon.
41
+ * Reserving the label margin there is what collapsed it: a Spark is 80×24, so the
42
+ * half-extent is 12px against a 32px margin, giving R = 0 and an invisible glyph.
43
+ *
44
+ * @param {number} innerWidth
45
+ * @param {number} innerHeight
46
+ * @returns {{ R: number, micro: boolean }}
47
+ */
48
+ export function resolveRadarRadius(innerWidth, innerHeight) {
49
+ const extent = Math.min(innerWidth, innerHeight)
50
+ const micro = extent < MICRO_THRESHOLD
51
+ const half = extent / 2
52
+ return { R: Math.max(0, micro ? half : half - LABEL_MARGIN), micro }
53
+ }
54
+
55
+ /**
56
+ * Converts `polar.js`'s angle convention (degrees, -90 = top, increasing clockwise) to
57
+ * `lineRadial`'s (radians, 0 = top/12 o'clock, increasing clockwise).
58
+ *
59
+ * Both conventions already agree on the direction of travel (clockwise), so the only
60
+ * difference is where zero sits: polar.js's zero (3 o'clock) is a quarter turn AFTER
61
+ * lineRadial's zero (12 o'clock). Rotating polar.js's angle forward by the 90° between
62
+ * them, then converting to radians, aligns the two: `-90°` (polar.js's top) maps to `0`
63
+ * (lineRadial's top).
64
+ *
65
+ * @param {number} degrees - polar.js convention, -90 = top
66
+ * @returns {number} radians, 0 = top, for `lineRadial`'s `.angle()`
67
+ */
68
+ function toRadialAngle(degrees) {
69
+ return ((degrees + 90) * Math.PI) / 180
70
+ }
71
+
72
+ /**
73
+ * Build renderable radar/spider polygon marks: one closed polygon per series, split into a
74
+ * fill-only copy and a stroke-only copy so every series' fill can be drawn before any series'
75
+ * stroke (see the return-order note below).
76
+ *
77
+ * Geometry comes from the pure `buildRadarLayout` (shared with the `Spark` radar form); this
78
+ * adapter's job is purely the Svelte/Plot-facing plumbing: deriving the outer radius from the
79
+ * plot's actual pixel size, resolving each series' fill/stroke/pattern through the shared
80
+ * palette, and applying the geom's alpha.
81
+ *
82
+ * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
83
+ * @returns {{ d: string, fill: string, stroke: string, alpha: number, key: string, patternId: string|null }[]}
84
+ * Fill marks (stroke: 'none') for every series, followed by stroke marks (fill: 'none') for
85
+ * every series — never interleaved. A smaller series nested inside a larger one would have its
86
+ * outline buried under the next series' fill if fills and strokes were paired up per series
87
+ * instead, so paint order is fills-then-strokes across the WHOLE series set, not per series.
88
+ */
89
+ export function buildRadarMarks({ data, plot, channels, options = {}, alpha, type = 'radar' }) {
90
+ if (!data?.length || !channels?.x || !channels?.y) return []
91
+
92
+ const { innerWidth, innerHeight, colors, patterns, chartPreset } = plot
93
+
94
+ const { R, micro } = resolveRadarRadius(innerWidth, innerHeight)
95
+
96
+ const layout = buildRadarLayout(data, channels, {
97
+ axes: options.axes,
98
+ sharedDomain: options.sharedDomain,
99
+ rings: options.rings,
100
+ radiusScale: options.radiusScale,
101
+ R
102
+ })
103
+
104
+ // The micro form is read as a gestalt shape, not measured: below 3 axes there is no area
105
+ // to perceive, and above 5 at glyph size the spokes collapse into an unreadable star.
106
+ // Warned rather than clamped — silently dropping a caller's axis would change what the
107
+ // glyph asserts about the data. Lives here, alongside polar.js's own dev warns, rather
108
+ // than in Radar.svelte, so the component stays render-only.
109
+ if (micro && (layout.axes.length < MICRO_MIN_AXES || layout.axes.length > MICRO_MAX_AXES))
110
+ // eslint-disable-next-line no-console
111
+ console.warn(
112
+ `[Radar] ${layout.axes.length} axes in a micro (Spark-sized) radar — ` +
113
+ `${MICRO_MIN_AXES}–${MICRO_MAX_AXES} is the legible range at glyph size. ` +
114
+ 'The shape is meant to be read at a glance, not measured.'
115
+ )
116
+
117
+ const radial = lineRadial()
118
+ .angle((p) => p.angleRad)
119
+ .radius((p) => p.radius)
120
+ .defined((p) => p.radius !== null)
121
+ .curve(curveLinearClosed)
122
+
123
+ const a = resolveAlpha(alpha, type, chartPreset)
124
+
125
+ const fills = []
126
+ const strokes = []
127
+
128
+ for (const [seriesKey, vertices] of layout.series) {
129
+ const points = vertices.map((vertex, i) => ({
130
+ angleRad: toRadialAngle(layout.angles[i]),
131
+ radius: vertex ? vertex.radius : null
132
+ }))
133
+ const d = radial(points)
134
+
135
+ // One representative row for this series — mirrors the pattern `buildLines` uses for
136
+ // its grouped path — so the shared fill/stroke resolution reads real row data instead
137
+ // of a synthetic aggregate.
138
+ const repRow = vertices.find((vertex) => vertex)?.row ?? {}
139
+ const { fill, stroke } = resolveFillStroke(repRow, channels, colors)
140
+
141
+ const patternKey = channels.pattern ? repRow[channels.pattern] : null
142
+ const patternId =
143
+ patternKey !== null && patternKey !== undefined && patterns?.has(patternKey)
144
+ ? toPatternId(String(patternKey))
145
+ : null
146
+
147
+ const keyBase = String(seriesKey)
148
+ fills.push({ d, fill, stroke: 'none', alpha: a, key: `${keyBase}::fill`, patternId })
149
+ strokes.push({ d, fill: 'none', stroke, alpha: 1, key: `${keyBase}::stroke`, patternId: null })
150
+ }
151
+
152
+ return [...fills, ...strokes]
153
+ }
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export namespace Plot {
19
19
  export { Ribbon };
20
20
  export { Waterfall };
21
21
  export { Rule };
22
+ export { Radar };
22
23
  }
23
24
  export namespace PlotLayers { }
24
25
  export { default as PlotChart } from "./Plot.svelte";
@@ -32,6 +33,7 @@ export { default as GeomLine } from "./geoms/Line.svelte";
32
33
  export { default as GeomArea } from "./geoms/Area.svelte";
33
34
  export { default as GeomPoint } from "./geoms/Point.svelte";
34
35
  export { default as GeomArc } from "./geoms/Arc.svelte";
36
+ export { default as GeomRadar } from "./geoms/Radar.svelte";
35
37
  export { default as GeomBox } from "./geoms/Box.svelte";
36
38
  export { default as GeomViolin } from "./geoms/Violin.svelte";
37
39
  export { default as GeomHeatmap } from "./geoms/Heatmap.svelte";
@@ -45,10 +47,12 @@ export { default as GeomJitter } from "./geoms/Jitter.svelte";
45
47
  export { default as GeomRule } from "./geoms/Rule.svelte";
46
48
  export { default as Chart } from "./Chart.svelte";
47
49
  export { default as Sparkline } from "./Sparkline.svelte";
50
+ export { default as Spark } from "./Spark.svelte";
48
51
  export { default as BarChart } from "./charts/BarChart.svelte";
49
52
  export { default as LineChart } from "./charts/LineChart.svelte";
50
53
  export { default as AreaChart } from "./charts/AreaChart.svelte";
51
54
  export { default as PieChart } from "./charts/PieChart.svelte";
55
+ export { default as RadarChart } from "./charts/RadarChart.svelte";
52
56
  export { default as ScatterPlot } from "./charts/ScatterPlot.svelte";
53
57
  export { default as BoxPlot } from "./charts/BoxPlot.svelte";
54
58
  export { default as ViolinPlot } from "./charts/ViolinPlot.svelte";
@@ -79,5 +83,7 @@ import Hexbin from './geoms/Hexbin.svelte';
79
83
  import Ribbon from './geoms/Ribbon.svelte';
80
84
  import Waterfall from './geoms/Waterfall.svelte';
81
85
  import Rule from './geoms/Rule.svelte';
86
+ import Radar from './geoms/Radar.svelte';
82
87
  export { createChartPreset, defaultPreset } from "./lib/preset.js";
88
+ export { SparkState, GEOM_CONTRACT } from "./SparkState.svelte.js";
83
89
  export { buildSequentialScale, buildDivergingScale } from "./lib/brewing/colors.js";
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import Hexbin from './geoms/Hexbin.svelte'
18
18
  import Ribbon from './geoms/Ribbon.svelte'
19
19
  import Waterfall from './geoms/Waterfall.svelte'
20
20
  import Rule from './geoms/Rule.svelte'
21
+ import Radar from './geoms/Radar.svelte'
21
22
 
22
23
  // Composable Plot primitives — use as <Plot.Root>, <Plot.Axis>, <Plot.Bar>, etc.
23
24
  export const Plot = {
@@ -40,7 +41,8 @@ export const Plot = {
40
41
  Hexbin,
41
42
  Ribbon,
42
43
  Waterfall,
43
- Rule
44
+ Rule,
45
+ Radar
44
46
  }
45
47
 
46
48
  /** @deprecated Use Plot instead */
@@ -63,6 +65,7 @@ export { default as GeomLine } from './geoms/Line.svelte'
63
65
  export { default as GeomArea } from './geoms/Area.svelte'
64
66
  export { default as GeomPoint } from './geoms/Point.svelte'
65
67
  export { default as GeomArc } from './geoms/Arc.svelte'
68
+ export { default as GeomRadar } from './geoms/Radar.svelte'
66
69
  export { default as GeomBox } from './geoms/Box.svelte'
67
70
  export { default as GeomViolin } from './geoms/Violin.svelte'
68
71
  export { default as GeomHeatmap } from './geoms/Heatmap.svelte'
@@ -78,10 +81,12 @@ export { default as GeomRule } from './geoms/Rule.svelte'
78
81
  // Export standalone components
79
82
  export { default as Chart } from './Chart.svelte'
80
83
  export { default as Sparkline } from './Sparkline.svelte'
84
+ export { default as Spark } from './Spark.svelte'
81
85
  export { default as BarChart } from './charts/BarChart.svelte'
82
86
  export { default as LineChart } from './charts/LineChart.svelte'
83
87
  export { default as AreaChart } from './charts/AreaChart.svelte'
84
88
  export { default as PieChart } from './charts/PieChart.svelte'
89
+ export { default as RadarChart } from './charts/RadarChart.svelte'
85
90
  export { default as ScatterPlot } from './charts/ScatterPlot.svelte'
86
91
  export { default as BoxPlot } from './charts/BoxPlot.svelte'
87
92
  export { default as ViolinPlot } from './charts/ViolinPlot.svelte'
@@ -89,6 +94,7 @@ export { default as BubbleChart } from './charts/BubbleChart.svelte'
89
94
 
90
95
  // Export state and types
91
96
  export { PlotState } from './PlotState.svelte.js'
97
+ export { SparkState, GEOM_CONTRACT } from './SparkState.svelte.js'
92
98
 
93
99
  // Export utilities
94
100
  export { buildSequentialScale, buildDivergingScale } from './lib/brewing/colors.js'
@@ -37,6 +37,23 @@ export function buildBoxes(data, channels, xScale, yScale, colors, opts = {}) {
37
37
  const key = pf ? d[pf] : null
38
38
  return key !== null && key !== undefined && patterns?.has(key) ? toPatternId(String(key)) : null
39
39
  }
40
+ // Every box shares the same vertical quartile geometry; only horizontal placement,
41
+ // thickness and colour differ between the grouped and non-grouped branches.
42
+ const toBox = (d, { cx, boxWidth, whiskerWidth, colorEntry }) => ({
43
+ data: d,
44
+ cx,
45
+ q1: yScale(d.q1),
46
+ median: yScale(d.median),
47
+ q3: yScale(d.q3),
48
+ iqr_min: yScale(d.iqr_min),
49
+ iqr_max: yScale(d.iqr_max),
50
+ outliers: (d.outliers ?? []).map((v) => ({ cy: yScale(v), value: v })),
51
+ width: boxWidth,
52
+ whiskerWidth,
53
+ fill: colorEntry.fill,
54
+ stroke: colorEntry.stroke,
55
+ patternId: patternIdFor(d)
56
+ })
40
57
 
41
58
  if (grouped) {
42
59
  const fillValues = [...new Set(data.map((d) => d[ff]))]
@@ -52,21 +69,7 @@ export function buildBoxes(data, channels, xScale, yScale, colors, opts = {}) {
52
69
  const cx = bandStart + subIndex * subBandWidth + subBandWidth / 2 + laneOffset(subBandWidth)
53
70
  const colorEntry = markEntry(lit, fillVal, colors, { fill: '#aaa', stroke: '#666' })
54
71
 
55
- return {
56
- data: d,
57
- cx,
58
- q1: yScale(d.q1),
59
- median: yScale(d.median),
60
- q3: yScale(d.q3),
61
- iqr_min: yScale(d.iqr_min),
62
- iqr_max: yScale(d.iqr_max),
63
- outliers: (d.outliers ?? []).map((v) => ({ cy: yScale(v), value: v })),
64
- width: boxWidth,
65
- whiskerWidth,
66
- fill: colorEntry.fill,
67
- stroke: colorEntry.stroke,
68
- patternId: patternIdFor(d)
69
- }
72
+ return toBox(d, { cx, boxWidth, whiskerWidth, colorEntry })
70
73
  })
71
74
  }
72
75
 
@@ -81,20 +84,6 @@ export function buildBoxes(data, channels, xScale, yScale, colors, opts = {}) {
81
84
  (xScale(d[xf]) ?? 0) +
82
85
  (typeof xScale.bandwidth === 'function' ? bw / 2 : 0) +
83
86
  laneOffset(bw)
84
- return {
85
- data: d,
86
- cx,
87
- q1: yScale(d.q1),
88
- median: yScale(d.median),
89
- q3: yScale(d.q3),
90
- iqr_min: yScale(d.iqr_min),
91
- iqr_max: yScale(d.iqr_max),
92
- outliers: (d.outliers ?? []).map((v) => ({ cy: yScale(v), value: v })),
93
- width: boxWidth,
94
- whiskerWidth,
95
- fill: colorEntry.fill,
96
- stroke: colorEntry.stroke,
97
- patternId: patternIdFor(d)
98
- }
87
+ return toBox(d, { cx, boxWidth, whiskerWidth, colorEntry })
99
88
  })
100
89
  }