Jollywatt / typst-fletcher

Typst package for drawing diagrams with arrows, built on top of CeTZ.
MIT License
341 stars 6 forks source link

Is it possible to combine multiple subnodes into a fat node? #32

Closed waterlens closed 4 months ago

waterlens commented 4 months ago

I'm trying to draw a diagram similar to this one:

image

My plan is to regard it as separate 4 nodes which are close enough together. However, I found it hard to specify the distance between nodes in fletcher because I cannot directly get the size of a node. And I don't know if I can let all nodes share the same height automatically. Is it possible to add support to such a diagram, through a new concept of fat nodes and subnodes in fletcher?

Currently, I can get a scribbled diagram using fletcher,

image

with this code:

#import "@preview/fletcher:0.4.4" as fletcher: diagram, node, edge, draw

#let debug = if false { 2 } else { 0 }

#let nodeshape(node, extrude, fradius: x => x + 3pt) = {
    let (w, h) = node.size.map(i => i/2 + extrude)
    draw.rect(
        (-w, -h), (+w, +h),
        radius: fradius(extrude),
    )
}

#let lnodeshape = nodeshape.with(fradius: extrude => (north-west: extrude + 3pt, south-west: extrude + 3pt))
#let mnodeshape = nodeshape.with(fradius: extrude => 0pt)
#let rnodeshape = nodeshape.with(fradius: extrude => (north-east: extrude + 3pt, south-east: extrude + 3pt))

#let mydiagram = diagram.with(
  debug: debug,
  spacing: (10mm, 5mm),
  node-stroke: 1pt,
  edge-stroke: 1pt,
  mark-scale: 60%
)

#let node0 = node((0, 0), `Left`, shape: lnodeshape)
#let node1 = node((0.63, 0), `Right`, shape: rnodeshape)

#mydiagram(
  node0,
  node1,
)
Jollywatt commented 4 months ago

You could try making the composite node one node with a table as content. This solution still requires you to manually position edges, but that's easier than manually joining nodes.

#import "@preview/fletcher:0.4.4" as fletcher: diagram, node, edge, draw

#let label = table(
  columns: (12mm, 4mm, 4mm, 4mm),
  `Node`, none, `4`, none,
  stroke: (x, y) => if 0 < x and x < 3 { (x: 1pt) },
)

#diagram(
  node-stroke: 1pt,
  edge-stroke: 1pt,
  mark-scale: 50%,
  node((0,0), label, inset: 0pt, corner-radius: 3pt),
  edge((0.09,0), (0,1), "*-straight", snap-to: ((99,99), auto)),
  edge((0.41,0), (1,1), "*-straight", snap-to: ((99,99), auto)),
  node((0,1), `Subnode`),
)

I've used snap-to to prevent the edges from snapping to the composite node. (Surprisingly, passing none to snap-to doesn'y work — I'll fix that — so I gave it coordinates of a nonexistent node instead.)

It would be really cool to support something like pinit for doing something like this more automatically, though.

Jollywatt commented 4 months ago

@waterlens I'm going to assume that the table-in-a-node trick worked fine, so that we don't need to add a new kind of 'fat' node or 'subnode' :)

waterlens commented 3 months ago

Sorry, last month I'm busy with other things and didn't reply to this. Yeah it should be a better solution than mine. I think it could be more elegant to use pinit-like packages, but currently I'm not sure how to do it.

waterlens commented 3 months ago

I also try to position the edge automatically. I think relative coordinates like (rel: (x, y), to: (u, v)) are helpful here. We can pre-calculate the position of start point inside a table, and then tell fletcher to start from that. @Jollywatt How do you think about this? I guess this method could be extended to support richer features for this awesome package.

#import "@preview/fletcher:0.5.0" as fletcher: diagram, node, edge, draw

#let cell(..texts) = {
  let texts = texts.pos()
  let columns = texts.map(((_, len)) => len)
  let texts = texts.map(((text, _))  => text)
  let anchor_pos = {
    let anchor_pos = ()
    let width = 0pt
    for i in range(0, texts.len()) {
      let text = texts.at(i)
      if text == none {
        anchor_pos.push(width + columns.at(i) / 2)
      }
      width += columns.at(i)
    }
    anchor_pos = anchor_pos.map((x) => x - width / 2)
    anchor_pos
  }
  let n = columns.len()
  let tbl = table(
    columns: columns,
    stroke: (x, y) => if x != 0 and x != n - 1 { (x: 1pt) },
    ..texts,
  )

  (node: tbl, anchors: anchor_pos)
}

#let mynode(n) = cell(
  (`Node`, 12mm),
  (none, 4mm),
  (raw(str(n)), 4mm),
  (none, 4mm)
)

#diagram(
  node-stroke: 1pt,
  edge-stroke: 1pt,
  mark-scale: 50%,
  node((0,0), mynode(4).node, inset: 0pt, corner-radius: 3pt),
  edge((rel: (mynode(4).anchors.at(0), 0pt), to: (0, 0)), (0, 1), "*-straight", snap-to: (none, auto)),
  node((0,1), `Subnode`),
)
Jollywatt commented 3 months ago

That works really well:)

I'm not sure there's much more you can do with fletcher to automate this, but I did notice you could do this for the arrows:

#fletcher.MARKS.update(m => m + (
  "*": (inherit: "circle", fill: auto, tip-origin: 0),
  ">": (inherit: "straight", rev: false),
  "<": (inherit: "straight", rev: true),
))

Changing the tip-origin of the dot makes it centred, which looks slightly nicer for your use case. And if you want the stright arrows to be the default, you can do that too.

waterlens commented 3 months ago

Thanks!