Skip to content

Commit e151c4d

Browse files
authored
Violin plot (#25)
* begin work on violin plot * testing * Violin plots (draft) * Better violin plots * Better violin plots * documentation and clean * update test * Add to manual and fix typo * rename to `add-violin` * document kernal signature * update tests
1 parent 2e7158a commit e151c4d

File tree

5 files changed

+200
-1
lines changed

5 files changed

+200
-1
lines changed

manual.typ

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ module imported into the namespace.
5454
= Plot
5555

5656
#doc-style.parse-show-module("/src/plot.typ")
57-
#for m in ("line", "bar", "boxwhisker", "contour", "errorbar", "annotation", "formats") {
57+
#for m in ("line", "bar", "boxwhisker", "contour", "errorbar", "annotation", "formats", "violin") {
5858
doc-style.parse-show-module("/src/plot/" + m + ".typ")
5959
}
6060

src/plot.typ

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#import "/src/plot/bar.typ": add-bar
1313
#import "/src/plot/errorbar.typ": add-errorbar
1414
#import "/src/plot/mark.typ"
15+
#import "/src/plot/violin.typ": add-violin
1516
#import "/src/plot/formats.typ"
1617
#import plot-legend: add-legend
1718

src/plot/violin.typ

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#import "/src/cetz.typ": draw
2+
#import "util.typ"
3+
#import "sample.typ"
4+
5+
#let kernel-normal(x, stdev: 1.5) = {
6+
(1/calc.sqrt(2*calc.pi*calc.pow(stdev,2))) * calc.exp( - (x*x)/(2*calc.pow(stdev,2)))
7+
}
8+
9+
#let _violin-render(self, ctx, violin, filling: true) = {
10+
let path = range(self.samples)
11+
.map((t)=>violin.min + (violin.max - violin.min) * (t /self.samples ))
12+
.map((u)=>(u, (violin.convolve)(u)))
13+
.map(((u,v)) => {
14+
(violin.x-position + v, u)
15+
})
16+
17+
if self.side == "both"{
18+
path += path.rev().map(((x,y))=> {(2 * violin.x-position - x,y)})
19+
} else if self.side == "left"{
20+
path = path.map( ((x,y))=>{(2 * violin.x-position - x,y)})
21+
}
22+
23+
let (x, y) = (ctx.x, ctx.y)
24+
let stroke-paths = util.compute-stroke-paths(path, (x.min, y.min), (x.max, y.max))
25+
26+
for p in stroke-paths{
27+
let args = arguments(..p, closed: self.side == "both")
28+
if filling {
29+
args = arguments(..args, stroke: none)
30+
} else {
31+
args = arguments(..args, fill: none)
32+
}
33+
draw.line(..self.style, ..args)
34+
}
35+
}
36+
37+
#let _plot-prepare(self, ctx) = {
38+
self.violins = self.data.map(entry=> {
39+
let points = entry.at(self.y-key)
40+
let (min, max) = (calc.min(..points), calc.max(..points))
41+
let range = calc.abs(max - min)
42+
(
43+
x-position: entry.at(self.x-key),
44+
points: points,
45+
length: points.len(),
46+
min: min - (self.extents * range),
47+
max: max + (self.extents * range),
48+
convolve: (t) => {
49+
points.map((y)=>(self.kernel)((y - t)/self.bandwidth)).sum() / (points.len() * self.bandwidth)
50+
}
51+
)
52+
})
53+
return self
54+
}
55+
56+
#let _plot-stroke(self, ctx) = {
57+
for violin in self.violins {
58+
_violin-render(self, ctx, violin, filling: false)
59+
}
60+
}
61+
62+
#let _plot-fill(self, ctx) = {
63+
for violin in self.violins {
64+
_violin-render(self, ctx, violin, filling: true)
65+
}
66+
}
67+
68+
#let _plot-legend-preview(self) = {
69+
draw.rect((0,0), (1,1), ..self.style)
70+
}
71+
72+
73+
/// Add a violin plot
74+
///
75+
/// A violin plot is a chart that can be used to compare the distribution of continuous
76+
/// data between categories.
77+
///
78+
/// - data (array): Array of data items. An item is an array containing an `x` and one
79+
/// or more `y` values.
80+
/// - x-key (int, string): Key to use for retreiving the `x` position of the violin.
81+
/// - y-key (int, string): Key to use for retreiving values of points within the category.
82+
/// - side (string): The sides of the violin to be rendered:
83+
/// / left: Plot only the left side of the violin.
84+
/// / right: Plot only the right side of the violin.
85+
/// / both: Plot both sides of the violin.
86+
/// - kernel (function): The kernel density estimator function, which takes a single
87+
/// `x` value relative to the center of a distribution (0) and
88+
/// normalized by the bandwidth
89+
/// - bandwidth (float): The smoothing parameter of the kernel.
90+
/// - extents (float): The extension of the domain, expressed as a fraction of spread.
91+
/// - samples (int): The number of samples of the kernel to render.
92+
/// - style (dictionary): Style override dictionary.
93+
/// - mark-style (dictionary): (unused, will eventually be used to render interquartile ranges).
94+
/// - axes (axes): (unstable, documentation to follow once completed).
95+
/// - label (none, content): The name of the category to be shown in the legend.
96+
#let add-violin(
97+
data,
98+
x-key: 0,
99+
y-key: 1,
100+
side: "right",
101+
kernel: kernel-normal.with(stdev: 1.5),
102+
bandwidth: 1,
103+
extents: 0.25,
104+
105+
samples: 50,
106+
style: (:),
107+
mark-style: (:),
108+
axes: ("x", "y"),
109+
label: none,
110+
) = {
111+
112+
((
113+
type: "violins",
114+
115+
data: data,
116+
x-key: x-key,
117+
y-key: y-key,
118+
side: side,
119+
kernel: kernel,
120+
bandwidth: bandwidth,
121+
extents: extents,
122+
123+
samples: samples,
124+
style: style,
125+
mark-style: mark-style,
126+
axes: axes,
127+
label: label,
128+
129+
plot-prepare: _plot-prepare,
130+
plot-stroke: _plot-stroke,
131+
plot-fill: _plot-fill,
132+
plot-legend-preview: _plot-legend-preview,
133+
),)
134+
135+
}

