@rokkit/chart 1.0.0-next.151 → 1.0.0-next.158

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 (86) hide show
  1. package/dist/PlotState.svelte.d.ts +26 -0
  2. package/dist/index.d.ts +6 -1
  3. package/dist/lib/brewing/BoxBrewer.svelte.d.ts +3 -5
  4. package/dist/lib/brewing/QuartileBrewer.svelte.d.ts +9 -0
  5. package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +3 -4
  6. package/dist/lib/brewing/colors.d.ts +10 -1
  7. package/dist/lib/brewing/marks/points.d.ts +17 -2
  8. package/dist/lib/keyboard-nav.d.ts +15 -0
  9. package/dist/lib/plot/preset.d.ts +1 -1
  10. package/dist/lib/preset.d.ts +30 -0
  11. package/package.json +2 -1
  12. package/src/AnimatedPlot.svelte +375 -207
  13. package/src/Chart.svelte +81 -84
  14. package/src/ChartProvider.svelte +10 -0
  15. package/src/FacetPlot/Panel.svelte +30 -16
  16. package/src/FacetPlot.svelte +100 -76
  17. package/src/Plot/Area.svelte +26 -19
  18. package/src/Plot/Axis.svelte +81 -59
  19. package/src/Plot/Bar.svelte +47 -89
  20. package/src/Plot/Grid.svelte +23 -19
  21. package/src/Plot/Legend.svelte +213 -147
  22. package/src/Plot/Line.svelte +31 -21
  23. package/src/Plot/Point.svelte +35 -22
  24. package/src/Plot/Root.svelte +46 -91
  25. package/src/Plot/Timeline.svelte +82 -82
  26. package/src/Plot/Tooltip.svelte +68 -62
  27. package/src/Plot.svelte +290 -174
  28. package/src/PlotState.svelte.js +338 -265
  29. package/src/Sparkline.svelte +95 -56
  30. package/src/charts/AreaChart.svelte +22 -20
  31. package/src/charts/BarChart.svelte +23 -21
  32. package/src/charts/BoxPlot.svelte +15 -15
  33. package/src/charts/BubbleChart.svelte +17 -17
  34. package/src/charts/LineChart.svelte +20 -20
  35. package/src/charts/PieChart.svelte +30 -20
  36. package/src/charts/ScatterPlot.svelte +20 -19
  37. package/src/charts/ViolinPlot.svelte +15 -15
  38. package/src/crossfilter/CrossFilter.svelte +33 -29
  39. package/src/crossfilter/FilterBar.svelte +17 -25
  40. package/src/crossfilter/FilterHistogram.svelte +290 -0
  41. package/src/crossfilter/FilterSlider.svelte +69 -65
  42. package/src/crossfilter/createCrossFilter.svelte.js +94 -90
  43. package/src/geoms/Arc.svelte +114 -69
  44. package/src/geoms/Area.svelte +67 -39
  45. package/src/geoms/Bar.svelte +184 -126
  46. package/src/geoms/Box.svelte +101 -91
  47. package/src/geoms/LabelPill.svelte +11 -11
  48. package/src/geoms/Line.svelte +110 -86
  49. package/src/geoms/Point.svelte +130 -90
  50. package/src/geoms/Violin.svelte +51 -41
  51. package/src/geoms/lib/areas.js +122 -99
  52. package/src/geoms/lib/bars.js +195 -144
  53. package/src/index.js +21 -14
  54. package/src/lib/brewing/BoxBrewer.svelte.js +8 -50
  55. package/src/lib/brewing/CartesianBrewer.svelte.js +11 -7
  56. package/src/lib/brewing/PieBrewer.svelte.js +5 -5
  57. package/src/lib/brewing/QuartileBrewer.svelte.js +51 -0
  58. package/src/lib/brewing/ViolinBrewer.svelte.js +8 -49
  59. package/src/lib/brewing/brewer.svelte.js +242 -195
  60. package/src/lib/brewing/colors.js +34 -5
  61. package/src/lib/brewing/marks/arcs.js +28 -28
  62. package/src/lib/brewing/marks/areas.js +54 -41
  63. package/src/lib/brewing/marks/bars.js +34 -34
  64. package/src/lib/brewing/marks/boxes.js +51 -51
  65. package/src/lib/brewing/marks/lines.js +37 -30
  66. package/src/lib/brewing/marks/points.js +74 -26
  67. package/src/lib/brewing/marks/violins.js +57 -57
  68. package/src/lib/brewing/patterns.js +25 -11
  69. package/src/lib/brewing/scales.js +17 -17
  70. package/src/lib/brewing/stats.js +37 -29
  71. package/src/lib/brewing/symbols.js +1 -1
  72. package/src/lib/chart.js +2 -1
  73. package/src/lib/keyboard-nav.js +37 -0
  74. package/src/lib/plot/crossfilter.js +5 -5
  75. package/src/lib/plot/facet.js +30 -30
  76. package/src/lib/plot/frames.js +30 -29
  77. package/src/lib/plot/helpers.js +4 -4
  78. package/src/lib/plot/preset.js +48 -34
  79. package/src/lib/plot/scales.js +64 -39
  80. package/src/lib/plot/stat.js +47 -47
  81. package/src/lib/preset.js +41 -0
  82. package/src/patterns/DefinePatterns.svelte +24 -24
  83. package/src/patterns/README.md +3 -0
  84. package/src/patterns/patterns.js +328 -176
  85. package/src/patterns/scale.js +61 -32
  86. package/src/spec/chart-spec.js +64 -21
