Last active
May 2, 2024 00:00
-
-
Save ntjess/7c8f209ac09a1b5eb444452dfbd0afe4 to your computer and use it in GitHub Desktop.
Colorful, Date-Aligned Gantt Chart in Typst
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#let _records-to-dict(records) = { | |
let formatted = (:) | |
for r in records { | |
for (k, v) in r { | |
let cur = formatted.at(k, default: ()) | |
cur.push(v) | |
formatted.insert(k, cur) | |
} | |
} | |
formatted | |
} | |
// #get-timeframe(data) | |
#let _RESERVED-KEYS = ("begin", "duration", "end", "timestamp", "type", "color", "level", "indent") | |
#let _N-DATE-HEADERS = 2 | |
#let _N-TITLE-COLUMNS = 1 | |
#let _MILESTONE-MARGIN = 0 | |
#let _empty-cell = table.cell.with(none) | |
#let _split-opts-and-children(opts) = { | |
let out-opts = (:) | |
// "opts" is now the user-defined children, out-opts are the task properties. | |
// Rename variable for clarity | |
let children = opts | |
for key in _RESERVED-KEYS.filter(k => k in opts) { | |
out-opts.insert(key, children.remove(key)) | |
} | |
(children, out-opts) | |
} | |
#let _str-to-date(date-str, name-refs: (), units: none) = { | |
if type(date-str) in (datetime, type(auto)) { | |
return date-str | |
} | |
if date-str == "today" { | |
return datetime.today() | |
} | |
let name-dates-map = name-refs.map(opts => ((opts.name): opts)).join() | |
if name-dates-map == none { | |
name-dates-map = (:) | |
} | |
if "." in date-str { | |
let offset = date-str.match(regex(" *([\+\-]) *(\d+)")) | |
let increment = 0 | |
if offset != none { | |
increment = int(offset.captures.at(1)) | |
if offset.captures.at(0) == "-" { | |
increment *= -1 | |
} | |
date-str = date-str.slice(0, offset.start) | |
} | |
let (..name-pieces, key) = date-str.split(".") | |
let name = name-pieces.join(".") | |
let period = name-dates-map.at(name).at(key) | |
if increment != 0 { | |
period += duration(..((units): increment)) | |
} | |
return period | |
} | |
let pieces = date-str.split("-").map(int) | |
let today = datetime.today() | |
// Provide "none" defaults where unspecifeid | |
let getter = idx => pieces.at(idx, default: none) | |
datetime(year: getter(0), month: getter(1), day: getter(2)) | |
} | |
#let _user-arg-to-color(user-color) = { | |
if type(user-color) == color { | |
return user-color | |
} | |
if type(user-color) == array { | |
rgb(..user-color) | |
} else if type(user-color) == str { | |
if user-color.starts-with("#") { | |
rgb(user-color) | |
} else { | |
eval(user-color) | |
} | |
} else { | |
panic("Invalid color type") | |
} | |
} | |
#let _builtin-duration = duration | |
#let _resolve-dates(opts, units, name-refs) = { | |
let _str-to-date = _str-to-date.with(name-refs: name-refs, units: units) | |
if "timestamp" in opts { | |
return (_str-to-date(opts.timestamp), _str-to-date(opts.timestamp)) | |
} | |
let (begin, end, duration) = opts | |
let kwargs = ((units): duration) | |
if (begin, end, duration).filter(x => x == auto).len() > 1 { | |
panic("Only one of `begin`, `end`, or `duration` may be auto") | |
} | |
if begin == auto { | |
begin = _str-to-date(end) - _builtin-duration(..kwargs) | |
} | |
if end == auto { | |
end = _str-to-date(begin) + _builtin-duration(..kwargs) | |
} | |
(_str-to-date(begin), _str-to-date(end)) | |
} | |
#let _fill-task-dates(opts, children, units, name-refs) = { | |
let has-children = children.len() > 0 | |
let missing(key) = key not in opts | |
let default-begin = if has-children { | |
calc.min(..children.map(c => c.begin)) | |
} else { auto } | |
let default-end = if has-children { | |
calc.max(..children.map(c => c.end)) | |
} else { auto } | |
if missing("begin") { | |
opts.begin = default-begin | |
} | |
if missing("end") { | |
opts.end = default-end | |
} | |
if missing("duration") { | |
opts.duration = auto | |
} | |
(opts.begin, opts.end) = _resolve-dates(opts, units, name-refs) | |
opts | |
} | |
#let _fill-task-type(opts) = { | |
let default = opts.at("type", default: "task") | |
if "timestamp" in opts and "type" not in opts { | |
default = "milestone" | |
} | |
opts.insert("type", default) | |
opts | |
} | |
#let flatten-data(data, level: 0, units: "days", tasks-cache: ()) = { | |
if data.len() == 0 { | |
return () | |
} | |
let out = () | |
for (name, opts) in data { | |
let (children, opts) = _split-opts-and-children(opts) | |
let children = flatten-data(children, level: level + 1, units: units, tasks-cache: tasks-cache + out) | |
opts = _fill-task-dates(opts, children, units, tasks-cache + out) | |
opts = _fill-task-type(opts) | |
opts.name = name | |
opts.level = level | |
out += (opts, ..children) | |
} | |
out | |
} | |
#let _make-row-name(task) = { | |
let lvl = task.level | |
let indent = task.at("indent", default: 1) * 0.5em | |
let descr = task.name | |
if lvl == 0 { | |
descr = [*#descr*] | |
} | |
if lvl > 1 { | |
descr = [_#descr;_] | |
} | |
let description = [#h(indent * lvl) #descr] | |
description | |
} | |
#let _make-date-fills(task, date-range) = { | |
let out = () | |
let lighten-amt = 25% * task.level | |
for date in date-range { | |
let fill = if date < task.begin or date >= task.end { | |
none | |
} else { | |
task.color.lighten(lighten-amt) | |
} | |
out.push(_empty-cell(fill: fill)) | |
} | |
out | |
} | |
#let _make-milestones(milestone, date-range, y-offset, cellxy-milestone-map) = { | |
for (ii, date) in date-range.enumerate() { | |
if date < milestone.begin { | |
continue | |
} | |
let y-pos = y-offset + _MILESTONE-MARGIN | |
while repr((ii + _N-TITLE-COLUMNS, y-pos)) in cellxy-milestone-map { | |
y-pos += 1 | |
} | |
let contents = ( | |
table.vline( | |
x: ii + _N-TITLE-COLUMNS, | |
start: _N-TITLE-COLUMNS, | |
end: y-pos + 1, | |
stroke: milestone.color + 2pt | |
), | |
table.cell( | |
x: ii + _N-TITLE-COLUMNS, | |
y: y-pos, | |
text(fill: milestone.color)[*#milestone.name*] | |
) | |
) | |
cellxy-milestone-map.insert(repr((ii + _N-TITLE-COLUMNS, y-pos)), contents) | |
return cellxy-milestone-map | |
} | |
} | |
/// | |
/// Gantt chart generator. | |
/// | |
/// - data (dictionary): A dictionary of tasks, optional subtasks, and optional milestones. | |
/// | |
/// *Tasks* have a beginning, end, and duration. Exactly two of these three options | |
/// must be provided: | |
/// - begin (`time`): The start date of the task. | |
/// - duration (`int`): The duration of the task in the specified units. | |
/// - end (`time`): The end date of the task. | |
/// | |
/// `time` types can be specified using the following strings: | |
/// - "YYYY-MM-DD": A specific date. | |
/// - ```typc "today"```: The current date, equates to ```typc datetime.today()``` | |
/// - A reference to another task's date in the format `<task name.[begin|end]>`. For | |
/// example, `Task 1.end`. References can only be for previously encountered tasks. | |
/// | |
/// When a task has subtasks, its beginning and end dates are automatically set to the | |
/// earliest and latest dates of its subtasks (including milestone times). | |
/// | |
/// | |
/// *Milestones* are tasks with no duration. They are represented as vertical lines on | |
/// the chart. They can be used to mark important dates or events. Each milestone | |
/// must have a `timestamp` key that specifies the date of the milestone (see `time` | |
/// specifiers above). | |
/// | |
/// *Both tasks and milestones* use their name as the label for the row in the chart, | |
/// and dictionary key. The following additional keys are supported: | |
/// - `level` (int): The level of the task in the hierarchy. The top-level tasks have | |
/// a level of `0`, and subtasks have a level of `1`, and so on. This is set | |
/// automatically for each task but can be user-overridden. | |
/// - `color` (color): The color of the task. This can be a color object, a hex string, | |
/// or an RGB tuple. If not provided, the color will be automatically assigned. | |
/// | |
/// - units (string): The units of time to use for the chart. The only currently | |
/// supported options are ```typc "days"``` and ```typc "weeks"```. | |
/// - block-size (int): The size of each block in the chart. Each block is the integer | |
/// multiple of the unit of time. So a block size of `7` with units of `days` would | |
/// mean each gantt cell represents a week. | |
/// - default-colors (array): An array of colors to use for the chart. The colors will be | |
/// evenly distributed across the top-level tasks. | |
/// | |
#let gantt(data, units: "days", block-size: 7, default-colors: color.map.plasma) = { | |
let tasks = flatten-data(data, units: units) | |
let transposed = _records-to-dict(tasks) | |
let begin = calc.min(..transposed.begin) | |
let end = calc.max(..transposed.end) | |
let step-kwargs = ((units): block-size) | |
let step = duration(..step-kwargs) | |
let date-range = (begin,) | |
let cur-date = begin | |
while cur-date < end { | |
cur-date += step | |
date-range.push(cur-date) | |
} | |
let month-name-header = for (ii, date) in date-range.enumerate() { | |
let value = if date.month() == date-range.at(ii - 1).month() { | |
none | |
} else { | |
date.display("[month repr:short]") | |
} | |
(table.cell(fill: white.darken(10%), value),) | |
} | |
let date-header = date-range.map(d => table.cell(fill: white.darken(10%), d.display("[day]"))) | |
let num-colors = tasks.filter(t => t.level == 0).len() | |
let colors = range(0, default-colors.len(), step: int(default-colors.len()/num-colors)).map(i => default-colors.at(i)) | |
let group-idx = -1 | |
let (name-rows, date-rows) = ((), ()) | |
let cellxy-milestone-map = (:) | |
for (idx, task) in tasks.enumerate() { | |
if task.level == 0 { | |
group-idx += 1 | |
} | |
let milestone-offset = tasks.filter(t => t.type == "task").len() + _N-DATE-HEADERS | |
let clr = colors.at(group-idx) | |
let user-color = _user-arg-to-color(task.at("color", default: clr)) | |
task.insert("color", user-color) | |
if task.type == "milestone" { | |
cellxy-milestone-map = _make-milestones( | |
task, date-range, milestone-offset, cellxy-milestone-map, | |
) | |
} else { | |
date-rows.push((_make-row-name(task), _make-date-fills(task, date-range))) | |
} | |
} | |
let vline-end = date-rows.len() + _N-DATE-HEADERS | |
let date-table = table( | |
columns: (auto, ) + (1fr,) * date-range.len(), | |
stroke: none, | |
table.hline(start: _N-TITLE-COLUMNS), | |
table.vline(x: _N-TITLE-COLUMNS, end: vline-end), | |
table.header( | |
none, ..month-name-header, | |
none, ..date-header, | |
), | |
..date-rows.intersperse(table.hline(stroke: 0.25pt)).flatten(), | |
table.hline(start: _N-TITLE-COLUMNS), | |
table.vline(end: vline-end), | |
..cellxy-milestone-map.values().flatten() | |
) | |
date-table | |
} | |
#set page(margin: 0.25in, height: auto) | |
#set text(font: "Roboto", weight: 500) | |
#import "@preview/tidy:0.2.0" | |
#import "@preview/showman:0.1.1": formatter | |
#let m = tidy.parse-module(read("./gantt.typ")) | |
#tidy.show-module(m, show-outline: false) | |
#show raw: formatter.raw-with-eval.with( | |
langs: "typ", | |
eval-kwargs: (scope: (gantt: gantt), direction: ttb) | |
) | |
#show <example-output>: set text(font: "Roboto") | |
#show <example-input>: formatter.format-raw.with(width: 100%) | |
#show <example-output>: formatter.format-raw.with(fill: none) | |
== Example | |
````typ | |
#let raw-data = ```yaml | |
Task 1: | |
subtask 1: { begin: 2024-04-11, duration: 5 } | |
subtask 2: { begin: subtask 1.end + 2, duration: 7 } | |
REPORT DUE: { timestamp: "subtask 2.begin" } | |
Task 2: | |
subtask 2.1: | |
small task: | |
most nested: { duration: 6, begin: "Task 1.end" } | |
the final one: { duration: 3, begin: "small task.end + 2" } | |
Today: { timestamp: today, color: "red" } | |
``` | |
#let data = yaml.decode(raw-data.text) | |
#grid(gutter: 1em)[ | |
#align(center)[Weekly units in increments of 3:] | |
][ | |
#gantt(data, block-size: 3, units: "weeks") | |
][ | |
#align(center)[Daily units in increments of 5:] | |
][ | |
#gantt(data, block-size: 5) | |
] | |
````<gantt-example> | |
= Future Work | |
- Customizable defaults (aka "style sheet") | |
- Support for more time units | |
- Support more entry types (e.g. highlighted tasks, etc.) | |
- Task relationships (e.g. "Task 2 can't start until Task 1 is done") | |
- Gray-out past dates |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment