How to draw vertical line in grid column connecting/aligning end points with content items in another grid column

I am currently flabbergasted as to how to draw a vertical line in another grid column with start and end points aligned with content in another column.

I would like to draw a kind of timeline connecting items. I tried measuring and getting positions but the line interface did not work with that.

Current:

Desired:

Example code:

#let date = [2024-04-12]
#let row = (
  [],
  {
    strong(lorem(2))
    [
      - #lorem(5)
      - #lorem(5)
    ]
  },
  align(top, date),
)

#grid(
  inset: (left: 0mm, rest: 1mm),
  columns: (auto, 1fr, auto),
  column-gutter: 0.3em,
  row-gutter: 0.1em,
  ..(
    box(height: 2.5em, width: 2.5em, inset: 1mm, circle(fill: black)),
    [#strong[Heading] \ Subheading],
    date,
  ).map(
    align.with(horizon),
  ),
  ..(row * 3).flatten(),
)

A possible way is to replace the first item in row with a stroked block, and use outset to extend/shrink the stroke:

align(center, block(
  height: 1fr,
  width: 0pt,
  stroke: (left: 0.5pt),
  outset: (top: -1em, bottom: 0.5em),
  [•],
))

Full code
#set page(height: auto, width: 240pt, margin: 15pt)
#let date = [2024-04-12]
#let row = (
  align(center, block(
    height: 1fr,
    width: 0pt,
    stroke: (left: 0.5pt),
    outset: (top: -1em, bottom: 0.5em),
    [•],
  )),
  {
    strong(lorem(2))
    [
      - #lorem(5)
      - #lorem(5)
    ]
  },
  align(top, date),
)

#grid(
  inset: (left: 0mm, rest: 1mm),
  columns: (auto, 1fr, auto),
  column-gutter: 0.3em,
  row-gutter: 0.1em,
  ..(
    box(height: 2.5em, width: 2.5em, inset: 1mm, circle(fill: black)),
    [#strong[Heading] \ Subheading],
    date,
  ).map(
    align.with(horizon),
  ),
  ..(row * 3).flatten(),
)

Thank you for your reply, this is almost what I want. However, it does not work with pre-defined page sizes or a wrapping bordered box:

#set page(height: auto)
// #set page(paper: "a4")
// #show: box.with(
//   stroke: 0.15mm,
//   radius: 0.6em,
//   inset: 1.3em,
//   height: 100%,
//   width: 100%,
// )

#let date = [2024-04-12]

#let row(is-last: false) = (
  align(
    center,
    block(
      height: 1fr,
      width: 0pt,
      stroke: if not is-last { (left: 0.5pt) },
      outset: (top: -1em, bottom: 0.5em),
      [•],
    ),
  ),
  {
    strong(lorem(2))
    [
      - #lorem(5)
      - #lorem(5)
    ]
  },
  align(top, date),
)

#grid(
  inset: (left: 0mm, rest: 1mm),
  columns: (auto, 1fr, auto),
  column-gutter: 0.3em,
  row-gutter: 0.1em,
  ..(
    box(height: 2.5em, width: 2.5em, inset: 1mm, circle(fill: black)),
    [#strong[Heading] \ Subheading],
    date,
  ).map(
    align.with(horizon),
  ),
  ..(..row(is-last: false) * 2, row(is-last: true)).flatten(),
)
1 Like

This solution seems to work, but I would prefer if it could be done with less complexity

#let pin-corner(alignment) = {
  place(alignment)[#metadata(none)<_pin_corner>]
}
// place a magic marker in the left + top and right + bottom corners, so we get
// the bounds of the current cell
#let pin-corners() = {
  pin-corner(left + top)
  pin-corner(right + bottom)
}
#let date = [2024-04-12]
#let row(islast: false) = (
  grid.cell(align: top + center, {
    let pad = 0.7em
    place(center, sym.bullet)
    if not islast {
      // find the size of the current cell!
      // then extend a line of the right length..
      // this is the usual super query pattern ^_^
      context {
        let pin-y = query(selector(<_pin_corner>)
          .after(here())
          .before(selector(<_cell_end>).after(here())))
          .map(pin => pin.location().position().y)
        let height = pin-y.at(1) - pin-y.at(0)
        place(center, dy: pad, line(length: height, angle: 90deg))
      }
    }
    pin-corners()
    [#metadata(none)<_cell_end>]
  }),
  {
    strong(lorem(2))
    [
      - #lorem(5)
      - #lorem(5)
    ]
  },
  align(top, date),
)

