Skip to content

Instantly share code, notes, and snippets.

@ntjess
Last active May 2, 2024 00:00
Show Gist options
  • Save ntjess/7c8f209ac09a1b5eb444452dfbd0afe4 to your computer and use it in GitHub Desktop.
Save ntjess/7c8f209ac09a1b5eb444452dfbd0afe4 to your computer and use it in GitHub Desktop.
Colorful, Date-Aligned Gantt Chart in Typst
#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