@@ -1,81 +1,126 @@
1
1
  <script>
2
- import { getContext, onMount, onDestroy } from 'svelte'
3
- import { buildArcs } from '../lib/brewing/marks/arcs.js'
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+ import { buildArcs } from '../lib/brewing/marks/arcs.js'
4
4
 
5
- /**
6
- * `fill` is the primary prop name; `color` is accepted as an alias for
7
- * spec-driven usage (Plot.svelte passes `color` to all geoms generically).
8
- * @type {{ theta?: string, fill?: string, color?: string, pattern?: string, stat?: string, labelFn?: (data: Record<string, unknown>) => string, options?: { innerRadius?: number } }}
9
- */
10
- let { theta, fill, color, pattern, labelFn = undefined, stat = 'identity', options = {} } = $props()
5
+ /**
6
+ * `fill` is the primary prop name; `color` is accepted as an alias for
7
+ * spec-driven usage (Plot.svelte passes `color` to all geoms generically).
8
+ * @type {{ theta?: string, fill?: string, color?: string, pattern?: string, stat?: string, labelFn?: (data: Record<string, unknown>) => string, options?: { innerRadius?: number } }}
9
+ */
10
+ let {
11
+ theta,
12
+ fill,
13
+ color,
14
+ pattern,
15
+ labelFn = undefined,
16
+ stat = 'identity',
17
+ options = {},
18
+ onselect = undefined
19
+ } = $props()
11
20
 
12
- const fillField = $derived(fill ?? color)
21
+ const fillField = $derived(fill ?? color)
13
22
 
14
- const plotState = getContext('plot-state')
15
- let id = $state(null)
23
+ const plotState = getContext('plot-state')
24
+ let id = $state(null)
16
25
 
17
- onMount(() => {
18
- id = plotState.registerGeom({ type: 'arc', channels: { color: fillField, y: theta, pattern }, stat, options })
19
- })
20
- onDestroy(() => { if (id) plotState.unregisterGeom(id) })
26
+ onMount(() => {
27
+ id = plotState.registerGeom({
28
+ type: 'arc',
29
+ channels: { color: fillField, y: theta, pattern },
30
+ stat,
31
+ options
32
+ })
33
+ })
34
+ onDestroy(() => {
35
+ if (id) plotState.unregisterGeom(id)
36
+ })
21
37
 
22
- $effect(() => {
23
- if (id) plotState.updateGeom(id, { channels: { color: fillField, y: theta, pattern }, stat })
24
- })
38
+ $effect(() => {
39
+ if (id) plotState.updateGeom(id, { channels: { color: fillField, y: theta, pattern }, stat })
40
+ })
25
41
 
26
- const data = $derived(id ? plotState.geomData(id) : [])
27
- const colors = $derived(plotState.colors)
28
- const patterns = $derived(plotState.patterns)
29
- const w = $derived(plotState.innerWidth)
30
- const h = $derived(plotState.innerHeight)
42
+ const data = $derived(id ? plotState.geomData(id) : [])
43
+ const colors = $derived(plotState.colors)
44
+ const patterns = $derived(plotState.patterns)
45
+ const w = $derived(plotState.innerWidth)
46
+ const h = $derived(plotState.innerHeight)
31
47
 