#grid(
  //stroke: 0.2pt + blue,
  inset: (left: 0mm, rest: 1mm),
  columns: (auto, 1fr, auto),
  column-gutter: 0.3em,
  row-gutter: 0.1em,
  ..(
    box(height: 2.5em, width: 2.5em, inset: 1mm, circle(fill: black)),
    [#strong[Heading] \ Subheading],
    date,
  ).map(
    align.with(horizon),
  ),
  ..(row() * 2).flatten(),
  ..row(islast: true)
)

And you’d probably need to replace sym.bullet with a circle so that the exact dimensions of it are known, then the line can have correct spacing around it on both sides.

2 Likes

Fascinating! The place(bottom, metadata) trick would also improve the algorithm for diagonal splitting cells.

Just put some links here for future reference.

That trick first seen here How to avoid that a large table footer widens the whole table? - #8 by Andrew

2 Likes

This is exactly what I needed. Thanks so much for this neat trick!

2 Likes

The same discussion seems to be happening in this other thread too, and I prefer SillyFreak’s solution because it’s simple and seems robust How can I measure a table cell size to draw a vertical line as long as the cell height? - #8 by SillyFreak

While their idea is nice for that particular use case, it does not work perfectly for mine, because inset, gutters and par spacing will affect the position of the line for me and result in not connecting like in your solution:

#let row(body, last: false) = {
  let radius = 3pt
  let fill = black
  let stroke = if not last { (right: radius / 3 + fill) }
  let circle = circle(radius: radius, fill: fill)
  (
    grid.cell(colspan: 2, align: center, circle),
    grid.cell(rowspan: 2, inset: (bottom: 4pt), body),
    grid.cell(stroke: stroke)[],
    none,
  )
}

#grid(
  columns: (auto, auto, 1fr),
  stroke: 0.3pt + black.lighten(40%),
  inset: 1em,
  gutter: 1em,
  ..row(lorem(100)),
  ..row(lorem(100)),
  ..row(lorem(100), last: true),
)

results in

you can set a separate column gutter for the first gap, i.e. replace gutter: 1em with row-gutter: 1em, column-gutter: (0pt, 1em) to make the line centered again. for the row-gutter the same trick doesn’t work since the “artificial” and real gutter would need to alternate and that’s not how the property behaves. Tweaking the insets can be used to make it so a constant row gutter is fine.

The closest I could get to your original request is this:

#let row(body, date, last: false) = {
  let radius = 3pt
  let fill = black
  let stroke = if not last { (right: radius / 3 + fill) }
  let circle = box(circle(radius: radius, fill: black))
  (
    grid.cell(colspan: 2, align: center, inset: (x: 0pt), circle),
    grid.cell(rowspan: 2, inset: (bottom: 0.1em+4pt), body),
    grid.cell(rowspan: 2, date),
    grid.cell(stroke: stroke)[],
    none,
  )
}

#let date = [2024-04-12]
#let row-body = {
  strong(lorem(2))
  [
    - #lorem(5)
    - #lorem(5)
  ]
}

#grid(
  inset: (left: 0mm, rest: 1mm),
  columns: (1.25em, 1.25em, 1fr, auto),
  column-gutter: (0pt, 0.3em),
  grid.cell(colspan: 2, align: horizon, {
    box(height: 2.5em, width: 2.5em, inset: 1mm, circle(fill: black))
  }),
  grid.cell(align: horizon)[#strong[Heading] \ Subheading],
  grid.cell(align: horizon, date),
  ..row(row-body, date),
  ..row(row-body, date),
  ..row(row-body, date, last: true),
)
1 Like