diff --git a/gallery/radarchart.png b/gallery/radarchart.png new file mode 100644 index 0000000..15bf3e7 Binary files /dev/null and b/gallery/radarchart.png differ diff --git a/gallery/radarchart.typ b/gallery/radarchart.typ new file mode 100644 index 0000000..a962710 --- /dev/null +++ b/gallery/radarchart.typ @@ -0,0 +1,28 @@ +#import "@preview/cetz:0.4.2" +#import "/src/lib.typ": chart + +#set page(width: auto, height: auto, margin: .5cm) + +#cetz.canvas({ + chart.radarchart( + ( + [A], + [B], + [C], + [D], + [E], + [F], + ), + ( + (0.3, 1, 0.3, 0.8, 0.8, 1), + (0.9, 0.3, 0.9, 0.5, 0.5, 0.4), + ), + radius: 3, + web-label-offset: 0.6, + data-style: ( + blue.transparentize(10%), + red.transparentize(30%), + ), + ) +}) + diff --git a/manual.pdf b/manual.pdf index 9af75e1..5a39345 100644 Binary files a/manual.pdf and b/manual.pdf differ diff --git a/manual.typ b/manual.typ index 03d0ffa..5d61dd1 100644 --- a/manual.typ +++ b/manual.typ @@ -21,8 +21,8 @@ #set terms(indent: 1em) #set par(justify: true) #set heading(numbering: (..num) => if num.pos().len() < 4 { - numbering("1.1", ..num) - }) + numbering("1.1", ..num) +}) #show link: set text(blue) // Outline @@ -58,7 +58,17 @@ module imported into the namespace. #doc-style.parse-show-module("/src/plot.typ") -#for m in ("line", "bar", "boxwhisker", "contour", "errorbar", "annotation", "formats", "violin", "legend") { +#for m in ( + "line", + "bar", + "boxwhisker", + "contour", + "errorbar", + "annotation", + "formats", + "violin", + "legend", +) { doc-style.parse-show-module("/src/plot/" + m + ".typ") } @@ -87,7 +97,14 @@ plot.plot(size: (5, 4), axis-style: "school-book", y-tick-step: none, { = Chart #doc-style.parse-show-module("/src/chart.typ") -#for m in ("barchart", "boxwhisker", "columnchart", "piechart", "pyramid") { +#for m in ( + "barchart", + "boxwhisker", + "columnchart", + "piechart", + "radarchart", + "pyramid", +) { doc-style.parse-show-module("/src/chart/" + m + ".typ") } diff --git a/src/chart.typ b/src/chart.typ index e8f11a2..c9344a7 100644 --- a/src/chart.typ +++ b/src/chart.typ @@ -2,4 +2,5 @@ #import "chart/barchart.typ": barchart, barchart-default-style #import "chart/columnchart.typ": columnchart, columnchart-default-style #import "chart/piechart.typ": piechart, piechart-default-style -#import "chart/pyramid.typ": pyramid, pyramid-default-style \ No newline at end of file +#import "chart/radarchart.typ": radarchart, radarchart-default-style +#import "chart/pyramid.typ": pyramid, pyramid-default-style diff --git a/src/chart/radarchart.typ b/src/chart/radarchart.typ new file mode 100644 index 0000000..3dbbdb4 --- /dev/null +++ b/src/chart/radarchart.typ @@ -0,0 +1,176 @@ +#import "/src/cetz.typ": draw, palette, styles + +#import "/src/plot.typ" + +#let radarchart-default-style = ( + web-style: ( + stroke: black.lighten(40%), + ), + web-ticks: 4, + web-label-offset: 0.4, + center-pos: (0, 0), + radius: 2, +) + +/// Draw a radar chart (also known as spider chart or web chart). A radar +/// chart is a chart that represents multivariate data in the form of a +/// two-dimensional chart of three or more quantitative variables represented as +/// axes starting from the same point. +/// +/// ```cexample +/// chart.radarchart( +/// ( +/// [A], +/// [B], +/// [C], +/// [D], +/// [E], +/// [F], +/// ), +/// (0.3, 0.6, 0.3, 0.4, 0.8, 1), +/// ) +/// ``` +/// === Styling +/// Can be applied with `cetz.draw.set-style(radarchart: (web-ticks: 6))`. +/// +/// *Root*: `radarchart`. +/// #show-parameter-block("web-style", "style", default: (stroke: black.lighten(40%)), [ +/// Style of the web in the background of the chart.]) +/// #show-parameter-block("web-ticks", ("int", "array"), default: 4, [ +/// Amount of layers of the web or an array containing the distance of each web layer to draw.]) +/// #show-parameter-block("web-label-offset", "float", default: 0.4, [ +/// Distance from the end of the web to the label.]) +/// #show-parameter-block("center-pos", "float", default: 1, [ +/// Coordinate of the center of the chart.]) +/// #show-parameter-block("radius", "float", default: 2, [ +/// Radius of the radar chart.]) +/// +/// - labels (array): Array of content. Each entry is the label +/// of one coordinate axis. +/// +/// *Example* +/// ```typc +/// ([A], [B], [C]) +/// ``` +/// - data (array): Array of data rows. A row can be of type array of float or +/// array of array of float. All float values must be within the +/// the range $0 <= "value" <= "radius"$. Each of the data rows must +/// contain the same amount of items as `labels`. +/// +/// *Example* +/// ```typc +/// ((0.5, 0.3, 0.9), (0.3, 0.5, 0.2)) +/// ``` +/// - data-style (function, array): Style per data row. Can be either +/// - function: A function of the form `index => style` that must return a style dictionary. +/// This can be a `palette` function. +/// - array of style dictionaries: The dictionary at index `i` contains the style for the data row at index `i`. +/// - array of colors: The dictionary at index `i` contains the fill color for the data row at index `i`. +/// +#let radarchart( + labels, + data, + data-style: palette.red, + ..style, +) = { + assert(type(labels) == array) + assert(labels.len() >= 3) + + assert(type(data) == array) + assert(data.len() != 0) + if type(data.at(0)) != array { + // only one single data line + data = (data,) + } + + // ensure that all data lines have the same amount of coordinates + let size = labels.len() + for line in data { + assert(line.len() == size) + } + + draw.group(ctx => { + let style = styles.resolve( + ctx.style, + merge: style.named(), + root: "radarchart", + base: radarchart-default-style, + ) + draw.set-style(..style) + + let center-pos = style.at("center-pos") + let radius = style.at("radius") + let web-ticks = style.at("web-ticks") + let web-label-offset = style.at("web-label-offset") + + // ensure that no data point overflows out of the chart + for line in data { + for value in line { + assert(0 <= value and value <= radius) + } + } + + assert(radius > 0) + assert(type(web-ticks) in (int, array)) + if type(web-ticks) == int { + // automatically calculate ticks amount of equidistant ticks + web-ticks = range(web-ticks).map(i => (i + 1) / web-ticks) + } + + let angle-step = 360deg / labels.len() + + // draw labels and lines from center to label + // each of these axis is assigned the label "axis-{i}" + for (i, label) in labels.enumerate() { + let axis-name = "axis-" + str(i) + draw.line( + center-pos, + ( + rel: (-angle-step * i + 90deg, radius), + ), + name: axis-name, + ) + draw.content( + (axis-name + ".start", radius + web-label-offset, axis-name + ".end"), + label, + ) + } + + // web drawing logic + for tick in web-ticks { + let web-points = () + for i in range(labels.len()) { + web-points.push(( + rel: (-angle-step * i + 90deg, radius * tick), + to: center-pos, + )) + } + draw.line(..web-points, close: true, ..style.at("web-style")) + } + + // draw the coordinates of each data line as a polygon + for (line-index, line) in data.enumerate() { + let pts = () + for (i, value) in line.enumerate() { + let axis-name = "axis-" + str(i) + pts.push((axis-name + ".start", radius * value, axis-name + ".end")) + } + + let polygon-style = (:) + if type(data-style) == array { + let s = data-style.at(line-index) + if type(data-style.at(line-index)) == dictionary { + // data-style = style dict + polygon-style = s + } else { + // data-style = list of colors -> fill polygon with these colors + polygon-style = (fill: s) + } + } else if type(data-style) == function { + // data-style = method taking the index as param + polygon-style = data-style(line-index) + } + draw.line(..pts, close: true, ..polygon-style) + } + }) +} diff --git a/tests/chart/radarchart/test.typ b/tests/chart/radarchart/test.typ new file mode 100644 index 0000000..cbbf1fc --- /dev/null +++ b/tests/chart/radarchart/test.typ @@ -0,0 +1,37 @@ +#set page(width: auto, height: auto) +#import "/src/lib.typ": * +#import "/tests/helper.typ": * + +#let labels = ( + [A], + [B], + [C], + [D], + [E], +) + +#test-case({ + chart.radarchart( + labels, + (0.3, 1, 0.3, 0.8, 0.8), + ) +}) + +#test-case({ + chart.radarchart( + labels, + ( + (0.3, 1, 0.3, 0.8, 0.8), + (0.9, 0.3, 0.9, 0.5, 0.5), + (0.6, 0.5, 0, 0.5, 0.1), + ), + radius: 3, + web-label-offset: 0.6, + web-ticks: 3, + data-style: ( + blue.transparentize(30%), + red.transparentize(30%), + green.transparentize(30%), + ), + ) +})