Skip to content

Commit

Permalink
plot.add-boxwhisker - A second take on box-whisker plots (#287)
Browse files Browse the repository at this point in the history
In PR #265, I implemented a box and whisker chart following the style of
other charts, rather than implementing it as a plot. I believe this was
a mistake as it quickly became cumbersome adding more features.

Rather than implement a box and whisker chart all in one go, I've opted
to make a pull request where drawing a single box and whisker is handled
by a plot.


![image](https://github.com/johannes-wolf/cetz/assets/6299280/57af47e3-d6c9-4cee-8aa1-ae0b073c5b3f)
  • Loading branch information
jamesrswift authored Oct 26, 2023
1 parent 94af05b commit 27daf02
Show file tree
Hide file tree
Showing 6 changed files with 258 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/lib/chart.typ
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
#import "../util.typ"
#import "../styles.typ"

#import "chart/boxwhisker.typ": boxwhisker

// Styles
#let barchart-default-style = (
axes: (tick: (length: 0))
Expand Down
76 changes: 76 additions & 0 deletions src/lib/chart/boxwhisker.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#import "../palette.typ"
#import "../plot.typ"
#import "../../draw.typ"
#import "../../canvas.typ"

#let boxwhisker-default-style = (
axes: (tick: (length: -0.1)),
grid: none,
)

/// Add one or more box or whisker plots
///
/// - data (array, dictionary): dictionary or array of dictionaries containing the
/// needed entries to plot box and whisker plot.
///
/// *Examples:*
/// - ```( x: 1 // Location on x-axis
/// outliers: (7, 65, 69), // Optional
/// min: 15, max: 60 // Minimum and maximum
/// q1: 25, // Quartiles
/// q2: 35,
/// q3: 50
/// )```
/// - size (array) : Size of chart. If the second entry is auto, it automatically scales to accomodate the number of entries plotted
/// - y-min (float) : Lower end of y-axis range. If auto, defaults to lowest outlier or lowest min.
/// - y-max (float) : Upper end of y-axis range. If auto, defaults to greatest outlier or greatest max.
/// - label-key (integer, string): Index in the array where labels of each entry is stored
/// - box-width (float): Width from edge-to-edge of the box of the box and whisker in plot units. Defaults to 0.75
/// - whisker-width (float): Width from edge-to-edge of the whisker of the box and whisker in plot units. Defaults to 0.5
/// - mark (string): Mark to use for plotting outliers. Set `none` to disable. Defaults to "x"
/// - mark-size (float): Size of marks for plotting outliers. Defaults to 0.15
/// - ..arguments (variadic): Additional arguments are passed to `plot.plot`
#let boxwhisker( data,
size: (1, auto),
y-min: auto,
y-max: auto,
label-key: 0,
box-width: 0.75,
whisker-width: 0.5,
mark: "*",
mark-size: 0.15,
..arguments
) = {
// import draw: *

if type(data) == dictionary { data = (data,) }

if size.at(1) == auto {size.at(1) = (data.len() + 1)}

let x-tic-list = data.enumerate().map(((i, t)) => {
(i + 1, t.at(label-key, default: i))
})

plot.plot(
size: size,
x-tick-step: none,
x-ticks: x-tic-list,
y-min: y-min,
y-max: y-max,
x-label: none,
..arguments,
{
for (i, row) in data.enumerate() {
plot.add-boxwhisker(
( x: i + 1, ..row),
box-width: box-width,
whisker-width: whisker-width,
style: (:),
mark: mark,
mark-size: mark-size
)
}
}
)

}
2 changes: 2 additions & 0 deletions src/lib/plot.typ
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
#import "plot/sample.typ": sample-fn, sample-fn2
#import "plot/line.typ": add, add-hline, add-vline
#import "plot/contour.typ": add-contour
#import "plot/boxwhisker.typ": add-boxwhisker

#import "../draw.typ"
#import "../vector.typ"
#import "../bezier.typ"
Expand Down
114 changes: 114 additions & 0 deletions src/lib/plot/boxwhisker.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#import "../../draw.typ"
#import "../../util.typ"

