Skip to content

Commit 4ba426e

Browse files
feat: add support for radar charts (#174)
2 parents a6b1d99 + 6961848 commit 4ba426e

7 files changed

Lines changed: 264 additions & 5 deletions

File tree

gallery/radarchart.png

68.9 KB
Loading

gallery/radarchart.typ

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#import "@preview/cetz:0.4.2"
2+
#import "/src/lib.typ": chart
3+
4+
#set page(width: auto, height: auto, margin: .5cm)
5+
6+
#cetz.canvas({
7+
chart.radarchart(
8+
(
9+
[A],
10+
[B],
11+
[C],
12+
[D],
13+
[E],
14+
[F],
15+
),
16+
(
17+
(0.3, 1, 0.3, 0.8, 0.8, 1),
18+
(0.9, 0.3, 0.9, 0.5, 0.5, 0.4),
19+
),
20+
radius: 3,
21+
web-label-offset: 0.6,
22+
data-style: (
23+
blue.transparentize(10%),
24+
red.transparentize(30%),
25+
),
26+
)
27+
})
28+

manual.pdf

23.6 KB
Binary file not shown.

manual.typ

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
#set terms(indent: 1em)
2222
#set par(justify: true)
2323
#set heading(numbering: (..num) => if num.pos().len() < 4 {
24-
numbering("1.1", ..num)
25-
})
24+
numbering("1.1", ..num)
25+
})
2626
#show link: set text(blue)
2727

2828
// Outline
@@ -58,7 +58,17 @@ module imported into the namespace.
5858

5959
#doc-style.parse-show-module("/src/plot.typ")
6060

61-
#for m in ("line", "bar", "boxwhisker", "contour", "errorbar", "annotation", "formats", "violin", "legend") {
61+
#for m in (
62+
"line",
63+
"bar",
64+
"boxwhisker",
65+
"contour",
66+
"errorbar",
67+
"annotation",
68+
"formats",
69+
"violin",
70+
"legend",
71+
) {
6272
doc-style.parse-show-module("/src/plot/" + m + ".typ")
6373
}
6474

