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.
@@ -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
- if LEAF_KEYWORDS.include?(kind_token.value) && !children.empty?
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 include at the cursor, nil if it is something else (an attribute)
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
- def initialize(boxes, text_renderer, ids:, theme:, hover_sources: Set.new)
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
- @label_placer = LabelPlacer.new(@boxes.slice(*nodes.select(&:leaf?).map(&:id)).merge(legend_boxes),
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
- obstacle_map = ObstacleMap.new(@boxes, ignoring: nodes.select { |n| n.anonymous? && !n.leaf? }.map(&:id))
86
- @edge_routing = EdgeRouting.new(@graph, @boxes, obstacle_map: obstacle_map)
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
- def initialize(graph, boxes, obstacle_map: ObstacleMap.new(boxes))
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
- Layout::PositionedNode.new(node, @boxes[node.id])
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|
@@ -4,5 +4,5 @@
4
4
  # Do not edit manually.
5
5
 
6
6
  module Archsight
7
- VERSION = "0.5.4"
7
+ VERSION = "0.5.5"
8
8
  end
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
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