├── manual.pdf ├── sample.png ├── devenv.yaml ├── devenv.nix ├── .gitignore ├── .envrc ├── typst.toml ├── CHANGELOG.md ├── LICENSE ├── sample.typ ├── README.md ├── devenv.lock ├── manual.typ └── timeliney.typ /manual.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pta2002/typst-timeliney/HEAD/manual.pdf -------------------------------------------------------------------------------- /sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pta2002/typst-timeliney/HEAD/sample.png -------------------------------------------------------------------------------- /devenv.yaml: -------------------------------------------------------------------------------- 1 | inputs: 2 | nixpkgs: 3 | url: github:NixOS/nixpkgs/nixpkgs-unstable 4 | -------------------------------------------------------------------------------- /devenv.nix: -------------------------------------------------------------------------------- 1 | { pkgs, ... }: 2 | 3 | { 4 | packages = with pkgs; [ typst typst-lsp ]; 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Devenv 3 | .devenv* 4 | devenv.local.nix 5 | 6 | # direnv 7 | .direnv 8 | 9 | # pre-commit 10 | .pre-commit-config.yaml 11 | 12 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | source_url "https://raw.githubusercontent.com/cachix/devenv/d1f7b48e35e6dee421cfd0f51481d17f77586997/direnvrc" "sha256-YBzqskFZxmNb3kYVoKD9ZixoPXJh1C9ZvTLGFRkauZ0=" 2 | 3 | use devenv -------------------------------------------------------------------------------- /typst.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "timeliney" 3 | version = "0.4.0" 4 | entrypoint = "timeliney.typ" 5 | authors = ["Pedro Alves"] 6 | license = "MIT" 7 | description = "Create Gantt charts in Typst." 8 | repository = "https://github.com/pta2002/typst-timeliney" 9 | exclude = ["manual.pdf", "sample.png"] 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.4.0 4 | - feat: make cell line style configurable (@jghauser) 5 | - Update cetz version to 0.4.1 (@F2011) 6 | - Allow content on top of a task (@jipolanco) 7 | 8 | ## 0.3.0 9 | - Add optional parameters for milestones and taskgroups (@abhi18av) 10 | - misc: move changelog to CHANGELOG.md (Pedro Alves) 11 | 12 | 13 | ## 0.2.1 14 | - Update CeTZ to 0.3.2 (@JKRhb) 15 | 16 | ## 0.2.0 17 | - Update CeTZ to 0.3.1 (@Bahex) 18 | - Fix deprecation warnings (@Bahex) 19 | - Fix header height calculation (@tonyddg) 20 | 21 | ## 0.1.0 22 | - Update CeTZ to 0.2.2 (@LordBaryhobal) 23 | - Add offset parameter 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Pedro Alves 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /sample.typ: -------------------------------------------------------------------------------- 1 | #set page(width: 20cm, height: auto) 2 | 3 | #import "timeliney.typ" 4 | 5 | #timeliney.timeline( 6 | show-grid: true, 7 | { 8 | import timeliney: * 9 | 10 | headerline(group(([*2023*], 4)), group(([*2024*], 4))) 11 | headerline( 12 | group(..range(4).map(n => strong("Q" + str(n + 1)))), 13 | group(..range(4).map(n => strong("Q" + str(n + 1)))), 14 | ) 15 | 16 | taskgroup( 17 | title: [*Research*], 18 | content: text(10pt, white)[*John + Julia*], 19 | style: (stroke: 14pt + black), 20 | { 21 | task( 22 | "Research the market", 23 | (from: 0, to: 2, content: text(9pt)[John (70% done)]), 24 | style: (stroke: 13pt + gray), 25 | ) 26 | task( 27 | "Conduct user surveys", 28 | (from: 1, to: 3, content: text(9pt)[Julia (50% done)]), 29 | style: (stroke: 13pt + gray), 30 | ) 31 | }, 32 | ) 33 | 34 | taskgroup(title: [*Development*], { 35 | task("Create mock-ups", (2, 3), style: (stroke: 2pt + gray)) 36 | task("Develop application", (3, 5), style: (stroke: 2pt + gray)) 37 | task("QA", (3.5, 6), style: (stroke: 2pt + gray)) 38 | }) 39 | 40 | taskgroup(title: [*Marketing*], { 41 | task("Press demos", (3.5, 7), style: (stroke: 2pt + gray)) 42 | task("Social media advertising", (6, 7.5), style: (stroke: 2pt + gray)) 43 | }) 44 | 45 | milestone( 46 | at: 3.75, 47 | style: (stroke: (dash: "dashed")), 48 | align(center, [ 49 | *Conference demo*\ 50 | Dec 2023 51 | ]) 52 | ) 53 | 54 | milestone( 55 | at: 6.5, 56 | style: (stroke: (dash: "dashed")), 57 | align(center, [ 58 | *App store launch*\ 59 | Aug 2024 60 | ]) 61 | ) 62 | } 63 | ) 64 | 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Timeliney 2 | 3 | Create Gantt charts automatically with Typst! 4 | 5 | Here's a fully-featured example: 6 | 7 | ```typst 8 | #import "@preview/timeliney:0.4.0" 9 | 10 | #timeliney.timeline( 11 | show-grid: true, 12 | { 13 | import timeliney: * 14 | 15 | headerline(group(([*2023*], 4)), group(([*2024*], 4))) 16 | headerline( 17 | group(..range(4).map(n => strong("Q" + str(n + 1)))), 18 | group(..range(4).map(n => strong("Q" + str(n + 1)))), 19 | ) 20 | 21 | taskgroup( 22 | title: [*Research*], 23 | content: text(10pt, white)[*John + Julia*], 24 | style: (stroke: 14pt + black), 25 | { 26 | task( 27 | "Research the market", 28 | (from: 0, to: 2, content: text(9pt)[John (70% done)]), 29 | style: (stroke: 13pt + gray), 30 | ) 31 | task( 32 | "Conduct user surveys", 33 | (from: 1, to: 3, content: text(9pt)[Julia (50% done)]), 34 | style: (stroke: 13pt + gray), 35 | ) 36 | }, 37 | ) 38 | 39 | taskgroup(title: [*Development*], { 40 | task("Create mock-ups", (2, 3), style: (stroke: 2pt + gray)) 41 | task("Develop application", (3, 5), style: (stroke: 2pt + gray)) 42 | task("QA", (3.5, 6), style: (stroke: 2pt + gray)) 43 | }) 44 | 45 | taskgroup(title: [*Marketing*], { 46 | task("Press demos", (3.5, 7), style: (stroke: 2pt + gray)) 47 | task("Social media advertising", (6, 7.5), style: (stroke: 2pt + gray)) 48 | }) 49 | 50 | milestone( 51 | at: 3.75, 52 | style: (stroke: (dash: "dashed")), 53 | align(center, [ 54 | *Conference demo*\ 55 | Dec 2023 56 | ]) 57 | ) 58 | 59 | milestone( 60 | at: 6.5, 61 | style: (stroke: (dash: "dashed")), 62 | align(center, [ 63 | *App store launch*\ 64 | Aug 2024 65 | ]) 66 | ) 67 | } 68 | ) 69 | ``` 70 | 71 | ![Example Gantt chart](sample.png) 72 | 73 | ## Installation 74 | Import with `#import "@preview/timeliney:0.4.0"`. Then, call the `timeliney.timeline` function. 75 | 76 | ## Documentation 77 | See [the manual](manual.pdf)! 78 | 79 | ## Changelog 80 | 81 | See [CHANGELOG.md](changelog.md). 82 | -------------------------------------------------------------------------------- /devenv.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "devenv": { 4 | "locked": { 5 | "dir": "src/modules", 6 | "lastModified": 1728740426, 7 | "owner": "cachix", 8 | "repo": "devenv", 9 | "rev": "ef61728d91ad5eb91f86cdbcc16070602e7afa16", 10 | "treeHash": "66eb225a49f33429870faaa04db8056b54a82eef", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "dir": "src/modules", 15 | "owner": "cachix", 16 | "repo": "devenv", 17 | "type": "github" 18 | } 19 | }, 20 | "flake-compat": { 21 | "flake": false, 22 | "locked": { 23 | "lastModified": 1696426674, 24 | "owner": "edolstra", 25 | "repo": "flake-compat", 26 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 27 | "treeHash": "2addb7b71a20a25ea74feeaf5c2f6a6b30898ecb", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "edolstra", 32 | "repo": "flake-compat", 33 | "type": "github" 34 | } 35 | }, 36 | "gitignore": { 37 | "inputs": { 38 | "nixpkgs": [ 39 | "pre-commit-hooks", 40 | "nixpkgs" 41 | ] 42 | }, 43 | "locked": { 44 | "lastModified": 1709087332, 45 | "owner": "hercules-ci", 46 | "repo": "gitignore.nix", 47 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 48 | "treeHash": "ca14199cabdfe1a06a7b1654c76ed49100a689f9", 49 | "type": "github" 50 | }, 51 | "original": { 52 | "owner": "hercules-ci", 53 | "repo": "gitignore.nix", 54 | "type": "github" 55 | } 56 | }, 57 | "nixpkgs": { 58 | "locked": { 59 | "lastModified": 1728538411, 60 | "owner": "NixOS", 61 | "repo": "nixpkgs", 62 | "rev": "b69de56fac8c2b6f8fd27f2eca01dcda8e0a4221", 63 | "treeHash": "e3de9aba3603bbbf9daa84e962d0a58fc4d85378", 64 | "type": "github" 65 | }, 66 | "original": { 67 | "owner": "NixOS", 68 | "ref": "nixpkgs-unstable", 69 | "repo": "nixpkgs", 70 | "type": "github" 71 | } 72 | }, 73 | "nixpkgs-stable": { 74 | "locked": { 75 | "lastModified": 1728740863, 76 | "owner": "NixOS", 77 | "repo": "nixpkgs", 78 | "rev": "a3f9ad65a0bf298ed5847629a57808b97e6e8077", 79 | "treeHash": "9c00ffb74cb76518fd4f5e7a5f44854df986dcc6", 80 | "type": "github" 81 | }, 82 | "original": { 83 | "owner": "NixOS", 84 | "ref": "nixos-24.05", 85 | "repo": "nixpkgs", 86 | "type": "github" 87 | } 88 | }, 89 | "pre-commit-hooks": { 90 | "inputs": { 91 | "flake-compat": "flake-compat", 92 | "gitignore": "gitignore", 93 | "nixpkgs": [ 94 | "nixpkgs" 95 | ], 96 | "nixpkgs-stable": "nixpkgs-stable" 97 | }, 98 | "locked": { 99 | "lastModified": 1728778939, 100 | "owner": "cachix", 101 | "repo": "pre-commit-hooks.nix", 102 | "rev": "ff68f91754be6f3427e4986d7949e6273659be1d", 103 | "treeHash": "da7337c00b7d66f96afd696429712cabecb18299", 104 | "type": "github" 105 | }, 106 | "original": { 107 | "owner": "cachix", 108 | "repo": "pre-commit-hooks.nix", 109 | "type": "github" 110 | } 111 | }, 112 | "root": { 113 | "inputs": { 114 | "devenv": "devenv", 115 | "nixpkgs": "nixpkgs", 116 | "pre-commit-hooks": "pre-commit-hooks" 117 | } 118 | } 119 | }, 120 | "root": "root", 121 | "version": 7 122 | } 123 | -------------------------------------------------------------------------------- /manual.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/mantys:0.1.4": * 2 | #import "timeliney.typ": * 3 | 4 | #show: mantys.with( 5 | ..toml("typst.toml").package, 6 | examples-scope: ( 7 | group: group, 8 | headerline: headerline, 9 | milestone: milestone, 10 | task: task, 11 | taskgroup: taskgroup, 12 | timeline: timeline, 13 | ) 14 | ) 15 | 16 | #command( 17 | "timeline", 18 | arg[body], 19 | arg(spacing: 5pt), 20 | arg(show-grid: false), 21 | arg(grid-style: (stroke: (dash: "dashed", thickness: .5pt, paint: gray))), 22 | arg(task-vline: true), 23 | arg(line-style: (stroke: 3pt)), 24 | arg(milestone-overhang: 5pt), 25 | arg(milestone-layout: "in-place"), 26 | arg(box-milestones: true), 27 | arg(milestone-line-style: ()), 28 | arg(cell-line-style: (stroke: 1pt + black)), 29 | arg(offset: 0), 30 | )[ 31 | #argument("spacing", types: ("length",), default: 5pt)[ 32 | Spacing between lines 33 | ] 34 | 35 | #argument("show-grid", types: ("boolean",), default: true)[ 36 | Show a grid behind the timeline 37 | ] 38 | 39 | #argument( 40 | "grid-style", 41 | types: ("dictionary",), 42 | default: (stroke: (dash: "dashed", thickness: .5pt, paint: gray)), 43 | )[ 44 | The style to use for the grid (has no effect if `show-grid` is false) 45 | ] 46 | 47 | #argument("task-vline", types: ("boolean",), default: true)[ 48 | Show a vertical line next to the task names 49 | ] 50 | 51 | #argument("line-style", types: ("dictionary",), default: (stroke: 3pt))[ 52 | The style to use for the lines in the timelines 53 | ] 54 | 55 | #argument( 56 | "milestone-overhang", 57 | types: ("length",), 58 | default: 5pt, 59 | )[ 60 | How far the milestone lines should extend past the end of the timeline (only has 61 | an effect if `milestone-layout` is `in-place`) 62 | ] 63 | 64 | #argument( 65 | "milestone-layout", 66 | types: ("string",), 67 | default: "in-place", 68 | )[ 69 | How to lay out the milestone lines. Can be `in-place` or `aligned`. 70 | 71 | `in-place` displays the milestones directly below the timeline, and tries to lay 72 | them out as well as possible to avoid colisions. 73 | 74 | `aligned` displays the milestones in a separate box, aligned with the task 75 | titles. 76 | ] 77 | 78 | #argument("box-milestones", types: ("boolean",), default: true)[ 79 | Whether to draw a box around the milestones (only has an effect if 80 | `milestone-layout` is `aligned`) 81 | ] 82 | 83 | #argument("milestone-line-style", types: ("dictionary",), default: ())[ 84 | The style to use for the milestone lines 85 | ] 86 | 87 | #argument("cell-line-style", types: ("dictionary",), default: (stroke: 1pt + black))[ 88 | The style to use for the cells' border lines 89 | ] 90 | 91 | #argument("offset", types: ("float",), default: 0)[ 92 | Offset to be automatically added to all the timespans 93 | ] 94 | 95 | #example[``` 96 | #timeline( 97 | show-grid: true, 98 | { 99 | headerline(group(([*2023*], 4)), group(([*2024*], 4))) 100 | headerline( 101 | group(..range(4).map(n => strong("Q" + str(n + 1)))), 102 | group(..range(4).map(n => strong("Q" + str(n + 1)))), 103 | ) 104 | 105 | taskgroup(title: [*Research*], { 106 | task("Research the market", (0, 2), style: (stroke: 2pt + gray)) 107 | task("Conduct user surveys", (1, 3), style: (stroke: 2pt + gray)) 108 | }) 109 | 110 | taskgroup(title: [*Development*], { 111 | task("Create mock-ups", (2, 3), style: (stroke: 2pt + gray)) 112 | task("Develop application", (3, 5), style: (stroke: 2pt + gray)) 113 | task("QA", (3.5, 6), style: (stroke: 2pt + gray)) 114 | }) 115 | 116 | taskgroup(title: [*Marketing*], { 117 | task("Press demos", (3.5, 7), style: (stroke: 2pt + gray)) 118 | task("Social media advertising", (6, 7.5), style: (stroke: 2pt + gray)) 119 | }) 120 | 121 | milestone( 122 | at: 3.75, 123 | style: (stroke: (dash: "dashed")), 124 | align(center, [ 125 | *Conference demo*\ 126 | Dec 2023 127 | ]) 128 | ) 129 | 130 | milestone( 131 | at: 6.5, 132 | style: (stroke: (dash: "dashed")), 133 | align(center, [ 134 | *App store launch*\ 135 | Aug 2024 136 | ]) 137 | ) 138 | } 139 | ) 140 | 141 | 142 | ```] 143 | ] 144 | 145 | #command("headerline", arg("..titles", is-sink: true))[ 146 | #argument("titles", types: ("array",), is-sink: true)[ 147 | The titles to display in the header line. 148 | 149 | Can be specified in several different formats: 150 | 151 | #example[``` 152 | // One column per title 153 | #headerline("Title 1", "Title 2", "Title 3") 154 | ```] 155 | 156 | #example[``` 157 | // Each title occupies 2 columns 158 | #headerline(("Title 1", 2), ("Title 2", 2)) 159 | ```] 160 | 161 | #example[``` 162 | // Two groups of titles 163 | #headerline( 164 | group("Q1", "Q2", "Q3", "Q4"), 165 | group("Q1", "Q2", "Q3", "Q4"), 166 | ) 167 | ```] 168 | 169 | #example[``` 170 | // Two lines of headers 171 | #headerline( 172 | group(("2023", 4), ("2024", 4)) 173 | ) 174 | #headerline( 175 | group("Q1", "Q2", "Q3", "Q4"), 176 | group("Q1", "Q2", "Q3", "Q4"), 177 | ) 178 | ```] 179 | ] 180 | ] 181 | 182 | #command("group", arg("..titles", is-sink: true))[ 183 | Defines a group of titles in a header line. 184 | 185 | Takes the same options as `#headerline`. 186 | ] 187 | 188 | #command( 189 | "task", 190 | arg([name]), 191 | arg([style], default: none), 192 | arg("..lines", is-sink: true), 193 | )[ 194 | Defines a task 195 | 196 | #argument("name", types: ("content",))[ 197 | The name of the task 198 | ] 199 | 200 | #argument( 201 | "style", 202 | types: ("dictionary",), 203 | default: none, 204 | )[ 205 | The style to use for the task line. If not specified, the default style will be 206 | used. 207 | ] 208 | 209 | #argument( 210 | "..lines", 211 | types: ("array",) 212 | )[ 213 | The lines to display in the task. Can be specified in several different formats: 214 | 215 | #example[``` 216 | // Spans 1 month, starting at the first month of the timeline 217 | #task("Task", (0, 1)) 218 | ```] 219 | 220 | #example[``` 221 | // One red line at month 1, and a line spanning 2 months starting at month 4 222 | #task("Task", (from: 0, to: 1, style: (stroke: red)), (3, 5)) 223 | ```] 224 | ] 225 | ] 226 | 227 | #command("taskgroup", arg("title", default: none), arg("tasks"))[ 228 | Groups tasks together in a box. If `title` is specified, a title will be 229 | displayed, with a line spanning all the inner tasks. 230 | 231 | #argument("title", types: ("content",), default: none)[ 232 | The title of the task group 233 | ] 234 | 235 | #argument("tasks", types: ("content",))[ 236 | The tasks to display in the group 237 | ] 238 | 239 | #example[``` 240 | #taskgroup(title: "Research", { 241 | task("Task 1", (0, 1)) 242 | task("Task 2", (3, 5)) 243 | }) 244 | ```] 245 | ] 246 | 247 | #command( 248 | "milestone", 249 | arg("body"), 250 | arg("at"), 251 | arg("style"), 252 | arg("overhang"), 253 | arg("spacing"), 254 | arg(anchor: "top"), 255 | )[ 256 | Defines a milestone. The way it's displayed depends on the `milestone-layout` 257 | option of the `#timeline` command. 258 | 259 | #argument( 260 | "at", 261 | types: ("float",), 262 | )[ 263 | The month at which the milestone should be displayed. Can be fractional. 264 | ] 265 | 266 | #argument("style", types: ("dictionary",), default: ())[ 267 | Style for the milestone line. Defaults to `milestone-line-style`. 268 | ] 269 | 270 | #argument( 271 | "overhang", 272 | types: ("length",), 273 | default: 5pt, 274 | )[ 275 | How far the milestone line should extend past the end of the timeline. Defaults 276 | to `milestone-overhang`. 277 | ] 278 | 279 | #argument("spacing", types: ("length",), default: 5pt)[ 280 | Spacing between the milestone line and the text. Defaults to `spacing`. 281 | ] 282 | 283 | #argument( 284 | "anchor", 285 | types: ("string",), 286 | default: "top", 287 | )[ 288 | The anchor point for the milestone text. Can be `top`, `bottom`, `left`, 289 | `right`, `top-left`, `top-right`, `bottom-left`, `bottom-right`, `center`, 290 | `center-left`, `center-right`, `center-top`, `center-bottom`. Defaults to `top`. 291 | ] 292 | 293 | #argument("body", types: ("content",))[ 294 | The text to display next to the milestone line 295 | ] 296 | ] 297 | -------------------------------------------------------------------------------- /timeliney.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/cetz:0.4.1": canvas, draw, coordinate, util 2 | 3 | #let timeline( 4 | body, 5 | spacing: 5pt, 6 | heading-spacing: 10pt, 7 | show-grid: false, 8 | grid-style: (stroke: (dash: "dashed", thickness: .5pt, paint: gray)), 9 | tasks-vline: true, 10 | line-style: (stroke: 3pt), 11 | milestone-overhang: 5pt, 12 | milestone-layout: "in-place", 13 | box-milestones: true, 14 | milestone-line-style: (), 15 | offset: 0, 16 | cell-line-style: (stroke: 1pt + black) 17 | ) = layout(size => canvas.with(debug: false, length: size.width)({ 18 | import draw: * 19 | 20 | let headers = () 21 | let tasks = () 22 | let flat_tasks = () 23 | let milestones = () 24 | let n_cols = 0 25 | let pt = 1 / size.width.pt() 26 | 27 | let heading-spacing = heading-spacing.pt() 28 | 29 | for line in body { 30 | if line.type == "header" { 31 | headers.push(line.headers) 32 | if line.total > n_cols { 33 | n_cols = line.total 34 | } 35 | } else if line.type == "task" or line.type == "taskgroup" { 36 | tasks.push(line) 37 | } else if line.type == "milestone" { 38 | milestones.push(line) 39 | } 40 | } 41 | 42 | // Task titles 43 | group.with(name: "titles")({ 44 | let i = 0 45 | for task in tasks { 46 | if task.type == "task" { 47 | content( 48 | (rel: (0, 0)), 49 | task.name, 50 | anchor: "north", 51 | name: "task" + str(i), 52 | padding: spacing, 53 | ) 54 | 55 | anchor( 56 | "task" + str(i) + "-bottom", 57 | (rel: (0, 0), to: "task" + str(i) + ".south", update: true), 58 | ) 59 | anchor( 60 | "task" + str(i) + "-top", 61 | (rel: (0, 0), to: "task" + str(i) + ".north-west", update: false), 62 | ) 63 | anchor( 64 | "task" + str(i), 65 | (rel: (0, 0), to: "task" + str(i) + ".east", update: false), 66 | ) 67 | 68 | flat_tasks.push(task) 69 | 70 | i += 1 71 | } else if task.type == "taskgroup" { 72 | for t in task.tasks { 73 | content( 74 | (rel: (0, 0)), 75 | t.name, 76 | anchor: "north", 77 | name: "task" + str(i), 78 | padding: spacing, 79 | ) 80 | 81 | anchor( 82 | "task" + str(i) + "-bottom", 83 | (rel: (0, 0), to: "task" + str(i) + ".south", update: true), 84 | ) 85 | anchor( 86 | "task" + str(i) + "-top", 87 | (rel: (0, 0), to: "task" + str(i) + ".north-west", update: false), 88 | ) 89 | anchor( 90 | "task" + str(i), 91 | (rel: (0, 0), to: "task" + str(i) + ".east", update: false), 92 | ) 93 | 94 | flat_tasks.push(t) 95 | 96 | i += 1 97 | } 98 | } 99 | } 100 | 101 | if milestone-layout == "aligned" { 102 | for (i, milestone) in milestones.enumerate() { 103 | content( 104 | (rel: (0, 0)), 105 | milestone.body, 106 | anchor: "north", 107 | name: "milestone" + str(i), 108 | padding: spacing, 109 | ) 110 | 111 | anchor( 112 | "milestone" + str(i) + "-bottom", 113 | (rel: (0, 0), to: "milestone" + str(i) + ".south", update: true), 114 | ) 115 | anchor( 116 | "milestone" + str(i) + "-right", 117 | (rel: (0, 0), to: "milestone" + str(i) + ".east", update: false), 118 | ) 119 | anchor( 120 | "milestone" + str(i) + "-top", 121 | (rel: (0, 0), to: "milestone" + str(i) + ".north", update: false), 122 | ) 123 | } 124 | } 125 | }) 126 | 127 | // Now that we have laid out the task titles, we can render the task group boxes 128 | group.with(name: "boxes")(ctx => on-layer.with(1)({ 129 | let start_x = coordinate.resolve(ctx, "titles.north-west").at(1).at(0) 130 | let end_x = 1 + start_x 131 | 132 | let i = 0 133 | for group in tasks { 134 | if group.type != "taskgroup" { 135 | i += 1 136 | continue 137 | } 138 | 139 | let start_i = i 140 | let group_start = none 141 | let group_end = none 142 | 143 | for task in group.tasks { 144 | if group_start == none { 145 | let start_y = coordinate.resolve(ctx, "titles.task" + str(i) + "-top").at(1).at(1) 146 | group_start = (start_x, start_y) 147 | } 148 | 149 | let end_y = coordinate.resolve(ctx, "titles.task" + str(i) + "-bottom").at(1).at(1) 150 | group_end = (end_x, end_y) 151 | 152 | i += 1 153 | } 154 | 155 | rect(group_start, group_end, ..cell-line-style) 156 | } 157 | 158 | if tasks-vline { 159 | line("titles.north-east", "titles.south-east", ..cell-line-style) 160 | } 161 | 162 | if box-milestones and milestone-layout == "aligned" { 163 | let start = none 164 | let end = none 165 | 166 | for (i, milestone) in milestones.enumerate() { 167 | if start == none { 168 | let start_y = coordinate.resolve(ctx, "titles.milestone" + str(i) + "-top").at(1).at(1) 169 | start = (start_x, start_y) 170 | } 171 | let end_y = coordinate.resolve(ctx, "titles.milestone" + str(i) + "-bottom").at(1).at(1) 172 | end = (end_x, end_y) 173 | } 174 | 175 | rect(start, end, ..cell-line-style) 176 | } 177 | })) 178 | 179 | get-ctx(ctx => { 180 | let (start_x, start_y, _) = coordinate.resolve(ctx, "titles.north-east").at(1) 181 | let end_x = 1 + coordinate.resolve(ctx, "titles.north-west").at(1).at(0) 182 | let end_y = coordinate.resolve(ctx, "titles.south").at(1).at(1) 183 | 184 | group.with(name: "top-headers")({ 185 | 186 | // the offset to the start_y, use unit pt 187 | let current_start_y_offset = 0 188 | 189 | for (i, header) in headers.rev().enumerate() { 190 | 191 | // before creat content, find the hightest name, and the height of header will fit it 192 | let max_name_height = 0 193 | for group in header { 194 | for (name, len) in group.titles { 195 | let name_height = measure(name).height.pt() 196 | if max_name_height < name_height { 197 | max_name_height = name_height 198 | } 199 | } 200 | } 201 | let current_header_height = max_name_height + heading-spacing 202 | 203 | let passed = 0 204 | for group in header { 205 | let group_start = none 206 | let group_end = none 207 | 208 | for (name, len) in group.titles { 209 | let start = ( 210 | a: (start_x, start_y + (current_start_y_offset + current_header_height) * pt), 211 | b: (end_x, start_y + (current_start_y_offset + current_header_height) * pt), 212 | number: passed / n_cols * 100%, 213 | ) 214 | 215 | if group_start == none { group_start = start } 216 | 217 | let end = ( 218 | a: (start_x, start_y + current_start_y_offset * pt), 219 | b: (end_x, start_y + current_start_y_offset * pt), 220 | number: (passed + len) / n_cols * 100%, 221 | ) 222 | 223 | group_end = end 224 | 225 | content(start, end, anchor: "north-west", align(center + horizon, name)) 226 | 227 | passed += len 228 | } 229 | 230 | let group_style = cell-line-style 231 | if "style" in group { 232 | group_style = group.style 233 | } 234 | rect(group_start, group_end, ..group_style) 235 | } 236 | 237 | // add current height to offset, get the offset of next line of header 238 | current_start_y_offset = current_start_y_offset + current_header_height 239 | } 240 | }) 241 | 242 | // Draw the lines 243 | for (i, task) in flat_tasks.enumerate() { 244 | let start = "titles.task" + str(i) 245 | let task_start_y = coordinate.resolve(ctx, "titles.task" + str(i)).at(1).at(1) 246 | let (task_top_x, task_top_y, _) = coordinate.resolve(ctx, "titles.task" + str(i) + "-top").at(1) 247 | let task_bottom_y = coordinate.resolve(ctx, "titles.task" + str(i) + "-bottom").at(1).at(1) 248 | let h = task_top_y - task_bottom_y 249 | 250 | for gantt_line in task.lines { 251 | let start = ( 252 | a: (start_x, task_start_y), 253 | b: (end_x, task_start_y), 254 | number: (gantt_line.from + offset) / n_cols * 100%, 255 | ) 256 | 257 | let end = ( 258 | a: (start_x, task_start_y), 259 | b: (end_x, task_start_y), 260 | number: (gantt_line.to + offset) / n_cols * 100%, 261 | ) 262 | 263 | let style = line-style 264 | if ("style" in gantt_line) { style = gantt_line.style } 265 | line(start, end, ..style) 266 | 267 | if ("content" in gantt_line) { 268 | content((start, 50%, end), gantt_line.content) 269 | } 270 | } 271 | } 272 | 273 | // Grid 274 | if show-grid != false { 275 | let month_width = (end_x - start_x) / n_cols 276 | 277 | on-layer.with(-1)({ 278 | // Horizontal 279 | if show-grid == true or show-grid == "x" { 280 | for i in range(1, n_cols) { 281 | line( 282 | (start_x + month_width * i, start_y), 283 | (start_x + month_width * i, end_y), 284 | ..grid-style, 285 | ) 286 | } 287 | } 288 | 289 | if show-grid == true or show-grid == "y" { 290 | for (i, task) in flat_tasks.enumerate() { 291 | let task_bottom_y = coordinate.resolve(ctx, "titles.task" + str(i) + "-bottom").at(1).at(1) 292 | line((start_x, task_bottom_y), (end_x, task_bottom_y), ..grid-style) 293 | } 294 | 295 | if milestone-layout == "aligned" { 296 | for (i, milestone) in milestones.enumerate() { 297 | let bottom_y = coordinate.resolve(ctx, "titles.milestone" + str(i) + "-bottom").at(1).at(1) 298 | line((start_x, bottom_y), (end_x, bottom_y), ..grid-style) 299 | } 300 | } 301 | } 302 | 303 | // Border all around the timeline 304 | rect("titles.north-west", (end_x, end_y), ..cell-line-style) 305 | }) 306 | } 307 | 308 | // Milestones 309 | if milestones.len() > 0 { 310 | let draw-milestone( 311 | i, 312 | at: 0, 313 | body: "", 314 | style: milestone-line-style, 315 | overhang: milestone-overhang, 316 | spacing: spacing, 317 | anchor: "north", 318 | type: "milestone", 319 | ) = { 320 | if milestone-layout == "in-place" { 321 | let x = (end_x - start_x) * ((at + offset) / n_cols) + start_x 322 | 323 | get-ctx(ctx => { 324 | let pos = (x: x, y: end_y - (spacing + overhang).pt() * pt) 325 | let box_x = x 326 | 327 | let (w, h) = util.measure(ctx, body) 328 | if x + w / 2 > end_x { 329 | box_x = end_x - w / 2 330 | } 331 | 332 | if i != 0 { 333 | let (prev_end_x, prev_start_y, _) = coordinate.resolve-anchor(ctx, "milestone" + str(i - 1) + ".north-east") 334 | let prev_end_y = coordinate.resolve-anchor(ctx, "milestone" + str(i - 1) + ".south").at(1) 335 | 336 | if box_x - w / 2 < prev_end_x and pos.y <= prev_start_y and pos.y + h >= prev_end_y { 337 | pos = (x: x, y: prev_end_y - spacing.pt() * pt * 2) 338 | } 339 | } 340 | 341 | line((x, start_y), (rel: (0, overhang.pt() * pt), to: pos), ..style) 342 | on-layer.with(1)({ 343 | content((box_x, pos.y), anchor: anchor, body, name: "milestone" + str(i)) 344 | }) 345 | }) 346 | } else if milestone-layout == "aligned" { 347 | let x = (end_x - start_x) * (at / n_cols) + start_x 348 | let end_y = coordinate.resolve(ctx, "titles.milestone" + str(i) + "-right").at(1).at(1) 349 | line((x, start_y), (x, end_y), (start_x, end_y), ..style) 350 | } 351 | } 352 | 353 | on-layer.with(-0.5)({ 354 | if milestone-layout == "aligned" { 355 | set-ctx(ctx => { 356 | ctx.prev.pt = coordinate.resolve(ctx, "titles.south").at(1) 357 | return ctx 358 | }) 359 | } 360 | for (i, milestone) in milestones.enumerate() { 361 | draw-milestone(i, ..milestone) 362 | } 363 | }) 364 | } 365 | }) 366 | })) 367 | 368 | #let headerline(..args) = { 369 | let groups = args.pos() 370 | 371 | let headers = () 372 | let current_group = () 373 | let total = 0 374 | 375 | let parse_entry(e) = { 376 | if type(e) == array { 377 | return e 378 | } else { 379 | return (e, 1) 380 | } 381 | } 382 | 383 | for grp in groups { 384 | if type(grp) == array { 385 | current_group.push(grp) 386 | total += grp.at(1) 387 | } else if type(grp) == dictionary { 388 | if current_group.len() > 0 { 389 | headers.push(current_group) 390 | } 391 | 392 | headers.push((titles: grp.group.map(parse_entry))) 393 | total += grp.group.map(n => parse_entry(n).at(1)).sum() 394 | } else { 395 | current_group.push((grp, 1)) 396 | total += 1 397 | } 398 | } 399 | 400 | if current_group.len() > 0 { 401 | headers.push((titles: current_group)) 402 | } 403 | 404 | return ((type: "header", headers: headers, total: total),) 405 | } 406 | 407 | #let group(..args) = { 408 | return (group: args.pos()) 409 | } 410 | 411 | #let task(name, style: none, ..lines) = { 412 | let processed_lines = () 413 | 414 | for line in lines.pos() { 415 | if type(line) == dictionary { 416 | if (style != none) and ("style" not in line) { 417 | line.style = style 418 | } 419 | processed_lines.push(line) 420 | } else { 421 | let (from, to) = line 422 | if style != none { 423 | processed_lines.push((from: from, to: to, style: style)) 424 | } else { 425 | processed_lines.push((from: from, to: to)) 426 | } 427 | } 428 | } 429 | 430 | ((type: "task", name: name, lines: processed_lines),) 431 | } 432 | 433 | 434 | #let taskgroup(title: none, tasks, style: none, content: none) = { 435 | let extratask = () 436 | if title != none { 437 | let min = none 438 | let max = none 439 | for task in tasks { 440 | for l in task.lines { 441 | if min == none or l.from < min { 442 | min = l.from 443 | } 444 | if max == none or l.to > max { 445 | max = l.to 446 | } 447 | } 448 | } 449 | 450 | extratask = ((type: "task", name: title, lines: ((from: min, to: max, style: style, content: content),)),) 451 | } 452 | 453 | ((type: "taskgroup", tasks: extratask + tasks),) 454 | } 455 | 456 | #let milestone(body, at: none, ..options) = { 457 | ((type: "milestone", at: at, body: body, ..options.named()),) 458 | } 459 | --------------------------------------------------------------------------------