archsight 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ module Diagram
5
+ class Layout
6
+ # `align "top other"` (or `left`, `center`, `right`, `middle`, `bottom`; a bare id is `center`): once
7
+ # everything is placed, a node moves until that edge of it lines up with the same edge of the other node,
8
+ # which may sit in another container -- like the align buttons of a drawing editor. It only uses the
9
+ # free room its container already has, never growing one: where its container packs along that axis (a
10
+ # `layer` for left/right, a `stack` for up/down) it pushes its siblings along, keeping their order and
11
+ # gaps; elsewhere it moves within the container's inner box. It stops as close as it gets.
12
+ class Alignment
13
+ # Shifts below this are left alone (no visible change, no churn).
14
+ EPSILON = 0.5
15
+
16
+ AXES = { x: Axis::WIDTH, y: Axis::HEIGHT }.freeze
17
+
18
+ def initialize(graph, boxes, theme:)
19
+ @graph = graph
20
+ @boxes = boxes
21
+ @theme = theme
22
+ end
23
+
24
+ def apply
25
+ aligned = @graph.nodes_by_id.values.select(&:align)
26
+ return if aligned.empty?
27
+
28
+ # A second round settles a node aligned to a node that is aligned itself.
29
+ 2.times do
30
+ aligned.each { |node| node.alignments.each { |axis, edge, id| align(node, AXES.fetch(axis), edge, id) } }
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def align(node, axis, edge, target_id)
37
+ delta = wanted(axis, edge, @boxes[target_id]) - wanted(axis, edge, @boxes[node.id])
38
+ return if delta.abs < EPSILON
39
+
40
+ lo, hi = room(node, axis)
41
+ parent = node.parent
42
+ if parent && parent.main_axis == axis
43
+ sweep(parent.children.sort_by { |c| axis.position(@boxes[c.id]) }, node, axis, delta, lo, hi)
44
+ else
45
+ box = @boxes[node.id]
46
+ shift(node, axis, delta.clamp([lo - axis.leading_edge(box), 0.0].min, [hi - axis.trailing_edge(box), 0.0].max))
47
+ end
48
+ refit(parent, axis)
49
+ end
50
+
51
+ # The coordinate of `box` that `edge` lines up.
52
+ def wanted(axis, edge, box)
53
+ case edge
54
+ when "left", "top" then axis.leading_edge(box)
55
+ when "right", "bottom" then axis.trailing_edge(box)
56
+ else axis.position(box)
57
+ end
58
+ end
59
+
60
+ # The children of a container packed along `axis`, in order, with `node` wanting to move by `delta`: the
61
+ # others stay where they are unless they come closer to a neighbour than `min_gap`, then they are pushed
62
+ # along; everything stays inside `lo..hi`.
63
+ def sweep(children, node, axis, delta, lo, hi)
64
+ boxes = children.map { |c| @boxes[c.id] }
65
+ sizes = boxes.map { |b| axis.size(b) }
66
+ gaps = children.each_cons(2).map { |a, b| min_gap(a, b, axis) }
67
+ leads = boxes.each_with_index.map { |b, i| axis.leading_edge(b) + (children[i].equal?(node) ? delta : 0.0) }
68
+
69
+ push_on(leads, sizes, gaps)
70
+ leads[-1] = [leads[-1], hi - sizes[-1]].min
71
+ (leads.length - 2).downto(0) { |i| leads[i] = [leads[i], leads[i + 1] - gaps[i] - sizes[i]].min }
72
+ leads[0] = [leads[0], lo].max
73
+ push_on(leads, sizes, gaps)
74
+
75
+ children.each_with_index { |c, i| shift(c, axis, leads[i] - axis.leading_edge(boxes[i])) }
76
+ end
77
+
78
+ # How close two neighbours may come: the sibling gap plus half a port tab on each side that faces the
79
+ # other (see `RankArranger#port_overhangs`), never more than they are apart now.
80
+ def min_gap(a, b, axis)
81
+ current = [axis.leading_edge(@boxes[b.id]) - axis.trailing_edge(@boxes[a.id]), 0.0].max
82
+ trailing, leading = axis == Axis::HEIGHT ? %w[bottom top] : %w[right left]
83
+ [current, @theme.sibling_gap + tab_overhang(a, trailing) + tab_overhang(b, leading)].min
84
+ end
85
+
86
+ def tab_overhang(node, side)
87
+ return 0.0 unless node.leaf? && node.ports.any? { |p| p.side.nil? || p.side == side }
88
+
89
+ PortMetrics.new(@theme).height / 2.0
90
+ end
91
+
92
+ # Each leading edge at least its gap past the box before it.
93
+ def push_on(leads, sizes, gaps)
94
+ (1...leads.length).each { |i| leads[i] = [leads[i], leads[i - 1] + sizes[i - 1] + gaps[i - 1]].max }
95
+ end
96
+
97
+ # The room (lo, hi) a node may move in along `axis`: its container's inner box. An anonymous container
98
+ # packing along that axis is only as long as its children, so its room is that of its own container
99
+ # (one packing across it is as long as the line it sits in). Top-level nodes may use the whole drawing.
100
+ def room(node, axis)
101
+ container = node.parent
102
+ container = container.parent while container&.anonymous? && container.main_axis == axis
103
+ unless container
104
+ all = @graph.roots.map { |r| @boxes[r.id] }
105
+ return [all.map { |b| axis.leading_edge(b) }.min, all.map { |b| axis.trailing_edge(b) }.max]
106
+ end
107
+
108
+ box = @boxes[container.id]
109
+ padding = container.layout_padding(@theme)
110
+ title = axis == Axis::HEIGHT ? container.layout_title_height(@theme) : 0.0
111
+ [axis.leading_edge(box) + padding + title, axis.trailing_edge(box) - padding]
112
+ end
113
+
114
+ def shift(node, axis, delta)
115
+ return if delta.abs < EPSILON
116
+
117
+ box = @boxes[node.id]
118
+ axis.set_position(box, axis.position(box) + delta)
119
+ node.children.each { |c| shift(c, axis, delta) }
120
+ end
121
+
122
+ # An anonymous layer/stack's box follows its children (it draws nothing, but routing and the
123
+ # alignment of its own parent read it).
124
+ def refit(container, axis)
125
+ while container&.anonymous?
126
+ boxes = container.children.map { |c| @boxes[c.id] }
127
+ box = @boxes[container.id]
128
+ lead = boxes.map { |b| axis.leading_edge(b) }.min
129
+ trail = boxes.map { |b| axis.trailing_edge(b) }.max
130
+ axis.set_position(box, (lead + trail) / 2.0)
131
+ axis.set_size(box, trail - lead)
132
+ container = container.parent
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -46,6 +46,7 @@ module Archsight
46
46
  # tied to its box size in ways that break if stretched to match