@@ -87,7 +97,14 @@ plot.plot(size: (5, 4), axis-style: "school-book", y-tick-step: none, {
8797
= Chart
8898

8999
#doc-style.parse-show-module("/src/chart.typ")
90-
#for m in ("barchart", "boxwhisker", "columnchart", "piechart", "pyramid") {
100+
#for m in (
101+
"barchart",
102+
"boxwhisker",
103+
"columnchart",
104+
"piechart",
105+
"radarchart",
106+
"pyramid",
107+
) {
91108
doc-style.parse-show-module("/src/chart/" + m + ".typ")
92109
}
93110

src/chart.typ

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
#import "chart/barchart.typ": barchart, barchart-default-style
33
#import "chart/columnchart.typ": columnchart, columnchart-default-style
44
#import "chart/piechart.typ": piechart, piechart-default-style
5-
#import "chart/pyramid.typ": pyramid, pyramid-default-style
5+
#import "chart/radarchart.typ": radarchart, radarchart-default-style
6+
#import "chart/pyramid.typ": pyramid, pyramid-default-style

src/chart/radarchart.typ

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#import "/src/cetz.typ": draw, palette, styles
2+
3+
#import "/src/plot.typ"
4+
5+
#let radarchart-default-style = (
6+
web-style: (
7+
stroke: black.lighten(40%),
8+
),
9+
web-ticks: 4,
10+
web-label-offset: 0.4,
11+
center-pos: (0, 0),
12+
radius: 2,
13+
)
14+
15+
/// Draw a radar chart (also known as spider chart or web chart). A radar
16+
/// chart is a chart that represents multivariate data in the form of a
17+
/// two-dimensional chart of three or more quantitative variables represented as
18+
/// axes starting from the same point.
19+
///
20+
/// ```cexample
21+
/// chart.radarchart(
22+
/// (
23+
/// [A],
24+
/// [B],
25+
/// [C],
26+
/// [D],
27+
/// [E],
28+
/// [F],
29+
/// ),
30+
/// (0.3, 0.6, 0.3, 0.4, 0.8, 1),
31+
/// )
32+
/// ```
33+
/// === Styling
34+
/// Can be applied with `cetz.draw.set-style(radarchart: (web-ticks: 6))`.
35+
///
36+
/// *Root*: `radarchart`.
37+
/// #show-parameter-block("web-style", "style", default: (stroke: black.lighten(40%)), [
38+
/// Style of the web in the background of the chart.])
39+
/// #show-parameter-block("web-ticks", ("int", "array"), default: 4, [
40+
/// Amount of layers of the web or an array containing the distance of each web layer to draw.])
41+
/// #show-parameter-block("web-label-offset", "float", default: 0.4, [
42+
/// Distance from the end of the web to the label.])
43+
/// #show-parameter-block("center-pos", "float", default: 1, [
44+
/// Coordinate of the center of the chart.])
45+
/// #show-parameter-block("radius", "float", default: 2, [
46+
/// Radius of the radar chart.])
47+
///
48+
/// - labels (array): Array of content. Each entry is the label
49+
/// of one coordinate axis.
50+
///
51+
/// *Example*
52+
/// ```typc
53+
/// ([A], [B], [C])
54+
/// ```
55+
/// - data (array): Array of data rows. A row can be of type array of float or
56+
/// array of array of float. All float values must be within the
57+
/// the range $0 <= "value" <= "radius"$. Each of the data rows must
58+
/// contain the same amount of items as `labels`.
59+
///
60+
/// *Example*
61+
/// ```typc
62+
/// ((0.5, 0.3, 0.9), (0.3, 0.5, 0.2))
63+
/// ```
64+
/// - data-style (function, array): Style per data row. Can be either
65+
/// - function: A function of the form `index => style` that must return a style dictionary.
66+
/// This can be a `palette` function.
67+
/// - array of style dictionaries: The dictionary at index `i` contains the style for the data row at index `i`.
68+
/// - array of colors: The dictionary at index `i` contains the fill color for the data row at index `i`.
69+
///
70+
#let radarchart(
71+
labels,
72+
data,
73+
data-style: palette.red,
74+
..style,
75+
) = {
76+
assert(type(labels) == array)
77+
assert(labels.len() >= 3)
78+
79+
assert(type(data) == array)
80+
assert(data.len() != 0)
81+
if type(data.at(0)) != array {
82+
// only one single data line
83+
data = (data,)
84+
}
85+
86+
// ensure that all data lines have the same amount of coordinates
87+
let size = labels.len()
88+
for line in data {
89+
assert(line.len() == size)
90+
}
91+
92+
draw.group(ctx => {
93+
let style = styles.resolve(
94+
ctx.style,
95+
merge: style.named(),
96+
root: "radarchart",
97+
base: radarchart-default-style,
98+
)
99+
draw.set-style(..style)
100+
101+
let center-pos = style.at("center-pos")
102+
let radius = style.at("radius")
103+
let web-ticks = style.at("web-ticks")
104+
let web-label-offset = style.at("web-label-offset")
105+
106+
// ensure that no data point overflows out of the chart
107+
for line in data {
108+
for value in line {
109+
assert(0 <= value and value <= radius)
110+
}
111+
}
112+
113+
assert(radius > 0)
114+
assert(type(web-ticks) in (int, array))
115+
if type(web-ticks) == int {
116+
// automatically calculate ticks amount of equidistant ticks
117+
web-ticks = range(web-ticks).map(i => (i + 1) / web-ticks)
118+
}
119+
120+
let angle-step = 360deg / labels.len()
121+
122+
// draw labels and lines from center to label
123+
// each of these axis is assigned the label "axis-{i}"
124+
for (i, label) in labels.enumerate() {
125+
let axis-name = "axis-" + str(i)
126+
draw.line(
127+
center-pos,
128+
(
129+
rel: (-angle-step * i + 90deg, radius),
130+
),
131+
name: axis-name,
132+
)
133+
draw.content(
134+
(axis-name + ".start", radius + web-label-offset, axis-name + ".end"),
135+
label,
136+
)
137+
}
138+
139+
// web drawing logic
140+
for tick in web-ticks {
141+
let web-points = ()
142+
for i in range(labels.len()) {
143+
web-points.push((
144+
rel: (-angle-step * i + 90deg, radius * tick),
145+
to: center-pos,
146+
))
147+
}
148+
draw.line(..web-points, close: true, ..style.at("web-style"))
149+
}
150+
151+
// draw the coordinates of each data line as a polygon
152+
for (line-index, line) in data.enumerate() {
153+
let pts = ()
154+
for (i, value) in line.enumerate() {
155+
let axis-name = "axis-" + str(i)
156+
pts.push((axis-name + ".start", radius * value, axis-name + ".end"))
157+
}
158+
159+
let polygon-style = (:)
160+
if type(data-style) == array {
161+
let s = data-style.at(line-index)
162+
if type(data-style.at(line-index)) == dictionary {
163+
// data-style = style dict
164+
polygon-style = s
165+
} else {
166+
// data-style = list of colors -> fill polygon with these colors
167+
polygon-style = (fill: s)
168+
}
169+
} else if type(data-style) == function {
170+
// data-style = method taking the index as param
171+
polygon-style = data-style(line-index)
172+
}
173+
draw.line(..pts, close: true, ..polygon-style)
174+
}
175+
})
176+
}

tests/chart/radarchart/test.typ

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#set page(width: auto, height: auto)
2+
#import "/src/lib.typ": *
3+
#import "/tests/helper.typ": *
4+
5+
#let labels = (
6+
[A],
7+
[B],
8+
[C],
9+
[D],
10+
[E],
11+
)
12+
13+
#test-case({
14+
chart.radarchart(
15+
labels,
16+
(0.3, 1, 0.3, 0.8, 0.8),
17+
)
18+
})
19+
20+
#test-case({
21+
chart.radarchart(
22+
labels,
23+
(
24+
(0.3, 1, 0.3, 0.8, 0.8),
25+
(0.9, 0.3, 0.9, 0.5, 0.5),
26+
(0.6, 0.5, 0, 0.5, 0.1),
27+
),
28+
radius: 3,
29+
web-label-offset: 0.6,
30+
web-ticks: 3,
31+
data-style: (
32+
blue.transparentize(30%),
33+
red.transparentize(30%),
34+
green.transparentize(30%),
35+
),
36+
)
37+
})

0 commit comments

Comments
 (0)