@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
@@ -1,21 +1,23 @@
1
1
  <script>
2
2
  import { uniq } from 'ramda'
3
3
 
4
- export let size = 10
5
- export let patternUnits = 'userSpaceOnUse'
6
- /** @type {Array<import('./types').Pattern>} */
7
- export let patterns = []
4
+ let {
5
+ size = 10,
6
+ patternUnits = 'userSpaceOnUse',
7
+ /** @type {Array<import('./types').Pattern>} */
8
+ patterns = []
9
+ } = $props()
8
10
 
9
- $: names = uniq(patterns.map(({ id }) => id))
11
+ let names = $derived(uniq(patterns.map(({ id }) => id)))
10
12
  </script>
11
13
 
12
14
  {#if names.length < patterns.length}
13
15
  <error> Patterns should be an array and should have unique names for each pattern </error>
14
16
  {:else if patterns.length > 0}
15
17
  <defs>
16
- {#each patterns as { id, component, fill, stroke }}
18
+ {#each patterns as { id, component: Component, fill, stroke }, index (index)}
17
19
  <pattern {id} {patternUnits} width={size} height={size}>
18
- <svelte:component this={component} {size} {fill} {stroke} />
20
+ <Component {size} {fill} {stroke} />
19
21
  </pattern>
20
22
  {/each}
21
23
  </defs>
@@ -1,18 +1,20 @@
1
1
  <script>
2
- export let x = 0
3
- export let y = 0
4
- export let textSize = 5
5
- export let size = 10
6
- export let space = 2
7
- export let padding = 5
8
- export let scale
9
- export let tickCount = 10
2
+ let {
3
+ x = 0,
4
+ y = 0,
5
+ textSize = 5,
6
+ size = 10,
7
+ space = 2,
8
+ padding = 5,
9
+ scale,
10
+ tickCount = 10
11
+ } = $props()
10
12
 
11
- $: sizeWithSpace = size + space
12
- $: ticks = scale.ticks.apply(scale, [tickCount])
13
+ let sizeWithSpace = $derived(size + space)
14
+ let ticks = $derived(scale.ticks.apply(scale, [tickCount]))
13
15
  </script>
14
16
 
15
- {#each ticks as tick, i}
17
+ {#each ticks as tick, i (i)}
16
18
  <text x={x + padding + i * sizeWithSpace + size / 2} y={y + size / 2} font-size={textSize}
17
19
  >{tick}</text
18
20
  >
@@ -1,13 +1,8 @@
1
1
  <script>
2
- export let x
3
- export let y
4
- export let text
5
- export let angle = 0
6
- export let small = false
7
- export let anchor = 'middle'
2
+ let { x, y, text, angle = 0, small = false, anchor = 'middle' } = $props()
8
3
 
9
- $: transform = `translate(${x},${y}) rotate(${angle})`
10
- $: anchor = ['start', 'middle', 'end'].includes(anchor) ? anchor : 'middle'
4
+ let transform = $derived(`translate(${x},${y}) rotate(${angle})`)
5
+ let validAnchor = $derived(['start', 'middle', 'end'].includes(anchor) ? anchor : 'middle')
11
6
  </script>
12
7
 
13
- <text class="label" class:small text-anchor={anchor} {transform}>{text}</text>
8
+ <text class="label" class:small text-anchor={validAnchor} {transform}>{text}</text>
@@ -1,23 +1,22 @@
1
1
  <script>
2
- import { swatch, swatchGrid } from '../lib'
2
+ import { swatch } from '../lib/swatch'
3
+ import { swatchGrid } from '../lib/grid'
3
4
  import Symbol from '../Symbol.svelte'
4
5
 
5
- export let base = 'teal'
6
- export let size = 4
7
- export let shade = 600
6
+ let { base = 'teal', size = 4, shade = 600 } = $props()
8
7
 
9
- $: grid = swatchGrid($swatch.keys.symbol.length, size, 10)
8
+ let grid = $derived(swatchGrid(swatch.keys.symbol.length, size, 10))
10
9
  </script>
11
10
 
12
11
  <svg viewBox="0 0 {grid.width} {grid.height}">
13
- {#each grid.data as { x, y, r }, index}
12
+ {#each grid.data as { x, y, r }, index (index)}
14
13
  <Symbol
15
14
  {x}
16
15
  {y}
17
16
  size={r * 2}
18
- name={$swatch.keys.symbol[index]}
19
- fill={$swatch.palette[base][shade]}
20
- stroke={$swatch.palette[base][shade]}
17
+ name={swatch.keys.symbol[index]}
18
+ fill={swatch.palette[base][shade]}
19
+ stroke={swatch.palette[base][shade]}
21
20
  />
22
21
  {/each}
23
22
  </svg>
@@ -0,0 +1,81 @@
1
+ <script>
2
+ import { Plot } from '../index.js'
3
+ import { dataset } from '@rokkit/data'
4
+
5
+ // Sample data
6
+ const sampleData = [
7
+ { model: 'Model A', name: 'Product 1', category: 'Electronics', count: 45 },
8
+ { model: 'Model B', name: 'Product 2', category: 'Clothing', count: 32 },
9
+ { model: 'Model C', name: 'Product 3', category: 'Electronics', count: 62 },
10
+ { model: 'Model D', name: 'Product 4', category: 'Home', count: 28 },
11
+ { model: 'Model E', name: 'Product 5', category: 'Electronics', count: 53 },
12
+ { model: 'Model F', name: 'Product 6', category: 'Clothing', count: 24 },
13
+ { model: 'Model G', name: 'Product 7', category: 'Home', count: 35 }
14
+ ]
15
+
16
+ // Use the dataset class to process the data
17
+ const data = dataset(sampleData)
18
+ .groupBy('category')
19
+ .summarize('name', { count: (values) => values.length })
20
+ .rollup()
21
+
22
+ // Chart dimensions
23
+ const width = 600
24
+ const height = 400
25
+ const margin = { top: 20, right: 100, bottom: 60, left: 60 }
26
+
27
+ // Click handler for bars
28
+ function handleBarClick(item) {
29
+ console.log('Bar clicked:', item)
30
+ alert(`Clicked on ${item.category} with count ${item.count}`)
31
+ }
32
+ </script>
33
+
34
+ <div class="example">
35
+ <h2>Bar Chart Example</h2>
36
+
37
+ <div class="chart-wrapper">
38
+ <Plot.Root {data} {width} {height} {margin} fill="category">
39
+ <Plot.Grid direction="y" lineStyle="dashed" />
40
+ <Plot.Axis type="x" field="category" label="Product Category" />
41
+ <Plot.Axis type="y" field="count" label="Number of Products" />
42
+ <Plot.Bar x="category" y="count" fill="category" onClick={handleBarClick} />
43
+ <Plot.Legend title="Categories" />
44
+ </Plot.Root>
45
+ </div>
46
+
47
+ <div class="code-example">
48
+ <h3>Example Code:</h3>
49
+ <pre>
50
+ <!-- &lt;script&gt;
51
+ import { Plot } from '@rokkit/chart';
52
+ import { dataset } from '@rokkit/data';
53
+
54
+ // Sample data
55
+ const sampleData = [
56
+ { model: 'Model A', name: 'Product 1', category: 'Electronics', count: 45 },
57
+ { model: 'Model B', name: 'Product 2', category: 'Clothing', count: 32 },
58
+ { model: 'Model C', name: 'Product 3', category: 'Electronics', count: 62 },
59
+ { model: 'Model D', name: 'Product 4', category: 'Home', count: 28 },
60
+ { model: 'Model E', name: 'Product 5', category: 'Electronics', count: 53 },
61
+ { model: 'Model F', name: 'Product 6', category: 'Clothing', count: 24 },
62
+ { model: 'Model G', name: 'Product 7', category: 'Home', count: 35 },
63
+ ];
64
+
65
+ // Use the dataset class to process the data
66
+ const data = dataset(sampleData)
67
+ .groupBy('category')
68
+ .summarize('name', { count: values => values.length })
69
+ .rollup();
70
+ &lt;/script&gt;
71
+
72
+ &lt;Plot.Root {data} width={600} height={400} margin={{ top: 20, right: 100, bottom: 60, left: 60 }} fill="category"&gt;
73
+ &lt;Plot.Grid direction="y" lineStyle="dashed" /&gt;
74
+ &lt;Plot.Axis type="x" field="category" label="Product Category" /&gt;
75
+ &lt;Plot.Axis type="y" field="count" label="Number of Products" /&gt;
76
+ &lt;Plot.Bar x="category" y="count" fill="category" onClick={handleBarClick} /&gt;
77
+ &lt;Plot.Legend title="Categories" /&gt;
78
+ &lt;/Plot.Root&gt; -->
79
+ </pre>
80
+ </div>
81
+ </div>
@@ -0,0 +1,126 @@
1
+ <script>
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+ import { buildArcs } from '../lib/brewing/marks/arcs.js'
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 {
11
+ theta,
12
+ fill,
13
+ color,
14
+ pattern,
15
+ labelFn = undefined,
16
+ stat = 'identity',
17
+ options = {},
18
+ onselect = undefined
19
+ } = $props()
20
+
21
+ const fillField = $derived(fill ?? color)
22
+
23
+ const plotState = getContext('plot-state')
24
+ let id = $state(null)
25
+
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
+ })
37
+
38
+ $effect(() => {
39
+ if (id) plotState.updateGeom(id, { channels: { color: fillField, y: theta, pattern }, stat })
40
+ })
41
+
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)
47
+
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
+ })
66
+ </script>
67
+
68
+ {#if arcs.length > 0}
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>
126
+ {/if}
@@ -0,0 +1,78 @@
1
+ <script>
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+ import { buildAreas, buildStackedAreas } from './lib/areas.js'
4
+
5
+ let { x, y, color, pattern, stat = 'identity', options = {} } = $props()
6
+
7
+ const plotState = getContext('plot-state')
8
+ let id = $state(null)
9
+
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
+ })
21
+
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
+ })
30
+
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)
36
+
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
+ })
45
+ </script>
46
+
47
+ {#if areas.length > 0}
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>
78
+ {/if}
@@ -0,0 +1,200 @@
1
+ <script>
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'
6
+
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()
20
+
21
+ // `fill` is accepted as an alias for `color` (consistent with Arc.svelte)
22
+ const colorChannel = $derived(fillProp ?? color)
23
+
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
+ }
36
+
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
+ }
49
+
50
+ const plotState = getContext('plot-state')
51
+ const cf = getContext('crossfilter')
52
+ let id = $state(null)
53
+
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
+ })
65
+
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
+ })
74
+
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)
82
+
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
+ })
94
+
95
+ /** @type {Record<string, boolean>} */
96
+ let dimmedByKey = $state({})
97
+
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
+ }
120
+ </script>
121
+
122
+ {#if bars.length > 0}
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>
200
+ {/if}