47
47
  # an unrelated, much larger sibling).
48
48
  return if target_size <= current
49
+ return if natural_size?(node)
49
50
  return unless node.representer.stretchable?
50
51
 
51
52
  axis.set_size(box, target_size)
@@ -109,6 +110,7 @@ module Archsight
109
110
  node.children.each do |c|
110
111
  expand(c, axis, inner, inherited: extend)
111
112
  axis.set_position(@boxes[c.id], center)
113
+ place_natural(c, axis, inner, center) if natural_size?(c)
112
114
  end
113
115
  end
114
116
  return
@@ -121,6 +123,17 @@ module Archsight
121
123
  expand(node.children.first, axis, own_target - inset(node, axis), inherited: extend)
122
124
  end
123
125
 
126
+ # A leaf with `extend "false"` keeps its own size inside a wider stack or layer.
127
+ def natural_size?(node) = node.leaf? && node.attrs["extend"] == "false"
128
+
129
+ # Puts such a leaf at its `anchor` (centered by default) inside the `room` long stretch of `axis`
130
+ # around `center` that its stack or layer gave it.
131
+ def place_natural(node, axis, room, center)
132
+ box = @boxes[node.id]
133
+ extra = [room - axis.size(box), 0.0].max
134
+ axis.set_position(box, center - (room / 2.0) + (axis.size(box) / 2.0) + node.anchor_shift(axis, extra, default: :center))
135
+ end
136
+
124
137
  # `extend "height"`: grows `node` to `target` height keeping its
