@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.
- package/README.md +150 -46
- package/package.json +40 -51
- package/src/AnimatedPlot.svelte +383 -0
- package/src/Chart.svelte +87 -59
- package/src/ChartExporter.svelte +125 -0
- package/src/ChartLegend.svelte +148 -0
- package/src/ChartProvider.svelte +10 -0
- package/src/FacetPlot/Panel.svelte +37 -0
- package/src/FacetPlot.svelte +114 -0
- package/src/Plot/Arc.svelte +29 -0
- package/src/Plot/Area.svelte +32 -0
- package/src/Plot/Axis.svelte +164 -0
- package/src/Plot/Bar.svelte +54 -0
- package/src/Plot/Grid.svelte +34 -0
- package/src/Plot/Legend.svelte +268 -0
- package/src/Plot/Line.svelte +37 -0
- package/src/Plot/Point.svelte +40 -0
- package/src/Plot/Root.svelte +62 -0
- package/src/Plot/Timeline.svelte +95 -0
- package/src/Plot/Tooltip.svelte +87 -0
- package/src/Plot/index.js +9 -0
- package/src/Plot.svelte +313 -0
- package/src/PlotState.svelte.js +375 -0
- package/src/Sparkline.svelte +108 -0
- package/src/Symbol.svelte +15 -11
- package/src/Texture.svelte +9 -7
- package/src/charts/AreaChart.svelte +30 -0
- package/src/charts/BarChart.svelte +31 -0
- package/src/charts/BoxPlot.svelte +24 -0
- package/src/charts/BubbleChart.svelte +28 -0
- package/src/charts/LineChart.svelte +31 -0
- package/src/charts/PieChart.svelte +35 -0
- package/src/charts/ScatterPlot.svelte +31 -0
- package/src/charts/ViolinPlot.svelte +24 -0
- package/src/crossfilter/CrossFilter.svelte +42 -0
- package/src/crossfilter/FilterBar.svelte +24 -0
- package/src/crossfilter/FilterHistogram.svelte +290 -0
- package/src/crossfilter/FilterSlider.svelte +83 -0
- package/src/crossfilter/createCrossFilter.svelte.js +124 -0
- package/src/elements/Bar.svelte +20 -16
- package/src/elements/ColorRamp.svelte +19 -19
- package/src/elements/ContinuousLegend.svelte +41 -19
- package/src/elements/DefinePatterns.svelte +9 -7
- package/src/elements/DiscreteLegend.svelte +13 -11
- package/src/elements/Label.svelte +4 -9
- package/src/elements/SymbolGrid.svelte +8 -9
- package/src/examples/BarChartExample.svelte +81 -0
- package/src/geoms/Arc.svelte +126 -0
- package/src/geoms/Area.svelte +78 -0
- package/src/geoms/Bar.svelte +200 -0
- package/src/geoms/Box.svelte +113 -0
- package/src/geoms/Candlestick.svelte +110 -0
- package/src/geoms/Heatmap.svelte +92 -0
- package/src/geoms/Hexbin.svelte +114 -0
- package/src/geoms/LabelPill.svelte +17 -0
- package/src/geoms/Line.svelte +123 -0
- package/src/geoms/Point.svelte +145 -0
- package/src/geoms/Ribbon.svelte +161 -0
- package/src/geoms/Violin.svelte +56 -0
- package/src/geoms/Waterfall.svelte +114 -0
- package/src/geoms/lib/areas.js +154 -0
- package/src/geoms/lib/bars.js +223 -0
- package/src/index.js +81 -2
- package/src/lib/brewing/BoxBrewer.svelte.js +14 -0
- package/src/lib/brewing/CartesianBrewer.svelte.js +21 -0
- package/src/lib/brewing/PieBrewer.svelte.js +14 -0
- package/src/lib/brewing/QuartileBrewer.svelte.js +51 -0
- package/src/lib/brewing/ViolinBrewer.svelte.js +14 -0
- package/src/lib/brewing/axes.svelte.js +270 -0
- package/src/lib/brewing/bars.svelte.js +201 -0
- package/src/lib/brewing/brewer.svelte.js +277 -0
- package/src/lib/brewing/colors.js +157 -0
- package/src/lib/brewing/dimensions.svelte.js +56 -0
- package/src/lib/brewing/index.svelte.js +205 -0
- package/src/lib/brewing/legends.svelte.js +137 -0
- package/src/lib/brewing/marks/arcs.js +43 -0
- package/src/lib/brewing/marks/areas.js +72 -0
- package/src/lib/brewing/marks/bars.js +49 -0
- package/src/lib/brewing/marks/boxes.js +75 -0
- package/src/lib/brewing/marks/lines.js +55 -0
- package/src/lib/brewing/marks/points.js +105 -0
- package/src/lib/brewing/marks/violins.js +90 -0
- package/src/lib/brewing/patterns.js +45 -0
- package/src/lib/brewing/scales.js +51 -0
- package/src/lib/brewing/scales.svelte.js +82 -0
- package/src/lib/brewing/stats.js +74 -0
- package/src/lib/brewing/symbols.js +10 -0
- package/src/lib/brewing/types.js +73 -0
- package/src/lib/chart.js +16 -8
- package/src/lib/context.js +131 -0
- package/src/lib/keyboard-nav.js +37 -0
- package/src/lib/plot/chartProps.js +76 -0
- package/src/lib/plot/crossfilter.js +16 -0
- package/src/lib/plot/facet.js +58 -0
- package/src/lib/plot/frames.js +81 -0
- package/src/lib/plot/helpers.js +14 -0
- package/src/lib/plot/preset.js +67 -0
- package/src/lib/plot/scales.js +81 -0
- package/src/lib/plot/stat.js +92 -0
- package/src/lib/plot/types.js +65 -0
- package/src/lib/preset.js +41 -0
- package/src/lib/scales.svelte.js +151 -0
- package/src/lib/swatch.js +4 -7
- package/src/lib/utils.js +142 -0
- package/src/lib/xscale.js +31 -0
- package/src/patterns/DefinePatterns.svelte +32 -0
- package/src/patterns/PatternDef.svelte +27 -0
- package/src/patterns/index.js +4 -14
- package/src/patterns/patterns.js +360 -0
- package/src/patterns/scale.js +116 -0
- package/src/spec/chart-spec.js +72 -0
- package/src/symbols/RoundedSquare.svelte +18 -12
- package/src/symbols/Shape.svelte +22 -16
- package/src/symbols/constants/index.js +1 -1
- package/LICENSE +0 -21
- package/src/lib/index.js +0 -4
- package/src/lib/plots.js +0 -27
- package/src/patterns/Brick.svelte +0 -17
- package/src/patterns/Circles.svelte +0 -18
- package/src/patterns/CrossHatch.svelte +0 -14
- package/src/patterns/CurvedWave.svelte +0 -9
- package/src/patterns/Dots.svelte +0 -19
- package/src/patterns/OutlineCircles.svelte +0 -15
- package/src/patterns/Tile.svelte +0 -17
- package/src/patterns/Triangles.svelte +0 -15
- package/src/patterns/Waves.svelte +0 -13
- package/src/patterns/paths/NamedPattern.svelte +0 -12
- package/src/patterns/paths/constants.js +0 -4
- package/src/plots/Plot.svelte +0 -40
- package/src/plots/index.js +0 -1
- package/src/template/Texture.svelte +0 -16
- package/src/template/constants.js +0 -43
- package/src/template/shapes/Circles.svelte +0 -16
- package/src/template/shapes/Lines.svelte +0 -17
- package/src/template/shapes/Path.svelte +0 -12
- package/src/template/shapes/Polygons.svelte +0 -18
- 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
|
+
}
|