@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 +4 -1
- package/dist/Plot.svelte.d.ts +2 -0
- package/dist/PlotState.svelte.js +35 -1
- package/dist/geoms/lib/areas.js +14 -3
- package/dist/geoms/lib/bars.js +24 -9
- package/dist/geoms/lib/marks/heatmap.js +12 -6
- package/dist/geoms/lib/marks/ribbon.d.ts +3 -2
- package/dist/geoms/lib/marks/ribbon.js +6 -3
- package/dist/lib/brewing/colors.d.ts +31 -1
- package/dist/lib/brewing/colors.js +34 -1
- package/dist/lib/brewing/marks/arcs.js +9 -4
- package/dist/lib/brewing/marks/boxes.js +8 -4
- package/dist/lib/brewing/marks/lines.js +13 -2
- package/dist/lib/brewing/marks/points.js +7 -2
- package/dist/lib/brewing/marks/swarm.js +6 -2
- package/dist/lib/brewing/marks/violins.js +8 -4
- package/package.json +1 -1
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,
|
package/dist/Plot.svelte.d.ts
CHANGED
|
@@ -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;
|
package/dist/PlotState.svelte.js
CHANGED
|
@@ -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 =
|
|
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 })
|
package/dist/geoms/lib/areas.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/dist/geoms/lib/bars.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
179
|
-
const colorEntry =
|
|
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:
|
|
192
|
-
y:
|
|
193
|
-
width:
|
|
194
|
-
height:
|
|
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 =
|
|
22
|
-
let fill = '#ccc'
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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),
|
|
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),
|
|
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
|
-
|
|
34
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
75
|
-
const colorEntry =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
89
|
-
const colorEntry =
|
|
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 =
|
|
55
|
-
const colorEntry =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
94
|
-
const colorEntry =
|
|
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 {
|