125
138
  # content top-aligned (or at its `anchor`), then hands the extra room to whatever inside
126
139
  # can absorb it -- a `stack` child carrying `extend "height"`
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/text_metrics"
4
+
5
+ module Archsight
6
+ module Diagram
7
+ class Layout
8
+ # The size of a port's tab (see `Graph::Port`): its label in monospace at the edge-label font size, a
9
+ # little padding around it, and room for its badge dot.
10
+ class PortMetrics
11
+ PADDING_X = 6.0
12
+ PADDING_Y = 4.5
13
+ BADGE_ROOM = 10.0
14
+ # Never closer to a corner of the leaf than this, never closer to the next tab than `GAP`.
15
+ MARGIN = 10.0
16
+ GAP = 8.0
17
+
18
+ def initialize(theme)
19
+ @theme = theme
20
+ end
21
+
22
+ def font_size = @theme.edge_label_font_size
23
+ def height = font_size + (2 * PADDING_Y)
24
+
25
+ def width(port)
26
+ chars = TextMetrics.lines(port.label).map { |line| TextMetrics.markdown_runs(line).sum { |content, _| content.length } }.max
27
+ (chars * TextMetrics::MONO_WIDTH * font_size / 1000.0) + (2 * PADDING_X) + (port.badge ? BADGE_ROOM : 0.0)
28
+ end
29
+
30
+ # The length a side needs to hold `ports` side by side (`along` is the tab's size along that side).
31
+ def side_length(ports, &)
32
+ return 0.0 if ports.empty?
33
+
34
+ ports.sum(&) + (GAP * (ports.length - 1)) + (2 * MARGIN)
35
+ end
36
+
37
+ # `[min width, min height]` of a leaf with `ports`. A port without a `side` may end up on either
38
+ # side of the leaf, so it counts for the top and bottom alike (where most ports go).
39
+ def leaf_minimum(ports)
40
+ on = ->(side) { ports.select { |p| p.side == side || (p.side.nil? && %w[top bottom].include?(side)) } }
41
+ tab_height = height
42
+ [%w[top bottom].map { |side| side_length(on.call(side)) { |p| width(p) } }.max,
43
+ %w[left right].map { |side| side_length(on.call(side)) { tab_height } }.max]
44
+ end
45
+ end
46
+
47
+ # Puts every port's tab on its leaf's border, once the leaves have their final position: on its `side`
48
+ # (or the side facing what its edges connect it to), at its `at` (or lined up with what its edges
49
+ # connect it to, when that does not span the whole side; else spread in declaration order), the tabs of
50
+ # one side never overlapping. A tab straddles the border, half inside the leaf. Two rounds, so ports
51
+ # whose edges connect them to other ports settle on each other's place.
52
+ class PortPlacement
53
+ HORIZONTAL_SIDES = %i[top bottom].freeze
54
+
55
+ def initialize(graph, boxes, theme:)
56
+ @graph = graph
57
+ @boxes = boxes
58
+ @metrics = PortMetrics.new(theme)
59
+ end
60
+
61
+ # `{ "node.port" => Box }`
62
+ def place
63
+ ports = @graph.ports
64
+ return {} if ports.empty?
65
+
66
+ tabs = {}
67
+ 2.times { tabs = place_all(ports, tabs) }
68
+ tabs.each_value(&:freeze_bounds!)
69
+ tabs
70
+ end
71
+
72
+ private
73
+
74
+ def place_all(ports, previous)
75
+ sides = Hash.new { |h, k| h[k] = [] }
76
+ ports.each_with_index do |port, index|
77
+ box = @boxes[port.node.id]
78
+ peers = peer_boxes(port, previous)
79
+ side = (port.side || facing_side(box, peers)).to_sym
80
+ width = @metrics.width(port)
81
+ along = HORIZONTAL_SIDES.include?(side) ? width : @metrics.height
82
+ wanted = port.at ? at_position(box, side, port.at, along) : preference(box, side, peers)
83
+ sides[[port.node.id, side]] << { port: port, along: along, width: width, wanted: wanted, index: index }
84
+ end
85
+
86
+ sides.each_with_object({}) do |((node_id, side), members), tabs|
87
+ box = @boxes[node_id]
88
+ spread(box, side, members).each { |m| tabs[m[:port].ref] = tab_box(box, side, m[:position], m[:width]) }
89
+ end
90
+ end
91
+
92
+ # The boxes at the other ends of `port`'s edges: the peer's tab once it is placed, else its leaf.
93
+ def peer_boxes(port, previous)
94
+ @graph.edges.filter_map do |edge|
95
+ if edge.from_port.equal?(port)
96
+ (edge.to_port && previous[edge.to_port.ref]) || @boxes[edge.to.id]
97
+ elsif edge.to_port.equal?(port)
98
+ (edge.from_port && previous[edge.from_port.ref]) || @boxes[edge.from.id]
99
+ end
100
+ end
101
+ end
102
+
103
+ # The side whose outside the peers' mean center lies furthest beyond (top without peers).
104
+ def facing_side(box, peers)
105
+ return "top" if peers.empty?
106
+
107
+ dx = (peers.sum(&:x) / peers.length) - box.x
108
+ dy = (peers.sum(&:y) / peers.length) - box.y
109
+ if dy.abs - (box.height / 2.0) >= dx.abs - (box.width / 2.0)
110
+ dy.negative? ? "top" : "bottom"
111
+ else
112
+ dx.negative? ? "left" : "right"
113
+ end
114
+ end
115
+
116
+ # Where along its side a port lines up with its peers: the mean of their centers, leaving out a peer
117
+ # that spans the whole side (it lines up anywhere). `nil` when no peer has a preference.
118
+ def preference(box, side, peers)
119
+ lo, hi = side_range(box, side)
120
+ centers = peers.filter_map do |peer|
121
+ peer_lo, peer_hi, center = span(peer, side)
122
+ center unless peer_lo <= lo && peer_hi >= hi
123
+ end
124
+ centers.empty? ? nil : centers.sum / centers.length
125
+ end
126
+
127
+ def at_position(box, side, at, along)
128
+ lo, hi = side_range(box, side)
129
+ case at
130
+ when "left", "top" then lo + (along / 2.0)
131
+ when "right", "bottom" then hi - (along / 2.0)
132
+ when "center" then (lo + hi) / 2.0
133
+ else lo + (along / 2.0) + ((hi - lo - along) * at.to_f / 100.0)
134
+ end
135
+ end
136
+
137
+ # Ports without a preference share the side evenly in declaration order, then every port is pushed
138
+ # just far enough for the tabs not to overlap and to stay inside the side.
139
+ def spread(box, side, members)
140
+ lo, hi = side_range(box, side)
141
+ free = members.select { |m| m[:wanted].nil? }
142
+ free.each_with_index { |m, i| m[:wanted] = lo + ((hi - lo) * (i + 0.5) / free.length) }
143
+
144
+ ordered = members.sort_by { |m| [m[:wanted], m[:index]] }
145
+ ordered.each { |m| m[:position] = m[:wanted].clamp(lo + half(m), [lo + half(m), hi - half(m)].max) }
146
+ ordered.each_cons(2) { |a, b| b[:position] = [b[:position], a[:position] + apart(a, b)].max }
147
+ last = ordered.last
148
+ last[:position] = [last[:position], hi - half(last)].min
149
+ ordered.reverse.each_cons(2) { |b, a| a[:position] = [a[:position], b[:position] - apart(a, b)].min }
150
+ ordered
151
+ end
152
+
153
+ def half(member) = member[:along] / 2.0
154
+
155
+ # How far apart the centers of two neighbouring tabs must be.
156
+ def apart(one, other) = half(one) + half(other) + PortMetrics::GAP
157
+
158
+ # The part of a side a tab may sit on: inside the leaf's corners.
159
+ def side_range(box, side)
160
+ if HORIZONTAL_SIDES.include?(side)
161
+ [box.left + PortMetrics::MARGIN, box.right - PortMetrics::MARGIN]
162
+ else
163
+ [box.top + PortMetrics::MARGIN, box.bottom - PortMetrics::MARGIN]
164
+ end
165
+ end
166
+
167
+ def span(peer, side)
168
+ HORIZONTAL_SIDES.include?(side) ? [peer.left, peer.right, peer.x] : [peer.top, peer.bottom, peer.y]
169
+ end
170
+
171
+ def tab_box(box, side, position, width)
172
+ case side
173
+ when :top then Box.new(position, box.top, width, @metrics.height)
174
+ when :bottom then Box.new(position, box.bottom, width, @metrics.height)
175
+ when :left then Box.new(box.left, position, width, @metrics.height)
176
+ else Box.new(box.right, position, width, @metrics.height)
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -101,6 +101,30 @@ module Archsight
101
101
  # up, not necessarily through the narrow strip between the ranks, so
