///|
/// ASCII timeline of one horizon: a Gantt style view that fits in a terminal or
/// a log.
///
/// Each record becomes one row of fixed width cells. A cell is judged at the
/// minute it starts, which keeps the rendering cheap and completely
/// reproducible: widening the horizon changes the resolution, never the
/// algorithm.
///|
/// Pad a string on the right with spaces up to `width` characters.
fn pad_right(text : String, width : Int) -> String {
let builder = StringBuilder()
builder.write_string(text)
let mut padding = width - text.length()
while padding > 0 {
builder.write_char(' ')
padding = padding - 1
}
builder.to_string()
}
///|
/// Widest record id of the plan, used as the label column width.
fn widest_id(plan : Plan) -> Int {
let mut width = 0
for window in plan.windows {
if window.id.length() > width {
width = window.id.length()
}
}
width
}
///|
/// True when a change or a freeze covers the minute. `freeze` selects which of
/// the two collections is searched.
fn covers_minute(records : Array[Window], minute : Int) -> Bool {
for record in records {
if minute >= record.start && minute < record.end {
return true
}
}
false
}
///|
/// The cell of one row at one column.
///
/// A row only ever shows its own record: `.` when that record does not cover
/// the minute, `!` when it does and a record of the other kind covers it as well
/// (a change inside a freeze), `#` for a change and `=` for a freeze.
fn timeline_cell(record : Window, others : Array[Window], minute : Int) -> Char {
if minute < record.start || minute >= record.end {
return '.'
}
if covers_minute(others, minute) {
return '!'
}
if record.is_freeze() {
'='
} else {
'#'
}
}
///|
/// Render the timeline of `[from, to)` with `columns` cells per row.
///
/// `cell_minutes` is the ceiling of the horizon divided by the column count, so
/// the last cell may reach past `to`; the header prints the resolution so the
/// rounding is never a surprise. Records are ordered by start minute.
///
/// When the horizon is empty (`to <= from`) or `columns <= 0`, the result is
/// the single line `Timeline (empty horizon)` with one trailing newline.
pub fn render_timeline(
plan : Plan,
from : Int,
to : Int,
columns : Int,
) -> String {
if to <= from || columns <= 0 {
return "Timeline (empty horizon)\n"
}
let span = to - from
let cell_minutes = if span % columns == 0 {
span / columns
} else {
span / columns + 1
}
let builder = StringBuilder()
builder.write_string(
"Timeline " +
format_instant(from) +
" -> " +
format_instant(to) +
" (" +
format_duration(span) +
", " +
columns.to_string() +
" columns, 1 column = " +
cell_minutes.to_string() +
"m)\n",
)
let changes = plan.changes()
let freezes = plan.freezes()
let width = widest_id(plan)
for window in sort_windows_by_start(plan.windows) {
let others = if window.is_freeze() { changes } else { freezes }
builder.write_string(pad_right(window.id, width))
builder.write_string(" |")
for column = 0; column < columns; column = column + 1 {
builder.write_char(
timeline_cell(window, others, from + column * cell_minutes),
)
}
builder.write_string("|\n")
}
builder.write_string(
"legend: . free, # change, = freeze, ! change inside freeze\n",
)
builder.to_string()
}