@rokkit/chart 1.0.0-next.98 → 1.0.1

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,145 @@
1
+ <script>
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+ import { scaleSqrt } from 'd3-scale'
4
+ import { buildPoints } from '../lib/brewing/marks/points.js'
5
+ import { keyboardNav } from '../lib/keyboard-nav.js'
6
+ import LabelPill from './LabelPill.svelte'
7
+
8
+ let {
9
+ x,
10
+ y,
11
+ color,
12
+ size,
13
+ symbol: symbolField,
14
+ label = false,
15
+ stat = 'identity',
16
+ options = {},
17
+ onselect = undefined,
18
+ keyboard = false
19
+ } = $props()
20
+
21
+ /**
22
+ * @param {Record<string, unknown>} data
23
+ * @returns {string | null}
24
+ */
25
+ function resolveLabel(data) {
26
+ if (!label) return null
27
+ if (label === true) return String(data[y] ?? '')
28
+ if (typeof label === 'function') return String(label(data) ?? '')
29
+ return typeof label === 'string' ? String(data[label] ?? '') : null
30
+ }
31
+
32
+ const plotState = getContext('plot-state')
33
+ let id = $state(null)
34
+
35
+ onMount(() => {
36
+ id = plotState.registerGeom({
37
+ type: 'point',
38
+ channels: { x, y, color, size, symbol: symbolField },
39
+ stat,
40
+ options
41
+ })
42
+ })
43
+ onDestroy(() => {
44
+ if (id) plotState.unregisterGeom(id)
45
+ })
46
+
47
+ $effect(() => {
48
+ if (id) plotState.updateGeom(id, { channels: { x, y, color, size, symbol: symbolField }, stat })
49
+ })
50
+
51
+ const data = $derived(id ? plotState.geomData(id) : [])
52
+ const xScale = $derived(plotState.xScale)
53
+ const yScale = $derived(plotState.yScale)
54
+ const colors = $derived(plotState.colors)
55
+ const symbolMap = $derived(plotState.symbols)
56
+
57
+ function buildSizeScale() {
58
+ if (!size || !data?.length) return null
59
+ const vals = data.map((d) => Number(d[size])).filter((v) => !isNaN(v))
60
+ if (!vals.length) return null
61
+ const minVal = Math.min(...vals)
62
+ const maxVal = Math.max(...vals)
63
+ const minRadius = options.minRadius ?? 3
64
+ const maxRadius = options.maxRadius ?? 20
65
+ return scaleSqrt().domain([minVal, maxVal]).range([minRadius, maxRadius])
66
+ }
67
+
68
+ const sizeScale = $derived.by(() => buildSizeScale())
69
+
70
+ const defaultRadius = $derived(options.radius ?? 4)
71
+
72
+ const points = $derived.by(() => {
73
+ if (!data?.length || !xScale || !yScale) return []
74
+ return buildPoints(
75
+ data,
76
+ { x, y, color, size, symbol: symbolField },
77
+ xScale,
78
+ yScale,
79
+ colors,
80
+ sizeScale,
81
+ symbolMap,
82
+ defaultRadius,
83
+ options?.jitter ?? null
84
+ )
85
+ })
86
+ </script>
87
+
88
+ {#if points.length > 0}
89
+ <g data-plot-geom="point">
90
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
91
+ {#each points as pt, i (`${i}::${pt.data[x]}::${pt.data[y]}`)}
92
+ {#if pt.symbolPath}
93
+ <path
94
+ transform="translate({pt.cx},{pt.cy})"
95
+ d={pt.symbolPath}
96
+ fill={pt.fill}
97
+ stroke={pt.stroke}
98
+ stroke-width="1"
99
+ fill-opacity={options.opacity ?? plotState.chartPreset.opacity.point}
100
+ data-plot-element="point"
101
+ role={onselect || keyboard ? 'button' : 'graphics-symbol'}
102
+ tabindex={onselect || keyboard ? 0 : undefined}
103
+ style:cursor={onselect ? 'pointer' : undefined}
104
+ aria-label="{pt.data[x]}, {pt.data[y]}"
105
+ onmouseenter={() => plotState.setHovered(pt.data)}
106
+ onmouseleave={() => plotState.clearHovered()}
107
+ onclick={onselect ? () => onselect(pt.data) : undefined}
108
+ onkeydown={onselect ? (e) => (e.key === 'Enter' || e.key === ' ') && onselect(pt.data) : undefined}
109
+ use:keyboardNav={keyboard}
110
+ />
111
+ {:else}
112
+ <circle
113
+ cx={pt.cx}
114
+ cy={pt.cy}
115
+ r={pt.r}
116
+ fill={pt.fill}
117
+ stroke={pt.stroke}
118
+ stroke-width="1"
119
+ fill-opacity={options.opacity ?? plotState.chartPreset.opacity.point}
120
+ data-plot-element="point"
121
+ role={onselect || keyboard ? 'button' : 'graphics-symbol'}
122
+ tabindex={onselect || keyboard ? 0 : undefined}
123
+ style:cursor={onselect ? 'pointer' : undefined}
124
+ aria-label="{pt.data[x]}, {pt.data[y]}"
125
+ onmouseenter={() => plotState.setHovered(pt.data)}
126
+ onmouseleave={() => plotState.clearHovered()}
127
+ onclick={onselect ? () => onselect(pt.data) : undefined}
128
+ onkeydown={onselect ? (e) => (e.key === 'Enter' || e.key === ' ') && onselect(pt.data) : undefined}
129
+ use:keyboardNav={keyboard}
130
+ />
131
+ {/if}
132
+ {#if label}
133
+ {@const text = resolveLabel(pt.data)}
134
+ {#if text}
135
+ <LabelPill
136
+ x={pt.cx + (options.labelOffset?.x ?? 0)}
137
+ y={pt.cy - pt.r + (options.labelOffset?.y ?? -12)}
138
+ {text}
139
+ color={pt.stroke ?? '#333'}
140
+ />
141
+ {/if}
142
+ {/if}
143
+ {/each}
144
+ </g>
145
+ {/if}
@@ -0,0 +1,161 @@
1
+ <script>
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+
4
+ let {
5
+ x = undefined,
6
+ y = undefined,
7
+ color = undefined,
8
+ fill: fillProp = undefined,
9
+ stat = 'identity',
10
+ options = {}
11
+ } = $props()
12
+
13
+ const sourceField = $derived(options.source ?? 'source')
14
+ const targetField = $derived(options.target ?? 'target')
15
+ const valueField = $derived(options.value ?? 'value')
16
+ const colorChannel = $derived(fillProp ?? color)
17
+
18
+ const plotState = getContext('plot-state')
19
+ let id = $state(null)
20
+
21
+ onMount(() => {
22
+ id = plotState.registerGeom({
23
+ type: 'ribbon',
24
+ channels: { x, y, color: colorChannel },
25
+ stat
26
+ })
27
+ })
28
+ onDestroy(() => {
29
+ if (id) plotState.unregisterGeom(id)
30
+ })
31
+
32
+ $effect(() => {
33
+ if (id)
34
+ plotState.updateGeom(id, {
35
+ channels: { x, y, color: colorChannel },
36
+ stat
37
+ })
38
+ })
39
+
40
+ const data = $derived(id ? plotState.geomData(id) : [])
41
+ const colors = $derived(plotState.colors)
42
+ const innerHeight = $derived(plotState.innerHeight)
43
+ const innerWidth = $derived(plotState.innerWidth)
44
+
45
+ // Compute Sankey-style layout
46
+ const ribbons = $derived.by(() => {
47
+ if (!data?.length) return { links: [], sourceNodes: [], targetNodes: [] }
48
+
49
+ // Aggregate flows
50
+ const flows = data.map((d) => ({
51
+ source: String(d[sourceField]),
52
+ target: String(d[targetField]),
53
+ value: Number(d[valueField]) || 0,
54
+ data: d
55
+ }))
56
+
57
+ // Compute node positions
58
+ const sourceMap = new Map()
59
+ const targetMap = new Map()
60
+ for (const f of flows) {
61
+ sourceMap.set(f.source, (sourceMap.get(f.source) ?? 0) + f.value)
62
+ targetMap.set(f.target, (targetMap.get(f.target) ?? 0) + f.value)
63
+ }
64
+
65
+ const totalSource = Math.max(1, [...sourceMap.values()].reduce((s, v) => s + v, 0))
66
+ const totalTarget = Math.max(1, [...targetMap.values()].reduce((s, v) => s + v, 0))
67
+ const padding = 4
68
+ const availHeight = innerHeight - padding * (Math.max(sourceMap.size, targetMap.size) - 1)
69
+
70
+ // Position source nodes on left
71
+ let sourceY = 0
72
+ const sourceNodes = []
73
+ for (const [name, value] of sourceMap) {
74
+ const h = (value / totalSource) * availHeight
75
+ sourceNodes.push({ name, y: sourceY, height: h })
76
+ sourceY += h + padding
77
+ }
78
+
79
+ // Position target nodes on right
80
+ let targetY = 0
81
+ const targetNodes = []
82
+ for (const [name, value] of targetMap) {
83
+ const h = (value / totalTarget) * availHeight
84
+ targetNodes.push({ name, y: targetY, height: h })
85
+ targetY += h + padding
86
+ }
87
+
88
+ // Build ribbon paths
89
+ const sourceOffsets = new Map(sourceNodes.map((n) => [n.name, n.y]))
90
+ const targetOffsets = new Map(targetNodes.map((n) => [n.name, n.y]))
91
+ const x0 = 0
92
+ const x1 = innerWidth
93
+
94
+ const links = flows.map((f, i) => {
95
+ const sy0 = sourceOffsets.get(f.source) ?? 0
96
+ const ty0 = targetOffsets.get(f.target) ?? 0
97
+ const sourceTotal = sourceMap.get(f.source) ?? 1
98
+ const targetTotal = targetMap.get(f.target) ?? 1
99
+ const sh = (f.value / sourceTotal) * (sourceNodes.find((n) => n.name === f.source)?.height ?? 0)
100
+ const th = (f.value / targetTotal) * (targetNodes.find((n) => n.name === f.target)?.height ?? 0)
101
+
102
+ const path = `M${x0},${sy0} C${innerWidth * 0.4},${sy0} ${innerWidth * 0.6},${ty0} ${x1},${ty0} L${x1},${ty0 + th} C${innerWidth * 0.6},${ty0 + th} ${innerWidth * 0.4},${sy0 + sh} ${x0},${sy0 + sh} Z`
103
+
104
+ // Update offsets for stacking
105
+ sourceOffsets.set(f.source, sy0 + sh)
106
+ targetOffsets.set(f.target, ty0 + th)
107
+
108
+ const fillColor = colors?.get(f.source)?.fill ?? colors?.get(f.target)?.fill ?? '#888'
109
+
110
+ return {
111
+ key: `ribbon-${i}`,
112
+ d: path,
113
+ fill: fillColor,
114
+ data: f.data
115
+ }
116
+ })
117
+
118
+ return { links, sourceNodes, targetNodes }
119
+ })
120
+ </script>
121
+
122
+ {#if ribbons.links.length > 0}
123
+ <g data-plot-geom="ribbon">
124
+ {#each ribbons.links as link (link.key)}
125
+ <path
126
+ d={link.d}
127
+ fill={link.fill}
128
+ opacity="0.5"
129
+ data-plot-element="ribbon"
130
+ onmouseenter={() => plotState.setHovered(link.data)}
131
+ onmouseleave={() => plotState.clearHovered()}
132
+ >
133
+ <title>{link.data[sourceField]} → {link.data[targetField]}: {link.data[valueField]}</title>
134
+ </path>
135
+ {/each}
136
+ <!-- Source node labels -->
137
+ {#each ribbons.sourceNodes as node (node.name)}
138
+ <text
139
+ x="-4"
140
+ y={node.y + node.height / 2}
141
+ text-anchor="end"
142
+ dominant-baseline="middle"
143
+ font-size="11"
144
+ fill="currentColor"
145
+ data-plot-element="node-label"
146
+ >{node.name}</text>
147
+ {/each}
148
+ <!-- Target node labels -->
149
+ {#each ribbons.targetNodes as node (node.name)}
150
+ <text
151
+ x={innerWidth + 4}
152
+ y={node.y + node.height / 2}
153
+ text-anchor="start"
154
+ dominant-baseline="middle"
155
+ font-size="11"
156
+ fill="currentColor"
157
+ data-plot-element="node-label"
158
+ >{node.name}</text>
159
+ {/each}
160
+ </g>
161
+ {/if}
@@ -0,0 +1,56 @@
1
+ <script>
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+ import { buildViolins } from '../lib/brewing/marks/violins.js'
4
+
5
+ let { x, y, fill, stat = 'boxplot', options = {} } = $props()
6
+
7
+ const plotState = getContext('plot-state')
8
+ let id = $state(null)
9
+
10
+ // fill ?? x drives the colors map for both violin interior and outline
11
+ const fillChannel = $derived(fill ?? x)
12
+
13
+ onMount(() => {
14
+ id = plotState.registerGeom({
15
+ type: 'violin',
16
+ channels: { x, y, color: fillChannel },
17
+ stat,
18
+ options
19
+ })
20
+ })
21
+ onDestroy(() => {
22
+ if (id) plotState.unregisterGeom(id)
23
+ })
24
+
25
+ $effect(() => {
26
+ if (id) plotState.updateGeom(id, { channels: { x, y, color: fillChannel }, stat })
27
+ })
28
+
29
+ const data = $derived(id ? plotState.geomData(id) : [])
30
+ const xScale = $derived(plotState.xScale)
31
+ const yScale = $derived(plotState.yScale)
32
+ const colors = $derived(plotState.colors)
33
+
34
+ const violins = $derived.by(() => {
35
+ if (!data?.length || !xScale || !yScale) return []
36
+ return buildViolins(data, { x, fill: fillChannel }, xScale, yScale, colors)
37
+ })
38
+ </script>
39
+
40
+ {#if violins.length > 0}
41
+ <g data-plot-geom="violin">
42
+ {#each violins as v, i (`${String(v.cx)}::${i}`)}
43
+ <path
44
+ d={v.d}
45
+ fill={v.fill}
46
+ fill-opacity={options?.opacity ?? plotState.chartPreset.opacity.violin}
47
+ stroke={v.stroke}
48
+ stroke-width="1.5"
49
+ data-plot-element="violin"
50
+ role="presentation"
51
+ onmouseenter={() => plotState.setHovered(v.data)}
52
+ onmouseleave={() => plotState.clearHovered()}
53
+ />
54
+ {/each}
55
+ </g>
56
+ {/if}
@@ -0,0 +1,114 @@
1
+ <script>
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+
4
+ let {
5
+ x,
6
+ y,
7
+ color = undefined,
8
+ fill: fillProp = undefined,
9
+ stat = 'identity',
10
+ options = {}
11
+ } = $props()
12
+
13
+ const positiveColor = $derived(options.positiveColor ?? '#22c55e')
14
+ const negativeColor = $derived(options.negativeColor ?? '#ef4444')
15
+ const totalColor = $derived(options.totalColor ?? '#3b82f6')
16
+ const totalField = $derived(options.totalField ?? undefined)
17
+ const connectorWidth = $derived(options.connectorWidth ?? 0.5)
18
+
19
+ const plotState = getContext('plot-state')
20
+ let id = $state(null)
21
+
22
+ onMount(() => {
23
+ id = plotState.registerGeom({
24
+ type: 'waterfall',
25
+ channels: { x, y, color: fillProp ?? color },
26
+ stat
27
+ })
28
+ })
29
+ onDestroy(() => {
30
+ if (id) plotState.unregisterGeom(id)
31
+ })
32
+
33
+ $effect(() => {
34
+ if (id)
35
+ plotState.updateGeom(id, {
36
+ channels: { x, y, color: fillProp ?? color },
37
+ stat
38
+ })
39
+ })
40
+
41
+ const data = $derived(id ? plotState.geomData(id) : [])
42
+ const xScale = $derived(plotState.xScale)
43
+ const yScale = $derived(plotState.yScale)
44
+
45
+ const bars = $derived.by(() => {
46
+ if (!data?.length || !xScale || !yScale) return []
47
+ const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 10
48
+
49
+ let cumulative = 0
50
+ return data.map((d, i) => {
51
+ const xVal = d[x]
52
+ const yVal = Number(d[y])
53
+ const isTotal = totalField ? Boolean(d[totalField]) : false
54
+ const xPos = xScale(xVal) ?? 0
55
+ let barTop, barBottom, fill
56
+
57
+ if (isTotal) {
58
+ barTop = yScale(Math.max(0, cumulative))
59
+ barBottom = yScale(0)
60
+ fill = totalColor
61
+ } else {
62
+ const start = cumulative
63
+ cumulative += yVal
64
+ barTop = yScale(Math.max(start, cumulative))
65
+ barBottom = yScale(Math.min(start, cumulative))
66
+ fill = yVal >= 0 ? positiveColor : negativeColor
67
+ }
68
+
69
+ return {
70
+ key: `${xVal}-${i}`,
71
+ x: xPos,
72
+ y: barTop,
73
+ width: bw,
74
+ height: Math.max(1, barBottom - barTop),
75
+ fill,
76
+ cumulative,
77
+ data: d
78
+ }
79
+ })
80
+ })
81
+ </script>
82
+
83
+ {#if bars.length > 0}
84
+ <g data-plot-geom="waterfall">
85
+ {#each bars as bar, i (bar.key)}
86
+ <rect
87
+ x={bar.x}
88
+ y={bar.y}
89
+ width={Math.max(0, bar.width)}
90
+ height={bar.height}
91
+ fill={bar.fill}
92
+ data-plot-element="waterfall-bar"
93
+ onmouseenter={() => plotState.setHovered(bar.data)}
94
+ onmouseleave={() => plotState.clearHovered()}
95
+ >
96
+ <title>{bar.data[x]}: {bar.data[y]}</title>
97
+ </rect>
98
+ <!-- Connector line to next bar -->
99
+ {#if i < bars.length - 1}
100
+ <line
101
+ x1={bar.x + bar.width}
102
+ y1={yScale(bar.cumulative)}
103
+ x2={bars[i + 1].x}
104
+ y2={yScale(bar.cumulative)}
105
+ stroke="currentColor"
106
+ stroke-width={connectorWidth}
107
+ stroke-dasharray="3 2"
108
+ opacity="0.5"
109
+ data-plot-element="connector"
110
+ />
111
+ {/if}
112
+ {/each}
113
+ </g>
114
+ {/if}
@@ -0,0 +1,154 @@
1
+ import { area, stack, curveCatmullRom, curveStep } from 'd3-shape'
2
+ import { toPatternId } from '../../lib/brewing/patterns.js'
3
+
4
+ /**
5
+ * Builds area path geometry for multi-series area charts.
6
+ *
7
+ * @param {Object[]} data
8
+ * @param {{ x: string, y: string, color?: string }} channels
9
+ * @param {Function} xScale
10
+ * @param {Function} yScale
11
+ * @param {Map<unknown, {fill: string, stroke: string}>} colors
12
+ * @param {'linear'|'smooth'|'step'} [curve]
13
+ * @param {Map<unknown, string>} [patterns]
14
+ * @returns {{ d: string, fill: string, stroke: string, key: unknown, patternId: string|null }[]}
15
+ */
16
+ export function buildAreas(data, channels, xScale, yScale, colors, curve, patterns) {
17
+ const { x: xf, y: yf, color: cf, pattern: pf } = channels
18
+ const baseline = yScale.range()[0] // bottom of the chart (y pixel max)
19
+
20
+ const xPos = (d) =>
21
+ typeof xScale.bandwidth === 'function' ? xScale(d[xf]) + xScale.bandwidth() / 2 : xScale(d[xf])
22
+
23
+ const makeGen = () => {
24
+ const gen = area()
25
+ .x(xPos)
26
+ .y0(baseline)
27
+ .y1((d) => yScale(d[yf]))
28
+ if (curve === 'smooth') gen.curve(curveCatmullRom)
29
+ else if (curve === 'step') gen.curve(curveStep)
30
+ return gen
31
+ }
32
+
33
+ // For band (categorical) x scales, sort by domain index to preserve intended ordering.
34
+ // For continuous scales, sort numerically so the path draws left-to-right.
35
+ const sortByX = (rows) => {
36
+ if (typeof xScale.bandwidth === 'function') {
37
+ const domain = xScale.domain()
38
+ return [...rows].sort((a, b) => domain.indexOf(a[xf]) - domain.indexOf(b[xf]))
39
+ }
40
+ return [...rows].sort((a, b) => (a[xf] < b[xf] ? -1 : a[xf] > b[xf] ? 1 : 0))
41
+ }
42
+
43
+ if (!cf) {
44
+ const entry = colors?.values().next().value ?? { fill: '#888', stroke: '#888' }
45
+ const patternKey = pf ? data[0]?.[pf] : null
46
+ const patternId =
47
+ patternKey !== null && patternKey !== undefined && patterns?.has(patternKey)
48
+ ? toPatternId(String(patternKey))
49
+ : null
50
+ return [{ d: makeGen()(sortByX(data)), fill: entry.fill, stroke: 'none', key: null, patternId }]
51
+ }
52
+
53
+ // Group by color field
54
+ const groups = new Map()
55
+ for (const d of data) {
56
+ const key = d[cf]
57
+ if (!groups.has(key)) groups.set(key, [])
58
+ groups.get(key).push(d)
59
+ }
60
+ // For different-field patterns, assign positionally so each area gets a distinct pattern
61
+ const orderedPatternKeys = pf && pf !== cf ? [...(patterns?.keys() ?? [])] : null
62
+
63
+ return [...groups.entries()].map(([key, rows], i) => {
64
+ const entry = colors?.get(key) ?? { fill: '#888', stroke: '#888' }
65
+ // Same field or no pf: look up by colorKey. Different field: assign positionally.
66
+ const patternKey = !pf
67
+ ? key
68
+ : pf === cf
69
+ ? key
70
+ : (orderedPatternKeys?.[i % orderedPatternKeys.length] ?? null)
71
+ const patternId =
72
+ patternKey !== null && patternKey !== undefined && patterns?.has(patternKey)
73
+ ? toPatternId(String(patternKey))
74
+ : null
75
+ return { d: makeGen()(sortByX(rows)), fill: entry.fill, stroke: 'none', key, patternId }
76
+ })
77
+ }
78
+
79
+ /**
80
+ * Builds stacked area paths using d3 stack layout.
81
+ *
82
+ * @param {Object[]} data
83
+ * @param {{ x: string, y: string, color: string }} channels
84
+ * @param {Function} xScale
85
+ * @param {Function} yScale
86
+ * @param {Map<unknown, {fill: string, stroke: string}>} colors
87
+ * @param {'linear'|'smooth'|'step'} [curve]
88
+ * @param {Map<unknown, string>} [patterns]
89
+ * @returns {{ d: string, fill: string, stroke: string, key: unknown, patternId: string|null }[]}
90
+ */
91
+ export function buildStackedAreas(data, channels, xScale, yScale, colors, curve, patterns) {
92
+ const { x: xf, y: yf, color: cf, pattern: pf } = channels
93
+ if (!cf) return buildAreas(data, channels, xScale, yScale, colors, curve, patterns)
94
+
95
+ const xCategories = [...new Set(data.map((d) => d[xf]))].sort((a, b) =>
96
+ a < b ? -1 : a > b ? 1 : 0
97
+ )
98
+ const colorCategories = [...new Set(data.map((d) => d[cf]))]
99
+
100
+ // Build wide-form lookup: xVal → { colorKey: yVal }
101
+ const lookup = new Map()
102
+ for (const d of data) {
103
+ if (!lookup.has(d[xf])) lookup.set(d[xf], {})
104
+ lookup.get(d[xf])[d[cf]] = Number(d[yf])
105
+ }
106
+
107
+ const wide = xCategories.map((xVal) => {
108
+ const row = { [xf]: xVal }
109
+ for (const c of colorCategories) row[c] = lookup.get(xVal)?.[c] ?? 0
110
+ return row
111
+ })
112
+
113
+ const xPos = (d) =>
114
+ typeof xScale.bandwidth === 'function'
115
+ ? xScale(d.data[xf]) + xScale.bandwidth() / 2
116
+ : xScale(d.data[xf])
117
+
118
+ const makeGen = () => {
119
+ const gen = area()
120
+ .x(xPos)
121
+ .y0((d) => yScale(d[0]))
122
+ .y1((d) => yScale(d[1]))
123
+ if (curve === 'smooth') gen.curve(curveCatmullRom)
124
+ else if (curve === 'step') gen.curve(curveStep)
125
+ return gen
126
+ }
127
+
128
+ const stackGen = stack().keys(colorCategories)
129
+ const layers = stackGen(wide)
130
+
131
+ const orderedPatternKeys = pf && pf !== cf ? [...(patterns?.keys() ?? [])] : null
132
+
133
+ return layers.map((layer, i) => {
134
+ const colorKey = layer.key
135
+ const entry = colors?.get(colorKey) ?? { fill: '#888', stroke: '#888' }
136
+ // Same field (or no pf): look up by colorKey. Different field: assign positionally.
137
+ const patternKey = !pf
138
+ ? colorKey
139
+ : pf === cf
140
+ ? colorKey
141
+ : (orderedPatternKeys?.[i % orderedPatternKeys.length] ?? null)
142
+ const patternId =
143
+ patternKey !== null && patternKey !== undefined && patterns?.has(patternKey)
144
+ ? toPatternId(String(patternKey))
145
+ : null
146
+ return {
147
+ d: makeGen()(layer) ?? '',
148
+ fill: entry.fill,
149
+ stroke: 'none',
150
+ key: colorKey,
151
+ patternId
152
+ }
153
+ })
154
+ }