102
102
  # widening the strip for it doesn't reliably help and just leaves an
103
103
  # oversized gap.
104
+ # How much further apart two ranks go because port tabs stick out of the sides they face (half a tab
105
+ # each), keyed like `Stacker#pack`'s gaps.
106
+ def port_overhangs(children)
107
+ metrics = PortMetrics.new(@theme)
108
+ children.each_cons(2).with_object({}) do |(a, b), gaps|
109
+ extra = overhang(a, "bottom", metrics) + overhang(b, "top", metrics)
110
+ gaps[[a.id, b.id].sort] = extra if extra.positive?
111
+ end
112
+ end
113
+
114
+ # Half a tab if a port sits on `side` of `node`, or of a leaf an anonymous layer/stack puts on that side
115
+ # (a named container's padding already leaves room). A port without a `side` may end up on any side.
116
+ def overhang(node, side, metrics)
117
+ if node.leaf?
118
+ node.ports.any? { |p| p.side.nil? || p.side == side } ? metrics.height / 2.0 : 0.0
119
+ elsif !node.anonymous? || node.children.empty?
120
+ 0.0
121
+ elsif node.stack?
122
+ overhang(side == "top" ? node.children.first : node.children.last, side, metrics)
123
+ else
124
+ node.children.map { |c| overhang(c, side, metrics) }.max
125
+ end
126
+ end
127
+
104
128
  def arrange_stack(group, children)
