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
|
@@ -14,12 +14,15 @@ module Archsight
|
|
|
14
14
|
#
|
|
15
15
|
# Grammar (informal):
|
|
16
16
|
# program := statement*
|
|
17
|
-
# statement := block | dataflow | edge | setting | include
|
|
17
|
+
# statement := block | dataflow | edge | setting | include | definition
|
|
18
|
+
# definition := ("badge" | "line") STRING "{" attr* "}" -- top level only
|
|
18
19
|
# include := "include" STRING ["{" "namespace" STRING "}"] [";"]
|
|
19
20
|
# -- top level, or where a container may hold blocks and edges
|
|
20
21
|
# setting := ("theme" | "legend" | "ranks") STRING [";"] -- top level only
|
|
21
|
-
# block := (CONTAINER_KEYWORD | LEAF_KEYWORD) [STRING] "{" (attr | block | edge | include)* "}"
|
|
22
|
-
# -- the id is required except for ANONYMOUS_KEYWORDS
|
|
22
|
+
# block := (CONTAINER_KEYWORD | LEAF_KEYWORD) [STRING] "{" (attr | block | edge | include | port)* "}"
|
|
23
|
+
# -- the id is required except for ANONYMOUS_KEYWORDS; a leaf holds
|
|
24
|
+
# only attrs and ports, a container no ports
|
|
25
|
+
# port := "port" STRING "{" attr* "}"
|
|
23
26
|
# dataflow := "dataflow" STRING "{" (hop | hop_group | attr)* "}"
|
|
24
27
|
# -- needs >= 2 hops (per branch, if a hop_group is present);
|
|
25
28
|
# at most one hop_group per dataflow
|
|
@@ -50,6 +53,7 @@ module Archsight
|
|
|
50
53
|
NAMESPACE = /\A[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*\z/
|
|
51
54
|
ANONYMOUS_KEYWORDS = %w[layer stack].freeze
|
|
52
55
|
SETTING_KEYWORDS = %w[theme legend ranks].freeze
|
|
56
|
+
DEFINITION_KEYWORDS = %w[badge line].freeze
|
|
53
57
|
EDGE_ARROWS = {
|
|
54
58
|
arrow: :directed,
|
|
55
59
|
biarrow: :bidirectional,
|
|
@@ -89,6 +93,8 @@ module Archsight
|
|
|
89
93
|
edge
|
|
90
94
|
elsif SETTING_KEYWORDS.any? { |k| cursor.check(:ident, k) } && cursor.peek_type(1) == :string
|
|
91
95
|
setting
|
|
96
|
+
elsif definition?
|
|
97
|
+
definition
|
|
92
98
|
else
|
|
93
99
|
t = cursor.current
|
|
94
100
|
raise ParseError, "expected #{BLOCK_KEYWORDS.map(&:inspect).join(", ")}, 'dataflow', or an edge " \
|
|
@@ -123,10 +129,14 @@ module Archsight
|
|
|
123
129
|
|
|
124
130
|
cursor.expect(:rbrace, "expected '}' to close #{label}")
|
|
125
131
|
|
|
126
|
-
|
|
132
|
+
leaf = LEAF_KEYWORDS.include?(kind_token.value)
|
|
133
|
+
if leaf && children.any? { |c| !c.is_a?(AST::Port) }
|
|
127
134
|
raise ParseError, "#{label} at line #{kind_token.line} cannot " \
|
|
128
135
|
"contain nested blocks, edges or includes (did you mean 'group'?)"
|
|
129
136
|
end
|
|
137
|
+
if !leaf && (port = children.find { |c| c.is_a?(AST::Port) })
|
|
138
|
+
raise ParseError, "port #{port.id.inspect} at line #{port.line}: only a leaf (component, database, ...) has ports, not #{label}"
|
|
139
|
+
end
|
|
130
140
|
|
|
131
141
|
raise ParseError, "#{label} at line #{kind_token.line}: columns only applies to containers" if LEAF_KEYWORDS.include?(kind_token.value) && attrs.key?("columns")
|
|
132
142
|
|
|
@@ -135,10 +145,12 @@ module Archsight
|
|
|
135
145
|
AST::Block.new(kind: kind, id: id, anonymous: anonymous, attrs: attrs, children: children, line: kind_token.line)
|
|
136
146
|
end
|
|
137
147
|
|
|
138
|
-
# The nested block, edge or
|
|
148
|
+
# The nested block, edge, include or port at the cursor, nil if it is something else (an attribute)
|
|
139
149
|
def container_child
|
|
140
150
|
if check_block_keyword?
|
|
141
151
|
block
|
|
152
|
+
elsif keyword_block?("port")
|
|
153
|
+
port
|
|
142
154
|
elsif include_directive?
|
|
143
155
|
include_statement
|
|
144
156
|
elsif cursor.check(:ident) && EDGE_ARROWS.key?(cursor.peek_type(1))
|
|
@@ -185,6 +197,45 @@ module Archsight
|
|
|
185
197
|
AST::Setting.new(key: key_token.value, value: value_token.value, line: key_token.line)
|
|
186
198
|
end
|
|
187
199
|
|
|
200
|
+
# `port "id" { ... }` -- `port "x"` without a block stays an (unknown) attribute, so it is reported as one
|
|
201
|
+
def port
|
|
202
|
+
keyword = cursor.advance
|
|
203
|
+
id = cursor.advance
|
|
204
|
+
label = "port #{id.value.inspect}"
|
|
205
|
+
raise ParseError, "port at line #{keyword.line} has an empty id" if id.value.strip.empty?
|
|
206
|
+
raise ParseError, "#{label} at line #{keyword.line}: a port id cannot contain '.'" if id.value.include?(".")
|
|
207
|
+
|
|
208
|
+
attrs = read_attr_block(label)
|
|
209
|
+
AST::Port.new(id: id.value, attrs: attrs, line: keyword.line)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def definition?
|
|
213
|
+
DEFINITION_KEYWORDS.any? { |k| keyword_block?(k) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# `badge "id" { ... }` / `line "dashed" { ... }` at the top level
|
|
217
|
+
def definition
|
|
218
|
+
keyword = cursor.advance
|
|
219
|
+
id = cursor.advance
|
|
220
|
+
attrs = read_attr_block("#{keyword.value} #{id.value.inspect}")
|
|
221
|
+
AST::Definition.new(kind: keyword.value.to_sym, id: id.value, attrs: attrs, line: keyword.line)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# `KEYWORD STRING {` -- the brace tells it apart from an attribute (`badge "x"`) or an edge (`line -> x`)
|
|
225
|
+
def keyword_block?(keyword)
|
|
226
|
+
cursor.check(:ident, keyword) && cursor.peek_type(1) == :string && cursor.peek_type(2) == :lbrace
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# `{ attr* }`, read into a fresh hash
|
|
230
|
+
def read_attr_block(label)
|
|
231
|
+
cursor.expect(:lbrace, "expected '{' to open #{label}")
|
|
232
|
+
attrs = {}
|
|
233
|
+
parse_attrs_until_rbrace(attrs, label, expected: "an attribute") { false }
|
|
234
|
+
cursor.expect(:rbrace, "expected '}' to close #{label}")
|
|
235
|
+
cursor.consume(:semi)
|
|
236
|
+
attrs
|
|
237
|
+
end
|
|
238
|
+
|
|
188
239
|
def next_anonymous_id
|
|
189
240
|
@anon_counter += 1
|
|
190
241
|
"__asd_anon_#{@anon_scope}#{@anon_counter}"
|
|
@@ -29,7 +29,7 @@ module Archsight
|
|
|
29
29
|
@path_renderer.draw_relation_path(body, points, edge.relation,
|
|
30
30
|
marker_end: edge.direction != :undirected,
|
|
31
31
|
marker_start: edge.direction == :bidirectional,
|
|
32
|
-
curve: curve, color: color, id: @ids.part(edge, "line"))
|
|
32
|
+
curve: curve, color: color, id: @ids.part(edge, "line"), line_style: edge.line_style)
|
|
33
33
|
@path_renderer.draw_hit_path(body, points, curve: curve, id: @ids.part(edge, "hit"))
|
|
34
34
|
end
|
|
35
35
|
|
|
@@ -15,8 +15,12 @@ module Archsight
|
|
|
15
15
|
# `Stylesheet#edge_hover_css`), so its hover target -- a leaf's own
|
|
16
16
|
# `<g>`, a container's frame -- and its label are marked for the
|
|
17
17
|
# stylesheet's dim-everything-else rule.
|
|
18
|
-
|
|
18
|
+
# `ports` are the port tabs (`Layout::Result#ports`), `graph` names the badges' tints.
|
|
19
|
+
def initialize(boxes, text_renderer, ids:, theme:, hover_sources: Set.new, ports: {}, graph: nil)
|
|
19
20
|
@boxes = boxes
|
|
21
|
+
@ports = ports
|
|
22
|
+
@graph = graph
|
|
23
|
+
@port_metrics = Layout::PortMetrics.new(theme)
|
|
20
24
|
@text_renderer = text_renderer
|
|
21
25
|
@ids = ids
|
|
22
26
|
@hover_sources = hover_sources
|
|
@@ -47,6 +51,9 @@ module Archsight
|
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
HOVER_SOURCE_CLASS = "asd-hover-source"
|
|
54
|
+
# How far a node's badge dot sits from its top-right corner, and its radius.
|
|
55
|
+
BADGE_INSET = 9.0
|
|
56
|
+
BADGE_RADIUS = 4.0
|
|
50
57
|
# A node whose `resource` reference didn't resolve (see `ResourceLinks`)
|
|
51
58
|
BROKEN_LINK_CLASS = "asd-broken-link"
|
|
52
59
|
|
|
@@ -104,6 +111,33 @@ module Archsight
|
|
|
104
111
|
body.raw(@text_renderer.label_text(representer.label_x(box), representer.label_y(box, @theme), node.label,
|
|
105
112
|
font_size: representer.label_font_size(@theme), link: node.link,
|
|
106
113
|
attrs: @ids.label_attrs(node), extra_class: label_class(node)))
|
|
114
|
+
badge_dot(body, box.right - BADGE_INSET, box.top + BADGE_INSET, node.badge, id: @ids.part(node, "badge")) if node.badge
|
|
115
|
+
render_ports(body, node, style[:stroke_class])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Each port as a small white tab straddling the border, its label in monospace, its badge at its right end.
|
|
119
|
+
def render_ports(body, node, stroke_class)
|
|
120
|
+
node.ports.each do |port|
|
|
121
|
+
tab = @ports[port.ref]
|
|
122
|
+
next unless tab
|
|
123
|
+
|
|
124
|
+
name = "port-#{port.id.gsub(/[^A-Za-z0-9_-]/, "_")}"
|
|
125
|
+
body.element("rect", id: @ids.part(node, name), x: fmt(tab.left), y: fmt(tab.top), width: fmt(tab.width), height: fmt(tab.height),
|
|
126
|
+
rx: 4, class: ["asd-port", stroke_class], "data-asd-port": port.id)
|
|
127
|
+
room = port.badge ? Layout::PortMetrics::BADGE_ROOM : 0.0
|
|
128
|
+
body.raw(@text_renderer.label_text(tab.x - (room / 2.0), tab.y, port.label, font_size: @port_metrics.font_size,
|
|
129
|
+
attrs: { id: @ids.part(node, "#{name}-label"), "data-asd-owner": @ids.id(node), "data-asd-line": port.line },
|
|
130
|
+
extra_class: "asd-port-text asd-run-code"))
|
|
131
|
+
badge_dot(body, tab.right - Layout::PortMetrics::PADDING_X - 1.0, tab.y, port.badge, id: @ids.part(node, "#{name}-badge")) if port.badge
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def badge_dot(body, cx, cy, badge, id:)
|
|
136
|
+
definition = @graph&.badge_definition(badge)
|
|
137
|
+
tint = definition ? definition.tint : "gray"
|
|
138
|
+
body.element("circle", id: id, cx: fmt(cx), cy: fmt(cy), r: fmt(BADGE_RADIUS), class: "asd-badge-#{tint}") do
|
|
139
|
+
body.raw(Markup.tag("title", escape(definition.label))) if definition
|
|
140
|
+
end
|
|
107
141
|
end
|
|
108
142
|
|
|
109
143
|
# A `text` has no shape for the broken-link styling to dash, so its label is dimmed and dashed itself.
|
|
@@ -18,13 +18,13 @@ module Archsight
|
|
|
18
18
|
# replaces the default `asd-stroke-thin` width when the caller
|
|
19
19
|
# needs its own (only a dataflow's thicker, hover-animated line does).
|
|
20
20
|
def draw_relation_path(body, points, relation, marker_end: false, marker_start: false, color: nil, curve: false,
|
|
21
|
-
css_class: nil, id: nil)
|
|
21
|
+
css_class: nil, id: nil, line_style: "solid")
|
|
22
22
|
path_d = PathGeometry.path_d_string(points, curve: curve)
|
|
23
23
|
marker_id = "url(##{MarkerDefs.marker_id(relation, color: color)})"
|
|
24
24
|
|
|
25
25
|
body.element("path", id: id, d: path_d,
|
|
26
26
|
class: ["asd-line", "asd-relation-#{relation}", css_class || "asd-stroke-thin",
|
|
27
|
-
(MarkerDefs.color_class(color) if color)],
|
|
27
|
+
(MarkerDefs.color_class(color) if color), ("asd-line-#{line_style}" unless line_style == "solid")],
|
|
28
28
|
"marker-end": (marker_id if marker_end),
|
|
29
29
|
"marker-start": (marker_id if marker_start))
|
|
30
30
|
end
|
|
@@ -55,7 +55,7 @@ module Archsight
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def style_block
|
|
58
|
-
css = [text_css, line_css, relation_css, shape_css, tint_css, legend_css, line_color_css].join("\n")
|
|
58
|
+
css = [text_css, line_css, relation_css, shape_css, tint_css, port_css, legend_css, line_color_css].join("\n")
|
|
59
59
|
wrap(css)
|
|
60
60
|
end
|
|
61
61
|
|
|
@@ -233,6 +233,7 @@ module Archsight
|
|
|
233
233
|
.asd-underline { text-decoration: underline; }
|
|
234
234
|
.asd-run-bold { font-weight: bold; }
|
|
235
235
|
.asd-run-italic { font-style: italic; }
|
|
236
|
+
.asd-run-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "DejaVu Sans Mono", monospace; }
|
|
236
237
|
.asd-run-underline { text-decoration: underline; }
|
|
237
238
|
.asd-broken-text { opacity: 0.6; text-decoration: underline dashed; }
|
|
238
239
|
.asd-text-hit { fill: transparent; stroke: none; }
|
|
@@ -244,6 +245,8 @@ module Archsight
|
|
|
244
245
|
.asd-line { fill: none; }
|
|
245
246
|
.asd-stroke-thin { stroke-width: #{EDGE_STROKE_WIDTH}; }
|
|
246
247
|
.asd-stroke-application { stroke-width: 2.5; }
|
|
248
|
+
.asd-line.asd-line-dashed { stroke-dasharray: 6 4; }
|
|
249
|
+
.asd-line.asd-line-dotted { stroke-dasharray: 1.5 3; stroke-linecap: round; }
|
|
247
250
|
.asd-dataflow-line { stroke-width: #{DATAFLOW_STROKE_WIDTH}; transition: stroke-width 0.15s ease; }
|
|
248
251
|
.asd-dataflow-halo { fill: none; stroke: #fff; stroke-width: #{DataflowRenderer::DATAFLOW_HALO_WIDTH}; stroke-linecap: round; }
|
|
249
252
|
CSS
|
|
@@ -294,6 +297,12 @@ module Archsight
|
|
|
294
297
|
end.join("\n")
|
|
295
298
|
end
|
|
296
299
|
|
|
300
|
+
# Port tabs (see `NodeRenderer#render_ports`) and badge dots, one fill rule per tint.
|
|
301
|
+
def port_css
|
|
302
|
+
badges = Tints.names.map { |name| ".asd-badge-#{name} { fill: #{Tints.for(name).border(0)}; }" }
|
|
303
|
+
[".asd-port { fill: #fff; stroke-width: 1.1; }", *badges].join("\n")
|
|
304
|
+
end
|
|
305
|
+
|
|
297
306
|
def legend_css
|
|
298
307
|
<<~CSS.chomp
|
|
299
308
|
.asd-legend-bg { fill: #fafbfc; stroke-width: 1; }
|
|
@@ -123,6 +123,7 @@ module Archsight
|
|
|
123
123
|
classes << "asd-run-bold" if style[:bold]
|
|
124
124
|
classes << "asd-run-italic" if style[:italic]
|
|
125
125
|
classes << "asd-run-underline" if style[:underline] || style[:link]
|
|
126
|
+
classes << "asd-run-code" if style[:code]
|
|
126
127
|
|
|
127
128
|
span = Markup.tag("tspan", escape(content), class: classes)
|
|
128
129
|
return span if style[:link].nil? || suppress_inline_links
|
|
@@ -74,16 +74,20 @@ module Archsight
|
|
|
74
74
|
# The legend's frame is as solid as a node box, as far as a label is
|
|
75
75
|
# concerned.
|
|
76
76
|
legend_boxes = @layout.legend ? @boxes.slice(@layout.legend.frame.id) : {}
|
|
77
|
-
|
|
77
|
+
ports = layout.ports || {}
|
|
78
|
+
port_boxes = ports.transform_keys { |ref| "port:#{ref}" }
|
|
79
|
+
@label_placer = LabelPlacer.new(@boxes.slice(*nodes.select(&:leaf?).map(&:id)).merge(legend_boxes, port_boxes, title_boxes(nodes, theme)),
|
|
78
80
|
containers: @boxes.slice(*nodes.reject { |n| n.leaf? || n.anonymous? }.map(&:id)))
|
|
79
81
|
@node_renderer = NodeRenderer.new(@boxes, @text_renderer, ids: @ids, theme: theme,
|
|
80
|
-
hover_sources: drawn_edges.to_set { |e| e.from.id }
|
|
82
|
+
hover_sources: drawn_edges.to_set { |e| e.from.id },
|
|
83
|
+
ports: ports, graph: @graph)
|
|
81
84
|
@implements_tree_renderer = ImplementsTreeRenderer.new(@boxes, path_renderer: @path_renderer, text_renderer: @text_renderer,
|
|
82
85
|
ids: @ids, theme: theme)
|
|
83
86
|
# Both routing stages avoid the same boxes, so they share one map
|
|
84
87
|
# (and, with the native kernels, one packed box table).
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
# Port tabs are obstacles too, so other lines don't end on top of one.
|
|
89
|
+
obstacle_map = ObstacleMap.new(@boxes.merge(port_boxes), ignoring: nodes.select { |n| n.anonymous? && !n.leaf? }.map(&:id))
|
|
90
|
+
@edge_routing = EdgeRouting.new(@graph, @boxes, obstacle_map: obstacle_map, ports: ports)
|
|
87
91
|
@edge_renderer = EdgeRenderer.new(path_renderer: @path_renderer, text_renderer: @text_renderer, label_placer: @label_placer,
|
|
88
92
|
ids: @ids, theme: theme)
|
|
89
93
|
@dataflow_routing = DataflowRouting.new(@graph, @boxes, obstacle_map: obstacle_map)
|
|
@@ -230,7 +234,7 @@ module Archsight
|
|
|
230
234
|
# default `hash`/`==`.
|
|
231
235
|
@implements_tree_groups ||=
|
|
232
236
|
drawn_edges
|
|
233
|
-
.select { |e| e.relation_type.tree_grouped? && e.direction == :directed && !e.attrs.key?("style") && e.tint.nil? }
|
|
237
|
+
.select { |e| e.relation_type.tree_grouped? && e.direction == :directed && !e.attrs.key?("style") && e.tint.nil? && !e.attrs.key?("line") && !e.to_port && !e.from_port }
|
|
234
238
|
.group_by { |e| e.to.id }
|
|
235
239
|
.filter_map { |_id, edges| [edges.first.to, edges] if edges.length > 1 }
|
|
236
240
|
end
|
|
@@ -246,6 +250,18 @@ module Archsight
|
|
|
246
250
|
@tree_grouped_edges ||= implements_tree_groups.flat_map { |_target, edges| edges }
|
|
247
251
|
end
|
|
248
252
|
|
|
253
|
+
# Where each named container's title is written (see `NodeRenderer#render_container`), so edge labels
|
|
254
|
+
# keep off it like off a node.
|
|
255
|
+
def title_boxes(nodes, theme)
|
|
256
|
+
nodes.select { |n| n.container? && !n.anonymous? && @boxes[n.id] }.to_h do |n|
|
|
257
|
+
box = @boxes[n.id]
|
|
258
|
+
size = n.boundary? ? theme.boundary_title_font_size : theme.group_title_font_size
|
|
259
|
+
width = TextMetrics.rendered_width(MarkdownText.new("**#{n.label}**"), font_size: size)
|
|
260
|
+
height = n.layout_title_height(theme)
|
|
261
|
+
["title:#{n.id}", Layout::Box.new(box.left + theme.title_inset_x + (width / 2.0), box.top + (height / 2.0), width, height)]
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
249
265
|
# --- edges -------------------------------------------------------------
|
|
250
266
|
|
|
251
267
|
def individually_routed_edges
|
|
@@ -76,11 +76,18 @@ module Archsight
|
|
|
76
76
|
# instead (see `port_order`).
|
|
77
77
|
PORT_ORDER_TOLERANCE = PORT_MIN_GAP / 2.0
|
|
78
78
|
|
|
79
|
+
# A port end is routed to its tab, a box of its own (always a rectangle).
|
|
80
|
+
PortEnd = Struct.new(:box) do
|
|
81
|
+
def shape = "rectangle"
|
|
82
|
+
end
|
|
83
|
+
|
|
79
84
|
# `obstacle_map` can be shared with `DataflowRouting` over the same boxes.
|
|
80
|
-
|
|
85
|
+
# `ports` are the port tabs (`Layout::Result#ports`) an edge may attach to instead of its node.
|
|
86
|
+
def initialize(graph, boxes, obstacle_map: ObstacleMap.new(boxes), ports: {})
|
|
81
87
|
@graph = graph
|
|
82
88
|
@boxes = boxes
|
|
83
89
|
@obstacle_map = obstacle_map
|
|
90
|
+
@ports = ports
|
|
84
91
|
end
|
|
85
92
|
|
|
86
93
|
# Every edge's route, computed up front (rather than immediately
|
|
@@ -89,11 +96,13 @@ module Archsight
|
|
|
89
96
|
# split into multiple attachment points.
|
|
90
97
|
def compute_paths(edges)
|
|
91
98
|
edges.map do |edge|
|
|
92
|
-
from = positioned(edge.from)
|
|
93
|
-
to = positioned(edge.to)
|
|
99
|
+
from = positioned(edge.from, edge.from_port)
|
|
100
|
+
to = positioned(edge.to, edge.to_port)
|
|
94
101
|
obstacles = @obstacle_map.excluding(edge.from, edge.to)
|
|
95
102
|
candidates = EdgeRouter.candidate_paths(from.box, to.box, edge.attrs["style"],
|
|
96
103
|
from_shape: from.shape, to_shape: to.shape, obstacles: obstacles)
|
|
104
|
+
candidates = tab_free_lanes(edge, from, to) + candidates
|
|
105
|
+
candidates = through_ports(candidates, edge) if edge.from_port || edge.to_port
|
|
97
106
|
scored = EdgeRouter.score_candidates(candidates, obstacles)
|
|
98
107
|
points = EdgeRouter.select_best(scored, sibling_paths: [])
|
|
99
108
|
RoutedEdge.new(edge: edge, points: points, from_box: from.box, to_box: to.box, obstacles: obstacles, scored: scored)
|
|
@@ -234,8 +243,100 @@ module Archsight
|
|
|
234
243
|
points.map { |point| point.dup.tap { |p| p[index] += delta } }
|
|
235
244
|
end
|
|
236
245
|
|
|
237
|
-
def positioned(node)
|
|
238
|
-
|
|
246
|
+
def positioned(node, port = nil)
|
|
247
|
+
tab = port && @ports[port.ref]
|
|
248
|
+
tab ? PortEnd.new(tab) : Layout::PositionedNode.new(node, @boxes[node.id])
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Only lines that leave a tab on its outer side (away from its leaf) and never run through the leaf
|
|
252
|
+
# itself, which is not an obstacle to its own edges. If no candidate does, all of them are kept.
|
|
253
|
+
def through_ports(candidates, edge)
|
|
254
|
+
ends = [[edge.from_port, edge.from, :first], [edge.to_port, edge.to, :last]].select { |port, _, _| port && @ports[port.ref] }
|
|
255
|
+
kept = candidates.select do |points|
|
|
256
|
+
ends.all? do |port, node, at|
|
|
257
|
+
tab = @ports[port.ref]
|
|
258
|
+
outward?(tab, node, points.public_send(at)) && !EdgeRouter::PathMetrics.enters_interior?(points, @boxes[node.id])
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
kept.empty? ? candidates : kept
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# A straight line between two boxes one above the other (or side by side) normally runs at one box's
|
|
265
|
+
# center, which may be where a port tab sits on a side it attaches to (a tab is an obstacle). This
|
|
266
|
+
# offers the same line in the middle of the widest stretch the two boxes share that no tab on the facing
|
|
267
|
+
# sides covers -- only when such a tab is in the way, so other diagrams route as before.
|
|
268
|
+
def tab_free_lanes(edge, from, to)
|
|
269
|
+
a = from.box
|
|
270
|
+
b = to.box
|
|
271
|
+
return [] if [from, to].any? { |e| Representers.for(e.shape).elliptical? }
|
|
272
|
+
|
|
273
|
+
vertical = !a.overlaps_y?(b) && a.overlaps_x?(b)
|
|
274
|
+
horizontal = !a.overlaps_x?(b) && a.overlaps_y?(b)
|
|
275
|
+
return [] unless vertical || horizontal
|
|
276
|
+
|
|
277
|
+
axis = vertical ? %i[left right x] : %i[top bottom y]
|
|
278
|
+
margin = EdgeRouter::StraightPath::ALIGNED_CORNER_MARGIN
|
|
279
|
+
lo = [a.public_send(axis[0]), b.public_send(axis[0])].max + margin
|
|
280
|
+
hi = [a.public_send(axis[1]), b.public_send(axis[1])].min - margin
|
|
281
|
+
blocked = facing_tabs(edge, a, b, vertical).map { |t| [t.public_send(axis[0]) - margin, t.public_send(axis[1]) + margin] }
|
|
282
|
+
return [] if blocked.none? { |l, h| h > lo && l < hi }
|
|
283
|
+
|
|
284
|
+
lane = widest_free(lo, hi, blocked)
|
|
285
|
+
return [] unless lane
|
|
286
|
+
|
|
287
|
+
[lane_line(a, b, (lane[0] + lane[1]) / 2.0, vertical)]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The straight line at `c` between the facing sides of `a` and `b`.
|
|
291
|
+
def lane_line(a, b, c, vertical)
|
|
292
|
+
if vertical
|
|
293
|
+
down = a.y < b.y
|
|
294
|
+
[[c, down ? a.bottom : a.top], [c, down ? b.top : b.bottom]]
|
|
295
|
+
else
|
|
296
|
+
right = a.x < b.x
|
|
297
|
+
[[right ? a.right : a.left, c], [right ? b.left : b.right, c]]
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# The tabs on the sides of the edge's two leaves that face each other (a port end itself is its own box).
|
|
302
|
+
def facing_tabs(edge, a, b, vertical)
|
|
303
|
+
[[edge.from, edge.from_port, a, b], [edge.to, edge.to_port, b, a]].flat_map do |node, port, own, other|
|
|
304
|
+
next [] if port
|
|
305
|
+
|
|
306
|
+
side = if vertical
|
|
307
|
+
own.y < other.y ? :bottom : :top
|
|
308
|
+
else
|
|
309
|
+
own.x < other.x ? :right : :left
|
|
310
|
+
end
|
|
311
|
+
node.ports.filter_map { |p| @ports[p.ref] }.select { |tab| tab_on_side?(tab, @boxes[node.id], side) }
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def tab_on_side?(tab, box, side)
|
|
316
|
+
case side
|
|
317
|
+
when :top then EdgeRouter::PathMetrics.close?(tab.y, box.top)
|
|
318
|
+
when :bottom then EdgeRouter::PathMetrics.close?(tab.y, box.bottom)
|
|
319
|
+
when :left then EdgeRouter::PathMetrics.close?(tab.x, box.left)
|
|
320
|
+
else EdgeRouter::PathMetrics.close?(tab.x, box.right)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# The widest part of `lo..hi` outside every `blocked` interval, `nil` if none is left.
|
|
325
|
+
def widest_free(lo, hi, blocked)
|
|
326
|
+
cursor = lo
|
|
327
|
+
free = []
|
|
328
|
+
blocked.sort.each do |l, h|
|
|
329
|
+
free << [cursor, l] if l > cursor
|
|
330
|
+
cursor = [cursor, h].max
|
|
331
|
+
end
|
|
332
|
+
free << [cursor, hi] if hi > cursor
|
|
333
|
+
free.map { |l, h| [l, [h, hi].min] }.select { |l, h| h - l >= 1.0 }.max_by { |l, h| h - l }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Whether `point` (an end of a line) is on the side of `tab` that faces away from its leaf.
|
|
337
|
+
def outward?(tab, node, point)
|
|
338
|
+
side = tab.side_of(point)
|
|
339
|
+
!side.nil? && tab_on_side?(tab, @boxes[node.id], side)
|
|
239
340
|
end
|
|
240
341
|
|
|
241
342
|
# Returns whether any edge's `points` actually changed this round.
|
|
@@ -10,8 +10,8 @@ module Archsight
|
|
|
10
10
|
# plain run. Anything built from a label has to carry the flag along
|
|
11
11
|
# (`like`), or its markers render as literal text.
|
|
12
12
|
class MarkdownText < String
|
|
13
|
-
# Every marker (`**`, `__`, `*`, `[text](url)`) opens with one of these.
|
|
14
|
-
MARKER_START = /[*_
|
|
13
|
+
# Every marker (`**`, `__`, `*`, `` `code` ``, `[text](url)`) opens with one of these.
|
|
14
|
+
MARKER_START = /[*_`\[]/
|
|
15
15
|
|
|
16
16
|
# `text` as a `MarkdownText` if a marker could start in it, else as is.
|
|
17
17
|
def self.detect(text) = text.match?(MARKER_START) ? new(text) : text
|
|
@@ -56,6 +56,8 @@ module Archsight
|
|
|
56
56
|
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584
|
|
57
57
|
].freeze
|
|
58
58
|
FALLBACK_WIDTH = 700
|
|
59
|
+
# Every char of a monospace (`` `code` ``) run: the advance of Menlo, Consolas, DejaVu Sans Mono...
|
|
60
|
+
MONO_WIDTH = 602
|
|
59
61
|
|
|
60
62
|
# The rendered width, in px, of `text`'s widest line at `font_size`
|
|
61
63
|
# -- markdown-lite markers don't take up room, and `**bold**` runs
|
|
@@ -68,6 +70,8 @@ module Archsight
|
|
|
68
70
|
|
|
69
71
|
def line_units(line)
|
|
70
72
|
markdown_runs(line).sum do |content, style|
|
|
73
|
+
next content.length * MONO_WIDTH if style[:code]
|
|
74
|
+
|
|
71
75
|
table = style[:bold] ? HELVETICA_BOLD_WIDTHS : HELVETICA_WIDTHS
|
|
72
76
|
content.each_char.sum do |char|
|
|
73
77
|
index = char.ord - 32
|
|
@@ -79,7 +83,7 @@ module Archsight
|
|
|
79
83
|
# A small, deliberately non-nested markdown-lite: `**bold**`,
|
|
80
84
|
# `*italic*`, `__underline__` (a separate marker from bold's `**` so
|
|
81
85
|
# the two never collide, since real Markdown has no underline
|
|
82
|
-
# syntax), and `[text](url)` for an inline link. Scans left to
|
|
86
|
+
# syntax), `` `code` `` (monospace) and `[text](url)` for an inline link. Scans left to
|
|
83
87
|
# right, splitting `text` into `[content, style]` pairs -- `style`
|
|
84
88
|
# is `{}` for a plain run. Only a `MarkdownText` is scanned (the lexer
|
|
85
89
|
# flags every string literal a marker could start in); any other
|
|
@@ -109,7 +113,7 @@ module Archsight
|
|
|
109
113
|
end
|
|
110
114
|
|
|
111
115
|
# Checked in this order, so `**` wins over `*`.
|
|
112
|
-
PAIRED_MARKERS = { "**" => :bold, "__" => :underline, "*" => :italic }.freeze
|
|
116
|
+
PAIRED_MARKERS = { "`" => :code, "**" => :bold, "__" => :underline, "*" => :italic }.freeze
|
|
113
117
|
|
|
114
118
|
# The formatted `[content, style]` run starting at `text[i]`, plus the
|
|
115
119
|
# index just past it -- or nil when no (closed) marker starts there.
|
|
@@ -151,7 +155,7 @@ module Archsight
|
|
|
151
155
|
lines.empty? ? [line] : lines.map { |l| runs_to_markdown(source, l) }
|
|
152
156
|
end
|
|
153
157
|
|
|
154
|
-
MARKERS = { bold: "**", italic: "*", underline: "__" }.freeze
|
|
158
|
+
MARKERS = { bold: "**", italic: "*", underline: "__", code: "`" }.freeze
|
|
155
159
|
|
|
156
160
|
def runs_to_markdown(source, chars)
|
|
157
161
|
out = chars.chunk_while { |a, b| a[1] == b[1] }.map do |group|
|
data/lib/archsight/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: archsight
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vincent Landgraf
|
|
@@ -281,12 +281,15 @@ files:
|
|
|
281
281
|
- lib/archsight/diagram/graph/edge.rb
|
|
282
282
|
- lib/archsight/diagram/graph/gap_spec.rb
|
|
283
283
|
- lib/archsight/diagram/graph/node.rb
|
|
284
|
+
- lib/archsight/diagram/graph/port.rb
|
|
284
285
|
- lib/archsight/diagram/includes.rb
|
|
285
286
|
- lib/archsight/diagram/layout.rb
|
|
287
|
+
- lib/archsight/diagram/layout/alignment.rb
|
|
286
288
|
- lib/archsight/diagram/layout/expander.rb
|
|
287
289
|
- lib/archsight/diagram/layout/force_simulation.rb
|
|
288
290
|
- lib/archsight/diagram/layout/globalizer.rb
|
|
289
291
|
- lib/archsight/diagram/layout/overlap_resolver.rb
|
|
292
|
+
- lib/archsight/diagram/layout/ports.rb
|
|
290
293
|
- lib/archsight/diagram/layout/rank_arranger.rb
|
|
291
294
|
- lib/archsight/diagram/layout/rank_topology.rb
|
|
292
295
|
- lib/archsight/diagram/layout/ranked_arranger.rb
|