Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

plot.add-boxwhisker - A second take on box-whisker plots #287

Merged
merged 10 commits into from
Oct 26, 2023
Merged
2 changes: 2 additions & 0 deletions src/lib/chart.typ
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
#import "palette.typ"
#import "../draw.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
)
}
}
)

}
1 change: 1 addition & 0 deletions src/lib/plot.typ
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#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

#let default-colors = (blue, red, green, yellow, black)

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(
jamesrswift marked this conversation as resolved.
Show resolved Hide resolved
jamesrswift marked this conversation as resolved.
Show resolved Hide resolved
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(
jamesrswift marked this conversation as resolved.
Show resolved Hide resolved
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",
jamesrswift marked this conversation as resolved.
Show resolved Hide resolved
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),
))
})
}))