105
129
  return if children.empty?
106
130
 
@@ -114,8 +138,10 @@ module Archsight
114
138
  children, axis: Axis::HEIGHT, default_gap: group.gap(@theme.stack_gap),
115
139
  related_gap: group.gap(@theme.sibling_gap),
116
140
  label_gaps: @rank_topology.rank_label_gaps(rank_edges),
117
- related_pairs: rank_edges.map { |(ra, rb, _e)| [ra.id, rb.id].sort }
141
+ related_pairs: rank_edges.map { |(ra, rb, _e)| [ra.id, rb.id].sort },
142
+ extra_gaps: port_overhangs(children)
118
143
  )
144
+ children.each { |c| expander.place_natural(c, Axis::WIDTH, max_width, max_width / 2.0) if expander.natural_size?(c) }
119
145
  end
120
146
  end
121
147
  end
@@ -28,7 +28,8 @@ module Archsight
28
28
  # `related_pairs`, otherwise `default_gap`; a label gap is only a
29
29
  # minimum on top of that (room for the label), so it can widen the
30
30
  # gap but never shrink a larger one -- e.g. an explicit `gap "150"`.
31
- def pack(children, axis:, default_gap:, related_gap:, label_gaps: {}, related_pairs: [])
31
+ # `extra_gaps` (by sorted id pair) is added on top of the pair's gap, e.g. for port tabs sticking out.
32
+ def pack(children, axis:, default_gap:, related_gap:, label_gaps: {}, related_pairs: [], extra_gaps: {})
32
33
  return if children.empty?
