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.
- checksums.yaml +4 -4
- data/docs/diagram.md +101 -4
- data/lib/archsight/diagram/graph/attributes.rb +59 -6
- data/lib/archsight/diagram/graph/builder.rb +101 -3
- data/lib/archsight/diagram/graph/edge.rb +7 -1
- data/lib/archsight/diagram/graph/node.rb +29 -1
- data/lib/archsight/diagram/graph/port.rb +25 -0
- data/lib/archsight/diagram/graph.rb +18 -1
- data/lib/archsight/diagram/includes.rb +22 -4
- data/lib/archsight/diagram/layout/alignment.rb +138 -0
- data/lib/archsight/diagram/layout/expander.rb +13 -0
- data/lib/archsight/diagram/layout/ports.rb +182 -0
- data/lib/archsight/diagram/layout/rank_arranger.rb +27 -1
- data/lib/archsight/diagram/layout/stacker.rb +3 -2
- data/lib/archsight/diagram/layout.rb +15 -5
- data/lib/archsight/diagram/legend/inventory.rb +52 -4
- data/lib/archsight/diagram/legend/row.rb +44 -0
- data/lib/archsight/diagram/parser/ast.rb +9 -0
- data/lib/archsight/diagram/parser.rb +56 -5
- data/lib/archsight/diagram/renderer/edge_renderer.rb +1 -1
- data/lib/archsight/diagram/renderer/node_renderer.rb +35 -1
- data/lib/archsight/diagram/renderer/path_renderer.rb +2 -2
- data/lib/archsight/diagram/renderer/stylesheet.rb +10 -1
- data/lib/archsight/diagram/renderer/text_renderer.rb +1 -0
- data/lib/archsight/diagram/renderer.rb +21 -5
- data/lib/archsight/diagram/routing/edge_routing.rb +106 -5
- data/lib/archsight/diagram/support/markdown_text.rb +2 -2
- data/lib/archsight/diagram/support/text_metrics.rb +7 -3
- data/lib/archsight/version.rb +1 -1
- metadata +4 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
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
|