@rokkit/chart 1.0.0-next.99 → 1.0.2

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 (137) hide show
  1. package/README.md +150 -46
  2. package/package.json +40 -51
  3. package/src/AnimatedPlot.svelte +383 -0
  4. package/src/Chart.svelte +87 -59
  5. package/src/ChartExporter.svelte +125 -0
  6. package/src/ChartLegend.svelte +148 -0
  7. package/src/ChartProvider.svelte +10 -0
  8. package/src/FacetPlot/Panel.svelte +37 -0
  9. package/src/FacetPlot.svelte +114 -0
  10. package/src/Plot/Arc.svelte +29 -0
  11. package/src/Plot/Area.svelte +32 -0
  12. package/src/Plot/Axis.svelte +164 -0
  13. package/src/Plot/Bar.svelte +54 -0
  14. package/src/Plot/Grid.svelte +34 -0
  15. package/src/Plot/Legend.svelte +268 -0
  16. package/src/Plot/Line.svelte +37 -0
  17. package/src/Plot/Point.svelte +40 -0
  18. package/src/Plot/Root.svelte +62 -0
  19. package/src/Plot/Timeline.svelte +95 -0
  20. package/src/Plot/Tooltip.svelte +87 -0
  21. package/src/Plot/index.js +9 -0
  22. package/src/Plot.svelte +313 -0
  23. package/src/PlotState.svelte.js +375 -0
  24. package/src/Sparkline.svelte +108 -0
  25. package/src/Symbol.svelte +15 -11
  26. package/src/Texture.svelte +9 -7
  27. package/src/charts/AreaChart.svelte +30 -0
  28. package/src/charts/BarChart.svelte +31 -0
  29. package/src/charts/BoxPlot.svelte +24 -0
  30. package/src/charts/BubbleChart.svelte +28 -0
  31. package/src/charts/LineChart.svelte +31 -0
  32. package/src/charts/PieChart.svelte +35 -0
  33. package/src/charts/ScatterPlot.svelte +31 -0
  34. package/src/charts/ViolinPlot.svelte +24 -0
  35. package/src/crossfilter/CrossFilter.svelte +42 -0
  36. package/src/crossfilter/FilterBar.svelte +24 -0
  37. package/src/crossfilter/FilterHistogram.svelte +290 -0
  38. package/src/crossfilter/FilterSlider.svelte +83 -0
  39. package/src/crossfilter/createCrossFilter.svelte.js +124 -0
  40. package/src/elements/Bar.svelte +20 -16
  41. package/src/elements/ColorRamp.svelte +19 -19
  42. package/src/elements/ContinuousLegend.svelte +41 -19
  43. package/src/elements/DefinePatterns.svelte +9 -7
  44. package/src/elements/DiscreteLegend.svelte +13 -11
  45. package/src/elements/Label.svelte +4 -9
  46. package/src/elements/SymbolGrid.svelte +8 -9
  47. package/src/examples/BarChartExample.svelte +81 -0
  48. package/src/geoms/Arc.svelte +126 -0
  49. package/src/geoms/Area.svelte +78 -0
  50. package/src/geoms/Bar.svelte +200 -0
  51. package/src/geoms/Box.svelte +113 -0
  52. package/src/geoms/Candlestick.svelte +110 -0
  53. package/src/geoms/Heatmap.svelte +92 -0
  54. package/src/geoms/Hexbin.svelte +114 -0
  55. package/src/geoms/LabelPill.svelte +17 -0
  56. package/src/geoms/Line.svelte +123 -0
  57. package/src/geoms/Point.svelte +145 -0
  58. package/src/geoms/Ribbon.svelte +161 -0
  59. package/src/geoms/Violin.svelte +56 -0
  60. package/src/geoms/Waterfall.svelte +114 -0
  61. package/src/geoms/lib/areas.js +154 -0
  62. package/src/geoms/lib/bars.js +223 -0
  63. package/src/index.js +81 -2
  64. package/src/lib/brewing/BoxBrewer.svelte.js +14 -0
  65. package/src/lib/brewing/CartesianBrewer.svelte.js +21 -0
  66. package/src/lib/brewing/PieBrewer.svelte.js +14 -0
  67. package/src/lib/brewing/QuartileBrewer.svelte.js +51 -0
  68. package/src/lib/brewing/ViolinBrewer.svelte.js +14 -0
  69. package/src/lib/brewing/axes.svelte.js +270 -0
  70. package/src/lib/brewing/bars.svelte.js +201 -0
  71. package/src/lib/brewing/brewer.svelte.js +277 -0
  72. package/src/lib/brewing/colors.js +157 -0
  73. package/src/lib/brewing/dimensions.svelte.js +56 -0
  74. package/src/lib/brewing/index.svelte.js +205 -0
  75. package/src/lib/brewing/legends.svelte.js +137 -0
  76. package/src/lib/brewing/marks/arcs.js +43 -0
  77. package/src/lib/brewing/marks/areas.js +72 -0
  78. package/src/lib/brewing/marks/bars.js +49 -0
  79. package/src/lib/brewing/marks/boxes.js +75 -0
  80. package/src/lib/brewing/marks/lines.js +55 -0
  81. package/src/lib/brewing/marks/points.js +105 -0
  82. package/src/lib/brewing/marks/violins.js +90 -0
  83. package/src/lib/brewing/patterns.js +45 -0
  84. package/src/lib/brewing/scales.js +51 -0
  85. package/src/lib/brewing/scales.svelte.js +82 -0
  86. package/src/lib/brewing/stats.js +74 -0
  87. package/src/lib/brewing/symbols.js +10 -0
  88. package/src/lib/brewing/types.js +73 -0
  89. package/src/lib/chart.js +16 -8
  90. package/src/lib/context.js +131 -0
  91. package/src/lib/keyboard-nav.js +37 -0
  92. package/src/lib/plot/chartProps.js +76 -0
  93. package/src/lib/plot/crossfilter.js +16 -0
  94. package/src/lib/plot/facet.js +58 -0
  95. package/src/lib/plot/frames.js +81 -0
  96. package/src/lib/plot/helpers.js +14 -0
  97. package/src/lib/plot/preset.js +67 -0
  98. package/src/lib/plot/scales.js +81 -0
  99. package/src/lib/plot/stat.js +92 -0
  100. package/src/lib/plot/types.js +65 -0
  101. package/src/lib/preset.js +41 -0
  102. package/src/lib/scales.svelte.js +151 -0
  103. package/src/lib/swatch.js +4 -7
  104. package/src/lib/utils.js +142 -0
  105. package/src/lib/xscale.js +31 -0
  106. package/src/patterns/DefinePatterns.svelte +32 -0
  107. package/src/patterns/PatternDef.svelte +27 -0
  108. package/src/patterns/index.js +4 -14
  109. package/src/patterns/patterns.js +360 -0
  110. package/src/patterns/scale.js +116 -0
  111. package/src/spec/chart-spec.js +72 -0
  112. package/src/symbols/RoundedSquare.svelte +18 -12
  113. package/src/symbols/Shape.svelte +22 -16
  114. package/src/symbols/constants/index.js +1 -1
  115. package/LICENSE +0 -21
  116. package/src/lib/index.js +0 -4
  117. package/src/lib/plots.js +0 -27
  118. package/src/patterns/Brick.svelte +0 -17
  119. package/src/patterns/Circles.svelte +0 -18
  120. package/src/patterns/CrossHatch.svelte +0 -14
  121. package/src/patterns/CurvedWave.svelte +0 -9
  122. package/src/patterns/Dots.svelte +0 -19
  123. package/src/patterns/OutlineCircles.svelte +0 -15
  124. package/src/patterns/Tile.svelte +0 -17
  125. package/src/patterns/Triangles.svelte +0 -15
  126. package/src/patterns/Waves.svelte +0 -13
  127. package/src/patterns/paths/NamedPattern.svelte +0 -12
  128. package/src/patterns/paths/constants.js +0 -4
  129. package/src/plots/Plot.svelte +0 -40
  130. package/src/plots/index.js +0 -1
  131. package/src/template/Texture.svelte +0 -16
  132. package/src/template/constants.js +0 -43
  133. package/src/template/shapes/Circles.svelte +0 -16
  134. package/src/template/shapes/Lines.svelte +0 -17
  135. package/src/template/shapes/Path.svelte +0 -12
  136. package/src/template/shapes/Polygons.svelte +0 -18
  137. package/src/template/shapes/index.js +0 -4