32
- const arcs = $derived.by(() => {
33
- if (!data?.length) return []
34
- // Guard: skip until data catches up after a fill-field change.
35
- // When fillField changes, the $effect updates the geom asynchronously, but
36
- // this derived runs first with stale data whose rows don't have the new
37
- // field — causing all keys to be undefined (duplicate key error).
38
- if (fillField && !(fillField in data[0])) return []
39
- const innerRadius = (options.innerRadius ?? 0) * Math.min(w, h) / 2
40
- return buildArcs(data, { color: fillField, y: theta, pattern }, colors, w, h, { innerRadius }, patterns)
41
- })
48
+ const arcs = $derived.by(() => {
49
+ if (!data?.length) return []
50
+ // Guard: skip until data catches up after a fill-field change.
51
+ // When fillField changes, the $effect updates the geom asynchronously, but
52
+ // this derived runs first with stale data whose rows don't have the new
53
+ // field — causing all keys to be undefined (duplicate key error).
54
+ if (fillField && !(fillField in data[0])) return []
55
+ const innerRadius = ((options.innerRadius ?? 0) * Math.min(w, h)) / 2
56
+ return buildArcs(
57
+ data,
58
+ { color: fillField, y: theta, pattern },
59
+ colors,
60
+ w,
61
+ h,
62
+ { innerRadius },
63
+ patterns
64
+ )
65
+ })
42
66
  </script>
43
67
 