/// Add one or more box or whisker plots
///
/// - data (array, dictionary): dictionary or array of dictionaries containing the
/// needed entries to plot box and whisker plot.
///
/// *Examples:*
/// - ```( x: 1 // Location on x-axis
/// outliers: (7, 65, 69), // Optional
/// min: 15, max: 60 // Minimum and maximum
/// q1: 25, // Quartiles
/// q2: 35,
/// q3: 50
/// )```
/// - axes (array): Name of the axes to use ("x", "y"), note that not all
/// plot styles are able to display a custom axis!
/// - style (style): Style to use, can be used with a palette function
/// - box-width (float): Width from edge-to-edge of the box of the box and whisker in plot units. Defaults to 0.75
/// - whisker-width (float): Width from edge-to-edge of the whisker of the box and whisker in plot units. Defaults to 0.5
/// - mark (string): Mark to use for plotting outliers. Set `none` to disable. Defaults to "x"
/// - mark-size (float): Size of marks for plotting outliers. Defaults to 0.15
#let add-boxwhisker(
data,
axes: ("x", "y"),
style: (:),
box-width: 0.75,
whisker-width: 0.5,
mark: "*",
mark-size: 0.15
) = {

if type(data) == array {
for it in data{
add-boxwhisker(
it,
axes:axes,
style: style,
box-width: box-width,
whisker-width: whisker-width,
mark: mark,
mark-size: mark-size
)
}
return
}

assert( "x" in data, message: "Specify the x value at which to display the box and whisker")
assert( "min" in data, message: "Specify the q1, the minimum excluding outliers")
assert( "q1" in data, message: "Specify the q1, the lower quartile")
assert( "q2" in data, message: "Specify the q2, the median")
assert( "q3" in data, message: "Specify the q3, the upper quartile")
assert( "max" in data, message: "Specify the q1, the minimum excluding outliers")

// Calculate y-domain

let max-value = calc.max(
0,data.max,
..data.at("outliers", default: (0,))
)

let min-value = calc.min(
0,data.min,
..data.at("outliers", default: (0,))
)

let max-value = util.max(data.max, ..data.at("outliers", default: ()))
let min-value = util.min(data.min, ..data.at("outliers", default: ()))

let prepare(self, ctx) = {
return self
}

let stroke(self, ctx) = {
let data = self.bw-data

// Box
draw.rect((data.x - box-width / 2, data.q1),
(data.x + box-width / 2, data.q3),
..self.style)

// Mean
draw.line((data.x - box-width / 2, data.q2),
(data.x + box-width / 2, data.q2),
..self.style)

// whiskers
let whisker(x, start, end) = {
draw.line((x, start),(x, end),..self.style)
draw.line((x - whisker-width / 2, end),(x + whisker-width / 2, end), ..self.style)
}
whisker(data.x, data.q3, data.max)
whisker(data.x, data.q1, data.min)
}

((
type: "boxwhisker",
axes: axes,
bw-data: data,
style: style,
plot-prepare: prepare,
plot-stroke: stroke,
x-domain: (
data.x - calc.max(whisker-width, box-width),
data.x + calc.max(whisker-width, box-width)),
y-domain: (min-value, max-value),
) + (if "outliers" in data { (
data: data.outliers.map(it=>(data.x, it)),
mark: mark,
mark-size: mark-size,
mark-style: (:)
) }),)
}
26 changes: 26 additions & 0 deletions tests/chart/boxwhisker/test.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#set page(width: auto, height: auto)
#import "/src/lib.typ": *

#let data0 = (
(
label: "Control",
min: 10,q1: 25,q2: 50,
q3: 75,max: 90
),
(
label: "Condition aB",
min: 32,q1: 54,q2: 60,
q3: 69,max: 73,
outliers: (18, 23, 78,)
),
)

#box( canvas({
chart.boxwhisker(
y-min:0,
y-max: 100,
size: (10, 10),
label-key: "label",
data0
)
}))
38 changes: 38 additions & 0 deletions tests/plot/boxwhisker/test.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#set page(width: auto, height: auto)
#import "/src/lib.typ": *

#let data = (
outliers: (7, 65, 69),
min: 15,
q1: 25,
q2: 35,
q3: 50,
max: 60
)

#box(stroke: 2pt + red, canvas({
import draw: *

plot.plot(size: (10, 10),
y-min: 0,
y-max: 100,
{
plot.add-boxwhisker((x: 1, ..data))
})
}))

#box(stroke: 2pt + red, canvas({
import draw: *

plot.plot(size: (10, 10),
y-min: 0,
y-max: 100,
{
plot.add-boxwhisker((
(x: 1, ..data),
(x: 2, ..data),
(x: 3, ..data),
(x: 4, ..data),
))
})
}))

0 comments on commit 27daf02

Please sign in to comment.