@@ -0,0 +1,201 @@
1
+ import { SvelteSet } from 'svelte/reactivity'
2
+ import {} from './types.js'
3
+
4
+ /**
5
+ * @typedef {import('./types').BarData} BarData
6
+ * @typedef {import('./types').ScaleFields} ScaleFields
7
+ * @typedef {import('./types').ChartScales} ChartScales
8
+ * @typedef {import('./types').ChartDimensions} ChartDimensions
9
+ */
10
+
11
+ const DEFAULT_COLOR = '#4682b4'
12
+
13
+ /**
14
+ * @param {Object} d - data item
15
+ * @param {{ fields: Object, scales: Object, dimensions: Object, defaultColor: string }} ctx
16
+ * @returns {BarData}
17
+ */
18
+ function buildBar(d, ctx) {
19
+ const { fields, scales, dimensions, defaultColor } = ctx
20
+ const { x: xField, y: yField, color: colorField } = fields
21
+ const barWidth = scales.x.bandwidth ? scales.x.bandwidth() : 10
22
+ const barX = scales.x.bandwidth ? scales.x(d[xField]) : scales.x(d[xField]) - barWidth / 2
23
+ return {
24
+ data: d,
25
+ x: barX,
26
+ y: scales.y(d[yField]),
27
+ width: barWidth,
28
+ height: dimensions.innerHeight - scales.y(d[yField]),
29
+ color: colorField && scales.color ? scales.color(d[colorField]) : defaultColor
30
+ }
31
+ }
32
+
33
+ /**
34
+ * @param {Object} options
35
+ * @param {Object} fields
36
+ * @param {Object} scales
37
+ * @returns {{ ctx: Object }|null} null if input is invalid
38
+ */
39
+ function parseBarsInput(options, fields, scales) {
40
+ if (!scales.x || !scales.y) return null
41
+ const opts = options || {}
42
+ return {
43
+ fields,
44
+ scales,
45
+ dimensions: opts.dimensions,
46
+ defaultColor: opts.defaultColor !== undefined ? opts.defaultColor : DEFAULT_COLOR
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Creates bar data for rendering
52
+ *
53
+ * @param {Array} data - Chart data
54
+ * @param {Object} fields - Field mappings
55
+ * @param {string} fields.x - X-axis field
56
+ * @param {string} fields.y - Y-axis field
57
+ * @param {string} fields.color - Color field (optional)
58
+ * @param {Object} scales - Chart scales
59
+ * @param {Function} scales.x - X-axis scale
60
+ * @param {Function} scales.y - Y-axis scale
61
+ * @param {Function} scales.color - Color scale
62
+ * @param {Object} options - Options including dimensions and defaultColor
63
+ * @param {Object} options.dimensions - Chart dimensions
64
+ * @param {string} [options.defaultColor='#4682b4'] - Default color if no color scale
65
+ * @returns {BarData[]} Bar data for rendering
66
+ */
67
+ export function createBars(data, fields, scales, options) {
68
+ if (!data || !data.length) return []
69
+ const ctx = parseBarsInput(options, fields, scales)
70
+ if (!ctx) return []
71
+ return data.map((d) => buildBar(d, ctx))
72
+ }
73
+
74
+ /**
75
+ * Filter bars based on a selection criteria
76
+ *
77
+ * @param {BarData[]} bars - Bar data array
78
+ * @param {Object} selection - Selection criteria
79
+ * @returns {BarData[]} Filtered bars
80
+ */
81
+ export function filterBars(bars, selection) {
82
+ if (!selection) return bars
83
+
84
+ return bars.filter((bar) => {
85
+ for (const [key, value] of Object.entries(selection)) {
86
+ if (bar.data[key] !== value) return false
87
+ }
88
+ return true
89
+ })
90
+ }
91
+
92
+ /**
93
+ * @param {Object} item - data item
94
+ * @param {Object} scales - chart scales
95
+ * @param {{ yField: string, colorField: string, group: unknown, barX: number, barWidth: number, dimensions: Object }} ctx
96
+ * @returns {BarData}
97
+ */
98
+ function buildGroupBar(item, scales, ctx) {
99
+ const { yField, colorField, group, barX, barWidth, dimensions } = ctx
100
+ return {
101
+ data: item,
102
+ group,
103
+ x: barX,
104
+ y: scales.y(item[yField]),
105
+ width: barWidth,
106
+ height: dimensions.innerHeight - scales.y(item[yField]),
107
+ color: scales.color ? scales.color(item[colorField]) : DEFAULT_COLOR
108
+ }
109
+ }
110
+
111
+ /**
112
+ * @param {Object} scales
113
+ * @param {{ groupItems: Array, groupField: string, group: unknown, i: number, yField: string, colorField: string, barWidth: number, padding: number, dimensions: Object, xPos: number }} ctx
114
+ * @returns {BarData|null}
115
+ */
116
+ function buildOneGroupBar(scales, ctx) {
117
+ const {
118
+ groupItems,
119
+ groupField,
120
+ group,
121
+ i,
122
+ yField,
123
+ colorField,
124
+ barWidth,
125
+ padding,
126
+ dimensions,
127
+ xPos
128
+ } = ctx
129
+ const item = groupItems.find((d) => d[groupField] === group)
130
+ if (!item) return null
131
+ const barX = xPos + i * (barWidth + padding)
132
+ return buildGroupBar(item, scales, { yField, colorField, group, barX, barWidth, dimensions })
133
+ }
134
+
135
+ /**
136
+ * @param {Object} fields
137
+ * @param {Object} options
138
+ * @returns {{ xField: string, yField: string, groupField: string, colorField: string, dimensions: Object, padding: number }}
139
+ */
140
+ function parseGroupedBarsConfig(fields, options) {
141
+ const opts = options || {}
142
+ return {
143
+ xField: fields.x,
144
+ yField: fields.y,
145
+ groupField: fields.group,
146
+ colorField: fields.color !== undefined ? fields.color : fields.group,
147
+ dimensions: opts.dimensions,
148
+ padding: opts.padding !== undefined ? opts.padding : 0.1
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Creates a grouped bars layout
154
+ *
155
+ * @param {Array} data - Chart data
156
+ * @param {Object} fields - Field mappings
157
+ * @param {Object} scales - Chart scales
158
+ * @param {Object} options - Options including dimensions and padding
159
+ * @param {Object} options.dimensions - Chart dimensions
160
+ * @param {number} [options.padding=0.1] - Padding between bars in a group
161
+ * @returns {Object} Grouped bar data
162
+ */
163
+ export function createGroupedBars(data, fields, scales, options) {
164
+ if (!data || !data.length || !fields.group) return { groups: [], bars: [] }
165
+
166
+ const { xField, yField, groupField, colorField, dimensions, padding } = parseGroupedBarsConfig(
167
+ fields,
168
+ options
169
+ )
170
+
171
+ const groups = [...new SvelteSet(data.map((d) => d[groupField]))]
172
+ const xValues = [...new SvelteSet(data.map((d) => d[xField]))]
173
+
174
+ const xScale = scales.x
175
+ const groupWidth = xScale.bandwidth ? xScale.bandwidth() : 20
176
+ const barWidth = (groupWidth - padding * (groups.length - 1)) / groups.length
177
+
178
+ const bars = []
179
+ xValues.forEach((xValue) => {
180
+ const groupItems = data.filter((d) => d[xField] === xValue)
181
+ const xPos = xScale(xValue)
182
+ groups.forEach((group, i) => {
183
+ const ctx = {
184
+ groupItems,
185
+ groupField,
186
+ group,
187
+ i,
188
+ yField,
189
+ colorField,
190
+ barWidth,
191
+ padding,
192
+ dimensions,
193
+ xPos
194
+ }
195
+ const bar = buildOneGroupBar(scales, ctx)
196
+ if (bar) bars.push(bar)
197
+ })
198
+ })
199
+
200
+ return { groups, bars }
201
+ }
@@ -0,0 +1,277 @@
1
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
2
+ import { distinct, assignColors } from './colors.js'
3
+ import { assignPatterns, toPatternId, PATTERN_ORDER } from './patterns.js'
4
+ import { assignSymbols } from './symbols.js'
5
+ import { buildXScale, buildYScale, buildSizeScale } from './scales.js'
6
+ import { buildBars } from './marks/bars.js'
7
+ import { buildLines } from './marks/lines.js'
8
+ import { buildAreas } from './marks/areas.js'
9
+ import { buildArcs } from './marks/arcs.js'
10
+ import { buildPoints } from './marks/points.js'
11
+
12
+ const DEFAULT_MARGIN = { top: 20, right: 20, bottom: 40, left: 50 }
13
+
14
+ /**
15
+ * Groups aesthetic channel mappings by field name, merging aesthetics that
16
+ * share the same field into one legend section.
17
+ *
18
+ * @param {{ fill?: string, color?: string, pattern?: string, symbol?: string }} channels
19
+ * `fill` takes precedence over `color` for polygon charts (bars, areas, pie slices).
20
+ * @param {Map<unknown, {fill:string, stroke:string}>} colorMap
21
+ * @param {Map<unknown, string>} patternMap
22
+ * @param {Map<unknown, string>} symbolMap
23
+ * @returns {{ field: string, items: { label: string, fill: string|null, stroke: string|null, patternId: string|null, shape: string|null }[] }[]}
24
+ */
25
+ function addAesthetic(byField, field, aesthetic, keys) {
26
+ if (byField.has(field)) {
27
+ byField.get(field).aesthetics.push(aesthetic)
28
+ } else {
29
+ byField.set(field, { aesthetics: [aesthetic], keys })
30
+ }
31
+ }
32
+
33
+ function buildLegendItem(key, aesthetics, colorMap, patternMap, symbolMap) {
34
+ const hasColor = aesthetics.includes('color')
35
+ return {
36
+ label: String(key),
37
+ fill: hasColor ? (colorMap.get(key)?.fill ?? null) : null,
38
+ stroke: hasColor ? (colorMap.get(key)?.stroke ?? null) : null,
39
+ patternId: aesthetics.includes('pattern') && patternMap.has(key) ? toPatternId(key) : null,
40
+ shape: aesthetics.includes('symbol') ? (symbolMap.get(key) ?? 'circle') : null
41
+ }
42
+ }
43
+
44
+ export function buildLegendGroups(channels, colorMap, patternMap, symbolMap) {
45
+ const cf = channels.fill ?? channels.color
46
+ const { pattern: pf, symbol: sf } = channels
47
+ const byField = new SvelteMap()
48
+
49
+ if (cf) byField.set(cf, { aesthetics: ['color'], keys: [...colorMap.keys()] })
50
+ if (pf) addAesthetic(byField, pf, 'pattern', [...patternMap.keys()])
51
+ if (sf) addAesthetic(byField, sf, 'symbol', [...symbolMap.keys()])
52
+
53
+ return [...byField.entries()]
54
+ .map(([field, { aesthetics, keys }]) => ({
55
+ field,
56
+ items: keys
57
+ .filter((k) => k !== null && k !== undefined)
58
+ .map((key) => buildLegendItem(key, aesthetics, colorMap, patternMap, symbolMap))
59
+ }))
60
+ .filter((group) => group.items.length > 0)
61
+ }
62
+
63
+ export class ChartBrewer {
64
+ #rawData = $state([])
65
+ #channels = $state({})
66
+ #width = $state(600)
67
+ #height = $state(400)
68
+ #mode = $state('light')
69
+ #margin = $state(DEFAULT_MARGIN)
70
+ #layers = $state([])
71
+ #curve = $state(/** @type {'linear'|'smooth'|'step'|undefined} */ (undefined))
72
+ #stat = $state('identity')
73
+
74
+ /**
75
+ * Override in subclasses to apply stat aggregation.
76
+ * @param {Object[]} data
77
+ * @param {Object} channels
78
+ * @param {string|Function} stat
79
+ * @returns {Object[]}
80
+ */
81
+ transform(data, _channels, _stat) {
82
+ return data
83
+ }
84
+
85
+ /** Aggregated data — all derived marks read this, not #rawData */
86
+ processedData = $derived(this.transform(this.#rawData, this.#channels, this.#stat))
87
+
88
+ /** Exposes channels to subclasses for use in their own $derived properties */
89
+ get channels() {
90
+ return this.#channels
91
+ }
92
+
93
+ // Maps are built from rawData so the legend always reflects the full set of
94
+ // original values — independent of whichever stat aggregation is applied.
95
+ // e.g. pattern=quarter with stat=sum still shows all 8 quarters in the legend.
96
+
97
+ /** @type {Map<unknown, {fill:string,stroke:string}>} */
98
+ colorMap = $derived(
99
+ (this.#channels.fill ?? this.#channels.color)
100
+ ? assignColors(
101
+ distinct(this.#rawData, this.#channels.fill ?? this.#channels.color),
102
+ this.#mode
103
+ )
104
+ : new SvelteMap()
105
+ )
106
+
107
+ /** @type {Map<unknown, string>} */
108
+ patternMap = $derived(
109
+ this.#channels.pattern
110
+ ? assignPatterns(distinct(this.#rawData, this.#channels.pattern))
111
+ : new SvelteMap()
112
+ )
113
+
114
+ /**
115
+ * Unified pattern defs for ChartPatternDefs.
116
+ * When fill and pattern map the same field, pattern key = color key (simple case).
117
+ * When they differ, each unique (fillKey, patternKey) pair gets its own pattern def
118
+ * so bars/areas can have distinct colors per region AND distinct textures per category.
119
+ * @type {Array<{ id: string, name: string, fill: string, stroke: string }>}
120
+ */
121
+ patternDefs = $derived(
122
+ (() => {
123
+ const pf = this.#channels.pattern
124
+ const ff = this.#channels.fill ?? this.#channels.color
125
+ if (!pf || this.patternMap.size === 0) return []
126
+ if (!ff || pf === ff) {
127
+ // Same field: pattern key = fill key — simple 1:1 lookup
128
+ return Array.from(this.patternMap.entries()).map(([key, name]) => {
129
+ const color = this.colorMap.get(key) ?? { fill: '#ddd', stroke: '#666' }
130
+ return { id: toPatternId(key), name, fill: color.fill, stroke: color.stroke }
131
+ })
132
+ }
133
+ // Different fields: need two sets of defs in the SVG:
134
+ // 1. Simple defs (neutral background) — referenced by legend swatches via toPatternId(patternKey)
135
+ // 2. Composite defs (fill-colored background) — referenced by bars via toPatternId(fillKey::patternKey)
136
+ const defs = []
137
+ for (const [pk, name] of this.patternMap.entries()) {
138
+ defs.push({ id: toPatternId(pk), name, fill: '#ddd', stroke: '#666' })
139
+ }
140
+ const seenComposite = new SvelteSet()
141
+ for (const d of this.processedData) {
142
+ const fk = d[ff]
143
+ const pk = d[pf]
144
+ if (pk === null || pk === undefined) continue
145
+ const compositeKey = `${fk}::${pk}`
146
+ if (seenComposite.has(compositeKey)) continue
147
+ seenComposite.add(compositeKey)
148
+ const name = this.patternMap.get(pk) ?? PATTERN_ORDER[0]
149
+ const color = this.colorMap.get(fk) ?? { fill: '#ddd', stroke: '#666' }
150
+ defs.push({ id: toPatternId(compositeKey), name, fill: color.fill, stroke: color.stroke })
151
+ }
152
+ return defs
153
+ })()
154
+ )
155
+
156
+ /** @type {Map<unknown, string>} */
157
+ symbolMap = $derived(
158
+ this.#channels.symbol
159
+ ? assignSymbols(distinct(this.#rawData, this.#channels.symbol))
160
+ : new SvelteMap()
161
+ )
162
+
163
+ get innerWidth() {
164
+ return this.#width - this.#margin.left - this.#margin.right
165
+ }
166
+ get innerHeight() {
167
+ return this.#height - this.#margin.top - this.#margin.bottom
168
+ }
169
+
170
+ xScale = $derived(
171
+ this.#channels.x ? buildXScale(this.processedData, this.#channels.x, this.innerWidth) : null
172
+ )
173
+
174
+ yScale = $derived(
175
+ this.#channels.y
176
+ ? buildYScale(this.processedData, this.#channels.y, this.innerHeight, this.#layers)
177
+ : null
178
+ )
179
+
180
+ sizeScale = $derived(
181
+ this.#channels.size ? buildSizeScale(this.processedData, this.#channels.size) : null
182
+ )
183
+
184
+ bars = $derived(
185
+ this.xScale && this.yScale
186
+ ? buildBars(
187
+ this.processedData,
188
+ this.#channels,
189
+ this.xScale,
190
+ this.yScale,
191
+ this.colorMap,
192
+ this.patternMap
193
+ )
194
+ : []
195
+ )
196
+
197
+ lines = $derived(
198
+ this.xScale && this.yScale
199
+ ? buildLines(
200
+ this.processedData,
201
+ this.#channels,
202
+ this.xScale,
203
+ this.yScale,
204
+ this.colorMap,
205
+ this.#curve
206
+ )
207
+ : []
208
+ )
209
+
210
+ areas = $derived(
211
+ this.xScale && this.yScale
212
+ ? buildAreas(
213
+ this.processedData,
214
+ this.#channels,
215
+ this.xScale,
216
+ this.yScale,
217
+ this.colorMap,
218
+ this.#curve,
219
+ this.patternMap
220
+ )
221
+ : []
222
+ )
223
+
224
+ arcs = $derived(
225
+ this.#channels.y
226
+ ? buildArcs(this.processedData, this.#channels, this.colorMap, this.#width, this.#height)
227
+ : []
228
+ )
229
+
230
+ points = $derived(
231
+ this.xScale && this.yScale
232
+ ? buildPoints(
233
+ this.processedData,
234
+ this.#channels,
235
+ this.xScale,
236
+ this.yScale,
237
+ this.colorMap,
238
+ this.sizeScale,
239
+ this.symbolMap
240
+ )
241
+ : []
242
+ )
243
+
244
+ legendGroups = $derived(
245
+ buildLegendGroups(this.#channels, this.colorMap, this.patternMap, this.symbolMap)
246
+ )
247
+
248
+ get margin() {
249
+ return this.#margin
250
+ }
251
+ get width() {
252
+ return this.#width
253
+ }
254
+ get height() {
255
+ return this.#height
256
+ }
257
+ get mode() {
258
+ return this.#mode
259
+ }
260
+
261
+ /**
262
+ * @param {{ data?: Object[], channels?: Object, width?: number, height?: number, mode?: string, margin?: Object, layers?: Object[], curve?: string, stat?: string|Function }} opts
263
+ * Supported channel keys: `x`, `y`, `fill`, `color`, `pattern`, `symbol`, `size`, `label`.
264
+ * `frame` is reserved for future animation use (no-op).
265
+ */
266
+ update(opts = {}) {
267
+ if (opts.data !== undefined) this.#rawData = opts.data
268
+ if (opts.channels !== undefined) this.#channels = opts.channels
269
+ if (opts.width !== undefined) this.#width = opts.width
270
+ if (opts.height !== undefined) this.#height = opts.height
271
+ if (opts.mode !== undefined) this.#mode = opts.mode
272
+ if (opts.margin !== undefined) this.#margin = { ...DEFAULT_MARGIN, ...opts.margin }
273
+ if (opts.layers !== undefined) this.#layers = opts.layers
274
+ if (opts.curve !== undefined) this.#curve = opts.curve
275
+ if (opts.stat !== undefined) this.#stat = opts.stat
276
+ }
277
+ }
@@ -0,0 +1,157 @@
1
+ import { scaleSequential, scaleDiverging } from 'd3-scale'
2
+ import { extent } from 'd3-array'
3
+ import * as chromatic from 'd3-scale-chromatic'
4
+ import masterPalette from '../palette.json'
5
+ import { defaultPreset } from '../preset.js'
6
+
7
+ /**
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.).
10
+ * @param {unknown} value
11
+ * @returns {boolean}
12
+ */
13
+ export function isLiteralColor(value) {
14
+ if (!value || typeof value !== 'string') return false
15
+ if (/^#([0-9a-fA-F]{3,8})$/.test(value)) return true
16
+ if (/^(rgb|rgba|hsl|hsla|oklch|oklab|hwb|lab|lch|color)\s*\(/i.test(value)) return true
17
+ return false
18
+ }
19
+
20
+ /**
21
+ * Extracts distinct values for a given field from the data array.
22
+ * @param {Object[]} data
23
+ * @param {string|null} field
24
+ * @returns {unknown[]}
25
+ */
26
+ export function distinct(data, field) {
27
+ if (!field) return []
28
+ return [...new Set(data.map((d) => d[field]))].filter((v) => v !== null && v !== undefined)
29
+ }
30
+
31
+ /**
32
+ * Assigns palette colors to an array of distinct values.
33
+ * @param {unknown[]} values
34
+ * @param {'light'|'dark'} mode
35
+ * @param {typeof defaultPreset} preset
36
+ * @returns {Map<unknown, {fill: string, stroke: string}>}
37
+ */
38
+ export function assignColors(values, mode = 'light', preset = defaultPreset) {
39
+ const { colors, shades } = preset
40
+ const { fill, stroke } = shades[mode]
41
+ return new Map(
42
+ values.map((v, i) => {
43
+ const colorName = colors[i % colors.length]
44
+ const group = masterPalette[colorName]
45
+ return [
46
+ v,
47
+ {
48
+ fill: group?.[fill] ?? '#888',
49
+ stroke: group?.[stroke] ?? '#444'
50
+ }
51
+ ]
52
+ })
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Named sequential color schemes from d3-scale-chromatic.
58
+ * Keys are lowercase user-facing names.
59
+ * @type {Record<string, (t: number) => string>}
60
+ */
61
+ const SEQUENTIAL_SCHEMES = {
62
+ blues: chromatic.interpolateBlues,
63
+ greens: chromatic.interpolateGreens,
64
+ oranges: chromatic.interpolateOranges,
65
+ purples: chromatic.interpolatePurples,
66
+ reds: chromatic.interpolateReds,
67
+ greys: chromatic.interpolateGreys,
68
+ bugn: chromatic.interpolateBuGn,
69
+ bupu: chromatic.interpolateBuPu,
70
+ gnbu: chromatic.interpolateGnBu,
71
+ orrd: chromatic.interpolateOrRd,
72
+ pubu: chromatic.interpolatePuBu,
73
+ purd: chromatic.interpolatePuRd,
74
+ rdpu: chromatic.interpolateRdPu,
75
+ ylgn: chromatic.interpolateYlGn,
76
+ ylgnbu: chromatic.interpolateYlGnBu,
77
+ ylorbr: chromatic.interpolateYlOrBr,
78
+ ylorrd: chromatic.interpolateYlOrRd,
79
+ viridis: chromatic.interpolateViridis,
80
+ inferno: chromatic.interpolateInferno,
81
+ magma: chromatic.interpolateMagma,
82
+ plasma: chromatic.interpolatePlasma,
83
+ cividis: chromatic.interpolateCividis,
84
+ turbo: chromatic.interpolateTurbo,
85
+ warm: chromatic.interpolateWarm,
86
+ cool: chromatic.interpolateCool
87
+ }
88
+
89
+ /**
90
+ * Named diverging color schemes from d3-scale-chromatic.
91
+ * @type {Record<string, (t: number) => string>}
92
+ */
93
+ const DIVERGING_SCHEMES = {
94
+ rdbu: chromatic.interpolateRdBu,
95
+ rdylbu: chromatic.interpolateRdYlBu,
96
+ rdylgn: chromatic.interpolateRdYlGn,
97
+ brbg: chromatic.interpolateBrBG,
98
+ piyg: chromatic.interpolatePiYG,
99
+ prgn: chromatic.interpolatePRGn,
100
+ puor: chromatic.interpolatePuOr,
101
+ rdgy: chromatic.interpolateRdGy,
102
+ spectral: chromatic.interpolateSpectral
103
+ }
104
+
105
+ /**
106
+ * Resolves a color scheme name to an interpolation function.
107
+ * Falls back to the first scheme in the given map if the name is unknown.
108
+ * @param {string|undefined} name
109
+ * @param {Record<string, (t: number) => string>} schemes
110
+ * @returns {(t: number) => string}
111
+ */
112
+ function resolveScheme(name, schemes) {
113
+ if (!name) return Object.values(schemes)[0]
114
+ const key = name.toLowerCase().replace(/[-_\s]/g, '')
115
+ return schemes[key] ?? Object.values(schemes)[0]
116
+ }
117
+
118
+ /**
119
+ * Builds a sequential color scale for continuous numeric data.
120
+ * Maps data values to a gradient from light to dark.
121
+ *
122
+ * @param {Object[]} data
123
+ * @param {string} field
124
+ * @param {Object} [opts]
125
+ * @param {string} [opts.colorScheme] - Named scheme (e.g. 'blues', 'viridis')
126
+ * @param {[number, number]} [opts.colorDomain] - Explicit [min, max]
127
+ * @returns {{ scale: (v: number) => string, domain: [number, number], type: 'sequential' }}
128
+ */
129
+ export function buildSequentialScale(data, field, opts = {}) {
130
+ const values = data.map((d) => d[field]).filter((v) => v !== null && v !== undefined).map(Number)
131
+ const domain = opts.colorDomain ?? extent(values)
132
+ const interpolator = resolveScheme(opts.colorScheme, SEQUENTIAL_SCHEMES)
133
+ const scale = scaleSequential(interpolator).domain(domain)
134
+ return { scale, domain, type: 'sequential' }
135
+ }
136
+
137
+ /**
138
+ * Builds a diverging color scale for numeric data centered on a midpoint.
139
+ * Maps data values to a gradient diverging from a center color.
140
+ *
141
+ * @param {Object[]} data
142
+ * @param {string} field
143
+ * @param {Object} [opts]
144
+ * @param {string} [opts.colorScheme] - Named scheme (e.g. 'rdbu', 'spectral')
145
+ * @param {number} [opts.colorMidpoint] - Center value (default 0)
146
+ * @param {[number, number, number]} [opts.colorDomain] - Explicit [min, mid, max]
147
+ * @returns {{ scale: (v: number) => string, domain: [number, number, number], type: 'diverging' }}
148
+ */
149
+ export function buildDivergingScale(data, field, opts = {}) {
150
+ const values = data.map((d) => d[field]).filter((v) => v !== null && v !== undefined).map(Number)
151
+ const [min, max] = extent(values)
152
+ const mid = opts.colorMidpoint ?? 0
153
+ const domain = opts.colorDomain ?? [min ?? 0, mid, max ?? 0]
154
+ const interpolator = resolveScheme(opts.colorScheme, DIVERGING_SCHEMES)
155
+ const scale = scaleDiverging(interpolator).domain(domain)
156
+ return { scale, domain, type: 'diverging' }
157
+ }
@@ -0,0 +1,56 @@
1
+ import {} from './types.js'
2
+
3
+ /**
4
+ * @typedef {import('./types').ChartMargin} ChartMargin
5
+ * @typedef {import('./types').ChartDimensions} ChartDimensions
6
+ */
7
+
8
+ /**
9
+ * Default chart margin
10
+ * @type {ChartMargin}
11
+ */
12
+ export const DEFAULT_MARGIN = { top: 20, right: 30, bottom: 40, left: 50 }
13
+
14
+ /**
15
+ * Creates chart dimensions based on width, height and margins
16
+ *
17
+ * @param {number} width - Total chart width
18
+ * @param {number} height - Total chart height
19
+ * @param {ChartMargin} margin - Chart margins
20
+ * @returns {ChartDimensions} Chart dimensions
21
+ */
22
+ export function createDimensions(width = 600, height = 400, margin = DEFAULT_MARGIN) {
23
+ return {
24
+ width,
25
+ height,
26
+ margin: { ...margin },
27
+ innerWidth: width - margin.left - margin.right,
28
+ innerHeight: height - margin.top - margin.bottom
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Updates existing dimensions with new values
34
+ *
35
+ * @param {ChartDimensions} dimensions - Current dimensions
36
+ * @param {Object} updates - Values to update
37
+ * @param {number} [updates.width] - New width
38
+ * @param {number} [updates.height] - New height
39
+ * @param {ChartMargin} [updates.margin] - New margin
40
+ * @returns {ChartDimensions} Updated dimensions
41
+ */
42
+ export function updateDimensions(dimensions, updates = {}) {
43
+ const newDimensions = { ...dimensions }
44
+
45
+ if (updates.width !== undefined) newDimensions.width = updates.width
46
+ if (updates.height !== undefined) newDimensions.height = updates.height
47
+ if (updates.margin !== undefined) newDimensions.margin = { ...updates.margin }
48
+
49
+ // Recalculate inner dimensions
50
+ newDimensions.innerWidth =
51
+ newDimensions.width - newDimensions.margin.left - newDimensions.margin.right
52
+ newDimensions.innerHeight =
53
+ newDimensions.height - newDimensions.margin.top - newDimensions.margin.bottom
54
+
55
+ return newDimensions
56
+ }