33
34
 
34
35
  cross = axis.cross
@@ -40,7 +41,7 @@ module Archsight
40
41
  unless i.zero?
41
42
  pair = [children[i - 1].id, c.id].sort
42
43
  base = related_pairs.include?(pair) ? related_gap : default_gap
43
- pos += [label_gaps[pair], base].compact.max
44
+ pos += [label_gaps[pair], base].compact.max + extra_gaps.fetch(pair, 0.0)
44
45
  end
45
46
 
46
47
  size = axis.size(box)
@@ -16,6 +16,8 @@ require_relative "layout/ranked_arranger"
16
16
  require_relative "layout/rank_arranger"
17
17
  require_relative "layout/root_alignment"
18
18
  require_relative "layout/globalizer"
19
+ require_relative "layout/alignment"
20
+ require_relative "layout/ports"
19
21
  require_relative "legend"
20
22
 
21
23
  module Archsight
@@ -74,8 +76,9 @@ module Archsight
74
76
  # font sizes/title offsets the boxes were sized for; `legend` (a
75
77
  # `Legend::Placement`, or nil for none) is where the legend went --
76
78
  # its boxes are in `boxes` like any node's.
77
- Result = Struct.new(:boxes, :width, :height, :theme, :legend, keyword_init: true) do
78
- def initialize(theme: Theme::DEFAULT, **) = super
79
+ # `ports` holds each port's tab (`{ "node.port" => Box }`, see `PortPlacement`).
80
+ Result = Struct.new(:boxes, :width, :height, :theme, :legend, :ports, keyword_init: true) do
81
+ def initialize(theme: Theme::DEFAULT, ports: {}, **) = super
79
82
  end
80
83
 
81
84
  # Pairs a Graph::Node with its computed Box, for the routing stages
@@ -129,10 +132,11 @@ module Archsight
129
132
  @root_alignment.align_connected_roots
130
133
  end
131
134
  @globalizer.globalize_all(@graph.roots)
135
+ Alignment.new(@graph, @boxes, theme: @theme).apply
132
136
  legend = place_legend
133
137
  normalize_to_canvas
134
138
  @boxes.each_value(&:freeze_bounds!)
135
- Result.new(boxes: @boxes, width: @canvas_width, height: @canvas_height, theme: @theme, legend: legend)
139
+ Result.new(boxes: @boxes, width: @canvas_width, height: @canvas_height, theme: @theme, legend: legend, ports: place_ports)
136
140
  end
137
141
 
138
142
  # See `Layout.attach_legend`: the canvas only ever grows to take the
@@ -145,11 +149,13 @@ module Archsight
145
149
  frame = @boxes[legend.frame.id]
146
150
  width = [result.width, frame.right + @theme.canvas_margin].max
147
151
  height = [result.height, frame.bottom + @theme.canvas_margin].max
148
- Result.new(boxes: @boxes, width: width, height: height, theme: @theme, legend: legend)
152
+ Result.new(boxes: @boxes, width: width, height: height, theme: @theme, legend: legend, ports: result.ports)
149
153
  end
150
154
 
151
155
  private
152
156
 
157
+ def place_ports = PortPlacement.new(@graph, @boxes, theme: @theme).place
158
+
153
159
  # Bottom-up: determine width/height for every node, and (for groups)
154
160
  # position children relative to the group's own local origin (0, 0).
155
161
  def size_and_place(node)
@@ -177,7 +183,11 @@ module Archsight
177
183
  return text_size(node, extra_height) if node.kind == :text
178
184
 
179
185
  width = [label_width(node) + @theme.node_label_padding, @theme.node_min_width].max
180
- [width, @theme.node_height + extra_height]
186
+ height = @theme.node_height + extra_height
187
+ return [width, height] if node.ports.empty?
188
+
189
+ min_width, min_height = PortMetrics.new(@theme).leaf_minimum(node.ports)
190
+ [[width, min_width].max, [height, min_height].max]
181
191
  end
