diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..78716e9
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,10 @@
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+
+[Makefile]
+indent_style = tab
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..53fa049
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,44 @@
+name: Tests
+on:
+ push:
+ branches:
+ - main
+ - master
+ pull_request:
+ branches:
+ - '**'
+
+jobs:
+ ci:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: recursive
+ - uses: awalsh128/cache-apt-pkgs-action@latest
+ with:
+ packages: imagemagick cargo parallel
+ version: 1.0
+ - name: Install typos-cli from crates.io
+ uses: baptiste0928/cargo-install@v2.2.0
+ with:
+ crate: typos-cli
+ - name: Install just from crates.io
+ uses: baptiste0928/cargo-install@v2.2.0
+ with:
+ crate: just
+ - name: Install typst-test from github
+ uses: baptiste0928/cargo-install@v2.2.0
+ with:
+ crate: typst-test
+ git: https://github.com/tingerrr/typst-test.git
+ tag: ci-semi-stable
+ - uses: typst-community/setup-typst@v3
+ with:
+ typst-version: '0.11.1'
+ cache-dependency-path: src/cetz.typ
+ - run: |
+ just install @local
+ just install @preview
+ just manual
+ just test
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..132bc28
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C) 2024 Johannes Wolf
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3572d0e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,63 @@
+# CeTZ-Plot
+
+CeTZ-Plot is a library that adds a plots and charts to [CeTZ](https://github.com/cetz-package/cetz).
+
+## Examples
+
+
+
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+ Plot |
+ Pie Chart |
+ Clustered Barchart |
+
+
+
+*Click on the example image to jump to the code.*
+
+
+## Usage
+
+For information, see the [manual (stable)](https://github.com/cetz-package/cetz/blob/stable/manual.pdf?raw=true).
+
+To use this package, simply add the following code to your document:
+```
+#import "@preview/cetz:0.2.2"
+#import "@preview/cetz-plot:0.1.0": plot, chart
+
+#cetz.canvas({
+ // Your plot/chart code goes here
+})
+```
+
+## Installing
+
+To install the CeTZ-Plot package under [your local typst package dir](https://github.com/typst/packages?tab=readme-ov-file#local-packages) you can use the `install` script from the repository.
+
+### Just
+
+This project uses [just](https://github.com/casey/just), a handy command runner.
+
+You can run all commands without having `just` installed, just have a look into the `justfile`.
+To install `just` on your system, use your systems package manager. On Windows, [Cargo](https://doc.rust-lang.org/cargo/) (`cargo install just`), [Chocolatey](https://chocolatey.org/) (`choco install just`) and [some other sources](https://just.systems/man/en/chapter_4.html) can be used. You need to run it from a `sh` compatible shell on Windows (e.g git-bash).
+
+## Testing
+
+This package comes with some unit tests under the `tests` directory.
+To run all tests you can run the `just test` target. You need to have
+[`typst-test`](https://github.com/tingerrr/typst-test/) in your `PATH`: `cargo install typst-test --git https://github.com/tingerrr/typst-test`.
diff --git a/gallery/.gitkeep b/gallery/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/gallery/barchart.png b/gallery/barchart.png
new file mode 100644
index 0000000..f02dd78
Binary files /dev/null and b/gallery/barchart.png differ
diff --git a/gallery/barchart.typ b/gallery/barchart.typ
new file mode 100644
index 0000000..d40542c
--- /dev/null
+++ b/gallery/barchart.typ
@@ -0,0 +1,26 @@
+#import "@preview/cetz:0.2.2": canvas, draw
+#import "@preview/cetz-plot:0.1.0": chart
+
+#set page(width: auto, height: auto, margin: .5cm)
+
+#let data2 = (
+ ([15-24], 18.0, 20.1, 23.0, 17.0),
+ ([25-29], 16.3, 17.6, 19.4, 15.3),
+ ([30-34], 14.0, 15.3, 13.9, 18.7),
+ ([35-44], 35.5, 26.5, 29.4, 25.8),
+ ([45-54], 25.0, 20.6, 22.4, 22.0),
+ ([55+], 19.9, 18.2, 19.2, 16.4),
+)
+
+#canvas({
+ draw.set-style(legend: (fill: white))
+ chart.barchart(mode: "clustered",
+ size: (9, auto),
+ label-key: 0,
+ value-key: (..range(1, 5)),
+ bar-width: .8,
+ x-tick-step: 2.5,
+ data2,
+ labels: ([Low], [Medium], [High], [Very high]),
+ legend: "legend.inner-north-east",)
+})
diff --git a/gallery/line.pdf b/gallery/line.pdf
new file mode 100644
index 0000000..c2db485
Binary files /dev/null and b/gallery/line.pdf differ
diff --git a/gallery/line.png b/gallery/line.png
new file mode 100644
index 0000000..e3e1998
Binary files /dev/null and b/gallery/line.png differ
diff --git a/gallery/line.typ b/gallery/line.typ
new file mode 100644
index 0000000..702e127
--- /dev/null
+++ b/gallery/line.typ
@@ -0,0 +1,26 @@
+#import "@preview/cetz:0.2.2": canvas
+#import "@preview/cetz-plot:0.1.0": plot
+
+#set page(width: auto, height: auto, margin: .5cm)
+
+#let style = (stroke: black, fill: rgb(0, 0, 200, 75))
+
+#canvas(length: 1cm, {
+ plot.plot(size: (8, 6),
+ x-tick-step: none,
+ x-ticks: ((-calc.pi, $-pi$), (0, $0$), (calc.pi, $pi$)),
+ y-tick-step: 1,
+ {
+ plot.add(
+ style: style,
+ domain: (-calc.pi, calc.pi), calc.sin)
+ plot.add(
+ hypograph: true,
+ style: style,
+ domain: (-calc.pi, calc.pi), calc.cos)
+ plot.add(
+ hypograph: true,
+ style: style,
+ domain: (-calc.pi, calc.pi), x => calc.cos(x + calc.pi))
+ })
+})
diff --git a/gallery/piechart.pdf b/gallery/piechart.pdf
new file mode 100644
index 0000000..cd17ee2
Binary files /dev/null and b/gallery/piechart.pdf differ
diff --git a/gallery/piechart.png b/gallery/piechart.png
new file mode 100644
index 0000000..d5a87ea
Binary files /dev/null and b/gallery/piechart.png differ
diff --git a/gallery/piechart.typ b/gallery/piechart.typ
new file mode 100644
index 0000000..4eb477f
--- /dev/null
+++ b/gallery/piechart.typ
@@ -0,0 +1,32 @@
+#import "@preview/cetz:0.2.2"
+#import "@preview/cetz-plot:0.1.0": chart
+
+#set page(width: auto, height: auto, margin: .5cm)
+
+#let data = (
+ ([Belgium], 24),
+ ([Germany], 31),
+ ([Greece], 18),
+ ([Spain], 21),
+ ([France], 23),
+ ([Hungary], 18),
+ ([Netherlands], 27),
+ ([Romania], 17),
+ ([Finland], 26),
+ ([Turkey], 13),
+)
+
+#cetz.canvas({
+ let colors = gradient.linear(red, blue, green, yellow)
+
+ chart.piechart(
+ data,
+ value-key: 1,
+ label-key: 0,
+ radius: 4,
+ slice-style: colors,
+ inner-radius: 1,
+ outset: 3,
+ inner-label: (content: (value, label) => [#text(white, str(value))], radius: 110%),
+ outer-label: (content: "%", radius: 110%))
+})
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..dc85f31
--- /dev/null
+++ b/justfile
@@ -0,0 +1,22 @@
+# Local Variables:
+# mode: makefile
+# End:
+gallery_dir := "./gallery"
+
+package target *options:
+ ./scripts/package "{{target}}" {{options}}
+
+install target="@local":
+ ./scripts/package "{{target}}"
+
+test *filter:
+ typst-test run {{filter}}
+
+update-test *filter:
+ typst-test update {{filter}}
+
+manual:
+ typst c manual.typ manual.pdf
+
+gallery:
+ for f in "{{gallery_dir}}"/*.typ; do typst c "$f" "${f/typ/png}"; done
diff --git a/manual.typ b/manual.typ
new file mode 100644
index 0000000..1333ed7
--- /dev/null
+++ b/manual.typ
@@ -0,0 +1 @@
+TODO
diff --git a/scripts/package b/scripts/package
new file mode 100755
index 0000000..a30f932
--- /dev/null
+++ b/scripts/package
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+set -eu
+
+PKG_PREFIX="cetz-plot"
+
+# List of all files that get packaged
+files=(
+ src/
+ gallery/
+ typst.toml
+ LICENSE
+ README.md
+ manual.typ
+ manual.pdf
+)
+
+# Local package directories per platform
+if [[ "$OSTYPE" == "linux"* ]]; then
+ DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}"
+elif [[ "$OSTYPE" == "darwin"* ]]; then
+ DATA_DIR="$HOME/Library/Application Support"
+else
+ DATA_DIR="${APPDATA}"
+fi
+
+if (( $# < 1 )) || [[ "${1:-}" == "help" ]]; then
+ echo "package TARGET [--relative-paths]"
+ echo ""
+ echo "Packages all relevant files into a directory named '${PKG_PREFIX}/'"
+ echo "at TARGET. If TARGET is set to @local, the local Typst package directory"
+ echo "will be used so that the package gets installed for local use, if @preview"
+ echo "is used, Typsts preview cache dir will be used."
+ echo "The version is read from 'typst.toml' in the project root."
+ echo ""
+ echo "Local package prefix: $DATA_DIR/typst/package/local"
+ exit 1
+fi
+
+function read-toml() {
+ local file="$1"
+ local key="$2"
+ # Read a key value pair in the format: = ""
+ # stripping surrounding quotes.
+ perl -lne "print \"\$1\" if /^${key}\\s*=\\s*\"(.*)\"/" < "$file"
+}
+
+SOURCE="$(cd "$(dirname "$0")"; pwd -P)/.." # macOS has no realpath
+TARGET="${1:?Missing target path or @local}"; shift
+VERSION="$(read-toml "$SOURCE/typst.toml" "version")"
+
+OPT_RELATIVE_PATHS=false
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --relative-paths)
+ OPT_RELATIVE_PATHS=true
+ shift
+ ;;
+ *)
+ echo "Unexpected option $1!"
+ exit 1
+ ;;
+ esac
+done
+
+if [[ "$TARGET" == "@local" ]] || [[ "$TARGET" == "install" ]]; then
+ TARGET="${DATA_DIR}/typst/packages/local/"
+elif [[ "$TARGET" == "@preview" ]]; then
+ TARGET="${DATA_DIR}/typst/packages/preview/"
+fi
+echo "Install dir: $TARGET"
+
+TMP="$(mktemp -d)"
+
+for f in "${files[@]}"; do
+ mkdir -p "$TMP/$(dirname "$f")" 2>/dev/null
+ cp -r "$SOURCE/$f" "$TMP/$f"
+done
+
+TARGET="${TARGET:?}/${PKG_PREFIX:?}/${VERSION:?}"
+echo "Packaged to: $TARGET"
+if rm -rf "${TARGET:?}" 2>/dev/null; then
+ echo "Overwriting existing version."
+fi
+
+if $OPT_RELATIVE_PATHS; then
+ echo "Changing imports to relative."
+ "$SOURCE/scripts/relpaths" "$TMP"
+fi
+
+mkdir -p "$TARGET"
+mv "$TMP"/* "$TARGET"
diff --git a/scripts/relpaths b/scripts/relpaths
new file mode 100755
index 0000000..ef9677a
--- /dev/null
+++ b/scripts/relpaths
@@ -0,0 +1,20 @@
+#!/bin/env python
+import glob, os, sys, re
+
+import_regexp = re.compile(f'#(import|include)\\s*"(/.+)"')
+
+def replace_imports(filename):
+ s = None
+ with open(filename, "r") as file:
+ s = file.read()
+ def abs_to_rel(captures):
+ g = captures.groups()
+ p = os.path.relpath("." + g[1], os.path.dirname(filename))
+ return f'#{g[0]} "{p}"'
+ s = re.sub(import_regexp, abs_to_rel, s)
+ with open(filename, "w") as file:
+ file.write(s)
+
+os.chdir(sys.argv[1])
+for file in glob.iglob("./**/*.typ", recursive=True):
+ replace_imports(file)
diff --git a/src/axes.typ b/src/axes.typ
new file mode 100644
index 0000000..34ce66b
--- /dev/null
+++ b/src/axes.typ
@@ -0,0 +1,825 @@
+#import "/src/cetz.typ": util, draw, vector, styles, process, drawable, path-util
+
+#let typst-content = content
+
+/// Default axis style
+///
+/// #show-parameter-block("tick-limit", "int", default: 100, [Upper major tick limit.])
+/// #show-parameter-block("minor-tick-limit", "int", default: 1000, [Upper minor tick limit.])
+/// #show-parameter-block("auto-tick-factors", "array", [List of tick factors used for automatic tick step determination.])
+/// #show-parameter-block("auto-tick-count", "int", [Number of ticks to generate by default.])
+/// #show-parameter-block("stroke", "stroke", [Axis stroke style.])
+/// #show-parameter-block("label.offset", "number", [Distance to move axis labels away from the axis.])
+/// #show-parameter-block("label.anchor", "anchor", [Anchor of the axis label to use for it's placement.])
+/// #show-parameter-block("label.angle", "angle", [Angle of the axis label.])
+/// #show-parameter-block("axis-layer", "float", [Layer to draw axes on (see @@on-layer() )])
+/// #show-parameter-block("grid-layer", "float", [Layer to draw the grid on (see @@on-layer() )])
+/// #show-parameter-block("background-layer", "float", [Layer to draw the background on (see @@on-layer() )])
+/// #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.])
+/// #show-parameter-block("overshoot", "number", [School-book style axes only: Extra length to add to the end (right, top) of axes.])
+/// #show-parameter-block("tick.stroke", "stroke", [Major tick stroke style.])
+/// #show-parameter-block("tick.minor-stroke", "stroke", [Minor tick stroke style.])
+/// #show-parameter-block("tick.offset", ("number", "ratio"), [Major tick offset along the tick's direction, can be relative to the length.])
+/// #show-parameter-block("tick.minor-offset", ("number", "ratio"), [Minor tick offset along the tick's direction, can be relative to the length.])
+/// #show-parameter-block("tick.length", ("number"), [Major tick length.])
+/// #show-parameter-block("tick.minor-length", ("number", "ratio"), [Minor tick length, can be relative to the major tick length.])
+/// #show-parameter-block("tick.label.offset", ("number"), [Major tick label offset away from the tick.])
+/// #show-parameter-block("tick.label.angle", ("angle"), [Major tick label angle.])
+/// #show-parameter-block("tick.label.anchor", ("anchor"), [Anchor of major tick labels used for positioning.])
+/// #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.])
+/// #show-parameter-block("grid.stroke", "stroke", [Major grid line stroke style.])
+/// #show-parameter-block("break-point.width", "number", [Axis break width along the axis.])
+/// #show-parameter-block("break-point.length", "number", [Axis break length.])
+/// #show-parameter-block("minor-grid.stroke", "stroke", [Minor grid line stroke style.])
+/// #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$!])
+#let default-style = (
+ tick-limit: 100,
+ minor-tick-limit: 1000,
+ auto-tick-factors: (1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10), // Tick factor to try
+ auto-tick-count: 11, // Number of ticks the plot tries to place
+ fill: none,
+ stroke: auto,
+ label: (
+ offset: .2cm, // Axis label offset
+ anchor: auto, // Axis label anchor
+ angle: auto, // Axis label angle
+ ),
+ axis-layer: 0,
+ grid-layer: 0,
+ background-layer: 0,
+ padding: 0,
+ tick: (
+ fill: none,
+ stroke: black + 1pt,
+ minor-stroke: black + .5pt,
+ offset: 0,
+ minor-offset: 0,
+ length: .1cm, // Tick length: Number
+ minor-length: 70%, // Minor tick length: Number, Ratio
+ label: (
+ offset: .15cm, // Tick label offset
+ angle: 0deg, // Tick label angle
+ anchor: auto, // Tick label anchor
+ "show": auto, // Show tick labels for axes in use
+ )
+ ),
+ break-point: (
+ width: .75cm,
+ length: .15cm,
+ ),
+ grid: (
+ stroke: (paint: gray.lighten(50%), thickness: 1pt),
+ ),
+ minor-grid: (
+ stroke: (paint: gray.lighten(50%), thickness: .5pt),
+ ),
+)
+
+// Default Scientific Style
+#let default-style-scientific = util.merge-dictionary(default-style, (
+ left: (tick: (label: (anchor: "east"))),
+ bottom: (tick: (label: (anchor: "north"))),
+ right: (tick: (label: (anchor: "west"))),
+ top: (tick: (label: (anchor: "south"))),
+ stroke: (cap: "square"),
+ padding: 0,
+))
+
+#let default-style-schoolbook = util.merge-dictionary(default-style, (
+ x: (stroke: auto, fill: none, mark: (start: none, end: "straight"),
+ tick: (label: (anchor: "north"))),
+ y: (stroke: auto, fill: none, mark: (start: none, end: "straight"),
+ tick: (label: (anchor: "east"))),
+ label: (offset: .1cm),
+ origin: (label: (offset: .05cm)),
+ padding: .1cm, // Axis padding on both sides outsides the plotting area
+ overshoot: .5cm, // Axis end "overshoot" out of the plotting area
+ tick: (
+ offset: -50%,
+ minor-offset: -50%,
+ length: .2cm,
+ minor-length: 70%,
+ ),
+ shared-zero: $0$, // Show zero tick label at (0, 0)
+))
+
+#let _prepare-style(ctx, style) = {
+ if type(style) != dictionary { return style }
+
+ let res = util.resolve-number.with(ctx)
+ let rel-to(v, to) = {
+ if type(v) == ratio {
+ return v * to / 100%
+ } else {
+ return res(v)
+ }
+ }
+
+ style.tick.length = res(style.tick.length)
+ style.tick.offset = rel-to(style.tick.offset, style.tick.length)
+ style.tick.minor-length = rel-to(style.tick.minor-length, style.tick.length)
+ style.tick.minor-offset = rel-to(style.tick.minor-offset, style.tick.minor-length)
+ style.tick.label.offset = res(style.tick.label.offset)
+
+ // Break points
+ style.break-point.width = res(style.break-point.width)
+ style.break-point.length = res(style.break-point.length)
+
+ // Padding
+ style.padding = res(style.padding)
+
+ if "overshoot" in style {
+ style.overshoot = res(style.overshoot)
+ }
+
+ return style
+}
+
+#let _get-axis-style(ctx, style, name) = {
+ if not name in style {
+ return style
+ }
+
+ style = styles.resolve(style, merge: style.at(name))
+ return _prepare-style(ctx, style)
+}
+
+#let _get-grid-type(axis) = {
+ let grid = axis.ticks.at("grid", default: false)
+ if grid == "major" or grid == true { return 1 }
+ if grid == "minor" { return 2 }
+ if grid == "both" { return 3 }
+ return 0
+}
+
+#let _inset-axis-points(ctx, style, axis, start, end) = {
+ if axis == none { return (start, end) }
+
+ let (low, high) = axis.inset.map(v => util.resolve-number(ctx, v))
+
+ let is-horizontal = start.at(1) == end.at(1)
+ if is-horizontal {
+ start = vector.add(start, (low, 0))
+ end = vector.sub(end, (high, 0))
+ } else {
+ start = vector.add(start, (0, low))
+ end = vector.sub(end, (0, high))
+ }
+ return (start, end)
+}
+
+#let _draw-axis-line(start, end, axis, is-horizontal, style) = {
+ let enabled = if axis != none and axis.show-break {
+ axis.min > 0 or axis.max < 0
+ } else { false }
+
+ if enabled {
+ let size = if is-horizontal {
+ (style.break-point.width, 0)
+ } else {
+ (0, style.break-point.width, 0)
+ }
+
+ let up = if is-horizontal {
+ (0, style.break-point.length)
+ } else {
+ (style.break-point.length, 0)
+ }
+
+ let add-break(is-end) = {
+ let a = ()
+ let b = (rel: vector.scale(size, .3), update: false)
+ let c = (rel: vector.add(vector.scale(size, .4), vector.scale(up, -1)), update: false)
+ let d = (rel: vector.add(vector.scale(size, .6), vector.scale(up, +1)), update: false)
+ let e = (rel: vector.scale(size, .7), update: false)
+ let f = (rel: size)
+
+ let mark = if is-end {
+ style.at("mark", default: none)
+ }
+ draw.line(a, b, c, d, e, f, stroke: style.stroke, mark: mark)
+ }
+
+ draw.merge-path({
+ draw.move-to(start)
+ if axis.min > 0 {
+ add-break(false)
+ draw.line((rel: size, to: start), end, mark: style.at("mark", default: none))
+ } else if axis.max < 0 {
+ draw.line(start, (rel: vector.scale(size, -1), to: end))
+ add-break(true)
+ }
+ }, stroke: style.stroke)
+ } else {
+ draw.line(start, end, stroke: style.stroke, mark: style.at("mark", default: none))
+ }
+}
+
+// Construct Axis Object
+//
+// - min (number): Minimum value
+// - max (number): Maximum value
+// - ticks (dictionary): Tick settings:
+// - step (number): Major tic step
+// - minor-step (number): Minor tic step
+// - unit (content): Tick label suffix
+// - decimals (int): Tick float decimal length
+// - label (content): Axis label
+#let axis(min: -1, max: 1, label: none,
+ ticks: (step: auto, minor-step: none,
+ unit: none, decimals: 2, grid: false,
+ format: "float")) = (
+ min: min, max: max, ticks: ticks, label: label, inset: (0, 0), show-break: false,
+)
+
+// Format a tick value
+#let format-tick-value(value, tic-options) = {
+ // Without it we get negative zero in conversion
+ // to content! Typst has negative zero floats.
+ if value == 0 { value = 0 }
+
+ let round(value, digits) = {
+ calc.round(value, digits: digits)
+ }
+
+ let format-float(value, digits) = {
+ $#round(value, digits)$
+ }
+
+ let format-sci(value, digits) = {
+ let exponent = if value != 0 {
+ calc.floor(calc.log(calc.abs(value), base: 10))
+ } else {
+ 0
+ }
+
+ let ee = calc.pow(10, calc.abs(exponent + 1))
+ if exponent > 0 {
+ value = value / ee * 10
+ } else if exponent < 0 {
+ value = value * ee * 10
+ }
+
+ value = round(value, digits)
+ if exponent <= -1 or exponent >= 1 {
+ return $#value times 10^#exponent$
+ }
+ return $#value$
+ }
+
+ if type(value) != typst-content {
+ let format = tic-options.at("format", default: "float")
+ if format == none {
+ value = []
+ } else if type(format) == typst-content {
+ value = format
+ } else if type(format) == function {
+ value = (format)(value)
+ } else if format == "sci" {
+ value = format-sci(value, tic-options.at("decimals", default: 2))
+ } else {
+ value = format-float(value, tic-options.at("decimals", default: 2))
+ }
+ } else if type(value) != typst-content {
+ value = str(value)
+ }
+
+ if tic-options.at("unit", default: none) != none {
+ value += tic-options.unit
+ }
+ return value
+}
+
+// Get value on axis [0, 1]
+//
+// - axis (axis): Axis
+// - v (number): Value
+// -> float
+#let value-on-axis(axis, v) = {
+ if v == none { return }
+ let (min, max) = (axis.min, axis.max)
+ let dt = max - min; if dt == 0 { dt = 1 }
+
+ return (v - min) / dt
+}
+
+// Compute list of linear ticks for axis
+//
+// - axis (axis): Axis
+#let compute-linear-ticks(axis, style, add-zero: true) = {
+ let (min, max) = (axis.min, axis.max)
+ let dt = max - min; if (dt == 0) { dt = 1 }
+ let ticks = axis.ticks
+ let ferr = util.float-epsilon
+ let tick-limit = style.tick-limit
+ let minor-tick-limit = style.minor-tick-limit
+
+ let l = ()
+ if ticks != none {
+ let major-tick-values = ()
+ if "step" in ticks and ticks.step != none {
+ assert(ticks.step >= 0,
+ message: "Axis tick step must be positive and non 0.")
+ if axis.min > axis.max { ticks.step *= -1 }
+
+ let s = 1 / ticks.step
+
+ let num-ticks = int(max * s + 1.5) - int(min * s)
+ assert(num-ticks <= tick-limit,
+ message: "Number of major ticks exceeds limit " + str(tick-limit))
+
+ let n = range(int(min * s), int(max * s + 1.5))
+ for t in n {
+ let v = (t / s - min) / dt
+ if t / s == 0 and not add-zero { continue }
+
+ if v >= 0 - ferr and v <= 1 + ferr {
+ l.push((v, format-tick-value(t / s, ticks), true))
+ major-tick-values.push(v)
+ }
+ }
+ }
+
+ if "minor-step" in ticks and ticks.minor-step != none {
+ assert(ticks.minor-step >= 0,
+ message: "Axis minor tick step must be positive")
+
+ let s = 1 / ticks.minor-step
+
+ let num-ticks = int(max * s + 1.5) - int(min * s)
+ assert(num-ticks <= minor-tick-limit,
+ message: "Number of minor ticks exceeds limit " + str(minor-tick-limit))
+
+ let n = range(int(min * s), int(max * s + 1.5))
+ for t in n {
+ let v = (t / s - min) / dt
+ if v in major-tick-values {
+ // Prefer major ticks over minor ticks
+ continue
+ }
+
+ if v != none and v >= 0 and v <= 1 + ferr {
+ l.push((v, none, false))
+ }
+ }
+ }
+
+ }
+
+ return l
+}
+
+// Get list of fixed axis ticks
+//
+// - axis (axis): Axis object
+#let fixed-ticks(axis) = {
+ let l = ()
+ if "list" in axis.ticks {
+ for t in axis.ticks.list {
+ let (v, label) = (none, none)
+ if type(t) in (float, int) {
+ v = t
+ label = format-tick-value(t, axis.ticks)
+ } else {
+ (v, label) = t
+ }
+
+ v = value-on-axis(axis, v)
+ if v != none and v >= 0 and v <= 1 {
+ l.push((v, label, true))
+ }
+ }
+ }
+ return l
+}
+
+// Compute list of axis ticks
+//
+// A tick triple has the format:
+// (rel-value: float, label: content, major: bool)
+//
+// - axis (axis): Axis object
+#let compute-ticks(axis, style, add-zero: true) = {
+ let find-max-n-ticks(axis, n: 11) = {
+ let dt = calc.abs(axis.max - axis.min)
+ let scale = calc.floor(calc.log(dt, base: 10) - 1)
+ if scale > 5 or scale < -5 {return none}
+
+ let (step, best) = (none, 0)
+ for s in style.auto-tick-factors {
+ s = s * calc.pow(10, scale)
+
+ let divs = calc.abs(dt / s)
+ if divs >= best and divs <= n {
+ step = s
+ best = divs
+ }
+ }
+ return step
+ }
+
+ if axis == none or axis.ticks == none { return () }
+ if axis.ticks.step == auto {
+ axis.ticks.step = find-max-n-ticks(axis, n: style.auto-tick-count)
+ }
+ if axis.ticks.minor-step == auto {
+ axis.ticks.minor-step = if axis.ticks.step != none {
+ axis.ticks.step / 5
+ } else {
+ none
+ }
+ }
+
+ let ticks = compute-linear-ticks(axis, style, add-zero: add-zero)
+ ticks += fixed-ticks(axis)
+ return ticks
+}
+
+// Prepares the axis post creation. The given axis
+// must be completely set-up, including its intervall.
+// Returns the prepared axis
+#let prepare-axis(ctx, axis, name) = {
+ let style = styles.resolve(ctx.style, root: "axes",
+ base: default-style-scientific)
+ style = _prepare-style(ctx, style)
+ style = _get-axis-style(ctx, style, name)
+
+ if type(axis.inset) != array {
+ axis.inset = (axis.inset, axis.inset)
+ }
+
+ axis.inset = axis.inset.map(v => util.resolve-number(ctx, v))
+
+ if axis.show-break {
+ if axis.min > 0 {
+ axis.inset.at(0) += style.break-point.width
+ } else if axis.max < 0 {
+ axis.inset.at(1) += style.break-point.width
+ }
+ }
+
+ return axis
+}
+
+// Draw inside viewport coordinates of two axes
+//
+// - size (vector): Axis canvas size (relative to origin)
+// - origin (coordinates): Axis Canvas origin
+// - x (axis): Horizontal axis
+// - y (axis): Vertical axis
+// - name (string,none): Group name
+#let axis-viewport(size, x, y, origin: (0, 0), name: none, body) = {
+ draw.group(name: name, ctx => {
+ let origin = origin
+ let size = size
+
+ origin.at(0) += x.inset.at(0)
+ size.at(0) -= x.inset.sum()
+ origin.at(1) += y.inset.at(0)
+ size.at(1) -= y.inset.sum()
+
+ size = (rel: size, to: origin)
+ draw.set-viewport(origin, size,
+ bounds: (x.max - x.min,
+ y.max - y.min,
+ 0))
+ draw.translate((-x.min, -y.min))
+ body
+ })
+}
+
+// Draw grid lines for the ticks of an axis
+//
+// - cxt (context):
+// - axis (dictionary): The axis
+// - ticks (array): The computed ticks
+// - low (vector): Start position of a grid-line at tick 0
+// - high (vector): End position of a grid-line at tick 0
+// - dir (vector): Normalized grid direction vector along the grid axis
+// - style (style): Axis style
+#let draw-grid-lines(ctx, axis, ticks, low, high, dir, style) = {
+ let offset = (0,0)
+ if axis.inset != none {
+ let (inset-low, inset-high) = axis.inset.map(v => util.resolve-number(ctx, v))
+ offset = vector.scale(vector.norm(dir), inset-low)
+ dir = vector.sub(dir, vector.scale(vector.norm(dir), inset-low + inset-high))
+ }
+
+ let kind = _get-grid-type(axis)
+ if kind > 0 {
+ for (distance, label, is-major) in ticks {
+ let offset = vector.add(vector.scale(dir, distance), offset)
+ let start = vector.add(low, offset)
+ let end = vector.add(high, offset)
+
+ // Draw a major line
+ if is-major and (kind == 1 or kind == 3) {
+ draw.line(start, end, stroke: style.grid.stroke)
+ }
+ // Draw a minor line
+ if not is-major and kind >= 2 {
+ draw.line(start, end, stroke: style.minor-grid.stroke)
+ }
+ }
+ }
+}
+
+// Place a list of tick marks and labels along a path
+#let place-ticks-on-line(ticks, start, stop, style, flip: false, is-mirror: false) = {
+ let dir = vector.sub(stop, start)
+ let norm = vector.norm((-dir.at(1), dir.at(0), dir.at(2, default: 0)))
+
+ let def(v, d) = {
+ return if v == none or v == auto {d} else {v}
+ }
+
+ let show-label = style.tick.label.show
+ if show-label == auto {
+ show-label = not is-mirror
+ }
+
+ for (distance, label, is-major) in ticks {
+ let offset = style.tick.offset
+ let length = if is-major { style.tick.length } else { style.tick.minor-length }
+ if flip {
+ offset *= -1
+ length *= -1
+ }
+
+ let pt = vector.lerp(start, stop, distance)
+ let a = vector.add(pt, vector.scale(norm, offset))
+ let b = vector.add(a, vector.scale(norm, length))
+
+ draw.line(a, b, stroke: style.tick.stroke)
+
+ if show-label and label != none {
+ let offset = style.tick.label.offset
+ if flip {
+ offset *= -1
+ length *= -1
+ }
+
+ let c = vector.sub(if length <= 0 { b } else { a },
+ vector.scale(norm, offset))
+
+ let angle = def(style.tick.label.angle, 0deg)
+ let anchor = def(style.tick.label.anchor, "center")
+
+ draw.content(c, [#label], angle: angle, anchor: anchor)
+ }
+ }
+}
+
+// Draw up to four axes in an "scientific" style at origin (0, 0)
+//
+// - size (array): Size (width, height)
+// - left (axis): Left (y) axis
+// - bottom (axis): Bottom (x) axis
+// - right (axis): Right axis
+// - top (axis): Top axis
+// - name (string): Object name
+// - draw-unset (bool): Draw axes that are set to `none`
+// - ..style (any): Style
+#let scientific(size: (1, 1),
+ left: none,
+ right: auto,
+ bottom: none,
+ top: auto,
+ draw-unset: true,
+ name: none,
+ ..style) = {
+ import draw: *
+
+ if right == auto {
+ if left != none {
+ right = left; right.is-mirror = true
+ } else {
+ right = none
+ }
+ }
+ if top == auto {
+ if bottom != none {
+ top = bottom; top.is-mirror = true
+ } else {
+ top = none
+ }
+ }
+
+ group(name: name, ctx => {
+ let (w, h) = size
+ anchor("origin", (0, 0))
+
+ let style = style.named()
+ style = styles.resolve(ctx.style, merge: style, root: "axes",
+ base: default-style-scientific)
+ style = _prepare-style(ctx, style)
+
+ // Compute ticks
+ let x-ticks = compute-ticks(bottom, style)
+ let y-ticks = compute-ticks(left, style)
+ let x2-ticks = compute-ticks(top, style)
+ let y2-ticks = compute-ticks(right, style)
+
+ // Draw frame
+ if style.fill != none {
+ on-layer(style.background-layer, {
+ rect((0,0), (w,h), fill: style.fill, stroke: none)
+ })
+ }
+
+ // Draw grid
+ group(name: "grid", ctx => {
+ let axes = (
+ ("bottom", (0,0), (0,h), (+w,0), x-ticks, bottom),
+ ("top", (0,h), (0,0), (+w,0), x2-ticks, top),
+ ("left", (0,0), (w,0), (0,+h), y-ticks, left),
+ ("right", (w,0), (0,0), (0,+h), y2-ticks, right),
+ )
+ for (name, start, end, direction, ticks, axis) in axes {
+ if axis == none { continue }
+
+ let style = _get-axis-style(ctx, style, name)
+ let is-mirror = axis.at("is-mirror", default: false)
+
+ if not is-mirror {
+ on-layer(style.grid-layer, {
+ draw-grid-lines(ctx, axis, ticks, start, end, direction, style)
+ })
+ }
+ }
+ })
+
+ // Draw axes
+ group(name: "axes", {
+ let axes = (
+ ("bottom", (0, 0), (w, 0), (0, -1), false, x-ticks, bottom,),
+ ("top", (0, h), (w, h), (0, +1), true, x2-ticks, top,),
+ ("left", (0, 0), (0, h), (-1, 0), true, y-ticks, left,),
+ ("right", (w, 0), (w, h), (+1, 0), false, y2-ticks, right,)
+ )
+ let label-placement = (
+ bottom: ("south", "north", 0deg),
+ top: ("north", "south", 0deg),
+ left: ("west", "south", 90deg),
+ right: ("east", "north", 90deg),
+ )
+
+ for (name, start, end, outsides, flip, ticks, axis) in axes {
+ let style = _get-axis-style(ctx, style, name)
+ let is-mirror = axis == none or axis.at("is-mirror", default: false)
+ let is-horizontal = name in ("bottom", "top")
+
+ if style.padding != 0 {
+ let padding = vector.scale(outsides, style.padding)
+ start = vector.add(start, padding)
+ end = vector.add(end, padding)
+ }
+
+ let (data-start, data-end) = _inset-axis-points(ctx, style, axis, start, end)
+
+ let path = _draw-axis-line(start, end, axis, is-horizontal, style)
+ on-layer(style.axis-layer, {
+ group(name: "axis", {
+ if draw-unset or axis != none {
+ path;
+ place-ticks-on-line(ticks, data-start, data-end, style, flip: flip, is-mirror: is-mirror)
+ }
+ })
+
+ if axis != none and axis.label != none and not is-mirror {
+ let offset = vector.scale(outsides, style.label.offset)
+ let (group-anchor, content-anchor, angle) = label-placement.at(name)
+
+ if style.label.anchor != auto {
+ content-anchor = style.label.anchor
+ }
+ if style.label.angle != auto {
+ angle = style.label.angle
+ }
+
+ content((rel: offset, to: "axis." + group-anchor),
+ [#axis.label],
+ angle: angle,
+ anchor: content-anchor)
+ }
+ })
+ }
+ })
+ })
+}
+
+// Draw two axes in a "school book" style
+//
+// - x-axis (axis): X axis
+// - y-axis (axis): Y axis
+// - size (array): Size (width, height)
+// - x-position (number): X Axis position
+// - y-position (number): Y Axis position
+// - name (string): Object name
+// - ..style (any): Style
+#let school-book(x-axis, y-axis,
+ size: (1, 1),
+ x-position: 0,
+ y-position: 0,
+ name: none,
+ ..style) = {
+ import draw: *
+
+ group(name: name, ctx => {
+ let (w, h) = size
+ anchor("origin", (0, 0))
+
+ let style = style.named()
+ style = styles.resolve(
+ ctx.style,
+ merge: style,
+ root: "axes",
+ base: default-style-schoolbook)
+ style = _prepare-style(ctx, style)
+
+ let x-position = calc.min(calc.max(y-axis.min, x-position), y-axis.max)
+ let y-position = calc.min(calc.max(x-axis.min, y-position), x-axis.max)
+ let x-y = value-on-axis(y-axis, x-position) * h
+ let y-x = value-on-axis(x-axis, y-position) * w
+
+ let shared-zero = style.shared-zero != false and x-position == 0 and y-position == 0
+
+ let x-ticks = compute-ticks(x-axis, style, add-zero: not shared-zero)
+ let y-ticks = compute-ticks(y-axis, style, add-zero: not shared-zero)
+
+ // Draw grid
+ group(name: "grid", ctx => {
+ let axes = (
+ ("x", (0,0), (0,h), (+w,0), x-ticks, x-axis),
+ ("y", (0,0), (w,0), (0,+h), y-ticks, y-axis),
+ )
+
+ for (name, start, end, direction, ticks, axis) in axes {
+ if axis == none { continue }
+
+ let style = _get-axis-style(ctx, style, name)
+ on-layer(style.grid-layer, {
+ draw-grid-lines(ctx, axis, ticks, start, end, direction, style)
+ })
+ }
+ })
+
+ // Draw axes
+ group(name: "axes", {
+ let axes = (
+ ("x", (0, x-y), (w, x-y), (1, 0), false, x-ticks, x-axis),
+ ("y", (y-x, 0), (y-x, h), (0, 1), true, y-ticks, y-axis),
+ )
+ let label-pos = (
+ x: ("north", (0,-1)),
+ y: ("east", (-1,0)),
+ )
+
+ on-layer(style.axis-layer, {
+ for (name, start, end, dir, flip, ticks, axis) in axes {
+ let style = _get-axis-style(ctx, style, name)
+
+ let pad = style.padding
+ let overshoot = style.overshoot
+ let vstart = vector.sub(start, vector.scale(dir, pad))
+ let vend = vector.add(end, vector.scale(dir, pad + overshoot))
+ let is-horizontal = name == "x"
+
+ let (data-start, data-end) = _inset-axis-points(ctx, style, axis, start, end)
+ group(name: "axis", {
+ _draw-axis-line(vstart, vend, axis, is-horizontal, style)
+ place-ticks-on-line(ticks, data-start, data-end, style, flip: flip)
+ })
+
+ if axis.label != none {
+ let (content-anchor, offset-dir) = label-pos.at(name)
+
+ let angle = if style.label.angle not in (none, auto) {
+ style.label.angle
+ } else { 0deg }
+ if style.label.anchor not in (none, auto) {
+ content-anchor = style.label.anchor
+ }
+
+ let offset = vector.scale(offset-dir, style.label.offset)
+ content((rel: offset, to: vend),
+ [#axis.label],
+ angle: angle,
+ anchor: content-anchor)
+ }
+ }
+
+ if shared-zero {
+ let pt = (rel: (-style.tick.label.offset, -style.tick.label.offset),
+ to: (y-x, x-y))
+ let zero = if type(style.shared-zero) == typst-content {
+ style.shared-zero
+ } else {
+ $0$
+ }
+ content(pt, zero, anchor: "north-east")
+ }
+ })
+ })
+ })
+}
diff --git a/src/cetz.typ b/src/cetz.typ
new file mode 100644
index 0000000..0b00a19
--- /dev/null
+++ b/src/cetz.typ
@@ -0,0 +1,2 @@
+// Import cetz into the root scope. Import cetz by importing this file only!
+#import "@preview/cetz:0.2.2": *
diff --git a/src/chart.typ b/src/chart.typ
new file mode 100644
index 0000000..251cf65
--- /dev/null
+++ b/src/chart.typ
@@ -0,0 +1,4 @@
+#import "chart/boxwhisker.typ": boxwhisker, boxwhisker-default-style
+#import "chart/barchart.typ": barchart, barchart-default-style
+#import "chart/columnchart.typ": columnchart, columnchart-default-style
+#import "chart/piechart.typ": piechart, piechart-default-style
diff --git a/src/chart/barchart.typ b/src/chart/barchart.typ
new file mode 100644
index 0000000..cbb42b7
--- /dev/null
+++ b/src/chart/barchart.typ
@@ -0,0 +1,141 @@
+#import "/src/cetz.typ": draw, styles, palette
+
+#import "/src/plot.typ"
+
+#let barchart-default-style = (
+ axes: (tick: (length: 0), grid: (stroke: (dash: "dotted"))),
+ bar-width: .8,
+ cluster-gap: 0,
+ error: (
+ whisker-size: .25,
+ ),
+ y-inset: 1,
+)
+
+/// Draw a bar chart. A bar chart is a chart that represents data with
+/// rectangular bars that grow from left to right, proportional to the values
+/// they represent. For examples see @barchart-examples.
+///
+/// = Styling
+/// *Root*: `barchart`.
+/// #show-parameter-block("bar-width", "float", default: .8, [
+/// Width of a single bar (basic) or a cluster of bars (clustered) in the plot.])
+/// #show-parameter-block("y-inset", "float", default: 1, [
+/// Distance of the plot data to the plot's edges on the y-axis of the plot.])
+/// You can use any `plot` or `axes` related style keys, too.
+///
+/// The `barchart` function is a wrapper of the `plot` API. Arguments passed
+/// to `..plot-args` are passed to the `plot.plot` function.
+///
+/// - data (array): Array of data rows. A row can be of type array or
+/// dictionary, with `label-key` and `value-key` being
+/// the keys to access a rows label and value(s).
+///
+/// *Example*
+/// ```typc
+/// (([A], 1), ([B], 2), ([C], 3),)
+/// ```
+/// - label-key (int,string): Key to access the label of a data row.
+/// This key is used as argument to the
+/// rows `.at(..)` function.
+/// - value-key (int,string): Key(s) to access values of a data row.
+/// These keys are used as argument to the
+/// rows `.at(..)` function.
+/// - error-key (none,int,string): Key(s) to access error values of a data row.
+/// These keys are used as argument to the
+/// rows `.at(..)` function.
+/// - mode (string): Chart mode:
+/// / basic: Single bar per data row
+/// / clustered: Group of bars per data row
+/// / stacked: Stacked bars per data row
+/// / stacked100: Stacked bars per data row relative
+/// to the sum of the row
+/// - size (array): Chart size as width and height tuple in canvas unist;
+/// width can be set to `auto`.
+/// - bar-style (style,function): Style or function (idx => style) to use for
+/// each bar, accepts a palette function.
+/// - x-unit (content,auto): Tick suffix added to each tick label
+/// - y-label (content,none): Y axis label
+/// - x-label (content,none): x axis label
+/// - labels (none,content): Legend labels per x value group
+/// - ..plot-args (any): Arguments to pass to `plot.plot`
+#let barchart(data,
+ label-key: 0,
+ value-key: 1,
+ error-key: none,
+ mode: "basic",
+ size: (auto, 1),
+ bar-style: palette.red,
+ x-label: none,
+ x-unit: auto,
+ y-label: none,
+ labels: none,
+ ..plot-args
+ ) = {
+ assert(type(label-key) in (int, str))
+ if mode == "basic" {
+ assert(type(value-key) in (int, str))
+ } else {
+ assert(type(value-key) == array)
+ }
+
+ if type(value-key) != array {
+ value-key = (value-key,)
+ }
+
+ if error-key == none {
+ error-key = ()
+ } else if type(error-key) != array {
+ error-key = (error-key,)
+ }
+
+ if type(size) != array {
+ size = (size, auto)
+ }
+ if size.at(1) == auto {
+ size.at(1) = (data.len() + 1)
+ }
+
+ let y-tic-list = data.enumerate().map(((i, t)) => {
+ (data.len() - i - 1, t.at(label-key))
+ })
+
+ let x-unit = x-unit
+ if x-unit == auto {
+ x-unit = if mode == "stacked100" {[%]} else []
+ }
+
+ data = data.enumerate().map(((i, d)) => {
+ (data.len() - i - 1, value-key.map(k => d.at(k, default: 0)).flatten(), error-key.map(k => d.at(k, default: 0)).flatten())
+ })
+
+ draw.group(ctx => {
+ let style = styles.resolve(ctx.style, merge: (:),
+ root: "barchart", base: barchart-default-style)
+ draw.set-style(..style)
+
+ let y-inset = calc.max(style.y-inset, style.bar-width / 2)
+ plot.plot(size: size,
+ axis-style: "scientific-auto",
+ x-label: x-label,
+ x-grid: true,
+ y-label: y-label,
+ y-min: -y-inset,
+ y-max: data.len() + y-inset - 1,
+ y-tick-step: none,
+ y-ticks: y-tic-list,
+ plot-style: bar-style,
+ ..plot-args,
+ {
+ plot.add-bar(data,
+ x-key: 0,
+ y-key: 1,
+ error-key: if mode in ("basic", "clustered") { 2 },
+ mode: mode,
+ labels: labels,
+ bar-width: -style.bar-width,
+ cluster-gap: style.cluster-gap,
+ axes: ("y", "x"))
+ })
+ })
+}
diff --git a/src/chart/barcol-common.typ b/src/chart/barcol-common.typ
new file mode 100644
index 0000000..0c09a52
--- /dev/null
+++ b/src/chart/barcol-common.typ
@@ -0,0 +1,40 @@
+// Valid bar- and columnchart modes
+#let barchart-modes = (
+ "basic", "clustered", "stacked", "stacked100"
+)
+
+// Functions for max value calculation
+#let barchart-max-value-fn = (
+ basic: (data, value-key) => {
+ calc.max(0, ..data.map(t => t.at(value-key)))
+ },
+ clustered: (data, value-key) => {
+ calc.max(0, ..data.map(t => calc.max(
+ ..value-key.map(k => t.at(k)))))
+ },
+ stacked: (data, value-key) => {
+ calc.max(0, ..data.map(t =>
+ value-key.map(k => t.at(k)).sum()))
+ },
+ stacked100: (..) => {
+ 100
+ }
+)
+
+// Functions for min value calculation
+#let barchart-min-value-fn = (
+ basic: (data, value-key) => {
+ calc.min(0, ..data.map(t => t.at(value-key)))
+ },
+ clustered: (data, value-key) => {
+ calc.min(0, ..data.map(t => calc.max(
+ ..value-key.map(k => t.at(k)))))
+ },
+ stacked: (data, value-key) => {
+ calc.min(0, ..data.map(t =>
+ value-key.map(k => t.at(k)).sum()))
+ },
+ stacked100: (..) => {
+ 0
+ }
+)
diff --git a/src/chart/boxwhisker.typ b/src/chart/boxwhisker.typ
new file mode 100644
index 0000000..1d643cf
--- /dev/null
+++ b/src/chart/boxwhisker.typ
@@ -0,0 +1,97 @@
+#import "/src/cetz.typ": draw, styles, palette, util, vector, intersection
+
+#import "/src/plot.typ"
+
+#let boxwhisker-default-style = (
+ axes: (tick: (length: 0), grid: (stroke: (dash: "dotted"))),
+ box-width: 0.75,
+ whisker-width: 0.5,
+ mark-size: 0.15,
+)
+
+/// Add one or more box or whisker plots.
+///
+/// #example(```
+/// cetz.chart.boxwhisker(size: (2,2), label-key: none,
+/// y-min: 0, y-max: 70, y-tick-step: 20,
+/// (x: 1, min: 15, max: 60,
+/// q1: 25, q2: 35, q3: 50))
+/// ```)
+///
+/// = Styling
+/// *Root* `boxwhisker`
+/// #show-parameter-block("box-width", "float", default: .75, [
+/// The width of the box. Since boxes are placed 1 unit next to each other,
+/// a width of $1$ would make neighbouring boxes touch.])
+/// #show-parameter-block("whisker-width", "float", default: .5, [
+/// The width of the whisker, that is the horizontal bar on the top and bottom
+/// of the box.])
+/// #show-parameter-block("mark-size", "float", default: .15, [
+/// The scaling of the mark for the boxes outlier values in canvas units.])
+/// You can use any `plot` or `axes` related style keys, too.
+///
+/// - data (array, dictionary): Dictionary or array of dictionaries containing the
+/// needed entries to plot box and whisker plot.
+///
+/// See `plot.add-boxwhisker` for more details.
+///
+/// *Examples:*
+/// - ```typc
+/// (x: 1 // Location on x-axis
+/// outliers: (7, 65, 69), // Optional outliers
+/// min: 15, max: 60 // Minimum and maximum
+/// q1: 25, // Quartiles: Lower
+/// q2: 35, // Median
+/// q3: 50) // Upper
+/// ```
+/// - size (array) : Size of chart. If the second entry is auto, it automatically scales to accommodate the number of entries plotted
+/// - label-key (integer, string): Index in the array where labels of each entry is stored
+/// - mark (string): Mark to use for plotting outliers. Set `none` to disable. Defaults to "x"
+/// - ..plot-args (any): Additional arguments are passed to `plot.plot`
+#let boxwhisker(data,
+ size: (1, auto),
+ label-key: 0,
+ mark: "*",
+ ..plot-args
+ ) = {
+ if type(data) == dictionary { data = (data,) }
+
+ if type(size) != array {
+ size = (size, auto)
+ }
+ if size.at(1) == auto {
+ size.at(1) = (data.len() + 1)
+ }
+
+ let x-tick-list = data.enumerate().map(((i, t)) => {
+ (i + 1, if label-key != none { t.at(label-key, default: i) } else { [] })
+ })
+
+ draw.group(ctx => {
+ let style = styles.resolve(ctx.style, merge: (:),
+ root: "boxwhisker", base: boxwhisker-default-style)
+ draw.set-style(..style)
+
+ plot.plot(
+ size: size,
+ axis-style: "scientific-auto",
+ x-tick-step: none,
+ x-ticks: x-tick-list,
+ y-grid: true,
+ x-label: none,
+ y-label: none,
+ ..plot-args,
+ {
+ for (i, row) in data.enumerate() {
+ plot.add-boxwhisker(
+ (x: i + 1, ..row),
+ box-width: style.box-width,
+ whisker-width: style.whisker-width,
+ style: (:),
+ mark: mark,
+ mark-size: style.mark-size
+ )
+ }
+ })
+ })
+}
diff --git a/src/chart/columnchart.typ b/src/chart/columnchart.typ
new file mode 100644
index 0000000..d05db24
--- /dev/null
+++ b/src/chart/columnchart.typ
@@ -0,0 +1,141 @@
+#import "/src/cetz.typ": draw, styles, palette, util, vector, intersection
+
+#import "/src/plot.typ"
+
+#let columnchart-default-style = (
+ axes: (tick: (length: 0), grid: (stroke: (dash: "dotted"))),
+ bar-width: .8,
+ cluster-gap: 0,
+ error: (
+ whisker-size: .25,
+ ),
+ x-inset: 1,
+)
+
+/// Draw a column chart. A column chart is a chart that represents data with
+/// rectangular bars that grow from bottom to top, proportional to the values
+/// they represent. For examples see @columnchart-examples.
+///
+/// = Styling
+/// *Root*: `columnchart`.
+/// #show-parameter-block("bar-width", "float", default: .8, [
+/// Width of a single bar (basic) or a cluster of bars (clustered) in the plot.])
+/// #show-parameter-block("x-inset", "float", default: 1, [
+/// Distance of the plot data to the plot's edges on the x-axis of the plot.])
+/// You can use any `plot` or `axes` related style keys, too.
+///
+/// The `columnchart` function is a wrapper of the `plot` API. Arguments passed
+/// to `..plot-args` are passed to the `plot.plot` function.
+///
+/// - data (array): Array of data rows. A row can be of type array or
+/// dictionary, with `label-key` and `value-key` being
+/// the keys to access a rows label and value(s).
+///
+/// *Example*
+/// ```typc
+/// (([A], 1), ([B], 2), ([C], 3),)
+/// ```
+/// - label-key (int,string): Key to access the label of a data row.
+/// This key is used as argument to the
+/// rows `.at(..)` function.
+/// - value-key (int,string): Key(s) to access value(s) of data row.
+/// These keys are used as argument to the
+/// rows `.at(..)` function.
+/// - error-key (none,int,string): Key(s) to access error values of a data row.
+/// These keys are used as argument to the
+/// rows `.at(..)` function.
+/// - mode (string): Chart mode:
+/// / basic: Single bar per data row
+/// / clustered: Group of bars per data row
+/// / stacked: Stacked bars per data row
+/// / stacked100: Stacked bars per data row relative
+/// to the sum of the row
+/// - size (array): Chart size as width and height tuple in canvas unist;
+/// width can be set to `auto`.
+/// - bar-style (style,function): Style or function (idx => style) to use for
+/// each bar, accepts a palette function.
+/// - y-unit (content,auto): Tick suffix added to each tick label
+/// - y-label (content,none): Y axis label
+/// - x-label (content,none): x axis label
+/// - labels (none,content): Legend labels per y value group
+/// - ..plot-args (any): Arguments to pass to `plot.plot`
+#let columnchart(data,
+ label-key: 0,
+ value-key: 1,
+ error-key: none,
+ mode: "basic",
+ size: (auto, 1),
+ bar-style: palette.red,
+ x-label: none,
+ y-unit: auto,
+ y-label: none,
+ labels: none,
+ ..plot-args
+ ) = {
+ assert(type(label-key) in (int, str))
+ if mode == "basic" {
+ assert(type(value-key) in (int, str))
+ }
+
+ if type(value-key) != array {
+ value-key = (value-key,)
+ }
+
+ if error-key == none {
+ error-key = ()
+ } else if type(error-key) != array {
+ error-key = (error-key,)
+ }
+
+ if type(size) != array {
+ size = (auto, size)
+ }
+ if size.at(0) == auto {
+ size.at(0) = (data.len() + 1)
+ }
+
+ let x-tic-list = data.enumerate().map(((i, t)) => {
+ (i, t.at(label-key))
+ })
+
+ let y-unit = y-unit
+ if y-unit == auto {
+ y-unit = if mode == "stacked100" {[%]} else []
+ }
+
+ data = data.enumerate().map(((i, d)) => {
+ (i, value-key.map(k => d.at(k)).flatten(), error-key.map(k => d.at(k, default: 0)).flatten())
+ })
+
+ draw.group(ctx => {
+ let style = styles.resolve(ctx.style, merge: (:),
+ root: "columnchart", base: columnchart-default-style)
+ draw.set-style(..style)
+
+ let x-inset = calc.max(style.x-inset, style.bar-width / 2)
+ plot.plot(size: size,
+ axis-style: "scientific-auto",
+ y-grid: true,
+ y-label: y-label,
+ x-min: -x-inset,
+ x-max: data.len() + x-inset - 1,
+ x-tick-step: none,
+ x-ticks: x-tic-list,
+ x-label: x-label,
+ plot-style: bar-style,
+ ..plot-args,
+ {
+ plot.add-bar(data,
+ x-key: 0,
+ y-key: 1,
+ error-key: if mode in ("basic", "clustered") { 2 },
+ mode: mode,
+ labels: labels,
+ bar-width: style.bar-width,
+ cluster-gap: style.cluster-gap,
+ error-style: style.error,
+ whisker-size: style.error.whisker-size,
+ axes: ("x", "y"))
+ })
+ })
+}
diff --git a/src/chart/piechart.typ b/src/chart/piechart.typ
new file mode 100644
index 0000000..a1c1ebc
--- /dev/null
+++ b/src/chart/piechart.typ
@@ -0,0 +1,431 @@
+#import "/src/cetz.typ": draw, styles, palette, util, vector, intersection
+#import util: circle-arclen
+
+// Piechart Label Kind
+#let label-kind = (value: "VALUE", percentage: "%", label: "LABEL")
+
+// Piechart Default Style
+#let default-style = (
+ stroke: auto,
+ fill: auto,
+ /// Outer chart radius
+ radius: 1,
+ /// Inner slice radius
+ inner-radius: 0,
+ /// Gap between items. This can be a canvas length or an angle
+ gap: 0.5deg,
+ /// Outset offset, absolute or relative to radius
+ outset-offset: 10%,
+ /// Pie outset mode:
+ /// - "OFFSET": Offset slice position by outset-offset
+ /// - "RADIUS": Offset slice radius by outset-offset (the slice gets scaled)
+ outset-mode: "OFFSET",
+ /// Pie start angle
+ start: 90deg,
+ /// Pie stop angle
+ stop: 360deg + 90deg,
+ /// Pie rotation direction (true = clockwise, false = anti-clockwise)
+ clockwise: true,
+ outer-label: (
+ /// Label kind
+ /// If set to a function, that function gets called with (value, label) of each item
+ content: label-kind.label,
+ /// Absolute radius or percentage of radius
+ radius: 125%,
+ /// Absolute angle or auto to use secant of the slice as direction
+ angle: 0deg,
+ /// Label anchor
+ anchor: "center",
+ ),
+ inner-label: (
+ /// Label kind
+ /// If set to a function, that function gets called with (value, label) of each item
+ content: none,
+ /// Absolute radius or percentage of the mid between radius and inner-radius
+ radius: 150%,
+ /// Absolute angle or auto to use secant of the slice as direction
+ angle: 0deg,
+ /// Label anchor
+ anchor: "center",
+ ),
+)
+#let piechart-default-style = default-style
+
+/// Draw a pie- or donut-chart
+///
+/// #example(```
+/// import cetz.chart
+/// let data = (24, 31, 18, 21, 23, 18, 27, 17, 26, 13)
+/// let colors = gradient.linear(red, blue, green, yellow)
+///
+/// chart.piechart(
+/// data,
+/// radius: 1.5,
+/// slice-style: colors,
+/// inner-radius: .5,
+/// outer-label: (content: "%",))
+/// ```)
+///
+/// = Styling
+/// *Root* `piechart` \
+/// #show-parameter-block("radius", ("number"), [
+/// Outer radius of the chart.], default: 1)
+/// #show-parameter-block("inner-radius", ("number"), [
+/// Inner radius of the chart slices. If greater than zero, the chart becomes
+/// a "donut-chart".], default: 0)
+/// #show-parameter-block("gap", ("number", "angle"), [
+/// Gap between chart slices to leave empty. This does not increase the charts
+/// radius by pushing slices outwards, but instead shrinks the slice. Big
+/// values can result in slices becoming invisible if no space is left.], default: 0.5deg)
+/// #show-parameter-block("outset-offset", ("number", "ratio"), [
+/// Absolute, or radius relative distance to push slices marked for
+/// "outsetting" outwards from the center of the chart.], default: 10%)
+/// #show-parameter-block("outset-offset", ("string"), [
+/// The mode of how to perform "outsetting" of slices:
+/// - "OFFSET": Offset slice position by `outset-offset`, increasing their gap to their siblings
+/// - "RADIUS": Offset slice radius by `outset-offset`, which scales the slice and leaves the gap unchanged], default: "OFFSET")
+/// #show-parameter-block("start", ("angle"), [
+/// The pie-charts start angle (ccw). You can use this to draw charts not forming a full circle.], default: 90deg)
+/// #show-parameter-block("stop", ("angle"), [
+/// The pie-charts stop angle (ccw).], default: 360deg + 90deg)
+/// #show-parameter-block("clockwise", ("bool"), [
+/// The pie-charts rotation direction.], default: true)
+/// #show-parameter-block("outer-label.content", ("none","string","function"), [
+/// Content to display outsides the charts slices.
+/// There are the following predefined values:
+/// / LABEL: Display the slices label (see `label-key`)
+/// / %: Display the percentage of the items value in relation to the sum of
+/// all values, rounded to the next integer
+/// / VALUE: Display the slices value
+/// If passed a `` of the format `(value, label) => content`,
+/// that function gets called with each slices value and label and must return
+/// content, that gets displayed.], default: "LABEL")
+/// #show-parameter-block("outer-label.radius", ("number","ratio"), [
+/// Absolute, or radius relative distance from the charts center to position
+/// outer labels at.], default: 125%)
+/// #show-parameter-block("outer-label.angle", ("angle","auto"), [
+/// The angle of the outer label. If passed `auto`, the label gets rotated,
+/// so that the baseline is parallel to the slices secant. ], default: 0deg)
+/// #show-parameter-block("outer-label.anchor", ("string"), [
+/// The anchor of the outer label to use for positioning.], default: "center")
+/// #show-parameter-block("inner-label.content", ("none","string","function"), [
+/// Content to display insides the charts slices.
+/// See `outer-label.content` for the possible values.], default: none)
+/// #show-parameter-block("inner-label.radius", ("number","ratio"), [
+/// Distance of the inner label to the charts center. If passed a ``,
+/// that ratio is relative to the mid between the inner and outer radius (`inner-radius` and `radius`)
+/// of the chart], default: 150%)
+/// #show-parameter-block("inner-label.angle", ("angle","auto"), [
+/// See `outer-label.angle`.], default: 0deg)
+/// #show-parameter-block("inner-label.anchor", ("string"), [
+/// See `outer-label.anchor`.], default: "center")
+///
+/// = Anchors
+/// The chart places one anchor per item at the radius of it's slice that
+/// gets named `"item-"` (outer radius) and `"item--inner"` (inner radius),
+/// where index is the index of the sclice data in `data`.
+///
+/// - data (array): Array of data items. A data item can be:
+/// - A number: A number that is used as the fraction of the slice
+/// - An array: An array which is read depending on value-key, label-key and outset-key
+/// - A dictionary: A dictionary which is read depending on value-key, label-key and outset-key
+/// - value-key (none,int,string): Key of the "value" of a data item. If for example
+/// data items are passed as dictionaries, the value-key is the key of the dictionary to
+/// access the items chart value.
+/// - label-key (none,int,string): Same as the value-key but for getting an items label content.
+/// - outset-key (none,int,string): Same as the value-key but for getting if an item should get outset (highlighted). The
+/// outset can be a bool, float or ratio. If of type `bool`, the outset distance from the
+/// style gets used.
+/// - outset (none,int,array): A single or multiple indices of items that should get offset from the center to the outsides
+/// of the chart. Only used if outset-key is none!
+/// - slice-style (function,array,gradient): Slice style of the following types:
+/// - function: A function of the form `index => style` that must return a style dictionary.
+/// This can be a `palette` function.
+/// - array: An array of style dictionaries or fill colors of at least one item. For each slice the style at the slices
+/// index modulo the arrays length gets used.
+/// - gradient: A gradient that gets sampled for each data item using the the slices
+/// index divided by the number of slices as position on the gradient.
+/// If one of stroke or fill is not in the style dictionary, it is taken from the charts style.
+#let piechart(data,
+ value-key: none,
+ label-key: none,
+ outset-key: none,
+ outset: none,
+ slice-style: palette.red,
+ name: none,
+ ..style) = {
+ import draw: *
+
+ // Prepare data by converting it to tuples of the format
+ // (value, label, outset)
+ data = data.enumerate().map(((i, item)) => (
+ if value-key != none {
+ item.at(value-key)
+ } else {
+ item
+ },
+ if label-key != none {
+ item.at(label-key)
+ } else {
+ none
+ },
+ if outset-key != none {
+ item.at(outset-key, default: false)
+ } else if outset != none {
+ i == outset or (type(outset) == array and i in outset)
+ } else {
+ false
+ }
+ ))
+
+ let sum = data.map(((value, ..)) => value).sum()
+ if sum == 0 {
+ sum = 1
+ }
+
+ group(name: name, ctx => {
+ anchor("default", (0,0))
+
+ let style = styles.resolve(ctx,
+ merge: style.named(), root: "piechart", base: default-style)
+
+ let gap = style.gap
+ if type(gap) != angle {
+ gap = gap / (2 * calc.pi * style.radius) * 360deg
+ }
+ assert(gap < 360deg / data.len(),
+ message: "Gap angle is too big for " + str(data.len()) + "items. Maximum gap angle: " + repr(360deg / data.len()))
+
+ let radius = style.radius
+ assert(radius > 0,
+ message: "Radius must be > 0.")
+
+ let inner-radius = style.inner-radius
+ assert(inner-radius >= 0 and inner-radius <= radius,
+ message: "Radius must be >= 0 and <= radius.")
+
+ assert(style.outset-mode in ("OFFSET", "RADIUS"),
+ message: "Outset mode must be 'OFFSET' or 'RADIUS', but is: " + str(style.outset-mode))
+
+ let style-at = if type(slice-style) == function {
+ slice-style
+ } else if type(slice-style) == array {
+ i => {
+ let s = slice-style.at(calc.rem(i, slice-style.len()))
+ if type(s) == color {
+ (fill: s)
+ } else {
+ s
+ }
+ }
+ } else if type(slice-style) == gradient {
+ i => (fill: slice-style.sample(i / data.len() * 100%))
+ }
+
+ let start-angle = style.start
+ let stop-angle = style.stop
+ let f = (stop-angle - start-angle) / sum
+
+ let get-item-label(item, kind) = {
+ let (value, label, ..) = item
+ if kind == label-kind.value {
+ [#value]
+ } else if kind == label-kind.percentage {
+ [#{calc.round(value / sum * 100)}%]
+ } else if kind == label-kind.label {
+ label
+ } else if type(kind) == function {
+ (kind)(value, label)
+ }
+ }
+
+ let start = start-angle
+ let enum-items = if style.clockwise {
+ data.enumerate().rev()
+ } else {
+ data.enumerate()
+ }
+ for (i, item) in enum-items {
+ let (value, label, outset) = item
+ if value == 0 { continue }
+
+ let origin = (0,0)
+ let radius = radius
+ let inner-radius = inner-radius
+
+ // Calculate item angles
+ let delta = f * value
+ let end = start + delta
+
+ // Apply item outset
+ let outset-offset = if outset == true {
+ style.outset-offset
+ } else if outset == false {
+ 0
+ } else if type(outset) in (float, ratio) {
+ outset
+ } else {
+ panic("Invalid type for outset. Expected bool, float or ratio, got: " + repr(outset))
+ }
+ if type(outset-offset) == ratio {
+ outset-offset = outset-offset * radius / 100%
+ }
+
+ if outset-offset != 0 {
+ if style.outset-mode == "OFFSET" {
+ let dir = (calc.cos((start + end) / 2), calc.sin((start + end) / 2))
+ origin = vector.add(origin, vector.scale(dir, outset-offset))
+ radius += outset-offset
+ } else {
+ radius += outset-offset
+ if inner-radius > 0 {
+ inner-radius += outset-offset
+ }
+ }
+ }
+
+ // Calculate gap angles
+ let outer-gap = gap
+ let gap-dist = outer-gap / 360deg * 2 * calc.pi * radius
+ let inner-gap = if inner-radius > 0 {
+ gap-dist / (2 * calc.pi * inner-radius) * 360deg
+ } else {
+ 1 / calc.pi * 360deg
+ }
+
+ // Calculate angle deltas
+ let outer-angle = end - start - outer-gap * 2
+ let inner-angle = end - start - inner-gap * 2
+ let mid-angle = (start + end) / 2
+
+ // Skip negative values
+ if outer-angle < 0deg {
+ // TODO: Add a warning as soon as Typst is ready!
+ continue
+ }
+
+ // A sharp item is an item that should be round but is sharp due to the gap being big
+ let is-sharp = inner-radius == 0 or circle-arclen(inner-radius, angle: inner-angle) > circle-arclen(radius, angle: outer-angle)
+
+ let inner-origin = vector.add(origin, if inner-radius == 0 {
+ if gap-dist >= 0 {
+ let outer-end = vector.scale((calc.cos(end - outer-gap), calc.sin(end - outer-gap)), radius)
+ let inner-end = vector.scale((calc.cos(end - inner-gap), calc.sin(end - inner-gap)), gap-dist)
+ let outer-start = vector.scale((calc.cos(start + outer-gap), calc.sin(start + outer-gap)), radius)
+ let inner-start = vector.scale((calc.cos(start + inner-gap), calc.sin(start + inner-gap)), gap-dist)
+
+ intersection.line-line(outer-end, inner-end, outer-start, inner-start, ray: true)
+ } else {
+ (0,0)
+ }
+ } else if is-sharp {
+ let outer-end = vector.scale((calc.cos(end - outer-gap), calc.sin(end - outer-gap)), radius)
+ let inner-end = vector.scale((calc.cos(end - inner-gap), calc.sin(end - inner-gap)), inner-radius)
+ let outer-start = vector.scale((calc.cos(start + outer-gap), calc.sin(start + outer-gap)), radius)
+ let inner-start = vector.scale((calc.cos(start + inner-gap), calc.sin(start + inner-gap)), inner-radius)
+
+ intersection.line-line(outer-end, inner-end, outer-start, inner-start, ray: true)
+ } else {
+ (0,0)
+ })
+
+ // Draw one segment
+ let stroke = style-at(i).at("stroke", default: style.stroke)
+ let fill = style-at(i).at("fill", default: style.fill)
+ if data.len() == 1 {
+ // If the chart has only one segment, we may have to fake a path
+ // with a hole in it by using a combination of multiple arcs.
+ if inner-radius > 0 {
+ // Split the circle/arc into two arcs
+ // and fill them
+ merge-path({
+ arc(origin, start: start-angle, stop: mid-angle, radius: radius, anchor: "origin")
+ arc(origin, stop: start-angle, start: mid-angle, radius: inner-radius, anchor: "origin")
+ }, close: false, fill:fill, stroke: none)
+ merge-path({
+ arc(origin, start: mid-angle, stop: stop-angle, radius: radius, anchor: "origin")
+ arc(origin, stop: mid-angle, start: stop-angle, radius: inner-radius, anchor: "origin")
+ }, close: false, fill:fill, stroke: none)
+
+ // Create arcs for the inner and outer border and stroke them.
+ // If the chart is not a full circle, we have to merge two arc
+ // at their ends to create closing lines
+ if stroke != none {
+ if calc.abs(stop-angle - start-angle) != 360deg {
+ merge-path({
+ arc(origin, start: start, stop: end, radius: inner-radius, anchor: "origin")
+ arc(origin, start: end, stop: start, radius: radius, anchor: "origin")
+ }, close: true, fill: none, stroke: stroke)
+ } else {
+ arc(origin, start: start, stop: end, radius: inner-radius, fill: none, stroke: stroke, anchor: "origin")
+ arc(origin, start: start, stop: end, radius: radius, fill: none, stroke: stroke, anchor: "origin")
+ }
+ }
+ } else {
+ arc(origin, start: start, stop: end, radius: radius, fill: fill, stroke: stroke, mode: "PIE", anchor: "origin")
+ }
+ } else {
+ // Draw a normal segment
+ if inner-origin != none {
+ merge-path({
+ arc(origin, start: start + outer-gap, stop: end - outer-gap, anchor: "origin",
+ radius: radius)
+ if inner-radius > 0 and not is-sharp {
+ if inner-angle < 0deg {
+ arc(inner-origin, stop: end - inner-gap, delta: inner-angle, anchor: "origin",
+ radius: inner-radius)
+ } else {
+ arc(inner-origin, start: end - inner-gap, delta: -inner-angle, anchor: "origin",
+ radius: inner-radius)
+ }
+ } else {
+ line((rel: (end - outer-gap, radius), to: origin),
+ inner-origin,
+ (rel: (start + outer-gap, radius), to: origin))
+ }
+ }, close: true, fill: fill, stroke: stroke)
+ }
+ }
+
+ // Place outer label
+ let outer-label = get-item-label(item, style.outer-label.content)
+ if outer-label != none {
+ let r = style.outer-label.radius
+ if type(r) == ratio {r = r * radius / 100%}
+
+ let dir = (r * calc.cos(mid-angle), r * calc.sin(mid-angle))
+ let pt = vector.add(origin, dir)
+
+ let angle = style.outer-label.angle
+ if angle == auto {
+ angle = vector.add(pt, (dir.at(1), -dir.at(0)))
+ }
+
+ content(pt, outer-label, angle: angle, anchor: style.outer-label.anchor)
+ }
+
+ // Place inner label
+ let inner-label = get-item-label(item, style.inner-label.content)
+ if inner-label != none {
+ let r = style.inner-label.radius
+ if type(r) == ratio {r = r * (radius + inner-radius) / 200%}
+
+ let dir = (r * calc.cos(mid-angle), r * calc.sin(mid-angle))
+ let pt = vector.add(origin, dir)
+
+ let angle = style.inner-label.angle
+ if angle == auto {
+ angle = vector.add(pt, (dir.at(1), -dir.at(0)))
+ }
+
+ content(pt, inner-label, angle: angle, anchor: style.inner-label.anchor)
+ }
+
+ // Place item anchor
+ anchor("item-" + str(i), (rel: (mid-angle, radius), to: origin))
+ anchor("item-" + str(i) + "-inner", (rel: (mid-angle, inner-radius), to: origin))
+
+ start = end
+ }
+ })
+}
diff --git a/src/lib.typ b/src/lib.typ
new file mode 100644
index 0000000..128ff08
--- /dev/null
+++ b/src/lib.typ
@@ -0,0 +1,5 @@
+#let version = version(0,1,0)
+
+#import "/src/axes.typ"
+#import "/src/plot.typ"
+#import "/src/chart.typ"
diff --git a/src/plot.typ b/src/plot.typ
new file mode 100644
index 0000000..6f0e28d
--- /dev/null
+++ b/src/plot.typ
@@ -0,0 +1,513 @@
+#import "/src/cetz.typ": util, draw, matrix, vector, styles, palette
+#import util: bezier
+
+#import "/src/axes.typ"
+#import "/src/plot/sample.typ": sample-fn, sample-fn2
+#import "/src/plot/line.typ": add, add-hline, add-vline, add-fill-between
+#import "/src/plot/contour.typ": add-contour
+#import "/src/plot/boxwhisker.typ": add-boxwhisker
+#import "/src/plot/util.typ" as plot-util
+#import "/src/plot/legend.typ" as plot-legend
+#import "/src/plot/annotation.typ": annotate, calc-annotation-domain
+#import "/src/plot/bar.typ": add-bar
+#import "/src/plot/errorbar.typ": add-errorbar
+#import "/src/plot/mark.typ"
+
+#let default-colors = (blue, red, green, yellow, black)
+
+#let default-plot-style(i) = {
+ let color = default-colors.at(calc.rem(i, default-colors.len()))
+ return (stroke: color,
+ fill: color.lighten(75%))
+}
+
+#let default-mark-style(i) = {
+ return default-plot-style(i)
+}
+
+/// Create a plot environment. Data to be plotted is given by passing it to the
+/// `plot.add` or other plotting functions. The plot environment supports different
+/// axis styles to draw, see its parameter `axis-style:`.
+///
+/// #example(```
+/// import cetz.plot
+/// plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
+/// plot.add(((0,0), (1,1), (2,.5), (4,3)))
+/// })
+/// ```)
+///
+/// To draw elements insides a plot, using the plots coordinate system, use
+/// the `plot.annotate(..)` function.
+///
+/// = parameters
+///
+/// = Options
+///
+/// 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: [..]`.
+///
+/// #show-parameter-block("label", ("none", "content"), default: "none", [
+/// The axis' label. If and where the label is drawn depends on the `axis-style`.])
+/// #show-parameter-block("min", ("auto", "float"), default: "auto", [
+/// Axis lower domain value. If this is set greater than than `max`, the axis' direction is swapped])
+/// #show-parameter-block("max", ("auto", "float"), default: "auto", [
+/// Axis upper domain value. If this is set to a lower value than `min`, the axis' direction is swapped])
+/// #show-parameter-block("equal", ("string"), default: "none", [
+/// Set the axis domain to keep a fixed aspect ratio by multiplying the other axis domain by the plots aspect ratio,
+/// depending on the other axis orientation (see `horizontal`).
+/// This can be useful to force one axis to grow or shrink with another one.
+/// You can only "lock" two axes of different orientations.
+/// #example(```
+/// cetz.plot.plot(size: (2,1), x-tick-step: 1, y-tick-step: 1,
+/// x-equal: "y",
+/// {
+/// cetz.plot.add(domain: (0, 2 * calc.pi),
+/// t => (calc.cos(t), calc.sin(t)))
+/// })
+/// ```)
+/// ])
+/// #show-parameter-block("horizontal", ("bool"), default: "axis name dependant", [
+/// If true, the axis is considered an axis that gets drawn horizontally, vertically otherwise.
+/// The default value depends on the axis name on axis creation. Axes which name start with `x` have this
+/// set to `true`, all others have it set to `false`. Each plot has to use one horizontal and one
+/// vertical axis for plotting, a combination of two y-axes will panic: ("y", "y2").
+/// ])
+/// #show-parameter-block("tick-step", ("none", "auto", "float"), default: "auto", [
+/// The increment between tick marks on the axis. If set to `auto`, an
+/// increment is determined. When set to `none`, incrementing tick marks are disabled.])
+/// #show-parameter-block("minor-tick-step", ("none", "float"), default: "none", [
+/// Like `tick-step`, but for minor tick marks. In contrast to ticks, minor ticks do not have labels.])
+/// #show-parameter-block("ticks", ("none", "array"), default: "none", [
+/// A List of custom tick marks to additionally draw along the axis. They can be passed as
+/// an array of `` values or an array of `(, )` tuples for
+/// setting custom tick mark labels per mark.
+///
+/// #example(```
+/// cetz.plot.plot(x-tick-step: none, y-tick-step: none,
+/// x-min: 0, x-max: 4,
+/// x-ticks: (1, 2, 3),
+/// y-min: 1, y-max: 2,
+/// y-ticks: ((1, [One]), (2, [Two])),
+/// {
+/// cetz.plot.add(((0,0),))
+/// })
+/// ```)
+///
+/// Examples: `(1, 2, 3)` or `((1, [One]), (2, [Two]), (3, [Three]))`])
+/// #show-parameter-block("format", ("none", "string", "function"), default: "float", [
+/// How to format the tick label: You can give a function that takes a `` and return
+/// `` to use as the tick label. You can also give one of the predefined options:
+/// / float: Floating point formatting rounded to two digits after the point (see `decimals`)
+/// / sci: Scientific formatting with $times 10^n$ used as exponet syntax
+///
+/// #example(```
+/// let formatter(v) = if v != 0 {$ #{v/calc.pi} pi $} else {$ 0 $}
+/// cetz.plot.plot(x-tick-step: calc.pi, y-tick-step: none,
+/// x-min: 0, x-max: 2 * calc.pi,
+/// x-format: formatter,
+/// {
+/// cetz.plot.add(((0,0),))
+/// })
+/// ```)
+/// ])
+/// #show-parameter-block("decimals", ("int"), default: "2", [
+/// Number of decimals digits to display for tick labels, if the format is set
+/// to `"float"`.
+/// ])
+/// #show-parameter-block("unit", ("none", "content"), default: "none", [
+/// Suffix to append to all tick labels.
+/// ])
+/// #show-parameter-block("grid", ("bool", "string"), default: "false", [
+/// If `true` or `"major"`, show grid lines for all major ticks. If set
+/// to `"minor"`, show grid lines for minor ticks only.
+/// The value `"both"` enables grid lines for both, major- and minor ticks.
+///
+/// #example(```
+/// cetz.plot.plot(x-tick-step: 1, y-tick-step: 1,
+/// y-minor-tick-step: .2,
+/// x-min: 0, x-max: 2, x-grid: true,
+/// y-min: 0, y-max: 2, y-grid: "both", {
+/// cetz.plot.add(((0,0),))
+/// })
+/// ```)
+/// ])
+/// #show-parameter-block("break", ("bool"), default: "false", [
+/// If true, add a "sawtooth" at the start or end of the axis line, depending
+/// on the axis bounds. If the axis min. value is > 0, a sawtooth is added
+/// to the start of the axes, if the axis max. value is < 0, a sawtooth is added
+/// to its end.])
+///
+/// - body (body): Calls of `plot.add` or `plot.add-*` commands. Note that normal drawing
+/// commands like `line` or `rect` are not allowed inside the plots body, instead wrap
+/// them in `plot.annotate`, which lets you select the axes used for drawing.
+/// - size (array): Plot size tuple of `(, )` in canvas units.
+/// This is the plots inner plotting size without axes and labels.
+/// - axis-style (none, string): How the axes should be styled:
+/// / scientific: Frames plot area using a rectangle and draw axes `x` (bottom), `y` (left), `x2` (top), and `y2` (right) around it.
+/// If `x2` or `y2` are unset, they mirror their opposing axis.
+/// / scientific-auto: Draw set (used) axes `x` (bottom), `y` (left), `x2` (top) and `y2` (right) around
+/// the plotting area, forming a rect if all axes are in use or a L-shape if only `x` and `y` are in use.
+/// / school-book: Draw axes `x` (horizontal) and `y` (vertical) as arrows pointing to the right/top with both crossing at $(0, 0)$
+/// / left: Draw axes `x` and `y` as arrows, while the y axis stays on the left (at `x.min`)
+/// and the x axis at the bottom (at `y.min`)
+/// / `none`: Draw no axes (and no ticks).
+///
+/// #example(```
+/// let opts = (x-tick-step: none, y-tick-step: none, size: (2,1))
+/// let data = cetz.plot.add(((-1,-1), (1,1),), mark: "o")
+///
+/// for name in (none, "school-book", "left", "scientific") {
+/// cetz.plot.plot(axis-style: name, ..opts, data, name: "plot")
+/// content(((0,-1), "-|", "plot.south"), repr(name))
+/// set-origin((3.5,0))
+/// }
+/// ```, vertical: true)
+/// - plot-style (style,function): Styling to use for drawing plot graphs.
+/// This style gets inherited by all plots and supports `palette` functions.
+/// The following style keys are supported:
+/// #show-parameter-block("stroke", ("none", "stroke"), default: 1pt, [
+/// Stroke style to use for stroking the graph.
+/// ])
+/// #show-parameter-block("fill", ("none", "paint"), default: none, [
+/// Paint to use for filled graphs. Note that not all graphs may support filling and
+/// that you may have to enable filling per graph, see `plot.add(fill: ..)`.
+/// ])
+/// - mark-style (style,function): Styling to use for drawing plot marks.
+/// This style gets inherited by all plots and supports `palette` functions.
+/// The following style keys are supported:
+/// #show-parameter-block("stroke", ("none", "stroke"), default: 1pt, [
+/// Stroke style to use for stroking the mark.
+/// ])
+/// #show-parameter-block("fill", ("none", "paint"), default: none, [
+/// Paint to use for filling marks.
+/// ])
+/// - fill-below (bool): If true, the filled shape of plots is drawn _below_ axes.
+/// - name (string): The plots element name to be used when referring to anchors
+/// - legend (none, auto, coordinate): The position the legend will be drawn at. See @plot-legends for information about legends. If set to ``, the legend's "default-placement" styling will be used. If set to a ``, it will be taken as relative to the plot's origin.
+/// - legend-anchor (auto, string): Anchor of the legend group to use as its origin.
+/// If set to `auto` and `lengend` is one of the predefined legend anchors, the
+/// opposite anchor to `legend` gets used.
+/// - legend-style (style): Style key-value overwrites for the legend style with style root `legend`.
+/// - ..options (any): Axis options, see _options_ below.
+#let plot(body,
+ size: (1, 1),
+ axis-style: "scientific",
+ name: none,
+ plot-style: default-plot-style,
+ mark-style: default-mark-style,
+ fill-below: true,
+ legend: auto,
+ legend-anchor: auto,
+ legend-style: (:),
+ ..options
+ ) = draw.group(name: name, ctx => {
+ // Create plot context object
+ let make-ctx(x, y, size) = {
+ assert(x != none, message: "X axis does not exist")
+ assert(y != none, message: "Y axis does not exist")
+ assert(size.at(0) > 0 and size.at(1) > 0, message: "Plot size must be > 0")
+
+ let x-scale = ((x.max - x.min) / size.at(0))
+ let y-scale = ((y.max - y.min) / size.at(1))
+
+ if y.horizontal {
+ (x-scale, y-scale) = (y-scale, x-scale)
+ }
+
+ return (x: x, y: y, size: size, x-scale: x-scale, y-scale: y-scale)
+ }
+
+ // Setup data viewport
+ let data-viewport(data, x, y, size, body, name: none) = {
+ if body == none or body == () { return }
+
+ assert.ne(x.horizontal, y.horizontal,
+ message: "Data must use one horizontal and one vertical axis!")
+
+ // If y is the horizontal axis, swap x and y
+ // coordinates by swapping the transformation
+ // matrix columns.
+ if y.horizontal {
+ (x, y) = (y, x)
+ body = draw.set-ctx(ctx => {
+ ctx.transform = matrix.swap-cols(ctx.transform, 0, 1)
+ return ctx
+ }) + body
+ }
+
+ // Setup the viewport
+ axes.axis-viewport(size, x, y, body, name: name)
+ }
+
+ let data = ()
+ let anchors = ()
+ let annotations = ()
+ let body = if body != none { body } else { () }
+
+ for cmd in body {
+ assert(type(cmd) == dictionary and "type" in cmd,
+ message: "Expected plot sub-command in plot body")
+ if cmd.type == "anchor" {
+ anchors.push(cmd)
+ } else if cmd.type == "annotation" {
+ annotations.push(cmd)
+ } else { data.push(cmd) }
+ }
+
+ assert(axis-style in (none, "scientific", "scientific-auto", "school-book", "left"),
+ message: "Invalid plot style")
+
+ // Create axes for data & annotations
+ let axis-dict = (:)
+ for d in data + annotations {
+ for (i, name) in d.axes.enumerate() {
+ if not name in axis-dict {
+ axis-dict.insert(name, axes.axis(
+ min: none, max: none))
+ }
+
+ let axis = axis-dict.at(name)
+ let domain = if i == 0 {
+ d.at("x-domain", default: (0, 0))
+ } else {
+ d.at("y-domain", default: (0, 0))
+ }
+ if domain != (none, none) {
+ axis.min = util.min(axis.min, ..domain)
+ axis.max = util.max(axis.max, ..domain)
+ }
+
+ axis-dict.at(name) = axis
+ }
+ }
+
+ // Create axes for anchors
+ for a in anchors {
+ for (i, name) in a.axes.enumerate() {
+ if not name in axis-dict {
+ axis-dict.insert(name, axes.axis(min: none, max: none))
+ }
+ }
+ }
+
+ // Adjust axis bounds for annotations
+ for a in annotations {
+ let (x, y) = a.axes.map(name => axis-dict.at(name))
+ (x, y) = calc-annotation-domain(ctx, x, y, a)
+ axis-dict.at(a.axes.at(0)) = x
+ axis-dict.at(a.axes.at(1)) = y
+ }
+
+ // Set axis options
+ axis-dict = plot-util.setup-axes(ctx, axis-dict, options.named(), size)
+
+ // Prepare styles
+ for i in range(data.len()) {
+ let style-base = plot-style
+ if type(style-base) == function {
+ style-base = (style-base)(i)
+ }
+ assert.eq(type(style-base), dictionary,
+ message: "plot-style must be of type dictionary")
+
+ if type(data.at(i).style) == function {
+ data.at(i).style = (data.at(i).style)(i)
+ }
+ assert.eq(type(style-base), dictionary,
+ message: "data plot-style must be of type dictionary")
+
+ data.at(i).style = util.merge-dictionary(
+ style-base, data.at(i).style)
+
+ if "mark-style" in data.at(i) {
+ let mark-style-base = mark-style
+ if type(mark-style-base) == function {
+ mark-style-base = (mark-style-base)(i)
+ }
+ assert.eq(type(mark-style-base), dictionary,
+ message: "mark-style must be of type dictionary")
+
+ if type(data.at(i).mark-style) == function {
+ data.at(i).mark-style = (data.at(i).mark-style)(i)
+ }
+
+ if type(data.at(i).mark-style) == dictionary {
+ data.at(i).mark-style = util.merge-dictionary(
+ mark-style-base,
+ data.at(i).mark-style
+ )
+ }
+ }
+ }
+
+ draw.group(name: "plot", {
+ draw.anchor("origin", (0, 0))
+
+ // Prepare
+ for i in range(data.len()) {
+ let (x, y) = data.at(i).axes.map(name => axis-dict.at(name))
+ let plot-ctx = make-ctx(x, y, size)
+
+ if "plot-prepare" in data.at(i) {
+ data.at(i) = (data.at(i).plot-prepare)(data.at(i), plot-ctx)
+ assert(data.at(i) != none,
+ message: "Plot prepare(self, cxt) returned none!")
+ }
+ }
+
+ // Background Annotations
+ for a in annotations.filter(a => a.background) {
+ let (x, y) = a.axes.map(name => axis-dict.at(name))
+ let plot-ctx = make-ctx(x, y, size)
+
+ data-viewport(a, x, y, size, {
+ draw.anchor("default", (0, 0))
+ a.body
+ })
+ }
+
+ // Fill
+ if fill-below {
+ for d in data {
+ let (x, y) = d.axes.map(name => axis-dict.at(name))
+ let plot-ctx = make-ctx(x, y, size)
+
+ data-viewport(d, x, y, size, {
+ draw.anchor("default", (0, 0))
+ draw.set-style(..d.style)
+
+ if "plot-fill" in d {
+ (d.plot-fill)(d, plot-ctx)
+ }
+ })
+ }
+ }
+
+ if axis-style in ("scientific", "scientific-auto") {
+ let draw-unset = if axis-style == "scientific" {
+ true
+ } else {
+ false
+ }
+
+ let mirror = if axis-style == "scientific" {
+ auto
+ } else {
+ none
+ }
+
+ axes.scientific(
+ size: size,
+ draw-unset: draw-unset,
+ bottom: axis-dict.at("x", default: none),
+ top: axis-dict.at("x2", default: mirror),
+ left: axis-dict.at("y", default: none),
+ right: axis-dict.at("y2", default: mirror),)
+ } else if axis-style == "left" {
+ axes.school-book(
+ size: size,
+ axis-dict.x,
+ axis-dict.y,
+ x-position: axis-dict.y.min,
+ y-position: axis-dict.x.min)
+ } else if axis-style == "school-book" {
+ axes.school-book(
+ size: size,
+ axis-dict.x,
+ axis-dict.y,)
+ }
+
+ // Stroke + Mark data
+ for d in data {
+ let (x, y) = d.axes.map(name => axis-dict.at(name))
+ let plot-ctx = make-ctx(x, y, size)
+
+ data-viewport(d, x, y, size, {
+ draw.anchor("default", (0, 0))
+ draw.set-style(..d.style)
+
+ if not fill-below and "plot-fill" in d {
+ (d.plot-fill)(d, plot-ctx)
+ }
+ if "plot-stroke" in d {
+ (d.plot-stroke)(d, plot-ctx)
+ }
+ if "mark" in d and d.mark != none {
+ draw.set-style(..d.style, ..d.mark-style)
+ mark.draw-mark(d.data, x, y, d.mark, d.mark-size, size)
+ }
+ })
+ }
+
+ // Foreground Annotations
+ for a in annotations.filter(a => not a.background) {
+ let (x, y) = a.axes.map(name => axis-dict.at(name))
+ let plot-ctx = make-ctx(x, y, size)
+
+ data-viewport(a, x, y, size, {
+ draw.anchor("default", (0, 0))
+ a.body
+ })
+ }
+
+ // Place anchors
+ for a in anchors {
+ let (x, y) = a.axes.map(name => axis-dict.at(name))
+ let plot-ctx = make-ctx(x, y, size)
+
+ data-viewport(a, x, y, size, {
+ let (ax, ay) = a.position
+ if ax == "min" {ax = x.min} else if ax == "max" {ax = x.max}
+ if ay == "min" {ay = y.min} else if ay == "max" {ay = y.max}
+ draw.anchor("default", (0,0))
+ draw.anchor(a.name, (ax, ay))
+ }, name: "anchors")
+ draw.copy-anchors("anchors", filter: (a.name,))
+ }
+ })
+
+ // Draw the legend
+ if legend != none {
+ let items = data.filter(d => "label" in d and d.label != none)
+ if items.len() > 0 {
+ plot-legend.draw-legend(ctx, legend-style,
+ items, size, "plot", legend, legend-anchor)
+ }
+ }
+
+ draw.copy-anchors("plot")
+})
+
+/// Add an anchor to a plot environment
+///
+/// This function is similar to `draw.anchor` but it takes an additional
+/// axis tuple to specify which axis coordinate system to use.
+///
+/// #example(```
+/// import cetz.plot
+/// import cetz.draw: *
+/// plot.plot(size: (2,2), name: "plot",
+/// x-tick-step: none, y-tick-step: none, {
+/// plot.add(((0,0), (1,1), (2,.5), (4,3)))
+/// plot.add-anchor("pt", (1,1))
+/// })
+///
+/// line("plot.pt", ((), "|-", (0,1.5)), mark: (start: ">"), name: "line")
+/// content("line.end", [Here], anchor: "south", padding: .1)
+/// ```)
+///
+/// - name (string): Anchor name
+/// - position (tuple): Tuple of x and y values.
+/// Both values can have the special values "min" and
+/// "max", which resolve to the axis min/max value.
+/// Position is in axis space defined by the axes passed to `axes`.
+/// - axes (tuple): Name of the axes to use `("x", "y")` as coordinate
+/// system for `position`. Note that both axes must be used,
+/// as `add-anchors` does not create them on demand.
+#let add-anchor(name, position, axes: ("x", "y")) = {
+ ((
+ type: "anchor",
+ name: name,
+ position: position,
+ axes: axes,
+ ),)
+}
diff --git a/src/plot/annotation.typ b/src/plot/annotation.typ
new file mode 100644
index 0000000..a8584aa
--- /dev/null
+++ b/src/plot/annotation.typ
@@ -0,0 +1,76 @@
+#import "/src/cetz.typ": draw, process, util, matrix
+
+#import "util.typ"
+#import "sample.typ"
+
+/// Add an annotation to the plot
+///
+/// An annotation is a sub-canvas that uses the plots coordinates specified
+/// by its x and y axis.
+///
+/// #example(```
+/// import cetz.plot
+/// plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
+/// plot.add(domain: (0, 2*calc.pi), calc.sin)
+/// plot.annotate({
+/// rect((0, -1), (calc.pi, 1), fill: rgb(50,50,200,50))
+/// content((calc.pi, 0), [Here])
+/// })
+/// })
+/// ```)
+///
+/// Bounds calculation is done naively, therefore fixed size content _can_ grow
+/// out of the plot. You can adjust the padding manually to adjust for that. The
+/// feature of solving the correct bounds for fixed size elements might be added
+/// in the future.
+///
+/// - body (drawable): Elements to draw
+/// - axes (axes): X and Y axis names
+/// - resize (bool): If true, the plots axes get adjusted to contain the annotation
+/// - padding (none,number,dictionary): Annotation padding that is used for axis
+/// adjustment
+/// - background (bool): If true, the annotation is drawn behind all plots, in the background.
+/// If false, the annotation is drawn above all plots.
+#let annotate(body, axes: ("x", "y"), resize: true, padding: none, background: false) = {
+ ((
+ type: "annotation",
+ body: {
+ draw.set-style(mark: (transform-shape: false))
+ body;
+ },
+ axes: axes,
+ resize: resize,
+ background: background,
+ padding: util.as-padding-dict(padding),
+ ),)
+}
+
+// Returns the adjusted axes for the annotation object
+//
+// -> array Tuple of x and y axis
+#let calc-annotation-domain(ctx, x, y, annotation) = {
+ if not annotation.resize {
+ return (x, y)
+ }
+
+ ctx.transform = matrix.ident()
+ let (ctx: ctx, bounds: bounds, drawables: _) = process.many(ctx, annotation.body)
+ if bounds == none {
+ return (x, y)
+ }
+
+ let (x-min, y-min, ..) = bounds.low
+ let (x-max, y-max, ..) = bounds.high
+
+ x-min -= annotation.padding.left
+ x-max += annotation.padding.right
+ y-min -= annotation.padding.bottom
+ y-max += annotation.padding.top
+
+ x.min = calc.min(x.min, x-min)
+ x.max = calc.max(x.max, x-max)
+ y.min = calc.min(y.min, y-min)
+ y.max = calc.max(y.max, y-max)
+
+ return (x, y)
+}
diff --git a/src/plot/bar.typ b/src/plot/bar.typ
new file mode 100644
index 0000000..2c40233
--- /dev/null
+++ b/src/plot/bar.typ
@@ -0,0 +1,264 @@
+#import "/src/cetz.typ": draw, util
+
+#import "errorbar.typ": draw-errorbar
+
+#let _transform-row(row, x-key, y-key, error-key) = {
+ let x = row.at(x-key)
+ let y = if y-key == auto {
+ row.slice(1)
+ } else if type(y-key) == array {
+ y-key.map(k => row.at(k, default: 0))
+ } else {
+ row.at(y-key, default: 0)
+ }
+ let err = if error-key == none {
+ 0
+ } else if type(error-key) == array {
+ error-key.map(k => row.at(k, default: 0))
+ } else {
+ row.at(error-key, default: 0)
+ }
+
+ if type(y) != array { y = (y,) }
+ if type(err) != array { err = (err,) }
+
+ (x, y.flatten(), err.flatten())
+}
+
+// Get a single items min and maximum y-value
+#let _minmax-value(row) = {
+ let min = none
+ let max = none
+
+ let y = row.at(1)
+ let e = row.at(2)
+ for i in range(0, y.len()) {
+ let i-min = y.at(i) - e.at(i, default: 0)
+ if min == none { min = i-min }
+ else { min = calc.min(min, i-min) }
+
+ let i-max = y.at(i) + e.at(i, default: 0)
+ if max == none { max = i-max }
+ else { max = calc.max(max, i-max) }
+ }
+
+ return (min: min, max: max)
+}
+
+// Functions for max value calculation
+#let _max-value-fn = (
+ basic: (data, min: 0) => {
+ calc.max(min, ..data.map(t => _minmax-value(t).max))
+ },
+ clustered: (data, min: 0) => {
+ calc.max(min, ..data.map(t => _minmax-value(t).max))
+ },
+ stacked: (data, min: 0) => {
+ calc.max(min, ..data.map(t => t.at(1).sum()))
+ },
+ stacked100: (.., min: 0) => {min + 100}
+)
+
+// Functions for min value calculation
+#let _min-value-fn = (
+ basic: (data, min: 0) => {
+ calc.min(min, ..data.map(t => _minmax-value(t).min))
+ },
+ clustered: (data, min: 0) => {
+ calc.min(min, ..data.map(t => _minmax-value(t).min))
+ },
+ stacked: (data, min: 0) => {
+ calc.min(min, ..data.map(t => t.at(1).sum()))
+ },
+ stacked100: (.., min: 0) => {min}
+)
+
+#let _prepare(self, ctx) = {
+ return self
+}
+
+#let _get-x-offset(position, width) = {
+ if position == "start" { 0 }
+ else if position == "end" { width }
+ else { width / 2 }
+}
+
+#let _draw-rects(filling, self, ctx, ..args) = {
+ let x-axis = ctx.x
+ let y-axis = ctx.y
+
+ let bars = ()
+ let errors = ()
+
+ let w = self.bar-width
+ for d in self.data {
+ let (x, n, len, y-min, y-max, err) = d
+
+ let w = self.bar-width
+ let gap = self.cluster-gap * if w > 0 { -1 } else { +1 }
+ w += gap * (len - 1)
+
+ let x-offset = _get-x-offset(self.bar-position, self.bar-width)
+ x-offset += gap * n
+
+ let left = x - x-offset
+ let right = left + w
+ let width = (right - left) / len
+
+ if self.mode in ("basic", "clustered") {
+ left = left + width * n
+ right = left + width
+ }
+
+ if (left <= x-axis.max and right >= x-axis.min and
+ y-min <= y-axis.max and y-max >= y-axis.min) {
+ left = calc.max(left, x-axis.min)
+ right = calc.min(right, x-axis.max)
+ y-min = calc.max(y-min, y-axis.min)
+ y-max = calc.min(y-max, y-axis.max)
+
+ draw.rect((left, y-min), (right, y-max))
+
+ if not filling and err != 0 {
+ let y-whisker-size = self.whisker-size * ctx.x-scale
+ draw-errorbar(((left + right) / 2, y-max),
+ 0, err, 0, y-whisker-size / 2, self.style + self.error-style)
+ }
+ }
+ }
+}
+
+#let _stroke(self, ctx) = {
+ _draw-rects(false, self, ctx, fill: none)
+}
+
+#let _fill(self, ctx) = {
+ _draw-rects(true, self, ctx, stroke: none)
+}
+
+/// Add a bar- or column-chart to the plot
+///
+/// A bar- or column-chart is a chart where values are drawn as rectangular boxes.
+///
+/// - data (array): Array of data items. An item is an array containing a x an one or more y values.
+/// For example `(0, 1)` or `(0, 10, 5, 30)`. Depending on the `mode`, the data items
+/// get drawn as either clustered or stacked rects.
+/// - x-key: (int,string): Key to use for retreiving a bars x-value from a single data entry.
+/// This value gets passed to the `.at(...)` function of a data item.
+/// - y-key: (auto,int,string,array): Key to use for retreiving a bars y-value. For clustered/stacked
+/// data, this must be set to a list of keys (e.g. `range(1, 4)`). If set to `auto`, att but the first
+/// array-values of a data item are used as y-values.
+/// - error-key: (none,int,string): Key to use for retreiving a bars y-error.
+/// - mode (string): The mode on how to group data items into bars:
+/// / basic: Add one bar per data value. If the data contains multiple values,
+/// group those bars next to each other.
+/// / clustered: Like "basic", but take into account the maximum number of values of all items
+/// and group each cluster of bars together having the width of the widest cluster.
+/// / stacked: Stack bars of subsequent item values onto the previous bar, generating bars
+/// with the height of the sume of all an items values.
+/// / stacked100: Like "stacked", but scale each bar to height $100$, making the different
+/// bars percentages of the sum of an items values.
+/// - labels (none,content,array): A single legend label for "basic" bar-charts, or a
+/// a list of legend labels per bar category, if the mode is one of "clustered", "stacked" or "stacked100".
+/// - bar-width (float): Width of one data item on the y axis
+/// - bar-position (string): Positioning of data items relative to their x value.
+/// - "start": The lower edge of the data item is on the x value (left aligned)
+/// - "center": The data item is centered on the x value
+/// - "end": The upper edge of the data item is on the x value (right aligned)
+/// - cluster-gap (float): Spacing between bars insides a cluster.
+/// - style (dictionary): Plot style
+/// - axes (axes): Plot axes. To draw a horizontal growing bar chart, you can swap the x and y axes.
+#let add-bar(data,
+ x-key: 0,
+ y-key: auto,
+ error-key: none,
+ mode: "basic",
+ labels: none,
+ bar-width: 1,
+ bar-position: "center",
+ cluster-gap: 0,
+ whisker-size: .25,
+ error-style: (:),
+ style: (:),
+ axes: ("x", "y")) = {
+ assert(mode in ("basic", "clustered", "stacked", "stacked100"),
+ message: "Mode must be basic, clustered, stacked or stacked100, but is " + mode)
+ assert(bar-position in ("start", "center", "end"),
+ message: "Invalid bar-position '" + bar-position + "'. Allowed values are: start, center, end")
+ assert(bar-width != 0,
+ message: "Option bar-width must be != 0, but is " + str(bar-width))
+ if error-key != none {
+ assert(y-key != auto,
+ message: "Bar value-key must be set != auto if error-key is set")
+ assert(mode in ("basic", "clustered"),
+ message: "Error bars are supported for basic or clustered only, got " + mode)
+ }
+
+ // Transform data to (x, y, error) triplets
+ let data = data.map(row => _transform-row(row, x-key, y-key, error-key))
+
+ let n = util.max(..data.map(d => d.at(1).len()))
+ let x-offset = _get-x-offset(bar-position, bar-width)
+ let x-domain = (util.min(..data.map(d => d.at(0))) - x-offset,
+ util.max(..data.map(d => d.at(0))) - x-offset + bar-width)
+ let y-domain = (_min-value-fn.at(mode)(data),
+ _max-value-fn.at(mode)(data))
+
+ // For stacked 100%, multiply each column/bar
+ if mode == "stacked100" {
+ data = data.map(((x, y, err)) => {
+ let f = 100 / y.sum()
+ return (x, y.map(v => v * f), err)
+ })
+ }
+
+ // Transform data from (x, ..y) to (x, n, len, y-min, y-max) per y
+ let stacked = mode in ("stacked", "stacked100")
+ let clustered = mode == "clustered"
+ let bar-data = if mode == "basic" {
+ range(0, data.len()).map(_ => ())
+ } else {
+ range(0, n).map(_ => ())
+ }
+
+ let j = 0
+ for (x, y, err) in data {
+ let len = if clustered { n } else { y.len() }
+ let sum = 0
+ for (i, y) in y.enumerate() {
+ let err = err.at(i, default: 0)
+ if stacked {
+ bar-data.at(i).push((x, i, len, sum, sum + y, err))
+ } else if clustered {
+ bar-data.at(i).push((x, i, len, 0, y, err))
+ } else {
+ bar-data.at(j).push((x, i, len, 0, y, err))
+ }
+ sum += y
+ }
+ j += 1
+ }
+
+ let labels = if type(labels) == array { labels } else { (labels,) }
+ range(0, bar-data.len()).map(i => (
+ type: "bar",
+ label: labels.at(i, default: none),
+ axes: axes,
+ mode: mode,
+ data: bar-data.at(i),
+ x-domain: x-domain,
+ y-domain: y-domain,
+ style: style,
+ bar-width: bar-width,
+ bar-position: bar-position,
+ cluster-gap: cluster-gap,
+ whisker-size: whisker-size,
+ error-style: error-style,
+ plot-prepare: _prepare,
+ plot-stroke: _stroke,
+ plot-fill: _fill,
+ plot-legend-preview: self => {
+ draw.rect((0,0), (1,1), ..self.style)
+ }
+ ))
+}
diff --git a/src/plot/boxwhisker.typ b/src/plot/boxwhisker.typ
new file mode 100644
index 0000000..9b34c5e
--- /dev/null
+++ b/src/plot/boxwhisker.typ
@@ -0,0 +1,117 @@
+#import "/src/cetz.typ": draw, util
+
+/// Add one or more box or whisker plots
+///
+/// #example(```
+/// cetz.plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
+/// cetz.plot.add-boxwhisker((x: 1, // Location on x-axis
+/// outliers: (7, 65, 69), // Optional outlier values
+/// min: 15, max: 60, // Minimum and maximum
+/// q1: 25, // Quartiles: Lower
+/// q2: 35, // Median
+/// q3: 50)) // Upper
+/// })
+/// ```)
+///
+/// - data (array, dictionary): dictionary or array of dictionaries containing the
+/// needed entries to plot box and whisker plot.
+///
+/// The following fields are supported:
+/// - `x` (number) X-axis value
+/// - `min` (number) Minimum value
+/// - `max` (number) Maximum value
+/// - `q1`, `q2`, `q3` (number) Quartiles from lower to to upper
+/// - `outliers` (array of number) Optional outliers
+///
+/// - axes (array): Name of the axes to use ("x", "y"), note that not all
+/// plot styles are able to display a custom axis!
+/// - style (style): Style to use, can be used with a palette function
+/// - box-width (float): Width from edge-to-edge of the box of the box and whisker in plot units. Defaults to 0.75
+/// - whisker-width (float): Width from edge-to-edge of the whisker of the box and whisker in plot units. Defaults to 0.5
+/// - mark (string): Mark to use for plotting outliers. Set `none` to disable. Defaults to "x"
+/// - mark-size (float): Size of marks for plotting outliers. Defaults to 0.15
+/// - label (none,content): Legend label to show for this plot.
+#let add-boxwhisker(data,
+ label: none,
+ axes: ("x", "y"),
+ style: (:),
+ box-width: 0.75,
+ whisker-width: 0.5,
+ mark: "*",
+ mark-size: 0.15) = {
+ // Add multiple boxes as multiple calls to
+ // add-boxwhisker
+ if type(data) == array {
+ for it in data {
+ add-boxwhisker(
+ it,
+ axes:axes,
+ style: style,
+ box-width: box-width,
+ whisker-width: whisker-width,
+ mark: mark,
+ mark-size: mark-size)
+ }
+ return
+ }
+
+ assert("x" in data, message: "Specify 'x', the x value at which to display the box and whisker")
+ assert("q1" in data, message: "Specify 'q1', the lower quartile")
+ assert("q2" in data, message: "Specify 'q2', the median")
+ assert("q3" in data, message: "Specify 'q3', the upper quartile")
+ assert("min" in data, message: "Specify 'min', the minimum excluding outliers")
+ assert("max" in data, message: "Specify 'max', the maximum excluding outliers")
+ assert(data.q1 <= data.q2 and data.q2 <= data.q3,
+ message: "The quartiles q1, q2 and q3 must follow q1 < q2 < q3")
+ assert(data.min <= data.q1 and data.max >= data.q2,
+ message: "The minimum and maximum must be <= q1 and >= q3")
+
+ // Y domain
+ let max-value = util.max(data.max, ..data.at("outliers", default: ()))
+ let min-value = util.min(data.min, ..data.at("outliers", default: ()))
+
+ let prepare(self, ctx) = {
+ return self
+ }
+
+ let stroke(self, ctx) = {
+ let data = self.bw-data
+
+ // Box
+ draw.rect((data.x - box-width / 2, data.q1),
+ (data.x + box-width / 2, data.q3),
+ ..self.style)
+
+ // Mean
+ draw.line((data.x - box-width / 2, data.q2),
+ (data.x + box-width / 2, data.q2),
+ ..self.style)
+
+ // whiskers
+ let whisker(x, start, end) = {
+ draw.line((x, start),(x, end),..self.style)
+ draw.line((x - whisker-width / 2, end),(x + whisker-width / 2, end), ..self.style)
+ }
+ whisker(data.x, data.q3, data.max)
+ whisker(data.x, data.q1, data.min)
+ }
+
+ ((
+ type: "boxwhisker",
+ label: label,
+ axes: axes,
+ bw-data: data,
+ style: style,
+ plot-prepare: prepare,
+ plot-stroke: stroke,
+ x-domain: (data.x - calc.max(whisker-width, box-width),
+ data.x + calc.max(whisker-width, box-width)),
+ y-domain: (min-value, max-value),
+ ) + (if "outliers" in data { (
+ type: "boxwhisker-outliers",
+ data: data.outliers.map(it => (data.x, it)),
+ mark: mark,
+ mark-size: mark-size,
+ mark-style: (:)
+ ) }),)
+}
diff --git a/src/plot/contour.typ b/src/plot/contour.typ
new file mode 100644
index 0000000..db611c9
--- /dev/null
+++ b/src/plot/contour.typ
@@ -0,0 +1,350 @@
+#import "/src/cetz.typ": draw
+
+#import "util.typ"
+#import "sample.typ"
+
+// Find contours of a 2D array by using marching squares algorithm
+//
+// - data (array): A 2D array of floats where the first index is the row and the second index is the column
+// - offset (float): Z value threshold of a cell compare with `op` to, to count as true
+// - op (auto,string,function): Z value comparison oparator:
+// / `">", ">=", "<", "<=", "!=", "=="`: Use the passed operator to compare z.
+// / `auto`: Use ">=" for positive z values, "<=" for negative z values.
+// / ``: If set to a function, that function gets called
+// with two arguments, the z value `z1` to compare against and
+// the z value `z2` of the data and must return a boolean: `(z1, z2) => boolean`.
+// - interpolate (bool): Enable cell interpolation for smoother lines
+// - contour-limit (int): Contour limit after which the algorithm panics
+// -> array: Array of contour point arrays
+#let find-contours(data, offset, op: auto, interpolate: true, contour-limit: 50) = {
+ assert(data != none and type(data) == array,
+ message: "Data must be of type array")
+ assert(type(offset) in (int, float),
+ message: "Offset must be numeric")
+
+ let n-rows = data.len()
+ let n-cols = data.at(0).len()
+ if n-rows < 2 or n-cols < 2 {
+ return ()
+ }
+
+ assert(op == auto or type(op) in (str, function),
+ message: "Operator must be of type auto, string or function")
+ if op == auto {
+ op = if offset < 0 { "<=" } else { ">=" }
+ }
+ if type(op) == str {
+ assert(op in ("<", "<=", ">", ">=", "==", "!="),
+ message: "Operator must be one of: <, <=, >, >=, != or ==")
+ }
+
+ // Return if data is set
+ let is-set = if type(op) == function {
+ v => op(offset, v)
+ } else if op == "==" {
+ v => v == offset
+ } else if op == "!=" {
+ v => v != offset
+ } else if op == "<" {
+ v => v < offset
+ } else if op == "<=" {
+ v => v <= offset
+ } else if op == ">" {
+ v => v > offset
+ } else if op == ">=" {
+ v => v >= offset
+ }
+
+ // Build a binary map that has 0 for unset and 1 for set cells
+ let bin-data = data.map(r => r.map(is-set))
+
+ // Get binary data at x, y
+ let get-bin(x, y) = {
+ if x >= 0 and x < n-cols and y >= 0 and y < n-rows {
+ return bin-data.at(y).at(x)
+ }
+ return false
+ }
+
+ // Get data point for x, y coordinate
+ let get-data(x, y) = {
+ if x >= 0 and x < n-cols and y >= 0 and y < n-rows {
+ return float(data.at(y).at(x))
+ }
+ return none
+ }
+
+ // Get case (0 to 15)
+ let get-case(tl, tr, bl, br) = {
+ int(tl) * 8 + int(tr) * 4 + int(br) * 2 + int(bl)
+ }
+
+ let lerp(a, b) = {
+ if a == b { return a }
+ else if a == none { return 1 }
+ else if b == none { return 0 }
+ return (offset - a) / (b - a)
+ }
+
+ // List of all found contours
+ let contours = ()
+
+ let segments = ()
+ for y in range(-1, n-rows) {
+ for x in range(-1, n-cols) {
+ let tl = get-bin(x, y)
+ let tr = get-bin(x+1, y)
+ let bl = get-bin(x, y+1)
+ let br = get-bin(x+1, y+1)
+
+ // Corner data
+ //
+ // nw-----ne
+ // | |
+ // | |
+ // | |
+ // sw-----se
+ let nw = get-data(x, y)
+ let ne = get-data(x+1, y)
+ let se = get-data(x+1, y+1)
+ let sw = get-data(x, y+1)
+
+ // Interpolated edge points
+ //
+ // +-- a --+
+ // | |
+ // d b
+ // | |
+ // +-- c --+
+ let a = (x + .5, y)
+ let b = (x + 1, y + .5)
+ let c = (x + .5, y + 1)
+ let d = (x, y + .5)
+ if interpolate {
+ a = (x + lerp(nw, ne), y)
+ b = (x + 1, y + lerp(ne, se))
+ c = (x + lerp(sw, se), y + 1)
+ d = (x, y + lerp(nw, sw))
+ }
+
+ let case = get-case(tl, tr, bl, br)
+ if case in (1, 14) {
+ segments.push((d, c))
+ } else if case in (2, 13) {
+ segments.push((b, c))
+ } else if case in (3, 12) {
+ segments.push((d, b))
+ } else if case in (4, 11) {
+ segments.push((a, b))
+ } else if case == 5 {
+ segments.push((d, a))
+ segments.push((c, b))
+ } else if case in (6, 9) {
+ segments.push((c, a))
+ } else if case in (7, 8) {
+ segments.push((d, a))
+ } else if case == 10 {
+ segments.push((a, b))
+ segments.push((c, d))
+ }
+ }
+ }
+
+ // Join lines to one or more contours
+ // This is done by searching for the next line
+ // that starts at the current contours head or tail
+ // point. If found, push the other coordinate to
+ // the contour. If no line could be found, push a
+ // new contour.
+ let contours = ()
+ while segments.len() > 0 {
+ if contours.len() == 0 {
+ contours.push(segments.remove(0))
+ }
+
+ let found = false
+
+ let i = 0
+ while i < segments.len() {
+ let (a, b) = segments.at(i)
+ let (h, t) = (contours.last().first(),
+ contours.last().last())
+ if a == t {
+ contours.last().push(b)
+ segments.remove(i)
+ found = true
+ } else if b == t {
+ contours.last().push(a)
+ segments.remove(i)
+ found = true
+ } else if a == h {
+ contours.last().insert(0, b)
+ segments.remove(i)
+ found = true
+ } else if b == h {
+ contours.last().insert(0, a)
+ segments.remove(i)
+ found = true
+ } else {
+ i += 1
+ }
+ }
+
+ // Insert the next contour
+ if not found {
+ contours.push(segments.remove(0))
+ }
+
+ // Check limit
+ assert(contours.len() <= contour-limit,
+ message: "Countour limit reached! Raise contour-limit if you " +
+ "think this is not an error")
+ }
+
+ return contours
+}
+
+// Prepare line data
+#let _prepare(self, ctx) = {
+ let (x, y) = (ctx.x, ctx.y)
+
+ self.contours = self.contours.map(c => {
+ c.stroke-paths = util.compute-stroke-paths(c.line-data,
+ (x.min, y.min), (x.max, y.max))
+
+ if self.fill {
+ c.fill-paths = util.compute-fill-paths(c.line-data,
+ (x.min, y.min), (x.max, y.max))
+ }
+ return c
+ })
+
+ return self
+}
+
+// Stroke line data
+#let _stroke(self, ctx) = {
+ for c in self.contours {
+ for p in c.stroke-paths {
+ draw.line(..p, fill: none, close: p.first() == p.last())
+ }
+ }
+}
+
+// Fill line data
+#let _fill(self, ctx) = {
+ if not self.fill { return }
+ for c in self.contours {
+ for p in c.fill-paths {
+ draw.line(..p, stroke: none, close: p.first() == p.last())
+ }
+ }
+}
+
+/// Add a contour plot of a sampled function or a matrix.
+///
+/// #example(```
+/// cetz.plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
+/// cetz.plot.add-contour(x-domain: (-3, 3), y-domain: (-3, 3),
+/// style: (fill: rgb(50,50,250,50)),
+/// fill: true,
+/// op: "<", // Find contours where data < z
+/// z: (2.5, 2, 1), // Z values to find contours for
+/// (x, y) => calc.sqrt(x * x + y * y))
+/// })
+/// ```)
+///
+/// - data (array, function): A function of the signature `(x, y) => z`
+/// or an array of arrays of floats (a matrix) where the first
+/// index is the row and the second index is the column.
+/// - z (float, array): Z values to plot. Contours containing values
+/// above z (z >= 0) or below z (z < 0) get plotted.
+/// If you specify multiple z values, they get plotted in the order of specification.
+/// - x-domain (domain): X axis domain used if `data` is a function, that is the
+/// domain inside the function gets sampled.
+/// - y-domain (domain): Y axis domain used if `data` is a function, see `x-domain`.
+/// - x-samples (int): X axis domain samples (2 < n). Note that contour finding
+/// can be quite slow. Using a big sample count can improve accuracy but can
+/// also lead to bad compilation performance.
+/// - y-samples (int): Y axis domain samples (2 < n)
+/// - interpolate (bool): Use linear interpolation between sample values which can
+/// improve the resulting plot, especially if the contours are curved.
+/// - op (auto,string,function): Z value comparison oparator:
+/// / `">", ">=", "<", "<=", "!=", "=="`: Use the operator for comparison of `z` to
+/// the values from `data`.
+/// / `auto`: Use ">=" for positive z values, "<=" for negative z values.
+/// / ``: Call comparison function of the format `(plot-z, data-z) => boolean`,
+/// where `plot-z` is the z-value from the plots `z` argument and `data-z`
+/// is the z-value of the data getting plotted. The function must return true
+/// if at the combinations of arguments a contour is detected.
+/// - fill (bool): Fill each contour
+/// - style (style): Style to use for plotting, can be used with a palette function. Note
+/// that all z-levels use the same style!
+/// - axes (axes): Name of the axes to use for plotting.
+/// - limit (int): Limit of contours to create per z value before the function panics
+/// - label (none,content): Plot legend label to show. The legend preview for
+/// contour plots is a little rectangle drawn with the contours style.
+#let add-contour(data,
+ label: none,
+ z: (1,),
+ x-domain: (0, 1),
+ y-domain: (0, 1),
+ x-samples: 25,
+ y-samples: 25,
+ interpolate: true,
+ op: auto,
+ axes: ("x", "y"),
+ style: (:),
+ fill: false,
+ limit: 50,
+ ) = {
+ // Sample a x/y function
+ if type(data) == function {
+ data = sample.sample-fn2(data,
+ x-domain, y-domain,
+ x-samples, y-samples)
+ }
+
+ // Find matrix dimensions
+ assert(type(data) == array)
+ let (x-min, x-max) = x-domain
+ let dx = (x-max - x-min) / (data.at(0).len() - 1)
+ let (y-min, y-max) = y-domain
+ let dy = (y-max - y-min) / (data.len() - 1)
+
+ let contours = ()
+ let z = if type(z) == array { z } else { (z,) }
+ for z in z {
+ for contour in find-contours(data, z, op: op, interpolate: interpolate, contour-limit: limit) {
+ let line-data = contour.map(pt => {
+ (pt.at(0) * dx + x-min,
+ pt.at(1) * dy + y-min)
+ })
+
+ contours.push((
+ z: z,
+ line-data: line-data,
+ ))
+ }
+ }
+
+ return ((
+ type: "contour",
+ label: label,
+ contours: contours,
+ axes: axes,
+ x-domain: x-domain,
+ y-domain: y-domain,
+ style: style,
+ fill: fill,
+ mark: none,
+ mark-style: none,
+ plot-prepare: _prepare,
+ plot-stroke: _stroke,
+ plot-fill: _fill,
+ plot-legend-preview: self => {
+ if not self.fill { self.style.fill = none }
+ draw.rect((0,0), (1,1), ..self.style)
+ }
+ ),)
+}
diff --git a/src/plot/errorbar.typ b/src/plot/errorbar.typ
new file mode 100644
index 0000000..e8ec68c
--- /dev/null
+++ b/src/plot/errorbar.typ
@@ -0,0 +1,118 @@
+#import "/src/cetz.typ": draw, util, vector
+
+#let _draw-whisker(pt, dir, ..style) = {
+ let a = vector.add(pt, vector.scale(dir, -1))
+ let b = vector.add(pt, vector.scale(dir, +1))
+
+ draw.line(a, b, ..style)
+}
+
+#let draw-errorbar(pt, x, y, x-whisker-size, y-whisker-size, style) = {
+ if type(x) != array { x = (-x, x) }
+ if type(y) != array { y = (-y, y) }
+
+ let (x-min, x-max) = x
+ let x-min-pt = vector.add(pt, (x-min, 0))
+ let x-max-pt = vector.add(pt, (x-max, 0))
+ if x-min != 0 or x-max != 0 {
+ draw.line(x-min-pt, x-max-pt, ..style)
+ if x-whisker-size > 0 {
+ if x-min != 0 {
+ _draw-whisker(x-min-pt, (0, x-whisker-size), ..style)
+ }
+ if x-max != 0 {
+ _draw-whisker(x-max-pt, (0, x-whisker-size), ..style)
+ }
+ }
+ }
+
+ let (y-min, y-max) = y
+ let y-min-pt = vector.add(pt, (0, y-min))
+ let y-max-pt = vector.add(pt, (0, y-max))
+ if y-min != 0 or y-max != 0 {
+ draw.line(y-min-pt, y-max-pt, ..style)
+ if y-whisker-size > 0 {
+ if y-min != 0 {
+ _draw-whisker(y-min-pt, (y-whisker-size, 0), ..style)
+ }
+ if y-max != 0 {
+ _draw-whisker(y-max-pt, (y-whisker-size, 0), ..style)
+ }
+ }
+ }
+}
+
+#let _prepare(self, ctx) = {
+ return self
+}
+
+#let _stroke(self, ctx) = {
+ let x-whisker-size = self.whisker-size * ctx.y-scale
+ let y-whisker-size = self.whisker-size * ctx.x-scale
+
+ draw-errorbar((self.x, self.y),
+ self.x-error, self.y-error,
+ x-whisker-size, y-whisker-size,
+ self.style)
+}
+
+/// Add x- and/or y-error bars
+///
+/// - pt (tuple): Error-bar center coordinate tuple: `(x, y)`
+/// - x-error: (float,tuple): Single error or tuple of errors along the x-axis
+/// - y-error: (float,tuple): Single error or tuple of errors along the y-axis
+/// - mark: (none,string): Mark symbol to show at the error position (`pt`).
+/// - mark-size: (number): Size of the mark symbol.
+/// - mark-style: (style): Extra style to apply to the mark symbol.
+/// - whisker-size (float): Width of the error bar whiskers in canvas units.
+/// - style (dictionary): Style for the error bars
+/// - label: (none,content): Label to tsh
+/// - axes (axes): Plot axes. To draw a horizontal growing bar chart, you can swap the x and y axes.
+#let add-errorbar(pt,
+ x-error: 0,
+ y-error: 0,
+ label: none,
+ mark: "o",
+ mark-size: .2,
+ mark-style: (:),
+ whisker-size: .5,
+ style: (:),
+ axes: ("x", "y")) = {
+ assert(x-error != 0 or y-error != 0,
+ message: "Either x-error or y-error must be set.")
+
+ let (x, y) = pt
+
+ if type(x-error) != array {
+ x-error = (x-error, x-error)
+ }
+ if type(y-error) != array {
+ y-error = (y-error, y-error)
+ }
+
+ x-error.at(0) = calc.abs(x-error.at(0)) * -1
+ y-error.at(0) = calc.abs(y-error.at(0)) * -1
+
+ let x-domain = x-error.map(v => v + x)
+ let y-domain = y-error.map(v => v + y)
+
+ return ((
+ type: "errorbar",
+ label: label,
+ axes: axes,
+ data: ((x,y),),
+ x: x,
+ y: y,
+ x-error: x-error,
+ y-error: y-error,
+ x-domain: x-domain,
+ y-domain: y-domain,
+ mark: mark,
+ mark-size: mark-size,
+ mark-style: mark-style,
+ whisker-size: whisker-size,
+ style: style,
+ plot-prepare: _prepare,
+ plot-stroke: _stroke,
+ ),)
+}
diff --git a/src/plot/legend.typ b/src/plot/legend.typ
new file mode 100644
index 0000000..a8963d3
--- /dev/null
+++ b/src/plot/legend.typ
@@ -0,0 +1,179 @@
+#import "/src/cetz.typ"
+#import cetz: draw, styles
+
+#import "mark.typ": draw-mark-shape
+
+#let default-style = (
+ orientation: ttb,
+ default-position: "legend.north-east",
+ layer: 1, // Legend layer
+ fill: rgb(255,255,255,200), // Legend background
+ stroke: black, // Legend border
+ padding: .1, // Legend border padding
+ offset: (0, 0), // Legend displacement
+ spacing: .1, // Spacing between anchor and legend
+ item: (
+ radius: 0,
+ spacing: .05, // Spacing between items
+ preview: (
+ width: .75, // Preview width
+ height: .3, // Preview height
+ margin: .1 // Distance between preview and label
+ )
+ ),
+ radius: 0,
+)
+
+// Map position to legend group anchor
+#let auto-group-anchor = (
+ inner-north-west: "north-west",
+ inner-north: "north",
+ inner-north-east: "north-east",
+ inner-south-west: "south-west",
+ inner-south: "south",
+ inner-south-east: "south-east",
+ inner-west: "west",
+ inner-east: "east",
+ north-west: "north-east",
+ north: "south",
+ north-east: "north-west",
+ south-west: "south-east",
+ south: "north",
+ south-east: "south-west",
+ east: "west",
+ west: "east",
+)
+
+// Generate legend positioning anchors
+#let add-legend-anchors(style, element, size) = {
+ import draw: *
+ let (w, h) = size
+ let (xo, yo) = {
+ let spacing = style.at("spacing", default: (0, 0))
+ if type(spacing) == array {
+ spacing
+ } else {
+ (spacing, spacing)
+ }
+ }
+
+ anchor("north", (rel: (w / 2, yo), to: (element + ".north", "-|", element + ".origin")))
+ anchor("south", (rel: (w / 2, -yo), to: (element + ".south", "-|", element + ".origin")))
+ anchor("east", (rel: (xo, h / 2), to: (element + ".east", "|-", element + ".origin")))
+ anchor("west", (rel: (-xo, h / 2), to: (element + ".west", "|-", element + ".origin")))
+ anchor("north-east", (rel: (xo, h), to: (element + ".north-east", "|-", element + ".origin")))
+ anchor("north-west", (rel: (-xo, h), to: (element + ".north-west", "|-", element + ".origin")))
+ anchor("south-east", (rel: (xo, 0), to: (element + ".south-east", "|-", element + ".origin")))
+ anchor("south-west", (rel: (-xo, 0), to: (element + ".south-west", "|-", element + ".origin")))
+ anchor("inner-north", (rel: (w / 2, h - yo), to: element + ".origin"))
+ anchor("inner-north-east", (rel: (w - xo, h - yo), to: element + ".origin"))
+ anchor("inner-north-west", (rel: (yo, h - yo), to: element + ".origin"))
+ anchor("inner-south", (rel: (w / 2, yo), to: element + ".origin"))
+ anchor("inner-south-east", (rel: (w - xo, yo), to: element + ".origin"))
+ anchor("inner-south-west", (rel: (xo, yo), to: element + ".origin"))
+ anchor("inner-east", (rel: (w - xo, h / 2), to: element + ".origin"))
+ anchor("inner-west", (rel: (xo, h / 2), to: element + ".origin"))
+}
+
+// Draw a generic item preview
+#let draw-generic-preview(item) = {
+ import draw: *
+
+ if item.at("fill", default: false) {
+ rect((0,0), (1,1), ..item.style)
+ } else {
+ line((0,.5), (1,.5), ..item.style)
+ }
+}
+
+// Draw a legend box at position relative to anchor of plot-element
+#let draw-legend(ctx, style, items, size, plot, position, anchor) = {
+ let style = styles.resolve(
+ ctx.style, merge: style, base: default-style, root: "legend")
+ assert(style.orientation in (ttb, ltr),
+ message: "Unsupported legend orientation.")
+
+ if position == auto {
+ position = style.default-position
+ }
+
+ // Create legend anchors
+ draw.group(name: "legend", {
+ add-legend-anchors(style, plot, size)
+ })
+
+ // Try finding an optimal legend anchor
+ let anchor = if type(position) == str and anchor == auto {
+ auto-group-anchor.at(position.replace("legend.", ""), default: "north-west")
+ } else {
+ anchor
+ }
+
+ // Apply offset
+ if style.offset not in (none, (0,0)) {
+ position = (rel: style.offset, to: position)
+ }
+
+ draw.on-layer(style.layer, {
+ draw.group(name: "legend", padding: style.padding, ctx => {
+ import draw: *
+
+ set-origin(position)
+ anchor("default", (0,0))
+
+ let pt = (0, 0)
+ for (i, item) in items.enumerate() {
+ if item.label == none { continue }
+ let label = if item.label == auto {
+ $ f_(#i) $
+ } else { item.label }
+
+ group({
+ anchor("default", (0,0))
+
+ let row-height = style.item.preview.height
+ let preview-width = style.item.preview.width
+ let preview-a = (0, -row-height / 2)
+ let preview-b = (preview-width, +row-height / 2)
+ let label-west = (preview-width + style.item.preview.margin, 0)
+
+ // Draw item preview
+ let draw-preview = item.at("plot-legend-preview",
+ default: draw-generic-preview)
+ group({
+ set-viewport(preview-a, preview-b, bounds: (1, 1, 0))
+ (draw-preview)(item)
+ })
+
+ // Draw mark preview
+ let mark = item.at("mark", default: none)
+ if mark != none {
+ draw-mark-shape((preview-a, 50%, preview-b),
+ calc.min(style.item.preview.width / 2, item.mark-size),
+ mark,
+ item.mark-style)
+ }
+
+ // Draw label
+ content(label-west,
+ align(left + horizon, label),
+ name: "label", anchor: "west")
+ }, name: "item", anchor: if style.orientation == ltr { "west" } else { "north-west" })
+
+ if style.orientation == ttb {
+ set-origin((rel: (0, -style.item.spacing),
+ to: "item.south-west"))
+ } else if style.orientation == ltr {
+ set-origin((rel: (style.item.spacing, 0),
+ to: "item.east"))
+ }
+ }
+ }, anchor: anchor)
+ })
+
+ // Fill legend background
+ draw.on-layer(style.layer - .5, {
+ draw.rect("legend.south-west",
+ "legend.north-east", fill: style.fill, stroke: style.stroke, radius: style.radius)
+ })
+}
diff --git a/src/plot/line.typ b/src/plot/line.typ
new file mode 100644
index 0000000..12b5c6c
--- /dev/null
+++ b/src/plot/line.typ
@@ -0,0 +1,508 @@
+#import "/src/cetz.typ": draw
+
+#import "util.typ"
+#import "sample.typ"
+
+// Transform points
+//
+// - data (array): Data points
+// - line (str,dictionary): Line line
+#let transform-lines(data, line) = {
+ let hvh-data(t) = {
+ if type(t) == ratio {
+ t = t / 1%
+ }
+ t = calc.max(0, calc.min(t, 1))
+
+ let pts = ()
+
+ let len = data.len()
+ for i in range(0, len) {
+ pts.push(data.at(i))
+
+ if i < len - 1 {
+ let (a, b) = (data.at(i), data.at(i+1))
+ if t == 0 {
+ pts.push((a.at(0), b.at(1)))
+ } else if t == 1 {
+ pts.push((b.at(0), a.at(1)))
+ } else {
+ let x = a.at(0) + (b.at(0) - a.at(0)) * t
+ pts.push((x, a.at(1)))
+ pts.push((x, b.at(1)))
+ }
+ }
+ }
+ return pts
+ }
+
+ if type(line) == str {
+ line = (type: line)
+ }
+
+ let line-type = line.at("type", default: "linear")
+ assert(line-type in ("raw", "linear", "spline", "vh", "hv", "hvh"))
+
+ // Transform data into line-data
+ let line-data = if line-type == "linear" {
+ return util.linearized-data(data, line.at("epsilon", default: 0))
+ } else if line-type == "spline" {
+ return util.sampled-spline-data(data,
+ line.at("tension", default: .5),
+ line.at("samples", default: 15))
+ } else if line-type == "vh" {
+ return hvh-data(0)
+ } else if line-type == "hv" {
+ return hvh-data(1)
+ } else if line-type == "hvh" {
+ return hvh-data(line.at("mid", default: .5))
+ } else {
+ return data
+ }
+}
+
+// Fill a plot by generating a fill path to y value `to`
+#let fill-segments-to(segments, to) = {
+ for s in segments {
+ let low = calc.min(..s.map(v => v.at(0)))
+ let high = calc.max(..s.map(v => v.at(0)))
+
+ let origin = (low, to)
+ let target = (high, to)
+
+ draw.line(origin, ..s, target, stroke: none)
+ }
+}
+
+// Fill a shape by generating a fill path for each segment
+#let fill-shape(paths) = {
+ for p in paths {
+ draw.line(..p, stroke: none)
+ }
+}
+
+// Prepare line data
+#let _prepare(self, ctx) = {
+ let (x, y) = (ctx.x, ctx.y)
+
+ // Generate stroke paths
+ self.stroke-paths = util.compute-stroke-paths(self.line-data,
+ (x.min, y.min), (x.max, y.max))
+
+ // Compute fill paths if filling is requested
+ self.hypograph = self.at("hypograph", default: false)
+ self.epigraph = self.at("epigraph", default: false)
+ self.fill = self.at("fill", default: false)
+ if self.hypograph or self.epigraph or self.fill {
+ self.fill-paths = util.compute-fill-paths(self.line-data,
+ (x.min, y.min), (x.max, y.max))
+ }
+
+ return self
+}
+
+// Stroke line data
+#let _stroke(self, ctx) = {
+ let (x, y) = (ctx.x, ctx.y)
+
+ for p in self.stroke-paths {
+ draw.line(..p, fill: none)
+ }
+}
+
+// Fill line data
+#let _fill(self, ctx) = {
+ let (x, y) = (ctx.x, ctx.y)
+
+ if self.hypograph {
+ fill-segments-to(self.fill-paths, y.min)
+ }
+ if self.epigraph {
+ fill-segments-to(self.fill-paths, y.max)
+ }
+ if self.fill {
+ if self.at("fill-type", default: "axis") == "shape" {
+ fill-shape(self.fill-paths)
+ } else {
+ fill-segments-to(self.fill-paths,
+ calc.max(calc.min(y.max, 0), y.min))
+ }
+ }
+}
+
+/// Add data to a plot environment.
+///
+/// Note: You can use this for scatter plots by setting
+/// the stroke style to `none`: `add(..., style: (stroke: none))`.
+///
+/// Must be called from the body of a `plot(..)` command.
+///
+/// - domain (domain): Domain of `data`, if `data` is a function. Has no effect
+/// if `data` is not a function.
+/// - hypograph (bool): Fill hypograph; uses the `hypograph` style key for
+/// drawing
+/// - epigraph (bool): Fill epigraph; uses the `epigraph` style key for
+/// drawing
+/// - fill (bool): Fill the shape of the plot
+/// - fill-type (string): Fill type:
+/// / `"axis"`: Fill the shape to y = 0
+/// / `"shape"`: Fill the complete shape
+/// - samples (int): Number of times the `data` function gets called for
+/// sampling y-values. Only used if `data` is of type function. This parameter gets
+/// passed onto `sample-fn`.
+/// - sample-at (array): Array of x-values the function gets sampled at in addition
+/// to the default sampling. This parameter gets passed to `sample-fn`.
+/// - line (string, dictionary): Line type to use. The following types are
+/// supported:
+/// / `"linear"`: Draw linear lines between points
+/// / `"spline"`: Calculate a Catmull-Rom through all points
+/// / `"vh"`: Move vertical and then horizontal
+/// / `"hv"`: Move horizontal and then vertical
+/// / `"hvh"`: Add a vertical step in the middle
+/// / `"raw"`: Like linear, but without linearization taking place. This is
+/// meant as a "fallback" for either bad performance or bugs.
+///
+/// If the value is a dictionary, the type must be
+/// supplied via the `type` key. The following extra
+/// attributes are supported:
+/// / `"samples" `: Samples of splines
+/// / `"tension" `: Tension of splines
+/// / `"mid" `: Mid-Point of hvh lines (0 to 1)
+/// / `"epsilon" `: Linearization slope epsilon for
+/// use with `"linear"`, defaults to 0.
+///
+/// #example(```
+/// import cetz.plot
+/// let points(offset: 0) = ((0,0), (1,1), (2,0), (3,1), (4,0)).map(((x,y)) => {
+/// (x,y + offset * 1.5)
+/// })
+/// plot.plot(size: (12, 3), axis-style: none, {
+/// plot.add(points(offset: 5), line: (type: "hvh", mid: .1))
+/// plot.add(points(offset: 4), line: "hvh")
+/// plot.add(points(offset: 3), line: "hv")
+/// plot.add(points(offset: 2), line: "vh")
+/// plot.add(points(offset: 1), line: "spline")
+/// plot.add(points(offset: 0), line: "linear")
+/// })
+/// ```, vertical: true)
+///
+/// - style (style): Style to use, can be used with a `palette` function
+/// - axes (axes): Name of the axes to use for plotting. Reversing the axes
+/// means rotating the plot by 90 degrees.
+/// - mark (string): Mark symbol to place at each distinct value of the
+/// graph. Uses the `mark` style key of `style` for drawing.
+/// - mark-size (float): Mark size in cavas units
+/// - data (array,function): Array of 2D data points (numeric) or a function
+/// of the form `x => y`, where `x` is a value in `domain`
+/// and `y` must be numeric or a 2D vector (for parametric functions).
+/// #example(```
+/// import cetz.plot
+/// plot.plot(size: (2, 2), axis-style: none, {
+/// // Using an array of points:
+/// plot.add(((0,0), (calc.pi/2,1),
+/// (1.5*calc.pi,-1), (2*calc.pi,0)))
+/// // Sampling a function:
+/// plot.add(domain: (0, 2*calc.pi), calc.sin)
+/// })
+/// ```)
+/// - label (none,content): Legend label to show for this plot.
+#let add(domain: auto,
+ hypograph: false,
+ epigraph: false,
+ fill: false,
+ fill-type: "axis",
+ style: (:),
+ mark: none,
+ mark-size: .2,
+ mark-style: (:),
+ samples: 50,
+ sample-at: (),
+ line: "linear",
+ axes: ("x", "y"),
+ label: none,
+ data
+ ) = {
+ // If data is of type function, sample it
+ if type(data) == function {
+ data = sample.sample-fn(data, domain, samples, sample-at: sample-at)
+ }
+
+ // Transform data
+ let line-data = transform-lines(data, line)
+
+ // Get x-domain
+ let x-domain = (
+ calc.min(..line-data.map(t => t.at(0))),
+ calc.max(..line-data.map(t => t.at(0)))
+ )
+
+ // Get y-domain
+ let y-domain = if line-data != none {(
+ calc.min(..line-data.map(t => t.at(1))),
+ calc.max(..line-data.map(t => t.at(1)))
+ )}
+
+ ((
+ type: "line",
+ label: label,
+ data: data, /* Raw data */
+ line-data: line-data, /* Transformed data */
+ axes: axes,
+ x-domain: x-domain,
+ y-domain: y-domain,
+ epigraph: epigraph,
+ hypograph: hypograph,
+ fill: fill,
+ fill-type: fill-type,
+ style: style,
+ mark: mark,
+ mark-size: mark-size,
+ mark-style: mark-style,
+ plot-prepare: _prepare,
+ plot-stroke: _stroke,
+ plot-fill: _fill,
+ plot-legend-preview: self => {
+ if self.fill or self.epigraph or self.hypograph {
+ draw.rect((0,0), (1,1), ..self.style)
+ } else {
+ draw.line((0,.5), (1,.5), ..self.style)
+ }
+ }
+ ),)
+}
+
+/// Add horizontal lines at one or more y-values. Every lines start and end points
+/// are at their axis bounds.
+///
+/// #example(```
+/// cetz.plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
+/// cetz.plot.add(domain: (0, 4*calc.pi), calc.sin)
+/// // Add 3 horizontal lines
+/// cetz.plot.add-hline(-.5, 0, .5)
+/// })
+/// ```)
+///
+/// - ..y (float): Y axis value(s) to add a line at
+/// - min (auto,float): X axis minimum value or auto to take the axis minimum
+/// - max (auto,float): X axis maximum value or auto to take the axis maximum
+/// - axes (array): Name of the axes to use for plotting
+/// - style (style): Style to use, can be used with a palette function
+/// - label (none,content): Legend label to show for this plot.
+#let add-hline(..y,
+ min: auto,
+ max: auto,
+ axes: ("x", "y"),
+ style: (:),
+ label: none,
+ ) = {
+ assert(y.pos().len() >= 1,
+ message: "Specify at least one y value")
+ assert(y.named().len() == 0)
+
+ let prepare(self, ctx) = {
+ let (x-min, x-max) = (ctx.x.min, ctx.x.max)
+ let (y-min, y-max) = (ctx.y.min, ctx.y.max)
+ let x-min = if min == auto { x-min } else { min }
+ let x-max = if max == auto { x-max } else { max }
+
+ self.lines = self.y.filter(y => y >= y-min and y <= y-max)
+ .map(y => ((x-min, y), (x-max, y)))
+ return self
+ }
+
+ let stroke(self, ctx) = {
+ for (a, b) in self.lines {
+ draw.line(a, b, fill: none)
+ }
+ }
+
+ let x-min = if min == auto { none } else { min }
+ let x-max = if max == auto { none } else { max }
+
+ ((
+ type: "hline",
+ label: label,
+ y: y.pos(),
+ x-domain: (x-min, x-max),
+ y-domain: (calc.min(..y.pos()), calc.max(..y.pos())),
+ axes: axes,
+ style: style,
+ plot-prepare: prepare,
+ plot-stroke: stroke,
+ ),)
+}
+
+/// Add vertical lines at one or more x-values. Every lines start and end points
+/// are at their axis bounds.
+///
+/// #example(```
+/// cetz.plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
+/// cetz.plot.add(domain: (0, 2*calc.pi), calc.sin)
+/// // Add 3 vertical lines
+/// cetz.plot.add-vline(calc.pi/2, calc.pi, 3*calc.pi/2)
+/// })
+/// ```)
+///
+/// - ..x (float): X axis values to add a line at
+/// - min (auto,float): Y axis minimum value or auto to take the axis minimum
+/// - max (auto,float): Y axis maximum value or auto to take the axis maximum
+/// - axes (array): Name of the axes to use for plotting, note that not all
+/// plot styles are able to display a custom axis!
+/// - style (style): Style to use, can be used with a palette function
+/// - label (none,content): Legend label to show for this plot.
+#let add-vline(..x,
+ min: auto,
+ max: auto,
+ axes: ("x", "y"),
+ style: (:),
+ label: none,
+ ) = {
+ assert(x.pos().len() >= 1,
+ message: "Specify at least one x value")
+ assert(x.named().len() == 0)
+
+ let prepare(self, ctx) = {
+ let (x-min, x-max) = (ctx.x.min, ctx.x.max)
+ let (y-min, y-max) = (ctx.y.min, ctx.y.max)
+ let y-min = if min == auto { y-min } else { min }
+ let y-max = if max == auto { y-max } else { max }
+
+ self.lines = self.x.filter(x => x >= x-min and x <= x-max)
+ .map(x => ((x, y-min), (x, y-max)))
+ return self
+ }
+
+ let stroke(self, ctx) = {
+ for (a, b) in self.lines {
+ draw.line(a, b, fill: none)
+ }
+ }
+
+ let y-min = if min == auto { none } else { min }
+ let y-max = if max == auto { none } else { max }
+
+ ((
+ type: "vline",
+ label: label,
+ x: x.pos(),
+ x-domain: (calc.min(..x.pos()), calc.max(..x.pos())),
+ y-domain: (y-min, y-max),
+ axes: axes,
+ style: style,
+ plot-prepare: prepare,
+ plot-stroke: stroke
+ ),)
+}
+
+/// Fill the area between two graphs. This behaves same as `add` but takes
+/// a pair of data instead of a single data array/function.
+/// The area between both function plots gets filled. For a more detailed
+/// explanation of the arguments, see @@add().
+///
+/// This can be used to display an error-band of a function.
+///
+/// #example(```
+/// cetz.plot.plot(size: (2,2), x-tick-step: none, y-tick-step: none, {
+/// cetz.plot.add-fill-between(domain: (0, 2*calc.pi),
+/// calc.sin, // First function/data
+/// calc.cos) // Second function/data
+/// })
+/// ```)
+///
+/// - domain (domain): Domain of both `data-a` and `data-b`. The domain is used for
+/// sampling functions only and has no effect on data arrays.
+/// - samples (int): Number of times the `data-a` and `data-b` function gets called for
+/// sampling y-values. Only used if `data-a` or `data-b` is of
+/// type function.
+/// - sample-at (array): Array of x-values the function(s) get sampled at in addition
+/// to the default sampling.
+/// - line (string, dictionary): Line type to use, see @@add().
+/// - style (style): Style to use, can be used with a palette function.
+/// - label (none,content): Legend label to show for this plot.
+/// - axes (array): Name of the axes to use for plotting.
+/// - data-a (array,function): Data of the first plot, see @@add().
+/// - data-b (array,function): Data of the second plot, see @@add().
+#let add-fill-between(data-a,
+ data-b,
+ domain: auto,
+ samples: 50,
+ sample-at: (),
+ line: "linear",
+ axes: ("x", "y"),
+ label: none,
+ style: (:)) = {
+ // If data is of type function, sample it
+ if type(data-a) == function {
+ data-a = sample.sample-fn(data-a, domain, samples, sample-at: sample-at)
+ }
+ if type(data-b) == function {
+ data-b = sample.sample-fn(data-b, domain, samples, sample-at: sample-at)
+ }
+
+ // Transform data
+ let line-a-data = transform-lines(data-a, line)
+ let line-b-data = transform-lines(data-b, line)
+
+ // Get x-domain
+ let x-domain = (
+ calc.min(..line-a-data.map(t => t.at(0)),
+ ..line-b-data.map(t => t.at(0))),
+ calc.max(..line-a-data.map(t => t.at(0)),
+ ..line-b-data.map(t => t.at(0)))
+ )
+
+ // Get y-domain
+ let y-domain = if line-a-data != none and line-b-data != none {(
+ calc.min(..line-a-data.map(t => t.at(1)),
+ ..line-b-data.map(t => t.at(1))),
+ calc.max(..line-a-data.map(t => t.at(1)),
+ ..line-b-data.map(t => t.at(1)))
+ )}
+
+ let prepare(self, ctx) = {
+ let (x, y) = (ctx.x, ctx.y)
+
+ // Generate stroke paths
+ self.stroke-paths = (
+ a: util.compute-stroke-paths(self.line-data.a,
+ (x.min, y.min), (x.max, y.max)),
+ b: util.compute-stroke-paths(self.line-data.b,
+ (x.min, y.min), (x.max, y.max))
+ )
+
+ // Generate fill paths
+ self.fill-paths = util.compute-fill-paths(self.line-data.a + self.line-data.b.rev(),
+ (x.min, y.min), (x.max, y.max))
+
+ return self
+ }
+
+ let stroke(self, ctx) = {
+ for p in self.stroke-paths.a {
+ draw.line(..p, fill: none)
+ }
+ for p in self.stroke-paths.b {
+ draw.line(..p, fill: none)
+ }
+ }
+
+ let fill(self, ctx) = {
+ fill-shape(self.fill-paths)
+ }
+
+ ((
+ type: "fill-between",
+ label: label,
+ axes: axes,
+ line-data: (a: line-a-data, b: line-b-data),
+ x-domain: x-domain,
+ y-domain: y-domain,
+ style: style,
+ plot-prepare: prepare,
+ plot-stroke: stroke,
+ plot-fill: fill,
+ plot-legend-preview: self => {
+ draw.rect((0,0), (1,1), ..self.style)
+ }
+ ),)
+}
diff --git a/src/plot/mark.typ b/src/plot/mark.typ
new file mode 100644
index 0000000..c617eec
--- /dev/null
+++ b/src/plot/mark.typ
@@ -0,0 +1,51 @@
+#import "/src/cetz.typ": draw
+
+// Draw mark at point with size
+#let draw-mark-shape(pt, size, mark, style) = {
+ let (sx, sy) = if type(size) != array {
+ (size, size)
+ } else { size }
+
+ let bl(pt) = (rel: (-sx/2, -sy/2), to: pt)
+ let br(pt) = (rel: (sx/2, -sy/2), to: pt)
+ let tl(pt) = (rel: (-sx/2, sy/2), to: pt)
+ let tr(pt) = (rel: (sx/2, sy/2), to: pt)
+ let ll(pt) = (rel: (-sx/2, 0), to: pt)
+ let rr(pt) = (rel: (sx/2, 0), to: pt)
+ let tt(pt) = (rel: (0, sy/2), to: pt)
+ let bb(pt) = (rel: (0, -sy/2), to: pt)
+
+ if mark == "o" {
+ draw.circle(pt, radius: (sx/2, sy/2), ..style)
+ } else if mark == "square" {
+ draw.rect(bl(pt), tr(pt), ..style)
+ } else if mark == "triangle" {
+ draw.line(bl(pt), br(pt), tt(pt), close: true, ..style)
+ } else if mark == "*" or mark == "x" {
+ draw.line(bl(pt), tr(pt), ..style)
+ draw.line(tl(pt), br(pt), ..style)
+ } else if mark == "+" {
+ draw.line(ll(pt), rr(pt), ..style);
+ draw.line(tt(pt), bb(pt), ..style)
+ } else if mark == "-" {
+ draw.line(ll(pt), rr(pt), ..style)
+ } else if mark == "|" {
+ draw.line(tt(pt), bb(pt), ..style)
+ }
+}
+
+#let draw-mark(pts, x, y, mark, mark-size, plot-size) = {
+ // Scale marks back to canvas scaling
+ let (sx, sy) = plot-size
+ sx = (x.max - x.min) / sx
+ sy = (y.max - y.min) / sy
+ sx *= mark-size
+ sy *= mark-size
+
+ for pt in pts {
+ let (px, py, ..) = pt
+ if px >= x.min and px <= x.max and py >= y.min and py <= y.max {
+ draw-mark-shape(pt, (sx, sy), mark, (:))
+ }
+ }
+}
diff --git a/src/plot/sample.typ b/src/plot/sample.typ
new file mode 100644
index 0000000..3ad881d
--- /dev/null
+++ b/src/plot/sample.typ
@@ -0,0 +1,79 @@
+/// Sample the given single parameter function `samples` times, with values
+/// evenly spaced within the range given by `domain` and return each
+/// sampled `y` value in an array as `(x, y)` tuple.
+///
+/// If the functions first return value is a tuple `(x, y)`, then all return values
+/// must be a tuple.
+///
+/// - fn (function): Function to sample of the form `(x) => y` or `(t) => (x, y)`, where
+/// `x` or `t` are `float` values within the domain specified by `domain`.
+/// - domain (domain): Domain of `fn` used as bounding interval for the sampling points.
+/// - samples (int): Number of samples in domain.
+/// - sample-at (array): List of x values the function gets sampled at in addition
+/// to the `samples` number of samples. Values outsides the
+/// specified domain are legal.
+/// -> array: Array of (x, y) tuples
+#let sample-fn(fn, domain, samples, sample-at: ()) = {
+ assert(samples + sample-at.len() >= 2,
+ message: "You must at least sample 2 values")
+ assert(type(domain) == array and domain.len() == 2,
+ message: "Domain must be a tuple")
+
+ let (lo, hi) = domain
+
+ let y0 = (fn)(lo)
+ let is-vector = type(y0) == array
+ if not is-vector {
+ y0 = ((lo, y0), )
+ } else {
+ y0 = (y0, )
+ }
+
+ let pts = sample-at + range(0, samples).map(t => lo + t / (samples - 1) * (hi - lo))
+ pts = pts.sorted()
+
+ return pts.map(x => {
+ if is-vector {
+ (fn)(x)
+ } else {
+ (x, (fn)(x))
+ }
+ })
+}
+
+/// Samples the given two parameter function with `x-samples` and
+/// `y-samples` values evenly spaced within the range given by
+/// `x-domain` and `y-domain` and returns each sampled output in
+/// an array.
+///
+/// - fn (function): Function of the form `(x, y) => z` with all values being numbers.
+/// - x-domain (domain): Domain used as bounding interval for sampling point's x
+/// values.
+/// - y-domain (domain): Domain used as bounding interval for sampling point's y
+/// values.
+/// - x-samples (int): Number of samples in the x-domain.
+/// - y-samples (int): Number of samples in the y-domain.
+/// -> array: Array of z scalars
+#let sample-fn2(fn, x-domain, y-domain, x-samples, y-samples) = {
+ assert(x-samples >= 2,
+ message: "You must at least sample 2 x-values")
+ assert(y-samples >= 2,
+ message: "You must at least sample 2 y-values")
+ assert(type(x-domain) == array and x-domain.len() == 2,
+ message: "X-Domain must be a tuple")
+ assert(type(y-domain) == array and y-domain.len() == 2,
+ message: "Y-Domain must be a tuple")
+
+ let (x-min, x-max) = x-domain
+ let (y-min, y-max) = y-domain
+ let y-pts = range(0, y-samples)
+ let x-pts = range(0, x-samples)
+
+ return y-pts.map(y => {
+ let y = y / (y-samples - 1) * (y-max - y-min) + y-min
+ return x-pts.map(x => {
+ let x = x / (x-samples - 1) * (x-max - x-min) + x-min
+ return float((fn)(x, y))
+ })
+ })
+}
diff --git a/src/plot/util.typ b/src/plot/util.typ
new file mode 100644
index 0000000..a06df26
--- /dev/null
+++ b/src/plot/util.typ
@@ -0,0 +1,350 @@
+#import "/src/cetz.typ"
+#import cetz.util: bezier
+
+/// Clip line-strip in rect
+///
+/// - points (array): Array of vectors representing a line-strip
+/// - low (vector): Lower clip-window coordinate
+/// - high (vector): Upper clip-window coordinate
+/// -> array List of line-strips representing the paths insides the clip-window
+#let clipped-paths(points, low, high, fill: false) = {
+ let (min-x, max-x) = (calc.min(low.at(0), high.at(0)),
+ calc.max(low.at(0), high.at(0)))
+ let (min-y, max-y) = (calc.min(low.at(1), high.at(1)),
+ calc.max(low.at(1), high.at(1)))
+
+ let in-rect(pt) = {
+ return (pt.at(0) >= min-x and pt.at(0) <= max-x and
+ pt.at(1) >= min-y and pt.at(1) <= max-y)
+ }
+
+ let interpolated-end(a, b) = {
+ if in-rect(a) and in-rect(b) {
+ return b
+ }
+
+ let (x1, y1, ..) = a
+ let (x2, y2, ..) = b
+
+ if x2 - x1 == 0 {
+ return (x2, calc.min(max-y, calc.max(y2, min-y)))
+ }
+
+ if y2 - y1 == 0 {
+ return (calc.min(max-x, calc.max(x2, min-x)), y2)
+ }
+
+ let m = (y2 - y1) / (x2 - x1)
+ let n = y2 - m * x2
+
+ let x = x2
+ let y = y2
+
+ y = calc.min(max-y, calc.max(y, min-y))
+ x = (y - n) / m
+
+ x = calc.min(max-x, calc.max(x, min-x))
+ y = m * x + n
+
+ return (x, y)
+ }
+
+ // Append path to paths and return paths
+ //
+ // If path starts or ends with a vector of another part, merge those
+ // paths instead appending path as a new path.
+ let append-path(paths, path) = {
+ if path.len() <= 1 {
+ return paths
+ }
+
+ let cmp(a, b) = {
+ return a.map(calc.round.with(digits: 8)) == b.map(calc.round.with(digits: 8))
+ }
+
+ let added = false
+ for i in range(0, paths.len()) {
+ let p = paths.at(i)
+ if cmp(p.first(), path.last()) {
+ paths.at(i) = path + p
+ added = true
+ } else if cmp(p.first(), path.first()) {
+ paths.at(i) = path.rev() + p
+ added = true
+ } else if cmp(p.last(), path.first()) {
+ paths.at(i) = p + path
+ added = true
+ } else if cmp(p.last(), path.last()) {
+ paths.at(i) = p + path.rev()
+ added = true
+ }
+ if added { break }
+ }
+
+ if not added {
+ paths.push(path)
+ }
+ return paths
+ }
+
+ let clamped-pt(pt) = {
+ return (calc.max(min-x, calc.min(pt.at(0), max-x)),
+ calc.max(min-y, calc.min(pt.at(1), max-y)))
+ }
+
+ let paths = ()
+
+ let path = ()
+ let prev = points.at(0)
+ let was-inside = in-rect(prev)
+ if was-inside {
+ path.push(prev)
+ } else if fill {
+ path.push(clamped-pt(prev))
+ }
+
+ for i in range(1, points.len()) {
+ let pt = points.at(i)
+ let is-inside = in-rect(pt)
+
+ if is-inside {
+ if was-inside {
+ path.push(pt)
+ } else {
+ path.push(interpolated-end(pt, prev))
+ path.push(pt)
+ }
+ } else {
+ if was-inside {
+ path.push(interpolated-end(prev, pt))
+ } else {
+ let (a, b) = (interpolated-end(pt, prev),
+ interpolated-end(prev, pt))
+ if in-rect(a) and in-rect(b) {
+ path.push(a)
+ path.push(b)
+ } else if fill {
+ path.push((calc.max(min-x, calc.min(pt.at(0), max-x)),
+ calc.max(min-y, calc.min(pt.at(1), max-y))))
+ }
+ }
+
+ if path.len() > 0 and not fill {
+ paths = append-path(paths, path)
+ path = ()
+ }
+ }
+
+ prev = pt
+ was-inside = is-inside
+ }
+
+ // Append clamped last point if filling
+ if fill and not in-rect(prev) {
+ path.push(clamped-pt(prev))
+ }
+
+ if path.len() > 1 {
+ paths = append-path(paths, path)
+ }
+
+ return paths
+}
+
+/// Compute clipped stroke paths
+///
+/// - points (array): X/Y data points
+/// - low (vector): Lower clip-window coordinate
+/// - high (vector): Upper clip-window coordinate
+/// -> array List of stroke paths
+#let compute-stroke-paths(points, low, high) = {
+ clipped-paths(points, low, high, fill: false)
+}
+
+/// Compute clipped fill path
+///
+/// - points (array): X/Y data points
+/// - low (vector): Lower clip-window coordinate
+/// - high (vector): Upper clip-window coordinate
+/// -> array List of fill paths
+#let compute-fill-paths(points, low, high) = {
+ clipped-paths(points, low, high, fill: true)
+}
+
+/// Return points of a sampled catmull-rom through the
+/// input points.
+///
+/// - points (array): Array of input vectors
+/// - tension (float): Catmull-Rom tension
+/// - samples (int): Number of samples
+/// -> array Array of vectors
+#let sampled-spline-data(points, tension, samples) = {
+ assert(samples >= 1 and samples <= 100,
+ message: "Must at least use 1 sample per curve")
+
+ let curves = bezier.catmull-to-cubic(points, tension)
+ let pts = ()
+ for c in curves {
+ for t in range(0, samples + 1) {
+ let t = t / samples
+ pts.push(bezier.cubic-point(..c, t))
+ }
+ }
+ return pts
+}
+
+/// Simplify linear data by "detecting" linear sections
+/// and skipping points until the slope changes.
+/// This can have a huge impact on the number of lines
+/// getting rendered.
+///
+/// - data (array): Data points
+/// - epsilon (float): Curvature threshold to treat data as linear
+#let linearized-data(data, epsilon) = {
+ let pts = ()
+ // Current slope, set to none if infinite
+ let dx = none
+ // Previous point, last skipped point
+ let prev = none
+ let skipped = none
+ // Current direction
+ let dir = 0
+
+ let len = data.len()
+ for i in range(0, len) {
+ let pt = data.at(i)
+ if prev != none and i < len - 1 {
+ let new-dir = pt.at(0) - prev.at(0)
+ if new-dir == 0 {
+ // Infinite slope
+ if dx != none {
+ if skipped != none {pts.push(skipped); skipped = none}
+ pts.push(pt)
+ } else {
+ skipped = pt
+ }
+ dx = none
+ } else {
+ // Push the previous and the current point
+ // if slope or direction changed
+ let new-dx = ((pt.at(1) - prev.at(1)) / new-dir)
+ if dx == none or calc.abs(new-dx - dx) > epsilon or (new-dir * dir) < 0 {
+ if skipped != none {pts.push(skipped); skipped = none}
+ pts.push(pt)
+
+ dx = new-dx
+ dir = new-dir
+ } else {
+ skipped = pt
+ }
+ }
+ } else {
+ if skipped != none {pts.push(skipped); skipped = none}
+ pts.push(pt)
+ }
+
+ prev = pt
+ }
+
+ return pts
+}
+
+// Get the default axis orientation
+// depending on the axis name
+#let get-default-axis-horizontal(name) = {
+ return lower(name).starts-with("x")
+}
+
+// Setup axes dictionary
+//
+// - axis-dict (dictionary): Existing axis dictionary
+// - options (dictionary): Named arguments
+// - plot-size (tuple): Plot width, height tuple
+#let setup-axes(ctx, axis-dict, options, plot-size) = {
+ import "/src/axes.typ"
+
+ // Get axis option for name
+ let get-axis-option(axis-name, name, default) = {
+ let v = options.at(axis-name + "-" + name, default: default)
+ if v == auto { default } else { v }
+ }
+
+ for (name, axis) in axis-dict {
+ if not "ticks" in axis { axis.ticks = () }
+ axis.label = get-axis-option(name, "label", $#name$)
+
+ // Configure axis bounds
+ axis.min = get-axis-option(name, "min", axis.min)
+ axis.max = get-axis-option(name, "max", axis.max)
+
+ assert(axis.min not in (none, auto) and
+ axis.max not in (none, auto),
+ message: "Axis min and max must be set.")
+ if axis.min == axis.max {
+ axis.min -= 1; axis.max += 1
+ }
+
+ // Configure axis orientation
+ axis.horizontal = get-axis-option(name, "horizontal",
+ get-default-axis-horizontal(name))
+
+ // Configure ticks
+ axis.ticks.list = get-axis-option(name, "ticks", ())
+ axis.ticks.step = get-axis-option(name, "tick-step", axis.ticks.step)
+ axis.ticks.minor-step = get-axis-option(name, "minor-tick-step", axis.ticks.minor-step)
+ axis.ticks.decimals = get-axis-option(name, "decimals", 2)
+ axis.ticks.unit = get-axis-option(name, "unit", [])
+ axis.ticks.format = get-axis-option(name, "format", axis.ticks.format)
+
+ // Axis break
+ axis.show-break = get-axis-option(name, "break", false)
+ axis.inset = get-axis-option(name, "inset", (0, 0))
+
+ // Configure grid
+ axis.ticks.grid = get-axis-option(name, "grid", false)
+
+ axis-dict.at(name) = axis
+ }
+
+ // Set axis options round two, after setting
+ // axis bounds
+ for (name, axis) in axis-dict {
+ let changed = false
+
+ // Configure axis aspect ratio
+ let equal-to = get-axis-option(name, "equal", none)
+ if equal-to != none {
+ assert.eq(type(equal-to), str,
+ message: "Expected axis name.")
+ assert(equal-to != name,
+ message: "Axis can not be equal to itself.")
+
+ let other = axis-dict.at(equal-to, default: none)
+ assert(other != none,
+ message: "Other axis must exist.")
+ assert(other.horizontal != axis.horizontal,
+ message: "Equal axes must have opposing orientation.")
+
+ let (w, h) = plot-size
+ let ratio = if other.horizontal {
+ h / w
+ } else {
+ w / h
+ }
+ axis.min = other.min * ratio
+ axis.max = other.max * ratio
+
+ changed = true
+ }
+
+ if changed {
+ axis-dict.at(name) = axis
+ }
+ }
+
+ for (name, axis) in axis-dict {
+ axis-dict.at(name) = axes.prepare-axis(ctx, axis, name)
+ }
+
+ return axis-dict
+}
diff --git a/tests/.gitignore b/tests/.gitignore
new file mode 100644
index 0000000..fe5a11a
--- /dev/null
+++ b/tests/.gitignore
@@ -0,0 +1,3 @@
+**/out/*
+**/diff/*
+*.pdf
diff --git a/tests/axes/ref/1.png b/tests/axes/ref/1.png
new file mode 100644
index 0000000..ddf820d
Binary files /dev/null and b/tests/axes/ref/1.png differ
diff --git a/tests/axes/test.typ b/tests/axes/test.typ
new file mode 100644
index 0000000..e517c22
--- /dev/null
+++ b/tests/axes/test.typ
@@ -0,0 +1,59 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+// Schoolbook Axis Styling
+#test-case({
+ import draw: *
+
+ set-style(axes: (
+ stroke: blue,
+ padding: .25,
+ x: (stroke: red),
+ y: (stroke: green, tick: (stroke: blue, length: .3))
+ ))
+ axes.school-book(size: (6, 6),
+ axes.axis(min: -1, max: 1, ticks: (step: 1, minor-step: auto,
+ grid: "both")),
+ axes.axis(min: -1, max: 1, ticks: (step: .5, minor-step: auto,
+ grid: "major")))
+})
+
+// Scientific Axis Styling
+#test-case({
+ import draw: *
+
+ set-style(axes: (stroke: blue))
+ set-style(axes: (left: (tick: (stroke: green + 2pt))))
+ set-style(axes: (bottom: (tick: (stroke: red, length: .5,
+ label: (angle: 90deg,
+ anchor: "east")))))
+ set-style(axes: (right: (tick: (label: (offset: .2,
+ angle: -45deg,
+ anchor: "north-west"), length: -.1))))
+ axes.scientific(size: (6, 6),
+ draw-unset: false,
+ top: none,
+ bottom: axes.axis(min: -1, max: 1, ticks: (step: 1, minor-step: auto,
+ grid: "both", unit: [ units])),
+ left: axes.axis(min: -1, max: 1, ticks: (step: .5, minor-step: auto,
+ grid: false)),
+ right: axes.axis(min: -10, max: 10, ticks: (step: auto, minor-step: auto,
+ grid: "major")),)
+})
+
+// Custom Tick Format
+#test-case({
+ import draw: *
+
+ axes.scientific(size: (6, 1),
+ bottom: axes.axis(min: -2*calc.pi, max: 2*calc.pi, ticks: (
+ step: calc.pi, minor-step: auto, format: v => {
+ let d = v / calc.pi
+ if d == 0 {return $0$}
+ {$#{d}pi$}
+ }
+ )),
+ left: axes.axis(min: -1, max: 1, ticks: (step: none, minor-step: none)))
+})
diff --git a/tests/chart/boxwhisker/ref/1.png b/tests/chart/boxwhisker/ref/1.png
new file mode 100644
index 0000000..5486bae
Binary files /dev/null and b/tests/chart/boxwhisker/ref/1.png differ
diff --git a/tests/chart/boxwhisker/test.typ b/tests/chart/boxwhisker/test.typ
new file mode 100644
index 0000000..342f304
--- /dev/null
+++ b/tests/chart/boxwhisker/test.typ
@@ -0,0 +1,26 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/tests/helper.typ": *
+
+#let data0 = (
+ (
+ label: "Control",
+ min: 10,q1: 25,q2: 50,
+ q3: 75,max: 90
+ ),
+ (
+ label: "Condition aB",
+ min: 32,q1: 54,q2: 60,
+ q3: 69,max: 73,
+ outliers: (18, 23, 78,)
+ ),
+)
+
+#test-case({
+ chart.boxwhisker(
+ size: (10, 10),
+ y-min: 0,
+ y-max: 100,
+ label-key: "label",
+ data0)
+})
diff --git a/tests/chart/piechart/ref/1.png b/tests/chart/piechart/ref/1.png
new file mode 100644
index 0000000..ef4743d
Binary files /dev/null and b/tests/chart/piechart/ref/1.png differ
diff --git a/tests/chart/piechart/test.typ b/tests/chart/piechart/test.typ
new file mode 100644
index 0000000..47fc19e
--- /dev/null
+++ b/tests/chart/piechart/test.typ
@@ -0,0 +1,110 @@
+#set page(width: auto, height: auto)
+#import "/src/cetz.typ": *
+#import "/src/lib.typ": *
+#import chart: piechart
+#import "/tests/helper.typ": *
+
+#let colors = gradient.linear(rgb("FFCCE5"), rgb("660033"))
+
+// Outset items
+#test-case({
+ import draw: *
+ piechart(range(1,11), outset: 3, outset-offset: 25%, slice-style: colors)
+})
+
+// Outset items + inner radius
+#test-case({
+ import draw: *
+ piechart(range(1,11), outset: 3, inner-radius: .5, outset-offset: 25%, slice-style: colors)
+})
+
+// Outset items + arc shape
+#test-case({
+ import draw: *
+ piechart(range(1,5), outset-offset: 25%, slice-style: colors,
+ start: 0deg, stop: 180deg)
+})
+
+// Outset items + inner radius
+#test-case({
+ import draw: *
+ piechart(range(1,5), inner-radius: .5, outset-offset: 25%, slice-style: colors,
+ start: 45deg, stop: 135deg)
+})
+
+// Rotated Values
+#test-case({
+ piechart(range(1,11), slice-style: colors, outer-label: (angle: auto, content: "VALUE"))
+})
+
+// Rotated Percentages
+#test-case({
+ piechart(range(10, 60, step: 10), slice-style: colors, outer-label: (angle: auto, content: "%"))
+})
+
+// Inner Values
+#test-case({
+ piechart(range(1,11), slice-style: colors, inner-label: (content: "VALUE"), radius: 2)
+})
+
+// Inner Percentages
+#test-case({
+ piechart(range(10, 60, step: 10), slice-style: colors, inner-label: (content: "%"), radius: 2)
+})
+
+// Gap as canvas size
+#test-case({
+ piechart(range(1,11), gap: .1, slice-style: colors)
+})
+
+// Gap as canvas size + inner radius
+#test-case({
+ piechart(range(1,11), gap: .1, inner-radius: .5, slice-style: colors)
+})
+
+// Gap as angle
+#test-case({
+ piechart(range(1,11), gap: 5deg, slice-style: colors, outer-label: (angle: auto))
+})
+
+// Anchors
+#test-case({
+ import draw: *
+ piechart(range(1,11), slice-style: colors, name: "c", inner-radius: .5)
+ for-each-anchor("c", n => {
+ circle("c." + n, radius: .05)
+ })
+})
+
+// Keys
+#test-case({
+ piechart(((value: 1, label: [One], o: false),
+ (value: 1, label: [Two], o: true)), slice-style: colors,
+ value-key: "value", label-key: "label", outer-label: (content: "LABEL", radius: 150%), outset-key: "o")
+})
+
+// Keys
+#test-case({
+ piechart(((value: 1, label: [One]),
+ (value: 1, label: [Two], o: 2%),
+ (value: 1, label: [Three], o: 4%),
+ (value: 1, label: [Four], o: 6%),
+ (value: 1, label: [Five], o: 8%),
+ (value: 1, label: [Six], o: 10%),
+ (value: 1, label: [Seven], o: 12%),
+ (value: 1, label: [Eight], o: 14%),),
+ slice-style: colors,
+ value-key: "value", label-key: "label", outer-label: (content: "LABEL", radius: 150%), outset-key: "o")
+})
+
+// Clockwise rotation
+#test-case({
+ import draw: *
+ piechart(range(1,4), clockwise: true, slice-style: (green, yellow, red))
+})
+
+// Counter clockwise rotation
+#test-case({
+ import draw: *
+ piechart(range(1,4), clockwise: false, slice-style: (green, yellow, red))
+})
diff --git a/tests/chart/ref/1.png b/tests/chart/ref/1.png
new file mode 100644
index 0000000..5c093db
Binary files /dev/null and b/tests/chart/ref/1.png differ
diff --git a/tests/chart/test.typ b/tests/chart/test.typ
new file mode 100644
index 0000000..5f2a16d
--- /dev/null
+++ b/tests/chart/test.typ
@@ -0,0 +1,226 @@
+#set page(width: auto, height: auto)
+#import "/src/cetz.typ": *
+#import "/src/lib.typ": *
+#import "/tests/helper.typ": *
+
+#let data0 = (
+ ([1], 1),
+ ([2], 2),
+ ([3], 3),
+)
+
+#let data1 = (
+ ([15-24], 20.0),
+ ([25-29], 17.2),
+ ([30-34], 14.2),
+ ([35-44], 29.3),
+ ([45-54], 22.5),
+ ([55+], 18.4),
+)
+
+#let data2 = (
+ ([15-24], 18.0, 20.1, 23.0, 17.0),
+ ([25-29], 16.3, 17.6, 19.4, 15.3),
+ ([30-34], 14.0, 15.3, 13.9, 18.7),
+ ([35-44], 35.5, 26.5, 29.4, 25.8),
+ ([45-54], 25.0, 20.6, 22.4, 22.0),
+ ([55+], 19.9, 18.2, 19.2, 16.4),
+)
+
+#let data3 = (
+ (1, 0.001),
+ (2, 0.002),
+ (3, 0.003),
+)
+
+#let data4 = (
+ (1, 1, .3),
+ (2, 2, .2),
+ (3, 3, .1),
+)
+
+#test-case({
+ chart.barchart(mode: "basic",
+ size: (9, auto),
+ data0)
+}))
+
+#test-case({
+ chart.barchart(mode: "basic",
+ size: (9, auto),
+ value-key: 1,
+ label-key: 0,
+ x-tick-step: 5,
+ x-label: [x],
+ y-label: [y],
+ data1)
+}))
+
+#test-case({
+ chart.barchart(mode: "clustered",
+ size: (9, auto),
+ label-key: 0,
+ value-key: (..range(1, 5)),
+ data2)
+}))
+
+#test-case({
+ chart.barchart(mode: "stacked",
+ size: (9, auto),
+ label-key: 0,
+ value-key: (..range(1, 5)),
+ bar-style: palette.blue,
+ data2)
+}))
+
+#test-case({
+ chart.barchart(mode: "stacked100",
+ size: (9, auto),
+ label-key: 0,
+ value-key: (..range(1, 5)),
+ bar-style: palette.blue,
+ data2)
+}))
+
+#test-case({
+ chart.columnchart(mode: "basic",
+ size: (auto, 5),
+ data0)
+})
+
+#test-case({
+ chart.columnchart(mode: "basic",
+ size: (auto, 5),
+ value-key: 1,
+ label-key: 0,
+ y-tick-step: 5,
+ x-label: [x],
+ y-label: [y],
+ data1)
+})
+
+#test-case({
+ chart.columnchart(mode: "clustered",
+ size: (auto, 5),
+ label-key: 0,
+ value-key: (..range(1, 5)),
+ data2)
+})
+
+#test-case({
+ chart.columnchart(mode: "stacked",
+ size: (auto, 5),
+ label-key: 0,
+ value-key: (..range(1, 5)),
+ bar-style: palette.blue,
+ data2)
+})
+
+#test-case({
+ chart.columnchart(mode: "stacked100",
+ size: (auto, 4),
+ label-key: 0,
+ value-key: (..range(1, 5)),
+ bar-style: palette.blue,
+ data2)
+})
+
+#test-case({
+ chart.columnchart(
+ size: (auto, 2),
+ y-tick-step: .5,
+ y-max: 1.0,
+ (([$ cal(P)_+ $], 4 / 13), ([$ cal(P)_- $], 9 / 13))
+ )
+
+ draw.set-origin((4, 0))
+
+ chart.barchart(
+ size: (3, auto),
+ x-tick-step: .5,
+ x-max: 1.0,
+ (([$ cal(P)_+ $], 4 / 13), ([$ cal(P)_- $], 9 / 13))
+ )
+})
+
+#test-case({
+ chart.columnchart(
+ size: (auto, 2),
+ y-tick-step: .5,
+ y-max: 1.0,
+ (([$ cal(P)_+ $], -4 / 13), ([$ cal(P)_- $], 9 / 13))
+ )
+
+ draw.set-origin((4, 0))
+
+ chart.barchart(
+ size: (3, auto),
+ x-tick-step: .5,
+ x-max: 1.0,
+ (([$ cal(P)_+ $], 4 / 13), ([$ cal(P)_- $], -9 / 13))
+ )
+})
+
+#test-case({
+ chart.columnchart(
+ size: (auto, 2),
+ y-tick-step: 0.001,
+ y-format: "sci",
+ data3)
+})
+
+#test-case({
+ chart.columnchart(
+ size: (auto, 2),
+ y-tick-step: 0.001,
+ y-decimals: 3,
+ data3)
+})
+
+#test-case({
+ chart.barchart(
+ size: (5, auto),
+ x-tick-step: 0.001,
+ x-format: "sci",
+ data3)
+})
+
+#test-case({
+ chart.barchart(
+ size: (5, auto),
+ x-tick-step: 0.001,
+ x-decimals: 3,
+ data3)
+})
+
+#test-case({
+ draw.set-style(barchart: (bar-width: 1, cluster-gap: .2))
+ chart.barchart(mode: "clustered",
+ size: (5, auto),
+ label-key: 0,
+ value-key: (..range(1, 5)),
+ data2)
+})
+
+#test-case({
+ draw.set-style(columnchart: (bar-width: 1, cluster-gap: .2))
+ chart.columnchart(mode: "clustered",
+ size: (auto, 5),
+ label-key: 0,
+ value-key: (..range(1, 5)),
+ data2)
+})
+
+#test-case({
+ chart.columnchart(mode: "basic",
+ size: (auto, 4),
+ error-key: 2,
+ data4)
+})
+
+#test-case({
+ chart.barchart(mode: "basic",
+ size: (9, auto),
+ error-key: 2,
+ data4)
+})
diff --git a/tests/helper.typ b/tests/helper.typ
new file mode 100644
index 0000000..442acde
--- /dev/null
+++ b/tests/helper.typ
@@ -0,0 +1,31 @@
+#import "/src/cetz.typ"
+#import "/src/lib.typ" as cetz-plot
+
+/// Draw a cross at position pt
+#let cross(pt, size: .25, ..style) = {
+ import cetz.draw: *
+ let len = size / 2
+ line((rel: (-len,0), to: pt),
+ (rel: (len, 0), to: pt), stroke: green, ..style)
+ line((rel: (0,-len), to: pt),
+ (rel: (0, len), to: pt), stroke: green, ..style)
+}
+
+/// Test case canvas surrounded by a red border
+#let test-case(body, ..canvas-args, args: none) = {
+ if type(body) != function {
+ body = _ => { body }
+ args = (none,)
+ } else {
+ assert(type(args) == array and args.len() > 0,
+ message: "Function body requires args set!")
+ }
+
+ for arg in args {
+ block(stroke: 2pt + red,
+ cetz.canvas(..canvas-args, {
+ body(arg)
+ })
+ )
+ }
+}
diff --git a/tests/plot/annotation/ref.png b/tests/plot/annotation/ref.png
new file mode 100644
index 0000000..452e6f3
Binary files /dev/null and b/tests/plot/annotation/ref.png differ
diff --git a/tests/plot/annotation/ref/1.png b/tests/plot/annotation/ref/1.png
new file mode 100644
index 0000000..160481a
Binary files /dev/null and b/tests/plot/annotation/ref/1.png differ
diff --git a/tests/plot/annotation/test.typ b/tests/plot/annotation/test.typ
new file mode 100644
index 0000000..917066c
--- /dev/null
+++ b/tests/plot/annotation/test.typ
@@ -0,0 +1,25 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+#test-case({
+ import draw: *
+ set-style(rect: (stroke: none))
+
+ plot.plot(size: (6, 4), {
+ plot.add(domain: (-calc.pi, 3*calc.pi), calc.sin)
+ plot.annotate(background: true, {
+ rect((0, -1), (calc.pi, 1), fill: blue.lighten(90%))
+ rect((calc.pi, -1.1), (2*calc.pi, 1.1), fill: red.lighten(90%))
+ rect((2*calc.pi, -1.5), (3.5*calc.pi, 1.5), fill: green.lighten(90%))
+ })
+ plot.annotate(padding: .1, {
+ line((calc.pi / 2, 1.1), (rel: (0, .2)), (rel: (2*calc.pi, 0)), (rel: (0, -.2)))
+ content((calc.pi * 1.5, 1.5), $ lambda $)
+ })
+ plot.annotate(padding: .1, {
+ line((calc.pi / 2,-.1), (calc.pi / 2, .8), mark: (end: "stealth"))
+ })
+ })
+})
diff --git a/tests/plot/bar/ref/1.png b/tests/plot/bar/ref/1.png
new file mode 100644
index 0000000..8f18520
Binary files /dev/null and b/tests/plot/bar/ref/1.png differ
diff --git a/tests/plot/bar/test.typ b/tests/plot/bar/test.typ
new file mode 100644
index 0000000..ca4ecae
--- /dev/null
+++ b/tests/plot/bar/test.typ
@@ -0,0 +1,20 @@
+#set page(width: auto, height: auto)
+#import "/src/cetz.typ": *
+#import "/src/lib.typ": *
+#import "/tests/helper.typ": *
+
+#let data = (
+ (0, (1, 2, 3)),
+ (1, (6, 7, 8), (2, 1, 0)),
+ (2, 5, ()),
+)
+
+#test-case({
+ plot.plot(size: (3, 3), x-tick-step: 1, y-tick-step: 1,
+ {
+ plot.add-bar(data,
+ x-key: 0,
+ y-key: 1,
+ error-key: 2)
+ })
+})
diff --git a/tests/plot/boxwhisker/ref/1.png b/tests/plot/boxwhisker/ref/1.png
new file mode 100644
index 0000000..e3c1081
Binary files /dev/null and b/tests/plot/boxwhisker/ref/1.png differ
diff --git a/tests/plot/boxwhisker/test.typ b/tests/plot/boxwhisker/test.typ
new file mode 100644
index 0000000..5f5cc63
--- /dev/null
+++ b/tests/plot/boxwhisker/test.typ
@@ -0,0 +1,57 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+#let box1 = (
+ outliers: (7, 65, 69),
+ min: 15,
+ q1: 25,
+ q2: 35,
+ q3: 50,
+ max: 60)
+
+#let box2 = (
+ min: -1,
+ q1: 0,
+ q2: 3,
+ q3: 6,
+ max: 8)
+
+#test-case({
+ import draw: *
+
+ plot.plot(size: (10, 10),
+ y-min: 0,
+ y-max: 100,
+ {
+ plot.add-boxwhisker((x: 1, ..box1))
+ })
+})
+
+#test-case({
+ import draw: *
+
+ plot.plot(size: (10, 10),
+ y-min: 0, y-max: 100,
+ {
+ plot.add-boxwhisker((
+ (x: 1, ..box1),
+ (x: 2, ..box1),
+ (x: 3, ..box1),
+ (x: 4, ..box1),
+ ))
+ })
+})
+
+// Test auto-sizing of the plot
+#test-case({
+ import draw: *
+
+ plot.plot(size: (10, 10), {
+ plot.add-boxwhisker((
+ (x: 1, ..box1),
+ (x: 2, ..box2),
+ ))
+ })
+})
diff --git a/tests/plot/broken-axes/ref/1.png b/tests/plot/broken-axes/ref/1.png
new file mode 100644
index 0000000..ead6a97
Binary files /dev/null and b/tests/plot/broken-axes/ref/1.png differ
diff --git a/tests/plot/broken-axes/test.typ b/tests/plot/broken-axes/test.typ
new file mode 100644
index 0000000..19f09b2
--- /dev/null
+++ b/tests/plot/broken-axes/test.typ
@@ -0,0 +1,23 @@
+#import "/src/lib.typ": *
+#import "/tests/helper.typ": *
+
+#let data = ((5,5), (10,10))
+
+#test-case({
+ plot.plot(size: (8,8),
+ x-break: true,
+ y-break: true,
+ {
+ plot.add(data)
+ })
+})
+
+#test-case({
+ plot.plot(size: (8,8),
+ axis-style: "school-book",
+ x-break: true,
+ y-break: true,
+ {
+ plot.add(data)
+ })
+})
diff --git a/tests/plot/contour/ref/1.png b/tests/plot/contour/ref/1.png
new file mode 100644
index 0000000..9af5dbf
Binary files /dev/null and b/tests/plot/contour/ref/1.png differ
diff --git a/tests/plot/contour/test.typ b/tests/plot/contour/test.typ
new file mode 100644
index 0000000..9d6d4d2
--- /dev/null
+++ b/tests/plot/contour/test.typ
@@ -0,0 +1,130 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+#let peaks(x, y) = (
+ 3 * calc.pow(1 - x, 2) * calc.exp(-(x*x) - calc.pow(y + 1, 2)) -
+ 10 * (x/5 - calc.pow(x, 3) - calc.pow(y, 5)) *
+ calc.exp(-(x * x) - (y * y)) - 1/3 * calc.exp(-calc.pow(x + 1, 2) - (y * y))
+)
+
+/* Simple contour */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (8, 8),
+ x-tick-step: 5,
+ y-tick-step: 5,
+ {
+ plot.add-contour(
+ (x, y) => 2 - (x - 1) * (y - 1),
+ fill: true,
+ x-domain: (-10, 10),
+ y-domain: (-10, 11),
+ )
+
+ plot.add-contour(
+ (x, y) => 30 - (calc.pow(1 - x, 2) + calc.pow(1 - y, 2)),
+ fill: true,
+ x-domain: (-10, 10),
+ y-domain: (-10, 10),
+ )
+ })
+})
+
+/* Multi contour */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (8, 8),
+ x-tick-step: 1,
+ y-tick-step: 1,
+ {
+ plot.add-contour(
+ peaks,
+ z: (0, 1, 2, 3, 4),
+ fill: true,
+ x-domain: (-2, 3),
+ y-domain: (-2, 3),
+ x-samples: 50,
+ y-samples: 50,
+ )
+ })
+})
+
+/* Multi contour */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (8, 8),
+ x-tick-step: 1,
+ y-tick-step: 1,
+ {
+ let z(x, y) = {
+ (1 - x/2 + calc.pow(x,5) + calc.pow(y,3)) * calc.exp(-(x*x) - (y*y))
+ }
+ plot.add-contour(
+ z,
+ z: (-.68, -.39, -.1, .1, .47, .76, 1.05),
+ fill: true,
+ x-domain: (-3, 3),
+ y-domain: (-3, 3),
+ x-samples: 50,
+ y-samples: 50,
+ )
+ })
+})
+
+/* Complex contour #270 */
+#test-case({
+ plot.plot(size: (8, 8), {
+ // x >= 0
+ plot.add-contour(
+ (x, y) => x,
+ z: 0,
+ y-samples: 2,
+ x-samples: 2,
+ x-domain: (0, 10),
+ y-domain: (-10, 10),
+ fill: true,
+ )
+
+ // y >= 0
+ plot.add-contour(
+ (x, y) => y,
+ z: 0,
+ y-samples: 2,
+ x-samples: 2,
+ x-domain: (-10, 10),
+ y-domain: (0, 10),
+ fill: true,
+ )
+
+ // hyperbola
+ plot.add-contour(
+ (x, y) => (x - 1) * (y - 1),
+ x-domain: (-10, 10),
+ y-domain: (-10, 10),
+ fill: true,
+ z: 1,
+ )
+
+ // circle
+ plot.add-contour(
+ (x, y) => (calc.pow((x - 1), 2) + calc.pow((y - 1), 2)),
+ x-domain: (-10, 10),
+ y-domain: (-10, 10),
+ z: 9,
+ op: "<=",
+ fill: true,
+ )
+
+ // line
+ plot.add-contour(
+ (x, y) => x + 1 - y,
+ x-domain: (-10, 10),
+ y-domain: (-10, 10),
+ )
+ })
+})
diff --git a/tests/plot/equal-axis/ref/1.png b/tests/plot/equal-axis/ref/1.png
new file mode 100644
index 0000000..58672e5
Binary files /dev/null and b/tests/plot/equal-axis/ref/1.png differ
diff --git a/tests/plot/equal-axis/test.typ b/tests/plot/equal-axis/test.typ
new file mode 100644
index 0000000..eae91c5
--- /dev/null
+++ b/tests/plot/equal-axis/test.typ
@@ -0,0 +1,36 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+#test-case({
+ import draw: *
+
+ plot.plot(size: (6,3),
+ x-tick-step: none,
+ y-tick-step: none,
+ x-equal: "y",
+ a-equal: "b",
+ b-horizontal: true,
+ {
+ plot.add(domain: (0, 2 * calc.pi), t => (calc.cos(t), calc.sin(t)))
+ plot.add(domain: (0, 2 * calc.pi), t => (calc.cos(t), calc.sin(t)),
+ axes: ("a", "b"))
+ })
+})
+
+#test-case({
+ import draw: *
+
+ plot.plot(size: (3,6),
+ x-tick-step: none,
+ y-tick-step: none,
+ x-equal: "y",
+ a-equal: "b",
+ b-horizontal: true,
+ {
+ plot.add(domain: (0, 2 * calc.pi), t => (calc.cos(t), calc.sin(t)))
+ plot.add(domain: (0, 2 * calc.pi), t => (calc.cos(t), calc.sin(t)),
+ axes: ("a", "b"))
+ })
+})
diff --git a/tests/plot/grid/ref/1.png b/tests/plot/grid/ref/1.png
new file mode 100644
index 0000000..a62f0e4
Binary files /dev/null and b/tests/plot/grid/ref/1.png differ
diff --git a/tests/plot/grid/test.typ b/tests/plot/grid/test.typ
new file mode 100644
index 0000000..5d37857
--- /dev/null
+++ b/tests/plot/grid/test.typ
@@ -0,0 +1,74 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+/* X grid */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (3, 3),
+ x-grid: true,
+ x-tick-step: .5,
+ y-tick-step: .5,
+ {
+ plot.add(((0,0), (1,1)))
+ })
+})
+
+/* X grid */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (3, 3),
+ x-grid: "both",
+ x-tick-step: .5,
+ x-minor-tick-step: .25,
+ y-tick-step: .5,
+ {
+ plot.add(((0,0), (1,1)))
+ })
+})
+
+/* Y grid */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (3, 3),
+ y-grid: true,
+ x-tick-step: .5,
+ y-tick-step: .5,
+ {
+ plot.add(((0,0), (1,1)))
+ })
+})
+
+/* Y grid */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (3, 3),
+ y-grid: "both",
+ x-tick-step: .5,
+ y-tick-step: .5,
+ y-minor-tick-step: .25,
+ {
+ plot.add(((0,0), (1,1)))
+ })
+})
+
+/* X-Y grid */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (3, 3),
+ x-grid: "both",
+ y-grid: "both",
+ x-tick-step: .5,
+ x-minor-tick-step: .25,
+ y-tick-step: .5,
+ y-minor-tick-step: .25,
+ {
+ plot.add(((0,0), (1,1)))
+ })
+})
diff --git a/tests/plot/hvline/ref/1.png b/tests/plot/hvline/ref/1.png
new file mode 100644
index 0000000..b212394
Binary files /dev/null and b/tests/plot/hvline/ref/1.png differ
diff --git a/tests/plot/hvline/test.typ b/tests/plot/hvline/test.typ
new file mode 100644
index 0000000..efc907f
--- /dev/null
+++ b/tests/plot/hvline/test.typ
@@ -0,0 +1,61 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+/* Empty plot */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (1, 1),
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add-vline(0)
+ plot.add-hline(0)
+ plot.add(((0,0), (1, 0)))
+ })
+})
+
+/* Line plot + h/v line */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (4, 4),
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add-vline(0)
+ plot.add-hline(0)
+ plot.add(((-1, -1), (1,1)))
+ })
+})
+
+/* Line plot + Multiple h/v lines */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (4, 4),
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add-vline(-.1, 0, .1)
+ plot.add-hline(-.1, 0, .1)
+ plot.add(((-2, -2), (2,2)))
+ })
+})
+
+/* Clipped h/v lines */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (4, 4),
+ x-tick-step: none,
+ y-tick-step: none,
+ x-min: 0, x-max: 2,
+ y-min: 0, y-max: 2,
+ {
+ plot.add-vline(-.1, 1, 3)
+ plot.add-hline(-.1, 1, 3)
+ })
+})
diff --git a/tests/plot/legend/ref/1.png b/tests/plot/legend/ref/1.png
new file mode 100644
index 0000000..a5bf299
Binary files /dev/null and b/tests/plot/legend/ref/1.png differ
diff --git a/tests/plot/legend/test.typ b/tests/plot/legend/test.typ
new file mode 100644
index 0000000..b3b2359
--- /dev/null
+++ b/tests/plot/legend/test.typ
@@ -0,0 +1,166 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+#let dom = (domain: (0, 2 * calc.pi))
+#let fn(x, offset: 0) = {calc.sin(x) + offset}
+
+#for pos in ("north", "south", "west", "east",
+ "north-east", "north-west",
+ "south-east", "south-west",) {
+ pos = "legend." + pos
+ test-case({
+ import draw: *
+
+ plot.plot(size: (2, 2),
+ x-tick-step: none,
+ y-tick-step: none,
+ legend: pos,
+ {
+ plot.add(..dom, fn, label: $ f(x) $)
+ })
+ })
+}
+
+#for pos in ("inner-north", "inner-south", "inner-west", "inner-east",
+ "inner-north-east", "inner-north-west",
+ "inner-south-east", "inner-south-west",) {
+ pos = "legend." + pos
+ test-case({
+ import draw: *
+
+ plot.plot(size: (4, 2),
+ x-tick-step: none,
+ y-tick-step: none,
+ legend: pos,
+ {
+ plot.add(..dom, fn, label: $ f(x) $)
+ })
+ })
+}
+
+#test-case({
+ plot.plot(size: (4, 2),
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add(..dom, fn, label: $ f_1(x) $)
+ plot.add(..dom, fn.with(offset: .1), label: $ f_2(x) $)
+ plot.add(..dom, fn.with(offset: .2), label: $ f_3(x) $)
+ })
+})
+
+#test-case({
+ plot.plot(size: (4, 2),
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add(samples: 10, ..dom, fn, mark: "o", label: $ f(x) $)
+ plot.add(samples: 10, ..dom, fn.with(offset: .1), mark: "x", fill: true, label: $ f_2(x) $)
+ plot.add(samples: 10, ..dom, fn.with(offset: .2), mark: "|", style: (stroke: none), label: $ f_3(x) $)
+ })
+})
+
+#test-case({
+ plot.plot(size: (4, 2),
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add-fill-between(..dom, fn, fn.with(offset: .5), label: $ f(x) $)
+ })
+})
+
+#test-case({
+ plot.plot(size: (4, 2),
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add-hline(0, label: $ f(x) $)
+ plot.add-vline(0, label: $ f(x) $)
+ })
+})
+
+#test-case({
+ plot.plot(size: (4, 2),
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add-contour(x-domain: (-1, 1), y-domain: (-1, 1),
+ (x, y) => x, z: 0, op: "<=", label: $ f(x) $)
+ plot.add-contour(x-domain: (-1, 1), y-domain: (-1, 1),
+ (x, y) => x, z: 0, fill: true, label: $ f(x) $)
+ })
+})
+
+#test-case({
+ import draw: *
+
+ let box1 = (
+ x: 1,
+ outliers: (7, 65, 69),
+ min: 15,
+ q1: 25,
+ q2: 35,
+ q3: 50,
+ max: 60)
+
+ plot.plot(size: (4, 2),
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add-boxwhisker(box1, label: [Box])
+ })
+})
+
+#test-case({
+ import draw: *
+
+ set-style(legend: (item: (preview: (width: .4), spacing: .7),
+ orientation: ltr, default-position: "legend.north"))
+
+ plot.plot(size: (4, 2),
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add(samples: 10, ..dom, fn, mark: "o", label: $ f(x) $)
+ plot.add(samples: 10, ..dom, fn.with(offset: .1), mark: "x", fill: true, label: $ f_2(x) $)
+ plot.add(samples: 10, ..dom, fn.with(offset: .2), mark: "|", style: (stroke: none), label: $ f_3(x) $)
+ })
+})
+
+#test-case({
+ import draw: *
+
+ set-style(legend: (item: (preview: (width: .4, height: 1), spacing: 1),
+ padding: .1,
+ stroke: black,
+ fill: white,
+ orientation: ltr, default-position: "legend.north"))
+
+ plot.plot(size: (4, 2),
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add(samples: 10, ..dom, fn, mark: "o", label: $ f(x) $)
+ plot.add(samples: 10, ..dom, fn.with(offset: .1), mark: "x", fill: true, label: $ f_2(x) $)
+ plot.add(samples: 10, ..dom, fn.with(offset: .2), mark: "|", style: (stroke: none), label: $ f_3(x) $)
+ })
+})
+
+#test-case({
+ plot.plot(size: (4, 2),
+ axis-style: "school-book",
+ legend-style: (offset: (-2.5, 1),
+ item: (preview: (margin: .5), spacing: .15),
+ fill: white,
+ stroke: (paint: black, dash: "dotted"),
+ padding: (.1, .5)),
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add(samples: 10, ..dom, fn, mark: "o", label: $ f(x) $)
+ plot.add(samples: 10, ..dom, fn.with(offset: .1), mark: "x", fill: true, label: $ f_2(x) $)
+ plot.add(samples: 10, ..dom, fn.with(offset: .2), mark: "|", style: (stroke: none), label: $ f_3(x) $)
+ })
+})
diff --git a/tests/plot/line/between/ref/1.png b/tests/plot/line/between/ref/1.png
new file mode 100644
index 0000000..75e8230
Binary files /dev/null and b/tests/plot/line/between/ref/1.png differ
diff --git a/tests/plot/line/between/test.typ b/tests/plot/line/between/test.typ
new file mode 100644
index 0000000..4f7839a
--- /dev/null
+++ b/tests/plot/line/between/test.typ
@@ -0,0 +1,107 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+#let size = (6, 4)
+#let f(x, y: 0) = y + calc.sin(x * 1deg)
+
+/* Fill between */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add-fill-between(domain: (-360, 360), f.with(y: -1), f.with(y: 1))
+ })
+})
+
+/* Fill between - Clip Top */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-max: .5,
+ {
+ plot.add-fill-between(domain: (-360, 360), f.with(y: -1), f.with(y: 1))
+ })
+})
+
+/* Fill between - Clip Bottom */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-min: -.5,
+ {
+ plot.add-fill-between(domain: (-360, 360), f.with(y: -1), f.with(y: 1))
+ })
+})
+
+/* Fill between - Clip Top & Bottom */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-max: .5,
+ y-min: -.5,
+ {
+ plot.add-fill-between(domain: (-360, 360), f.with(y: -1), f.with(y: 1))
+ })
+})
+
+/* Fill between - Test 2 */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add-fill-between(domain: (0, 2 * calc.pi),
+ t => (calc.cos(t) * 1.5, calc.sin(t)),
+ t => (calc.cos(t), calc.sin(t) * 1.5))
+ })
+})
+
+/* Fill between - Test 3 */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add-fill-between(domain: (0, 2 * calc.pi),
+ t => (calc.cos(t) * 1.5, calc.sin(t) * 1.5),
+ t => (calc.cos(t), calc.sin(t)))
+ })
+})
+
+/* Fill between - Test 4 */
+#test-case({
+ import draw: *
+
+ let f(x) = calc.sin(x) + calc.cos(3 * x)
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ // Function
+ plot.add(domain: (0, 4 * calc.pi), f)
+ // Error-Band fill
+ plot.add-fill-between(domain: (0, 4 * calc.pi),
+ style: (stroke: none),
+ x => f(x) - calc.exp(x/4) / 2,
+ x => f(x) + calc.exp(x/4) / 2)
+ })
+})
diff --git a/tests/plot/line/fill/ref/1.png b/tests/plot/line/fill/ref/1.png
new file mode 100644
index 0000000..99f4d84
Binary files /dev/null and b/tests/plot/line/fill/ref/1.png differ
diff --git a/tests/plot/line/fill/test.typ b/tests/plot/line/fill/test.typ
new file mode 100644
index 0000000..1adad42
--- /dev/null
+++ b/tests/plot/line/fill/test.typ
@@ -0,0 +1,178 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+#let size = (6, 4)
+#let f(x, y: 0) = y + calc.sin(x * 1deg)
+
+/* Epigraph/Hypograph */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add(domain: (-360, 360), epigraph: true, f)
+ plot.add(domain: (-360, 360), hypograph: true, f)
+ })
+})
+
+/* Upper Half */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-min: 0,
+ {
+ plot.add(domain: (-360, 360), epigraph: true, f)
+ plot.add(domain: (-360, 360), hypograph: true, f)
+ })
+})
+
+/* Lower Half */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-max: 0,
+ {
+ plot.add(domain: (-360, 360), epigraph: true, f)
+ plot.add(domain: (-360, 360), hypograph: true, f)
+ })
+})
+
+/* To Y=0 Clipped on Y<1 */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-min: -1, y-max: 1,
+ {
+ plot.add(domain: (-360, 360), fill: true, f.with(y: -.5))
+ })
+})
+
+/* To Y=0 */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-min: -1, y-max: 1,
+ {
+ plot.add(domain: (-360, 360), fill: true, f)
+ })
+})
+
+/* To Y=0 Clipped on Y>1 */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-min: -1, y-max: 1,
+ {
+ plot.add(domain: (-360, 360), fill: true, f.with(y: +.5))
+ })
+})
+
+/* To Y=0 Offset +1.5 */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-min: 0, y-max: 1,
+ {
+ plot.add(domain: (-360, 360), fill: true, f.with(y: +1.5))
+ })
+})
+
+/* To Y=0 Offset -1.5 */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-min: -1, y-max: 0,
+ {
+ plot.add(domain: (-360, 360), fill: true, f.with(y: -1.5))
+ })
+})
+
+/* To Y=0 Out of range */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-min: 1, y-max: 2,
+ {
+ plot.add(domain: (-360, 360), fill: true, f)
+ })
+})
+
+/* Epigraph Full Fill */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-min: 1, y-max: 2,
+ {
+ plot.add(domain: (-360, 360), epigraph: true, f)
+ })
+})
+
+/* Hypograph Full Fill */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-min: -2, y-max: -1,
+ {
+ plot.add(domain: (-360, 360), hypograph: true, f)
+ })
+})
+
+/* Epigraph No Fill */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-min: -2, y-max: -1,
+ {
+ plot.add(domain: (-360, 360), epigraph: true, f)
+ })
+})
+
+/* Hypograph No Fill */
+#test-case({
+ import draw: *
+
+ plot.plot(size: size,
+ x-tick-step: none,
+ y-tick-step: none,
+ y-min: 1, y-max: 2,
+ {
+ plot.add(domain: (-360, 360), hypograph: true, f)
+ })
+})
diff --git a/tests/plot/line/line-type/ref/1.png b/tests/plot/line/line-type/ref/1.png
new file mode 100644
index 0000000..e5ab591
Binary files /dev/null and b/tests/plot/line/line-type/ref/1.png differ
diff --git a/tests/plot/line/line-type/test.typ b/tests/plot/line/line-type/test.typ
new file mode 100644
index 0000000..e6a57a4
--- /dev/null
+++ b/tests/plot/line/line-type/test.typ
@@ -0,0 +1,25 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+/* Draw different line types */
+#test-case({
+ import draw: *
+
+ let data(i) = ((1, 2, 3, 4, 5).zip((1, 3, 2, 3, 1).map(v => v + i)))
+ plot.plot(size: (6, 6),
+ y-min: 0, y-max: 35,
+ x-tick-step: 1,
+ y-tick-step: 5,
+ {
+ plot.add(data(0), line: "linear", mark: "o")
+ plot.add(data(5), line: "spline", mark: "o")
+ plot.add(data(10), line: "hv", mark: "o")
+ plot.add(data(15), line: "vh", mark: "o")
+ plot.add(data(20), line: "hvh", mark: "o")
+ plot.add(data(25), line: (type: "hvh", mid: .25), mark: "o")
+ plot.add(data(30), line: (type: "hvh", mid: .75), mark: "o")
+ })
+})
+
diff --git a/tests/plot/line/linearization/ref.png b/tests/plot/line/linearization/ref.png
new file mode 100644
index 0000000..c335f0a
Binary files /dev/null and b/tests/plot/line/linearization/ref.png differ
diff --git a/tests/plot/line/linearization/ref/1.png b/tests/plot/line/linearization/ref/1.png
new file mode 100644
index 0000000..2c589d2
Binary files /dev/null and b/tests/plot/line/linearization/ref/1.png differ
diff --git a/tests/plot/line/linearization/test.typ b/tests/plot/line/linearization/test.typ
new file mode 100644
index 0000000..c8bdabb
--- /dev/null
+++ b/tests/plot/line/linearization/test.typ
@@ -0,0 +1,28 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+/* Test linearization */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (6, 4),
+ {
+ plot.add(domain: (0, 360), x=>calc.sin(x * 1deg),
+ line: "raw", style: (stroke: 3pt))
+ plot.add(domain: (0, 360), x=>calc.sin(x * 1deg),
+ line: "linear")
+ })
+})
+
+/* Test linearization for vertical and horizontal lines */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (6, 4),
+ x-min: -1, x-max: 2, y-min: -1, y-max: 2,
+ {
+ plot.add(((0,0), (1,0), (1,0.1), (1,0.2), (1,0.5), (1,1), (0,1), (0,0)))
+ })
+})
diff --git a/tests/plot/line/mark/ref/1.png b/tests/plot/line/mark/ref/1.png
new file mode 100644
index 0000000..edf7a59
Binary files /dev/null and b/tests/plot/line/mark/ref/1.png differ
diff --git a/tests/plot/line/mark/test.typ b/tests/plot/line/mark/test.typ
new file mode 100644
index 0000000..2649e62
--- /dev/null
+++ b/tests/plot/line/mark/test.typ
@@ -0,0 +1,28 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+/* Draw different marks */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (5, 4),
+ axis-style: "scientific",
+ y-max: 2,
+ y-min: -2,
+ x-tick-step: 360,
+ y-tick-step: 1,
+ style: plot.palette.red,
+ mark-style: plot.palette.red,
+ {
+ for (i, m) in ("o", "square", "x", "triangle", "|", "-").enumerate() {
+ plot.add(domain: (i * 180, (i + 1) * 180),
+ samples: 12,
+ style: (stroke: none),
+ mark: m,
+ mark-size: .3,
+ x => calc.sin(x * 1deg))
+ }
+ })
+})
diff --git a/tests/plot/line/spline/ref.png b/tests/plot/line/spline/ref.png
new file mode 100644
index 0000000..939848e
Binary files /dev/null and b/tests/plot/line/spline/ref.png differ
diff --git a/tests/plot/line/spline/ref/1.png b/tests/plot/line/spline/ref/1.png
new file mode 100644
index 0000000..8533263
Binary files /dev/null and b/tests/plot/line/spline/ref/1.png differ
diff --git a/tests/plot/line/spline/test.typ b/tests/plot/line/spline/test.typ
new file mode 100644
index 0000000..fe6634d
--- /dev/null
+++ b/tests/plot/line/spline/test.typ
@@ -0,0 +1,16 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+/* Draw smoothed data by using spline interpolation */
+#test-case({
+ plot.plot(size: (6, 4),
+ {
+ plot.add(((0,0), (1,1), (2,-1), (3,3)), line: (type: "spline", tension: .40,
+ samples: 5))
+ plot.add(((0,0), (1,1), (2,-1), (3,3)), line: (type: "spline", tension: .47))
+ plot.add(((0,0), (1,1), (2,-1), (3,3)), line: "spline")
+ plot.add(((0,0), (1,1), (2,-1), (3,3)), line: (type: "spline", tension: .5))
+ })
+})
diff --git a/tests/plot/mirror-axes/ref/1.png b/tests/plot/mirror-axes/ref/1.png
new file mode 100644
index 0000000..2b86b6e
Binary files /dev/null and b/tests/plot/mirror-axes/ref/1.png differ
diff --git a/tests/plot/mirror-axes/test.typ b/tests/plot/mirror-axes/test.typ
new file mode 100644
index 0000000..c7d9dc8
--- /dev/null
+++ b/tests/plot/mirror-axes/test.typ
@@ -0,0 +1,12 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/tests/helper.typ": *
+
+#test-case({
+ // Force showing tick labels for mirrored axes
+ cetz.draw.set-style(axes: (tick: (label: ("show": true))))
+
+ cetz.plot.plot(size: (8,8), {
+ cetz.plot.add(domain: (0, 1), x => x)
+ })
+})
diff --git a/tests/plot/parametric/ref/1.png b/tests/plot/parametric/ref/1.png
new file mode 100644
index 0000000..b1924fd
Binary files /dev/null and b/tests/plot/parametric/ref/1.png differ
diff --git a/tests/plot/parametric/test.typ b/tests/plot/parametric/test.typ
new file mode 100644
index 0000000..3e58377
--- /dev/null
+++ b/tests/plot/parametric/test.typ
@@ -0,0 +1,94 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+/* Simple plot */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (4, 4),
+ x-tick-step: 1,
+ y-tick-step: 1,
+ {
+ plot.add((t) => (calc.cos(t * 1rad), calc.sin(t * 1rad)),
+ domain: (0, 2 * calc.pi))
+ })
+})
+
+/* Test clipping */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (4, 4),
+ x-min: -1, x-max: 1,
+ y-min: -1, y-max: 1,
+ x-tick-step: 1,
+ y-tick-step: 1,
+ {
+ plot.add((t) => (calc.cos(t * 1rad) + .5, calc.sin(t * 1rad)),
+ domain: (0, 2 * calc.pi))
+ plot.add((t) => (calc.cos(t * 1rad) - .5, calc.sin(t * 1rad)),
+ domain: (0, 2 * calc.pi))
+ plot.add((t) => (calc.cos(t * 1rad), calc.sin(t * 1rad) + .5),
+ domain: (0, 2 * calc.pi))
+ plot.add((t) => (calc.cos(t * 1rad), calc.sin(t * 1rad) - .5),
+ domain: (0, 2 * calc.pi))
+ })
+})
+
+/* Test filling */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (4, 4),
+ x-tick-step: 1,
+ y-tick-step: 1,
+ {
+ plot.add((t) => (calc.cos(t * 1rad), calc.sin(t * 1rad)),
+ domain: (0, 2 * calc.pi),
+ fill: true)
+ })
+})
+
+/* Test clipping + filling */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (4, 4),
+ x-min: -1, x-max: 1,
+ y-min: -1, y-max: 1,
+ x-tick-step: 1,
+ y-tick-step: 1,
+ {
+ plot.add((t) => (calc.cos(t * 1rad) + .5, calc.sin(t * 1rad)),
+ domain: (0, 2 * calc.pi), fill: true, fill-type: "shape")
+ plot.add((t) => (calc.cos(t * 1rad) - .5, calc.sin(t * 1rad)),
+ domain: (0, 2 * calc.pi), fill: true, fill-type: "shape")
+ plot.add((t) => (calc.cos(t * 1rad), calc.sin(t * 1rad) + .5),
+ domain: (0, 2 * calc.pi), fill: true, fill-type: "shape")
+ plot.add((t) => (calc.cos(t * 1rad), calc.sin(t * 1rad) - .5),
+ domain: (0, 2 * calc.pi), fill: true, fill-type: "shape")
+ })
+})
+
+/* Test clipping + filling */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (4, 4),
+ x-tick-step: 1,
+ y-tick-step: 1,
+ y-max: .5, y-min: -.5,
+ x-max: 1, x-min: -1,
+ {
+ let f(t, off: 0) = {(calc.cos(t) / (calc.pow(calc.sin(t), 2) + 1) + off,
+ calc.cos(t) * calc.sin(t) / (calc.pow(calc.sin(t), 2) + 1) + off)}
+ plot.add(samples: 50,
+ domain: (0, 2 * calc.pi), f, fill:true, fill-type: "shape")
+ plot.add(samples: 50,
+ domain: (0, 2 * calc.pi), f.with(off: .4), fill:true, fill-type: "shape")
+ plot.add(samples: 50,
+ domain: (0, 2 * calc.pi), f.with(off: -.4), fill:true, fill-type: "shape")
+ })
+})
diff --git a/tests/plot/ref.png b/tests/plot/ref.png
new file mode 100644
index 0000000..d52620e
Binary files /dev/null and b/tests/plot/ref.png differ
diff --git a/tests/plot/ref/1.png b/tests/plot/ref/1.png
new file mode 100644
index 0000000..e3e32d9
Binary files /dev/null and b/tests/plot/ref/1.png differ
diff --git a/tests/plot/reverse-axis/ref.png b/tests/plot/reverse-axis/ref.png
new file mode 100644
index 0000000..0c5896d
Binary files /dev/null and b/tests/plot/reverse-axis/ref.png differ
diff --git a/tests/plot/reverse-axis/ref/1.png b/tests/plot/reverse-axis/ref/1.png
new file mode 100644
index 0000000..a2d87cc
Binary files /dev/null and b/tests/plot/reverse-axis/ref/1.png differ
diff --git a/tests/plot/reverse-axis/test.typ b/tests/plot/reverse-axis/test.typ
new file mode 100644
index 0000000..726deff
--- /dev/null
+++ b/tests/plot/reverse-axis/test.typ
@@ -0,0 +1,18 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+#test-case({
+ plot.plot(size: (10, 10), x-min: 9, x-max: 0,
+ {
+ plot.add(domain: (0, 9), calc.sqrt)
+ })
+})
+
+#test-case({
+ plot.plot(size: (10, 10), y-min: 9, y-max: 0,
+ {
+ plot.add(domain: (-5, 5), x => calc.pow(x, 2))
+ })
+})
diff --git a/tests/plot/sample/sample.typ b/tests/plot/sample/sample.typ
new file mode 100644
index 0000000..54d617e
--- /dev/null
+++ b/tests/plot/sample/sample.typ
@@ -0,0 +1,37 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+
+#let cases = (
+ (samples: 2, res: ((0,0), (100,10))),
+ (samples: 5, res: ((0,0), (25,2.5), (50,5.0), (75,7.5), (100,10.0))),
+ (samples: 2, res: ((0,0), (50,5.0), (60,6.0), (100,10)), extra: (50,60)),
+)
+#for c in cases {
+ let pts = plot.sample-fn(x => x/10, (0, 100), c.samples,
+ sample-at: c.at("extra", default: ()))
+ assert.eq(pts, c.res,
+ message: "Expected: " + repr(c.res) + ", got: " + repr(pts))
+}
+
+#let cases = (
+ (samples: (2,2), res: (( 0,100),
+ (100,200))),
+ (samples: (3,3), res: (( 0, 50,100),
+ ( 50,100,150),
+ (100,150,200))),
+)
+#for c in cases {
+ let rows = plot.sample-fn2((x, y) => x + y, (0, 100), (0,100),
+ c.samples.at(0), c.samples.at(1))
+ assert.eq(rows, c.res,
+ message: "Expected: " + repr(c.res) + ", got: " + repr(rows))
+}
+
+#box(stroke: 2pt + red, canvas({
+ import draw: *
+
+ plot.plot(size: (3, 1), axis-style: none, {
+ plot.add(domain: (0, 100), x => 0, mark: "x", samples: 2)
+ plot.add(domain: (0, 100), x => 1, mark: "x", samples: 5)
+ })
+}))
diff --git a/tests/plot/test.typ b/tests/plot/test.typ
new file mode 100644
index 0000000..9ea7b68
--- /dev/null
+++ b/tests/plot/test.typ
@@ -0,0 +1,288 @@
+#set page(width: auto, height: auto)
+#import "/src/cetz.typ": *
+#import "/src/lib.typ": *
+#import "/tests/helper.typ": *
+
+#let line-data = ((-1,-1), (1,1),)
+
+#let data = (..(for x in range(-360, 360 + 1) {
+ ((x, calc.sin(x * 1deg)),)
+}))
+
+/* Scientific Style */
+#test-case({
+ plot.plot(size: (5, 2),
+ x-tick-step: 180,
+ y-tick-step: 1,
+ x-grid: "major",
+ y-grid: "major",
+ {
+ plot.add(data)
+ })
+})
+
+/* 4-Axes */
+#test-case({
+ plot.plot(size: (5, 3),
+ x-tick-step: 180,
+ x-min: -360,
+ x-max: 360,
+ y-tick-step: 1,
+ x2-label: none,
+ x2-min: -90,
+ x2-max: 90,
+ x2-tick-step: 45,
+ x2-minor-tick-step: 15,
+ y2-label: none,
+ y2-min: -1.5,
+ y2-max: 1.5,
+ y2-tick-step: .5,
+ y2-minor-tick-step: .1,
+ {
+ plot.add(data)
+ plot.add(data, style: (stroke: blue), axes: ("x2", "y2"))
+ })
+})
+
+/* School-Book Style */
+#test-case({
+ plot.plot(size: (5, 4),
+ axis-style: "school-book",
+ x-tick-step: 180,
+ y-tick-step: 1,
+ {
+ plot.add(data)
+ })
+})
+
+/* Clipping */
+#test-case({
+ plot.plot(size: (5, 4),
+ axis-style: "school-book",
+ x-min: auto,
+ x-max: 350,
+ x-tick-step: 180,
+ y-min: -.5,
+ y-max: .5,
+ y-tick-step: 1,
+ {
+ plot.add(data)
+ })
+})
+
+/* Palettes */
+#test-case({
+ plot.plot(size: (5, 4),
+ x-label: [Rainbow],
+ x-tick-step: none,
+ axis-style: "scientific",
+ y-label: [Color],
+ y-max: 8,
+ y-tick-step: none,
+ {
+ for i in range(0, 7) {
+ plot.add(domain: (i * 180, (i + 1) * 180),
+ epigraph: true,
+ style: plot.palette.rainbow,
+ x => calc.sin(x * 1deg))
+ }
+ })
+})
+
+/* Tick Step Calculation */
+#test-case({
+ plot.plot(size: (12, 4),
+ y2-decimals: 4,
+ {
+ plot.add(((0,0), (1,10)), axes: ("x", "y"))
+ plot.add(((0,0), (.1,.01)), axes: ("x2", "y2"))
+ })
+})
+
+#test-case({
+ plot.plot(size: (12, 4),
+ y2-decimals: 9,
+ x2-decimals: 9,
+ y2-format: "sci",
+ {
+ plot.add(((0,0), (30,2500)), axes: ("x", "y"))
+ plot.add(((0,0), (.001,.0001)), axes: ("x2", "y2"))
+ })
+})
+
+/* Axis Styles */
+
+
+#test-case(args => {
+ plot.plot(size: (4,4), x-tick-step: 90, y-tick-step: 1,
+ axis-style: args, {
+ plot.add(domain: (0, 360), x => calc.sin(x * 1deg))
+ })
+}, args: (
+ "scientific", "scientific-auto", "left", "school-book", none
+))
+
+/* Manual Axis Bounds */
+#let circle-data = range(0, 361).map(
+ t => (.5 * calc.cos(t*1deg), .5 * calc.sin(t*1deg)))
+#test-case({
+ plot.plot(size: (4, 4),
+ x-tick-step: 1,
+ y-tick-step: 1,
+ x-min: -1, x-max: 1,
+ y-min: -1, y-max: 1,
+ xl-min: -1.5, xl-max: .5,
+ xr-min: -.5, xr-max: 1.5,
+ yb-min: -1.5, yb-max: .5,
+ yt-min: -.5, yt-max: 1.5,
+ {
+ plot.add(circle-data)
+ plot.add(circle-data, axes: ("xl", "y"), style: (stroke: green))
+ plot.add(circle-data, axes: ("xr", "y"), style: (stroke: red))
+ plot.add(circle-data, axes: ("x", "yt"), style: (stroke: blue))
+ plot.add(circle-data, axes: ("x", "yb"), style: (stroke: yellow))
+ })
+})
+
+#test-case({
+ plot.plot(size: (4, 4),
+ x-tick-step: 1,
+ y-tick-step: 1,
+ x-min: -1, x-max: 1,
+ y-min: -1, y-max: 1,
+ xl-min: -1.75, xl-max: .25,
+ xr-min: -.25, xr-max: 1.75,
+ yb-min: -1.75, yb-max: .25,
+ yt-min: -.25, yt-max: 1.75,
+ {
+ plot.add(circle-data)
+ plot.add(circle-data, axes: ("xl", "y"), style: (stroke: green))
+ plot.add(circle-data, axes: ("xr", "y"), style: (stroke: red))
+ plot.add(circle-data, axes: ("x", "yt"), style: (stroke: blue))
+ plot.add(circle-data, axes: ("x", "yb"), style: (stroke: yellow))
+ })
+}),))
+
+/* Anchors */
+#test-case({
+ import draw: *
+
+ plot.plot(size: (5, 3), name: "plot",
+ x-tick-step: 180,
+ y-tick-step: 1,
+ x-grid: "major",
+ y-grid: "major",
+ {
+ plot.add(data, fill: true)
+ plot.add-anchor("from", (-270, "max"))
+ plot.add-anchor("to", (90, "max"))
+ plot.add-anchor("lo", (90, 0))
+ plot.add-anchor("hi", (90, "max"))
+ })
+
+ line((rel: (0, .2), to: "plot.from"),
+ (rel: (0, .2), to: "plot.to"),
+ mark: (start: "|", end: "|"), name: "annotation")
+ content((rel: (0, .1), to: ("annotation.start", 50%, "annotation.end")), $2 pi$, anchor: "south")
+
+ line((rel: (0, .2), to: "plot.lo"),
+ (rel: (0, -.2), to: "plot.hi"),
+ mark: (start: ">", end: ">"), name: "amplitude")
+})
+
+/* Custom sample points */
+#test-case({
+ plot.plot(size: (6, 4), y-min: -2, y-max: 2,
+ samples: 10,
+ {
+ plot.add(samples: 2, sample-at: (.99, 1.001, 1.99, 2.001, 2.99), domain: (0, 3),
+ x => calc.pow(-1, int(x)))
+ })
+})
+
+/* Format tick values */
+#test-case({
+ plot.plot(size: (6, 4),
+ x-tick-step: none,
+ x-ticks: (-1, 0, 1),
+ x-format: x => $x_(#x)$,
+ y-tick-step: none,
+ y-ticks: (-1, 0, 1),
+ y-format: x => $y_(#x)$,
+ x2-tick-step: none,
+ x2-ticks: (-1, 0, 1),
+ x2-format: x => $x_(2,#x)$,
+ y2-tick-step: none,
+ y2-ticks: (-1, 0, 1),
+ y2-format: x => $y_(2,#x)$,
+ {
+ plot.add(samples: 2, domain: (-1, 1), x => -x, axes: ("x", "y"))
+ plot.add(samples: 2, domain: (-1, 1), x => x, axes: ("x2", "y2"))
+ })
+})
+
+// Test plot with anchors only
+#test-case({
+ import draw: *
+
+ plot.plot(size: (6, 4), name: "plot",
+ x-min: -1, x-max: 1, y-min: -1, y-max: 1,
+ {
+ plot.add-anchor("test", (0,0))
+ })
+
+ circle("plot.test", radius: 1)
+})
+
+// Test empty plot
+#test-case({
+ plot.plot(size: (1, 1), {})
+})
+
+// Some axis styling
+#test-case({
+ import draw: *
+
+ set-style(axes: (
+ padding: .1,
+ tick: (
+ length: -.1,
+ ),
+ left: (
+ stroke: (paint: red),
+ tick: (
+ stroke: auto,
+ )
+ ),
+ bottom: (
+ stroke: (paint: blue, thickness: 2pt),
+ tick: (
+ stroke: auto,
+ )
+ ),
+ ))
+
+ plot.plot(size: (6, 4), axis-style: "scientific-auto", {
+ plot.add(line-data)
+ })
+
+ set-origin((7, 0))
+
+ set-style(axes: (
+ overshoot: .5,
+ x: (
+ padding: 1,
+ overshoot: -.5,
+ stroke: blue,
+ ),
+ y: (
+ stroke: red,
+ )
+ ))
+ plot.plot(size: (6, 4), axis-style: "school-book",
+ x-tick-step: none,
+ y-tick-step: none,
+ {
+ plot.add(line-data)
+ })
+})
diff --git a/tests/plot/vertical/ref/1.png b/tests/plot/vertical/ref/1.png
new file mode 100644
index 0000000..6cbfb6f
Binary files /dev/null and b/tests/plot/vertical/ref/1.png differ
diff --git a/tests/plot/vertical/test.typ b/tests/plot/vertical/test.typ
new file mode 100644
index 0000000..98d9420
--- /dev/null
+++ b/tests/plot/vertical/test.typ
@@ -0,0 +1,51 @@
+#set page(width: auto, height: auto)
+#import "/src/lib.typ": *
+#import "/src/cetz.typ": *
+#import "/tests/helper.typ": *
+
+#test-case({
+ import draw: *
+
+ plot.plot(size: (10, 10),
+ {
+ plot.add(domain: (0, 4*calc.pi), calc.sin, axes: ("y", "x"))
+ })
+})
+
+#test-case({
+ import draw: *
+
+ plot.plot(size: (10, 10),
+ {
+ plot.add-contour(x-domain: (0, 4), y-domain: (-2, 2),
+ (x, y) => x - .5 * y, op: ">=", z: 2, axes: ("y", "x"), fill: true)
+ })
+})
+
+#test-case({
+ import draw: *
+
+ let box1 = (
+ outliers: (7, 65, 69),
+ min: 15,
+ q1: 25,
+ q2: 35,
+ q3: 50,
+ max: 60)
+
+ plot.plot(size: (10, 10),
+ {
+ plot.add-boxwhisker((x: 1, ..box1), axes: ("y", "x"))
+ })
+})
+
+#test-case({
+ import draw: *
+
+ plot.plot(size: (10, 10), y-label: $ x $,
+ x-label: $ y $,
+ x-min: -.75, x-max: .75,
+ {
+ plot.add(domain: (0, 4*calc.pi), calc.sin, axes: ("y", "x"))
+ })
+})
diff --git a/typst.toml b/typst.toml
new file mode 100644
index 0000000..38af3d7
--- /dev/null
+++ b/typst.toml
@@ -0,0 +1,15 @@
+[package]
+name = "cetz-plot"
+version = "0.1.0"
+compiler = "0.11.0"
+repository = "https://github.com/cetz-package/cetz-plot"
+entrypoint = "src/lib.typ"
+authors = [
+ "Johannes Wolf ",
+ "fenjalien "
+]
+categories = [ "visualization" ]
+license = "GPL-3.0-or-later"
+description = "Plotting module for CeTZ."
+keywords = [ "plot", "chart" ]
+exclude = [ "/gallery/*", "manual.pdf", "manual.typ" ]