Skip to content

Instantly share code, notes, and snippets.

@cosmobobak
Last active May 31, 2026 23:55
Show Gist options
  • Select an option

  • Save cosmobobak/2fb3d298c18abcff3488a53e94c6dd31 to your computer and use it in GitHub Desktop.

Select an option

Save cosmobobak/2fb3d298c18abcff3488a53e94c6dd31 to your computer and use it in GitHub Desktop.
#!/usr/bin/env -S cargo +nightly -Zscript -q
---cargo
[package]
name = "type-help-timeline"
version = "1.0.0"
edition = "2024"
[dependencies]
anyhow = "1.0"
arrayvec = "0.7.6"
---
use anyhow::Context;
use arrayvec::ArrayVec;
use std::{
collections::{BTreeMap, BTreeSet},
io,
};
const WIDTH: usize = 12;
#[derive(Debug, PartialOrd, Ord, PartialEq, Eq)]
struct Scene {
time: u8,
area: [u8; 2],
team: ArrayVec<u8, 16>,
}
fn main() -> anyhow::Result<()> {
let stdin = io::read_to_string(io::stdin())?;
let mut scenes = Vec::with_capacity(64);
for line in stdin.lines() {
let mut parts = line.split('-');
let time = parts
.next()
.with_context(|| format!("No time code in {line:?}"))?;
let time = time
.parse()
.with_context(|| format!("Could not parse {time:?} as time code"))?;
let area = parts
.next()
.with_context(|| format!("No area code in {line:?}"))?;
let area = area
.as_bytes()
.as_array()
.copied()
.with_context(|| format!("Area code must be two bytes, got {area:?}"))?;
let team = parts
.map(|part| part.parse())
.collect::<Result<ArrayVec<_, _>, _>>()
.with_context(|| format!("Got unparsable person codes in {line:?}"))?;
scenes.push(Scene { time, area, team });
}
// We will construct a *grid*. We index by time-slice, then area.
let last_participant = scenes
.iter()
.flat_map(|s| s.team.clone())
.max()
.context("No scenes provided")?;
let area_indices = scenes
.iter()
.map(|s| s.area)
.collect::<BTreeSet<_>>()
.into_iter()
.zip(0..)
.collect::<BTreeMap<_, _>>();
let max_participants = area_indices
.iter()
.map(|(k, _)| {
(
*k,
scenes
.iter()
.filter(|s| s.area == *k)
.map(|s| s.team.len())
.max()
.unwrap_or(1),
)
})
.collect::<BTreeMap<_, _>>();
let base = |area: [u8; 2]| {
// the base index for an area is the sum of max participants
// in prior areas.
let this_idx = area_indices.get(&area).unwrap();
let mut sum = 0;
for (area, idx) in area_indices.iter() {
if idx >= this_idx {
continue;
}
sum += max_participants.get(area).unwrap() + 1;
}
sum
};
let emit_nodes = |scene: &Scene| {
// the y-coördinate is simply the timepoint.
let y = scene.time * 2;
// the x-coördinates are trickier – we emit one
// for each participant, and base-index them from
// the area index.
let base = base(scene.area);
let n = *max_participants.get(&scene.area).unwrap();
let locs = (base..)
.take(n)
.map(|x| format!("({x}em, {y}em)"))
.collect::<Vec<_>>();
let width = n * WIDTH;
// note: “shape: rect” is necessary due to a bug in the library.
println!(
" node(shape: rect, width: {width}pt, height: 1em, snap: -1, inset: 0pt, stroke: teal, fill: teal.lighten(90%), enclose: ({locs})),",
locs = locs.join(", ")
);
// and also emit person markers:
// let locs = (base..)
// .take(scene.team.len())
// .map(|x| format!("({x}, {y})"));
// for loc in locs {
for (i, p) in scene.team.iter().copied().enumerate() {
let x = base + i;
let loc = format!("({x}em, {y}em)");
println!(" node({loc}, \"{p}\"),");
}
};
// emit document header
let header = r#"
#import "@preview/fletcher:0.5.8" as fletcher: diagram, node, edge
// #set page(width: 1600pt, height: 900pt)
// #set page(flipped: true)
#set page(width: auto, height: auto)
= What’s going on in Type Help?
#line()
#diagram(
// debug: true, // show a coordinate grid
spacing: (6pt, 0.5em),
// node-stroke: 0.6pt,
"#;
println!("{header}");
// emit zero-time location headers:
for (location, _) in area_indices.iter() {
let base = base(*location);
let n = *max_participants.get(location).unwrap();
let locs = (base..)
.take(n)
.map(|x| format!("({x}em, 0em)"))
.collect::<Vec<_>>();
let name = std::str::from_utf8(location).context("bad UTF-8 for area name")?;
let width = n * WIDTH;
// note: “shape: rect” is necessary due to a bug in the library.
println!(
" node(shape: rect, width: {width}pt, height: 2em, snap: -1, inset: 0pt, stroke: blue, fill: blue.lighten(90%), enclose: ({locs}), [{name}]),",
locs = locs.join(", ")
);
}
for scene in &scenes[..] {
emit_nodes(scene);
}
let next_appearance = |person: u8, from_time: u8| {
scenes
.iter()
.find(move |s| s.time > from_time && s.team.contains(&person))
};
// now generate arrows – for each timeslice, generate forward-arrows
// from the participants to their next appearance.
let t0 = scenes.iter().map(|s| s.time).min().unwrap();
let tx = scenes.iter().map(|s| s.time).max().unwrap();
for t in t0..=tx {
// select those scenes at this time-point
let at_t = scenes.iter().filter(|s| s.time == t);
for scene in at_t {
// get the base-index for this scene
let src_base = base(scene.area);
for (src_offset, p) in scene.team.iter().copied().enumerate() {
// find this person’s next scene
// if it exists, draw an arrow
if let Some(dst) = next_appearance(p, t) {
let dst_base = base(dst.area);
let dst_offset = dst.team.iter().position(|v| *v == p).unwrap();
let src_x = src_base + src_offset;
let src_y = t * 2;
let dst_x = dst_base + dst_offset;
let dst_y = dst.time * 2;
// if this is the last appearance, mark it well:
let mark = if next_appearance(p, dst.time).is_none() {
"-x"
} else {
"->"
};
println!(
" edge(stroke: gradient.linear(space: oklch, red, blue).sample({:.0}%),\
marks: \"{mark}\", ({src_x}em, {src_y}em), ({dst_x}em, {dst_y}em)),",
p as f64 / last_participant as f64 * 100.0,
);
}
}
}
}
println!(")");
Ok(())
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment