@rokkit/chart 1.0.0-next.146 → 1.0.0-next.148

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 (157) hide show
  1. package/dist/Plot/index.d.ts +4 -0
  2. package/dist/PlotState.svelte.d.ts +47 -0
  3. package/dist/crossfilter/createCrossFilter.svelte.d.ts +15 -0
  4. package/dist/geoms/lib/areas.d.ts +52 -0
  5. package/dist/geoms/lib/bars.d.ts +3 -0
  6. package/dist/index.d.ts +38 -1
  7. package/dist/lib/brewing/BoxBrewer.svelte.d.ts +10 -0
  8. package/dist/lib/brewing/CartesianBrewer.svelte.d.ts +8 -0
  9. package/dist/lib/brewing/PieBrewer.svelte.d.ts +8 -0
  10. package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +9 -0
  11. package/dist/lib/brewing/brewer.svelte.d.ts +145 -0
  12. package/dist/lib/brewing/colors.d.ts +17 -0
  13. package/dist/lib/brewing/marks/arcs.d.ts +17 -0
  14. package/dist/lib/brewing/marks/areas.d.ts +31 -0
  15. package/dist/lib/brewing/marks/bars.d.ts +1 -0
  16. package/dist/lib/brewing/marks/boxes.d.ts +24 -0
  17. package/dist/lib/brewing/marks/lines.d.ts +24 -0
  18. package/dist/lib/brewing/marks/points.d.ts +40 -0
  19. package/dist/lib/brewing/marks/violins.d.ts +20 -0
  20. package/dist/lib/brewing/patterns.d.ts +14 -0
  21. package/dist/lib/brewing/scales.d.ts +28 -0
  22. package/dist/lib/brewing/stats.d.ts +31 -0
  23. package/dist/lib/brewing/symbols.d.ts +7 -0
  24. package/dist/lib/plot/chartProps.d.ts +177 -0
  25. package/dist/lib/plot/crossfilter.d.ts +13 -0
  26. package/dist/lib/plot/facet.d.ts +24 -0
  27. package/dist/lib/plot/frames.d.ts +45 -0
  28. package/dist/lib/plot/helpers.d.ts +3 -0
  29. package/dist/lib/plot/preset.d.ts +29 -0
  30. package/dist/lib/plot/scales.d.ts +5 -0
  31. package/dist/lib/plot/stat.d.ts +32 -0
  32. package/dist/lib/plot/types.d.ts +89 -0
  33. package/dist/lib/scales.svelte.d.ts +1 -1
  34. package/dist/lib/swatch.d.ts +12 -0
  35. package/dist/lib/utils.d.ts +1 -0
  36. package/dist/lib/xscale.d.ts +11 -0
  37. package/dist/patterns/index.d.ts +4 -9
  38. package/dist/patterns/patterns.d.ts +72 -0
  39. package/dist/patterns/scale.d.ts +30 -0
  40. package/package.json +9 -3
  41. package/src/AnimatedPlot.svelte +194 -0
  42. package/src/Chart.svelte +101 -0
  43. package/src/FacetPlot/Panel.svelte +23 -0
  44. package/src/FacetPlot.svelte +90 -0
  45. package/src/Plot/Arc.svelte +29 -0
  46. package/src/Plot/Area.svelte +25 -0
  47. package/src/Plot/Axis.svelte +62 -84
  48. package/src/Plot/Grid.svelte +20 -58
  49. package/src/Plot/Legend.svelte +160 -120
  50. package/src/Plot/Line.svelte +27 -0
  51. package/src/Plot/Point.svelte +27 -0
  52. package/src/Plot/Timeline.svelte +95 -0
  53. package/src/Plot/Tooltip.svelte +81 -0
  54. package/src/Plot/index.js +4 -0
  55. package/src/Plot.svelte +189 -0
  56. package/src/PlotState.svelte.js +278 -0
  57. package/src/Sparkline.svelte +69 -0
  58. package/src/charts/AreaChart.svelte +25 -0
  59. package/src/charts/BarChart.svelte +26 -0
  60. package/src/charts/BoxPlot.svelte +21 -0
  61. package/src/charts/BubbleChart.svelte +23 -0
  62. package/src/charts/LineChart.svelte +26 -0
  63. package/src/charts/PieChart.svelte +25 -0
  64. package/src/charts/ScatterPlot.svelte +25 -0
  65. package/src/charts/ViolinPlot.svelte +21 -0
  66. package/src/crossfilter/CrossFilter.svelte +38 -0
  67. package/src/crossfilter/FilterBar.svelte +32 -0
  68. package/src/crossfilter/FilterSlider.svelte +79 -0
  69. package/src/crossfilter/createCrossFilter.svelte.js +113 -0
  70. package/src/elements/SymbolGrid.svelte +6 -7
  71. package/src/geoms/Arc.svelte +81 -0
  72. package/src/geoms/Area.svelte +50 -0
  73. package/src/geoms/Bar.svelte +142 -0
  74. package/src/geoms/Box.svelte +101 -0
  75. package/src/geoms/LabelPill.svelte +17 -0
  76. package/src/geoms/Line.svelte +100 -0
  77. package/src/geoms/Point.svelte +100 -0
  78. package/src/geoms/Violin.svelte +44 -0
  79. package/src/geoms/lib/areas.js +131 -0
  80. package/src/geoms/lib/bars.js +172 -0
  81. package/src/index.js +52 -3
  82. package/src/lib/brewing/BoxBrewer.svelte.js +56 -0
  83. package/src/lib/brewing/CartesianBrewer.svelte.js +16 -0
  84. package/src/lib/brewing/PieBrewer.svelte.js +14 -0
  85. package/src/lib/brewing/ViolinBrewer.svelte.js +55 -0
  86. package/src/lib/brewing/brewer.svelte.js +229 -0
  87. package/src/lib/brewing/colors.js +22 -0
  88. package/src/lib/brewing/marks/arcs.js +43 -0
  89. package/src/lib/brewing/marks/areas.js +59 -0
  90. package/src/lib/brewing/marks/bars.js +49 -0
  91. package/src/lib/brewing/marks/boxes.js +75 -0
  92. package/src/lib/brewing/marks/lines.js +48 -0
  93. package/src/lib/brewing/marks/points.js +57 -0
  94. package/src/lib/brewing/marks/violins.js +90 -0
  95. package/src/lib/brewing/patterns.js +31 -0
  96. package/src/lib/brewing/scales.js +51 -0
  97. package/src/lib/brewing/scales.svelte.js +2 -26
  98. package/src/lib/brewing/stats.js +62 -0
  99. package/src/lib/brewing/symbols.js +10 -0
  100. package/src/lib/plot/chartProps.js +76 -0
  101. package/src/lib/plot/crossfilter.js +16 -0
  102. package/src/lib/plot/facet.js +58 -0
  103. package/src/lib/plot/frames.js +90 -0
  104. package/src/lib/plot/helpers.js +14 -0
  105. package/src/lib/plot/preset.js +53 -0
  106. package/src/lib/plot/scales.js +56 -0
  107. package/src/lib/plot/stat.js +92 -0
  108. package/src/lib/plot/types.js +65 -0
  109. package/src/lib/scales.svelte.js +2 -26
  110. package/src/lib/swatch.js +13 -0
  111. package/src/lib/utils.js +9 -0
  112. package/src/lib/xscale.js +31 -0
  113. package/src/patterns/DefinePatterns.svelte +32 -0
  114. package/src/patterns/PatternDef.svelte +27 -0
  115. package/src/patterns/index.js +4 -14
  116. package/src/patterns/patterns.js +208 -0
  117. package/src/patterns/scale.js +87 -0
  118. package/src/spec/chart-spec.js +29 -0
  119. package/src/symbols/Shape.svelte +1 -1
  120. package/src/symbols/constants/index.js +1 -1
  121. package/dist/old_lib/index.d.ts +0 -4
  122. package/dist/old_lib/plots.d.ts +0 -3
  123. package/dist/old_lib/swatch.d.ts +0 -285
  124. package/dist/old_lib/utils.d.ts +0 -1
  125. package/dist/patterns/paths/constants.d.ts +0 -1
  126. package/dist/template/constants.d.ts +0 -43
  127. package/dist/template/shapes/index.d.ts +0 -4
  128. package/src/old_lib/index.js +0 -4
  129. package/src/old_lib/plots.js +0 -27
  130. package/src/old_lib/swatch.js +0 -16
  131. package/src/old_lib/utils.js +0 -8
  132. package/src/patterns/Brick.svelte +0 -15
  133. package/src/patterns/Circles.svelte +0 -18
  134. package/src/patterns/CrossHatch.svelte +0 -12
  135. package/src/patterns/CurvedWave.svelte +0 -7
  136. package/src/patterns/Dots.svelte +0 -20
  137. package/src/patterns/OutlineCircles.svelte +0 -13
  138. package/src/patterns/Tile.svelte +0 -16
  139. package/src/patterns/Triangles.svelte +0 -13
  140. package/src/patterns/Waves.svelte +0 -9
  141. package/src/patterns/paths/NamedPattern.svelte +0 -9
  142. package/src/patterns/paths/constants.js +0 -4
  143. package/src/template/Texture.svelte +0 -13
  144. package/src/template/constants.js +0 -43
  145. package/src/template/shapes/Circles.svelte +0 -15
  146. package/src/template/shapes/Lines.svelte +0 -16
  147. package/src/template/shapes/Path.svelte +0 -9
  148. package/src/template/shapes/Polygons.svelte +0 -15
  149. package/src/template/shapes/index.js +0 -4
  150. /package/dist/{old_lib → lib}/brewer.d.ts +0 -0
  151. /package/dist/{old_lib → lib}/chart.d.ts +0 -0
  152. /package/dist/{old_lib → lib}/grid.d.ts +0 -0
  153. /package/dist/{old_lib → lib}/ticks.d.ts +0 -0
  154. /package/src/{old_lib → lib}/brewer.js +0 -0
  155. /package/src/{old_lib → lib}/chart.js +0 -0
  156. /package/src/{old_lib → lib}/grid.js +0 -0
  157. /package/src/{old_lib → lib}/ticks.js +0 -0
