diff --git a/manual.typ b/manual.typ index ad4d930..6a938ce 100644 --- a/manual.typ +++ b/manual.typ @@ -54,7 +54,7 @@ module imported into the namespace. = Plot #doc-style.parse-show-module("/src/plot.typ") -#for m in ("line", "bar", "boxwhisker", "contour", "errorbar", "annotation", "formats") { +#for m in ("line", "bar", "boxwhisker", "contour", "errorbar", "annotation", "formats", "violin") { doc-style.parse-show-module("/src/plot/" + m + ".typ") } diff --git a/src/plot.typ b/src/plot.typ index 04578fd..05640d5 100644 --- a/src/plot.typ +++ b/src/plot.typ @@ -12,6 +12,7 @@ #import "/src/plot/bar.typ": add-bar #import "/src/plot/errorbar.typ": add-errorbar #import "/src/plot/mark.typ" +#import "/src/plot/violin.typ": add-violin #import "/src/plot/formats.typ" #import plot-legend: add-legend diff --git a/src/plot/violin.typ b/src/plot/violin.typ new file mode 100644 index 0000000..3f8f876 --- /dev/null +++ b/src/plot/violin.typ @@ -0,0 +1,135 @@ +#import "/src/cetz.typ": draw +#import "util.typ" +#import "sample.typ" + +#let kernel-normal(x, stdev: 1.5) = { + (1/calc.sqrt(2*calc.pi*calc.pow(stdev,2))) * calc.exp( - (x*x)/(2*calc.pow(stdev,2))) +} + +#let _violin-render(self, ctx, violin, filling: true) = { + let path = range(self.samples) + .map((t)=>violin.min + (violin.max - violin.min) * (t /self.samples )) + .map((u)=>(u, (violin.convolve)(u))) + .map(((u,v)) => { + (violin.x-position + v, u) + }) + + if self.side == "both"{ + path += path.rev().map(((x,y))=> {(2 * violin.x-position - x,y)}) + } else if self.side == "left"{ + path = path.map( ((x,y))=>{(2 * violin.x-position - x,y)}) + } + + let (x, y) = (ctx.x, ctx.y) + let stroke-paths = util.compute-stroke-paths(path, (x.min, y.min), (x.max, y.max)) + + for p in stroke-paths{ + let args = arguments(..p, closed: self.side == "both") + if filling { + args = arguments(..args, stroke: none) + } else { + args = arguments(..args, fill: none) + } + draw.line(..self.style, ..args) + } +} + +#let _plot-prepare(self, ctx) = { + self.violins = self.data.map(entry=> { + let points = entry.at(self.y-key) + let (min, max) = (calc.min(..points), calc.max(..points)) + let range = calc.abs(max - min) + ( + x-position: entry.at(self.x-key), + points: points, + length: points.len(), + min: min - (self.extents * range), + max: max + (self.extents * range), + convolve: (t) => { + points.map((y)=>(self.kernel)((y - t)/self.bandwidth)).sum() / (points.len() * self.bandwidth) + } + ) + }) + return self +} + +#let _plot-stroke(self, ctx) = { + for violin in self.violins { + _violin-render(self, ctx, violin, filling: false) + } +} + +#let _plot-fill(self, ctx) = { + for violin in self.violins { + _violin-render(self, ctx, violin, filling: true) + } +} + +#let _plot-legend-preview(self) = { + draw.rect((0,0), (1,1), ..self.style) +} + + +/// Add a violin plot +/// +/// A violin plot is a chart that can be used to compare the distribution of continuous +/// data between categories. +/// +/// - data (array): Array of data items. An item is an array containing an `x` and one +/// or more `y` values. +/// - x-key (int, string): Key to use for retreiving the `x` position of the violin. +/// - y-key (int, string): Key to use for retreiving values of points within the category. +/// - side (string): The sides of the violin to be rendered: +/// / left: Plot only the left side of the violin. +/// / right: Plot only the right side of the violin. +/// / both: Plot both sides of the violin. +/// - kernel (function): The kernel density estimator function, which takes a single +/// `x` value relative to the center of a distribution (0) and +/// normalized by the bandwidth +/// - bandwidth (float): The smoothing parameter of the kernel. +/// - extents (float): The extension of the domain, expressed as a fraction of spread. +/// - samples (int): The number of samples of the kernel to render. +/// - style (dictionary): Style override dictionary. +/// - mark-style (dictionary): (unused, will eventually be used to render interquartile ranges). +/// - axes (axes): (unstable, documentation to follow once completed). +/// - label (none, content): The name of the category to be shown in the legend. +#let add-violin( + data, + x-key: 0, + y-key: 1, + side: "right", + kernel: kernel-normal.with(stdev: 1.5), + bandwidth: 1, + extents: 0.25, + + samples: 50, + style: (:), + mark-style: (:), + axes: ("x", "y"), + label: none, +) = { + + (( + type: "violins", + + data: data, + x-key: x-key, + y-key: y-key, + side: side, + kernel: kernel, + bandwidth: bandwidth, + extents: extents, + + samples: samples, + style: style, + mark-style: mark-style, + axes: axes, + label: label, + + plot-prepare: _plot-prepare, + plot-stroke: _plot-stroke, + plot-fill: _plot-fill, + plot-legend-preview: _plot-legend-preview, + ),) + +} \ No newline at end of file diff --git a/tests/plot/violin/ref/1.png b/tests/plot/violin/ref/1.png new file mode 100644 index 0000000..88e6d7d Binary files /dev/null and b/tests/plot/violin/ref/1.png differ diff --git a/tests/plot/violin/test.typ b/tests/plot/violin/test.typ new file mode 100644 index 0000000..ed1cbfc --- /dev/null +++ b/tests/plot/violin/test.typ @@ -0,0 +1,63 @@ +#set page(width: auto, height: auto) +#import "/src/lib.typ": * +#import "/src/cetz.typ": * +#import "/tests/helper.typ": * + +/* Empty plot */ +#test-case({ + import draw: * + + draw.set-style( + axes: ( + stroke: 0.55pt, + tick: ( + stroke: 0.5pt, + ) + ), + legend: ( + stroke: none, + ) + ) + + let default-colors = (palette.blue-colors.at(3), palette.pink-colors.at(3)) + + plot.plot(size: (9, 6), + + y-label: [Age], + y-min: -10, y-max: 20, + y-tick-step: 10, y-minor-tick-step: 5, + y-grid: "major", + + x-label: [Class], + x-min: -0.5, x-max: 2.5, + x-tick-step: none, + x-ticks: ( (0, [First]), (1, [Second]), (2, [Third])), + + plot-style: (i) => { + let color = default-colors.at(calc.rem(i, default-colors.len())) + (stroke: color + 0.75pt, fill: color.lighten(75%)) + }, + { + let vals = ( + (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,)), + (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,)), + (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,)), + ) + + cetz-plot.plot.add-violin( + vals, + extents: 0.5, + side: "left", + bandwidth: 0.45, + label: [Male], + ) + + cetz-plot.plot.add-violin( + vals, + extents: 0.5, + side: "right", + bandwidth: 0.5, + label: [Female] + ) + }) +}) \ No newline at end of file