44
68
  {#if arcs.length > 0}
45
- <g
46
- data-plot-geom="arc"
47
- transform="translate({w / 2}, {h / 2})"
48
- >
49
- {#each arcs as arc (arc.key)}
50
- <path
51
- d={arc.d}
52
- fill={arc.fill}
53
- stroke={arc.stroke}
54
- stroke-width="1"
55
- role="presentation"
56
- data-plot-element="arc"
57
- onmouseenter={() => plotState.setHovered({ ...arc.data, '%': `${arc.pct}%` })}
58
- onmouseleave={() => plotState.clearHovered()}
59
- />
60
- {#if arc.patternId}
61
- <path d={arc.d} fill="url(#{arc.patternId})" stroke={arc.stroke} stroke-width="1" pointer-events="none" data-plot-element="arc" />
62
- {/if}
63
- {#if arc.pct >= 5}
64
- {@const labelText = labelFn ? String(labelFn(arc.data) ?? '') : `${arc.pct}%`}
65
- {#if labelText}
66
- {@const lw = Math.max(36, labelText.length * 7 + 12)}
67
- <g transform="translate({arc.centroid[0]},{arc.centroid[1]})" pointer-events="none" data-plot-element="arc-label">
68
- <rect x={-lw / 2} y="-9" width={lw} height="18" rx="4" fill="white" fill-opacity="0.82" />
69
- <text
70
- text-anchor="middle"
71
- dominant-baseline="central"
72
- font-size="11"
73
- font-weight="600"
74
- fill={arc.stroke}
75
- >{labelText}</text>
76
- </g>
77
- {/if}
78
- {/if}
79
- {/each}
80
- </g>
69
+ <g data-plot-geom="arc" transform="translate({w / 2}, {h / 2})">
70
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
71
+ {#each arcs as arc (arc.key)}
72
+ <path
73
+ d={arc.d}
74
+ fill={arc.fill}
75
+ stroke={arc.stroke}
76
+ stroke-width="1"
77
+ role={onselect ? 'button' : 'presentation'}
78
+ tabindex={onselect ? 0 : undefined}
79
+ style:cursor={onselect ? 'pointer' : undefined}
80
+ data-plot-element="arc"
81
+ onmouseenter={() => plotState.setHovered({ ...arc.data, '%': `${arc.pct}%` })}
82
+ onmouseleave={() => plotState.clearHovered()}
83
+ onclick={onselect ? () => onselect({ ...arc.data, '%': `${arc.pct}%` }) : undefined}
84
+ onkeydown={onselect ? (e) => (e.key === 'Enter' || e.key === ' ') && onselect({ ...arc.data, '%': `${arc.pct}%` }) : undefined}
85
+ />
86
+ {#if arc.patternId}
87
+ <path
88
+ d={arc.d}
89
+ fill="url(#{arc.patternId})"
90
+ stroke={arc.stroke}
91
+ stroke-width="1"
92
+ pointer-events="none"
93
+ data-plot-element="arc"
94
+ />
95
+ {/if}
96
+ {#if arc.pct >= 5}
97
+ {@const labelText = labelFn ? String(labelFn(arc.data) ?? '') : `${arc.pct}%`}
98
+ {#if labelText}
99
+ {@const lw = Math.max(36, labelText.length * 7 + 12)}
100
+ <g
101
+ transform="translate({arc.centroid[0]},{arc.centroid[1]})"
102
+ pointer-events="none"
103
+ data-plot-element="arc-label"
104
+ >
105
+ <rect
106
+ x={-lw / 2}
107
+ y="-9"
108
+ width={lw}
109
+ height="18"
110
+ rx="4"
111
+ fill="white"
112
+ fill-opacity="0.82"
113
+ />
114
+ <text
115
+ text-anchor="middle"
116
+ dominant-baseline="central"
117
+ font-size="11"
118
+ font-weight="600"
119
+ fill={arc.stroke}>{labelText}</text
120
+ >
121
+ </g>
122
+ {/if}
123
+ {/if}
124
+ {/each}
125
+ </g>
81
126
  {/if}
@@ -1,50 +1,78 @@
1
1
  <script>
2
- import { getContext, onMount, onDestroy } from 'svelte'
3
- import { buildAreas, buildStackedAreas } from './lib/areas.js'
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+ import { buildAreas, buildStackedAreas } from './lib/areas.js'
4
4
 
5
- let { x, y, color, pattern, stat = 'identity', options = {} } = $props()
5
+ let { x, y, color, pattern, stat = 'identity', options = {} } = $props()
6
6
 
7
- const plotState = getContext('plot-state')
8
- let id = $state(null)
7
+ const plotState = getContext('plot-state')
8
+ let id = $state(null)
9
9
 
10
- onMount(() => {
11
- id = plotState.registerGeom({ type: 'area', channels: { x, y, color, pattern }, stat, options: { stack: options?.stack ?? false } })
12
- })
13
- onDestroy(() => { if (id) plotState.unregisterGeom(id) })
10
+ onMount(() => {
11
+ id = plotState.registerGeom({
12
+ type: 'area',
13
+ channels: { x, y, color, pattern },
14
+ stat,
15
+ options: { stack: options?.stack ?? false }
16
+ })
17
+ })
18
+ onDestroy(() => {
19
+ if (id) plotState.unregisterGeom(id)
20
+ })
14
21
 
15
- $effect(() => {
16
- if (id) plotState.updateGeom(id, { channels: { x, y, color, pattern }, stat, options: { stack: options?.stack ?? false } })
17
- })
22
+ $effect(() => {
23
+ if (id)
24
+ plotState.updateGeom(id, {
25
+ channels: { x, y, color, pattern },
26
+ stat,
27
+ options: { stack: options?.stack ?? false }
28
+ })
29
+ })
18
30
 
19
- const data = $derived(id ? plotState.geomData(id) : [])
20
- const xScale = $derived(plotState.xScale)
21
- const yScale = $derived(plotState.yScale)
22
- const colors = $derived(plotState.colors)
23
- const patterns = $derived(plotState.patterns)
31
+ const data = $derived(id ? plotState.geomData(id) : [])
32
+ const xScale = $derived(plotState.xScale)
33
+ const yScale = $derived(plotState.yScale)
34
+ const colors = $derived(plotState.colors)
35
+ const patterns = $derived(plotState.patterns)
24
36
 
25
- const areas = $derived.by(() => {
26
- if (!data?.length || !xScale || !yScale) return []
27
- const channels = { x, y, color, pattern }
28
- if (options.stack) {
29
- return buildStackedAreas(data, channels, xScale, yScale, colors, options.curve, patterns)
30
- }
31
- return buildAreas(data, channels, xScale, yScale, colors, options.curve, patterns)
32
- })
37
+ const areas = $derived.by(() => {
38
+ if (!data?.length || !xScale || !yScale) return []
39
+ const channels = { x, y, color, pattern }
40
+ if (options.stack) {
41
+ return buildStackedAreas(data, channels, xScale, yScale, colors, options.curve, patterns)
42
+ }
43
+ return buildAreas(data, channels, xScale, yScale, colors, options.curve, patterns)
44
+ })
33
45
  </script>
34
46
 
35
47
  {#if areas.length > 0}
36
- <g data-plot-geom="area">
37
- {#each areas as seg (seg.key ?? seg.d)}
38
- <path
39
- d={seg.d}
40
- fill={seg.fill}
41
- fill-opacity={seg.patternId ? 1 : (options.opacity ?? 0.6)}
42
- stroke={seg.stroke ?? 'none'}
43
- data-plot-element="area"
44
- />
45
- {#if seg.patternId}
46
- <path d={seg.d} fill="url(#{seg.patternId})" data-plot-element="area" />
47
- {/if}
48
- {/each}
49
- </g>
48
+ <g data-plot-geom="area">
49
+ {#each areas as seg (seg.key ?? seg.d)}
50
+ <path
51
+ d={seg.d}
52
+ fill={seg.fill}
53
+ fill-opacity={seg.patternId ? 1 : (options.opacity ?? plotState.chartPreset.opacity.area)}
54
+ stroke={seg.stroke ?? 'none'}
55
+ data-plot-element="area"
56
+ />
57
+ {#if seg.patternId}
58
+ <path d={seg.d} fill="url(#{seg.patternId})" data-plot-element="area" />
59
+ {/if}
60
+ {/each}
61
+ <!-- Invisible hit circles for tooltip: one per data point -->
62
+ {#each data as d, i (`hover::${i}`)}
63
+ {@const px = typeof xScale?.bandwidth === 'function' ? (xScale(d[x]) ?? 0) + xScale.bandwidth() / 2 : (xScale?.(d[x]) ?? 0)}
64
+ {@const py = yScale?.(d[y]) ?? 0}
65
+ <circle
66
+ cx={px}
67
+ cy={py}
68
+ r="8"
69
+ fill="transparent"
70
+ stroke="none"
71
+ role="presentation"
72
+ data-plot-element="area-hover"
73
+ onmouseenter={() => plotState.setHovered(d)}
74
+ onmouseleave={() => plotState.clearHovered()}
75
+ />
76
+ {/each}
77
+ </g>
50
78
  {/if}
@@ -1,142 +1,200 @@
1
1
  <script>
2
- import { getContext, onMount, onDestroy } from 'svelte'
3
- import { buildGroupedBars, buildStackedBars, buildHorizontalBars } from './lib/bars.js'
4
- import LabelPill from './LabelPill.svelte'
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+ import { buildGroupedBars, buildStackedBars, buildHorizontalBars } from './lib/bars.js'
4
+ import { keyboardNav } from '../lib/keyboard-nav.js'
5
+ import LabelPill from './LabelPill.svelte'
5
6
 
6
- let { x, y, color, fill: fillProp, pattern, label = false, stat = 'identity', options = {}, filterable = false } = $props()
7
+ let {
8
+ x,
9
+ y,
10
+ color,
11
+ fill: fillProp,
12
+ pattern,
13
+ label = false,
14
+ stat = 'identity',
15
+ options = {},
16
+ filterable = false,
17
+ onselect = undefined,
18
+ keyboard = false
19
+ } = $props()
7
20
 
8
- // `fill` is accepted as an alias for `color` (consistent with Arc.svelte)
9
- const colorChannel = $derived(fillProp ?? color)
21
+ // `fill` is accepted as an alias for `color` (consistent with Arc.svelte)
22
+ const colorChannel = $derived(fillProp ?? color)
10
23
 
11
- /**
12
- * @param {Record<string, unknown>} data
13
- * @param {string} defaultField
14
- * @returns {string | null}
15
- */
16
- function resolveLabel(data, defaultField) {
17
- if (!label) return null
18
- if (label === true) return String(data[defaultField] ?? '')
19
- if (typeof label === 'function') return String(label(data) ?? '')
20
- if (typeof label === 'string') return String(data[label] ?? '')
21
- return null
22
- }
24
+ /**
25
+ * @param {Record<string, unknown>} data
26
+ * @param {string} defaultField
27
+ * @returns {string | null}
28
+ */
29
+ function resolveLabel(data, defaultField) {
30
+ if (!label) return null
31
+ if (label === true) return String(data[defaultField] ?? '')
32
+ if (typeof label === 'function') return String(label(data) ?? '')
33
+ if (typeof label === 'string') return String(data[label] ?? '')
34
+ return null
35
+ }
23
36
 
24
- const plotState = getContext('plot-state')
25
- const cf = getContext('crossfilter')
26
- let id = $state(null)
37
+ /**
38
+ * Pick white or dark text based on perceived luminance of a hex fill color.
39
+ * @param {string | undefined} hex
40
+ * @returns {string}
41
+ */
42
+ function contrastColor(hex) {
43
+ if (!hex || !hex.startsWith('#') || hex.length < 7) return 'white'
44
+ const r = parseInt(hex.slice(1, 3), 16) / 255
45
+ const g = parseInt(hex.slice(3, 5), 16) / 255
46
+ const b = parseInt(hex.slice(5, 7), 16) / 255
47
+ return 0.299 * r + 0.587 * g + 0.114 * b > 0.55 ? '#333' : 'white'
48
+ }
27
49
 
28
- onMount(() => {
29
- id = plotState.registerGeom({ type: 'bar', channels: { x, y, color: colorChannel, pattern }, stat, options: { stack: options?.stack ?? false } })
30
- })
31
- onDestroy(() => {
32
- if (id) plotState.unregisterGeom(id)
33
- })
50
+ const plotState = getContext('plot-state')
51
+ const cf = getContext('crossfilter')
52
+ let id = $state(null)
34
53
 
35
- $effect(() => {
36
- if (id) plotState.updateGeom(id, { channels: { x, y, color: colorChannel, pattern }, stat, options: { stack: options?.stack ?? false } })
37
- })
54
+ onMount(() => {
55
+ id = plotState.registerGeom({
56
+ type: 'bar',
57
+ channels: { x, y, color: colorChannel, pattern },
58
+ stat,
59
+ options: { stack: options?.stack ?? false }
60
+ })
61
+ })
62
+ onDestroy(() => {
63
+ if (id) plotState.unregisterGeom(id)
64
+ })
38
65
 
39
- const data = $derived(id ? plotState.geomData(id) : [])
40
- const xScale = $derived(plotState.xScale)
41
- const yScale = $derived(plotState.yScale)
42
- const colors = $derived(plotState.colors)
43
- const patterns = $derived(plotState.patterns)
44
- const orientation = $derived(plotState.orientation)
45
- const innerHeight = $derived(plotState.innerHeight)
66
+ $effect(() => {
67
+ if (id)
68
+ plotState.updateGeom(id, {
69
+ channels: { x, y, color: colorChannel, pattern },
70
+ stat,
71
+ options: { stack: options?.stack ?? false }
72
+ })
73
+ })
46
74
 
47
- const bars = $derived.by(() => {
48
- if (!data?.length || !xScale || !yScale) return []
49
- const channels = { x, y, color: colorChannel, pattern }
50
- if (orientation === 'horizontal') {
51
- return buildHorizontalBars(data, channels, xScale, yScale, colors, innerHeight)
52
- }
53
- if (options.stack) {
54
- return buildStackedBars(data, channels, xScale, yScale, colors, innerHeight, patterns)
55
- }
56
- return buildGroupedBars(data, channels, xScale, yScale, colors, innerHeight, patterns)
57
- })
75
+ const data = $derived(id ? plotState.geomData(id) : [])
76
+ const xScale = $derived(plotState.xScale)
77
+ const yScale = $derived(plotState.yScale)
78
+ const colors = $derived(plotState.colors)
79
+ const patterns = $derived(plotState.patterns)
80
+ const effectiveOrientation = $derived(options.orientation ?? plotState.orientation)
81
+ const innerHeight = $derived(plotState.innerHeight)
58
82
 
59
- /** @type {Record<string, boolean>} */
60
- let dimmedByKey = $state({})
83
+ const bars = $derived.by(() => {
84
+ if (!data?.length || !xScale || !yScale) return []
85
+ const channels = { x, y, color: colorChannel, pattern }
86
+ if (effectiveOrientation === 'horizontal') {
87
+ return buildHorizontalBars(data, channels, xScale, yScale, colors, innerHeight)
88
+ }
89
+ if (options.stack) {
90
+ return buildStackedBars(data, channels, xScale, yScale, colors, innerHeight, patterns)
91
+ }
92
+ return buildGroupedBars(data, channels, xScale, yScale, colors, innerHeight, patterns)
93
+ })
61
94
 
62
- $effect(() => {
63
- if (!cf) { dimmedByKey = {}; return }
64
- // cf.version is a $state counter that increments on every filter mutation.
65
- // Reading it here establishes a reactive dependency so the effect re-runs
66
- // whenever any filter changes — including changes from sibling FilterBars.
67
- void cf.version
68
- const next = /** @type {Record<string, boolean>} */ ({})
69
- for (const bar of bars) {
70
- const dimmedByX = x ? cf.isDimmed(x, bar.data[x]) : false
71
- const dimmedByY = y ? cf.isDimmed(y, bar.data[y]) : false
72
- next[bar.key] = dimmedByX || dimmedByY
73
- }
74
- dimmedByKey = next
75
- })
95
+ /** @type {Record<string, boolean>} */
96
+ let dimmedByKey = $state({})
76
97
 
77
- function handleBarClick(barX) {
78
- if (!filterable || !x || !cf) return
79
- cf.toggleCategorical(x, barX)
80
- }
98
+ $effect(() => {
99
+ if (!cf) {
100
+ dimmedByKey = {}
101
+ return
102
+ }
103
+ // cf.version is a $state counter that increments on every filter mutation.
104
+ // Reading it here establishes a reactive dependency so the effect re-runs
105
+ // whenever any filter changes — including changes from sibling FilterBars.
106
+ void cf.version
107
+ const next = /** @type {Record<string, boolean>} */ ({})
108
+ for (const bar of bars) {
109
+ const dimmedByX = x ? cf.isDimmed(x, bar.data[x]) : false
110
+ const dimmedByY = y ? cf.isDimmed(y, bar.data[y]) : false
111
+ next[bar.key] = dimmedByX || dimmedByY
112
+ }
113
+ dimmedByKey = next
114
+ })
115
+
116
+ function handleBarClick(barX) {
117
+ if (!filterable || !x || !cf) return
118
+ cf.toggleCategorical(x, barX)
119
+ }
81
120
  </script>
82
121
 
83
122
  {#if bars.length > 0}
84
- <g data-plot-geom="bar">
85
- {#each bars as bar (bar.key)}
86
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
87
- <rect
88
- x={bar.x}
89
- y={bar.y}
90
- width={Math.max(0, bar.width)}
91
- height={Math.max(0, bar.height)}
92
- fill={bar.fill}
93
- stroke={bar.stroke ?? 'none'}
94
- stroke-width={bar.stroke ? 0.5 : 0}
95
- data-plot-element="bar"
96
- data-plot-value={bar.data[y]}
97
- data-plot-category={bar.data[x]}
98
- data-dimmed={dimmedByKey[bar.key] ? true : undefined}
99
- style:cursor={filterable ? 'pointer' : undefined}
100
- onclick={filterable && x ? () => handleBarClick(bar.data[x]) : undefined}
101
- onkeydown={filterable && x ? (e) => (e.key === 'Enter' || e.key === ' ') && handleBarClick(bar.data[x]) : undefined}
102
- role={filterable ? 'button' : 'graphics-symbol'}
103
- tabindex={filterable ? 0 : undefined}
104
- aria-label="{bar.data[x]}: {bar.data[y]}"
105
- onmouseenter={() => plotState.setHovered(bar.data)}
106
- onmouseleave={() => plotState.clearHovered()}
107
- >
108
- <title>{bar.data[x]}: {bar.data[y]}</title>
109
- </rect>
110
- {#if bar.patternId}
111
- <rect
112
- x={bar.x}
113
- y={bar.y}
114
- width={Math.max(0, bar.width)}
115
- height={Math.max(0, bar.height)}
116
- fill="url(#{bar.patternId})"
117
- pointer-events="none"
118
- />
119
- {/if}
120
- {#if label}
121
- {@const text = resolveLabel(bar.data, orientation === 'horizontal' ? x : y)}
122
- {#if text}
123
- {#if orientation === 'horizontal'}
124
- <LabelPill
125
- x={bar.x + bar.width + (options.labelOffset ?? 8)}
126
- y={bar.y + bar.height / 2}
127
- {text}
128
- color={bar.stroke ?? '#333'}
129
- />
130
- {:else}
131
- <LabelPill
132
- x={bar.x + bar.width / 2}
133
- y={bar.y + (options.labelOffset ?? -8)}
134
- {text}
135
- color={bar.stroke ?? '#333'}
136
- />
137
- {/if}
138
- {/if}
139
- {/if}
140
- {/each}
141
- </g>
123
+ <g data-plot-geom="bar">
124
+ {#each bars as bar (bar.key)}
125
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
126
+ <rect
127
+ x={bar.x}
128
+ y={bar.y}
129
+ width={Math.max(0, bar.width)}
130
+ height={Math.max(0, bar.height)}
131
+ fill={bar.fill}
132
+ stroke={bar.stroke ?? 'none'}
133
+ stroke-width={bar.stroke ? 0.5 : 0}
134
+ data-plot-element="bar"
135
+ data-plot-value={bar.data[y]}
136
+ data-plot-category={bar.data[x]}
137
+ data-dimmed={dimmedByKey[bar.key] ? true : undefined}
138
+ style:cursor={filterable || onselect ? 'pointer' : undefined}
139
+ onclick={filterable && x ? () => { handleBarClick(bar.data[x]); onselect?.(bar.data) } : onselect ? () => onselect(bar.data) : undefined}
140
+ onkeydown={filterable && x
141
+ ? (e) => (e.key === 'Enter' || e.key === ' ') && handleBarClick(bar.data[x])
142
+ : onselect ? (e) => (e.key === 'Enter' || e.key === ' ') && onselect(bar.data) : undefined}
143
+ role={filterable || onselect || keyboard ? 'button' : 'graphics-symbol'}
144
+ tabindex={filterable || onselect || keyboard ? 0 : undefined}
145
+ use:keyboardNav={keyboard}
146
+ aria-label="{bar.data[x]}: {bar.data[y]}"
147
+ onmouseenter={() => plotState.setHovered(bar.data)}
148
+ onmouseleave={() => plotState.clearHovered()}
149
+ >
150
+ <title>{bar.data[x]}: {bar.data[y]}</title>
151
+ </rect>
152
+ {#if bar.patternId}
153
+ <rect
154
+ x={bar.x}
155
+ y={bar.y}
156
+ width={Math.max(0, bar.width)}
157
+ height={Math.max(0, bar.height)}
158
+ fill="url(#{bar.patternId})"
159
+ pointer-events="none"
160
+ />
161
+ {/if}
162
+ {#if label}
163
+ {@const text = resolveLabel(bar.data, effectiveOrientation === 'horizontal' ? x : y)}
164
+ {#if text}
165
+ {#if effectiveOrientation === 'horizontal'}
166
+ {#if options.labelInside}
167
+ {@const estimatedWidth = text.length * 7 + 16}
168
+ {@const fitsInside = bar.width >= estimatedWidth}
169
+ <text
170
+ x={fitsInside ? bar.x + bar.width - 8 : bar.x + bar.width + 6}
171
+ y={bar.y + bar.height / 2}
172
+ dominant-baseline="central"
173
+ text-anchor={fitsInside ? 'end' : 'start'}
174
+ font-size="11"
175
+ font-weight="600"
176
+ fill={fitsInside ? contrastColor(bar.fill) : (bar.stroke ?? '#555')}
177
+ pointer-events="none"
178
+ data-plot-element="label"
179
+ >{text}</text>
180
+ {:else}
181
+ <LabelPill
182
+ x={bar.x + bar.width + (options.labelOffset ?? 8)}
183
+ y={bar.y + bar.height / 2}
184
+ {text}
185
+ color={bar.stroke ?? '#333'}
186
+ />
187
+ {/if}
188
+ {:else}
189
+ <LabelPill
190
+ x={bar.x + bar.width / 2}
191
+ y={bar.y + (options.labelOffset ?? -8)}
192
+ {text}
193
+ color={bar.stroke ?? '#333'}
194
+ />
195
+ {/if}
196
+ {/if}
197
+ {/if}
198
+ {/each}
199
+ </g>
142
200
  {/if}