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 + + + + + + + + + + + +
+ + + + + + + + + + + +
PlotPie ChartClustered 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" ]