@rokkit/chart 1.0.0-next.99 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +150 -46
  2. package/package.json +40 -51
  3. package/src/AnimatedPlot.svelte +383 -0
  4. package/src/Chart.svelte +87 -59
  5. package/src/ChartExporter.svelte +125 -0
  6. package/src/ChartLegend.svelte +148 -0
  7. package/src/ChartProvider.svelte +10 -0
  8. package/src/FacetPlot/Panel.svelte +37 -0
  9. package/src/FacetPlot.svelte +114 -0
  10. package/src/Plot/Arc.svelte +29 -0
  11. package/src/Plot/Area.svelte +32 -0
  12. package/src/Plot/Axis.svelte +164 -0
  13. package/src/Plot/Bar.svelte +54 -0
  14. package/src/Plot/Grid.svelte +34 -0
  15. package/src/Plot/Legend.svelte +268 -0
  16. package/src/Plot/Line.svelte +37 -0
  17. package/src/Plot/Point.svelte +40 -0
  18. package/src/Plot/Root.svelte +62 -0
  19. package/src/Plot/Timeline.svelte +95 -0
  20. package/src/Plot/Tooltip.svelte +87 -0
  21. package/src/Plot/index.js +9 -0
  22. package/src/Plot.svelte +313 -0
  23. package/src/PlotState.svelte.js +375 -0
  24. package/src/Sparkline.svelte +108 -0
  25. package/src/Symbol.svelte +15 -11
  26. package/src/Texture.svelte +9 -7
  27. package/src/charts/AreaChart.svelte +30 -0
  28. package/src/charts/BarChart.svelte +31 -0
  29. package/src/charts/BoxPlot.svelte +24 -0
  30. package/src/charts/BubbleChart.svelte +28 -0
  31. package/src/charts/LineChart.svelte +31 -0
  32. package/src/charts/PieChart.svelte +35 -0
  33. package/src/charts/ScatterPlot.svelte +31 -0
  34. package/src/charts/ViolinPlot.svelte +24 -0
  35. package/src/crossfilter/CrossFilter.svelte +42 -0
  36. package/src/crossfilter/FilterBar.svelte +24 -0
  37. package/src/crossfilter/FilterHistogram.svelte +290 -0
  38. package/src/crossfilter/FilterSlider.svelte +83 -0
  39. package/src/crossfilter/createCrossFilter.svelte.js +124 -0
  40. package/src/elements/Bar.svelte +20 -16
  41. package/src/elements/ColorRamp.svelte +19 -19
  42. package/src/elements/ContinuousLegend.svelte +41 -19
  43. package/src/elements/DefinePatterns.svelte +9 -7
  44. package/src/elements/DiscreteLegend.svelte +13 -11
  45. package/src/elements/Label.svelte +4 -9
  46. package/src/elements/SymbolGrid.svelte +8 -9
  47. package/src/examples/BarChartExample.svelte +81 -0
  48. package/src/geoms/Arc.svelte +126 -0
  49. package/src/geoms/Area.svelte +78 -0
  50. package/src/geoms/Bar.svelte +200 -0
  51. package/src/geoms/Box.svelte +113 -0
  52. package/src/geoms/Candlestick.svelte +110 -0
  53. package/src/geoms/Heatmap.svelte +92 -0
  54. package/src/geoms/Hexbin.svelte +114 -0
  55. package/src/geoms/LabelPill.svelte +17 -0
  56. package/src/geoms/Line.svelte +123 -0
  57. package/src/geoms/Point.svelte +145 -0
  58. package/src/geoms/Ribbon.svelte +161 -0
  59. package/src/geoms/Violin.svelte +56 -0
  60. package/src/geoms/Waterfall.svelte +114 -0
  61. package/src/geoms/lib/areas.js +154 -0
  62. package/src/geoms/lib/bars.js +223 -0
  63. package/src/index.js +81 -2
  64. package/src/lib/brewing/BoxBrewer.svelte.js +14 -0
  65. package/src/lib/brewing/CartesianBrewer.svelte.js +21 -0
  66. package/src/lib/brewing/PieBrewer.svelte.js +14 -0
  67. package/src/lib/brewing/QuartileBrewer.svelte.js +51 -0
  68. package/src/lib/brewing/ViolinBrewer.svelte.js +14 -0
  69. package/src/lib/brewing/axes.svelte.js +270 -0
  70. package/src/lib/brewing/bars.svelte.js +201 -0
  71. package/src/lib/brewing/brewer.svelte.js +277 -0
  72. package/src/lib/brewing/colors.js +157 -0
  73. package/src/lib/brewing/dimensions.svelte.js +56 -0
  74. package/src/lib/brewing/index.svelte.js +205 -0
  75. package/src/lib/brewing/legends.svelte.js +137 -0
  76. package/src/lib/brewing/marks/arcs.js +43 -0
  77. package/src/lib/brewing/marks/areas.js +72 -0
  78. package/src/lib/brewing/marks/bars.js +49 -0
  79. package/src/lib/brewing/marks/boxes.js +75 -0
  80. package/src/lib/brewing/marks/lines.js +55 -0
  81. package/src/lib/brewing/marks/points.js +105 -0
  82. package/src/lib/brewing/marks/violins.js +90 -0
  83. package/src/lib/brewing/patterns.js +45 -0
  84. package/src/lib/brewing/scales.js +51 -0
  85. package/src/lib/brewing/scales.svelte.js +82 -0
  86. package/src/lib/brewing/stats.js +74 -0
  87. package/src/lib/brewing/symbols.js +10 -0
  88. package/src/lib/brewing/types.js +73 -0
  89. package/src/lib/chart.js +16 -8
  90. package/src/lib/context.js +131 -0
  91. package/src/lib/keyboard-nav.js +37 -0
  92. package/src/lib/plot/chartProps.js +76 -0
  93. package/src/lib/plot/crossfilter.js +16 -0
  94. package/src/lib/plot/facet.js +58 -0
  95. package/src/lib/plot/frames.js +81 -0
  96. package/src/lib/plot/helpers.js +14 -0
  97. package/src/lib/plot/preset.js +67 -0
  98. package/src/lib/plot/scales.js +81 -0
  99. package/src/lib/plot/stat.js +92 -0
  100. package/src/lib/plot/types.js +65 -0
  101. package/src/lib/preset.js +41 -0
  102. package/src/lib/scales.svelte.js +151 -0
  103. package/src/lib/swatch.js +4 -7
  104. package/src/lib/utils.js +142 -0
  105. package/src/lib/xscale.js +31 -0
  106. package/src/patterns/DefinePatterns.svelte +32 -0
  107. package/src/patterns/PatternDef.svelte +27 -0
  108. package/src/patterns/index.js +4 -14
  109. package/src/patterns/patterns.js +360 -0
  110. package/src/patterns/scale.js +116 -0
  111. package/src/spec/chart-spec.js +72 -0
  112. package/src/symbols/RoundedSquare.svelte +18 -12
  113. package/src/symbols/Shape.svelte +22 -16
  114. package/src/symbols/constants/index.js +1 -1
  115. package/LICENSE +0 -21
  116. package/src/lib/index.js +0 -4
  117. package/src/lib/plots.js +0 -27
  118. package/src/patterns/Brick.svelte +0 -17
  119. package/src/patterns/Circles.svelte +0 -18
  120. package/src/patterns/CrossHatch.svelte +0 -14
  121. package/src/patterns/CurvedWave.svelte +0 -9
  122. package/src/patterns/Dots.svelte +0 -19
  123. package/src/patterns/OutlineCircles.svelte +0 -15
  124. package/src/patterns/Tile.svelte +0 -17
  125. package/src/patterns/Triangles.svelte +0 -15
  126. package/src/patterns/Waves.svelte +0 -13
  127. package/src/patterns/paths/NamedPattern.svelte +0 -12
  128. package/src/patterns/paths/constants.js +0 -4
  129. package/src/plots/Plot.svelte +0 -40
  130. package/src/plots/index.js +0 -1
  131. package/src/template/Texture.svelte +0 -16
  132. package/src/template/constants.js +0 -43
  133. package/src/template/shapes/Circles.svelte +0 -16
  134. package/src/template/shapes/Lines.svelte +0 -17
  135. package/src/template/shapes/Path.svelte +0 -12
  136. package/src/template/shapes/Polygons.svelte +0 -18
  137. package/src/template/shapes/index.js +0 -4
