Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/assets/typst/packs/preview/cetz-plot/0.1.1.pack

180 KiB, 1 run

created by r2519314175:1061, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1DAIMOND TYPST PACK 1
2namespace preview
3name cetz-plot
4version 0.1.1
5entrypoint src/lib.typ
6file 7651 LICENSE
7file 28825 src/axes.typ
8file 110 src/cetz.typ
9file 326 src/chart.typ
10file 4997 src/chart/barchart.typ
11file 971 src/chart/barcol-common.typ
12file 3222 src/chart/boxwhisker.typ
13file 4794 src/chart/columnchart.typ
14file 19804 src/chart/piechart.typ
15file 14247 src/chart/pyramid.typ
16file 1569 src/clip.typ
17file 1032 src/grid-layout.typ
18file 104 src/lib.typ
19file 20396 src/plot.typ
20file 2268 src/plot/annotation.typ
21file 8738 src/plot/bar.typ
22file 4255 src/plot/boxwhisker.typ
23file 10558 src/plot/contour.typ
24file 3340 src/plot/errorbar.typ
25file 4640 src/plot/formats.typ
26file 8191 src/plot/legend.typ
27file 15402 src/plot/line.typ
28file 1338 src/plot/mark.typ
29file 2923 src/plot/sample.typ
30file 9742 src/plot/util.typ
31file 3934 src/plot/violin.typ
32file 455 typst.toml
33
34 GNU LESSER GENERAL PUBLIC LICENSE
35 Version 3, 29 June 2007
36
37 Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
38 Everyone is permitted to copy and distribute verbatim copies
39 of this license document, but changing it is not allowed.
40
41
42 This version of the GNU Lesser General Public License incorporates
43the terms and conditions of version 3 of the GNU General Public
44License, supplemented by the additional permissions listed below.
45
46 0. Additional Definitions.
47
48 As used herein, "this License" refers to version 3 of the GNU Lesser
49General Public License, and the "GNU GPL" refers to version 3 of the GNU
50General Public License.
51
52 "The Library" refers to a covered work governed by this License,
53other than an Application or a Combined Work as defined below.
54
55 An "Application" is any work that makes use of an interface provided
56by the Library, but which is not otherwise based on the Library.
57Defining a subclass of a class defined by the Library is deemed a mode
58of using an interface provided by the Library.
59
60 A "Combined Work" is a work produced by combining or linking an
61Application with the Library. The particular version of the Library
62with which the Combined Work was made is also called the "Linked
63Version".
64
65 The "Minimal Corresponding Source" for a Combined Work means the
66Corresponding Source for the Combined Work, excluding any source code
67for portions of the Combined Work that, considered in isolation, are
68based on the Application, and not on the Linked Version.
69
70 The "Corresponding Application Code" for a Combined Work means the
71object code and/or source code for the Application, including any data
72and utility programs needed for reproducing the Combined Work from the
73Application, but excluding the System Libraries of the Combined Work.
74
75 1. Exception to Section 3 of the GNU GPL.
76
77 You may convey a covered work under sections 3 and 4 of this License
78without being bound by section 3 of the GNU GPL.
79
80 2. Conveying Modified Versions.
81
82 If you modify a copy of the Library, and, in your modifications, a
83facility refers to a function or data to be supplied by an Application
84that uses the facility (other than as an argument passed when the
85facility is invoked), then you may convey a copy of the modified
86version:
87
88 a) under this License, provided that you make a good faith effort to
89 ensure that, in the event an Application does not supply the
90 function or data, the facility still operates, and performs
91 whatever part of its purpose remains meaningful, or
92
93 b) under the GNU GPL, with none of the additional permissions of
94 this License applicable to that copy.
95
96 3. Object Code Incorporating Material from Library Header Files.
97
98 The object code form of an Application may incorporate material from
99a header file that is part of the Library. You may convey such object
100code under terms of your choice, provided that, if the incorporated
101material is not limited to numerical parameters, data structure
102layouts and accessors, or small macros, inline functions and templates
103(ten or fewer lines in length), you do both of the following:
104
105 a) Give prominent notice with each copy of the object code that the
106 Library is used in it and that the Library and its use are
107 covered by this License.
108
109 b) Accompany the object code with a copy of the GNU GPL and this license
110 document.
111
112 4. Combined Works.
113
114 You may convey a Combined Work under terms of your choice that,
115taken together, effectively do not restrict modification of the
116portions of the Library contained in the Combined Work and reverse
117engineering for debugging such modifications, if you also do each of
118the following:
119
120 a) Give prominent notice with each copy of the Combined Work that
121 the Library is used in it and that the Library and its use are
122 covered by this License.
123
124 b) Accompany the Combined Work with a copy of the GNU GPL and this license
125 document.
126
127 c) For a Combined Work that displays copyright notices during
128 execution, include the copyright notice for the Library among
129 these notices, as well as a reference directing the user to the
130 copies of the GNU GPL and this license document.
131
132 d) Do one of the following:
133
134 0) Convey the Minimal Corresponding Source under the terms of this
135 License, and the Corresponding Application Code in a form
136 suitable for, and under terms that permit, the user to
137 recombine or relink the Application with a modified version of
138 the Linked Version to produce a modified Combined Work, in the
139 manner specified by section 6 of the GNU GPL for conveying
140 Corresponding Source.
141
142 1) Use a suitable shared library mechanism for linking with the
143 Library. A suitable mechanism is one that (a) uses at run time
144 a copy of the Library already present on the user's computer
145 system, and (b) will operate properly with a modified version
146 of the Library that is interface-compatible with the Linked
147 Version.
148
149 e) Provide Installation Information, but only if you would otherwise
150 be required to provide such information under section 6 of the
151 GNU GPL, and only to the extent that such information is
152 necessary to install and execute a modified version of the
153 Combined Work produced by recombining or relinking the
154 Application with a modified version of the Linked Version. (If
155 you use option 4d0, the Installation Information must accompany
156 the Minimal Corresponding Source and Corresponding Application
157 Code. If you use option 4d1, you must provide the Installation
158 Information in the manner specified by section 6 of the GNU GPL
159 for conveying Corresponding Source.)
160
161 5. Combined Libraries.
162
163 You may place library facilities that are a work based on the
164Library side by side in a single library together with other library
165facilities that are not Applications and are not covered by this
166License, and convey such a combined library under terms of your
167choice, if you do both of the following:
168
169 a) Accompany the combined library with a copy of the same work based
170 on the Library, uncombined with any other library facilities,
171 conveyed under the terms of this License.
172
173 b) Give prominent notice with the combined library that part of it
174 is a work based on the Library, and explaining where to find the
175 accompanying uncombined form of the same work.
176
177 6. Revised Versions of the GNU Lesser General Public License.
178
179 The Free Software Foundation may publish revised and/or new versions
180of the GNU Lesser General Public License from time to time. Such new
181versions will be similar in spirit to the present version, but may
182differ in detail to address new problems or concerns.
183
184 Each version is given a distinguishing version number. If the
185Library as you received it specifies that a certain numbered version
186of the GNU Lesser General Public License "or any later version"
187applies to it, you have the option of following the terms and
188conditions either of that published version or of any later version
189published by the Free Software Foundation. If the Library as you
190received it does not specify a version number of the GNU Lesser
191General Public License, you may choose any version of the GNU Lesser
192General Public License ever published by the Free Software Foundation.
193
194 If the Library as you received it specifies that a proxy can decide
195whether future versions of the GNU Lesser General Public License shall
196apply, that proxy's public statement of acceptance of any version is
197permanent authorization for you to choose that version for the
198Library.
199#import "/src/cetz.typ": util, draw, vector, matrix, styles, process, drawable, path-util, process
200#import "/src/plot/formats.typ"
201
202/// Default axis style
203///
204/// #show-parameter-block("tick-limit", "int", default: 100, [Upper major tick limit.])
205/// #show-parameter-block("minor-tick-limit", "int", default: 1000, [Upper minor tick limit.])
206/// #show-parameter-block("auto-tick-factors", "array", [List of tick factors used for automatic tick step determination.])
207/// #show-parameter-block("auto-tick-count", "int", [Number of ticks to generate by default.])
208/// #show-parameter-block("stroke", "stroke", [Axis stroke style.])
209/// #show-parameter-block("label.offset", "number", [Distance to move axis labels away from the axis.])
210/// #show-parameter-block("label.anchor", "anchor", [Anchor of the axis label to use for it's placement.])
211/// #show-parameter-block("label.angle", "angle", [Angle of the axis label.])
212/// #show-parameter-block("axis-layer", "float", [Layer to draw axes on (see cetz' `on-layer`)])
213/// #show-parameter-block("grid-layer", "float", [Layer to draw the grid on (see cetz' `on-layer`)])
214/// #show-parameter-block("background-layer", "float", [Layer to draw the background on (see cetz' `on-layer`)])
215/// #show-parameter-block("padding", "number", [Extra distance between axes and plotting area. For schoolbook axes, this is the length of how much axes grow out of the plotting area.])
216/// #show-parameter-block("overshoot", "number", [School-book style axes only: Extra length to add to the end (right, top) of axes.])
217/// #show-parameter-block("tick.stroke", "stroke", [Major tick stroke style.])
218/// #show-parameter-block("tick.minor-stroke", "stroke", [Minor tick stroke style.])
219/// #show-parameter-block("tick.offset", ("number", "ratio"), [Major tick offset along the tick's direction, can be relative to the length.])
220/// #show-parameter-block("tick.minor-offset", ("number", "ratio"), [Minor tick offset along the tick's direction, can be relative to the length.])
221/// #show-parameter-block("tick.length", ("number"), [Major tick length.])
222/// #show-parameter-block("tick.minor-length", ("number", "ratio"), [Minor tick length, can be relative to the major tick length.])
223/// #show-parameter-block("tick.label.offset", ("number"), [Major tick label offset away from the tick.])
224/// #show-parameter-block("tick.label.angle", ("angle"), [Major tick label angle.])
225/// #show-parameter-block("tick.label.anchor", ("anchor"), [Anchor of major tick labels used for positioning.])
226/// #show-parameter-block("tick.label.show", ("auto", "bool"), default: auto, [Set visibility of tick labels. A value of `auto` shows tick labels for all but mirrored axes.])
227/// #show-parameter-block("grid.stroke", "stroke", [Major grid line stroke style.])
228/// #show-parameter-block("break-point.width", "number", [Axis break width along the axis.])
229/// #show-parameter-block("break-point.length", "number", [Axis break length.])
230/// #show-parameter-block("minor-grid.stroke", "stroke", [Minor grid line stroke style.])
231/// #show-parameter-block("shared-zero", ("bool", "content"), default: "$0$", [School-book style axes only: Content to display at the plots origin (0,0). If set to `false`, nothing is shown. Having this set, suppresses auto-generated ticks for $0$!])
232#let default-style = (
233 tick-limit: 100,
234 minor-tick-limit: 1000,
235 auto-tick-factors: (1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10), // Tick factor to try
236 auto-tick-count: 11, // Number of ticks the plot tries to place
237 fill: none,
238 stroke: auto,
239 label: (
240 offset: .2cm, // Axis label offset
241 anchor: auto, // Axis label anchor
242 angle: auto, // Axis label angle
243 ),
244 axis-layer: 0,
245 grid-layer: 0,
246 background-layer: 0,
247 padding: 0,
248 tick: (
249 fill: none,
250 stroke: black + 1pt,
251 minor-stroke: black + .5pt,
252 offset: 0,
253 minor-offset: 0,
254 length: .1cm, // Tick length: Number
255 minor-length: 70%, // Minor tick length: Number, Ratio
256 label: (
257 offset: .15cm, // Tick label offset
258 angle: 0deg, // Tick label angle
259 anchor: auto, // Tick label anchor
260 "show": auto, // Show tick labels for axes in use
261 )
262 ),
263 break-point: (
264 width: .75cm,
265 length: .15cm,
266 ),
267 grid: (
268 stroke: (paint: gray.lighten(50%), thickness: 1pt),
269 ),
270 minor-grid: (
271 stroke: (paint: gray.lighten(50%), thickness: .5pt),
272 ),
273)
274
275// Default Scientific Style
276#let default-style-scientific = util.merge-dictionary(default-style, (
277 left: (tick: (label: (anchor: "east"))),
278 bottom: (tick: (label: (anchor: "north"))),
279 right: (tick: (label: (anchor: "west"))),
280 top: (tick: (label: (anchor: "south"))),
281 stroke: (cap: "square"),
282 padding: 0,
283))
284
285// Default Schoolbook Style
286#let default-style-schoolbook = util.merge-dictionary(default-style, (
287 x: (stroke: auto, fill: none, mark: (start: none, end: "straight"),
288 tick: (label: (anchor: "north"))),
289 y: (stroke: auto, fill: none, mark: (start: none, end: "straight"),
290 tick: (label: (anchor: "east"))),
291 label: (offset: .1cm),
292 origin: (label: (offset: .05cm)),
293 padding: .1cm, // Axis padding on both sides outsides the plotting area
294 overshoot: .5cm, // Axis end "overshoot" out of the plotting area
295 tick: (
296 offset: -50%,
297 minor-offset: -50%,
298 length: .2cm,
299 minor-length: 70%,
300 ),
301 shared-zero: $0$, // Show zero tick label at (0, 0)
302))
303
304#let _prepare-style(ctx, style) = {
305 if type(style) != dictionary { return style }
306
307 let res = util.resolve-number.with(ctx)
308 let rel-to(v, to) = {
309 if type(v) == ratio {
310 return v * to / 100%
311 } else {
312 return res(v)
313 }
314 }
315
316 style.tick.length = res(style.tick.length)
317 style.tick.offset = rel-to(style.tick.offset, style.tick.length)
318 style.tick.minor-length = rel-to(style.tick.minor-length, style.tick.length)
319 style.tick.minor-offset = rel-to(style.tick.minor-offset, style.tick.minor-length)
320 style.tick.label.offset = res(style.tick.label.offset)
321
322 // Break points
323 style.break-point.width = res(style.break-point.width)
324 style.break-point.length = res(style.break-point.length)
325
326 // Padding
327 style.padding = res(style.padding)
328
329 if "overshoot" in style {
330 style.overshoot = res(style.overshoot)
331 }
332
333 return style
334}
335
336#let _get-axis-style(ctx, style, name) = {
337 if not name in style {
338 return style
339 }
340
341 style = styles.resolve(style, merge: style.at(name))
342 return _prepare-style(ctx, style)
343}
344
345#let _get-grid-type(axis) = {
346 let grid = axis.ticks.at("grid", default: false)
347 if grid == "major" or grid == true { return 1 }
348 if grid == "minor" { return 2 }
349 if grid == "both" { return 3 }
350 return 0
351}
352
353#let _inset-axis-points(ctx, style, axis, start, end) = {
354 if axis == none { return (start, end) }
355
356 let (low, high) = axis.inset.map(v => util.resolve-number(ctx, v))
357
358 let is-horizontal = start.at(1) == end.at(1)
359 if is-horizontal {
360 start = vector.add(start, (low, 0))
361 end = vector.sub(end, (high, 0))
362 } else {
363 start = vector.add(start, (0, low))
364 end = vector.sub(end, (0, high))
365 }
366 return (start, end)
367}
368
369#let _draw-axis-line(start, end, axis, is-horizontal, style) = {
370 let enabled = if axis != none and axis.show-break {
371 axis.min > 0 or axis.max < 0
372 } else { false }
373
374 if enabled {
375 let size = if is-horizontal {
376 (style.break-point.width, 0)
377 } else {
378 (0, style.break-point.width, 0)
379 }
380
381 let up = if is-horizontal {
382 (0, style.break-point.length)
383 } else {
384 (style.break-point.length, 0)
385 }
386
387 let add-break(is-end) = {
388 let a = ()
389 let b = (rel: vector.scale(size, .3), update: false)
390 let c = (rel: vector.add(vector.scale(size, .4), vector.scale(up, -1)), update: false)
391 let d = (rel: vector.add(vector.scale(size, .6), vector.scale(up, +1)), update: false)
392 let e = (rel: vector.scale(size, .7), update: false)
393 let f = (rel: size)
394
395 let mark = if is-end {
396 style.at("mark", default: none)
397 }
398 draw.line(a, b, c, d, e, f, stroke: style.stroke, mark: mark)
399 }
400
401 draw.merge-path({
402 draw.move-to(start)
403 if axis.min > 0 {
404 add-break(false)
405 draw.line((rel: size, to: start), end, mark: style.at("mark", default: none))
406 } else if axis.max < 0 {
407 draw.line(start, (rel: vector.scale(size, -1), to: end))
408 add-break(true)
409 }
410 }, stroke: style.stroke)
411 } else {
412 draw.line(start, end, stroke: style.stroke, mark: style.at("mark", default: none))
413 }
414}
415
416// Construct Axis Object
417//
418// - min (number): Minimum value
419// - max (number): Maximum value
420// - ticks (dictionary): Tick settings:
421// - step (number): Major tic step
422// - minor-step (number): Minor tic step
423// - decimals (int): Tick float decimal length
424// - label (content): Axis label
425// - mode (string): Axis scaling function. Takes `lin` or `log`
426// - base (number): Base for tick labels when logarithmically scaled.
427#let axis(min: -1, max: 1, label: none,
428 ticks: (step: auto, minor-step: none,
429 decimals: 2, grid: false,
430 format: "float"
431 ),
432 mode: auto, base: auto) = (
433 min: min, max: max, ticks: ticks, label: label, inset: (0, 0), show-break: false, mode: mode, base: base
434)
435
436// Format a tick value
437#let format-tick-value(value, tic-options) = {
438 // Without it we get negative zero in conversion
439 // to content! Typst has negative zero floats.
440 if value == 0 { value = 0 }
441
442 if type(value) != std.content {
443 let format = tic-options.at("format", default: "float")
444 if format == none {
445 value = []
446 } else if type(format) == std.content {
447 value = format
448 } else if type(format) == function {
449 value = (format)(value)
450 } else if format == "sci" {
451 value = formats.sci(value, digits: tic-options.at("decimals", default: 2))
452 } else {
453 value = formats.decimal(value, digits: tic-options.at("decimals", default: 2))
454 }
455 } else if type(value) != std.content {
456 value = str(value)
457 }
458
459 return value
460}
461
462// Get value on axis [0, 1]
463//
464// - axis (axis): Axis
465// - v (number): Value
466// -> float
467#let value-on-axis(axis, v) = {
468 if v == none { return }
469 let (min, max) = (axis.min, axis.max)
470 let dt = max - min; if dt == 0 { dt = 1 }
471
472 return (v - min) / dt
473}
474
475// Compute list of linear ticks for axis
476//
477// - axis (axis): Axis
478#let compute-linear-ticks(axis, style, add-zero: true) = {
479 let (min, max) = (axis.min, axis.max)
480 let dt = max - min; if (dt == 0) { dt = 1 }
481 let ticks = axis.ticks
482 let ferr = util.float-epsilon
483 let tick-limit = style.tick-limit
484 let minor-tick-limit = style.minor-tick-limit
485
486 let l = ()
487 if ticks != none {
488 let major-tick-values = ()
489 if "step" in ticks and ticks.step != none {
490 assert(ticks.step >= 0,
491 message: "Axis tick step must be positive and non 0.")
492 if axis.min > axis.max { ticks.step *= -1 }
493
494 let s = 1 / ticks.step
495
496 let num-ticks = int(max * s + 1.5) - int(min * s)
497 assert(num-ticks <= tick-limit,
498 message: "Number of major ticks exceeds limit " + str(tick-limit))
499
500 let n = range(int(min * s), int(max * s + 1.5))
501 for t in n {
502 let v = (t / s - min) / dt
503 if t / s == 0 and not add-zero { continue }
504
505 if v >= 0 - ferr and v <= 1 + ferr {
506 l.push((v, format-tick-value(t / s, ticks), true))
507 major-tick-values.push(v)
508 }
509 }
510 }
511
512 if "minor-step" in ticks and ticks.minor-step != none {
513 assert(ticks.minor-step >= 0,
514 message: "Axis minor tick step must be positive")
515 if axis.min > axis.max { ticks.minor-step *= -1 }
516
517 let s = 1 / ticks.minor-step
518
519 let num-ticks = int(max * s + 1.5) - int(min * s)
520 assert(num-ticks <= minor-tick-limit,
521 message: "Number of minor ticks exceeds limit " + str(minor-tick-limit))
522
523 let n = range(int(min * s), int(max * s + 1.5))
524 for t in n {
525 let v = (t / s - min) / dt
526 if v in major-tick-values {
527 // Prefer major ticks over minor ticks
528 continue
529 }
530
531 if v != none and v >= 0 and v <= 1 + ferr {
532 l.push((v, none, false))
533 }
534 }
535 }
536
537 }
538
539 return l
540}
541
542// Compute list of linear ticks for axis
543//
544// - axis (axis): Axis
545#let compute-logarithmic-ticks(axis, style, add-zero: true) = {
546 let ferr = util.float-epsilon
547 let (min, max) = (
548 calc.log(calc.max(axis.min, ferr), base: axis.base),
549 calc.log(calc.max(axis.max, ferr), base: axis.base)
550 )
551 let dt = max - min; if (dt == 0) { dt = 1 }
552 let ticks = axis.ticks
553
554 let tick-limit = style.tick-limit
555 let minor-tick-limit = style.minor-tick-limit
556 let l = ()
557
558 if ticks != none {
559 let major-tick-values = ()
560 if "step" in ticks and ticks.step != none {
561 assert(ticks.step >= 0,
562 message: "Axis tick step must be positive and non 0.")
563 if axis.min > axis.max { ticks.step *= -1 }
564
565 let s = 1 / ticks.step
566
567 let num-ticks = int(max * s + 1.5) - int(min * s)
568 assert(num-ticks <= tick-limit,
569 message: "Number of major ticks exceeds limit " + str(tick-limit))
570
571 let n = range(
572 int(min * s),
573 int(max * s + 1.5)
574 )
575
576 for t in n {
577 let v = (t / s - min) / dt
578 if t / s == 0 and not add-zero { continue }
579
580 if v >= 0 - ferr and v <= 1 + ferr {
581 l.push((v, format-tick-value( calc.pow(axis.base, t / s), ticks), true))
582 major-tick-values.push(v)
583 }
584 }
585 }
586
587 if "minor-step" in ticks and ticks.minor-step != none {
588 assert(ticks.minor-step >= 0,
589 message: "Axis minor tick step must be positive")
590 if axis.min > axis.max { ticks.minor-step *= -1 }
591
592 let s = 1 / ticks.step
593 let n = range(int(min * s)-1, int(max * s + 1.5)+1)
594
595 for t in n {
596 for vv in range(1, int(axis.base / ticks.minor-step)) {
597
598 let v = ( (calc.log(vv * ticks.minor-step, base: axis.base) + t)/ s - min) / dt
599 if v in major-tick-values {continue}
600
601 if v != none and v >= 0 and v <= 1 + ferr {
602 l.push((v, none, false))
603 }
604
605 }
606
607 }
608 }
609 }
610
611 return l
612}
613
614// Get list of fixed axis ticks
615//
616// - axis (axis): Axis object
617#let fixed-ticks(axis) = {
618 let l = ()
619 if "list" in axis.ticks {
620 for t in axis.ticks.list {
621 let (v, label) = (none, none)
622 if type(t) in (float, int) {
623 v = t
624 label = format-tick-value(t, axis.ticks)
625 } else {
626 (v, label) = t
627 }
628
629 v = value-on-axis(axis, v)
630 if v != none and v >= 0 and v <= 1 {
631 l.push((v, label, true))
632 }
633 }
634 }
635 return l
636}
637
638// Compute list of axis ticks
639//
640// A tick triple has the format:
641// (rel-value: float, label: content, major: bool)
642//
643// - axis (axis): Axis object
644#let compute-ticks(axis, style, add-zero: true) = {
645 let find-max-n-ticks(axis, n: 11) = {
646 let dt = calc.abs(axis.max - axis.min)
647 let scale = calc.floor(calc.log(dt, base: 10) - 1)
648 if scale > 5 or scale < -5 {return none}
649
650 let (step, best) = (none, 0)
651 for s in style.auto-tick-factors {
652 s = s * calc.pow(10, scale)
653
654 let divs = calc.abs(dt / s)
655 if divs >= best and divs <= n {
656 step = s
657 best = divs
658 }
659 }
660 return step
661 }
662
663 if axis == none or axis.ticks == none { return () }
664 if axis.ticks.step == auto {
665 axis.ticks.step = find-max-n-ticks(axis, n: style.auto-tick-count)
666 }
667 if axis.ticks.minor-step == auto {
668 axis.ticks.minor-step = if axis.ticks.step != none {
669 axis.ticks.step / 5
670 } else {
671 none
672 }
673 }
674
675 let ticks = if axis.mode == "log" {
676 compute-logarithmic-ticks(axis, style, add-zero: add-zero)
677 } else {
678 compute-linear-ticks(axis, style, add-zero: add-zero)
679 }
680 ticks += fixed-ticks(axis)
681 return ticks
682}
683
684// Prepares the axis post creation. The given axis
685// must be completely set-up, including its interval.
686// Returns the prepared axis
687#let prepare-axis(ctx, axis, name) = {
688 let style = styles.resolve(ctx.style, root: "axes",
689 base: default-style-scientific)
690 style = _prepare-style(ctx, style)
691 style = _get-axis-style(ctx, style, name)
692
693 if type(axis.inset) != array {
694 axis.inset = (axis.inset, axis.inset)
695 }
696
697 axis.inset = axis.inset.map(v => util.resolve-number(ctx, v))
698
699 if axis.show-break {
700 if axis.min > 0 {
701 axis.inset.at(0) += style.break-point.width
702 } else if axis.max < 0 {
703 axis.inset.at(1) += style.break-point.width
704 }
705 }
706
707 return axis
708}
709
710// Transform a single vector along a x, y and z axis
711//
712// - size (vector): Coordinate system size
713// - x-axis (axis): X axis
714// - y-axis (axis): Y axis
715// - z-axis (axis): Z axis
716// - vec (vector): Input vector to transform
717// -> vector
718#let transform-vec(size, x-axis, y-axis, z-axis, vec) = {
719 let axes = (x-axis, y-axis)
720
721 let (x, y,) = for (dim, axis) in axes.enumerate() {
722 let s = size.at(dim) - axis.inset.sum()
723 let o = axis.inset.at(0)
724
725 let transform-func(n) = if axis.mode == "log" {
726 calc.log(calc.max(n, util.float-epsilon), base: axis.base)
727 } else {
728 n
729 }
730
731 let range = transform-func(axis.max) - transform-func(axis.min)
732
733 let f = s / range
734 ((transform-func(vec.at(dim)) - transform-func(axis.min)) * f + o,)
735 }
736
737 return (x, y, 0)
738}
739
740// Draw inside viewport coordinates of two axes
741//
742// - size (vector): Axis canvas size (relative to origin)
743// - x (axis): Horizontal axis
744// - y (axis): Vertical axis
745// - z (axis): Z axis
746// - name (string,none): Group name
747#let axis-viewport(size, x, y, z, body, name: none) = {
748 draw.group(name: name, (ctx => {
749 let transform = ctx.transform
750
751 ctx.transform = matrix.ident()
752 let (ctx, drawables, bounds) = process.many(ctx, util.resolve-body(ctx, body))
753
754 ctx.transform = transform
755
756 drawables = drawables.map(d => {
757 if "segments" in d {
758 d.segments = d.segments.map(((kind, ..pts)) => {
759 (kind, ..pts.map(pt => {
760 transform-vec(size, x, y, none, pt)
761 }))
762 })
763 }
764 if "pos" in d {
765 d.pos = transform-vec(size, x, y, none, d.pos)
766 }
767 return d
768 })
769
770 return (
771 ctx: ctx,
772 drawables: drawable.apply-transform(ctx.transform, drawables)
773 )
774 },))
775}
776
777// Draw grid lines for the ticks of an axis
778//
779// - cxt (context):
780// - axis (dictionary): The axis
781// - ticks (array): The computed ticks
782// - low (vector): Start position of a grid-line at tick 0
783// - high (vector): End position of a grid-line at tick 0
784// - dir (vector): Normalized grid direction vector along the grid axis
785// - style (style): Axis style
786#let draw-grid-lines(ctx, axis, ticks, low, high, dir, style) = {
787 let offset = (0,0)
788 if axis.inset != none {
789 let (inset-low, inset-high) = axis.inset.map(v => util.resolve-number(ctx, v))
790 offset = vector.scale(vector.norm(dir), inset-low)
791 dir = vector.sub(dir, vector.scale(vector.norm(dir), inset-low + inset-high))
792 }
793
794 let kind = _get-grid-type(axis)
795 if kind > 0 {
796 for (distance, label, is-major) in ticks {
797 let offset = vector.add(vector.scale(dir, distance), offset)
798 let start = vector.add(low, offset)
799 let end = vector.add(high, offset)
800
801 // Draw a major line
802 if is-major and (kind == 1 or kind == 3) {
803 draw.line(start, end, stroke: style.grid.stroke)
804 }
805 // Draw a minor line
806 if not is-major and kind >= 2 {
807 draw.line(start, end, stroke: style.minor-grid.stroke)
808 }
809 }
810 }
811}
812
813// Place a list of tick marks and labels along a path
814#let place-ticks-on-line(ticks, start, stop, style, flip: false, is-mirror: false) = {
815 let dir = vector.sub(stop, start)
816 let norm = vector.norm((-dir.at(1), dir.at(0), dir.at(2, default: 0)))
817
818 let def(v, d) = {
819 return if v == none or v == auto {d} else {v}
820 }
821
822 let show-label = style.tick.label.show
823 if show-label == auto {
824 show-label = not is-mirror
825 }
826
827 for (distance, label, is-major) in ticks {
828 let offset = style.tick.offset
829 let length = if is-major { style.tick.length } else { style.tick.minor-length }
830 if flip {
831 offset *= -1
832 length *= -1
833 }
834
835 let pt = vector.lerp(start, stop, distance)
836 let a = vector.add(pt, vector.scale(norm, offset))
837 let b = vector.add(a, vector.scale(norm, length))
838
839 draw.line(a, b, stroke: style.tick.stroke)
840
841 if show-label and label != none {
842 let offset = style.tick.label.offset
843 if flip {
844 offset *= -1
845 length *= -1
846 }
847
848 let c = vector.sub(if length <= 0 { b } else { a },
849 vector.scale(norm, offset))
850
851 let angle = def(style.tick.label.angle, 0deg)
852 let anchor = def(style.tick.label.anchor, "center")
853
854 draw.content(c, [#label], angle: angle, anchor: anchor)
855 }
856 }
857}
858
859// Draw up to four axes in an "scientific" style at origin (0, 0)
860//
861// - size (array): Size (width, height)
862// - left (axis): Left (y) axis
863// - bottom (axis): Bottom (x) axis
864// - right (axis): Right axis
865// - top (axis): Top axis
866// - name (string): Object name
867// - draw-unset (bool): Draw axes that are set to `none`
868// - ..style (any): Style
869#let scientific(size: (1, 1),
870 left: none,
871 right: auto,
872 bottom: none,
873 top: auto,
874 draw-unset: true,
875 name: none,
876 ..style) = {
877 import draw: *
878
879 if right == auto {
880 if left != none {
881 right = left; right.is-mirror = true
882 } else {
883 right = none
884 }
885 }
886 if top == auto {
887 if bottom != none {
888 top = bottom; top.is-mirror = true
889 } else {
890 top = none
891 }
892 }
893
894 group(name: name, ctx => {
895 let (w, h) = size
896 anchor("origin", (0, 0))
897
898 let style = style.named()
899 style = styles.resolve(ctx.style, merge: style, root: "axes",
900 base: default-style-scientific)
901 style = _prepare-style(ctx, style)
902
903 // Compute ticks
904 let x-ticks = compute-ticks(bottom, style)
905 let y-ticks = compute-ticks(left, style)
906 let x2-ticks = compute-ticks(top, style)
907 let y2-ticks = compute-ticks(right, style)
908
909 // Draw frame
910 if style.fill != none {
911 on-layer(style.background-layer, {
912 rect((0,0), (w,h), fill: style.fill, stroke: none)
913 })
914 }
915
916 // Draw grid
917 group(name: "grid", ctx => {
918 let axes = (
919 ("bottom", (0,0), (0,h), (+w,0), x-ticks, bottom),
920 ("top", (0,h), (0,0), (+w,0), x2-ticks, top),
921 ("left", (0,0), (w,0), (0,+h), y-ticks, left),
922 ("right", (w,0), (0,0), (0,+h), y2-ticks, right),
923 )
924 for (name, start, end, direction, ticks, axis) in axes {
925 if axis == none { continue }
926
927 let style = _get-axis-style(ctx, style, name)
928 let is-mirror = axis.at("is-mirror", default: false)
929
930 if not is-mirror {
931 on-layer(style.grid-layer, {
932 draw-grid-lines(ctx, axis, ticks, start, end, direction, style)
933 })
934 }
935 }
936 })
937
938 // Draw axes
939 group(name: "axes", {
940 let axes = (
941 ("bottom", (0, 0), (w, 0), (0, -1), false, x-ticks, bottom,),
942 ("top", (0, h), (w, h), (0, +1), true, x2-ticks, top,),
943 ("left", (0, 0), (0, h), (-1, 0), true, y-ticks, left,),
944 ("right", (w, 0), (w, h), (+1, 0), false, y2-ticks, right,)
945 )
946 let label-placement = (
947 bottom: ("south", "north", 0deg),
948 top: ("north", "south", 0deg),
949 left: ("west", "south", 90deg),
950 right: ("east", "north", 90deg),
951 )
952
953 for (name, start, end, outsides, flip, ticks, axis) in axes {
954 let style = _get-axis-style(ctx, style, name)
955 let is-mirror = axis == none or axis.at("is-mirror", default: false)
956 let is-horizontal = name in ("bottom", "top")
957
958 if style.padding != 0 {
959 let padding = vector.scale(outsides, style.padding)
960 start = vector.add(start, padding)
961 end = vector.add(end, padding)
962 }
963
964 let (data-start, data-end) = _inset-axis-points(ctx, style, axis, start, end)
965
966 let path = _draw-axis-line(start, end, axis, is-horizontal, style)
967 on-layer(style.axis-layer, {
968 group(name: "axis", {
969 if draw-unset or axis != none {
970 path;
971 place-ticks-on-line(ticks, data-start, data-end, style, flip: flip, is-mirror: is-mirror)
972 }
973 })
974
975 if axis != none and axis.label != none and not is-mirror {
976 let offset = vector.scale(outsides, style.label.offset)
977 let (group-anchor, content-anchor, angle) = label-placement.at(name)
978
979 if style.label.anchor != auto {
980 content-anchor = style.label.anchor
981 }
982 if style.label.angle != auto {
983 angle = style.label.angle
984 }
985
986 content((rel: offset, to: "axis." + group-anchor),
987 [#axis.label],
988 angle: angle,
989 anchor: content-anchor)
990 }
991 })
992 }
993 })
994 })
995}
996
997// Draw two axes in a "school book" style
998//
999// - x-axis (axis): X axis
1000// - y-axis (axis): Y axis
1001// - size (array): Size (width, height)
1002// - x-position (number): X Axis position
1003// - y-position (number): Y Axis position
1004// - name (string): Object name
1005// - ..style (any): Style
1006#let school-book(x-axis, y-axis,
1007 size: (1, 1),
1008 x-position: 0,
1009 y-position: 0,
1010 name: none,
1011 ..style) = {
1012 import draw: *
1013
1014 group(name: name, ctx => {
1015 let (w, h) = size
1016 anchor("origin", (0, 0))
1017
1018 let style = style.named()
1019 style = styles.resolve(
1020 ctx.style,
1021 merge: style,
1022 root: "axes",
1023 base: default-style-schoolbook)
1024 style = _prepare-style(ctx, style)
1025
1026 let x-position = calc.min(calc.max(y-axis.min, x-position), y-axis.max)
1027 let y-position = calc.min(calc.max(x-axis.min, y-position), x-axis.max)
1028 let x-y = value-on-axis(y-axis, x-position) * h
1029 let y-x = value-on-axis(x-axis, y-position) * w
1030
1031 let shared-zero = style.shared-zero != false and x-position == 0 and y-position == 0
1032
1033 let x-ticks = compute-ticks(x-axis, style, add-zero: not shared-zero)
1034 let y-ticks = compute-ticks(y-axis, style, add-zero: not shared-zero)
1035
1036 // Draw grid
1037 group(name: "grid", ctx => {
1038 let axes = (
1039 ("x", (0,0), (0,h), (+w,0), x-ticks, x-axis),
1040 ("y", (0,0), (w,0), (0,+h), y-ticks, y-axis),
1041 )
1042
1043 for (name, start, end, direction, ticks, axis) in axes {
1044 if axis == none { continue }
1045
1046 let style = _get-axis-style(ctx, style, name)
1047 on-layer(style.grid-layer, {
1048 draw-grid-lines(ctx, axis, ticks, start, end, direction, style)
1049 })
1050 }
1051 })
1052
1053 // Draw axes
1054 group(name: "axes", {
1055 let axes = (
1056 ("x", (0, x-y), (w, x-y), (1, 0), false, x-ticks, x-axis),
1057 ("y", (y-x, 0), (y-x, h), (0, 1), true, y-ticks, y-axis),
1058 )
1059 let label-pos = (
1060 x: ("north", (0,-1)),
1061 y: ("east", (-1,0)),
1062 )
1063
1064 on-layer(style.axis-layer, {
1065 for (name, start, end, dir, flip, ticks, axis) in axes {
1066 let style = _get-axis-style(ctx, style, name)
1067
1068 let pad = style.padding
1069 let overshoot = style.overshoot
1070 let vstart = vector.sub(start, vector.scale(dir, pad))
1071 let vend = vector.add(end, vector.scale(dir, pad + overshoot))
1072 let is-horizontal = name == "x"
1073
1074 let (data-start, data-end) = _inset-axis-points(ctx, style, axis, start, end)
1075 group(name: "axis", {
1076 _draw-axis-line(vstart, vend, axis, is-horizontal, style)
1077 place-ticks-on-line(ticks, data-start, data-end, style, flip: flip)
1078 })
1079
1080 if axis.label != none {
1081 let (content-anchor, offset-dir) = label-pos.at(name)
1082
1083 let angle = if style.label.angle not in (none, auto) {
1084 style.label.angle
1085 } else { 0deg }
1086 if style.label.anchor not in (none, auto) {
1087 content-anchor = style.label.anchor
1088 }
1089
1090 let offset = vector.scale(offset-dir, style.label.offset)
1091 content((rel: offset, to: vend),
1092 [#axis.label],
1093 angle: angle,
1094 anchor: content-anchor)
1095 }
1096 }
1097
1098 if shared-zero {
1099 let pt = (rel: (-style.tick.label.offset, -style.tick.label.offset),
1100 to: (y-x, x-y))
1101 let zero = if type(style.shared-zero) == std.content {
1102 style.shared-zero
1103 } else {
1104 $0$
1105 }
1106 content(pt, zero, anchor: "north-east")
1107 }
1108 })
1109 })
1110 })
1111}
1112// Import cetz into the root scope. Import cetz by importing this file only!
1113#import "@preview/cetz:0.3.2": *
1114#import "chart/boxwhisker.typ": boxwhisker, boxwhisker-default-style
1115#import "chart/barchart.typ": barchart, barchart-default-style
1116#import "chart/columnchart.typ": columnchart, columnchart-default-style
1117#import "chart/piechart.typ": piechart, piechart-default-style
1118#import "chart/pyramid.typ": pyramid, pyramid-default-style#import "/src/cetz.typ": draw, styles, palette
1119
1120#import "/src/plot.typ"
1121
1122#let barchart-default-style = (
1123 axes: (tick: (length: 0), grid: (stroke: (dash: "dotted"))),
1124 bar-width: .8,
1125 cluster-gap: 0,
1126 error: (
1127 whisker-size: .25,
1128 ),
1129 y-inset: 1,
1130)
1131
1132/// Draw a bar chart. A bar chart is a chart that represents data with
1133/// rectangular bars that grow from left to right, proportional to the values
1134/// they represent.
1135///
1136/// = Styling
1137/// Can be applied with `cetz.draw.set-style(barchart: (bar-width: 1))`.
1138///
1139/// *Root*: `barchart`.
1140/// #show-parameter-block("bar-width", "float", default: .8, [
1141/// Width of a single bar (basic) or a cluster of bars (clustered) in the plot.])
1142/// #show-parameter-block("y-inset", "float", default: 1, [
1143/// Distance of the plot data to the plot's edges on the y-axis of the plot.])
1144/// #show-parameter-block("cluster-gap", "float", default: 0, [
1145/// Spacing between bars insides a cluster.])
1146/// You can use any `plot` or `axes` related style keys, too.
1147///
1148/// The `barchart` function is a wrapper of the `plot` API. Arguments passed
1149/// to `..plot-args` are passed to the `plot.plot` function.
1150///
1151/// - data (array): Array of data rows. A row can be of type array or
1152/// dictionary, with `label-key` and `value-key` being
1153/// the keys to access a rows label and value(s).
1154///
1155/// *Example*
1156/// ```typc
1157/// (([A], 1), ([B], 2), ([C], 3),)
1158/// ```
1159/// - label-key (int,string): Key to access the label of a data row.
1160/// This key is used as argument to the
1161/// rows `.at(..)` function.
1162/// - value-key (int,string): Key(s) to access values of a data row.
1163/// These keys are used as argument to the
1164/// rows `.at(..)` function.
1165/// - error-key (none,int,string,array): Key(s) to access error values of a data row.
1166/// These keys are used as argument to the
1167/// rows `.at(..)` function.
1168/// - mode (string): Chart mode:
1169/// / basic: Single bar per data row
1170/// / clustered: Group of bars per data row
1171/// / stacked: Stacked bars per data row
1172/// / stacked100: Stacked bars per data row relative
1173/// to the sum of the row
1174/// - size (array): Chart size as width and height tuple in canvas unist;
1175/// width can be set to `auto`.
1176/// - bar-style (style,function): Style or function (idx => style) to use for
1177/// each bar, accepts a palette function.
1178/// - y-label (content,none): Y axis label
1179/// - x-label (content,none): x axis label
1180/// - labels (none,content): Legend labels per x value group
1181/// - ..plot-args (any): Arguments to pass to `plot.plot`
1182#let barchart(data,
1183 label-key: 0,
1184 value-key: 1,
1185 error-key: none,
1186 mode: "basic",
1187 size: (auto, 1),
1188 bar-style: palette.red,
1189 x-label: none,
1190 x-format: auto,
1191 y-label: none,
1192 labels: none,
1193 ..plot-args
1194 ) = {
1195 assert(type(label-key) in (int, str))
1196 if mode == "basic" {
1197 assert(type(value-key) in (int, str))
1198 } else {
1199 assert(type(value-key) == array)
1200 }
1201
1202 if type(value-key) != array {
1203 value-key = (value-key,)
1204 }
1205
1206 if error-key == none {
1207 error-key = ()
1208 } else if type(error-key) != array {
1209 error-key = (error-key,)
1210 }
1211
1212 if type(size) != array {
1213 size = (size, auto)
1214 }
1215 if size.at(1) == auto {
1216 size.at(1) = (data.len() + 1)
1217 }
1218
1219 let y-tic-list = data.enumerate().map(((i, t)) => {
1220 (data.len() - i - 1, t.at(label-key))
1221 })
1222
1223 let x-format = x-format
1224 if x-format == auto {
1225 x-format = if mode == "stacked100" {plot.formats.decimal.with(suffix: [%])} else {auto}
1226 }
1227
1228 data = data.enumerate().map(((i, d)) => {
1229 (data.len() - i - 1, value-key.map(k => d.at(k, default: 0)).flatten(), error-key.map(k => d.at(k, default: 0)).flatten())
1230 })
1231
1232 draw.group(ctx => {
1233 let style = styles.resolve(ctx.style, merge: (:),
1234 root: "barchart", base: barchart-default-style)
1235 draw.set-style(..style)
1236
1237 let y-inset = calc.max(style.y-inset, style.bar-width / 2)
1238 plot.plot(size: size,
1239 axis-style: "scientific-auto",
1240 x-label: x-label,
1241 x-grid: true,
1242 x-format: x-format,
1243 y-label: y-label,
1244 y-min: -y-inset,
1245 y-max: data.len() + y-inset - 1,
1246 y-tick-step: none,
1247 y-ticks: y-tic-list,
1248 plot-style: bar-style,
1249 ..plot-args,
1250 {
1251 plot.add-bar(data,
1252 x-key: 0,
1253 y-key: 1,
1254 error-key: if mode in ("basic", "clustered") { 2 },
1255 mode: mode,
1256 labels: labels,
1257 bar-width: -style.bar-width,
1258 cluster-gap: style.cluster-gap,
1259 axes: ("y", "x"))
1260 })
1261 })
1262}
1263// Valid bar- and columnchart modes
1264#let barchart-modes = (
1265 "basic", "clustered", "stacked", "stacked100"
1266)
1267
1268// Functions for max value calculation
1269#let barchart-max-value-fn = (
1270 basic: (data, value-key) => {
1271 calc.max(0, ..data.map(t => t.at(value-key)))
1272 },
1273 clustered: (data, value-key) => {
1274 calc.max(0, ..data.map(t => calc.max(
1275 ..value-key.map(k => t.at(k)))))
1276 },
1277 stacked: (data, value-key) => {
1278 calc.max(0, ..data.map(t =>
1279 value-key.map(k => t.at(k)).sum()))
1280 },
1281 stacked100: (..) => {
1282 100
1283 }
1284)
1285
1286// Functions for min value calculation
1287#let barchart-min-value-fn = (
1288 basic: (data, value-key) => {
1289 calc.min(0, ..data.map(t => t.at(value-key)))
1290 },
1291 clustered: (data, value-key) => {
1292 calc.min(0, ..data.map(t => calc.max(
1293 ..value-key.map(k => t.at(k)))))
1294 },
1295 stacked: (data, value-key) => {
1296 calc.min(0, ..data.map(t =>
1297 value-key.map(k => t.at(k)).sum()))
1298 },
1299 stacked100: (..) => {
1300 0
1301 }
1302)
1303#import "/src/cetz.typ": draw, styles, palette, util, vector, intersection
1304
1305#import "/src/plot.typ"
1306
1307#let boxwhisker-default-style = (
1308 axes: (tick: (length: 0), grid: (stroke: (dash: "dotted"))),
1309 box-width: 0.75,
1310 whisker-width: 0.5,
1311 mark-size: 0.15,
1312)
1313
1314/// Add one or more box or whisker plots.
1315///
1316/// #example(```
1317/// chart.boxwhisker(size: (2,2), label-key: none,
1318/// y-min: 0, y-max: 70, y-tick-step: 20,
1319/// (x: 1, min: 15, max: 60,
1320/// q1: 25, q2: 35, q3: 50))
1321/// ```)
1322///
1323/// = Styling
1324/// *Root* `boxwhisker`
1325/// #show-parameter-block("box-width", "float", default: .75, [
1326/// The width of the box. Since boxes are placed 1 unit next to each other,
1327/// a width of $1$ would make neighbouring boxes touch.])
1328/// #show-parameter-block("whisker-width", "float", default: .5, [
1329/// The width of the whisker, that is the horizontal bar on the top and bottom
1330/// of the box.])
1331/// #show-parameter-block("mark-size", "float", default: .15, [
1332/// The scaling of the mark for the boxes outlier values in canvas units.])
1333/// You can use any `plot` or `axes` related style keys, too.
1334///
1335/// - data (array, dictionary): Dictionary or array of dictionaries containing the
1336/// needed entries to plot box and whisker plot.
1337///
1338/// See `plot.add-boxwhisker` for more details.
1339///
1340/// *Examples:*
1341/// - ```typc
1342/// (x: 1 // Location on x-axis
1343/// outliers: (7, 65, 69), // Optional outliers
1344/// min: 15, max: 60 // Minimum and maximum
1345/// q1: 25, // Quartiles: Lower
1346/// q2: 35, // Median
1347/// q3: 50) // Upper
1348/// ```
1349/// - size (array) : Size of chart. If the second entry is auto, it automatically scales to accommodate the number of entries plotted
1350/// - label-key (integer, string): Index in the array where labels of each entry is stored
1351/// - mark (string): Mark to use for plotting outliers. Set `none` to disable. Defaults to "x"
1352/// - ..plot-args (any): Additional arguments are passed to `plot.plot`
1353#let boxwhisker(data,
1354 size: (1, auto),
1355 label-key: 0,
1356 mark: "*",
1357 ..plot-args
1358 ) = {
1359 if type(data) == dictionary { data = (data,) }
1360
1361 if type(size) != array {
1362 size = (size, auto)
1363 }
1364 if size.at(1) == auto {
1365 size.at(1) = (data.len() + 1)
1366 }
1367
1368 let x-tick-list = data.enumerate().map(((i, t)) => {
1369 (i + 1, if label-key != none { t.at(label-key, default: i) } else { [] })
1370 })
1371
1372 draw.group(ctx => {
1373 let style = styles.resolve(ctx.style, merge: (:),
1374 root: "boxwhisker", base: boxwhisker-default-style)
1375 draw.set-style(..style)
1376
1377 plot.plot(
1378 size: size,
1379 axis-style: "scientific-auto",
1380 x-tick-step: none,
1381 x-ticks: x-tick-list,
1382 y-grid: true,
1383 x-label: none,
1384 y-label: none,
1385 ..plot-args,
1386 {
1387 for (i, row) in data.enumerate() {
1388 plot.add-boxwhisker(
1389 (x: i + 1, ..row),
1390 box-width: style.box-width,
1391 whisker-width: style.whisker-width,
1392 style: (:),
1393 mark: mark,
1394 mark-size: style.mark-size
1395 )
1396 }
1397 })
1398 })
1399}
1400#import "/src/cetz.typ": draw, styles, palette, util, vector, intersection
1401
1402#import "/src/plot.typ"
1403
1404#let columnchart-default-style = (
1405 axes: (tick: (length: 0), grid: (stroke: (dash: "dotted"))),
1406 bar-width: .8,
1407 cluster-gap: 0,
1408 error: (
1409 whisker-size: .25,
1410 ),
1411 x-inset: 1,
1412)
1413
1414/// Draw a column chart. A column chart is a chart that represents data with
1415/// rectangular bars that grow from bottom to top, proportional to the values
1416/// they represent.
1417///
1418/// = Styling
1419/// *Root*: `columnchart`.
1420/// #show-parameter-block("bar-width", "float", default: .8, [
1421/// Width of a single bar (basic) or a cluster of bars (clustered) in the plot.])
1422/// #show-parameter-block("x-inset", "float", default: 1, [
1423/// Distance of the plot data to the plot's edges on the x-axis of the plot.])
1424/// You can use any `plot` or `axes` related style keys, too.
1425///
1426/// The `columnchart` function is a wrapper of the `plot` API. Arguments passed
1427/// to `..plot-args` are passed to the `plot.plot` function.
1428///
1429/// - data (array): Array of data rows. A row can be of type array or
1430/// dictionary, with `label-key` and `value-key` being
1431/// the keys to access a rows label and value(s).
1432///
1433/// *Example*
1434/// ```typc
1435/// (([A], 1), ([B], 2), ([C], 3),)
1436/// ```
1437/// - label-key (int,string): Key to access the label of a data row.
1438/// This key is used as argument to the
1439/// rows `.at(..)` function.
1440/// - value-key (int,string): Key(s) to access value(s) of data row.
1441/// These keys are used as argument to the
1442/// rows `.at(..)` function.
1443/// - error-key (none,int,string,array): Key(s) to access error values of a data row.
1444/// These keys are used as argument to the rows `.at(..)` function.
1445/// - mode (string): Chart mode:
1446/// / basic: Single bar per data row
1447/// / clustered: Group of bars per data row
1448/// / stacked: Stacked bars per data row
1449/// / stacked100: Stacked bars per data row relative
1450/// to the sum of the row
1451/// - size (array): Chart size as width and height tuple in canvas unist;
1452/// width can be set to `auto`.
1453/// - bar-style (style,function): Style or function (idx => style) to use for
1454/// each bar, accepts a palette function.
1455/// - y-label (content,none): Y axis label
1456/// - x-label (content,none): x axis label
1457/// - labels (none,content): Legend labels per y value group
1458/// - ..plot-args (any): Arguments to pass to `plot.plot`
1459#let columnchart(data,
1460 label-key: 0,
1461 value-key: 1,
1462 error-key: none,
1463 mode: "basic",
1464 size: (auto, 1),
1465 bar-style: palette.red,
1466 x-label: none,
1467 y-format: auto,
1468 y-label: none,
1469 labels: none,
1470 ..plot-args
1471 ) = {
1472 assert(type(label-key) in (int, str))
1473 if mode == "basic" {
1474 assert(type(value-key) in (int, str))
1475 }
1476
1477 if type(value-key) != array {
1478 value-key = (value-key,)
1479 }
1480
1481 if error-key == none {
1482 error-key = ()
1483 } else if type(error-key) != array {
1484 error-key = (error-key,)
1485 }
1486
1487 if type(size) != array {
1488 size = (auto, size)
1489 }
1490 if size.at(0) == auto {
1491 size.at(0) = (data.len() + 1)
1492 }
1493
1494 let x-tic-list = data.enumerate().map(((i, t)) => {
1495 (i, t.at(label-key))
1496 })
1497
1498 let y-format = y-format
1499 if y-format == auto {
1500 y-format = if mode == "stacked100" {plot.formats.decimal.with(suffix: [%])} else {auto}
1501 }
1502
1503 data = data.enumerate().map(((i, d)) => {
1504 (i, value-key.map(k => d.at(k)).flatten(), error-key.map(k => d.at(k, default: 0)).flatten())
1505 })
1506
1507 draw.group(ctx => {
1508 let style = styles.resolve(ctx.style, merge: (:),
1509 root: "columnchart", base: columnchart-default-style)
1510 draw.set-style(..style)
1511
1512 let x-inset = calc.max(style.x-inset, style.bar-width / 2)
1513 plot.plot(size: size,
1514 axis-style: "scientific-auto",
1515 y-grid: true,
1516 y-label: y-label,
1517 y-format: y-format,
1518 x-min: -x-inset,
1519 x-max: data.len() + x-inset - 1,
1520 x-tick-step: none,
1521 x-ticks: x-tic-list,
1522 x-label: x-label,
1523 plot-style: bar-style,
1524 ..plot-args,
1525 {
1526 plot.add-bar(data,
1527 x-key: 0,
1528 y-key: 1,
1529 error-key: if mode in ("basic", "clustered") { 2 },
1530 mode: mode,
1531 labels: labels,
1532 bar-width: style.bar-width,
1533 cluster-gap: style.cluster-gap,
1534 error-style: style.error,
1535 whisker-size: style.error.whisker-size,
1536 axes: ("x", "y"))
1537 })
1538 })
1539}
1540#import "/src/cetz.typ": draw, styles, palette, util, vector, intersection
1541#import util: circle-arclen
1542
1543#import "/src/plot/legend.typ"
1544
1545// Piechart Label Kind
1546#let label-kind = (value: "VALUE", percentage: "%", label: "LABEL")
1547
1548// Piechart Default Style
1549#let default-style = (
1550 stroke: auto,
1551 fill: auto,
1552 /// Outer chart radius
1553 radius: 1,
1554 /// Inner slice radius
1555 inner-radius: 0,
1556 /// Gap between items. This can be a canvas length or an angle
1557 gap: 0.5deg,
1558 /// Outset offset, absolute or relative to radius
1559 outset-offset: 10%,
1560 /// Pie outset mode:
1561 /// - "OFFSET": Offset slice position by outset-offset
1562 /// - "RADIUS": Offset slice radius by outset-offset (the slice gets scaled)
1563 outset-mode: "OFFSET",
1564 /// Pie start angle
1565 start: 90deg,
1566 /// Pie stop angle
1567 stop: 360deg + 90deg,
1568 /// Pie rotation direction (true = clockwise, false = anti-clockwise)
1569 clockwise: true,
1570 outer-label: (
1571 /// Label kind
1572 /// If set to a function, that function gets called with (value, label) of each item
1573 content: label-kind.label,
1574 /// Absolute radius or percentage of radius
1575 radius: 125%,
1576 /// Absolute angle or auto to use secant of the slice as direction
1577 angle: 0deg,
1578 /// Label anchor
1579 anchor: "center",
1580 ),
1581 inner-label: (
1582 /// Label kind
1583 /// If set to a function, that function gets called with (value, label) of each item
1584 content: none,
1585 /// Absolute radius or percentage of the mid between radius and inner-radius
1586 radius: 150%,
1587 /// Absolute angle or auto to use secant of the slice as direction
1588 angle: 0deg,
1589 /// Label anchor
1590 anchor: "center",
1591 ),
1592 legend: (
1593 ..legend.default-style,
1594
1595 /// Label used for the legend
1596 /// The legend gets rendered as soon as at least one item with a label
1597 /// exists and the `legend-label.content` is set != none. This field
1598 /// accepts the same values as inner-label.content or outer-label.content.
1599 label: "LABEL",
1600
1601 /// Anchor of the charts data bounding box to place the legend relative to
1602 position: "south",
1603
1604 /// Anchor of the legend bounding box to use as origin
1605 anchor: "north",
1606
1607 /// Custom preview function override
1608 /// The function takes an item dictionary an is responsible for drawing
1609 /// the preview icon. Stroke and fill styles are set to match the items
1610 /// style.
1611 preview: none,
1612
1613 /// See lenged.typ for the following style keys
1614 orientation: ltr,
1615 offset: (0,-.5em),
1616 stroke: none,
1617 item: (
1618 spacing: .25,
1619 preview: (
1620 width: .3,
1621 height: .3,
1622 ),
1623 ),
1624 )
1625)
1626#let piechart-default-style = default-style
1627
1628
1629/// Draw a pie- or donut-chart
1630///
1631/// #example(```
1632/// let data = (24, 31, 18, 21, 23, 18, 27, 17, 26, 13)
1633/// let colors = gradient.linear(red, blue, green, yellow)
1634///
1635/// chart.piechart(
1636/// data,
1637/// radius: 1.5,
1638/// slice-style: colors,
1639/// inner-radius: .5,
1640/// outer-label: (content: "%",))
1641/// ```)
1642///
1643/// = Styling
1644/// *Root* `piechart` \
1645/// #show-parameter-block("radius", ("number"), [
1646/// Outer radius of the chart.], default: 1)
1647/// #show-parameter-block("inner-radius", ("number"), [
1648/// Inner radius of the chart slices. If greater than zero, the chart becomes
1649/// a "donut-chart".], default: 0)
1650/// #show-parameter-block("gap", ("number", "angle"), [
1651/// Gap between chart slices to leave empty. This does not increase the charts
1652/// radius by pushing slices outwards, but instead shrinks the slice. Big
1653/// values can result in slices becoming invisible if no space is left.], default: 0.5deg)
1654/// #show-parameter-block("outset-offset", ("number", "ratio"), [
1655/// Absolute, or radius relative distance to push slices marked for
1656/// "outsetting" outwards from the center of the chart.], default: 10%)
1657/// #show-parameter-block("outset-offset", ("string"), [
1658/// The mode of how to perform "outsetting" of slices:
1659/// - "OFFSET": Offset slice position by `outset-offset`, increasing their gap to their siblings
1660/// - "RADIUS": Offset slice radius by `outset-offset`, which scales the slice and leaves the gap unchanged], default: "OFFSET")
1661/// #show-parameter-block("start", ("angle"), [
1662/// The pie-charts start angle (ccw). You can use this to draw charts not forming a full circle.], default: 90deg)
1663/// #show-parameter-block("stop", ("angle"), [
1664/// The pie-charts stop angle (ccw).], default: 360deg + 90deg)
1665/// #show-parameter-block("clockwise", ("bool"), [
1666/// The pie-charts rotation direction.], default: true)
1667/// #show-parameter-block("outer-label.content", ("none","string","function"), [
1668/// Content to display outsides the charts slices.
1669/// There are the following predefined values:
1670/// / LABEL: Display the slices label (see `label-key`)
1671/// / %: Display the percentage of the items value in relation to the sum of
1672/// all values, rounded to the next integer
1673/// / VALUE: Display the slices value
1674/// If passed a `<function>` of the format `(value, label) => content`,
1675/// that function gets called with each slices value and label and must return
1676/// content, that gets displayed.], default: "LABEL")
1677/// #show-parameter-block("outer-label.radius", ("number","ratio"), [
1678/// Absolute, or radius relative distance from the charts center to position
1679/// outer labels at.], default: 125%)
1680/// #show-parameter-block("outer-label.angle", ("angle","auto"), [
1681/// The angle of the outer label. If passed `auto`, the label gets rotated,
1682/// so that the baseline is parallel to the slices secant. ], default: 0deg)
1683/// #show-parameter-block("outer-label.anchor", ("string"), [
1684/// The anchor of the outer label to use for positioning.], default: "center")
1685/// #show-parameter-block("inner-label.content", ("none","string","function"), [
1686/// Content to display insides the charts slices.
1687/// See `outer-label.content` for the possible values.], default: none)
1688/// #show-parameter-block("inner-label.radius", ("number","ratio"), [
1689/// Distance of the inner label to the charts center. If passed a `<ratio>`,
1690/// that ratio is relative to the mid between the inner and outer radius (`inner-radius` and `radius`)
1691/// of the chart], default: 150%)
1692/// #show-parameter-block("inner-label.angle", ("angle","auto"), [
1693/// See `outer-label.angle`.], default: 0deg)
1694/// #show-parameter-block("inner-label.anchor", ("string"), [
1695/// See `outer-label.anchor`.], default: "center")
1696/// #show-parameter-block("legend.label", ("none","string","function"), [
1697/// See `outer-label.content`. The legend gets shown if this key is set != none.], default: "LABEL")
1698///
1699/// = Anchors
1700/// The chart places one anchor per item at the radius of it's slice that
1701/// gets named `"item-<index>"` (outer radius) and `"item-<index>-inner"` (inner radius),
1702/// where index is the index of the sclice data in `data`.
1703///
1704/// - data (array): Array of data items. A data item can be:
1705/// - A number: A number that is used as the fraction of the slice
1706/// - An array: An array which is read depending on value-key, label-key and outset-key
1707/// - A dictionary: A dictionary which is read depending on value-key, label-key and outset-key
1708/// - value-key (none,int,string): Key of the "value" of a data item. If for example
1709/// data items are passed as dictionaries, the value-key is the key of the dictionary to
1710/// access the items chart value.
1711/// - label-key (none,int,string): Same as the value-key but for getting an items label content.
1712/// - outset-key (none,int,string): Same as the value-key but for getting if an item should get outset (highlighted). The
1713/// outset can be a bool, float or ratio. If of type `bool`, the outset distance from the
1714/// style gets used.
1715/// - outset (none,int,array): A single or multiple indices of items that should get offset from the center to the outsides
1716/// of the chart. Only used if outset-key is none!
1717/// - slice-style (function,array,gradient): Slice style of the following types:
1718/// - function: A function of the form `index => style` that must return a style dictionary.
1719/// This can be a `palette` function.
1720/// - array: An array of style dictionaries or fill colors of at least one item. For each slice the style at the slices
1721/// index modulo the arrays length gets used.
1722/// - gradient: A gradient that gets sampled for each data item using the the slices
1723/// index divided by the number of slices as position on the gradient.
1724/// If one of stroke or fill is not in the style dictionary, it is taken from the charts style.
1725#let piechart(data,
1726 value-key: none,
1727 label-key: none,
1728 outset-key: none,
1729 outset: none,
1730 slice-style: palette.red,
1731 name: none,
1732 ..style) = {
1733 import draw: *
1734
1735 // Prepare data by converting it to tuples of the format
1736 // (value, label, outset)
1737 data = data.enumerate().map(((i, item)) => (
1738 if value-key != none {
1739 item.at(value-key)
1740 } else {
1741 item
1742 },
1743 if label-key != none {
1744 item.at(label-key)
1745 } else {
1746 none
1747 },
1748 if outset-key != none {
1749 item.at(outset-key, default: false)
1750 } else if outset != none {
1751 i == outset or (type(outset) == array and i in outset)
1752 } else {
1753 false
1754 }
1755 ))
1756
1757 let visible-data = data.filter(((value, ..)) => value != 0)
1758
1759 let sum = visible-data.map(((value, ..)) => value).sum()
1760 if sum == 0 {
1761 sum = 1
1762 }
1763
1764 group(name: name, ctx => {
1765 anchor("default", (0,0))
1766
1767 let style = styles.resolve(ctx,
1768 merge: style.named(), root: "piechart", base: default-style)
1769
1770 let gap = style.gap
1771 if type(gap) != angle {
1772 gap = gap / (2 * calc.pi * style.radius) * 360deg
1773 }
1774 assert(gap < 360deg / visible-data.len(),
1775 message: "Gap angle is too big for " + str(visible-data.len()) + "items. Maximum gap angle: " + repr(360deg / visible-data.len()))
1776
1777 let radius = style.radius
1778 assert(radius > 0,
1779 message: "Radius must be > 0.")
1780
1781 let inner-radius = style.inner-radius
1782 assert(inner-radius >= 0 and inner-radius <= radius,
1783 message: "Radius must be >= 0 and <= radius.")
1784
1785 assert(style.outset-mode in ("OFFSET", "RADIUS"),
1786 message: "Outset mode must be 'OFFSET' or 'RADIUS', but is: " + str(style.outset-mode))
1787
1788 let style-at = if type(slice-style) == function {
1789 slice-style
1790 } else if type(slice-style) == array {
1791 i => {
1792 let s = slice-style.at(calc.rem(i, slice-style.len()))
1793 if type(s) == color {
1794 (fill: s)
1795 } else {
1796 s
1797 }
1798 }
1799 } else if type(slice-style) == gradient {
1800 i => (fill: slice-style.sample(i / visible-data.len() * 100%))
1801 }
1802
1803 let start-angle = style.start
1804 let stop-angle = style.stop
1805 let f = (stop-angle - start-angle) / sum
1806
1807 let get-item-label(item, kind) = {
1808 let (value, label, ..) = item
1809 if kind == label-kind.value {
1810 [#value]
1811 } else if kind == label-kind.percentage {
1812 [#{calc.round(value / sum * 100)}%]
1813 } else if kind == label-kind.label {
1814 label
1815 } else if type(kind) == function {
1816 (kind)(value, label)
1817 }
1818 }
1819
1820 let start = start-angle
1821 let enum-items = (if style.clockwise {
1822 data.enumerate().rev()
1823 } else {
1824 data.enumerate()
1825 })
1826 group(name: "chart", {
1827 for (i, item) in enum-items.filter((value, ..) => value != 0) {
1828 let (value, label, outset) = item
1829 if value == 0 { continue }
1830
1831 let origin = (0,0)
1832 let radius = radius
1833 let inner-radius = inner-radius
1834
1835 // Calculate item angles
1836 let delta = f * value
1837 let end = start + delta
1838
1839 // Apply item outset
1840 let outset-offset = if outset == true {
1841 style.outset-offset
1842 } else if outset == false {
1843 0
1844 } else if type(outset) in (float, ratio) {
1845 outset
1846 } else {
1847 panic("Invalid type for outset. Expected bool, float or ratio, got: " + repr(outset))
1848 }
1849 if type(outset-offset) == ratio {
1850 outset-offset = outset-offset * radius / 100%
1851 }
1852
1853 if outset-offset != 0 {
1854 if style.outset-mode == "OFFSET" {
1855 let dir = (calc.cos((start + end) / 2), calc.sin((start + end) / 2))
1856 origin = vector.add(origin, vector.scale(dir, outset-offset))
1857 radius += outset-offset
1858 } else {
1859 radius += outset-offset
1860 if inner-radius > 0 {
1861 inner-radius += outset-offset
1862 }
1863 }
1864 }
1865
1866 // Calculate gap angles
1867 let outer-gap = gap
1868 let gap-dist = outer-gap / 360deg * 2 * calc.pi * radius
1869 let inner-gap = if inner-radius > 0 {
1870 gap-dist / (2 * calc.pi * inner-radius) * 360deg
1871 } else {
1872 1 / calc.pi * 360deg
1873 }
1874
1875 // Calculate angle deltas
1876 let outer-angle = end - start - outer-gap * 2
1877 let inner-angle = end - start - inner-gap * 2
1878 let mid-angle = (start + end) / 2
1879
1880 // Skip negative values
1881 if outer-angle < 0deg {
1882 // TODO: Add a warning as soon as Typst is ready!
1883 continue
1884 }
1885
1886 // A sharp item is an item that should be round but is sharp due to the gap being big
1887 let is-sharp = inner-radius == 0 or circle-arclen(inner-radius, angle: inner-angle) > circle-arclen(radius, angle: outer-angle)
1888
1889 let inner-origin = vector.add(origin, if inner-radius == 0 {
1890 if gap-dist >= 0 {
1891 let outer-end = vector.scale((calc.cos(end - outer-gap), calc.sin(end - outer-gap)), radius)
1892 let inner-end = vector.scale((calc.cos(end - inner-gap), calc.sin(end - inner-gap)), gap-dist)
1893 let outer-start = vector.scale((calc.cos(start + outer-gap), calc.sin(start + outer-gap)), radius)
1894 let inner-start = vector.scale((calc.cos(start + inner-gap), calc.sin(start + inner-gap)), gap-dist)
1895
1896 intersection.line-line(outer-end, inner-end, outer-start, inner-start, ray: true)
1897 } else {
1898 (0,0)
1899 }
1900 } else if is-sharp {
1901 let outer-end = vector.scale((calc.cos(end - outer-gap), calc.sin(end - outer-gap)), radius)
1902 let inner-end = vector.scale((calc.cos(end - inner-gap), calc.sin(end - inner-gap)), inner-radius)
1903 let outer-start = vector.scale((calc.cos(start + outer-gap), calc.sin(start + outer-gap)), radius)
1904 let inner-start = vector.scale((calc.cos(start + inner-gap), calc.sin(start + inner-gap)), inner-radius)
1905
1906 intersection.line-line(outer-end, inner-end, outer-start, inner-start, ray: true)
1907 } else {
1908 (0,0)
1909 })
1910
1911 // Draw one segment
1912 let stroke = style-at(i).at("stroke", default: style.stroke)
1913 let fill = style-at(i).at("fill", default: style.fill)
1914 if visible-data.len() == 1 {
1915 // If the chart has only one segment, we may have to fake a path
1916 // with a hole in it by using a combination of multiple arcs.
1917 if inner-radius > 0 {
1918 // Split the circle/arc into two arcs
1919 // and fill them
1920 merge-path({
1921 arc(origin, start: start-angle, stop: mid-angle, radius: radius, anchor: "origin")
1922 arc(origin, stop: start-angle, start: mid-angle, radius: inner-radius, anchor: "origin")
1923 }, close: false, fill:fill, stroke: none)
1924 merge-path({
1925 arc(origin, start: mid-angle, stop: stop-angle, radius: radius, anchor: "origin")
1926 arc(origin, stop: mid-angle, start: stop-angle, radius: inner-radius, anchor: "origin")
1927 }, close: false, fill:fill, stroke: none)
1928
1929 // Create arcs for the inner and outer border and stroke them.
1930 // If the chart is not a full circle, we have to merge two arc
1931 // at their ends to create closing lines
1932 if stroke != none {
1933 if calc.abs(stop-angle - start-angle) != 360deg {
1934 merge-path({
1935 arc(origin, start: start, stop: end, radius: inner-radius, anchor: "origin")
1936 arc(origin, start: end, stop: start, radius: radius, anchor: "origin")
1937 }, close: true, fill: none, stroke: stroke)
1938 } else {
1939 arc(origin, start: start, stop: end, radius: inner-radius, fill: none, stroke: stroke, anchor: "origin")
1940 arc(origin, start: start, stop: end, radius: radius, fill: none, stroke: stroke, anchor: "origin")
1941 }
1942 }
1943 } else {
1944 arc(origin, start: start, stop: end, radius: radius, fill: fill, stroke: stroke, mode: "PIE", anchor: "origin")
1945 }
1946 } else {
1947 // Draw a normal segment
1948 if inner-origin != none {
1949 merge-path({
1950 arc(origin, start: start + outer-gap, stop: end - outer-gap, anchor: "origin",
1951 radius: radius)
1952 if inner-radius > 0 and not is-sharp {
1953 if inner-angle < 0deg {
1954 arc(inner-origin, stop: end - inner-gap, delta: inner-angle, anchor: "origin",
1955 radius: inner-radius)
1956 } else {
1957 arc(inner-origin, start: end - inner-gap, delta: -inner-angle, anchor: "origin",
1958 radius: inner-radius)
1959 }
1960 } else {
1961 line((rel: (end - outer-gap, radius), to: origin),
1962 inner-origin,
1963 (rel: (start + outer-gap, radius), to: origin))
1964 }
1965 }, close: true, fill: fill, stroke: stroke)
1966 }
1967 }
1968
1969 // Place outer label
1970 let outer-label = get-item-label(item, style.outer-label.content)
1971 if outer-label != none {
1972 let r = style.outer-label.radius
1973 if type(r) == ratio {r = r * radius / 100%}
1974
1975 let dir = (r * calc.cos(mid-angle), r * calc.sin(mid-angle))
1976 let pt = vector.add(origin, dir)
1977
1978 let angle = style.outer-label.angle
1979 if angle == auto {
1980 angle = vector.add(pt, (dir.at(1), -dir.at(0)))
1981 }
1982
1983 content(pt, outer-label, angle: angle, anchor: style.outer-label.anchor)
1984 }
1985
1986 // Place inner label
1987 let inner-label = get-item-label(item, style.inner-label.content)
1988 if inner-label != none {
1989 let r = style.inner-label.radius
1990 if type(r) == ratio {r = r * (radius + inner-radius) / 200%}
1991
1992 let dir = (r * calc.cos(mid-angle), r * calc.sin(mid-angle))
1993 let pt = vector.add(origin, dir)
1994
1995 let angle = style.inner-label.angle
1996 if angle == auto {
1997 angle = vector.add(pt, (dir.at(1), -dir.at(0)))
1998 }
1999
2000 content(pt, inner-label, angle: angle, anchor: style.inner-label.anchor)
2001 }
2002
2003 // Place item anchor
2004 anchor("item-" + str(i), (rel: (mid-angle, radius), to: origin))
2005 anchor("item-" + str(i) + "-inner", (rel: (mid-angle, inner-radius), to: origin))
2006
2007 start = end
2008 }
2009 })
2010
2011 legend.legend((name: "chart", anchor: style.legend.position), {
2012 let preview-fn = if style.legend.preview != none {
2013 style.legend.preview
2014 } else {
2015 (_) => { rect((0,0), (1,1)) }
2016 }
2017
2018 for (i, item) in enum-items.rev() {
2019 let label = get-item-label(item, style.legend.label)
2020 let preview = (item) => {
2021 let stroke = style-at(i).at("stroke", default: style.stroke)
2022 let fill = style-at(i).at("fill", default: style.fill)
2023
2024 set-style(stroke: stroke, fill: fill)
2025 preview-fn(item)
2026 }
2027
2028 legend.item(label, preview)
2029 }
2030 }, ..style.at("legend", default: (:)))
2031 })
2032}
2033#import "/src/cetz.typ": draw, styles, palette, coordinate
2034
2035// Pyramid Chart Label Kind
2036#let label-kind = (value: "VALUE", percentage: "%", label: "LABEL")
2037
2038// Pyramid Chart Default Style
2039#let default-style = (
2040 stroke: auto,
2041 fill: auto,
2042 /// Gap between levels to leave empty.
2043 /// If `mode` is "AREA-HEIGHT", the value must be a ratio and will be proportional to the height of the first level
2044 gap: 0,
2045 /// Pyramid mode defining how to shape each level:
2046 /// - "REGULAR": All levels have the same height and make a perfectly triangular pyramid
2047 /// - "AREA-HEIGHT": The area of each level is proportional to its value. Only the height is adapted, keeping the pyramid triangular
2048 /// - "HEIGHT": The height of each level is proportional to its value. The pyramid is kept as a perfect triangle
2049 /// - "WIDTH": The height of each level is fixed, but its width is proportional to the value. The pyramid might not be perfectly triangular
2050 mode: "REGULAR",
2051 /// Height of each level
2052 level-height: 1,
2053 inner-label: (
2054 /// Label kind
2055 /// If set to a function, that function gets called with (value, label) of each item
2056 content: "LABEL",
2057 force-inside: false
2058 ),
2059 side-label: (
2060 /// Label kind
2061 /// If set to a function, that function gets called with (value, label) of each item
2062 content: none,
2063 side: "west"
2064 )
2065)
2066
2067#let pyramid-default-style = default-style
2068
2069/// Draw a pyramid chart
2070///
2071/// #example(```
2072/// let data = (
2073/// "transcendence",
2074/// "self-actualization",
2075/// "aesthetic",
2076/// "cognitive",
2077/// "esteem",
2078/// "belonging and love",
2079/// "safety",
2080/// "physiological"
2081/// )
2082/// let colors = (
2083/// rgb("#FFFFC5"), rgb("#FEB6A5"),
2084/// rgb("#FFD89F"), rgb("#C6C6C6"),
2085/// rgb("#D4D1FF"), rgb("#FFB7CD"),
2086/// rgb("#F7BCFF"), rgb("#BDE0B0"),
2087/// )
2088///
2089/// chart.pyramid(
2090/// data,
2091/// level-style: colors,
2092/// level-height: 0.7)
2093/// ```)
2094///
2095/// = Styling
2096/// *Root* `pyramid` \
2097/// #show-parameter-block("level-height", ("number"), [
2098/// Minimum level height.], default: 1)
2099/// #show-parameter-block("gap", ("number", "ratio"), [
2100/// Gap between levels to leave empty. If `mode` is "AREA-HEIGHT", the value must be a ratio and will be proportional to the height of the first level.], default: 0)
2101/// #show-parameter-block("mode", ("string"), [
2102/// The mode of how to shape each level:
2103/// - "REGULAR": All levels have the same height and make a perfectly triangular pyramid
2104/// - "AREA-HEIGHT": The area of each level is proportional to its value. Only the height is adapted, keeping the pyramid triangular
2105/// - "HEIGHT": The height of each level is proportional to its value. The pyramid is kept as a perfect triangle
2106/// - "WIDTH": The height of each level is fixed, but its width is proportional to the value. The pyramid might not be perfectly triangular], default: "REGULAR")
2107/// #show-parameter-block("side-label.content", ("none","string","function"), [
2108/// Content to display outsides the charts levels, on the side.
2109/// There are the following predefined values:
2110/// / LABEL: Display the levels label (see `label-key`)
2111/// / %: Display the percentage of the items value in relation to the sum of
2112/// all values, rounded to the next integer
2113/// / VALUE: Display the levels value
2114/// If passed a `<function>` of the format `(value, label) => content`,
2115/// that function gets called with each levels value and label and must return
2116/// content, that gets displayed.], default: none)
2117/// #show-parameter-block("side-label.side", ("string"), [
2118/// The side of the chart on which to place side labels, either "west" or "east"], default: "west")
2119/// #show-parameter-block("inner-label.content", ("none","string","function"), [
2120/// Content to display insides the charts levels.
2121/// See `side-label.content` for the possible values.], default: "LABEL")
2122/// #show-parameter-block("inner-label.force-inside", ("boolean"), [
2123/// If false, labels are automatically placed outside their correspoding levels if they don't fit inside. If true, they are always placed inside.], default: false)
2124///
2125/// = Anchors
2126/// The chart places one anchor per item at the center of its level that
2127/// gets named `"levels.<index>"`, one on the middle of its left side named `"levels.<index>.west"`, and one on the right side named `"levels.<index>.east"`,
2128/// where index is the index of the level data in `data`.
2129///
2130/// - data (array): Array of data items. A data item can be:
2131/// - A number: A number that is used as the fraction of the level
2132/// - An array: An array which is read depending on value-key and label-key
2133/// - A dictionary: A dictionary which is read depending on value-key and label-key
2134/// - value-key (none,int,string): Key of the "value" of a data item. If for example
2135/// data items are passed as dictionaries, the value-key is the key of the dictionary to
2136/// access the items chart value.
2137/// - label-key (none,int,string): Same as the value-key but for getting an items label content.
2138/// - level-style (function,array,gradient): Level style of the following types:
2139/// - function: A function of the form `index => style` that must return a style dictionary.
2140/// This can be a `palette` function.
2141/// - array: An array of style dictionaries or fill colors of at least one item. For each level the style at the levels
2142/// index modulo the arrays length gets used.
2143/// - gradient: A gradient that gets sampled for each data item using the the levels
2144/// index divided by the number of levels as position on the gradient.
2145/// If one of stroke or fill is not in the style dictionary, it is taken from the charts style.
2146#let pyramid(
2147 data,
2148 value-key: none,
2149 label-key: none,
2150 level-style: palette.red,
2151 name: none,
2152 ..style
2153) = {
2154 // Prepare data by converting it to tuples of the format
2155 // (value, label)
2156 data = data.enumerate().map(((i, item)) => (
2157 if value-key != none {
2158 item.at(value-key)
2159 } else {
2160 none
2161 },
2162 if label-key != none {
2163 item.at(label-key)
2164 } else {
2165 item
2166 }
2167 ))
2168
2169 draw.group(name: name, ctx => {
2170 draw.anchor("default", (0, 0))
2171
2172 let style = styles.resolve(
2173 ctx,
2174 merge: style.named(),
2175 root: "pyramid",
2176 base: default-style
2177 )
2178
2179 let mode = style.mode
2180 let gap = style.gap
2181
2182 assert(mode in ("REGULAR", "AREA-HEIGHT", "HEIGHT", "AREA-WIDTH", "WIDTH"),
2183 message: "Mode must be 'REGULAR', 'AREA-HEIGHT', 'HEIGHT', 'AREA-WIDTH' or 'WIDTH', but is: " + str(mode))
2184
2185
2186 if mode == "AREA-HEIGHT" {
2187 if gap == 0 {
2188 gap = 0%
2189 }
2190 assert(type(gap) == ratio, message: "When mode is set to 'AREA-HEIGHT', gap must be of type ratio, but is: " + str(type(gap)))
2191 }
2192
2193 assert(style.side-label.side in ("west", "east"),
2194 message: "Side label side must either be 'west' or 'east', but is: " + str(style.side-label.side))
2195
2196 let style-at = if type(level-style) == function {
2197 level-style
2198 } else if type(level-style) == array {
2199 i => {
2200 let s = level-style.at(calc.rem(i, level-style.len()))
2201 if type(s) == color or type(s) == gradient {
2202 (fill: s)
2203 } else {
2204 s
2205 }
2206 }
2207 } else if type(level-style) == gradient {
2208 i => (fill: level-style.sample(i / data.len() * 100%))
2209 }
2210
2211 let total = data.map(d => d.first()).sum()
2212 let get-item-label(item, kind) = {
2213 let (value, label, ..) = item
2214 if kind == label-kind.value {
2215 [#value]
2216 } else if kind == label-kind.percentage {
2217 if total == none {
2218 panic("Using percentage label without values")
2219 }
2220 [#{calc.round(value / total * 100)}%]
2221 } else if kind == label-kind.label {
2222 label
2223 } else if type(kind) == function {
2224 (kind)(value, label)
2225 }
2226 }
2227
2228 let rect-overlaps-trapezoid(rect, trapezoid) = {
2229 let r = rect
2230 let t = trapezoid
2231 let side-m = (t.y1 - t.y2) / (t.x1 - t.x2)
2232 let side-h = t.y1 - side-m * t.x1
2233
2234 let x-side = (r.y - side-h) / side-m
2235
2236 return if r.y > t.y1 {
2237 r.x < t.x1
2238 } else {
2239 r.x < x-side
2240 }
2241 }
2242
2243 let total-gaps = style.gap * data.len()
2244 let total-height = if mode in ("REGULAR", "WIDTH") {
2245 style.level-height * data.len()
2246 } else if mode == "HEIGHT" {
2247
2248 }
2249
2250 total-height += total-gaps
2251
2252 let base-width = if mode == "REGULAR" {
2253 2 * total-height / calc.sqrt(3)
2254
2255 }
2256
2257 let enum-items = data.enumerate()
2258
2259 // Array of levels
2260 // level = (
2261 // y,
2262 // h,
2263 // w-top,
2264 // w-bottom,
2265 // item
2266 // )
2267 let levels = ()
2268
2269 if mode == "REGULAR" {
2270 levels = enum-items.map(((i, item)) => (
2271 i * (style.level-height + style.gap),
2272 style.level-height,
2273 auto,
2274 auto,
2275 item
2276 ))
2277 } else if mode == "WIDTH" {
2278 let w = 0
2279 let last-val = 0
2280 levels = ()
2281 for (i, item) in enum-items {
2282 let val = item.first()
2283 let y = i * (style.level-height + style.gap)
2284 let h = style.level-height
2285 let y2 = y + h
2286 let w1 = w
2287 let w2 = if i == 0 {
2288 y2 / calc.sqrt(3)
2289 } else {
2290 w * val / last-val
2291 }
2292 w = w2
2293 last-val = val
2294 levels.push((
2295 y,
2296 h,
2297 w1,
2298 w2,
2299 item
2300 ))
2301 }
2302 } else if mode == "HEIGHT" {
2303 let smallest = calc.min(
2304 ..data.map(d => d.first())
2305 .filter(v => v != 0)
2306 )
2307
2308 let get-height(value) = {
2309 return value / smallest * style.level-height
2310 }
2311
2312 let y = 0
2313 for (i, item) in enum-items {
2314 let h = get-height(item.first())
2315 levels.push((
2316 y,
2317 h,
2318 auto,
2319 auto,
2320 item
2321 ))
2322 y += h + style.gap
2323 }
2324
2325 } else if mode == "AREA-HEIGHT" {
2326 let y = 0
2327 let get-area(y, h) = {
2328 return h * (2 *y + h) / 2
2329 }
2330 let h-for-area(y, area) = {
2331 /*
2332 A = (2yh + h²)/2
2333 2 * A = 2yh + h²
2334 h² + 2yh - 2A = 0
2335
2336 h = -y +- sqrt(y² + 2A)
2337 */
2338
2339 let delta = calc.sqrt(y * y + 2 * area)
2340 return delta - y
2341 }
2342
2343 let first-val = enum-items.first().last().first()
2344 let first-area = 1 / calc.sqrt(3)
2345 let thinnest = 1
2346
2347 for (i, item) in enum-items {
2348 let h = if i == 0 {
2349 1
2350 } else {
2351 let area = item.first() / first-val * first-area
2352 h-for-area(y, area)
2353 }
2354
2355 levels.push((
2356 y,
2357 h,
2358 auto,
2359 auto,
2360 item
2361 ))
2362 thinnest = calc.min(thinnest, h)
2363 y += h + gap / 100%
2364 }
2365
2366 let f = style.level-height / thinnest
2367
2368 levels = levels.map(((y, h, w1, w2, item)) => (
2369 y * f,
2370 h * f,
2371 w1,
2372 w2,
2373 item
2374 ))
2375 }
2376
2377 let anchors = ()
2378 draw.group(name: "chart", {
2379 draw.group(name: "levels", {
2380 for (i, level) in levels.enumerate() {
2381 let (
2382 y,
2383 h,
2384 width-top,
2385 width-bottom,
2386 (value, label)
2387 ) = level
2388
2389 let y2 = y + h
2390 if width-top == auto {
2391 width-top = y / calc.sqrt(3)
2392 }
2393 if width-bottom == auto {
2394 width-bottom = y2 / calc.sqrt(3)
2395 }
2396
2397 let stroke = style-at(i).at("stroke", default: style.stroke)
2398 let fill = style-at(i).at("fill", default: style.fill)
2399
2400 let lvl-name = str(i)
2401 draw.group(name: lvl-name, {
2402 draw.line(
2403 (-width-top, -y),
2404 (width-top, -y),
2405 (width-bottom, -y2),
2406 (-width-bottom, -y2),
2407 close: true,
2408 fill: fill,
2409 stroke: stroke
2410 )
2411 let my = -(y + y2)/2
2412 draw.anchor(
2413 "west",
2414 (-(width-top + width-bottom)/2, my)
2415 )
2416 draw.anchor(
2417 "east",
2418 ((width-top + width-bottom)/2, my)
2419 )
2420 draw.anchor("center", (0, my))
2421 draw.anchor("default", (0, my))
2422 })
2423
2424 let inner-label = get-item-label(
2425 (value, label),
2426 style.inner-label.content
2427 )
2428
2429 if inner-label != none {
2430 let my = if width-top == 0 {
2431 -y - 2*h/3
2432 } else {
2433 -y - h/2
2434 }
2435 let m = measure(inner-label)
2436 let rw = m.width / ctx.length
2437 let rh = m.height / ctx.length
2438 let rect = (
2439 x: -rw / 2,
2440 y: my + rh / 2,
2441 w: rw,
2442 h: rh
2443 )
2444 let trapezoid = (
2445 y1: -y,
2446 y2: -y2,
2447 x1: -width-top,
2448 x2: -width-bottom
2449 )
2450
2451 let mid = (0, my)
2452 if not style.inner-label.force-inside and rect-overlaps-trapezoid(rect, trapezoid) {
2453 let (anchor, f) = if calc.rem(i, 2) == 0 {
2454 ("west", 1)
2455 } else {
2456 ("east", -1)
2457 }
2458
2459 let dy = 0.1 * h
2460 let pt = (
2461 rel: (
2462 f * ((-my + dy) / calc.sqrt(3) + .5),
2463 dy
2464 ),
2465 to: mid
2466 )
2467
2468 draw.line(mid, pt)
2469 draw.content(
2470 pt,
2471 inner-label,
2472 anchor: anchor,
2473 padding: 3pt
2474 )
2475 } else {
2476 draw.content(
2477 mid,
2478 inner-label
2479 )
2480 }
2481 }
2482 }
2483 })
2484 if style.side-label.content != none {
2485 let side = style.side-label.side
2486 let corner = "levels.north-" + side
2487 for (i, item) in enum-items {
2488 let lvl-pt = "levels." + str(i)
2489 let pos = (lvl-pt, "-|", corner)
2490 draw.content(
2491 pos,
2492 get-item-label(item, style.side-label.content),
2493 anchor: ("west":"east","east":"west").at(side)
2494 )
2495 }
2496 }
2497 })
2498 })
2499}
2500#import "/src/cetz.typ": vector
2501
2502/// Clip a single component of a list of vectors between [min, max]
2503/// and returns a list of clipped shapes.
2504#let clip-component-axis(component, min, max, points) = {
2505 if points == () {
2506 return ()
2507 }
2508
2509 // List of tuples (index, ratio, inside)
2510 let intersections = ()
2511
2512 // Collect all intersections
2513 let inside = auto
2514 for (i, pt) in points.enumerate() {
2515 let v = pt.at(component, default: min)
2516 let this-inside = min <= v and v <= max
2517
2518 if inside == auto {
2519 inside = this-inside
2520 } else if inside != this-inside {
2521 inside = not inside
2522 if this-inside {
2523 intersections.push((i, (v - min) / (max - min), inside))
2524 } else {
2525 intersections.push((i, (v - min) / (max - min), inside))
2526 }
2527 }
2528 }
2529
2530 // Clip intersections
2531 if intersections != () {
2532 let shapes = ()
2533
2534 let start = none
2535 let start-ratio = none
2536 for (i, ratio, this-inside) in intersections {
2537 if this-inside {
2538 start = i
2539 start-ratio = ratio
2540 } else {
2541 let start-pt = if start == 0 or start == points.len() - 1 {
2542 points.at(start)
2543 } else {
2544 vector.lerp(points.at(start - 1), points.at(start), start-ratio)
2545 }
2546
2547 let end-pt = if i == points.len() - 1 {
2548 points.last()
2549 } else {
2550 vector.lerp(points.at(i - 1), points.at(i), ratio)
2551 }
2552
2553 shapes.push((
2554 start-pt, ..points.slice(start, i), end-pt,
2555 ))
2556 start = none
2557 }
2558 }
2559
2560 return shapes
2561 }
2562
2563 return (points,)
2564}
2565#import "/src/cetz.typ"
2566
2567#let grid(columns: 2, ..items) = {
2568 let items = items.pos()
2569 let valign = (horizon,) * columns
2570
2571 (ctx => {
2572 let rows = ()
2573 let cells = ()
2574
2575 for (i, item) in items.enumerate() {
2576 let (bounds: bounds, drawables: drawables, ..) = cetz.process.many(ctx, item)
2577
2578 let width = bounds.high.at(0) - bounds.low.at(0)
2579 let height = bounds.high.at(1) - bounds.low.at(1)
2580 cells.push(((width, height), drawables))
2581 }
2582
2583 let height = 0
2584 for i in range(0, items.len()) {
2585 let size = cells.at(i).at(0)
2586 height = calc.max(size.at(1), height)
2587 if calc.rem(i, columns) == 0{
2588 rows.push(height)
2589 height = 0
2590 }
2591 }
2592
2593 cells = cells.enumerate().map(((i, (size, cell))) => {
2594 let t = cetz.matrix.ident()
2595 let row = rows.at(int(i / columns))
2596
2597 t = cetz.matrix.transform-translate(0, -(row - size.at(1)) / 2, 0)
2598
2599 cetz.drawable.apply-transform(t, cell)
2600 })
2601
2602 return (
2603 ctx: ctx,
2604 drawables: cells.join(),
2605 )
2606 },)
2607}
2608#let version = version(0,1,1)
2609
2610#import "/src/axes.typ"
2611#import "/src/plot.typ"
2612#import "/src/chart.typ"
2613#import "/src/cetz.typ": util, draw, matrix, vector, styles, palette
2614#import util: bezier
2615
2616#import "/src/axes.typ"
2617#import "/src/plot/sample.typ": sample-fn, sample-fn2
2618#import "/src/plot/line.typ": add, add-hline, add-vline, add-fill-between
2619#import "/src/plot/contour.typ": add-contour
2620#import "/src/plot/boxwhisker.typ": add-boxwhisker
2621#import "/src/plot/util.typ" as plot-util
2622#import "/src/plot/legend.typ" as plot-legend
2623#import "/src/plot/annotation.typ": annotate, calc-annotation-domain
2624#import "/src/plot/bar.typ": add-bar
2625#import "/src/plot/errorbar.typ": add-errorbar
2626#import "/src/plot/mark.typ"
2627#import "/src/plot/violin.typ": add-violin
2628#import "/src/plot/formats.typ"
2629#import plot-legend: add-legend
2630
2631#let default-colors = (blue, red, green, yellow, black)
2632
2633#let default-plot-style(i) = {
2634 let color = default-colors.at(calc.rem(i, default-colors.len()))
2635 return (stroke: color,
2636 fill: color.lighten(75%))
2637}
2638
2639#let default-mark-style(i) = {
2640 return default-plot-style(i)
2641}
2642
2643/// Create a plot environment. Data to be plotted is given by passing it to the
2644/// `plot.add` or other plotting functions. The plot environment supports different
2645/// axis styles to draw, see its parameter `axis-style:`.
2646///
2647/// #example(```
2648/// plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
2649/// plot.add(((0,0), (1,1), (2,.5), (4,3)))
2650/// })
2651/// ```)
2652///
2653/// To draw elements insides a plot, using the plots coordinate system, use
2654/// the `plot.annotate(..)` function.
2655///
2656/// = parameters
2657///
2658/// = Options
2659///
2660/// You can use the following options to customize each axis of the plot. You must pass them as named arguments prefixed by the axis name followed by a dash (`-`) they should target. Example: `x-min: 0`, `y-ticks: (..)` or `x2-label: [..]`.
2661///
2662/// #show-parameter-block("label", ("none", "content"), default: "none", [
2663/// The axis' label. If and where the label is drawn depends on the `axis-style`.])
2664/// #show-parameter-block("min", ("auto", "float"), default: "auto", [
2665/// Axis lower domain value. If this is set greater than than `max`, the axis' direction is swapped])
2666/// #show-parameter-block("max", ("auto", "float"), default: "auto", [
2667/// Axis upper domain value. If this is set to a lower value than `min`, the axis' direction is swapped])
2668/// #show-parameter-block("equal", ("string"), default: "none", [
2669/// Set the axis domain to keep a fixed aspect ratio by multiplying the other axis domain by the plots aspect ratio,
2670/// depending on the other axis orientation (see `horizontal`).
2671/// This can be useful to force one axis to grow or shrink with another one.
2672/// You can only "lock" two axes of different orientations.
2673/// #example(```
2674/// plot.plot(size: (2,1), x-tick-step: 1, y-tick-step: 1,
2675/// x-equal: "y",
2676/// {
2677/// plot.add(domain: (0, 2 * calc.pi),
2678/// t => (calc.cos(t), calc.sin(t)))
2679/// })
2680/// ```)
2681/// ])
2682/// #show-parameter-block("horizontal", ("bool"), default: "axis name dependant", [
2683/// If true, the axis is considered an axis that gets drawn horizontally, vertically otherwise.
2684/// The default value depends on the axis name on axis creation. Axes which name start with `x` have this
2685/// set to `true`, all others have it set to `false`. Each plot has to use one horizontal and one
2686/// vertical axis for plotting, a combination of two y-axes will panic: ("y", "y2").
2687/// ])
2688/// #show-parameter-block("tick-step", ("none", "auto", "float"), default: "auto", [
2689/// The increment between tick marks on the axis. If set to `auto`, an
2690/// increment is determined. When set to `none`, incrementing tick marks are disabled.])
2691/// #show-parameter-block("minor-tick-step", ("none", "float"), default: "none", [
2692/// Like `tick-step`, but for minor tick marks. In contrast to ticks, minor ticks do not have labels.])
2693/// #show-parameter-block("ticks", ("none", "array"), default: "none", [
2694/// A List of custom tick marks to additionally draw along the axis. They can be passed as
2695/// an array of `<float>` values or an array of `(<float>, <content>)` tuples for
2696/// setting custom tick mark labels per mark.
2697///
2698/// #example(```
2699/// plot.plot(x-tick-step: none, y-tick-step: none,
2700/// x-min: 0, x-max: 4,
2701/// x-ticks: (1, 2, 3),
2702/// y-min: 1, y-max: 2,
2703/// y-ticks: ((1, [One]), (2, [Two])),
2704/// {
2705/// plot.add(((0,0),))
2706/// })
2707/// ```)
2708///
2709/// Examples: `(1, 2, 3)` or `((1, [One]), (2, [Two]), (3, [Three]))`])
2710/// #show-parameter-block("format", ("none", "string", "function"), default: "float", [
2711/// How to format the tick label: You can give a function that takes a `<float>` and return
2712/// `<content>` to use as the tick label. You can also give one of the predefined options:
2713/// / float: Floating point formatting rounded to two digits after the point (see `decimals`)
2714/// / sci: Scientific formatting with $times 10^n$ used as exponet syntax
2715///
2716/// #example(```
2717/// let formatter(v) = if v != 0 {$ #{v/calc.pi} pi $} else {$ 0 $}
2718/// plot.plot(x-tick-step: calc.pi, y-tick-step: none,
2719/// x-min: 0, x-max: 2 * calc.pi,
2720/// x-format: formatter,
2721/// {
2722/// plot.add(((0,0),))
2723/// })
2724/// ```)
2725/// ])
2726/// #show-parameter-block("decimals", ("int"), default: "2", [
2727/// Number of decimals digits to display for tick labels, if the format is set
2728/// to `"float"`.
2729/// ])
2730/// #show-parameter-block("mode", ("none", "string"), default: "none", [
2731/// The scaling function of the axis. Takes `lin` (default) for linear scaling,
2732/// and `log` for logarithmic scaling.])
2733/// #show-parameter-block("base", ("none", "number"), default: "none", [
2734/// The base to be used when labeling axis ticks in logarithmic scaling])
2735/// #show-parameter-block("grid", ("bool", "string"), default: "false", [
2736/// If `true` or `"major"`, show grid lines for all major ticks. If set
2737/// to `"minor"`, show grid lines for minor ticks only.
2738/// The value `"both"` enables grid lines for both, major- and minor ticks.
2739///
2740/// #example(```
2741/// plot.plot(x-tick-step: 1, y-tick-step: 1,
2742/// y-minor-tick-step: .2,
2743/// x-min: 0, x-max: 2, x-grid: true,
2744/// y-min: 0, y-max: 2, y-grid: "both", {
2745/// plot.add(((0,0),))
2746/// })
2747/// ```)
2748/// ])
2749/// #show-parameter-block("break", ("bool"), default: "false", [
2750/// If true, add a "sawtooth" at the start or end of the axis line, depending
2751/// on the axis bounds. If the axis min. value is > 0, a sawtooth is added
2752/// to the start of the axes, if the axis max. value is < 0, a sawtooth is added
2753/// to its end.])
2754///
2755/// - body (body): Calls of `plot.add` or `plot.add-*` commands. Note that normal drawing
2756/// commands like `line` or `rect` are not allowed inside the plots body, instead wrap
2757/// them in `plot.annotate`, which lets you select the axes used for drawing.
2758/// - size (array): Plot size tuple of `(<width>, <height>)` in canvas units.
2759/// This is the plots inner plotting size without axes and labels.
2760/// - axis-style (none, string): How the axes should be styled:
2761/// / scientific: Frames plot area using a rectangle and draw axes `x` (bottom), `y` (left), `x2` (top), and `y2` (right) around it.
2762/// If `x2` or `y2` are unset, they mirror their opposing axis.
2763/// / scientific-auto: Draw set (used) axes `x` (bottom), `y` (left), `x2` (top) and `y2` (right) around
2764/// the plotting area, forming a rect if all axes are in use or a L-shape if only `x` and `y` are in use.
2765/// / school-book: Draw axes `x` (horizontal) and `y` (vertical) as arrows pointing to the right/top with both crossing at $(0, 0)$
2766/// / left: Draw axes `x` and `y` as arrows, while the y axis stays on the left (at `x.min`)
2767/// and the x axis at the bottom (at `y.min`)
2768/// / `none`: Draw no axes (and no ticks).
2769///
2770/// #example(```
2771/// let opts = (x-tick-step: none, y-tick-step: none, size: (2,1))
2772/// let data = plot.add(((-1,-1), (1,1),), mark: "o")
2773///
2774/// for name in (none, "school-book", "left", "scientific") {
2775/// plot.plot(axis-style: name, ..opts, data, name: "plot")
2776/// content(((0,-1), "-|", "plot.south"), repr(name))
2777/// set-origin((3.5,0))
2778/// }
2779/// ```, vertical: true)
2780/// - plot-style (style,function): Styling to use for drawing plot graphs.
2781/// This style gets inherited by all plots and supports `palette` functions.
2782/// The following style keys are supported:
2783/// #show-parameter-block("stroke", ("none", "stroke"), default: 1pt, [
2784/// Stroke style to use for stroking the graph.
2785/// ])
2786/// #show-parameter-block("fill", ("none", "paint"), default: none, [
2787/// Paint to use for filled graphs. Note that not all graphs may support filling and
2788/// that you may have to enable filling per graph, see `plot.add(fill: ..)`.
2789/// ])
2790/// - mark-style (style,function): Styling to use for drawing plot marks.
2791/// This style gets inherited by all plots and supports `palette` functions.
2792/// The following style keys are supported:
2793/// #show-parameter-block("stroke", ("none", "stroke"), default: 1pt, [
2794/// Stroke style to use for stroking the mark.
2795/// ])
2796/// #show-parameter-block("fill", ("none", "paint"), default: none, [
2797/// Paint to use for filling marks.
2798/// ])
2799/// - fill-below (bool): If true, the filled shape of plots is drawn _below_ axes.
2800/// - name (string): The plots element name to be used when referring to anchors
2801/// - legend (none, auto, coordinate): The position the legend will be drawn at. See plot-legends for information about legends. If set to `<auto>`, the legend's "default-placement" styling will be used. If set to a `<coordinate>`, it will be taken as relative to the plot's origin.
2802/// - legend-anchor (auto, string): Anchor of the legend group to use as its origin.
2803/// If set to `auto` and `lengend` is one of the predefined legend anchors, the
2804/// opposite anchor to `legend` gets used.
2805/// - legend-style (style): Style key-value overwrites for the legend style with style root `legend`.
2806/// - ..options (any): Axis options, see _options_ below.
2807#let plot(body,
2808 size: (1, 1),
2809 axis-style: "scientific",
2810 name: none,
2811 plot-style: default-plot-style,
2812 mark-style: default-mark-style,
2813 fill-below: true,
2814 legend: auto,
2815 legend-anchor: auto,
2816 legend-style: (:),
2817 ..options
2818 ) = draw.group(name: name, ctx => {
2819 draw.assert-version(version(0, 3, 1))
2820
2821 // Create plot context object
2822 let make-ctx(x, y, size) = {
2823 assert(x != none, message: "X axis does not exist")
2824 assert(y != none, message: "Y axis does not exist")
2825 assert(size.at(0) > 0 and size.at(1) > 0, message: "Plot size must be > 0")
2826
2827 let x-scale = ((x.max - x.min) / size.at(0))
2828 let y-scale = ((y.max - y.min) / size.at(1))
2829
2830 if y.horizontal {
2831 (x-scale, y-scale) = (y-scale, x-scale)
2832 }
2833
2834 return (x: x, y: y, size: size, x-scale: x-scale, y-scale: y-scale)
2835 }
2836
2837 // Setup data viewport
2838 let data-viewport(data, x, y, size, body, name: none) = {
2839 if body == none or body == () { return }
2840
2841 assert.ne(x.horizontal, y.horizontal,
2842 message: "Data must use one horizontal and one vertical axis!")
2843
2844 // If y is the horizontal axis, swap x and y
2845 // coordinates by swapping the transformation
2846 // matrix columns.
2847 if y.horizontal {
2848 (x, y) = (y, x)
2849 body = draw.set-ctx(ctx => {
2850 ctx.transform = matrix.swap-cols(ctx.transform, 0, 1)
2851 return ctx
2852 }) + body
2853 }
2854
2855 // Setup the viewport
2856 axes.axis-viewport(size, x, y, none, body, name: name)
2857 }
2858
2859 let data = ()
2860 let anchors = ()
2861 let annotations = ()
2862 let body = if body != none { body } else { () }
2863
2864 for cmd in body {
2865 assert(type(cmd) == dictionary and "type" in cmd,
2866 message: "Expected plot sub-command in plot body")
2867 if cmd.type == "anchor" {
2868 anchors.push(cmd)
2869 } else if cmd.type == "annotation" {
2870 annotations.push(cmd)
2871 } else { data.push(cmd) }
2872 }
2873
2874 assert(axis-style in (none, "scientific", "scientific-auto", "school-book", "left"),
2875 message: "Invalid plot style")
2876
2877 // Create axes for data & annotations
2878 let axis-dict = (:)
2879 for d in data + annotations {
2880 if "axes" not in d { continue }
2881
2882 for (i, name) in d.axes.enumerate() {
2883 if not name in axis-dict {
2884 axis-dict.insert(name, axes.axis(
2885 min: none, max: none))
2886 }
2887
2888 let axis = axis-dict.at(name)
2889 let domain = if i == 0 {
2890 d.at("x-domain", default: (none, none))
2891 } else {
2892 d.at("y-domain", default: (none, none))
2893 }
2894 if domain != (none, none) {
2895 axis.min = util.min(axis.min, ..domain)
2896 axis.max = util.max(axis.max, ..domain)
2897 }
2898
2899 axis-dict.at(name) = axis
2900 }
2901 }
2902
2903 // Create axes for anchors
2904 for a in anchors {
2905 for (i, name) in a.axes.enumerate() {
2906 if not name in axis-dict {
2907 axis-dict.insert(name, axes.axis(min: none, max: none))
2908 }
2909 }
2910 }
2911
2912 // Adjust axis bounds for annotations
2913 for a in annotations {
2914 let (x, y) = a.axes.map(name => axis-dict.at(name))
2915 (x, y) = calc-annotation-domain(ctx, x, y, a)
2916 axis-dict.at(a.axes.at(0)) = x
2917 axis-dict.at(a.axes.at(1)) = y
2918 }
2919
2920 // Set axis options
2921 axis-dict = plot-util.setup-axes(ctx, axis-dict, options.named(), size)
2922
2923 // Prepare styles
2924 for i in range(data.len()) {
2925 if "style" not in data.at(i) { continue }
2926
2927 let style-base = plot-style
2928 if type(style-base) == function {
2929 style-base = (style-base)(i)
2930 }
2931 assert.eq(type(style-base), dictionary,
2932 message: "plot-style must be of type dictionary")
2933
2934 if type(data.at(i).style) == function {
2935 data.at(i).style = (data.at(i).style)(i)
2936 }
2937 assert.eq(type(style-base), dictionary,
2938 message: "data plot-style must be of type dictionary")
2939
2940 data.at(i).style = util.merge-dictionary(
2941 style-base, data.at(i).style)
2942
2943 if "mark-style" in data.at(i) {
2944 let mark-style-base = mark-style
2945 if type(mark-style-base) == function {
2946 mark-style-base = (mark-style-base)(i)
2947 }
2948 assert.eq(type(mark-style-base), dictionary,
2949 message: "mark-style must be of type dictionary")
2950
2951 if type(data.at(i).mark-style) == function {
2952 data.at(i).mark-style = (data.at(i).mark-style)(i)
2953 }
2954
2955 if type(data.at(i).mark-style) == dictionary {
2956 data.at(i).mark-style = util.merge-dictionary(
2957 mark-style-base,
2958 data.at(i).mark-style
2959 )
2960 }
2961 }
2962 }
2963
2964 draw.group(name: "plot", {
2965 draw.anchor("origin", (0, 0))
2966
2967 // Prepare
2968 for i in range(data.len()) {
2969 if "axes" not in data.at(i) { continue }
2970
2971 let (x, y) = data.at(i).axes.map(name => axis-dict.at(name))
2972 let plot-ctx = make-ctx(x, y, size)
2973
2974 if "plot-prepare" in data.at(i) {
2975 data.at(i) = (data.at(i).plot-prepare)(data.at(i), plot-ctx)
2976 assert(data.at(i) != none,
2977 message: "Plot prepare(self, cxt) returned none!")
2978 }
2979 }
2980
2981 // Background Annotations
2982 for a in annotations.filter(a => a.background) {
2983 let (x, y) = a.axes.map(name => axis-dict.at(name))
2984 let plot-ctx = make-ctx(x, y, size)
2985
2986 data-viewport(a, x, y, size, {
2987 draw.anchor("default", (0, 0))
2988 a.body
2989 })
2990 }
2991
2992 // Fill
2993 if fill-below {
2994 for d in data {
2995 if "axes" not in d { continue }
2996
2997 let (x, y) = d.axes.map(name => axis-dict.at(name))
2998 let plot-ctx = make-ctx(x, y, size)
2999
3000 data-viewport(d, x, y, size, {
3001 draw.anchor("default", (0, 0))
3002 draw.set-style(..d.style)
3003
3004 if "plot-fill" in d {
3005 (d.plot-fill)(d, plot-ctx)
3006 }
3007 })
3008 }
3009 }
3010
3011 if axis-style in ("scientific", "scientific-auto") {
3012 let draw-unset = if axis-style == "scientific" {
3013 true
3014 } else {
3015 false
3016 }
3017
3018 let mirror = if axis-style == "scientific" {
3019 auto
3020 } else {
3021 none
3022 }
3023
3024 axes.scientific(
3025 size: size,
3026 draw-unset: draw-unset,
3027 bottom: axis-dict.at("x", default: none),
3028 top: axis-dict.at("x2", default: mirror),
3029 left: axis-dict.at("y", default: none),
3030 right: axis-dict.at("y2", default: mirror),)
3031 } else if axis-style == "left" {
3032 axes.school-book(
3033 size: size,
3034 axis-dict.x,
3035 axis-dict.y,
3036 x-position: axis-dict.y.min,
3037 y-position: axis-dict.x.min)
3038 } else if axis-style == "school-book" {
3039 axes.school-book(
3040 size: size,
3041 axis-dict.x,
3042 axis-dict.y,)
3043 }
3044
3045 // Stroke + Mark data
3046 for d in data {
3047 if "axes" not in d { continue }
3048
3049 let (x, y) = d.axes.map(name => axis-dict.at(name))
3050 let plot-ctx = make-ctx(x, y, size)
3051
3052 data-viewport(d, x, y, size, {
3053 draw.anchor("default", (0, 0))
3054 draw.set-style(..d.style)
3055
3056 if not fill-below and "plot-fill" in d {
3057 (d.plot-fill)(d, plot-ctx)
3058 }
3059 if "plot-stroke" in d {
3060 (d.plot-stroke)(d, plot-ctx)
3061 }
3062 })
3063
3064 if "mark" in d and d.mark != none {
3065 draw.scope({
3066 if y.horizontal {
3067 draw.set-ctx(ctx => {
3068 ctx.transform = matrix.swap-cols(ctx.transform, 0, 1)
3069 return ctx
3070 })
3071 }
3072
3073 draw.set-style(..d.style, ..d.mark-style)
3074 mark.draw-mark(d.data, x, y, d.mark, d.mark-size, size)
3075 })
3076 }
3077 }
3078
3079 // Foreground Annotations
3080 for a in annotations.filter(a => not a.background) {
3081 let (x, y) = a.axes.map(name => axis-dict.at(name))
3082 let plot-ctx = make-ctx(x, y, size)
3083
3084 data-viewport(a, x, y, size, {
3085 draw.anchor("default", (0, 0))
3086 a.body
3087 })
3088 }
3089
3090 // Place anchors
3091 for a in anchors {
3092 let (x, y) = a.axes.map(name => axis-dict.at(name))
3093 let plot-ctx = make-ctx(x, y, size)
3094
3095 let pt = a.position.enumerate().map(((i, v)) => {
3096 if v == "min" { return axis-dict.at(a.axes.at(i)).min }
3097 if v == "max" { return axis-dict.at(a.axes.at(i)).max }
3098 return v
3099 })
3100 pt = axes.transform-vec(size, x, y, none, pt)
3101 if pt != none {
3102 draw.anchor(a.name, pt)
3103 }
3104 }
3105 })
3106
3107 // Draw the legend
3108 if legend != none {
3109 let items = data.filter(d => "label" in d and d.label != none)
3110 if items.len() > 0 {
3111 let legend-style = styles.resolve(ctx.style,
3112 base: plot-legend.default-style, merge: legend-style, root: "legend")
3113
3114 plot-legend.add-legend-anchors(legend-style, "plot", size)
3115 plot-legend.legend(legend, anchor: legend-anchor, {
3116 for item in items {
3117 let preview = if "plot-legend-preview" in item {
3118 _ => {(item.plot-legend-preview)(item) }
3119 } else {
3120 auto
3121 }
3122
3123 plot-legend.item(item.label, preview,
3124 mark: item.at("mark", default: none),
3125 mark-size: item.at("mark-size", default: none),
3126 mark-style: item.at("mark-style", default: none),
3127 ..item.at("style", default: (:)))
3128 }
3129 }, ..legend-style)
3130 }
3131 }
3132
3133 draw.copy-anchors("plot")
3134})
3135
3136/// Add an anchor to a plot environment
3137///
3138/// This function is similar to `draw.anchor` but it takes an additional
3139/// axis tuple to specify which axis coordinate system to use.
3140///
3141/// #example(```
3142/// plot.plot(size: (2,2), name: "plot",
3143/// x-tick-step: none, y-tick-step: none, {
3144/// plot.add(((0,0), (1,1), (2,.5), (4,3)))
3145/// plot.add-anchor("pt", (1,1))
3146/// })
3147///
3148/// line("plot.pt", ((), "|-", (0,1.5)), mark: (start: ">"), name: "line")
3149/// content("line.end", [Here], anchor: "south", padding: .1)
3150/// ```)
3151///
3152/// - name (string): Anchor name
3153/// - position (tuple): Tuple of x and y values.
3154/// Both values can have the special values "min" and
3155/// "max", which resolve to the axis min/max value.
3156/// Position is in axis space defined by the axes passed to `axes`.
3157/// - axes (tuple): Name of the axes to use `("x", "y")` as coordinate
3158/// system for `position`. Note that both axes must be used,
3159/// as `add-anchors` does not create them on demand.
3160#let add-anchor(name, position, axes: ("x", "y")) = {
3161 ((
3162 type: "anchor",
3163 name: name,
3164 position: position,
3165 axes: axes,
3166 ),)
3167}
3168#import "/src/cetz.typ"
3169#import cetz: draw, process, util, matrix
3170#import "util.typ"
3171#import "sample.typ"
3172
3173/// Add an annotation to the plot
3174///
3175/// An annotation is a sub-canvas that uses the plots coordinates specified
3176/// by its x and y axis.
3177///
3178/// #example(```
3179/// plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
3180/// plot.add(domain: (0, 2*calc.pi), calc.sin)
3181/// plot.annotate({
3182/// rect((0, -1), (calc.pi, 1), fill: rgb(50,50,200,50))
3183/// content((calc.pi, 0), [Here])
3184/// })
3185/// })
3186/// ```)
3187///
3188/// Bounds calculation is done naively, therefore fixed size content _can_ grow
3189/// out of the plot. You can adjust the padding manually to adjust for that. The
3190/// feature of solving the correct bounds for fixed size elements might be added
3191/// in the future.
3192///
3193/// - body (drawable): Elements to draw
3194/// - axes (axes): X and Y axis names
3195/// - resize (bool): If true, the plots axes get adjusted to contain the annotation
3196/// - padding (none,number,dictionary): Annotation padding that is used for axis
3197/// adjustment
3198/// - background (bool): If true, the annotation is drawn behind all plots, in the background.
3199/// If false, the annotation is drawn above all plots.
3200#let annotate(body, axes: ("x", "y"), resize: true, padding: none, background: false) = {
3201 ((
3202 type: "annotation",
3203 body: {
3204 draw.set-style(mark: (transform-shape: false))
3205 body;
3206 },
3207 axes: axes,
3208 resize: resize,
3209 background: background,
3210 padding: cetz.util.as-padding-dict(padding),
3211 ),)
3212}
3213
3214// Returns the adjusted axes for the annotation object
3215//
3216// -> array Tuple of x and y axis
3217#let calc-annotation-domain(ctx, x, y, annotation) = {
3218 if not annotation.resize {
3219 return (x, y)
3220 }
3221
3222 ctx.transform = matrix.ident()
3223 let (ctx: ctx, bounds: bounds, drawables: _) = process.many(ctx, annotation.body)
3224 if bounds == none {
3225 return (x, y)
3226 }
3227
3228 let (x-min, y-min, ..) = bounds.low
3229 let (x-max, y-max, ..) = bounds.high
3230
3231 x-min -= annotation.padding.left
3232 x-max += annotation.padding.right
3233 y-min -= annotation.padding.bottom
3234 y-max += annotation.padding.top
3235
3236 x.min = calc.min(x.min, x-min)
3237 x.max = calc.max(x.max, x-max)
3238 y.min = calc.min(y.min, y-min)
3239 y.max = calc.max(y.max, y-max)
3240
3241 return (x, y)
3242}
3243#import "/src/cetz.typ": draw, util
3244
3245#import "errorbar.typ": draw-errorbar
3246
3247#let _transform-row(row, x-key, y-key, error-key) = {
3248 let x = row.at(x-key)
3249 let y = if y-key == auto {
3250 row.slice(1)
3251 } else if type(y-key) == array {
3252 y-key.map(k => row.at(k, default: 0))
3253 } else {
3254 row.at(y-key, default: 0)
3255 }
3256 let err = if error-key == none {
3257 0
3258 } else if type(error-key) == array {
3259 error-key.map(k => row.at(k, default: 0))
3260 } else {
3261 row.at(error-key, default: 0)
3262 }
3263
3264 if type(y) != array { y = (y,) }
3265 if type(err) != array { err = (err,) }
3266
3267 (x, y.flatten(), err.flatten())
3268}
3269
3270// Get a single items min and maximum y-value
3271#let _minmax-value(row) = {
3272 let min = none
3273 let max = none
3274
3275 let y = row.at(1)
3276 let e = row.at(2)
3277 for i in range(0, y.len()) {
3278 let i-min = y.at(i) - e.at(i, default: 0)
3279 if min == none { min = i-min }
3280 else { min = calc.min(min, i-min) }
3281
3282 let i-max = y.at(i) + e.at(i, default: 0)
3283 if max == none { max = i-max }
3284 else { max = calc.max(max, i-max) }
3285 }
3286
3287 return (min: min, max: max)
3288}
3289
3290// Functions for max value calculation
3291#let _max-value-fn = (
3292 basic: (data, min: 0) => {
3293 calc.max(min, ..data.map(t => _minmax-value(t).max))
3294 },
3295 clustered: (data, min: 0) => {
3296 calc.max(min, ..data.map(t => _minmax-value(t).max))
3297 },
3298 stacked: (data, min: 0) => {
3299 calc.max(min, ..data.map(t => t.at(1).sum()))
3300 },
3301 stacked100: (.., min: 0) => {min + 100}
3302)
3303
3304// Functions for min value calculation
3305#let _min-value-fn = (
3306 basic: (data, min: 0) => {
3307 calc.min(min, ..data.map(t => _minmax-value(t).min))
3308 },
3309 clustered: (data, min: 0) => {
3310 calc.min(min, ..data.map(t => _minmax-value(t).min))
3311 },
3312 stacked: (data, min: 0) => {
3313 calc.min(min, ..data.map(t => t.at(1).sum()))
3314 },
3315 stacked100: (.., min: 0) => {min}
3316)
3317
3318#let _prepare(self, ctx) = {
3319 return self
3320}
3321
3322#let _get-x-offset(position, width) = {
3323 if position == "start" { 0 }
3324 else if position == "end" { width }
3325 else { width / 2 }
3326}
3327
3328#let _draw-rects(filling, self, ctx, ..args) = {
3329 let x-axis = ctx.x
3330 let y-axis = ctx.y
3331
3332 let bars = ()
3333 let errors = ()
3334
3335 let w = self.bar-width
3336 for d in self.data {
3337 let (x, n, len, y-min, y-max, err) = d
3338
3339 let w = self.bar-width
3340 let gap = self.cluster-gap * if w > 0 { -1 } else { +1 }
3341 w += gap * (len - 1)
3342
3343 let x-offset = _get-x-offset(self.bar-position, self.bar-width)
3344 x-offset += gap * n
3345
3346 let left = x - x-offset
3347 let right = left + w
3348 let width = (right - left) / len
3349
3350 if self.mode in ("basic", "clustered") {
3351 left = left + width * n
3352 right = left + width
3353 }
3354
3355 if (left <= x-axis.max and right >= x-axis.min and
3356 y-min <= y-axis.max and y-max >= y-axis.min) {
3357 left = calc.max(left, x-axis.min)
3358 right = calc.min(right, x-axis.max)
3359 y-min = calc.max(y-min, y-axis.min)
3360 y-max = calc.min(y-max, y-axis.max)
3361
3362 draw.rect((left, y-min), (right, y-max))
3363
3364 if not filling and err != 0 {
3365 let y-whisker-size = self.whisker-size * ctx.x-scale
3366 draw-errorbar(((left + right) / 2, y-max),
3367 0, err, 0, y-whisker-size / 2, self.style + self.error-style)
3368 }
3369 }
3370 }
3371}
3372
3373#let _stroke(self, ctx) = {
3374 _draw-rects(false, self, ctx, fill: none)
3375}
3376
3377#let _fill(self, ctx) = {
3378 _draw-rects(true, self, ctx, stroke: none)
3379}
3380
3381/// Add a bar- or column-chart to the plot
3382///
3383/// A bar- or column-chart is a chart where values are drawn as rectangular boxes.
3384///
3385/// - data (array): Array of data items. An item is an array containing a x an one or more y values.
3386/// For example `(0, 1)` or `(0, 10, 5, 30)`. Depending on the `mode`, the data items
3387/// get drawn as either clustered or stacked rects.
3388/// - x-key: (int,string): Key to use for retrieving a bars x-value from a single data entry.
3389/// This value gets passed to the `.at(...)` function of a data item.
3390/// - y-key: (auto,int,string,array): Key to use for retrieving a bars y-value. For clustered/stacked
3391/// data, this must be set to a list of keys (e.g. `range(1, 4)`). If set to `auto`, att but the first
3392/// array-values of a data item are used as y-values.
3393/// - error-key: (none,int,string,array): Key(s) to use for retrieving a bars y-error.
3394/// - mode (string): The mode on how to group data items into bars:
3395/// / basic: Add one bar per data value. If the data contains multiple values,
3396/// group those bars next to each other.
3397/// / clustered: Like "basic", but take into account the maximum number of values of all items
3398/// and group each cluster of bars together having the width of the widest cluster.
3399/// / stacked: Stack bars of subsequent item values onto the previous bar, generating bars
3400/// with the height of the sume of all an items values.
3401/// / stacked100: Like "stacked", but scale each bar to height $100$, making the different
3402/// bars percentages of the sum of an items values.
3403/// - labels (none,content,array): A single legend label for "basic" bar-charts, or a
3404/// a list of legend labels per bar category, if the mode is one of "clustered", "stacked" or "stacked100".
3405/// - bar-width (float): Width of one data item on the y axis
3406/// - bar-position (string): Positioning of data items relative to their x value.
3407/// - "start": The lower edge of the data item is on the x value (left aligned)
3408/// - "center": The data item is centered on the x value
3409/// - "end": The upper edge of the data item is on the x value (right aligned)
3410/// - cluster-gap (float): Spacing between bars insides a cluster.
3411/// - style (dictionary): Plot style
3412/// - axes (axes): Plot axes. To draw a horizontal growing bar chart, you can swap the x and y axes.
3413#let add-bar(data,
3414 x-key: 0,
3415 y-key: auto,
3416 error-key: none,
3417 mode: "basic",
3418 labels: none,
3419 bar-width: 1,
3420 bar-position: "center",
3421 cluster-gap: 0,
3422 whisker-size: .25,
3423 error-style: (:),
3424 style: (:),
3425 axes: ("x", "y")) = {
3426 assert(mode in ("basic", "clustered", "stacked", "stacked100"),
3427 message: "Mode must be basic, clustered, stacked or stacked100, but is " + mode)
3428 assert(bar-position in ("start", "center", "end"),
3429 message: "Invalid bar-position '" + bar-position + "'. Allowed values are: start, center, end")
3430 assert(bar-width != 0,
3431 message: "Option bar-width must be != 0, but is " + str(bar-width))
3432 if error-key != none {
3433 assert(y-key != auto,
3434 message: "Bar value-key must be set != auto if error-key is set")
3435 assert(mode in ("basic", "clustered"),
3436 message: "Error bars are supported for basic or clustered only, got " + mode)
3437 }
3438
3439 // Transform data to (x, y, error) triplets
3440 let data = data.map(row => _transform-row(row, x-key, y-key, error-key))
3441
3442 let n = util.max(..data.map(d => d.at(1).len()))
3443 let x-offset = _get-x-offset(bar-position, bar-width)
3444 let x-domain = (util.min(..data.map(d => d.at(0))) - x-offset,
3445 util.max(..data.map(d => d.at(0))) - x-offset + bar-width)
3446 let y-domain = (_min-value-fn.at(mode)(data),
3447 _max-value-fn.at(mode)(data))
3448
3449 // For stacked 100%, multiply each column/bar
3450 if mode == "stacked100" {
3451 data = data.map(((x, y, err)) => {
3452 let f = 100 / y.sum()
3453 return (x, y.map(v => v * f), err)
3454 })
3455 }
3456
3457 // Transform data from (x, ..y) to (x, n, len, y-min, y-max) per y
3458 let stacked = mode in ("stacked", "stacked100")
3459 let clustered = mode == "clustered"
3460 let bar-data = if mode == "basic" {
3461 range(0, data.len()).map(_ => ())
3462 } else {
3463 range(0, n).map(_ => ())
3464 }
3465
3466 let j = 0
3467 for (x, y, err) in data {
3468 let len = if clustered { n } else { y.len() }
3469 let sum = 0
3470 for (i, y) in y.enumerate() {
3471 let err = err.at(i, default: 0)
3472 if stacked {
3473 bar-data.at(i).push((x, i, len, sum, sum + y, err))
3474 } else if clustered {
3475 bar-data.at(i).push((x, i, len, 0, y, err))
3476 } else {
3477 bar-data.at(j).push((x, i, len, 0, y, err))
3478 }
3479 sum += y
3480 }
3481 j += 1
3482 }
3483
3484 let labels = if type(labels) == array { labels } else { (labels,) }
3485 range(0, bar-data.len()).map(i => (
3486 type: "bar",
3487 label: labels.at(i, default: none),
3488 axes: axes,
3489 mode: mode,
3490 data: bar-data.at(i),
3491 x-domain: x-domain,
3492 y-domain: y-domain,
3493 style: style,
3494 bar-width: bar-width,
3495 bar-position: bar-position,
3496 cluster-gap: cluster-gap,
3497 whisker-size: whisker-size,
3498 error-style: error-style,
3499 plot-prepare: _prepare,
3500 plot-stroke: _stroke,
3501 plot-fill: _fill,
3502 plot-legend-preview: self => {
3503 draw.rect((0,0), (1,1), ..self.style)
3504 }
3505 ))
3506}
3507#import "/src/cetz.typ": draw, util
3508
3509/// Add one or more box or whisker plots
3510///
3511/// #example(```
3512/// plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
3513/// plot.add-boxwhisker((x: 1, // Location on x-axis
3514/// outliers: (7, 65, 69), // Optional outlier values
3515/// min: 15, max: 60, // Minimum and maximum
3516/// q1: 25, // Quartiles: Lower
3517/// q2: 35, // Median
3518/// q3: 50)) // Upper
3519/// })
3520/// ```)
3521///
3522/// - data (array, dictionary): dictionary or array of dictionaries containing the
3523/// needed entries to plot box and whisker plot.
3524///
3525/// The following fields are supported:
3526/// - `x` (number) X-axis value
3527/// - `min` (number) Minimum value
3528/// - `max` (number) Maximum value
3529/// - `q1`, `q2`, `q3` (number) Quartiles from lower to to upper
3530/// - `outliers` (array of number) Optional outliers
3531///
3532/// - axes (array): Name of the axes to use ("x", "y"), note that not all
3533/// plot styles are able to display a custom axis!
3534/// - style (style): Style to use, can be used with a palette function
3535/// - box-width (float): Width from edge-to-edge of the box of the box and whisker in plot units. Defaults to 0.75
3536/// - whisker-width (float): Width from edge-to-edge of the whisker of the box and whisker in plot units. Defaults to 0.5
3537/// - mark (string): Mark to use for plotting outliers. Set `none` to disable. Defaults to "x"
3538/// - mark-size (float): Size of marks for plotting outliers. Defaults to 0.15
3539/// - label (none,content): Legend label to show for this plot.
3540#let add-boxwhisker(data,
3541 label: none,
3542 axes: ("x", "y"),
3543 style: (:),
3544 box-width: 0.75,
3545 whisker-width: 0.5,
3546 mark: "*",
3547 mark-size: 0.15) = {
3548 // Add multiple boxes as multiple calls to
3549 // add-boxwhisker
3550 if type(data) == array {
3551 for it in data {
3552 add-boxwhisker(
3553 it,
3554 axes:axes,
3555 style: style,
3556 box-width: box-width,
3557 whisker-width: whisker-width,
3558 mark: mark,
3559 mark-size: mark-size)
3560 }
3561 return
3562 }
3563
3564 assert("x" in data, message: "Specify 'x', the x value at which to display the box and whisker")
3565 assert("q1" in data, message: "Specify 'q1', the lower quartile")
3566 assert("q2" in data, message: "Specify 'q2', the median")
3567 assert("q3" in data, message: "Specify 'q3', the upper quartile")
3568 assert("min" in data, message: "Specify 'min', the minimum excluding outliers")
3569 assert("max" in data, message: "Specify 'max', the maximum excluding outliers")
3570 assert(data.q1 <= data.q2 and data.q2 <= data.q3,
3571 message: "The quartiles q1, q2 and q3 must follow q1 < q2 < q3")
3572 assert(data.min <= data.q1 and data.max >= data.q2,
3573 message: "The minimum and maximum must be <= q1 and >= q3")
3574
3575 // Y domain
3576 let max-value = util.max(data.max, ..data.at("outliers", default: ()))
3577 let min-value = util.min(data.min, ..data.at("outliers", default: ()))
3578
3579 let prepare(self, ctx) = {
3580 return self
3581 }
3582
3583 let stroke(self, ctx) = {
3584 let data = self.bw-data
3585
3586 // Box
3587 draw.rect((data.x - box-width / 2, data.q1),
3588 (data.x + box-width / 2, data.q3),
3589 ..self.style)
3590
3591 // Mean
3592 draw.line((data.x - box-width / 2, data.q2),
3593 (data.x + box-width / 2, data.q2),
3594 ..self.style)
3595
3596 // whiskers
3597 let whisker(x, start, end) = {
3598 draw.line((x, start),(x, end),..self.style)
3599 draw.line((x - whisker-width / 2, end),(x + whisker-width / 2, end), ..self.style)
3600 }
3601 whisker(data.x, data.q3, data.max)
3602 whisker(data.x, data.q1, data.min)
3603 }
3604
3605 ((
3606 type: "boxwhisker",
3607 label: label,
3608 axes: axes,
3609 bw-data: data,
3610 style: style,
3611 plot-prepare: prepare,
3612 plot-stroke: stroke,
3613 x-domain: (data.x - calc.max(whisker-width, box-width),
3614 data.x + calc.max(whisker-width, box-width)),
3615 y-domain: (min-value, max-value),
3616 ) + (if "outliers" in data { (
3617 type: "boxwhisker-outliers",
3618 data: data.outliers.map(it => (data.x, it)),
3619 axes: axes,
3620 mark: mark,
3621 mark-size: mark-size,
3622 mark-style: (:)
3623 ) }),)
3624}
3625#import "/src/cetz.typ": draw
3626
3627#import "util.typ"
3628#import "sample.typ"
3629
3630// Find contours of a 2D array by using marching squares algorithm
3631//
3632// - data (array): A 2D array of floats where the first index is the row and the second index is the column
3633// - offset (float): Z value threshold of a cell compare with `op` to, to count as true
3634// - op (auto,string,function): Z value comparison oparator:
3635// / `">", ">=", "<", "<=", "!=", "=="`: Use the passed operator to compare z.
3636// / `auto`: Use ">=" for positive z values, "<=" for negative z values.
3637// / `<function>`: If set to a function, that function gets called
3638// with two arguments, the z value `z1` to compare against and
3639// the z value `z2` of the data and must return a boolean: `(z1, z2) => boolean`.
3640// - interpolate (bool): Enable cell interpolation for smoother lines
3641// - contour-limit (int): Contour limit after which the algorithm panics
3642// -> array: Array of contour point arrays
3643#let find-contours(data, offset, op: auto, interpolate: true, contour-limit: 50) = {
3644 assert(data != none and type(data) == array,
3645 message: "Data must be of type array")
3646 assert(type(offset) in (int, float),
3647 message: "Offset must be numeric")
3648
3649 let n-rows = data.len()
3650 let n-cols = data.at(0).len()
3651 if n-rows < 2 or n-cols < 2 {
3652 return ()
3653 }
3654
3655 assert(op == auto or type(op) in (str, function),
3656 message: "Operator must be of type auto, string or function")
3657 if op == auto {
3658 op = if offset < 0 { "<=" } else { ">=" }
3659 }
3660 if type(op) == str {
3661 assert(op in ("<", "<=", ">", ">=", "==", "!="),
3662 message: "Operator must be one of: <, <=, >, >=, != or ==")
3663 }
3664
3665 // Return if data is set
3666 let is-set = if type(op) == function {
3667 v => op(offset, v)
3668 } else if op == "==" {
3669 v => v == offset
3670 } else if op == "!=" {
3671 v => v != offset
3672 } else if op == "<" {
3673 v => v < offset
3674 } else if op == "<=" {
3675 v => v <= offset
3676 } else if op == ">" {
3677 v => v > offset
3678 } else if op == ">=" {
3679 v => v >= offset
3680 }
3681
3682 // Build a binary map that has 0 for unset and 1 for set cells
3683 let bin-data = data.map(r => r.map(is-set))
3684
3685 // Get binary data at x, y
3686 let get-bin(x, y) = {
3687 if x >= 0 and x < n-cols and y >= 0 and y < n-rows {
3688 return bin-data.at(y).at(x)
3689 }
3690 return false
3691 }
3692
3693 // Get data point for x, y coordinate
3694 let get-data(x, y) = {
3695 if x >= 0 and x < n-cols and y >= 0 and y < n-rows {
3696 return float(data.at(y).at(x))
3697 }
3698 return none
3699 }
3700
3701 // Get case (0 to 15)
3702 let get-case(tl, tr, bl, br) = {
3703 int(tl) * 8 + int(tr) * 4 + int(br) * 2 + int(bl)
3704 }
3705
3706 let lerp(a, b) = {
3707 if a == b { return a }
3708 else if a == none { return 1 }
3709 else if b == none { return 0 }
3710 return (offset - a) / (b - a)
3711 }
3712
3713 // List of all found contours
3714 let contours = ()
3715
3716 let segments = ()
3717 for y in range(-1, n-rows) {
3718 for x in range(-1, n-cols) {
3719 let tl = get-bin(x, y)
3720 let tr = get-bin(x+1, y)
3721 let bl = get-bin(x, y+1)
3722 let br = get-bin(x+1, y+1)
3723
3724 // Corner data
3725 //
3726 // nw-----ne
3727 // | |
3728 // | |
3729 // | |
3730 // sw-----se
3731 let nw = get-data(x, y)
3732 let ne = get-data(x+1, y)
3733 let se = get-data(x+1, y+1)
3734 let sw = get-data(x, y+1)
3735
3736 // Interpolated edge points
3737 //
3738 // +-- a --+
3739 // | |
3740 // d b
3741 // | |
3742 // +-- c --+
3743 let a = (x + .5, y)
3744 let b = (x + 1, y + .5)
3745 let c = (x + .5, y + 1)
3746 let d = (x, y + .5)
3747 if interpolate {
3748 a = (x + lerp(nw, ne), y)
3749 b = (x + 1, y + lerp(ne, se))
3750 c = (x + lerp(sw, se), y + 1)
3751 d = (x, y + lerp(nw, sw))
3752 }
3753
3754 let case = get-case(tl, tr, bl, br)
3755 if case in (1, 14) {
3756 segments.push((d, c))
3757 } else if case in (2, 13) {
3758 segments.push((b, c))
3759 } else if case in (3, 12) {
3760 segments.push((d, b))
3761 } else if case in (4, 11) {
3762 segments.push((a, b))
3763 } else if case == 5 {
3764 segments.push((d, a))
3765 segments.push((c, b))
3766 } else if case in (6, 9) {
3767 segments.push((c, a))
3768 } else if case in (7, 8) {
3769 segments.push((d, a))
3770 } else if case == 10 {
3771 segments.push((a, b))
3772 segments.push((c, d))
3773 }
3774 }
3775 }
3776
3777 // Join lines to one or more contours
3778 // This is done by searching for the next line
3779 // that starts at the current contours head or tail
3780 // point. If found, push the other coordinate to
3781 // the contour. If no line could be found, push a
3782 // new contour.
3783 let contours = ()
3784 while segments.len() > 0 {
3785 if contours.len() == 0 {
3786 contours.push(segments.remove(0))
3787 }
3788
3789 let found = false
3790
3791 let i = 0
3792 while i < segments.len() {
3793 let (a, b) = segments.at(i)
3794 let (h, t) = (contours.last().first(),
3795 contours.last().last())
3796 if a == t {
3797 contours.last().push(b)
3798 segments.remove(i)
3799 found = true
3800 } else if b == t {
3801 contours.last().push(a)
3802 segments.remove(i)
3803 found = true
3804 } else if a == h {
3805 contours.last().insert(0, b)
3806 segments.remove(i)
3807 found = true
3808 } else if b == h {
3809 contours.last().insert(0, a)
3810 segments.remove(i)
3811 found = true
3812 } else {
3813 i += 1
3814 }
3815 }
3816
3817 // Insert the next contour
3818 if not found {
3819 contours.push(segments.remove(0))
3820 }
3821
3822 // Check limit
3823 assert(contours.len() <= contour-limit,
3824 message: "Countour limit reached! Raise contour-limit if you " +
3825 "think this is not an error")
3826 }
3827
3828 return contours
3829}
3830
3831// Prepare line data
3832#let _prepare(self, ctx) = {
3833 let (x, y) = (ctx.x, ctx.y)
3834
3835 self.contours = self.contours.map(c => {
3836 c.stroke-paths = util.compute-stroke-paths(c.line-data, x, y)
3837
3838 if self.fill {
3839 c.fill-paths = util.compute-fill-paths(c.line-data, x, y)
3840 }
3841 return c
3842 })
3843
3844 return self
3845}
3846
3847// Stroke line data
3848#let _stroke(self, ctx) = {
3849 for c in self.contours {
3850 for p in c.stroke-paths {
3851 draw.line(..p, fill: none, close: p.first() == p.last())
3852 }
3853 }
3854}
3855
3856// Fill line data
3857#let _fill(self, ctx) = {
3858 if not self.fill { return }
3859 for c in self.contours {
3860 for p in c.fill-paths {
3861 draw.line(..p, stroke: none, close: p.first() == p.last())
3862 }
3863 }
3864}
3865
3866/// Add a contour plot of a sampled function or a matrix.
3867///
3868/// #example(```
3869/// plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
3870/// plot.add-contour(x-domain: (-3, 3), y-domain: (-3, 3),
3871/// style: (fill: rgb(50,50,250,50)),
3872/// fill: true,
3873/// op: "<", // Find contours where data < z
3874/// z: (2.5, 2, 1), // Z values to find contours for
3875/// (x, y) => calc.sqrt(x * x + y * y))
3876/// })
3877/// ```)
3878///
3879/// - data (array, function): A function of the signature `(x, y) => z`
3880/// or an array of arrays of floats (a matrix) where the first
3881/// index is the row and the second index is the column.
3882/// - z (float, array): Z values to plot. Contours containing values
3883/// above z (z >= 0) or below z (z < 0) get plotted.
3884/// If you specify multiple z values, they get plotted in the order of specification.
3885/// - x-domain (domain): X axis domain used if `data` is a function, that is the
3886/// domain inside the function gets sampled.
3887/// - y-domain (domain): Y axis domain used if `data` is a function, see `x-domain`.
3888/// - x-samples (int): X axis domain samples (2 < n). Note that contour finding
3889/// can be quite slow. Using a big sample count can improve accuracy but can
3890/// also lead to bad compilation performance.
3891/// - y-samples (int): Y axis domain samples (2 < n)
3892/// - interpolate (bool): Use linear interpolation between sample values which can
3893/// improve the resulting plot, especially if the contours are curved.
3894/// - op (auto,string,function): Z value comparison oparator:
3895/// / `">", ">=", "<", "<=", "!=", "=="`: Use the operator for comparison of `z` to
3896/// the values from `data`.
3897/// / `auto`: Use ">=" for positive z values, "<=" for negative z values.
3898/// / `<function>`: Call comparison function of the format `(plot-z, data-z) => boolean`,
3899/// where `plot-z` is the z-value from the plots `z` argument and `data-z`
3900/// is the z-value of the data getting plotted. The function must return true
3901/// if at the combinations of arguments a contour is detected.
3902/// - fill (bool): Fill each contour
3903/// - style (style): Style to use for plotting, can be used with a palette function. Note
3904/// that all z-levels use the same style!
3905/// - axes (axes): Name of the axes to use for plotting.
3906/// - limit (int): Limit of contours to create per z value before the function panics
3907/// - label (none,content): Plot legend label to show. The legend preview for
3908/// contour plots is a little rectangle drawn with the contours style.
3909#let add-contour(data,
3910 label: none,
3911 z: (1,),
3912 x-domain: (0, 1),
3913 y-domain: (0, 1),
3914 x-samples: 25,
3915 y-samples: 25,
3916 interpolate: true,
3917 op: auto,
3918 axes: ("x", "y"),
3919 style: (:),
3920 fill: false,
3921 limit: 50,
3922 ) = {
3923 // Sample a x/y function
3924 if type(data) == function {
3925 data = sample.sample-fn2(data,
3926 x-domain, y-domain,
3927 x-samples, y-samples)
3928 }
3929
3930 // Find matrix dimensions
3931 assert(type(data) == array)
3932 let (x-min, x-max) = x-domain
3933 let dx = (x-max - x-min) / (data.at(0).len() - 1)
3934 let (y-min, y-max) = y-domain
3935 let dy = (y-max - y-min) / (data.len() - 1)
3936
3937 let contours = ()
3938 let z = if type(z) == array { z } else { (z,) }
3939 for z in z {
3940 for contour in find-contours(data, z, op: op, interpolate: interpolate, contour-limit: limit) {
3941 let line-data = contour.map(pt => {
3942 (pt.at(0) * dx + x-min,
3943 pt.at(1) * dy + y-min)
3944 })
3945
3946 contours.push((
3947 z: z,
3948 line-data: line-data,
3949 ))
3950 }
3951 }
3952
3953 return ((
3954 type: "contour",
3955 label: label,
3956 contours: contours,
3957 axes: axes,
3958 x-domain: x-domain,
3959 y-domain: y-domain,
3960 style: style,
3961 fill: fill,
3962 mark: none,
3963 mark-style: none,
3964 plot-prepare: _prepare,
3965 plot-stroke: _stroke,
3966 plot-fill: _fill,
3967 plot-legend-preview: self => {
3968 if not self.fill { self.style.fill = none }
3969 draw.rect((0,0), (1,1), ..self.style)
3970 }
3971 ),)
3972}
3973#import "/src/cetz.typ": draw, util, vector
3974
3975#let _draw-whisker(pt, dir, ..style) = {
3976 let a = vector.add(pt, vector.scale(dir, -1))
3977 let b = vector.add(pt, vector.scale(dir, +1))
3978
3979 draw.line(a, b, ..style)
3980}
3981
3982#let draw-errorbar(pt, x, y, x-whisker-size, y-whisker-size, style) = {
3983 if type(x) != array { x = (-x, x) }
3984 if type(y) != array { y = (-y, y) }
3985
3986 let (x-min, x-max) = x
3987 let x-min-pt = vector.add(pt, (x-min, 0))
3988 let x-max-pt = vector.add(pt, (x-max, 0))
3989 if x-min != 0 or x-max != 0 {
3990 draw.line(x-min-pt, x-max-pt, ..style)
3991 if x-whisker-size > 0 {
3992 if x-min != 0 {
3993 _draw-whisker(x-min-pt, (0, x-whisker-size), ..style)
3994 }
3995 if x-max != 0 {
3996 _draw-whisker(x-max-pt, (0, x-whisker-size), ..style)
3997 }
3998 }
3999 }
4000
4001 let (y-min, y-max) = y
4002 let y-min-pt = vector.add(pt, (0, y-min))
4003 let y-max-pt = vector.add(pt, (0, y-max))
4004 if y-min != 0 or y-max != 0 {
4005 draw.line(y-min-pt, y-max-pt, ..style)
4006 if y-whisker-size > 0 {
4007 if y-min != 0 {
4008 _draw-whisker(y-min-pt, (y-whisker-size, 0), ..style)
4009 }
4010 if y-max != 0 {
4011 _draw-whisker(y-max-pt, (y-whisker-size, 0), ..style)
4012 }
4013 }
4014 }
4015}
4016
4017#let _prepare(self, ctx) = {
4018 return self
4019}
4020
4021#let _stroke(self, ctx) = {
4022 let x-whisker-size = self.whisker-size * ctx.y-scale
4023 let y-whisker-size = self.whisker-size * ctx.x-scale
4024
4025 draw-errorbar((self.x, self.y),
4026 self.x-error, self.y-error,
4027 x-whisker-size, y-whisker-size,
4028 self.style)
4029}
4030
4031/// Add x- and/or y-error bars
4032///
4033/// - pt (tuple): Error-bar center coordinate tuple: `(x, y)`
4034/// - x-error: (float,tuple): Single error or tuple of errors along the x-axis
4035/// - y-error: (float,tuple): Single error or tuple of errors along the y-axis
4036/// - mark: (none,string): Mark symbol to show at the error position (`pt`).
4037/// - mark-size: (number): Size of the mark symbol.
4038/// - mark-style: (style): Extra style to apply to the mark symbol.
4039/// - whisker-size (float): Width of the error bar whiskers in canvas units.
4040/// - style (dictionary): Style for the error bars
4041/// - label: (none,content): Label to tsh
4042/// - axes (axes): Plot axes. To draw a horizontal growing bar chart, you can swap the x and y axes.
4043#let add-errorbar(pt,
4044 x-error: 0,
4045 y-error: 0,
4046 label: none,
4047 mark: "o",
4048 mark-size: .2,
4049 mark-style: (:),
4050 whisker-size: .5,
4051 style: (:),
4052 axes: ("x", "y")) = {
4053 assert(x-error != 0 or y-error != 0,
4054 message: "Either x-error or y-error must be set.")
4055
4056 let (x, y) = pt
4057
4058 if type(x-error) != array {
4059 x-error = (x-error, x-error)
4060 }
4061 if type(y-error) != array {
4062 y-error = (y-error, y-error)
4063 }
4064
4065 x-error.at(0) = calc.abs(x-error.at(0)) * -1
4066 y-error.at(0) = calc.abs(y-error.at(0)) * -1
4067
4068 let x-domain = x-error.map(v => v + x)
4069 let y-domain = y-error.map(v => v + y)
4070
4071 return ((
4072 type: "errorbar",
4073 label: label,
4074 axes: axes,
4075 data: ((x,y),),
4076 x: x,
4077 y: y,
4078 x-error: x-error,
4079 y-error: y-error,
4080 x-domain: x-domain,
4081 y-domain: y-domain,
4082 mark: mark,
4083 mark-size: mark-size,
4084 mark-style: mark-style,
4085 whisker-size: whisker-size,
4086 style: style,
4087 plot-prepare: _prepare,
4088 plot-stroke: _stroke,
4089 ),)
4090}
4091// Compare two floats
4092#let _compare(a, b, eps: 1e-6) = {
4093 return calc.abs(a - b) <= eps
4094}
4095
4096// Pre-computed table of fractions
4097#let _common-denoms = range(2, 11 + 1).map(d => {
4098 (d, range(1, d).map(n => n/d))
4099})
4100
4101#let _find-fraction(v, denom: auto, eps: 1e-6) = {
4102 let i = calc.floor(v)
4103 let f = v - i
4104 if _compare(f, 0, eps: eps) {
4105 return $#v$
4106 }
4107
4108 let denom = if denom != auto {
4109 for n in range(1, denom) {
4110 if _compare(f, n/denom, eps: eps) {
4111 denom
4112 }
4113 }
4114 } else {
4115 (() => {
4116 for ((denom, tab)) in _common-denoms {
4117 for vv in tab {
4118 if _compare(f, vv, eps: eps) {
4119 return denom
4120 }
4121 }
4122 }
4123 })()
4124 }
4125
4126 if denom != none {
4127 return if v < 0 { $-$ } else {} + $#calc.round(calc.abs(v) * denom)/#denom$
4128 }
4129}
4130
4131/// Fraction tick formatter
4132///
4133/// ```example
4134/// plot.plot(size: (5,1),
4135/// x-format: plot.formats.fraction,
4136/// x-tick-step: 1/5,
4137/// y-tick-step: none, {
4138/// plot.add(calc.sin, domain: (-1, 1))
4139/// })
4140/// ```
4141///
4142/// - value (number): Value to format
4143/// - denom (auto, int): Denominator for result fractions. If set to `auto`,
4144/// a hardcoded fraction table is used for finding fractions with a
4145/// denominator <= 11.
4146/// - eps (number): Epsilon used for comparison
4147/// -> Content if a matching fraction could be found or none
4148#let fraction(value, denom: auto, eps: 1e-6) = {
4149 return _find-fraction(value, denom: denom, eps: eps)
4150}
4151
4152/// Multiple of tick formatter
4153///
4154/// ```example
4155/// plot.plot(size: (5,1),
4156/// x-format: plot.formats.multiple-of,
4157/// x-tick-step: calc.pi/4,
4158/// y-tick-step: none, {
4159/// plot.add(calc.sin, domain: (-calc.pi, 1.5 * calc.pi))
4160/// })
4161/// ```
4162///
4163/// - value (number): Value to format
4164/// - factor (number): Factor value is expected to be a multiple of.
4165/// - symbol (content): Suffix symbol. For `value` = 0, the symbol is not
4166/// appended.
4167/// - fraction (none, true, int): If not none, try finding matching fractions
4168/// using the same mechanism as `fraction`. If set to an integer, that integer
4169/// is used as denominator. If set to `none` or `false`, or if no fraction
4170/// could be found, a real number with `digits` digits is used.
4171/// - digits (int): Number of digits to use for rounding
4172/// - eps (number): Epsilon used for comparison
4173/// - prefix (content): Content to prefix
4174/// - suffix (content): Content to append
4175/// -> Content if a matching fraction could be found or none
4176#let multiple-of(value, factor: calc.pi, symbol: $pi$, fraction: true, digits: 2, eps: 1e-6, prefix: [], suffix: []) = {
4177 if _compare(value, 0, eps: eps) {
4178 return $0$
4179 }
4180
4181 let a = value / factor
4182 if _compare(a, 1, eps: eps) {
4183 return prefix + symbol + suffix
4184 } else if _compare(a, -1, eps: eps) {
4185 return prefix + $-$ + symbol + suffix
4186 }
4187
4188 if fraction != none {
4189 let frac = _find-fraction(a, denom: if fraction == true { auto } else { fraction })
4190 if frac != none {
4191 return prefix + frac + symbol + suffix
4192 }
4193 }
4194
4195 return prefix + $#calc.round(a, digits: digits)$ + symbol + suffix
4196}
4197
4198/// Scientific notation tick formatter
4199///
4200/// ```example
4201/// plot.plot(size: (5,1),
4202/// x-format: plot.formats.sci,
4203/// x-tick-step: 1e3,
4204/// y-tick-step: none, {
4205/// plot.add(x => x, domain: (-2e3, 2e3))
4206/// })
4207/// ```
4208///
4209/// - value (number): Value to format
4210/// - digits (int): Number of digits for rounding the factor
4211/// - prefix (content): Content to prefix
4212/// - suffix (content): Content to append
4213/// -> Content
4214#let sci(value, digits: 2, prefix: [], suffix: []) = {
4215 let exponent = if value != 0 {
4216 calc.floor(calc.log(calc.abs(value), base: 10))
4217 } else {
4218 0
4219 }
4220
4221 let ee = calc.pow(10, calc.abs(exponent + 1))
4222 if exponent > 0 {
4223 value = value / ee * 10
4224 } else if exponent < 0 {
4225 value = value * ee * 10
4226 }
4227
4228 value = calc.round(value, digits: digits)
4229 if exponent <= -1 or exponent >= 1 {
4230 return prefix + $#value times 10^#exponent$ + suffix
4231 }
4232
4233 return prefix + $#value$ + suffix
4234}
4235
4236/// Rounded decimal number formatter
4237///
4238/// ```example
4239/// plot.plot(size: (5,1),
4240/// x-format: plot.formats.decimal,
4241/// x-tick-step: .5,
4242/// y-tick-step: none, {
4243/// plot.add(x => x, domain: (-1, 1))
4244/// })
4245/// ```
4246///
4247/// - value (number): Value to format
4248/// - digits (int): Number of digits to round to
4249/// - prefix (content): Content to prefix
4250/// - suffix (content): Content to append
4251/// -> Content
4252#let decimal(value, digits: 2, prefix: [], suffix: []) = {
4253 prefix + $#calc.round(value, digits: digits)$ + suffix
4254}
4255#import "/src/cetz.typ"
4256#import cetz: draw, styles
4257#import draw: group
4258
4259#import "mark.typ": draw-mark-shape
4260
4261#let default-style = (
4262 orientation: ttb,
4263 default-position: "north-east",
4264 layer: 1, // Legend layer
4265 fill: rgb(255,255,255,200), // Legend background
4266 stroke: black, // Legend border
4267 padding: .1, // Legend border padding
4268 offset: (0, 0), // Legend displacement
4269 spacing: .1, // Spacing between anchor and legend
4270 item: (
4271 radius: 0,
4272 spacing: 0, // Extra spacing between items
4273 preview: (
4274 width: .75, // Preview width
4275 height: .3, // Preview height
4276 margin: .1 // Distance between preview and label
4277 )
4278 ),
4279 radius: 0,
4280 scale: 100%,
4281)
4282
4283// Map position to legend group anchor
4284#let auto-group-anchor = (
4285 inner-north-west: "north-west",
4286 inner-north: "north",
4287 inner-north-east: "north-east",
4288 inner-south-west: "south-west",
4289 inner-south: "south",
4290 inner-south-east: "south-east",
4291 inner-west: "west",
4292 inner-east: "east",
4293 north-west: "north-east",
4294 north: "south",
4295 north-east: "north-west",
4296 south-west: "south-east",
4297 south: "north",
4298 south-east: "south-west",
4299 east: "west",
4300 west: "east",
4301)
4302
4303// Generate legend positioning anchors
4304#let add-legend-anchors(style, element, size) = {
4305 import draw: *
4306 let (w, h) = size
4307 let (xo, yo) = {
4308 let spacing = style.at("spacing", default: (0, 0))
4309 if type(spacing) == array {
4310 spacing
4311 } else {
4312 (spacing, spacing)
4313 }
4314 }
4315
4316 anchor("north", (rel: (w / 2, yo), to: (element + ".north", "-|", element + ".origin")))
4317 anchor("south", (rel: (w / 2, -yo), to: (element + ".south", "-|", element + ".origin")))
4318 anchor("east", (rel: (xo, h / 2), to: (element + ".east", "|-", element + ".origin")))
4319 anchor("west", (rel: (-xo, h / 2), to: (element + ".west", "|-", element + ".origin")))
4320 anchor("north-east", (rel: (xo, h), to: (element + ".north-east", "|-", element + ".origin")))
4321 anchor("north-west", (rel: (-xo, h), to: (element + ".north-west", "|-", element + ".origin")))
4322 anchor("south-east", (rel: (xo, 0), to: (element + ".south-east", "|-", element + ".origin")))
4323 anchor("south-west", (rel: (-xo, 0), to: (element + ".south-west", "|-", element + ".origin")))
4324 anchor("inner-north", (rel: (w / 2, h - yo), to: element + ".origin"))
4325 anchor("inner-north-east", (rel: (w - xo, h - yo), to: element + ".origin"))
4326 anchor("inner-north-west", (rel: (yo, h - yo), to: element + ".origin"))
4327 anchor("inner-south", (rel: (w / 2, yo), to: element + ".origin"))
4328 anchor("inner-south-east", (rel: (w - xo, yo), to: element + ".origin"))
4329 anchor("inner-south-west", (rel: (xo, yo), to: element + ".origin"))
4330 anchor("inner-east", (rel: (w - xo, h / 2), to: element + ".origin"))
4331 anchor("inner-west", (rel: (xo, h / 2), to: element + ".origin"))
4332}
4333
4334// Draw a generic item preview
4335#let draw-generic-preview(item) = {
4336 import draw: *
4337
4338 if item.at("fill", default: false) {
4339 rect((0,0), (1,1), ..item.style)
4340 } else {
4341 line((0,.5), (1,.5), ..item.style)
4342 }
4343}
4344
4345/// Construct a legend item for use with the `legend` function
4346///
4347/// - label (none, auto, content): Legend label or auto to use the enumerated default label
4348/// - preview (auto, function): Legend preview icon function of the format `item => elements`.
4349/// Note that the canvas bounds for drawing the preview are (0,0) to (1,1).
4350/// - mark: (none,string): Legend mark symbol
4351/// - mark-style: (none,dictionary): Mark style
4352/// - mark-size: (number): Mark size
4353/// - ..style (styles): Style keys for the single item
4354#let item(label, preview, mark: none, mark-style: (:), mark-size: 1, ..style) = {
4355 assert.eq(style.pos().len(), 0,
4356 message: "Unexpected positional arguments")
4357 return ((label: label, preview: preview,
4358 mark: mark, mark-style: mark-style, mark-size: mark-size,
4359 style: style.named()),)
4360}
4361
4362/// Draw a legend
4363#let legend(position, items, name: "legend", ..style) = group(name: name, ctx => {
4364 draw.anchor("default", ())
4365 let items = if items != none { items.filter(v => v.label != none) } else { () }
4366 if items == () {
4367 return
4368 }
4369
4370 let style = styles.resolve(
4371 ctx.style, merge: style.named(), base: default-style, root: "legend")
4372 assert(style.orientation in (ttb, ltr),
4373 message: "Unsupported legend orientation.")
4374
4375 // Scaling
4376 draw.scale(style.scale)
4377
4378 // Position
4379 let position = if position == auto {
4380 style.default-position
4381 } else {
4382 position
4383 }
4384
4385 // Adjust anchor
4386 if style.anchor == auto {
4387 style.anchor = if type(position) == str {
4388 auto-group-anchor.at(position, default: "north-west")
4389 } else {
4390 "north-west"
4391 }
4392 }
4393
4394 // Apply offset
4395 if style.offset not in (none, (0,0)) {
4396 position = (rel: style.offset, to: position)
4397 }
4398
4399 // Draw items
4400 draw.on-layer(style.layer, {
4401 draw.group(name: "items", padding: style.padding, ctx => {
4402 import draw: *
4403
4404 set-origin(position)
4405 anchor("default", (0,0))
4406
4407 let pt = (0, 0)
4408 for (i, item) in items.enumerate() {
4409 let (label, preview) = item
4410 if label == none {
4411 continue
4412 } else if label == auto {
4413 label = $ f_(#i) $
4414 }
4415
4416 group({
4417 anchor("default", (0,0))
4418
4419 let row-height = style.item.preview.height
4420 let preview-width = style.item.preview.width
4421 let preview-a = (0, -row-height / 2)
4422 let preview-b = (preview-width, +row-height / 2)
4423 let label-west = (preview-width + style.item.preview.margin, 0)
4424
4425 // Draw item preview
4426 let draw-preview = if preview == auto { draw-generic-preview } else { preview }
4427 scope({
4428 set-viewport(preview-a, preview-b, bounds: (1, 1, 0))
4429 (draw-preview)(item)
4430 })
4431
4432 // Draw mark preview
4433 let mark = item.at("mark", default: none)
4434 if mark != none {
4435 draw-mark-shape((preview-a, 50%, preview-b),
4436 calc.min(style.item.preview.width / 2, item.mark-size),
4437 mark,
4438 item.mark-style)
4439 }
4440
4441 // Draw label
4442 content(label-west,
4443 text(top-edge: "ascender", bottom-edge: "descender", align(left + horizon, label)),
4444 name: "label", anchor: "west")
4445 }, name: "item", anchor: if style.orientation == ltr { "west" } else { "north-west" })
4446
4447 if style.orientation == ttb {
4448 set-origin((rel: (0, -style.item.spacing),
4449 to: "item.south-west"))
4450 } else if style.orientation == ltr {
4451 set-origin((rel: (style.item.spacing, 0),
4452 to: "item.east"))
4453 }
4454 }
4455 }, anchor: style.anchor)
4456 })
4457
4458 // Fill legend background
4459 draw.on-layer(style.layer - .5, {
4460 draw.rect("items.south-west",
4461 "items.north-east", fill: style.fill, stroke: style.stroke, radius: style.radius)
4462 })
4463})
4464
4465/// Function for manually adding a legend item from within
4466/// a plot environment
4467///
4468/// - label (content): Legend label
4469/// - preview (auto,function): Legend preview function of the format `() => elements`.
4470/// The preview canvas bounds are between (0,0) and (1,1).
4471/// If set to `auto`, a straight line is drawn.
4472///
4473/// ```example
4474/// plot.plot(size: (1,1), x-tick-step: none, y-tick-step: none, {
4475/// plot.add(((0,0), (1,1))) // Some data
4476/// plot.add-legend([Custom item], preview: () => {
4477/// import cetz.draw: *
4478/// circle((.5,.5), radius: .5) // Draw a custom preview
4479/// // between (0,0) and (1,1)
4480/// })
4481/// plot.add-legend([Another item])
4482/// })
4483/// ```
4484#let add-legend(label, preview: auto) = {
4485 assert(preview == auto or type(preview) == function,
4486 message: "Expected auto or function, got " + repr(type(preview)))
4487
4488 return ((
4489 type: "legend-item",
4490 label: label,
4491 style: (:),
4492 axes: ("x", "y"),
4493 ) + if preview != auto {
4494 (plot-legend-preview: _ => { preview() })
4495 },)
4496}
4497#import "/src/cetz.typ": draw
4498
4499#import "util.typ"
4500#import "sample.typ"
4501
4502// Transform points
4503//
4504// - data (array): Data points
4505// - line (str,dictionary): Line line
4506#let transform-lines(data, line) = {
4507 let hvh-data(t) = {
4508 if type(t) == ratio {
4509 t = t / 1%
4510 }
4511 t = calc.max(0, calc.min(t, 1))
4512
4513 let pts = ()
4514
4515 let len = data.len()
4516 for i in range(0, len) {
4517 pts.push(data.at(i))
4518
4519 if i < len - 1 {
4520 let (a, b) = (data.at(i), data.at(i+1))
4521 if t == 0 {
4522 pts.push((a.at(0), b.at(1)))
4523 } else if t == 1 {
4524 pts.push((b.at(0), a.at(1)))
4525 } else {
4526 let x = a.at(0) + (b.at(0) - a.at(0)) * t
4527 pts.push((x, a.at(1)))
4528 pts.push((x, b.at(1)))
4529 }
4530 }
4531 }
4532 return pts
4533 }
4534
4535 if type(line) == str {
4536 line = (type: line)
4537 }
4538
4539 let line-type = line.at("type", default: "raw")
4540 assert(line-type in ("raw", "linear", "spline", "vh", "hv", "hvh"))
4541
4542 // Transform data into line-data
4543 let line-data = if line-type == "linear" {
4544 return util.linearized-data(data, line.at("epsilon", default: 0))
4545 } else if line-type == "spline" {
4546 return util.sampled-spline-data(data,
4547 line.at("tension", default: .5),
4548 line.at("samples", default: 15))
4549 } else if line-type == "vh" {
4550 return hvh-data(0)
4551 } else if line-type == "hv" {
4552 return hvh-data(1)
4553 } else if line-type == "hvh" {
4554 return hvh-data(line.at("mid", default: .5))
4555 } else {
4556 return data
4557 }
4558}
4559
4560// Fill a plot by generating a fill path to y value `to`
4561#let fill-segments-to(segments, to) = {
4562 for s in segments {
4563 let low = calc.min(..s.map(v => v.at(0)))
4564 let high = calc.max(..s.map(v => v.at(0)))
4565
4566 let origin = (low, to)
4567 let target = (high, to)
4568
4569 draw.line(origin, ..s, target, stroke: none)
4570 }
4571}
4572
4573// Fill a shape by generating a fill path for each segment
4574#let fill-shape(paths) = {
4575 for p in paths {
4576 draw.line(..p, stroke: none)
4577 }
4578}
4579
4580// Prepare line data
4581#let _prepare(self, ctx) = {
4582 let (x, y) = (ctx.x, ctx.y)
4583
4584 // Generate stroke paths
4585 self.stroke-paths = util.compute-stroke-paths(self.line-data, x, y)
4586
4587 // Compute fill paths if filling is requested
4588 self.hypograph = self.at("hypograph", default: false)
4589 self.epigraph = self.at("epigraph", default: false)
4590 self.fill = self.at("fill", default: false)
4591 if self.hypograph or self.epigraph or self.fill {
4592 self.fill-paths = util.compute-fill-paths(self.line-data, x, y)
4593 }
4594
4595 return self
4596}
4597
4598// Stroke line data
4599#let _stroke(self, ctx) = {
4600 let (x, y) = (ctx.x, ctx.y)
4601
4602 for p in self.stroke-paths {
4603 draw.line(..p, fill: none)
4604 }
4605}
4606
4607// Fill line data
4608#let _fill(self, ctx) = {
4609 let (x, y) = (ctx.x, ctx.y)
4610
4611 if self.hypograph {
4612 fill-segments-to(self.fill-paths, y.min)
4613 }
4614 if self.epigraph {
4615 fill-segments-to(self.fill-paths, y.max)
4616 }
4617 if self.fill {
4618 if self.at("fill-type", default: "axis") == "shape" {
4619 fill-shape(self.fill-paths)
4620 } else {
4621 fill-segments-to(self.fill-paths,
4622 calc.max(calc.min(y.max, 0), y.min))
4623 }
4624 }
4625}
4626
4627/// Add data to a plot environment.
4628///
4629/// Note: You can use this for scatter plots by setting
4630/// the stroke style to `none`: `add(..., style: (stroke: none))`.
4631///
4632/// Must be called from the body of a `plot(..)` command.
4633///
4634/// - domain (domain): Domain of `data`, if `data` is a function. Has no effect
4635/// if `data` is not a function.
4636/// - hypograph (bool): Fill hypograph; uses the `hypograph` style key for
4637/// drawing
4638/// - epigraph (bool): Fill epigraph; uses the `epigraph` style key for
4639/// drawing
4640/// - fill (bool): Fill the shape of the plot
4641/// - fill-type (string): Fill type:
4642/// / `"axis"`: Fill the shape to y = 0
4643/// / `"shape"`: Fill the complete shape
4644/// - samples (int): Number of times the `data` function gets called for
4645/// sampling y-values. Only used if `data` is of type function. This parameter gets
4646/// passed onto `sample-fn`.
4647/// - sample-at (array): Array of x-values the function gets sampled at in addition
4648/// to the default sampling. This parameter gets passed to `sample-fn`.
4649/// - line (string, dictionary): Line type to use. The following types are
4650/// supported:
4651/// / `"raw"`: Plot raw data
4652/// / `"linear"`: Linearize data
4653/// / `"spline"`: Calculate a Catmull-Rom curve through all points
4654/// / `"vh"`: Move vertical and then horizontal
4655/// / `"hv"`: Move horizontal and then vertical
4656/// / `"hvh"`: Add a vertical step in the middle
4657///
4658/// If the value is a dictionary, the type must be
4659/// supplied via the `type` key. The following extra
4660/// attributes are supported:
4661/// / `"samples" <int>`: Samples of splines
4662/// / `"tension" <float>`: Tension of splines
4663/// / `"mid" <float>`: Mid-Point of hvh lines (0 to 1)
4664/// / `"epsilon" <float>`: Linearization slope epsilon for
4665/// use with `"linear"`, defaults to 0.
4666///
4667/// #example(```
4668/// let points(offset: 0) = ((0,0), (1,1), (2,0), (3,1), (4,0)).map(((x,y)) => {
4669/// (x,y + offset * 1.5)
4670/// })
4671/// plot.plot(size: (12, 3), axis-style: none, {
4672/// plot.add(points(offset: 5), line: (type: "hvh", mid: .1))
4673/// plot.add(points(offset: 4), line: "hvh")
4674/// plot.add(points(offset: 3), line: "hv")
4675/// plot.add(points(offset: 2), line: "vh")
4676/// plot.add(points(offset: 1), line: "spline")
4677/// plot.add(points(offset: 0), line: "linear")
4678/// })
4679/// ```, vertical: true)
4680///
4681/// - style (style): Style to use, can be used with a `palette` function
4682/// - axes (axes): Name of the axes to use for plotting. Reversing the axes
4683/// means rotating the plot by 90 degrees.
4684/// - mark (string): Mark symbol to place at each distinct value of the
4685/// graph. Uses the `mark` style key of `style` for drawing.
4686/// - mark-size (float): Mark size in cavas units
4687/// - data (array,function): Array of 2D data points (numeric) or a function
4688/// of the form `x => y`, where `x` is a value in `domain`
4689/// and `y` must be numeric or a 2D vector (for parametric functions).
4690/// #example(```
4691/// plot.plot(size: (2, 2), axis-style: none, {
4692/// // Using an array of points:
4693/// plot.add(((0,0), (calc.pi/2,1),
4694/// (1.5*calc.pi,-1), (2*calc.pi,0)))
4695/// // Sampling a function:
4696/// plot.add(domain: (0, 2*calc.pi), calc.sin)
4697/// })
4698/// ```)
4699/// - label (none,content): Legend label to show for this plot.
4700#let add(domain: auto,
4701 hypograph: false,
4702 epigraph: false,
4703 fill: false,
4704 fill-type: "axis",
4705 style: (:),
4706 mark: none,
4707 mark-size: .2,
4708 mark-style: (:),
4709 samples: 50,
4710 sample-at: (),
4711 line: "raw",
4712 axes: ("x", "y"),
4713 label: none,
4714 data
4715 ) = {
4716 // If data is of type function, sample it
4717 if type(data) == function {
4718 data = sample.sample-fn(data, domain, samples, sample-at: sample-at)
4719 }
4720
4721 // Transform data
4722 let line-data = transform-lines(data, line)
4723
4724 // Get x-domain
4725 let x-domain = (
4726 calc.min(..line-data.map(t => t.at(0))),
4727 calc.max(..line-data.map(t => t.at(0)))
4728 )
4729
4730 // Get y-domain
4731 let y-domain = if line-data != none {(
4732 calc.min(..line-data.map(t => t.at(1))),
4733 calc.max(..line-data.map(t => t.at(1)))
4734 )}
4735
4736 ((
4737 type: "line",
4738 label: label,
4739 data: data, /* Raw data */
4740 line-data: line-data, /* Transformed data */
4741 axes: axes,
4742 x-domain: x-domain,
4743 y-domain: y-domain,
4744 epigraph: epigraph,
4745 hypograph: hypograph,
4746 fill: fill,
4747 fill-type: fill-type,
4748 style: style,
4749 mark: mark,
4750 mark-size: mark-size,
4751 mark-style: mark-style,
4752 plot-prepare: _prepare,
4753 plot-stroke: _stroke,
4754 plot-fill: _fill,
4755 plot-legend-preview: self => {
4756 if self.fill or self.epigraph or self.hypograph {
4757 draw.rect((0,0), (1,1), ..self.style)
4758 } else {
4759 draw.line((0,.5), (1,.5), ..self.style)
4760 }
4761 }
4762 ),)
4763}
4764
4765/// Add horizontal lines at one or more y-values. Every lines start and end points
4766/// are at their axis bounds.
4767///
4768/// #example(```
4769/// plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
4770/// plot.add(domain: (0, 4*calc.pi), calc.sin)
4771/// // Add 3 horizontal lines
4772/// plot.add-hline(-.5, 0, .5)
4773/// })
4774/// ```)
4775///
4776/// - ..y (float): Y axis value(s) to add a line at
4777/// - min (auto,float): X axis minimum value or auto to take the axis minimum
4778/// - max (auto,float): X axis maximum value or auto to take the axis maximum
4779/// - axes (array): Name of the axes to use for plotting
4780/// - style (style): Style to use, can be used with a palette function
4781/// - label (none,content): Legend label to show for this plot.
4782#let add-hline(..y,
4783 min: auto,
4784 max: auto,
4785 axes: ("x", "y"),
4786 style: (:),
4787 label: none,
4788 ) = {
4789 assert(y.pos().len() >= 1,
4790 message: "Specify at least one y value")
4791 assert(y.named().len() == 0)
4792
4793 let prepare(self, ctx) = {
4794 let (x-min, x-max) = (ctx.x.min, ctx.x.max)
4795 let (y-min, y-max) = (ctx.y.min, ctx.y.max)
4796 let x-min = if min == auto { x-min } else { min }
4797 let x-max = if max == auto { x-max } else { max }
4798
4799 self.lines = self.y.filter(y => y >= y-min and y <= y-max)
4800 .map(y => ((x-min, y), (x-max, y)))
4801 return self
4802 }
4803
4804 let stroke(self, ctx) = {
4805 for (a, b) in self.lines {
4806 draw.line(a, b, fill: none)
4807 }
4808 }
4809
4810 let x-min = if min == auto { none } else { min }
4811 let x-max = if max == auto { none } else { max }
4812
4813 ((
4814 type: "hline",
4815 label: label,
4816 y: y.pos(),
4817 x-domain: (x-min, x-max),
4818 y-domain: (calc.min(..y.pos()), calc.max(..y.pos())),
4819 axes: axes,
4820 style: style,
4821 plot-prepare: prepare,
4822 plot-stroke: stroke,
4823 ),)
4824}
4825
4826/// Add vertical lines at one or more x-values. Every lines start and end points
4827/// are at their axis bounds.
4828///
4829/// #example(```
4830/// plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
4831/// plot.add(domain: (0, 2*calc.pi), calc.sin)
4832/// // Add 3 vertical lines
4833/// plot.add-vline(calc.pi/2, calc.pi, 3*calc.pi/2)
4834/// })
4835/// ```)
4836///
4837/// - ..x (float): X axis values to add a line at
4838/// - min (auto,float): Y axis minimum value or auto to take the axis minimum
4839/// - max (auto,float): Y axis maximum value or auto to take the axis maximum
4840/// - axes (array): Name of the axes to use for plotting, note that not all
4841/// plot styles are able to display a custom axis!
4842/// - style (style): Style to use, can be used with a palette function
4843/// - label (none,content): Legend label to show for this plot.
4844#let add-vline(..x,
4845 min: auto,
4846 max: auto,
4847 axes: ("x", "y"),
4848 style: (:),
4849 label: none,
4850 ) = {
4851 assert(x.pos().len() >= 1,
4852 message: "Specify at least one x value")
4853 assert(x.named().len() == 0)
4854
4855 let prepare(self, ctx) = {
4856 let (x-min, x-max) = (ctx.x.min, ctx.x.max)
4857 let (y-min, y-max) = (ctx.y.min, ctx.y.max)
4858 let y-min = if min == auto { y-min } else { min }
4859 let y-max = if max == auto { y-max } else { max }
4860
4861 self.lines = self.x.filter(x => x >= x-min and x <= x-max)
4862 .map(x => ((x, y-min), (x, y-max)))
4863 return self
4864 }
4865
4866 let stroke(self, ctx) = {
4867 for (a, b) in self.lines {
4868 draw.line(a, b, fill: none)
4869 }
4870 }
4871
4872 let y-min = if min == auto { none } else { min }
4873 let y-max = if max == auto { none } else { max }
4874
4875 ((
4876 type: "vline",
4877 label: label,
4878 x: x.pos(),
4879 x-domain: (calc.min(..x.pos()), calc.max(..x.pos())),
4880 y-domain: (y-min, y-max),
4881 axes: axes,
4882 style: style,
4883 plot-prepare: prepare,
4884 plot-stroke: stroke
4885 ),)
4886}
4887
4888/// Fill the area between two graphs. This behaves same as `add` but takes
4889/// a pair of data instead of a single data array/function.
4890/// The area between both function plots gets filled. For a more detailed
4891/// explanation of the arguments, see @@add().
4892///
4893/// This can be used to display an error-band of a function.
4894///
4895/// #example(```
4896/// plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
4897/// plot.add-fill-between(domain: (0, 2*calc.pi),
4898/// calc.sin, // First function/data
4899/// calc.cos) // Second function/data
4900/// })
4901/// ```)
4902///
4903/// - domain (domain): Domain of both `data-a` and `data-b`. The domain is used for
4904/// sampling functions only and has no effect on data arrays.
4905/// - samples (int): Number of times the `data-a` and `data-b` function gets called for
4906/// sampling y-values. Only used if `data-a` or `data-b` is of
4907/// type function.
4908/// - sample-at (array): Array of x-values the function(s) get sampled at in addition
4909/// to the default sampling.
4910/// - line (string, dictionary): Line type to use, see @@add().
4911/// - style (style): Style to use, can be used with a palette function.
4912/// - label (none,content): Legend label to show for this plot.
4913/// - axes (array): Name of the axes to use for plotting.
4914/// - data-a (array,function): Data of the first plot, see @@add().
4915/// - data-b (array,function): Data of the second plot, see @@add().
4916#let add-fill-between(data-a,
4917 data-b,
4918 domain: auto,
4919 samples: 50,
4920 sample-at: (),
4921 line: "raw",
4922 axes: ("x", "y"),
4923 label: none,
4924 style: (:)) = {
4925 // If data is of type function, sample it
4926 if type(data-a) == function {
4927 data-a = sample.sample-fn(data-a, domain, samples, sample-at: sample-at)
4928 }
4929 if type(data-b) == function {
4930 data-b = sample.sample-fn(data-b, domain, samples, sample-at: sample-at)
4931 }
4932
4933 // Transform data
4934 let line-a-data = transform-lines(data-a, line)
4935 let line-b-data = transform-lines(data-b, line)
4936
4937 // Get x-domain
4938 let x-domain = (
4939 calc.min(..line-a-data.map(t => t.at(0)),
4940 ..line-b-data.map(t => t.at(0))),
4941 calc.max(..line-a-data.map(t => t.at(0)),
4942 ..line-b-data.map(t => t.at(0)))
4943 )
4944
4945 // Get y-domain
4946 let y-domain = if line-a-data != none and line-b-data != none {(
4947 calc.min(..line-a-data.map(t => t.at(1)),
4948 ..line-b-data.map(t => t.at(1))),
4949 calc.max(..line-a-data.map(t => t.at(1)),
4950 ..line-b-data.map(t => t.at(1)))
4951 )}
4952
4953 let prepare(self, ctx) = {
4954 let (x, y) = (ctx.x, ctx.y)
4955
4956 // Generate stroke paths
4957 self.stroke-paths = (
4958 a: util.compute-stroke-paths(self.line-data.a, x, y),
4959 b: util.compute-stroke-paths(self.line-data.b, x, y),
4960 )
4961
4962 // Generate fill paths
4963 self.fill-paths = util.compute-fill-paths(self.line-data.a + self.line-data.b.rev(), x, y)
4964
4965 return self
4966 }
4967
4968 let stroke(self, ctx) = {
4969 for p in self.stroke-paths.a {
4970 draw.line(..p, fill: none)
4971 }
4972 for p in self.stroke-paths.b {
4973 draw.line(..p, fill: none)
4974 }
4975 }
4976
4977 let fill(self, ctx) = {
4978 fill-shape(self.fill-paths)
4979 }
4980
4981 ((
4982 type: "fill-between",
4983 label: label,
4984 axes: axes,
4985 line-data: (a: line-a-data, b: line-b-data),
4986 x-domain: x-domain,
4987 y-domain: y-domain,
4988 style: style,
4989 plot-prepare: prepare,
4990 plot-stroke: stroke,
4991 plot-fill: fill,
4992 plot-legend-preview: self => {
4993 draw.rect((0,0), (1,1), ..self.style)
4994 }
4995 ),)
4996}
4997#import "/src/cetz.typ": draw
4998#import "/src/axes.typ"
4999
5000// Draw mark at point with size
5001#let draw-mark-shape(pt, size, mark, style) = {
5002 let sx = size
5003 let sy = size
5004
5005 let bl(pt) = (rel: (-sx/2, -sy/2), to: pt)
5006 let br(pt) = (rel: (sx/2, -sy/2), to: pt)
5007 let tl(pt) = (rel: (-sx/2, sy/2), to: pt)
5008 let tr(pt) = (rel: (sx/2, sy/2), to: pt)
5009 let ll(pt) = (rel: (-sx/2, 0), to: pt)
5010 let rr(pt) = (rel: (sx/2, 0), to: pt)
5011 let tt(pt) = (rel: (0, sy/2), to: pt)
5012 let bb(pt) = (rel: (0, -sy/2), to: pt)
5013
5014 if mark == "o" {
5015 draw.circle(pt, radius: (sx/2, sy/2), ..style)
5016 } else if mark == "square" {
5017 draw.rect(bl(pt), tr(pt), ..style)
5018 } else if mark == "triangle" {
5019 draw.line(bl(pt), br(pt), tt(pt), close: true, ..style)
5020 } else if mark == "*" or mark == "x" {
5021 draw.line(bl(pt), tr(pt), ..style)
5022 draw.line(tl(pt), br(pt), ..style)
5023 } else if mark == "+" {
5024 draw.line(ll(pt), rr(pt), ..style);
5025 draw.line(tt(pt), bb(pt), ..style)
5026 } else if mark == "-" {
5027 draw.line(ll(pt), rr(pt), ..style)
5028 } else if mark == "|" {
5029 draw.line(tt(pt), bb(pt), ..style)
5030 }
5031}
5032
5033#let draw-mark(pts, x, y, mark, mark-size, plot-size) = {
5034 let pts = pts.map(pt => {
5035 axes.transform-vec(plot-size, x, y, none, pt)
5036 }).filter(pt => pt != none)
5037
5038 for pt in pts {
5039 draw-mark-shape(pt, mark-size, mark, (:))
5040 }
5041}
5042/// Sample the given single parameter function `samples` times, with values
5043/// evenly spaced within the range given by `domain` and return each
5044/// sampled `y` value in an array as `(x, y)` tuple.
5045///
5046/// If the functions first return value is a tuple `(x, y)`, then all return values
5047/// must be a tuple.
5048///
5049/// - fn (function): Function to sample of the form `(x) => y` or `(t) => (x, y)`, where
5050/// `x` or `t` are `float` values within the domain specified by `domain`.
5051/// - domain (domain): Domain of `fn` used as bounding interval for the sampling points.
5052/// - samples (int): Number of samples in domain.
5053/// - sample-at (array): List of x values the function gets sampled at in addition
5054/// to the `samples` number of samples. Values outsides the
5055/// specified domain are legal.
5056/// -> array: Array of (x, y) tuples
5057#let sample-fn(fn, domain, samples, sample-at: ()) = {
5058 assert(samples + sample-at.len() >= 2,
5059 message: "You must at least sample 2 values")
5060 assert(type(domain) == array and domain.len() == 2,
5061 message: "Domain must be a tuple")
5062
5063 let (lo, hi) = domain
5064
5065 let y0 = (fn)(lo)
5066 let is-vector = type(y0) == array
5067 if not is-vector {
5068 y0 = ((lo, y0), )
5069 } else {
5070 y0 = (y0, )
5071 }
5072
5073 let pts = sample-at + range(0, samples).map(t => lo + t / (samples - 1) * (hi - lo))
5074 pts = pts.sorted()
5075
5076 return pts.map(x => {
5077 if is-vector {
5078 (fn)(x)
5079 } else {
5080 (x, (fn)(x))
5081 }
5082 })
5083}
5084
5085/// Samples the given two parameter function with `x-samples` and
5086/// `y-samples` values evenly spaced within the range given by
5087/// `x-domain` and `y-domain` and returns each sampled output in
5088/// an array.
5089///
5090/// - fn (function): Function of the form `(x, y) => z` with all values being numbers.
5091/// - x-domain (domain): Domain used as bounding interval for sampling point's x
5092/// values.
5093/// - y-domain (domain): Domain used as bounding interval for sampling point's y
5094/// values.
5095/// - x-samples (int): Number of samples in the x-domain.
5096/// - y-samples (int): Number of samples in the y-domain.
5097/// -> array: Array of z scalars
5098#let sample-fn2(fn, x-domain, y-domain, x-samples, y-samples) = {
5099 assert(x-samples >= 2,
5100 message: "You must at least sample 2 x-values")
5101 assert(y-samples >= 2,
5102 message: "You must at least sample 2 y-values")
5103 assert(type(x-domain) == array and x-domain.len() == 2,
5104 message: "X-Domain must be a tuple")
5105 assert(type(y-domain) == array and y-domain.len() == 2,
5106 message: "Y-Domain must be a tuple")
5107
5108 let (x-min, x-max) = x-domain
5109 let (y-min, y-max) = y-domain
5110 let y-pts = range(0, y-samples)
5111 let x-pts = range(0, x-samples)
5112
5113 return y-pts.map(y => {
5114 let y = y / (y-samples - 1) * (y-max - y-min) + y-min
5115 return x-pts.map(x => {
5116 let x = x / (x-samples - 1) * (x-max - x-min) + x-min
5117 return float((fn)(x, y))
5118 })
5119 })
5120}
5121#import "/src/cetz.typ"
5122#import cetz.util: bezier
5123
5124/// Clip line-strip in rect
5125///
5126/// - points (array): Array of vectors representing a line-strip
5127/// - low (vector): Lower clip-window coordinate
5128/// - high (vector): Upper clip-window coordinate
5129/// -> array List of line-strips representing the paths insides the clip-window
5130#let clipped-paths(points, low, high, fill: false) = {
5131 let (min-x, max-x) = (calc.min(low.at(0), high.at(0)),
5132 calc.max(low.at(0), high.at(0)))
5133 let (min-y, max-y) = (calc.min(low.at(1), high.at(1)),
5134 calc.max(low.at(1), high.at(1)))
5135
5136 let in-rect(pt) = {
5137 return (pt.at(0) >= min-x and pt.at(0) <= max-x and
5138 pt.at(1) >= min-y and pt.at(1) <= max-y)
5139 }
5140
5141 let interpolated-end(a, b) = {
5142 if in-rect(a) and in-rect(b) {
5143 return b
5144 }
5145
5146 let (x1, y1, ..) = a
5147 let (x2, y2, ..) = b
5148
5149 if x2 - x1 == 0 {
5150 return (x2, calc.min(max-y, calc.max(y2, min-y)))
5151 }
5152
5153 if y2 - y1 == 0 {
5154 return (calc.min(max-x, calc.max(x2, min-x)), y2)
5155 }
5156
5157 let m = (y2 - y1) / (x2 - x1)
5158 let n = y2 - m * x2
5159
5160 let x = x2
5161 let y = y2
5162
5163 y = calc.min(max-y, calc.max(y, min-y))
5164 x = (y - n) / m
5165
5166 x = calc.min(max-x, calc.max(x, min-x))
5167 y = m * x + n
5168
5169 return (x, y)
5170 }
5171
5172 // Append path to paths and return paths
5173 //
5174 // If path starts or ends with a vector of another part, merge those
5175 // paths instead appending path as a new path.
5176 let append-path(paths, path) = {
5177 if path.len() <= 1 {
5178 return paths
5179 }
5180
5181 let cmp(a, b) = {
5182 return a.map(calc.round.with(digits: 8)) == b.map(calc.round.with(digits: 8))
5183 }
5184
5185 let added = false
5186 for i in range(0, paths.len()) {
5187 let p = paths.at(i)
5188 if cmp(p.first(), path.last()) {
5189 paths.at(i) = path + p
5190 added = true
5191 } else if cmp(p.first(), path.first()) {
5192 paths.at(i) = path.rev() + p
5193 added = true
5194 } else if cmp(p.last(), path.first()) {
5195 paths.at(i) = p + path
5196 added = true
5197 } else if cmp(p.last(), path.last()) {
5198 paths.at(i) = p + path.rev()
5199 added = true
5200 }
5201 if added { break }
5202 }
5203
5204 if not added {
5205 paths.push(path)
5206 }
5207 return paths
5208 }
5209
5210 let clamped-pt(pt) = {
5211 return (calc.max(min-x, calc.min(pt.at(0), max-x)),
5212 calc.max(min-y, calc.min(pt.at(1), max-y)))
5213 }
5214
5215 let paths = ()
5216
5217 let path = ()
5218 let prev = points.at(0)
5219 let was-inside = in-rect(prev)
5220 if was-inside {
5221 path.push(prev)
5222 } else if fill {
5223 path.push(clamped-pt(prev))
5224 }
5225
5226 for i in range(1, points.len()) {
5227 let prev = points.at(i - 1)
5228 let pt = points.at(i)
5229
5230 let is-inside = in-rect(pt)
5231
5232 let (x1, y1, ..) = prev
5233 let (x2, y2, ..) = pt
5234
5235 // Ignore lines if both ends are outsides the x-window and on the
5236 // same side.
5237 if (x1 < min-x and x2 < min-x) or (x1 > max-x and x2 > max-x) {
5238 if fill {
5239 let clamped = clamped-pt(pt)
5240 if path.last() != clamped {
5241 path.push(clamped)
5242 }
5243 }
5244 was-inside = false
5245 continue
5246 }
5247
5248 if is-inside {
5249 if was-inside {
5250 path.push(pt)
5251 } else {
5252 path.push(interpolated-end(pt, prev))
5253 path.push(pt)
5254 }
5255 } else {
5256 if was-inside {
5257 path.push(interpolated-end(prev, pt))
5258 } else {
5259 let (a, b) = (interpolated-end(pt, prev),
5260 interpolated-end(prev, pt))
5261 if in-rect(a) and in-rect(b) {
5262 path.push(a)
5263 path.push(b)
5264 } else if fill {
5265 let clamped = clamped-pt(pt)
5266 if path.last() != clamped {
5267 path.push(clamped)
5268 }
5269 }
5270 }
5271
5272 if path.len() > 0 and not fill {
5273 paths = append-path(paths, path)
5274 path = ()
5275 }
5276 }
5277
5278 was-inside = is-inside
5279 }
5280
5281 // Append clamped last point if filling
5282 if fill and not in-rect(points.last()) {
5283 path.push(clamped-pt(points.last()))
5284 }
5285
5286 if path.len() > 1 {
5287 paths = append-path(paths, path)
5288 }
5289
5290 return paths
5291}
5292
5293/// Compute clipped stroke paths
5294///
5295/// - points (array): X/Y data points
5296/// - x (axis): X-Axis
5297/// - y (axis): Y-Axis
5298/// -> array List of stroke paths
5299#let compute-stroke-paths(points, x, y) = {
5300 clipped-paths(points, (x.min, y.min), (x.max, y.max), fill: false)
5301}
5302
5303/// Compute clipped fill path
5304///
5305/// - points (array): X/Y data points
5306/// - x (axis): X-Axis
5307/// - y (axis): Y-Axis
5308/// -> array List of fill paths
5309#let compute-fill-paths(points, x, y) = {
5310 clipped-paths(points, (x.min, y.min), (x.max, y.max), fill: true)
5311}
5312
5313/// Return points of a sampled catmull-rom through the
5314/// input points.
5315///
5316/// - points (array): Array of input vectors
5317/// - tension (float): Catmull-Rom tension
5318/// - samples (int): Number of samples
5319/// -> array Array of vectors
5320#let sampled-spline-data(points, tension, samples) = {
5321 assert(samples >= 1 and samples <= 100,
5322 message: "Must at least use 1 sample per curve")
5323
5324 let curves = bezier.catmull-to-cubic(points, tension)
5325 let pts = ()
5326 for c in curves {
5327 for t in range(0, samples + 1) {
5328 let t = t / samples
5329 pts.push(bezier.cubic-point(..c, t))
5330 }
5331 }
5332 return pts
5333}
5334
5335/// Simplify linear data by "detecting" linear sections
5336/// and skipping points until the slope changes.
5337/// This can have a huge impact on the number of lines
5338/// getting rendered.
5339///
5340/// - data (array): Data points
5341/// - epsilon (float): Curvature threshold to treat data as linear
5342#let linearized-data(data, epsilon) = {
5343 let pts = ()
5344 // Current slope, set to none if infinite
5345 let dx = none
5346 // Previous point, last skipped point
5347 let prev = none
5348 let skipped = none
5349 // Current direction
5350 let dir = 0
5351
5352 let len = data.len()
5353 for i in range(0, len) {
5354 let pt = data.at(i)
5355 if prev != none and i < len - 1 {
5356 let new-dir = pt.at(0) - prev.at(0)
5357 if new-dir == 0 {
5358 // Infinite slope
5359 if dx != none {
5360 if skipped != none {pts.push(skipped); skipped = none}
5361 pts.push(pt)
5362 } else {
5363 skipped = pt
5364 }
5365 dx = none
5366 } else {
5367 // Push the previous and the current point
5368 // if slope or direction changed
5369 let new-dx = ((pt.at(1) - prev.at(1)) / new-dir)
5370 if dx == none or calc.abs(new-dx - dx) > epsilon or (new-dir * dir) < 0 {
5371 if skipped != none {pts.push(skipped); skipped = none}
5372 pts.push(pt)
5373
5374 dx = new-dx
5375 dir = new-dir
5376 } else {
5377 skipped = pt
5378 }
5379 }
5380 } else {
5381 if skipped != none {pts.push(skipped); skipped = none}
5382 pts.push(pt)
5383 }
5384
5385 prev = pt
5386 }
5387
5388 return pts
5389}
5390
5391// Get the default axis orientation
5392// depending on the axis name
5393#let get-default-axis-horizontal(name) = {
5394 return lower(name).starts-with("x")
5395}
5396
5397// Setup axes dictionary
5398//
5399// - axis-dict (dictionary): Existing axis dictionary
5400// - options (dictionary): Named arguments
5401// - plot-size (tuple): Plot width, height tuple
5402#let setup-axes(ctx, axis-dict, options, plot-size) = {
5403 import "/src/axes.typ"
5404
5405 // Get axis option for name
5406 let get-axis-option(axis-name, name, default) = {
5407 let v = options.at(axis-name + "-" + name, default: default)
5408 if v == auto { default } else { v }
5409 }
5410
5411 for (name, axis) in axis-dict {
5412 if not "ticks" in axis { axis.ticks = () }
5413 axis.label = get-axis-option(name, "label", $#name$)
5414
5415 // Configure axis bounds
5416 axis.min = get-axis-option(name, "min", axis.min)
5417 axis.max = get-axis-option(name, "max", axis.max)
5418
5419 assert(axis.min not in (none, auto) and
5420 axis.max not in (none, auto),
5421 message: "Axis min and max must be set.")
5422 if axis.min == axis.max {
5423 axis.min -= 1; axis.max += 1
5424 }
5425
5426 axis.mode = get-axis-option(name, "mode", "lin")
5427 axis.base = get-axis-option(name, "base", 10)
5428
5429 // Configure axis orientation
5430 axis.horizontal = get-axis-option(name, "horizontal",
5431 get-default-axis-horizontal(name))
5432
5433 // Configure ticks
5434 axis.ticks.list = get-axis-option(name, "ticks", ())
5435 axis.ticks.step = get-axis-option(name, "tick-step", axis.ticks.step)
5436 axis.ticks.minor-step = get-axis-option(name, "minor-tick-step", axis.ticks.minor-step)
5437 axis.ticks.decimals = get-axis-option(name, "decimals", 2)
5438 axis.ticks.unit = get-axis-option(name, "unit", [])
5439 axis.ticks.format = get-axis-option(name, "format", axis.ticks.format)
5440
5441 // Axis break
5442 axis.show-break = get-axis-option(name, "break", false)
5443 axis.inset = get-axis-option(name, "inset", (0, 0))
5444
5445 // Configure grid
5446 axis.ticks.grid = get-axis-option(name, "grid", false)
5447
5448 axis-dict.at(name) = axis
5449 }
5450
5451 // Set axis options round two, after setting
5452 // axis bounds
5453 for (name, axis) in axis-dict {
5454 let changed = false
5455
5456 // Configure axis aspect ratio
5457 let equal-to = get-axis-option(name, "equal", none)
5458 if equal-to != none {
5459 assert.eq(type(equal-to), str,
5460 message: "Expected axis name.")
5461 assert(equal-to != name,
5462 message: "Axis can not be equal to itself.")
5463
5464 let other = axis-dict.at(equal-to, default: none)
5465 assert(other != none,
5466 message: "Other axis must exist.")
5467 assert(other.horizontal != axis.horizontal,
5468 message: "Equal axes must have opposing orientation.")
5469
5470 let (w, h) = plot-size
5471 let ratio = if other.horizontal {
5472 h / w
5473 } else {
5474 w / h
5475 }
5476 axis.min = other.min * ratio
5477 axis.max = other.max * ratio
5478
5479 changed = true
5480 }
5481
5482 if changed {
5483 axis-dict.at(name) = axis
5484 }
5485 }
5486
5487 for (name, axis) in axis-dict {
5488 axis-dict.at(name) = axes.prepare-axis(ctx, axis, name)
5489 }
5490
5491 return axis-dict
5492}
5493#import "/src/cetz.typ": draw
5494#import "util.typ"
5495#import "sample.typ"
5496
5497#let kernel-normal(x, stdev: 1.5) = {
5498 (1 / calc.sqrt(2 * calc.pi*calc.pow(stdev, 2))) * calc.exp(-(x*x) / (2 * calc.pow(stdev, 2)))
5499}
5500
5501#let _violin-render(self, ctx, violin, filling: true) = {
5502 let path = range(self.samples)
5503 .map((t)=>violin.min + (violin.max - violin.min) * (t / self.samples ))
5504 .map((u)=>(u, (violin.convolve)(u)))
5505 .map(((u,v)) => {
5506 (violin.x-position + v, u)
5507 })
5508
5509 if self.side == "both"{
5510 path += path.rev().map(((x,y))=> {(2 * violin.x-position - x,y)})
5511 } else if self.side == "left"{
5512 path = path.map(((x,y)) => (2 * violin.x-position - x,y))
5513 }
5514
5515 let stroke-paths = util.compute-stroke-paths(path, ctx.x, ctx.y)
5516
5517 for p in stroke-paths{
5518 let args = arguments(..p, closed: self.side == "both")
5519 if filling {
5520 args = arguments(..args, stroke: none)
5521 } else {
5522 args = arguments(..args, fill: none)
5523 }
5524 draw.line(..self.style, ..args)
5525 }
5526}
5527
5528#let _plot-prepare(self, ctx) = {
5529 self.violins = self.data.map(entry=> {
5530 let points = entry.at(self.y-key)
5531 let (min, max) = (calc.min(..points), calc.max(..points))
5532 let range = calc.abs(max - min)
5533 (
5534 x-position: entry.at(self.x-key),
5535 points: points,
5536 length: points.len(),
5537 min: min - (self.extents * range),
5538 max: max + (self.extents * range),
5539 convolve: (t) => {
5540 points.map(y => (self.kernel)((y - t) / self.bandwidth)).sum() / (points.len() * self.bandwidth)
5541 }
5542 )
5543 })
5544 return self
5545}
5546
5547#let _plot-stroke(self, ctx) = {
5548 for violin in self.violins {
5549 _violin-render(self, ctx, violin, filling: false)
5550 }
5551}
5552
5553#let _plot-fill(self, ctx) = {
5554 for violin in self.violins {
5555 _violin-render(self, ctx, violin, filling: true)
5556 }
5557}
5558
5559#let _plot-legend-preview(self) = {
5560 draw.rect((0,0), (1,1), ..self.style)
5561}
5562
5563
5564/// Add a violin plot
5565///
5566/// A violin plot is a chart that can be used to compare the distribution of continuous
5567/// data between categories.
5568///
5569/// - data (array): Array of data items. An item is an array containing an `x` and one
5570/// or more `y` values.
5571/// - x-key (int, string): Key to use for retrieving the `x` position of the violin.
5572/// - y-key (int, string): Key to use for retrieving values of points within the category.
5573/// - side (string): The sides of the violin to be rendered:
5574/// / left: Plot only the left side of the violin.
5575/// / right: Plot only the right side of the violin.
5576/// / both: Plot both sides of the violin.
5577/// - kernel (function): The kernel density estimator function, which takes a single
5578/// `x` value relative to the center of a distribution (0) and
5579/// normalized by the bandwidth
5580/// - bandwidth (float): The smoothing parameter of the kernel.
5581/// - extents (float): The extension of the domain, expressed as a fraction of spread.
5582/// - samples (int): The number of samples of the kernel to render.
5583/// - style (dictionary): Style override dictionary.
5584/// - mark-style (dictionary): (unused, will eventually be used to render interquartile ranges).
5585/// - axes (axes): (unstable, documentation to follow once completed).
5586/// - label (none, content): The name of the category to be shown in the legend.
5587#let add-violin(
5588 data,
5589 x-key: 0,
5590 y-key: 1,
5591 side: "right",
5592 kernel: kernel-normal.with(stdev: 1.5),
5593 bandwidth: 1,
5594 extents: 0.25,
5595
5596 samples: 50,
5597 style: (:),
5598 mark-style: (:),
5599 axes: ("x", "y"),
5600 label: none,
5601) = {
5602
5603 ((
5604 type: "violins",
5605
5606 data: data,
5607 x-key: x-key,
5608 y-key: y-key,
5609 side: side,
5610 kernel: kernel,
5611 bandwidth: bandwidth,
5612 extents: extents,
5613
5614 samples: samples,
5615 style: style,
5616 mark-style: mark-style,
5617 axes: axes,
5618 label: label,
5619
5620 plot-prepare: _plot-prepare,
5621 plot-stroke: _plot-stroke,
5622 plot-fill: _plot-fill,
5623 plot-legend-preview: _plot-legend-preview,
5624 ),)
5625
5626}
5627[package]
5628name = "cetz-plot"
5629version = "0.1.1"
5630compiler = "0.12.0"
5631repository = "https://github.com/cetz-package/cetz-plot"
5632entrypoint = "src/lib.typ"
5633authors = [
5634 "Johannes Wolf <https://github.com/johannes-wolf>",
5635 "fenjalien <https://github.com/fenjalien>"
5636]
5637categories = [ "visualization" ]
5638license = "LGPL-3.0-or-later"
5639description = "Plotting module for CeTZ."
5640keywords = [ "plot", "chart" ]
5641exclude = [ "/gallery/*", "manual.pdf", "manual.typ" ]