@rokkit/chart 1.3.9 → 1.3.11

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 CHANGED
@@ -43,6 +43,8 @@
43
43
  summary?: string
44
44
  tooltip?: boolean | ((data: Row) => string)
45
45
  zoom?: boolean
46
+ /** Force chart direction (else inferred). Also settable via `spec.orientation`. */
47
+ orientation?: 'horizontal' | 'vertical'
46
48
  xFormat?: (v: unknown) => string
47
49
  yFormat?: (v: unknown) => string
48
50
  xTicks?: number
@@ -78,6 +80,7 @@
78
80
  summary = '',
79
81
  tooltip = false,
80
82
  zoom = false,
83
+ orientation = undefined,
81
84
  xFormat = undefined,
82
85
  yFormat = undefined,
83
86
  xTicks = undefined,
@@ -138,7 +141,7 @@
138
141
  colorScale: spec?.colorScale,
139
142
  colorScheme: spec?.colorScheme,
140
143
  colorMidpoint: spec?.colorMidpoint,
141
- orientation: spec?.orientation,
144
+ orientation: orientation ?? spec?.orientation,
142
145
  sort: spec?.sort,
143
146
  continuousCategory: spec?.continuousCategory,
144
147
  chartPreset,
@@ -26,6 +26,8 @@ type Props = {
26
26
  summary?: string;
27
27
  tooltip?: boolean | ((data: Row) => string);
28
28
  zoom?: boolean;
29
+ /** Force chart direction (else inferred). Also settable via `spec.orientation`. */
30
+ orientation?: 'horizontal' | 'vertical';
29
31
  xFormat?: (v: unknown) => string;
30
32
  yFormat?: (v: unknown) => string;
31
33
  xTicks?: number;
@@ -261,6 +261,11 @@ export class PlotState {
261
261
  // Mirror buildStackedBars/subBandFields (group=fill first, then color, then pattern).
262
262
  const stackField =
263
263
  [fillField, colorField, patternField].find((f) => f && f !== xField) ?? (fillField ?? colorField)
264
+ // No grouping field → buildStackedBars falls back to buildBars (individual bars, no
265
+ // actual stacking), so there's no stacked total to size to. Bail to the normal value
266
+ // extent; otherwise every row collapses to the same cKey below and the lookup's set()
267
+ // overwrites, shrinking the domain to the last row per x — and the bars overflow.
268
+ if (!stackField) return null
264
269
  const lookup = new SvelteMap()
265
270
  for (const d of stackData) {
266
271
  const xVal = d[xField]
@@ -278,6 +283,31 @@ export class PlotState {
278
283
  return [0, Math.max(0, ...totals.values())]
279
284
  }
280
285
 
286
+ // Waterfall bars sit at the RUNNING TOTAL, not the per-step value — so the y-domain must
287
+ // span the cumulative range, else bars overflow the axis. Mirrors buildWaterfallMarks.
288
+ #resolveWaterfallDomain(field) {
289
+ const geom = this.#geoms.find((g) => g.type === 'waterfall')
290
+ if (!geom) return null
291
+ const data = this.geomData(geom.id)
292
+ if (data.length === 0) return null
293
+ const totalField = geom.options?.totalField
294
+ let cumulative = 0
295
+ let min = 0
296
+ let max = 0
297
+ for (const d of data) {
298
+ if (totalField && d[totalField]) {
299
+ min = Math.min(min, 0, cumulative)
300
+ max = Math.max(max, 0, cumulative)
301
+ } else {
302
+ const start = cumulative
303
+ cumulative += Number(d[field]) || 0
304
+ min = Math.min(min, start, cumulative)
305
+ max = Math.max(max, start, cumulative)
306
+ }
307
+ }
308
+ return [min, max]
309
+ }
310
+
281
311
  yScale = $derived.by(() => {
282
312
  const field = this.#effectiveChannels.y
283
313
  if (!field) return null
@@ -286,7 +316,11 @@ export class PlotState {
286
316
  // includeZero applies to the VALUE axis: vertical charts (value on y) and flipped
287
317
  // charts (value still y, but now the horizontal screen axis) both want a 0 baseline.
288
318
  const includeZero = this.orientation === 'vertical' || this.#flipped
289
- const yDomain = this.#yDomain ?? this.#resolveBoxDomain() ?? this.#resolveStackDomain(field)
319
+ const yDomain =
320
+ this.#yDomain ??
321
+ this.#resolveBoxDomain() ??
322
+ this.#resolveStackDomain(field) ??
323
+ this.#resolveWaterfallDomain(field)
290
324
  // Flip: the value (y) axis runs along the horizontal screen → range over width.
291
325
  const range = this.#flipped ? [0, this.#innerWidth] : undefined
292
326
  const base = buildUnifiedYScale(datasets, field, this.#innerHeight, { domain: yDomain, includeZero, range })
@@ -1,5 +1,6 @@
1
1
  import { area, stack, stackOffsetExpand, curveCatmullRom, curveStep } from 'd3-shape'
2
2
  import { toPatternId } from '../../lib/brewing/patterns.js'
3
+ import { literalColor, markEntry } from '../../lib/brewing/colors.js'
3
4
 
4
5
  /**
5
6
  * Builds area path geometry for multi-series area charts.
@@ -15,16 +16,25 @@ import { toPatternId } from '../../lib/brewing/patterns.js'
15
16
  * @returns {{ d: string, fill: string, stroke: string, key: unknown, patternId: string|null }[]}
16
17
  */
17
18
  export function buildAreas(data, channels, xScale, yScale, colors, curve, patterns, place = (x, y) => ({ x, y })) {
18
- const { x: xf, y: yf, color: cf, pattern: pf } = channels
19
+ const { x: xf, y: yf, pattern: pf } = channels
20
+ // A literal color (var()/oklch()/hex/currentColor) isn't a data field — it's the exact
21
+ // fill the caller wants for THIS area. Don't group on it, and don't fall back to the
22
+ // shared color scale (one value across all geoms); use it directly.
23
+ const lit = literalColor(channels.color)
24
+ const cf = lit ? undefined : channels.color
19
25
  const baseline = yScale.range()[0] // bottom of the chart (y pixel max)
20
26
 
21
27
  const xPos = (d) =>
22
28
  typeof xScale.bandwidth === 'function' ? xScale(d[xf]) + xScale.bandwidth() / 2 : xScale(d[xf])
23
29
 
24
30
  // Each area edge (baseline + top) is placed so the area transposes under orientation.
25
- const toEdge = (d) => ({ base: place(xPos(d), baseline), top: place(xPos(d), yScale(d[yf])) })
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] })
26
33
  const makeGen = () => {
27
34
  const gen = area()
35
+ // A null y-value is a GAP (a missing period): break the area there instead of
36
+ // dropping it onto the 0 baseline. A genuine 0 stays defined and fills.
37
+ .defined((p) => Number.isFinite(p.top.y) && (yf === undefined || yf === null || (p.v !== undefined && p.v !== null)))
28
38
  .x0((p) => p.base.x)
29
39
  .y0((p) => p.base.y)
30
40
  .x1((p) => p.top.x)
@@ -45,7 +55,8 @@ export function buildAreas(data, channels, xScale, yScale, colors, curve, patter
45
55
  }
46
56
 
47
57
  if (!cf) {
48
- const entry = colors?.values().next().value ?? { fill: '#888', stroke: '#888' }
58
+ // A literal color IS the fill; a single-series field uses the shared shade.
59
+ const entry = markEntry(lit, undefined, colors, colors?.values().next().value ?? { fill: '#888', stroke: '#888' })
49
60
  const patternKey = pf ? data[0]?.[pf] : null
50
61
  const patternId =
51
62
  patternKey !== null && patternKey !== undefined && patterns?.has(patternKey)
@@ -1,7 +1,7 @@
1
1
  import { scaleBand } from 'd3-scale'
2
2
  import { stack, stackOffsetExpand } from 'd3-shape'
3
3
  import { toPatternId } from '../../lib/brewing/patterns.js'
4
- import { isLiteralColor } from '../../lib/brewing/colors.js'
4
+ import { isLiteralColor, literalColor, markEntry } from '../../lib/brewing/colors.js'
5
5
 
6
6
  /**
7
7
  * Returns a band scale suitable for bar x-positioning.
@@ -57,6 +57,9 @@ export function buildBars(
57
57
  dodge = true
58
58
  ) {
59
59
  const { x: xf, y: yf, color: cf, pattern: pf } = channels
60
+ // A literal color is the exact fill for every bar; a field indexes the shared color scale.
61
+ const lit = literalColor(cf)
62
+ const field = lit ? undefined : cf
60
63
 
61
64
  // No sub-banding for a continuous (race) axis or position='identity' (dodge=false → overlap).
62
65
  const subFields = continuousCategory || !dodge ? [] : subBandFields(channels)
@@ -96,12 +99,16 @@ export function buildBars(
96
99
 
97
100
  return data.map((d, i) => {
98
101
  const xVal = d[xf]
99
- const colorKey = cf ? d[cf] : null
102
+ const colorKey = field ? d[field] : null
100
103
  const patternKey = pf ? d[pf] : null
101
104
  const subKey = getSubKey(d)
102
105
 
103
- const colorEntry = colors?.get(colorKey) ??
106
+ const colorEntry = markEntry(
107
+ lit,
108
+ colorKey,
109
+ colors,
104
110
  colors?.values().next().value ?? { fill: '#888', stroke: '#888' }
111
+ )
105
112
  const patternId =
106
113
  patternKey !== null && patternKey !== undefined && patterns?.has(patternKey)
107
114
  ? toPatternId(String(patternKey))
@@ -136,6 +143,8 @@ export function buildBars(
136
143
 
137
144
  export function buildStackedBars(data, channels, xScale, yScale, colors, innerHeight, patterns, place = (x, y) => ({ x, y }), normalize = false) {
138
145
  const { x: xf, y: yf, color: cf, pattern: pf } = channels
146
+ const lit = literalColor(cf)
147
+ const field = lit ? undefined : cf
139
148
 
140
149
  const bandScale = ensureBandX(xScale, data, xf)
141
150
 
@@ -175,8 +184,8 @@ export function buildStackedBars(data, channels, xScale, yScale, colors, innerHe
175
184
  const xVal = point.data[xf]
176
185
 
177
186
  // Color lookup: cf may equal xf (= xVal) or stackField (= stackKey)
178
- const colorKey = cf ? (cf === xf ? xVal : cf === stackField ? stackKey : null) : null
179
- const colorEntry = colors?.get(colorKey) ?? { fill: '#888', stroke: '#888' }
187
+ const colorKey = field ? (field === xf ? xVal : field === stackField ? stackKey : null) : null
188
+ const colorEntry = markEntry(lit, colorKey, colors, { fill: '#888', stroke: '#888' })
180
189
 
181
190
  // Pattern lookup: pf may equal xf (= xVal) or stackField (= stackKey)
182
191
  const patternKey = pf ? (pf === xf ? xVal : pf === stackField ? stackKey : null) : null
@@ -185,13 +194,19 @@ export function buildStackedBars(data, channels, xScale, yScale, colors, innerHe
185
194
  ? toPatternId(String(patternKey))
186
195
  : null
187
196
 
197
+ // Two opposite corners in (band, value) space, mapped through place() so the
198
+ // stack transposes under orientation='horizontal' (identity default → unchanged).
199
+ // Without this a grouped stack/fill ignored place() and always drew vertically.
200
+ const band = bandScale(xVal) ?? 0
201
+ const c1 = place(band, yScale(y0))
202
+ const c2 = place(band + bandScale.bandwidth(), yScale(y1))
188
203
  bars.push({
189
204
  data: point.data,
190
205
  key: `${String(xVal)}::${String(stackKey)}`,
191
- x: bandScale(xVal) ?? 0,
192
- y: yScale(y1),
193
- width: bandScale.bandwidth(),
194
- height: yScale(y0) - yScale(y1),
206
+ x: Math.min(c1.x, c2.x),
207
+ y: Math.min(c1.y, c2.y),
208
+ width: Math.abs(c2.x - c1.x),
209
+ height: Math.abs(c2.y - c1.y),
195
210
  fill: colorEntry.fill,
196
211
  stroke: colorEntry.stroke,
197
212
  patternId
@@ -1,4 +1,5 @@
1
1
  import { resolveAlpha } from '../aesthetics.js'
2
+ import { literalColor } from '../../../lib/brewing/colors.js'
2
3
 
3
4
  /**
4
5
  * Build renderable heatmap cells. Cell fill is the continuous color scale (numeric color
@@ -10,6 +11,9 @@ export function buildHeatmapMarks({ data, plot, channels, alpha, type = 'heatmap
10
11
  if (!data?.length || !xScale || !yScale) return []
11
12
 
12
13
  const colorField = channels.fill ?? channels.color
14
+ // A literal fill/color paints every cell directly, skipping the value/palette scale.
15
+ const lit = literalColor(colorField)
16
+ const field = lit ? undefined : colorField
13
17
  const continuous = plot.continuousColorScale
14
18
  const bwX = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 0
15
19
  const bwY = typeof yScale.bandwidth === 'function' ? yScale.bandwidth() : 0
@@ -18,12 +22,14 @@ export function buildHeatmapMarks({ data, plot, channels, alpha, type = 'heatmap
18
22
  return data.map((d, i) => {
19
23
  const xVal = d[channels.x ?? '']
20
24
  const yVal = d[channels.y ?? '']
21
- const colorVal = colorField ? d[colorField] : null
22
- let fill = '#ccc'
23
- if (continuous) fill = continuous.scale(Number(colorVal))
24
- else {
25
- const entry = colors?.get(colorVal)
26
- if (entry) fill = entry.fill
25
+ const colorVal = field ? d[field] : null
26
+ let fill = lit ?? '#ccc'
27
+ if (!lit) {
28
+ if (continuous) fill = continuous.scale(Number(colorVal))
29
+ else {
30
+ const entry = colors?.get(colorVal)
31
+ if (entry) fill = entry.fill
32
+ }
27
33
  }
28
34
  return {
29
35
  key: `${xVal}-${yVal}-${i}`,
@@ -2,11 +2,12 @@
2
2
  * Build a Sankey-style ribbon layout: links (flows) + source/target node boxes. Link fill
3
3
  * comes from the source (then target) palette entry; `alpha` is fixed (default 0.5). Returns
4
4
  * an object rather than a flat array, so the geom template renders links + node labels.
5
- * @param {{ data: any[], plot: any, options?: any, alpha?: number, type?: string }} ctx
5
+ * @param {{ data: any[], plot: any, channels?: any, options?: any, alpha?: number, type?: string }} ctx
6
6
  */
7
- export function buildRibbonMarks({ data, plot, options, alpha, type }: {
7
+ export function buildRibbonMarks({ data, plot, channels, options, alpha, type }: {
8
8
  data: any[];
9
9
  plot: any;
10
+ channels?: any;
10
11
  options?: any;
11
12
  alpha?: number;
12
13
  type?: string;
@@ -1,12 +1,13 @@
1
1
  import { resolveAlpha } from '../aesthetics.js'
2
+ import { literalColor } from '../../../lib/brewing/colors.js'
2
3
 
3
4
  /**
4
5
  * Build a Sankey-style ribbon layout: links (flows) + source/target node boxes. Link fill
5
6
  * comes from the source (then target) palette entry; `alpha` is fixed (default 0.5). Returns
6
7
  * an object rather than a flat array, so the geom template renders links + node labels.
7
- * @param {{ data: any[], plot: any, options?: any, alpha?: number, type?: string }} ctx
8
+ * @param {{ data: any[], plot: any, channels?: any, options?: any, alpha?: number, type?: string }} ctx
8
9
  */
9
- export function buildRibbonMarks({ data, plot, options = {}, alpha, type = 'ribbon' }) {
10
+ export function buildRibbonMarks({ data, plot, channels = {}, options = {}, alpha, type = 'ribbon' }) {
10
11
  const empty = { links: [], sourceNodes: [], targetNodes: [] }
11
12
  if (!data?.length) return empty
12
13
 
@@ -14,6 +15,8 @@ export function buildRibbonMarks({ data, plot, options = {}, alpha, type = 'ribb
14
15
  const sourceField = options.source ?? 'source'
15
16
  const targetField = options.target ?? 'target'
16
17
  const valueField = options.value ?? 'value'
18
+ // A literal color/fill paints every ribbon directly (else fill comes from the node palette).
19
+ const lit = literalColor(channels.color ?? channels.fill)
17
20
  const a = resolveAlpha(alpha, type, plot.chartPreset)
18
21
 
19
22
  const flows = data.map((d) => ({
@@ -69,7 +72,7 @@ export function buildRibbonMarks({ data, plot, options = {}, alpha, type = 'ribb
69
72
  sourceOffsets.set(f.source, sy0 + sh)
70
73
  targetOffsets.set(f.target, ty0 + th)
71
74
 
72
- const fill = colors?.get(f.source)?.fill ?? colors?.get(f.target)?.fill ?? '#888'
75
+ const fill = lit ?? (colors?.get(f.source)?.fill ?? colors?.get(f.target)?.fill ?? '#888')
73
76
  return { key: `ribbon-${i}`, d: path, fill, alpha: a, data: f.data }
74
77
  })
75
78
 
@@ -1,10 +1,40 @@
1
1
  /**
2
2
  * Returns true if the value looks like a CSS color literal (not a field name).
3
- * Supports hex (#rgb, #rrggbb, #rrggbbaa), and functional notations (rgb, hsl, oklch, etc.).
3
+ * Supports hex (#rgb, #rrggbb, #rrggbbaa), functional notations (rgb, hsl, oklch, etc.),
4
+ * CSS custom-property refs (var(--token)), and the `currentColor` keyword.
4
5
  * @param {unknown} value
5
6
  * @returns {boolean}
6
7
  */
7
8
  export function isLiteralColor(value: unknown): boolean;
9
+ /**
10
+ * A color-channel value as a literal CSS color (var()/oklch()/#hex/currentColor),
11
+ * or undefined when it names a data field. Pair with {@link markEntry}: a literal is
12
+ * painted directly on every mark; a field is grouped and looked up in the color scale.
13
+ * @param {unknown} value
14
+ * @returns {string | undefined}
15
+ */
16
+ export function literalColor(value: unknown): string | undefined;
17
+ /**
18
+ * Resolves the `{fill, stroke}` for one mark. A `literal` color IS the paint for every
19
+ * mark — applied directly, never grouped as a field or run through the shared `colors`
20
+ * scale (which holds a single value across all geoms). Otherwise `key` is looked up in
21
+ * `colors`, falling back to `fallback`.
22
+ * @param {string | undefined} literal
23
+ * @param {unknown} key
24
+ * @param {Map<unknown, {fill: string, stroke: string}> | undefined} colors
25
+ * @param {{fill: string, stroke: string}} fallback
26
+ * @returns {{fill: string, stroke: string}}
27
+ */
28
+ export function markEntry(literal: string | undefined, key: unknown, colors: Map<unknown, {
29
+ fill: string;
30
+ stroke: string;
31
+ }> | undefined, fallback: {
32
+ fill: string;
33
+ stroke: string;
34
+ }): {
35
+ fill: string;
36
+ stroke: string;
37
+ };
8
38
  /**
9
39
  * Extracts distinct values for a given field from the data array.
10
40
  * @param {Object[]} data
@@ -6,7 +6,8 @@ import { defaultPreset } from '../preset.js'
6
6
 
7
7
  /**
8
8
  * Returns true if the value looks like a CSS color literal (not a field name).
9
- * Supports hex (#rgb, #rrggbb, #rrggbbaa), and functional notations (rgb, hsl, oklch, etc.).
9
+ * Supports hex (#rgb, #rrggbb, #rrggbbaa), functional notations (rgb, hsl, oklch, etc.),
10
+ * CSS custom-property refs (var(--token)), and the `currentColor` keyword.
10
11
  * @param {unknown} value
11
12
  * @returns {boolean}
12
13
  */
@@ -14,9 +15,41 @@ export function isLiteralColor(value) {
14
15
  if (!value || typeof value !== 'string') return false
15
16
  if (/^#([0-9a-fA-F]{3,8})$/.test(value)) return true
16
17
  if (/^(rgb|rgba|hsl|hsla|oklch|oklab|hwb|lab|lch|color)\s*\(/i.test(value)) return true
18
+ // A `var(--token)` ref or `currentColor` resolves to a concrete color at paint time
19
+ // (and stays theme-reactive under [data-mode]), so it means "use this color", not
20
+ // "map the field with this name" — pass it straight through to fill/stroke.
21
+ if (/^var\(\s*--/.test(value)) return true
22
+ if (/^currentcolor$/i.test(value)) return true
17
23
  return false
18
24
  }
19
25
 
26
+ /**
27
+ * A color-channel value as a literal CSS color (var()/oklch()/#hex/currentColor),
28
+ * or undefined when it names a data field. Pair with {@link markEntry}: a literal is
29
+ * painted directly on every mark; a field is grouped and looked up in the color scale.
30
+ * @param {unknown} value
31
+ * @returns {string | undefined}
32
+ */
33
+ export function literalColor(value) {
34
+ return isLiteralColor(value) ? /** @type {string} */ (value) : undefined
35
+ }
36
+
37
+ /**
38
+ * Resolves the `{fill, stroke}` for one mark. A `literal` color IS the paint for every
39
+ * mark — applied directly, never grouped as a field or run through the shared `colors`
40
+ * scale (which holds a single value across all geoms). Otherwise `key` is looked up in
41
+ * `colors`, falling back to `fallback`.
42
+ * @param {string | undefined} literal
43
+ * @param {unknown} key
44
+ * @param {Map<unknown, {fill: string, stroke: string}> | undefined} colors
45
+ * @param {{fill: string, stroke: string}} fallback
46
+ * @returns {{fill: string, stroke: string}}
47
+ */
48
+ export function markEntry(literal, key, colors, fallback) {
49
+ if (literal) return { fill: literal, stroke: literal }
50
+ return colors?.get(key) ?? fallback
51
+ }
52
+
20
53
  /**
21
54
  * Extracts distinct values for a given field from the data array.
22
55
  * @param {Object[]} data
@@ -1,5 +1,6 @@
1
1
  import { pie, arc } from 'd3-shape'
2
2
  import { toPatternId } from '../../brewing/patterns.js'
3
+ import { literalColor, markEntry } from '../colors.js'
3
4
 
4
5
  /**
5
6
  * Builds arc geometry for pie/donut charts.
@@ -14,6 +15,8 @@ import { toPatternId } from '../../brewing/patterns.js'
14
15
  */
15
16
  export function buildArcs(data, channels, colors, width, height, opts = {}, patterns) {
16
17
  const { color: lf, y: yf } = channels
18
+ // A literal color paints every slice directly; a field indexes the palette scale.
19
+ const lit = literalColor(lf)
17
20
  const radius = Math.min(width, height) / 2
18
21
  // innerRadius: a value <= 1 is a fraction of the radius (responsive donut);
19
22
  // a value > 1 is absolute pixels. Clamp below the outer radius so a bad value
@@ -29,11 +32,13 @@ export function buildArcs(data, channels, colors, width, height, opts = {}, patt
29
32
  // Label radius: midpoint between inner and outer (or 70% out for solid pie)
30
33
  const labelRadius = innerRadius > 0 ? (innerRadius + radius) / 2 : radius * 0.65
31
34
  const labelArc = arc().innerRadius(labelRadius).outerRadius(labelRadius)
32
- return slices.map((slice) => {
33
- const key = slice.data[lf]
34
- const colorEntry = colors?.get(key) ?? { fill: '#888', stroke: '#fff' }
35
+ return slices.map((slice, i) => {
36
+ // With a literal color there's no category field, so key each slice by index to keep
37
+ // them distinct (patterns, which are field-keyed, don't apply to a single literal fill).
38
+ const key = lit ? i : slice.data[lf]
39
+ const colorEntry = markEntry(lit, key, colors, { fill: '#888', stroke: '#fff' })
35
40
  const patternId =
36
- key !== null && key !== undefined && patterns?.has(key) ? toPatternId(String(key)) : null
41
+ !lit && key !== null && key !== undefined && patterns?.has(key) ? toPatternId(String(key)) : null
37
42
  const pct = Math.round(((slice.endAngle - slice.startAngle) / total) * 100)
38
43
  const [cx, cy] = labelArc.centroid(slice)
39
44
  return {
@@ -1,4 +1,5 @@
1
1
  import { toPatternId } from '../patterns.js'
2
+ import { literalColor, markEntry } from '../colors.js'
2
3
 
3
4
  /**
4
5
  * Builds box geometry for box plot charts.
@@ -23,9 +24,12 @@ import { toPatternId } from '../patterns.js'
23
24
  */
24
25
  export function buildBoxes(data, channels, xScale, yScale, colors, opts = {}) {
25
26
  const { x: xf, fill: ff, pattern: pf } = channels
27
+ // A literal fill colours every box directly; a field sub-groups within each x-band.
28
+ const lit = literalColor(ff)
29
+ const field = lit ? undefined : ff
26
30
  const { side = 'center', width, patterns } = opts
27
31
  const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 20
28
- const grouped = ff && ff !== xf
32
+ const grouped = field && field !== xf
29
33
  // Offset the box centre to the middle of the chosen half-lane.
30
34
  const laneOffset = (unit) => (side === 'left' ? -unit / 4 : side === 'right' ? unit / 4 : 0)
31
35
  // Texture fill: patternId per box from the pattern channel value (null = solid fill).
@@ -46,7 +50,7 @@ export function buildBoxes(data, channels, xScale, yScale, colors, opts = {}) {
46
50
  const subIndex = fillValues.indexOf(fillVal)
47
51
  const bandStart = xScale(d[xf]) ?? 0
48
52
  const cx = bandStart + subIndex * subBandWidth + subBandWidth / 2 + laneOffset(subBandWidth)
49
- const colorEntry = colors?.get(fillVal) ?? { fill: '#aaa', stroke: '#666' }
53
+ const colorEntry = markEntry(lit, fillVal, colors, { fill: '#aaa', stroke: '#666' })
50
54
 
51
55
  return {
52
56
  data: d,
@@ -71,8 +75,8 @@ export function buildBoxes(data, channels, xScale, yScale, colors, opts = {}) {
71
75
  const whiskerWidth = width !== undefined ? boxWidth * 0.5 : bw * 0.3
72
76
 
73
77
  return data.map((d) => {
74
- const fillKey = ff ? d[ff] : d[xf]
75
- const colorEntry = colors?.get(fillKey) ?? { fill: '#aaa', stroke: '#666' }
78
+ const fillKey = field ? d[field] : d[xf]
79
+ const colorEntry = markEntry(lit, fillKey, colors, { fill: '#aaa', stroke: '#666' })
76
80
  const cx =
77
81
  (xScale(d[xf]) ?? 0) +
78
82
  (typeof xScale.bandwidth === 'function' ? bw / 2 : 0) +
@@ -1,4 +1,5 @@
1
1
  import { line, curveCatmullRom, curveStep } from 'd3-shape'
2
+ import { literalColor } from '../colors.js'
2
3
 
3
4
  /**
4
5
  * @param {Object[]} data
@@ -10,7 +11,12 @@ import { line, curveCatmullRom, curveStep } from 'd3-shape'
10
11
  * @returns {{ d: string | null, fill: string, stroke: string, points: {x:number, y:number, data: Record<string, unknown>, i: number}[], key?: unknown }[]}
11
12
  */
12
13
  export function buildLines(data, channels, xScale, yScale, colors, curve, place = (x, y) => ({ x, y })) {
13
- const { x: xf, y: yf, color: cf } = channels
14
+ const { x: xf, y: yf } = channels
15
+ // A literal color (var()/oklch()/hex/currentColor) is NOT a data field — it's the exact
16
+ // stroke the caller wants for THIS geom. Don't group the series on it, and don't fall
17
+ // back to the shared color scale (which is one value across all geoms); use it directly.
18
+ const lit = literalColor(channels.color)
19
+ const cf = lit ? undefined : channels.color
14
20
  const xPos = (d) =>
15
21
  typeof xScale.bandwidth === 'function' ? xScale(d[xf]) + xScale.bandwidth() / 2 : xScale(d[xf])
16
22
  // Screen points, sorted by the x-channel position, each mapped through place() so the
@@ -25,6 +31,10 @@ export function buildLines(data, channels, xScale, yScale, colors, curve, place
25
31
  }
26
32
  const makeGen = () => {
27
33
  const gen = line()
34
+ // A null y-value is a GAP (a missing period), not a zero: break the path there
35
+ // (d3 `.defined`) instead of letting yScale(null) collapse it onto the 0 baseline.
36
+ // A genuine 0 stays defined and plots.
37
+ .defined((p) => Number.isFinite(p.y) && (yf === undefined || yf === null || (p.data?.[yf] !== undefined && p.data?.[yf] !== null)))
28
38
  .x((p) => p.x)
29
39
  .y((p) => p.y)
30
40
  if (curve === 'smooth') gen.curve(curveCatmullRom)
@@ -34,7 +44,8 @@ export function buildLines(data, channels, xScale, yScale, colors, curve, place
34
44
 
35
45
  if (!cf) {
36
46
  const pts = screenPoints(data)
37
- const stroke = colors?.values().next().value?.stroke ?? '#888'
47
+ // A literal color IS the stroke; a single-series field uses the shared shade.
48
+ const stroke = lit ?? colors?.values().next().value?.stroke ?? '#888'
38
49
  return [{ d: makeGen()(pts), fill: 'none', stroke, points: pts }]
39
50
  }
40
51
  const groups = groupBy(data, cf)
@@ -8,6 +8,7 @@ import {
8
8
  symbolStar
9
9
  } from 'd3-shape'
10
10
  import { defaultPreset } from '../../preset.js'
11
+ import { literalColor, markEntry } from '../colors.js'
11
12
 
12
13
  const SYMBOL_TYPES = [
13
14
  symbolCircle,
@@ -84,9 +85,13 @@ export function buildPoints(
84
85
  jitter = null
85
86
  ) {
86
87
  const { x: xf, y: yf, color: cf, size: sf, symbol: symf } = channels
88
+ // A literal color (var()/oklch()/#hex/currentColor) is the exact fill for every point,
89
+ // not a field to group on — apply it directly instead of looking it up in the scale.
90
+ const lit = literalColor(cf)
91
+ const field = lit ? undefined : cf
87
92
  return data.map((d, i) => {
88
- const colorKey = cf ? d[cf] : null
89
- const colorEntry = colors?.get(colorKey) ?? { fill: '#888', stroke: '#444' }
93
+ const colorKey = field ? d[field] : null
94
+ const colorEntry = markEntry(lit, colorKey, colors, { fill: '#888', stroke: '#444' })
90
95
  const r = sf && sizeScale ? sizeScale(d[sf]) : defaultRadius
91
96
  const shapeName = symf && symbolMap ? (symbolMap.get(d[symf]) ?? 'circle') : null
92
97
  const symbolPath = shapeName ? buildSymbolPath(shapeName, r) : null
@@ -1,4 +1,5 @@
1
1
  import { ascending } from 'd3-array'
2
+ import { literalColor, markEntry } from '../colors.js'
2
3
 
3
4
  /**
4
5
  * Deterministic pseudo-random in [0, 1) from a non-negative integer seed.
@@ -28,6 +29,9 @@ function hashUnit(n) {
28
29
  */
29
30
  export function buildSwarm(data, channels, xScale, yScale, colors, opts = {}) {
30
31
  const { x: xf, y: yf, fill: ff } = channels
32
+ // A literal fill colours every point directly; otherwise group on the fill field.
33
+ const lit = literalColor(ff)
34
+ const field = lit ? undefined : ff
31
35
  const { method = 'jitter', r = 2, side = 'center' } = opts
32
36
  const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 40
33
37
  // One-sided layouts get the full half-band on their side; centred straddles ±halfBand.
@@ -51,8 +55,8 @@ export function buildSwarm(data, channels, xScale, yScale, colors, opts = {}) {
51
55
  : rows.map((_, i) => jitterOffset(hashUnit(i), halfBand, side))
52
56
 
53
57
  rows.forEach((d, i) => {
54
- const fillKey = ff ? d[ff] : xVal
55
- const colorEntry = colors?.get(fillKey) ?? { fill: '#aaa', stroke: '#666' }
58
+ const fillKey = field ? d[field] : xVal
59
+ const colorEntry = markEntry(lit, fillKey, colors, { fill: '#aaa', stroke: '#666' })
56
60
  result.push({
57
61
  data: d,
58
62
  cx: center + offsets[i],
@@ -1,5 +1,6 @@
1
1
  import { line, curveCatmullRom } from 'd3-shape'
2
2
  import { toPatternId } from '../patterns.js'
3
+ import { literalColor, markEntry } from '../colors.js'
3
4
 
4
5
  // Relative widths at each stat anchor (fraction of max half-width)
5
6
  const DENSITY_AT = { iqr_min: 0.08, q1: 0.55, median: 1.0, q3: 0.55, iqr_max: 0.08 }
@@ -49,8 +50,11 @@ export function buildViolins(
49
50
  patterns
50
51
  ) {
51
52
  const { x: xf, fill: ff, pattern: pf } = channels
53
+ // A literal fill colours every violin directly; a field sub-groups within each x-band.
54
+ const lit = literalColor(ff)
55
+ const field = lit ? undefined : ff
52
56
  const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 40
53
- const grouped = ff && ff !== xf
57
+ const grouped = field && field !== xf
54
58
  // Texture fill: patternId per violin from the pattern channel value (null = solid fill).
55
59
  const patternIdFor = (d) => {
56
60
  const key = pf ? d[pf] : null
@@ -73,7 +77,7 @@ export function buildViolins(
73
77
  const subIndex = fillValues.indexOf(fillVal)
74
78
  const bandStart = xScale(d[xf]) ?? 0
75
79
  const cx = bandStart + subIndex * subBandWidth + subBandWidth / 2
76
- const colorEntry = colors?.get(fillVal) ?? { fill: '#aaa', stroke: '#666' }
80
+ const colorEntry = markEntry(lit, fillVal, colors, { fill: '#aaa', stroke: '#666' })
77
81
 
78
82
  return {
79
83
  data: d,
@@ -90,8 +94,8 @@ export function buildViolins(
90
94
  const halfMax = bw * 0.45
91
95
 
92
96
  return data.map((d) => {
93
- const fillKey = ff ? d[ff] : d[xf]
94
- const colorEntry = colors?.get(fillKey) ?? { fill: '#aaa', stroke: '#666' }
97
+ const fillKey = field ? d[field] : d[xf]
98
+ const colorEntry = markEntry(lit, fillKey, colors, { fill: '#aaa', stroke: '#666' })
95
99
  const cx = (xScale(d[xf]) ?? 0) + (typeof xScale.bandwidth === 'function' ? bw / 2 : 0)
96
100
 
97
101
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/chart",
3
- "version": "1.3.9",
3
+ "version": "1.3.11",
4
4
  "type": "module",
5
5
  "description": "Data-driven chart components",
6
6
  "repository": {