package/src/index.js CHANGED
@@ -3,16 +3,65 @@ import Axis from './Plot/Axis.svelte'
3
3
  import Bar from './Plot/Bar.svelte'
4
4
  import Grid from './Plot/Grid.svelte'
5
5
  import Legend from './Plot/Legend.svelte'
6
+ import Line from './Plot/Line.svelte'
7
+ import Area from './Plot/Area.svelte'
8
+ import Point from './Plot/Point.svelte'
9
+ import Arc from './Plot/Arc.svelte'
6
10
 
7
- // Export components
8
- export const Plot = {
11
+ // Export components (old Plot namespace — renamed to avoid collision with new PlotChart export)
12
+ export const PlotLayers = {
9
13
  Root,
10
14
  Axis,
11
15
  Bar,
12
16
  Grid,
13
- Legend
17
+ Legend,
18
+ Line,
19
+ Area,
20
+ Point,
21
+ Arc
14
22
  }
15
23
 
24
+ // New Plot system
25
+ export { default as PlotChart } from './Plot.svelte'
26
+
27
+ // Facets and Animation
28
+ export { default as FacetPlot } from './FacetPlot.svelte'
29
+ export { default as AnimatedPlot } from './AnimatedPlot.svelte'
30
+
31
+ // Geom components (for declarative use inside PlotChart)
32
+ export { default as GeomBar } from './geoms/Bar.svelte'
33
+ export { default as GeomLine } from './geoms/Line.svelte'
34
+ export { default as GeomArea } from './geoms/Area.svelte'
35
+ export { default as GeomPoint } from './geoms/Point.svelte'
36
+ export { default as GeomArc } from './geoms/Arc.svelte'
37
+ export { default as GeomBox } from './geoms/Box.svelte'
38
+ export { default as GeomViolin } from './geoms/Violin.svelte'
39
+
40
+ // Export standalone components
41
+ export { default as Chart } from './Chart.svelte'
42
+ export { default as Sparkline } from './Sparkline.svelte'
43
+ export { default as BarChart } from './charts/BarChart.svelte'
44
+ export { default as LineChart } from './charts/LineChart.svelte'
45
+ export { default as AreaChart } from './charts/AreaChart.svelte'
46
+ export { default as PieChart } from './charts/PieChart.svelte'
47
+ export { default as ScatterPlot } from './charts/ScatterPlot.svelte'
48
+ export { default as BoxPlot } from './charts/BoxPlot.svelte'
49
+ export { default as ViolinPlot } from './charts/ViolinPlot.svelte'
50
+ export { default as BubbleChart } from './charts/BubbleChart.svelte'
51
+
52
+ // Export state and types
53
+ export { PlotState } from './PlotState.svelte.js'
54
+
16
55
  // Export utilities
17
56
  export { ChartBrewer } from './lib/brewing/index.svelte.js'
18
57
  export * from './lib/brewing/index.svelte.js'
58
+ export { CartesianBrewer } from './lib/brewing/CartesianBrewer.svelte.js'
59
+ export { PieBrewer } from './lib/brewing/PieBrewer.svelte.js'
60
+ export { BoxBrewer } from './lib/brewing/BoxBrewer.svelte.js'
61
+ export { ViolinBrewer } from './lib/brewing/ViolinBrewer.svelte.js'
62
+
63
+ // CrossFilter system
64
+ export { createCrossFilter } from './crossfilter/createCrossFilter.svelte.js'
65
+ export { default as CrossFilter } from './crossfilter/CrossFilter.svelte'
66
+ export { default as FilterBar } from './crossfilter/FilterBar.svelte'
67
+ export { default as FilterSlider } from './crossfilter/FilterSlider.svelte'
@@ -0,0 +1,56 @@
1
+ import { quantile, ascending } from 'd3-array'
2
+ import { dataset } from '@rokkit/data'
3
+ import { ChartBrewer } from './brewer.svelte.js'
4
+ import { buildBoxes } from './marks/boxes.js'
5
+ import { buildXScale, buildYScale } from './scales.js'
6
+
7
+ function sortedQuantile(values, p) {
8
+ return quantile([...values].sort(ascending), p)
9
+ }
10
+
11
+ /**
12
+ * Brewer for box plots. Always computes quartile statistics regardless of the stat prop.
13
+ * Groups by x + fill (primary) or x + color (fallback).
14
+ * fill = box interior color, color = whisker/outline stroke.
15
+ */
16
+ export class BoxBrewer extends ChartBrewer {
17
+ transform(data, channels) {
18
+ if (!channels.x || !channels.y) return data
19
+ const by = [channels.x, channels.fill ?? channels.color].filter(Boolean)
20
+ return dataset(data)
21
+ .groupBy(...by)
22
+ .summarize((row) => row[channels.y], {
23
+ q1: (v) => sortedQuantile(v, 0.25),
24
+ median: (v) => sortedQuantile(v, 0.5),
25
+ q3: (v) => sortedQuantile(v, 0.75),
26
+ iqr_min: (v) => { const q1 = sortedQuantile(v, 0.25); const q3 = sortedQuantile(v, 0.75); return q1 - 1.5 * (q3 - q1) },
27
+ iqr_max: (v) => { const q1 = sortedQuantile(v, 0.25); const q3 = sortedQuantile(v, 0.75); return q3 + 1.5 * (q3 - q1) }
28
+ })
29
+ .rollup()
30
+ .select()
31
+ }
32
+
33
+ /**
34
+ * Override xScale to use processedData (post-transform rows have the x field).
35
+ */
36
+ xScale = $derived(
37
+ this.channels.x && this.processedData.length > 0
38
+ ? buildXScale(this.processedData, this.channels.x, this.innerWidth)
39
+ : null
40
+ )
41
+
42
+ /**
43
+ * Override yScale to use iqr_max as the upper bound of the quartile data.
44
+ */
45
+ yScale = $derived(
46
+ this.channels.y && this.processedData.length > 0
47
+ ? buildYScale(this.processedData, 'iqr_max', this.innerHeight)
48
+ : null
49
+ )
50
+
51
+ boxes = $derived(
52
+ this.xScale && this.yScale
53
+ ? buildBoxes(this.processedData, this.channels, this.xScale, this.yScale, this.colorMap)
54
+ : []
55
+ )
56
+ }
@@ -0,0 +1,16 @@
1
+ import { ChartBrewer } from './brewer.svelte.js'
2
+ import { applyAggregate } from './stats.js'
3
+
4
+ /**
5
+ * Brewer for cartesian charts (Bar, Line, Area).
6
+ * Groups by x (and fill/color if set) and applies the given stat.
7
+ */
8
+ export class CartesianBrewer extends ChartBrewer {
9
+ transform(data, channels, stat) {
10
+ if (stat === 'identity' || !channels.x || !channels.y) return data
11
+ // Group by all mapped aesthetic dimensions so they survive aggregation.
12
+ // e.g. x=region, fill=region, pattern=quarter → by=['region','quarter']
13
+ const by = [...new Set([channels.x, channels.fill, channels.color, channels.pattern].filter(Boolean))]
14
+ return applyAggregate(data, { by, value: channels.y, stat })
15
+ }
16
+ }
@@ -0,0 +1,14 @@
1
+ import { ChartBrewer } from './brewer.svelte.js'
2
+ import { applyAggregate } from './stats.js'
3
+
4
+ /**
5
+ * Brewer for pie charts. Always aggregates by the label field.
6
+ * 'identity' is not meaningful for pie charts — falls back to 'sum'.
7
+ */
8
+ export class PieBrewer extends ChartBrewer {
9
+ transform(data, channels, stat) {
10
+ if (!channels.label || !channels.y) return data
11
+ const effectiveStat = stat === 'identity' ? 'sum' : (stat ?? 'sum')
12
+ return applyAggregate(data, { by: [channels.label], value: channels.y, stat: effectiveStat })
13
+ }
14
+ }
@@ -0,0 +1,55 @@
1
+ import { quantile, ascending } from 'd3-array'
2
+ import { dataset } from '@rokkit/data'
3
+ import { ChartBrewer } from './brewer.svelte.js'
4
+ import { buildViolins } from './marks/violins.js'
5
+ import { buildXScale, buildYScale } from './scales.js'
6
+
7
+ function sortedQuantile(values, p) {
8
+ return quantile([...values].sort(ascending), p)
9
+ }
10
+
11
+ /**
12
+ * Brewer for violin plots. Computes the same quartile statistics as BoxBrewer.
13
+ * fill = violin body color, color = outline stroke.
14
+ */
15
+ export class ViolinBrewer extends ChartBrewer {
16
+ transform(data, channels) {
17
+ if (!channels.x || !channels.y) return data
18
+ const by = [channels.x, channels.fill ?? channels.color].filter(Boolean)
19
+ return dataset(data)
20
+ .groupBy(...by)
21
+ .summarize((row) => row[channels.y], {
22
+ q1: (v) => sortedQuantile(v, 0.25),
23
+ median: (v) => sortedQuantile(v, 0.5),
24
+ q3: (v) => sortedQuantile(v, 0.75),
25
+ iqr_min: (v) => { const q1 = sortedQuantile(v, 0.25); const q3 = sortedQuantile(v, 0.75); return q1 - 1.5 * (q3 - q1) },
26
+ iqr_max: (v) => { const q1 = sortedQuantile(v, 0.25); const q3 = sortedQuantile(v, 0.75); return q3 + 1.5 * (q3 - q1) }
27
+ })
28
+ .rollup()
29
+ .select()
30
+ }
31
+
32
+ /**
33
+ * Override xScale to use processedData (post-transform rows have the x field).
34
+ */
35
+ xScale = $derived(
36
+ this.channels.x && this.processedData.length > 0
37
+ ? buildXScale(this.processedData, this.channels.x, this.innerWidth)
38
+ : null
39
+ )
40
+
41
+ /**
42
+ * Override yScale to use iqr_max as the upper bound of the quartile data.
43
+ */
44
+ yScale = $derived(
45
+ this.channels.y && this.processedData.length > 0
46
+ ? buildYScale(this.processedData, 'iqr_max', this.innerHeight)
47
+ : null
48
+ )
49
+
50
+ violins = $derived(
51
+ this.xScale && this.yScale
52
+ ? buildViolins(this.processedData, this.channels, this.xScale, this.yScale, this.colorMap)
53
+ : []
54
+ )
55
+ }
@@ -0,0 +1,229 @@
1
+ import { distinct, assignColors } from './colors.js'
2
+ import { assignPatterns, toPatternId, PATTERN_ORDER } from './patterns.js'
3
+ import { assignSymbols } from './symbols.js'
4
+ import { buildXScale, buildYScale, buildSizeScale } from './scales.js'
5
+ import { buildBars } from './marks/bars.js'
6
+ import { buildLines } from './marks/lines.js'
7
+ import { buildAreas } from './marks/areas.js'
8
+ import { buildArcs } from './marks/arcs.js'
9
+ import { buildPoints } from './marks/points.js'
10
+
11
+ const DEFAULT_MARGIN = { top: 20, right: 20, bottom: 40, left: 50 }
12
+
13
+ /**
14
+ * Groups aesthetic channel mappings by field name, merging aesthetics that
15
+ * share the same field into one legend section.
16
+ *
17
+ * @param {{ fill?: string, color?: string, pattern?: string, symbol?: string }} channels
18
+ * `fill` takes precedence over `color` for polygon charts (bars, areas, pie slices).
19
+ * @param {Map<unknown, {fill:string, stroke:string}>} colorMap
20
+ * @param {Map<unknown, string>} patternMap
21
+ * @param {Map<unknown, string>} symbolMap
22
+ * @returns {{ field: string, items: { label: string, fill: string|null, stroke: string|null, patternId: string|null, shape: string|null }[] }[]}
23
+ */
24
+ export function buildLegendGroups(channels, colorMap, patternMap, symbolMap) {
25
+ const cf = channels.fill ?? channels.color
26
+ const { pattern: pf, symbol: sf } = channels
27
+ const byField = new Map()
28
+
29
+ if (cf) {
30
+ byField.set(cf, { aesthetics: ['color'], keys: [...colorMap.keys()] })
31
+ }
32
+ if (pf) {
33
+ if (byField.has(pf)) {
34
+ byField.get(pf).aesthetics.push('pattern')
35
+ } else {
36
+ byField.set(pf, { aesthetics: ['pattern'], keys: [...patternMap.keys()] })
37
+ }
38
+ }
39
+ if (sf) {
40
+ if (byField.has(sf)) {
41
+ byField.get(sf).aesthetics.push('symbol')
42
+ } else {
43
+ byField.set(sf, { aesthetics: ['symbol'], keys: [...symbolMap.keys()] })
44
+ }
45
+ }
46
+
47
+ return [...byField.entries()].map(([field, { aesthetics, keys }]) => ({
48
+ field,
49
+ items: keys.filter((k) => k !== null && k !== undefined).map((key) => ({
50
+ label: String(key),
51
+ fill: aesthetics.includes('color') ? (colorMap.get(key)?.fill ?? null) : null,
52
+ stroke: aesthetics.includes('color') ? (colorMap.get(key)?.stroke ?? null) : null,
53
+ patternId:
54
+ aesthetics.includes('pattern') && patternMap.has(key) ? toPatternId(key) : null,
55
+ shape: aesthetics.includes('symbol') ? (symbolMap.get(key) ?? 'circle') : null
56
+ }))
57
+ })).filter((group) => group.items.length > 0)
58
+ }
59
+
60
+ export class ChartBrewer {
61
+ #rawData = $state([])
62
+ #channels = $state({})
63
+ #width = $state(600)
64
+ #height = $state(400)
65
+ #mode = $state('light')
66
+ #margin = $state(DEFAULT_MARGIN)
67
+ #layers = $state([])
68
+ #curve = $state(/** @type {'linear'|'smooth'|'step'|undefined} */(undefined))
69
+ #stat = $state('identity')
70
+
71
+ /**
72
+ * Override in subclasses to apply stat aggregation.
73
+ * @param {Object[]} data
74
+ * @param {Object} channels
75
+ * @param {string|Function} stat
76
+ * @returns {Object[]}
77
+ */
78
+ transform(data, _channels, _stat) {
79
+ return data
80
+ }
81
+
82
+ /** Aggregated data — all derived marks read this, not #rawData */
83
+ processedData = $derived(this.transform(this.#rawData, this.#channels, this.#stat))
84
+
85
+ /** Exposes channels to subclasses for use in their own $derived properties */
86
+ get channels() { return this.#channels }
87
+
88
+ // Maps are built from rawData so the legend always reflects the full set of
89
+ // original values — independent of whichever stat aggregation is applied.
90
+ // e.g. pattern=quarter with stat=sum still shows all 8 quarters in the legend.
91
+
92
+ /** @type {Map<unknown, {fill:string,stroke:string}>} */
93
+ colorMap = $derived(
94
+ (this.#channels.fill ?? this.#channels.color)
95
+ ? assignColors(distinct(this.#rawData, this.#channels.fill ?? this.#channels.color), this.#mode)
96
+ : new Map()
97
+ )
98
+
99
+ /** @type {Map<unknown, string>} */
100
+ patternMap = $derived(
101
+ this.#channels.pattern
102
+ ? assignPatterns(distinct(this.#rawData, this.#channels.pattern))
103
+ : new Map()
104
+ )
105
+
106
+ /**
107
+ * Unified pattern defs for ChartPatternDefs.
108
+ * When fill and pattern map the same field, pattern key = color key (simple case).
109
+ * When they differ, each unique (fillKey, patternKey) pair gets its own pattern def
110
+ * so bars/areas can have distinct colors per region AND distinct textures per category.
111
+ * @type {Array<{ id: string, name: string, fill: string, stroke: string }>}
112
+ */
113
+ patternDefs = $derived((() => {
114
+ const pf = this.#channels.pattern
115
+ const ff = this.#channels.fill ?? this.#channels.color
116
+ if (!pf || this.patternMap.size === 0) return []
117
+ if (!ff || pf === ff) {
118
+ // Same field: pattern key = fill key — simple 1:1 lookup
119
+ return Array.from(this.patternMap.entries()).map(([key, name]) => {
120
+ const color = this.colorMap.get(key) ?? { fill: '#ddd', stroke: '#666' }
121
+ return { id: toPatternId(key), name, fill: color.fill, stroke: color.stroke }
122
+ })
123
+ }
124
+ // Different fields: need two sets of defs in the SVG:
125
+ // 1. Simple defs (neutral background) — referenced by legend swatches via toPatternId(patternKey)
126
+ // 2. Composite defs (fill-colored background) — referenced by bars via toPatternId(fillKey::patternKey)
127
+ const defs = []
128
+ for (const [pk, name] of this.patternMap.entries()) {
129
+ defs.push({ id: toPatternId(pk), name, fill: '#ddd', stroke: '#666' })
130
+ }
131
+ const seenComposite = new Set()
132
+ for (const d of this.processedData) {
133
+ const fk = d[ff]
134
+ const pk = d[pf]
135
+ if (pk === null || pk === undefined) continue
136
+ const compositeKey = `${fk}::${pk}`
137
+ if (seenComposite.has(compositeKey)) continue
138
+ seenComposite.add(compositeKey)
139
+ const name = this.patternMap.get(pk) ?? PATTERN_ORDER[0]
140
+ const color = this.colorMap.get(fk) ?? { fill: '#ddd', stroke: '#666' }
141
+ defs.push({ id: toPatternId(compositeKey), name, fill: color.fill, stroke: color.stroke })
142
+ }
143
+ return defs
144
+ })())
145
+
146
+ /** @type {Map<unknown, string>} */
147
+ symbolMap = $derived(
148
+ this.#channels.symbol
149
+ ? assignSymbols(distinct(this.#rawData, this.#channels.symbol))
150
+ : new Map()
151
+ )
152
+
153
+ get innerWidth() { return this.#width - this.#margin.left - this.#margin.right }
154
+ get innerHeight() { return this.#height - this.#margin.top - this.#margin.bottom }
155
+
156
+ xScale = $derived(
157
+ this.#channels.x
158
+ ? buildXScale(this.processedData, this.#channels.x, this.innerWidth)
159
+ : null
160
+ )
161
+
162
+ yScale = $derived(
163
+ this.#channels.y
164
+ ? buildYScale(this.processedData, this.#channels.y, this.innerHeight, this.#layers)
165
+ : null
166
+ )
167
+
168
+ sizeScale = $derived(
169
+ this.#channels.size
170
+ ? buildSizeScale(this.processedData, this.#channels.size)
171
+ : null
172
+ )
173
+
174
+ bars = $derived(
175
+ this.xScale && this.yScale
176
+ ? buildBars(this.processedData, this.#channels, this.xScale, this.yScale, this.colorMap, this.patternMap)
177
+ : []
178
+ )
179
+
180
+ lines = $derived(
181
+ this.xScale && this.yScale
182
+ ? buildLines(this.processedData, this.#channels, this.xScale, this.yScale, this.colorMap, this.#curve)
183
+ : []
184
+ )
185
+
186
+ areas = $derived(
187
+ this.xScale && this.yScale
188
+ ? buildAreas(this.processedData, this.#channels, this.xScale, this.yScale, this.colorMap, this.#curve, this.patternMap)
189
+ : []
190
+ )
191
+
192
+ arcs = $derived(
193
+ this.#channels.y
194
+ ? buildArcs(this.processedData, this.#channels, this.colorMap, this.#width, this.#height)
195
+ : []
196
+ )
197
+
198
+ points = $derived(
199
+ this.xScale && this.yScale
200
+ ? buildPoints(this.processedData, this.#channels, this.xScale, this.yScale, this.colorMap, this.sizeScale, this.symbolMap)
201
+ : []
202
+ )
203
+
204
+ legendGroups = $derived(
205
+ buildLegendGroups(this.#channels, this.colorMap, this.patternMap, this.symbolMap)
206
+ )
207
+
208
+ get margin() { return this.#margin }
209
+ get width() { return this.#width }
210
+ get height() { return this.#height }
211
+ get mode() { return this.#mode }
212
+
213
+ /**
214
+ * @param {{ data?: Object[], channels?: Object, width?: number, height?: number, mode?: string, margin?: Object, layers?: Object[], curve?: string, stat?: string|Function }} opts
215
+ * Supported channel keys: `x`, `y`, `fill`, `color`, `pattern`, `symbol`, `size`, `label`.
216
+ * `frame` is reserved for future animation use (no-op).
217
+ */
218
+ update(opts = {}) {
219
+ if (opts.data !== undefined) this.#rawData = opts.data
220
+ if (opts.channels !== undefined) this.#channels = opts.channels
221
+ if (opts.width !== undefined) this.#width = opts.width
222
+ if (opts.height !== undefined) this.#height = opts.height
223
+ if (opts.mode !== undefined) this.#mode = opts.mode
224
+ if (opts.margin !== undefined) this.#margin = { ...DEFAULT_MARGIN, ...opts.margin }
225
+ if (opts.layers !== undefined) this.#layers = opts.layers
226
+ if (opts.curve !== undefined) this.#curve = opts.curve
227
+ if (opts.stat !== undefined) this.#stat = opts.stat
228
+ }
229
+ }
@@ -0,0 +1,22 @@
1
+ import palette from './palette.json'
2
+
3
+ /**
4
+ * Extracts distinct values for a given field from the data array.
5
+ * @param {Object[]} data
6
+ * @param {string|null} field
7
+ * @returns {unknown[]}
8
+ */
9
+ export function distinct(data, field) {
10
+ if (!field) return []
11
+ return [...new Set(data.map((d) => d[field]))].filter((v) => v !== null && v !== undefined)
12
+ }
13
+
14
+ /**
15
+ * Assigns palette colors to an array of distinct values.
16
+ * @param {unknown[]} values
17
+ * @param {'light'|'dark'} mode
18
+ * @returns {Map<unknown, {fill: string, stroke: string}>}
19
+ */
20
+ export function assignColors(values, mode = 'light') {
21
+ return new Map(values.map((v, i) => [v, palette[i % palette.length].shades[mode]]))
22
+ }
@@ -0,0 +1,43 @@
1
+ import { pie, arc } from 'd3-shape'
2
+ import { toPatternId } from '../../brewing/patterns.js'
3
+
4
+ /**
5
+ * Builds arc geometry for pie/donut charts.
6
+ * @param {Object[]} data
7
+ * @param {{ color: string, y: string, pattern?: string }} channels
8
+ * @param {Map} colors
9
+ * @param {number} width
10
+ * @param {number} height
11
+ * @param {{ innerRadius?: number }} opts
12
+ * @param {Map<unknown, string>} [patterns]
13
+ */
14
+ export function buildArcs(data, channels, colors, width, height, opts = {}, patterns) {
15
+ const { color: lf, y: yf } = channels
16
+ const radius = Math.min(width, height) / 2
17
+ const innerRadius = opts.innerRadius ?? 0
18
+ const pieGen = pie().value((d) => Number(d[yf]))
19
+ const arcGen = arc().innerRadius(innerRadius).outerRadius(radius)
20
+ const slices = pieGen(data)
21
+ const total = slices.reduce((s, sl) => s + (sl.endAngle - sl.startAngle), 0)
22
+ // Label radius: midpoint between inner and outer (or 70% out for solid pie)
23
+ const labelRadius = innerRadius > 0 ? (innerRadius + radius) / 2 : radius * 0.65
24
+ const labelArc = arc().innerRadius(labelRadius).outerRadius(labelRadius)
25
+ return slices.map((slice) => {
26
+ const key = slice.data[lf]
27
+ const colorEntry = colors?.get(key) ?? { fill: '#888', stroke: '#fff' }
28
+ const patternId = key !== null && key !== undefined && patterns?.has(key)
29
+ ? toPatternId(String(key)) : null
30
+ const pct = Math.round(((slice.endAngle - slice.startAngle) / total) * 100)
31
+ const [cx, cy] = labelArc.centroid(slice)
32
+ return {
33
+ d: arcGen(slice),
34
+ fill: colorEntry.fill,
35
+ stroke: colorEntry.stroke,
36
+ key,
37
+ patternId,
38
+ pct,
39
+ centroid: [cx, cy],
40
+ data: slice.data
41
+ }
42
+ })
43
+ }
@@ -0,0 +1,59 @@
1
+ import { area, curveCatmullRom, curveStep } from 'd3-shape'
2
+ import { toPatternId } from '../patterns.js'
3
+
4
+ /**
5
+ * @param {Object[]} data
6
+ * @param {{ x: string, y: string, fill?: string, pattern?: string }} channels
7
+ * `fill` is the primary aesthetic — drives grouping and interior color.
8
+ * @param {Function} xScale
9
+ * @param {Function} yScale
10
+ * @param {Map} colors
11
+ * @param {'linear'|'smooth'|'step'} [curve]
12
+ * @param {Map} [patternMap]
13
+ */
14
+ export function buildAreas(data, channels, xScale, yScale, colors, curve, patternMap) {
15
+ const { x: xf, y: yf, pattern: pf } = channels
16
+ const cf = channels.fill // fill is the primary aesthetic for area charts
17
+ const innerHeight = yScale.range()[0]
18
+ const xPos = (d) => typeof xScale.bandwidth === 'function'
19
+ ? xScale(d[xf]) + xScale.bandwidth() / 2
20
+ : xScale(d[xf])
21
+ const makeGen = () => {
22
+ const gen = area().x(xPos).y0(innerHeight).y1((d) => yScale(d[yf]))
23
+ if (curve === 'smooth') gen.curve(curveCatmullRom)
24
+ else if (curve === 'step') gen.curve(curveStep)
25
+ return gen
26
+ }
27
+ if (!cf) {
28
+ const colorEntry = colors?.values().next().value ?? { fill: '#888', stroke: '#444' }
29
+ return [{ d: makeGen()(data), fill: colorEntry.fill, stroke: 'none', colorKey: null, patternKey: null, patternId: null }]
30
+ }
31
+ const groups = groupBy(data, cf)
32
+ return [...groups.entries()].map(([key, rows]) => {
33
+ const colorEntry = colors?.get(key) ?? { fill: '#888', stroke: '#444' }
34
+ const patternKey = pf ? (pf === cf ? key : rows[0]?.[pf]) : null
35
+ const patternName = patternKey !== null && patternKey !== undefined ? patternMap?.get(patternKey) : null
36
+ const compositePatternKey = (cf && pf && cf !== pf && patternKey !== null && patternKey !== undefined)
37
+ ? `${key}::${patternKey}`
38
+ : patternKey
39
+ return {
40
+ d: makeGen()(rows),
41
+ fill: colorEntry.fill,
42
+ stroke: 'none',
43
+ key,
44
+ colorKey: key,
45
+ patternKey,
46
+ patternId: patternName ? toPatternId(compositePatternKey) : null
47
+ }
48
+ })
49
+ }
50
+
51
+ function groupBy(arr, field) {
52
+ const map = new Map()
53
+ for (const item of arr) {
54
+ const key = item[field]
55
+ if (!map.has(key)) map.set(key, [])
56
+ map.get(key).push(item)
57
+ }
58
+ return map
59
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @param {Object[]} data
3
+ * @param {{ x: string, y: string, fill?: string, color?: string, pattern?: string }} channels
4
+ * `fill` drives the bar interior color. `color` drives the border stroke; falls back to fill.
5
+ * @param {import('d3-scale').ScaleBand|import('d3-scale').ScaleLinear} xScale
6
+ * @param {import('d3-scale').ScaleLinear} yScale
7
+ * @param {Map} colors - value→{fill,stroke}
8
+ * @param {Map} [patternMap] - value→patternName
9
+ * @returns {Array}
10
+ */
11
+ import { toPatternId } from '../patterns.js'
12
+
13
+ export function buildBars(data, channels, xScale, yScale, colors, patternMap) {
14
+ const { x: xf, y: yf, fill: ff, color: cf, pattern: pf } = channels
15
+ const barWidth = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 10
16
+ const innerHeight = yScale.range()[0]
17
+
18
+ return data.map((d) => {
19
+ const xVal = d[xf]
20
+ const fillKey = ff ? d[ff] : xVal // fill channel drives interior color
21
+ const strokeKey = cf ? d[cf] : null // color channel drives border; null = no border
22
+ const colorEntry = colors?.get(fillKey) ?? { fill: '#888', stroke: '#444' }
23
+ const strokeEntry = colors?.get(strokeKey) ?? colorEntry
24
+ const patternKey = pf ? d[pf] : null
25
+ const patternName = patternKey !== null && patternKey !== undefined ? patternMap?.get(patternKey) : null
26
+ // When fill and pattern are different fields, bars need a composite pattern def id
27
+ // so each (region, category) pair gets its uniquely colored+textured pattern.
28
+ const compositePatternKey = (ff && pf && ff !== pf && patternKey !== null && patternKey !== undefined)
29
+ ? `${d[ff]}::${patternKey}`
30
+ : patternKey
31
+ const barX = typeof xScale.bandwidth === 'function'
32
+ ? xScale(xVal)
33
+ : xScale(xVal) - barWidth / 2
34
+ const barY = yScale(d[yf])
35
+ return {
36
+ data: d,
37
+ key: `${xVal}::${fillKey ?? ''}::${patternKey ?? ''}`,
38
+ x: barX,
39
+ y: barY,
40
+ width: barWidth,
41
+ height: innerHeight - barY,
42
+ fill: colorEntry.fill,
43
+ stroke: strokeKey !== null ? strokeEntry.stroke : null,
44
+ colorKey: fillKey,
45
+ patternKey,
46
+ patternId: patternName ? toPatternId(compositePatternKey) : null
47
+ }
48
+ })
49
+ }