@@ -0,0 +1,113 @@
1
+ <script>
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+ import { buildBoxes } from '../lib/brewing/marks/boxes.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 box interior and whisker strokes
11
+ const fillChannel = $derived(fill ?? x)
12
+
13
+ onMount(() => {
14
+ id = plotState.registerGeom({
15
+ type: 'box',
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 boxes = $derived.by(() => {
35
+ if (!data?.length || !xScale || !yScale) return []
36
+ return buildBoxes(data, { x, fill: fillChannel }, xScale, yScale, colors)
37
+ })
38
+ </script>
39
+
40
+ {#if boxes.length > 0}
41
+ <g data-plot-geom="box">
42
+ {#each boxes as box, i (`${String(box.cx)}::${i}`)}
43
+ {@const x0 = box.cx - box.width / 2}
44
+ {@const xMid = box.cx}
45
+ {@const xCap0 = box.cx - box.whiskerWidth / 2}
46
+ {@const xCap1 = box.cx + box.whiskerWidth / 2}
47
+ <!-- Box body (IQR): lighter fill shade -->
48
+ <rect
49
+ x={x0}
50
+ y={box.q3}
51
+ width={box.width}
52
+ height={Math.max(0, box.q1 - box.q3)}
53
+ fill={box.fill}
54
+ fill-opacity={options?.opacity ?? plotState.chartPreset.opacity.box}
55
+ stroke={box.stroke}
56
+ stroke-width="1"
57
+ data-plot-element="box-body"
58
+ role="presentation"
59
+ onmouseenter={() => plotState.setHovered(box.data)}
60
+ onmouseleave={() => plotState.clearHovered()}
61
+ />
62
+ <!-- Median line: darker stroke shade -->
63
+ <line
64
+ x1={x0}
65
+ y1={box.median}
66
+ x2={x0 + box.width}
67
+ y2={box.median}
68
+ stroke={box.stroke}
69
+ stroke-width="2"
70
+ data-plot-element="box-median"
71
+ />
72
+ <!-- Lower whisker (q1 to iqr_min) -->
73
+ <line
74
+ x1={xMid}
75
+ y1={box.q1}
76
+ x2={xMid}
77
+ y2={box.iqr_min}
78
+ stroke={box.stroke}
79
+ stroke-width="1"
80
+ data-plot-element="box-whisker"
81
+ />
82
+ <!-- Upper whisker (q3 to iqr_max) -->
83
+ <line
84
+ x1={xMid}
85
+ y1={box.q3}
86
+ x2={xMid}
87
+ y2={box.iqr_max}
88
+ stroke={box.stroke}
89
+ stroke-width="1"
90
+ data-plot-element="box-whisker"
91
+ />
92
+ <!-- Lower whisker cap -->
93
+ <line
94
+ x1={xCap0}
95
+ y1={box.iqr_min}
96
+ x2={xCap1}
97
+ y2={box.iqr_min}
98
+ stroke={box.stroke}
99
+ stroke-width="1"
100
+ />
101
+ <!-- Upper whisker cap -->
102
+ <line
103
+ x1={xCap0}
104
+ y1={box.iqr_max}
105
+ x2={xCap1}
106
+ y2={box.iqr_max}
107
+ stroke={box.stroke}
108
+ stroke-width="1"
109
+ />
110
+ <!-- Outlier rendering deferred: buildBoxes does not compute outliers yet -->
111
+ {/each}
112
+ </g>
113
+ {/if}
@@ -0,0 +1,110 @@
1
+ <script>
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+
4
+ let {
5
+ x,
6
+ y: _y = undefined,
7
+ color = undefined,
8
+ fill: fillProp = undefined,
9
+ stat = 'identity',
10
+ options = {}
11
+ } = $props()
12
+
13
+ const open = $derived(options.open ?? 'open')
14
+ const high = $derived(options.high ?? 'high')
15
+ const low = $derived(options.low ?? 'low')
16
+ const close = $derived(options.close ?? 'close')
17
+ const upColor = $derived(options.upColor ?? '#22c55e')
18
+ const downColor = $derived(options.downColor ?? '#ef4444')
19
+ const wickWidth = $derived(options.wickWidth ?? 1)
20
+
21
+ const plotState = getContext('plot-state')
22
+ let id = $state(null)
23
+
24
+ onMount(() => {
25
+ // Register with y pointing to 'high' so the y scale covers the full range
26
+ id = plotState.registerGeom({
27
+ type: 'candlestick',
28
+ channels: { x, y: high, color: fillProp ?? color },
29
+ stat
30
+ })
31
+ })
32
+ onDestroy(() => {
33
+ if (id) plotState.unregisterGeom(id)
34
+ })
35
+
36
+ $effect(() => {
37
+ if (id)
38
+ plotState.updateGeom(id, {
39
+ channels: { x, y: high, color: fillProp ?? color },
40
+ stat
41
+ })
42
+ })
43
+
44
+ const data = $derived(id ? plotState.geomData(id) : [])
45
+ const xScale = $derived(plotState.xScale)
46
+ const yScale = $derived(plotState.yScale)
47
+
48
+ const candles = $derived.by(() => {
49
+ if (!data?.length || !xScale || !yScale) return []
50
+ const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 10
51
+ const bodyWidth = bw * 0.6
52
+ const bodyOffset = (bw - bodyWidth) / 2
53
+
54
+ return data.map((d, i) => {
55
+ const xPos = (xScale(d[x]) ?? 0) + bodyOffset
56
+ const openVal = Number(d[open])
57
+ const closeVal = Number(d[close])
58
+ const highVal = Number(d[high])
59
+ const lowVal = Number(d[low])
60
+ const isUp = closeVal >= openVal
61
+ const bodyTop = yScale(isUp ? closeVal : openVal)
62
+ const bodyBottom = yScale(isUp ? openVal : closeVal)
63
+
64
+ return {
65
+ key: `${d[x]}-${i}`,
66
+ // Body
67
+ bodyX: xPos,
68
+ bodyY: bodyTop,
69
+ bodyWidth,
70
+ bodyHeight: Math.max(1, bodyBottom - bodyTop),
71
+ fill: isUp ? upColor : downColor,
72
+ // Wick
73
+ wickX: xPos + bodyWidth / 2,
74
+ wickTop: yScale(highVal),
75
+ wickBottom: yScale(lowVal),
76
+ data: d
77
+ }
78
+ })
79
+ })
80
+ </script>
81
+
82
+ {#if candles.length > 0}
83
+ <g data-plot-geom="candlestick">
84
+ {#each candles as c (c.key)}
85
+ <!-- Wick (high to low) -->
86
+ <line
87
+ x1={c.wickX}
88
+ y1={c.wickTop}
89
+ x2={c.wickX}
90
+ y2={c.wickBottom}
91
+ stroke={c.fill}
92
+ stroke-width={wickWidth}
93
+ data-plot-element="wick"
94
+ />
95
+ <!-- Body (open to close) -->
96
+ <rect
97
+ x={c.bodyX}
98
+ y={c.bodyY}
99
+ width={c.bodyWidth}
100
+ height={c.bodyHeight}
101
+ fill={c.fill}
102
+ data-plot-element="candle"
103
+ onmouseenter={() => plotState.setHovered(c.data)}
104
+ onmouseleave={() => plotState.clearHovered()}
105
+ >
106
+ <title>{c.data[x]}: O={c.data[open]} H={c.data[high]} L={c.data[low]} C={c.data[close]}</title>
107
+ </rect>
108
+ {/each}
109
+ </g>
110
+ {/if}
@@ -0,0 +1,92 @@
1
+ <script>
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+
4
+ let {
5
+ x,
6
+ y,
7
+ color,
8
+ fill: fillProp,
9
+ stat = 'identity',
10
+ options = {}
11
+ } = $props()
12
+
13
+ const colorChannel = $derived(fillProp ?? color)
14
+
15
+ const plotState = getContext('plot-state')
16
+ let id = $state(null)
17
+
18
+ onMount(() => {
19
+ id = plotState.registerGeom({
20
+ type: 'heatmap',
21
+ channels: { x, y, color: colorChannel },
22
+ stat
23
+ })
24
+ })
25
+ onDestroy(() => {
26
+ if (id) plotState.unregisterGeom(id)
27
+ })
28
+
29
+ $effect(() => {
30
+ if (id)
31
+ plotState.updateGeom(id, {
32
+ channels: { x, y, color: colorChannel },
33
+ stat
34
+ })
35
+ })
36
+
37
+ const data = $derived(id ? plotState.geomData(id) : [])
38
+ const xScale = $derived(plotState.xScale)
39
+ const yScale = $derived(plotState.yScale)
40
+ const continuousColor = $derived(plotState.continuousColorScale)
41
+ const categoricalColors = $derived(plotState.colors)
42
+ const rx = $derived(options.rounded ?? 0)
43
+
44
+ const cells = $derived.by(() => {
45
+ if (!data?.length || !xScale || !yScale) return []
46
+ const bwX = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 0
47
+ const bwY = typeof yScale.bandwidth === 'function' ? yScale.bandwidth() : 0
48
+
49
+ return data.map((d, i) => {
50
+ const xVal = d[x]
51
+ const yVal = d[y]
52
+ const colorVal = colorChannel ? d[colorChannel] : null
53
+ let cellFill = '#ccc'
54
+
55
+ if (continuousColor) {
56
+ cellFill = continuousColor.scale(Number(colorVal))
57
+ } else if (categoricalColors?.has(colorVal)) {
58
+ cellFill = categoricalColors.get(colorVal).fill
59
+ }
60
+
61
+ return {
62
+ key: `${xVal}-${yVal}-${i}`,
63
+ x: xScale(xVal) ?? 0,
64
+ y: yScale(yVal) ?? 0,
65
+ width: bwX,
66
+ height: bwY,
67
+ fill: cellFill,
68
+ data: d
69
+ }
70
+ })
71
+ })
72
+ </script>
73
+
74
+ {#if cells.length > 0}
75
+ <g data-plot-geom="heatmap">
76
+ {#each cells as cell (cell.key)}
77
+ <rect
78
+ x={cell.x}
79
+ y={cell.y}
80
+ width={Math.max(0, cell.width)}
81
+ height={Math.max(0, cell.height)}
82
+ fill={cell.fill}
83
+ rx={rx}
84
+ data-plot-element="cell"
85
+ onmouseenter={() => plotState.setHovered(cell.data)}
86
+ onmouseleave={() => plotState.clearHovered()}
87
+ >
88
+ <title>{cell.data[x]}, {cell.data[y]}: {colorChannel ? cell.data[colorChannel] : ''}</title>
89
+ </rect>
90
+ {/each}
91
+ </g>
92
+ {/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 radius = $derived(options.radius ?? 20)
14
+
15
+ const plotState = getContext('plot-state')
16
+ let id = $state(null)
17
+
18
+ onMount(() => {
19
+ id = plotState.registerGeom({
20
+ type: 'hexbin',
21
+ channels: { x, y, color: fillProp ?? color },
22
+ stat
23
+ })
24
+ })
25
+ onDestroy(() => {
26
+ if (id) plotState.unregisterGeom(id)
27
+ })
28
+
29
+ $effect(() => {
30
+ if (id)
31
+ plotState.updateGeom(id, {
32
+ channels: { x, y, color: fillProp ?? color },
33
+ stat
34
+ })
35
+ })
36
+
37
+ const data = $derived(id ? plotState.geomData(id) : [])
38
+ const xScale = $derived(plotState.xScale)
39
+ const yScale = $derived(plotState.yScale)
40
+ const continuousColor = $derived(plotState.continuousColorScale)
41
+
42
+ // Hex binning: group data points into hexagonal cells
43
+ function hexBin(data, xField, yField, xScale, yScale, r) {
44
+ const dx = r * 2 * Math.sin(Math.PI / 3) // horizontal spacing
45
+ const dy = r * 1.5 // vertical spacing
46
+ const bins = new Map()
47
+
48
+ for (const d of data) {
49
+ const px = xScale(d[xField])
50
+ const py = yScale(d[yField])
51
+ if (px === null || px === undefined || py === null || py === undefined) continue
52
+
53
+ // Find nearest hex center
54
+ const col = Math.round(px / dx)
55
+ const row = Math.round(py / dy)
56
+ const cx = col * dx + (row % 2 ? dx / 2 : 0)
57
+ const cy = row * dy
58
+ const key = `${col},${row}`
59
+
60
+ if (!bins.has(key)) {
61
+ bins.set(key, { cx, cy, count: 0, points: [] })
62
+ }
63
+ const bin = bins.get(key)
64
+ bin.count++
65
+ bin.points.push(d)
66
+ }
67
+ return [...bins.values()]
68
+ }
69
+
70
+ // Build hexagon path for a given radius
71
+ function hexPath(r) {
72
+ const angles = [0, 1, 2, 3, 4, 5].map((i) => ((i * 60 - 30) * Math.PI) / 180)
73
+ return `${angles.map((a, i) => `${i === 0 ? 'M' : 'L'}${r * Math.cos(a)},${r * Math.sin(a)}`).join('') }Z`
74
+ }
75
+
76
+ const hexes = $derived.by(() => {
77
+ if (!data?.length || !xScale || !yScale) return []
78
+ const bins = hexBin(data, x, y, xScale, yScale, radius)
79
+ const maxCount = Math.max(1, ...bins.map((b) => b.count))
80
+ const colorScale = continuousColor?.scale
81
+
82
+ return bins.map((bin, i) => ({
83
+ key: `hex-${i}`,
84
+ cx: bin.cx,
85
+ cy: bin.cy,
86
+ count: bin.count,
87
+ fill: colorScale
88
+ ? colorScale(bin.count)
89
+ : `rgba(66, 133, 244, ${0.1 + 0.9 * (bin.count / maxCount)})`,
90
+ data: { count: bin.count, x: bin.cx, y: bin.cy }
91
+ }))
92
+ })
93
+
94
+ const hex = $derived(hexPath(radius))
95
+ </script>
96
+
97
+ {#if hexes.length > 0}
98
+ <g data-plot-geom="hexbin">
99
+ {#each hexes as h (h.key)}
100
+ <path
101
+ transform="translate({h.cx},{h.cy})"
102
+ d={hex}
103
+ fill={h.fill}
104
+ stroke="white"
105
+ stroke-width="0.5"
106
+ data-plot-element="hex"
107
+ onmouseenter={() => plotState.setHovered(h.data)}
108
+ onmouseleave={() => plotState.clearHovered()}
109
+ >
110
+ <title>{h.count} points</title>
111
+ </path>
112
+ {/each}
113
+ </g>
114
+ {/if}
@@ -0,0 +1,17 @@
1
+ <script>
2
+ /** @type {{ x: number, y: number, text: string, color?: string }} */
3
+ let { x, y, text, color = '#333' } = $props()
4
+
5
+ const w = $derived(Math.max(36, String(text).length * 7 + 12))
6
+ </script>
7
+
8
+ <g transform="translate({x},{y})" pointer-events="none" data-plot-element="label">
9
+ <rect x={-w / 2} y="-9" width={w} height="18" rx="4" fill="white" fill-opacity="0.82" />
10
+ <text
11
+ text-anchor="middle"
12
+ dominant-baseline="central"
13
+ font-size="11"
14
+ font-weight="600"
15
+ fill={color}>{text}</text
16
+ >
17
+ </g>
@@ -0,0 +1,123 @@
1
+ <script>
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+ import { buildLines } from '../lib/brewing/marks/lines.js'
4
+ import { buildSymbolPath } 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
+ symbol: symbolField,
13
+ label = false,
14
+ stat = 'identity',
15
+ options = {},
16
+ onselect = undefined,
17
+ keyboard = false
18
+ } = $props()
19
+
20
+ /**
21
+ * @param {Record<string, unknown>} data
22
+ * @returns {string | null}
23
+ */
24
+ function resolveLabel(data) {
25
+ if (!label) return null
26
+ if (label === true) return String(data[y] ?? '')
27
+ if (typeof label === 'function') return String(label(data) ?? '')
28
+ return typeof label === 'string' ? String(data[label] ?? '') : null
29
+ }
30
+
31
+ const plotState = getContext('plot-state')
32
+ let id = $state(null)
33
+
34
+ onMount(() => {
35
+ id = plotState.registerGeom({
36
+ type: 'line',
37
+ channels: { x, y, color, symbol: symbolField },
38
+ stat,
39
+ options
40
+ })
41
+ })
42
+ onDestroy(() => {
43
+ if (id) plotState.unregisterGeom(id)
44
+ })
45
+
46
+ $effect(() => {
47
+ if (id) plotState.updateGeom(id, { channels: { x, y, color, symbol: symbolField }, stat })
48
+ })
49
+
50
+ const data = $derived(id ? plotState.geomData(id) : [])
51
+ const xScale = $derived(plotState.xScale)
52
+ const yScale = $derived(plotState.yScale)
53
+ const colors = $derived(plotState.colors)
54
+ const symbolMap = $derived(plotState.symbols)
55
+
56
+ const lines = $derived.by(() => {
57
+ if (!data?.length || !xScale || !yScale) return []
58
+ return buildLines(data, { x, y, color }, xScale, yScale, colors, options.curve)
59
+ })
60
+
61
+ const markerRadius = $derived(options.markerRadius ?? 4)
62
+ </script>
63
+
64
+ {#if lines.length > 0}
65
+ <g data-plot-geom="line">
66
+ {#each lines as seg (seg.key ?? seg.d)}
67
+ <path
68
+ d={seg.d}
69
+ fill="none"
70
+ stroke={seg.stroke}
71
+ stroke-width={options.strokeWidth ?? 2}
72
+ stroke-linejoin="round"
73
+ stroke-linecap="round"
74
+ data-plot-element="line"
75
+ />
76
+ {#if symbolField && symbolMap}
77
+ {#each seg.points as pt (`${pt.x}::${pt.y}`)}
78
+ <path
79
+ transform="translate({pt.x},{pt.y})"
80
+ d={buildSymbolPath(symbolMap.get(pt.data[symbolField]) ?? 'circle', markerRadius)}
81
+ fill={seg.stroke}
82
+ stroke={seg.stroke}
83
+ stroke-width="1"
84
+ data-plot-element="line-marker"
85
+ />
86
+ {/each}
87
+ {/if}
88
+ {#if label}
89
+ {#each seg.points as pt (`label::${pt.x}::${pt.y}`)}
90
+ {@const text = resolveLabel(pt.data)}
91
+ {#if text}
92
+ <LabelPill
93
+ x={pt.x + (options.labelOffset?.x ?? 0)}
94
+ y={pt.y + (options.labelOffset?.y ?? -12)}
95
+ {text}
96
+ color={seg.stroke ?? '#333'}
97
+ />
98
+ {/if}
99
+ {/each}
100
+ {/if}
101
+ <!-- Invisible hit areas for tooltip and selection -->
102
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
103
+ {#each seg.points as pt (`hover::${pt.x}::${pt.y}`)}
104
+ <circle
105
+ cx={pt.x}
106
+ cy={pt.y}
107
+ r="8"
108
+ fill="transparent"
109
+ stroke="none"
110
+ role={onselect || keyboard ? 'button' : 'presentation'}
111
+ tabindex={onselect || keyboard ? 0 : undefined}
112
+ style:cursor={onselect ? 'pointer' : undefined}
113
+ data-plot-element="line-hover"
114
+ onmouseenter={() => plotState.setHovered(pt.data)}
115
+ onmouseleave={() => plotState.clearHovered()}
116
+ onclick={onselect ? () => onselect(pt.data) : undefined}
117
+ onkeydown={onselect ? (e) => (e.key === 'Enter' || e.key === ' ') && onselect(pt.data) : undefined}
118
+ use:keyboardNav={keyboard}
119
+ />
120
+ {/each}
121
+ {/each}
122
+ </g>
123
+ {/if}