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
| 1 | DAIMOND TYPST PACK 1 |
| 2 | namespace preview |
| 3 | name cetz-plot |
| 4 | version 0.1.1 |
| 5 | entrypoint src/lib.typ |
| 6 | file 7651 LICENSE |
| 7 | file 28825 src/axes.typ |
| 8 | file 110 src/cetz.typ |
| 9 | file 326 src/chart.typ |
| 10 | file 4997 src/chart/barchart.typ |
| 11 | file 971 src/chart/barcol-common.typ |
| 12 | file 3222 src/chart/boxwhisker.typ |
| 13 | file 4794 src/chart/columnchart.typ |
| 14 | file 19804 src/chart/piechart.typ |
| 15 | file 14247 src/chart/pyramid.typ |
| 16 | file 1569 src/clip.typ |
| 17 | file 1032 src/grid-layout.typ |
| 18 | file 104 src/lib.typ |
| 19 | file 20396 src/plot.typ |
| 20 | file 2268 src/plot/annotation.typ |
| 21 | file 8738 src/plot/bar.typ |
| 22 | file 4255 src/plot/boxwhisker.typ |
| 23 | file 10558 src/plot/contour.typ |
| 24 | file 3340 src/plot/errorbar.typ |
| 25 | file 4640 src/plot/formats.typ |
| 26 | file 8191 src/plot/legend.typ |
| 27 | file 15402 src/plot/line.typ |
| 28 | file 1338 src/plot/mark.typ |
| 29 | file 2923 src/plot/sample.typ |
| 30 | file 9742 src/plot/util.typ |
| 31 | file 3934 src/plot/violin.typ |
| 32 | file 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 |
| 43 | the terms and conditions of version 3 of the GNU General Public |
| 44 | License, 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 |
| 49 | General Public License, and the "GNU GPL" refers to version 3 of the GNU |
| 50 | General Public License. |
| 51 | |
| 52 | "The Library" refers to a covered work governed by this License, |
| 53 | other than an Application or a Combined Work as defined below. |
| 54 | |
| 55 | An "Application" is any work that makes use of an interface provided |
| 56 | by the Library, but which is not otherwise based on the Library. |
| 57 | Defining a subclass of a class defined by the Library is deemed a mode |
| 58 | of using an interface provided by the Library. |
| 59 | |
| 60 | A "Combined Work" is a work produced by combining or linking an |
| 61 | Application with the Library. The particular version of the Library |
| 62 | with which the Combined Work was made is also called the "Linked |
| 63 | Version". |
| 64 | |
| 65 | The "Minimal Corresponding Source" for a Combined Work means the |
| 66 | Corresponding Source for the Combined Work, excluding any source code |
| 67 | for portions of the Combined Work that, considered in isolation, are |
| 68 | based on the Application, and not on the Linked Version. |
| 69 | |
| 70 | The "Corresponding Application Code" for a Combined Work means the |
| 71 | object code and/or source code for the Application, including any data |
| 72 | and utility programs needed for reproducing the Combined Work from the |
| 73 | Application, 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 |
| 78 | without 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 |
| 83 | facility refers to a function or data to be supplied by an Application |
| 84 | that uses the facility (other than as an argument passed when the |
| 85 | facility is invoked), then you may convey a copy of the modified |
| 86 | version: |
| 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 |
| 99 | a header file that is part of the Library. You may convey such object |
| 100 | code under terms of your choice, provided that, if the incorporated |
| 101 | material is not limited to numerical parameters, data structure |
| 102 | layouts 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, |
| 115 | taken together, effectively do not restrict modification of the |
| 116 | portions of the Library contained in the Combined Work and reverse |
| 117 | engineering for debugging such modifications, if you also do each of |
| 118 | the 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 |
| 164 | Library side by side in a single library together with other library |
| 165 | facilities that are not Applications and are not covered by this |
| 166 | License, and convey such a combined library under terms of your |
| 167 | choice, 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 |
| 180 | of the GNU Lesser General Public License from time to time. Such new |
| 181 | versions will be similar in spirit to the present version, but may |
| 182 | differ in detail to address new problems or concerns. |
| 183 | |
| 184 | Each version is given a distinguishing version number. If the |
| 185 | Library as you received it specifies that a certain numbered version |
| 186 | of the GNU Lesser General Public License "or any later version" |
| 187 | applies to it, you have the option of following the terms and |
| 188 | conditions either of that published version or of any later version |
| 189 | published by the Free Software Foundation. If the Library as you |
| 190 | received it does not specify a version number of the GNU Lesser |
| 191 | General Public License, you may choose any version of the GNU Lesser |
| 192 | General Public License ever published by the Free Software Foundation. |
| 193 | |
| 194 | If the Library as you received it specifies that a proxy can decide |
| 195 | whether future versions of the GNU Lesser General Public License shall |
| 196 | apply, that proxy's public statement of acceptance of any version is |
| 197 | permanent authorization for you to choose that version for the |
| 198 | Library. |
| 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] |
| 5628 | name = "cetz-plot" |
| 5629 | version = "0.1.1" |
| 5630 | compiler = "0.12.0" |
| 5631 | repository = "https://github.com/cetz-package/cetz-plot" |
| 5632 | entrypoint = "src/lib.typ" |
| 5633 | authors = [ |
| 5634 | "Johannes Wolf <https://github.com/johannes-wolf>", |
| 5635 | "fenjalien <https://github.com/fenjalien>" |
| 5636 | ] |
| 5637 | categories = [ "visualization" ] |
| 5638 | license = "LGPL-3.0-or-later" |
| 5639 | description = "Plotting module for CeTZ." |
| 5640 | keywords = [ "plot", "chart" ] |
| 5641 | exclude = [ "/gallery/*", "manual.pdf", "manual.typ" ] |