182
192
 
183
193
  # A text node has no box to pad out: one line height per line and a little air, and no minimum width, so a
@@ -10,6 +10,7 @@ module Archsight
10
10
  # decides *how* those rows look once assembled. `relations` are the
11
11
  # relations actually drawn (see `Legend.rows`).
12
12
  class Inventory
13
+ Attributes = Graph::Attributes
13
14
  def initialize(graph, relations:)
14
15
  @graph = graph
15
16
  @relations = relations
@@ -18,7 +19,7 @@ module Archsight
18
19
  def rows
19
20
  return @rows if defined?(@rows)
20
21
 
21
- @rows = trivial? ? [] : shape_rows + relation_rows + dataflow_rows + swatch_rows
22
+ @rows = trivial? ? [] : shape_rows + port_rows + relation_rows + line_rows + dataflow_rows + swatch_rows + badge_rows
22
23
  end
23
24
 
24
25
  private
@@ -28,7 +29,29 @@ module Archsight
28
29
  def trivial?
29
30
  (used_shapes - ["rectangle"]).empty? && @relations.all? { |r| Relations.for(r).default? } &&
30
31
  !boundary_present? && !application_present? && @graph.dataflows.empty? && used_tints.empty? &&
31
- tinted_containers.empty?
32
+ tinted_containers.empty? && @graph.ports.empty? && used_badges.empty? && used_lines.empty?
33
+ end
34
+
35
+ def port_rows = @graph.ports.empty? ? [] : [PortRow.new("Port")]
36
+
37
+ # A row per line style that is drawn and has its legend text from a top-level `line "..." { label }`.
38
+ def line_rows = used_lines.map { |d| LineRow.new(d.label, line_style: d.id) }
39
+
40
+ def badge_rows = used_badges.map { |d| BadgeRow.new(d.label, tint: d.tint, badge: d.id) }
41
+
42
+ def used_lines
43
+ @used_lines ||= Attributes::LINE_STYLES.filter_map do |style|
44
+ definition = @graph.line_definition(style)
45
+ definition if definition && @graph.edges.any? { |e| e.line_style == style && @relations.include?(e.relation) }
46
+ end
47
+ end
48
+
49
+ # Badges in the order they are defined.
50
+ def used_badges
51
+ @used_badges ||= begin
52
+ ids = (@graph.nodes_by_id.values.filter_map(&:badge) + @graph.ports.filter_map(&:badge)).uniq
53
+ @graph.definitions.values.select { |d| d.kind == :badge && ids.include?(d.id) }
54
+ end
32
55
  end
33
56
 
34
57
  def shape_rows
@@ -62,9 +85,34 @@ module Archsight
62
85
  rows << SwatchRow.new("Trust boundary", tint: Tints.for("red"), dash_class: "asd-swatch-boundary") if default_boundary_present?
63
86
  tinted_containers.each do |n|
64
87
  dash_class = n.boundary? ? "asd-swatch-boundary" : "asd-swatch-container"
65
- rows << SwatchRow.new(MarkdownText.like(n.label, "#{n.kind.to_s.capitalize} #{n.label}"), tint: Tints.for(n.effective_tint), dash_class: dash_class)
88
+ label = n.legend_label || MarkdownText.like(n.label, "#{n.kind.to_s.capitalize} #{n.label}")
89
+ rows << SwatchRow.new(label, tint: Tints.for(n.effective_tint), dash_class: dash_class)
66
90
  end