tests/plot/violin/ref/1.png

19.3 KB
Loading

tests/plot/violin/test.typ

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#set page(width: auto, height: auto)
2+
#import "/src/lib.typ": *
3+
#import "/src/cetz.typ": *
4+
#import "/tests/helper.typ": *
5+
6+
/* Empty plot */
7+
#test-case({
8+
import draw: *
9+
10+
draw.set-style(
11+
axes: (
12+
stroke: 0.55pt,
13+
tick: (
14+
stroke: 0.5pt,
15+
)
16+
),
17+
legend: (
18+
stroke: none,
19+
)
20+
)
21+
22+
let default-colors = (palette.blue-colors.at(3), palette.pink-colors.at(3))
23+
24+
plot.plot(size: (9, 6),
25+
26+
y-label: [Age],
27+
y-min: -10, y-max: 20,
28+
y-tick-step: 10, y-minor-tick-step: 5,
29+
y-grid: "major",
30+
31+
x-label: [Class],
32+
x-min: -0.5, x-max: 2.5,
33+
x-tick-step: none,
34+
x-ticks: ( (0, [First]), (1, [Second]), (2, [Third])),
35+
36+
plot-style: (i) => {
37+
let color = default-colors.at(calc.rem(i, default-colors.len()))
38+
(stroke: color + 0.75pt, fill: color.lighten(75%))
39+
},
40+
{
41+
let vals = (
42+
(0,(5,4,6,8,5.1,4.1,1,5.2,5.3,5.4,4.2,2,5.5,4.3,6,5,4,5,8,4,5,)),
43+
(1,(5,4,6,8,5.1,4.1,1,5.2,5.3,5.4,4.2,2,5.5,4.3,6,5,4,5,8,4,5,)),
44+
(2,(5,4,6,8,5.1,4.1,1,5.2,5.3,5.4,4.2,2,5.5,4.3,6,5,4,5,8,4,5,)),
45+
)
46+
47+
cetz-plot.plot.add-violin(
48+
vals,
49+
extents: 0.5,
50+
side: "left",
51+
bandwidth: 0.45,
52+
label: [Male],
53+
)
54+
55+
cetz-plot.plot.add-violin(
56+
vals,
57+
extents: 0.5,
58+
side: "right",
59+
bandwidth: 0.5,
60+
label: [Female]
61+
)
62+
})
63+
})

0 commit comments

Comments
 (0)