67
- rows + used_tints.map { |t| SwatchRow.new(t.name.capitalize, tint: t) }
91
+ rows + tint_rows
92
+ end
93
+
94
+ # One row per tint set on a leaf, named by a `legend "..."` of one of those leaves, else by the leaves'
95
+ # own names -- not by the color, which says nothing. Left out when every such leaf sits inside a
96
+ # container of the same tint, whose own row already explains it.
97
+ def tint_rows
98
+ used_tints.filter_map do |tint|
99
+ leaves = tinted_leaves.select { |n| n.tint == tint.name }
100
+ next if leaves.all? { |n| inside_same_tinted_container?(n) }
101
+
102
+ label = leaves.filter_map(&:legend_label).first || leaves.map { |n| display_name(n) }.uniq.join(", ")
103
+ SwatchRow.new(label, tint: tint)
104
+ end
105
+ end
106
+
107
+ def tinted_leaves = @graph.nodes_by_id.values.reject { |n| n.container? || n.kind == :text || n.tint.nil? }
108
+
109
+ def inside_same_tinted_container?(node)
110
+ node.ancestors.drop(1).any? { |a| !a.anonymous? && a.tint == node.tint }
111
+ end
112
+
113
+ # A node's name as its label shows it: the first line, without markdown markers.
114
+ def display_name(node)
115
+ TextMetrics.markdown_runs(TextMetrics.lines(node.label).first).map(&:first).join.strip
68
116
  end
69
117
 
70
118
  def used_shapes
@@ -138,6 +138,50 @@ module Archsight
138
138
  m.to_s
139
139
  end
140
140
  end
141
+
142
+ # A port tab (see `NodeRenderer#render_ports`).
143
+ class PortRow < Row
144
+ def id_key = "port"
145
+
146
+ def icon(cx, cy, id:)
147
+ m = Renderer::Markup.new
148
+ m.element("rect", id: "#{id}__icon", x: SvgFormat.fmt(cx - 11), y: SvgFormat.fmt(cy - 6), width: 22, height: 12, rx: 3,
149
+ class: %w[asd-port asd-stroke-blue-0])
150
+ m.to_s
151
+ end
152
+ end
153
+
154
+ # A dashed/dotted edge line, explained by a top-level `line "dashed" { label "..." }`.
155
+ class LineRow < Row
156
+ def initialize(label, line_style:)
157
+ super(label)
158
+ @line_style = line_style
159
+ end
160
+
161
+ def id_key = "line-#{@line_style}"
162
+
163
+ def icon(cx, cy, id:)
164
+ legend_line_sample(cx, cy, css_class: ["asd-relation-dependency", "asd-stroke-thin", "asd-line-#{@line_style}"],
165
+ marker_id: Renderer::MarkerDefs.marker_id("dependency"), id: "#{id}__icon")
166
+ end
167
+ end
168
+
169
+ # A badge dot (`badge "id" { tint; label }`).
170
+ class BadgeRow < Row
171
+ def initialize(label, tint:, badge:)
172
+ super(label)
173
+ @tint = tint
174
+ @badge = badge
175
+ end
176
+
177
+ def id_key = "badge-#{Renderer::ElementIds.slug(@badge)}"
178
+
179
+ def icon(cx, cy, id:)
180
+ m = Renderer::Markup.new
181
+ m.element("circle", id: "#{id}__icon", cx: SvgFormat.fmt(cx), cy: SvgFormat.fmt(cy), r: 4, class: "asd-badge-#{@tint}")
182
+ m.to_s
183
+ end
184
+ end
141
185
  end
142
186
  end
143
187
  end
@@ -51,6 +51,15 @@ module Archsight
51
51
  # A top-level diagram-wide `key "value"` setting -- currently only
52
52
  # `theme "compact"` (see `Theme`).
53
53
  Setting = Struct.new(:key, :value, :line, keyword_init: true)
54
+
55
+ # `port "id" { label "..."; side "top"; at "left"; badge "..." }` inside a leaf: a labelled tab on
56
+ # the leaf's border that edges attach to (`a -> leaf.id`). Its id is local to the leaf.
57
+ Port = Struct.new(:id, :attrs, :line, keyword_init: true)
58
+
59
+ # A top-level legend definition: `badge "id" { tint "orange"; label "..." }` (a dot nodes and
60
+ # ports carry with `badge "id"`) or `line "dashed" { label "..." }` (the legend text of edges
61
+ # drawn with `line "dashed"`). `kind` is :badge or :line.
62
+ Definition = Struct.new(:kind, :id, :attrs, :line, keyword_init: true)
54
63
  end
55
64
  end
56
65
  end