ea 0.6.121 → 0.6.123

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d27202e3cefb07cca1a7bc22923a692ef3eb882be925e4ec8c31962518620c07
4
- data.tar.gz: c31f63e7b1d97192c503d2a6b1464067aaff21dd06d8f716eeb83abc0f42a858
3
+ metadata.gz: d897b1e2165c9993420958c081ee3e2eedebbfbb8ac4db1ea8f160fd45913a3d
4
+ data.tar.gz: 667e5cce39c543c97f96fd7b64c994d72808daf3bf6a875ae112e0efe4b2623b
5
5
  SHA512:
6
- metadata.gz: 2a22e4c323e4e34f1e9b65ed0b3881dc6f8593d4e36546e64550cf050aeef603373d656850ae88d5992a2dc330371203fa07f6fab495378093a1c409e510114f
7
- data.tar.gz: 3923190f152ee52b99476f64c97e0201b783beea6291b18e8b1bb28967ef9a1f09188cf7bbad41d8a4339434471ea9e577d73cc4c1135f2dc88f0b1b1fdf650b
6
+ metadata.gz: e8789c4187a4c72534f2e4d17f1caf650e7da1748aa08bed49764fa6fde0dafdfc1bf8c6811ab7a33e31b6da1ffe0a71397b6ca60b1a8c2e7ee0e65a4798368d
7
+ data.tar.gz: 37bf77d8c14e921888081ee961506f835b5cd0de1bb28de790d5ba848003915922e83c83e2b29d6db0f58141f25dc1d643059f642950ee6cd2028da87d42b42f
@@ -22,6 +22,8 @@ module Ea
22
22
  attribute :line_width, :integer
23
23
  attribute :font_family, :string
24
24
  attribute :font_size, :integer
25
+ # nil: unspecified provenance; true: source delegates to default resolution.
26
+ attribute :font_size_inherited, :boolean
25
27
  attribute :font_logical_height, :integer # original GDI pixel height before point normalization
26
28
  attribute :font_size_unit, :string # source font size unit, before theme fallback
27
29
  attribute :font_bold, :boolean
@@ -55,6 +57,7 @@ module Ea
55
57
  map "lineWidth", to: :line_width
56
58
  map "fontFamily", to: :font_family
57
59
  map "fontSize", to: :font_size
60
+ map "fontSizeInherited", to: :font_size_inherited, render_default: true
58
61
  map "fontSizeUnit", to: :font_size_unit
59
62
  map "fontLogicalHeight", to: :font_logical_height
60
63
  map "fontBold", to: :font_bold, render_default: true
@@ -138,6 +138,7 @@ module Ea
138
138
  style: style_hash,
139
139
  font_family: presence(font_style[:font]),
140
140
  font_size: font_size_from(font_style),
141
+ font_size_inherited: font_size_from(font_style).nil?,
141
142
  font_size_unit: "pt",
142
143
  font_logical_height: font_logical_height_from(font_style),
143
144
  font_bold: truthy?(style_hash[:bold]),
@@ -5,12 +5,10 @@ module Ea
5
5
  module EaEmitter
6
6
  module Compartment
7
7
  # Instance-name underline for object diagrams. EA draws a
8
- # short horizontal line just below each instance name to
9
- # denote that it is an instance (UML convention). Rendered
10
- # only when the diagram's `diagram_type` is "Object".
8
+ # short horizontal line below each instance name to denote
9
+ # that it is an instance (UML convention).
11
10
  module InstanceUnderline
12
- Y_OFFSET = 2
13
- WIDTH_FACTOR = 0.65
11
+ Y_OFFSET = 1
14
12
 
15
13
  module_function
16
14
 
@@ -19,11 +17,22 @@ module Ea
19
17
  return nil unless object_diagram?(context)
20
18
  return nil if context.header_lines.empty?
21
19
 
22
- name_line = context.header_lines.last
23
- text = name_line.first.to_s
24
- return nil if text.empty?
20
+ header = Element::HeaderRenderer.line_layout(
21
+ context.header_lines,
22
+ bounds: context.bounds,
23
+ first_y: context.geometry.header_first_y,
24
+ family: context.family,
25
+ size: context.size,
26
+ size_unit: context.size_unit,
27
+ logical_height: context.logical_height,
28
+ weight_normal: context.theme.text_weight_normal,
29
+ weight_bold: context.theme.text_weight_bold,
30
+ width_factor: context.theme.text_width_factor,
31
+ line_offset: context.theme.compartments.header_line_offset
32
+ ).last
33
+ return nil unless header && !header.text.to_s.empty?
25
34
 
26
- underline_path(context, text)
35
+ underline_path(header)
27
36
  end
28
37
 
29
38
  def object_diagram?(context)
@@ -31,18 +40,13 @@ module Ea
31
40
  end
32
41
  module_function :object_diagram?
33
42
 
34
- def underline_path(context, text)
35
- bounds = context.bounds
36
- text_width = TextRenderer.estimate_width(text, context.size,
37
- WIDTH_FACTOR)
38
- x_start = bounds.x + (bounds.width - text_width) / 2.0
39
- y_pos = context.geometry.header_first_y + Y_OFFSET
40
- build_path_tag(x_start, y_pos, text_width)
43
+ def underline_path(header)
44
+ build_path_tag(header.x, header.y + Y_OFFSET, header.text_length)
41
45
  end
42
46
  module_function :underline_path
43
47
 
44
- def build_path_tag(x_start, y_pos, text_width)
45
- x_end = x_start + text_width
48
+ def build_path_tag(x_start, y_pos, text_length)
49
+ x_end = x_start + text_length
46
50
  coords = format_coords(x_start, y_pos, x_end)
47
51
  d = "M #{coords[:x1]} #{coords[:y1]} L #{coords[:x2]} #{coords[:y1]}"
48
52
  style = "stroke-width:1;stroke-linecap:square;" \
@@ -14,6 +14,7 @@ module Ea
14
14
  ROW_HEIGHT = 16
15
15
  ICON_X_OFFSET = 5
16
16
  TEXT_X_OFFSET = 23
17
+ FIRST_BASELINE_OFFSET = 15
17
18
 
18
19
  module_function
19
20
 
@@ -22,7 +23,7 @@ module Ea
22
23
  return nil if rows.nil? || rows.empty?
23
24
 
24
25
  bounds = context.bounds
25
- first_y = context.geometry.attr_first_y
26
+ first_y = first_baseline(context)
26
27
  blocks = rows.each_with_index.flat_map do |row, idx|
27
28
  y_top = first_y - 11 + (idx * ROW_HEIGHT)
28
29
  y_text = first_y + (idx * ROW_HEIGHT)
@@ -34,6 +35,14 @@ module Ea
34
35
  wrap_group(blocks.join("\n"))
35
36
  end
36
37
 
38
+ def first_baseline(context)
39
+ context.bounds.y + Element::PackageShapeRenderer.tab_height_for(
40
+ stereotype: Shape.package_stereotype(context.model_element),
41
+ family: context.family, size: context.size, size_unit: context.size_unit
42
+ ) + FIRST_BASELINE_OFFSET
43
+ end
44
+ private_class_method :first_baseline
45
+
37
46
  def wrap_group(body)
38
47
  %(<g style="#{group_style}">\n#{body}\n</g>)
39
48
  end
@@ -15,7 +15,7 @@ module Ea
15
15
  context.bounds,
16
16
  legend: context.model_element.legend,
17
17
  family: context.family,
18
- item_font: { size: context.element&.font_size&.nonzero?, unit: context.size_unit,
18
+ item_font: { size: legend_item_size(context), unit: context.size_unit,
19
19
  logical_height: context.logical_height }
20
20
  )
21
21
  elsif context.model_element.is_a?(Ea::Model::Note)
@@ -54,6 +54,17 @@ module Ea
54
54
  end
55
55
  end
56
56
 
57
+ def legend_item_size(context)
58
+ element = context.element
59
+ return unless element
60
+ return element.font_size if element.font_size&.nonzero?
61
+
62
+ # Parsed source defaults inherit; unspecified callers keep 9pt.
63
+ inherited = element.font_size_inherited || element.style&.fetch(:fontsz, nil).to_s == "0"
64
+ context.size if inherited
65
+ end
66
+ private_class_method :legend_item_size
67
+
57
68
  # A Text element with LegendOpts= renders as the legend
58
69
  # block rather than a folder silhouette. Dispatch on the
59
70
  # presence of the attached Legend payload.
@@ -50,14 +50,14 @@ module Ea
50
50
  end
51
51
 
52
52
  def layers
53
- widths = paths_with_widths
53
+ styles = paths_with_styles
54
54
 
55
55
  if grouped
56
- widths.group_by(&:first).map do |width, entries|
57
- line_layer(width, entries.map(&:last).join("\n "))
56
+ styles.group_by(&:first).map do |(width, dashed), entries|
57
+ line_layer(width, dashed, entries.map(&:last).join("\n "))
58
58
  end
59
59
  else
60
- widths.map { |width, path| line_layer(width, path) }
60
+ styles.map { |(width, dashed), path| line_layer(width, dashed, path) }
61
61
  end
62
62
  end
63
63
 
@@ -73,16 +73,29 @@ module Ea
73
73
 
74
74
  private
75
75
 
76
- def paths_with_widths
76
+ def paths_with_styles
77
77
  visible_connectors.filter_map do |connector|
78
78
  path = path_for(connector)
79
- [self.class.stroke_width_for(connector, stroke_width), path] if path
79
+ next unless path
80
+
81
+ [[self.class.stroke_width_for(connector, stroke_width), dashed?(connector)], path]
80
82
  end
81
83
  end
82
84
 
83
- def line_layer(width, body)
84
- Layer.new(style_key: self.class.layer_key(LINE_STYLE_KEY, width, stroke_width),
85
- style: line_style(width), body: body)
85
+ def line_layer(width, dashed, body)
86
+ key = dashed ? :connector_line_dashed : LINE_STYLE_KEY
87
+ style = line_style(width)
88
+ style += ";stroke-dasharray:6,3" if dashed
89
+ Layer.new(style_key: self.class.layer_key(key, width, stroke_width),
90
+ style: style, body: body)
91
+ end
92
+
93
+ def dashed?(connector)
94
+ return true if connector.connector_type == "Dependency"
95
+ return false unless connector.connector_type == "Package"
96
+
97
+ relationship = model_index && model_index[connector.relationship_ref]
98
+ relationship&.stereotype == "import"
86
99
  end
87
100
 
88
101
  def visible_connectors
@@ -11,55 +11,70 @@ module Ea
11
11
  # the left edge from text-width approximation since SVG text
12
12
  # x= is the start position, not the center).
13
13
  class HeaderRenderer
14
+ HeaderLine = Struct.new(:text, :style, :x, :y, :text_length,
15
+ :weight, :font_style, keyword_init: true)
16
+
14
17
  def self.render(lines, bounds:, first_y:, family:,
15
18
  size:, size_unit: "pt", logical_height: nil,
16
19
  fill: "#000000", weight_normal: 400, weight_bold: 700,
17
20
  stroke_in_text: "#000000", width_factor: 0.612,
18
21
  line_offset: 6)
19
22
  logical_height = nil unless size_unit == "pt"
23
+ layout = line_layout(
24
+ lines, bounds: bounds, first_y: first_y, family: family,
25
+ size: size, size_unit: size_unit, logical_height: logical_height,
26
+ weight_normal: weight_normal, weight_bold: weight_bold,
27
+ width_factor: width_factor, line_offset: line_offset
28
+ )
29
+ text_blocks = layout.map do |line|
30
+ TextRenderer.new(
31
+ content: line.text,
32
+ x: line.x, y: line.y,
33
+ family: family, size: size, size_unit: size_unit, logical_height: logical_height,
34
+ weight: line.weight, style: line.font_style, fill: fill,
35
+ stroke_in_text: stroke_in_text, width_factor: width_factor,
36
+ text_length: line.text_length
37
+ ).to_svg
38
+ end
39
+ %(<g style="#{Style::TEXT_GROUP}">\n#{text_blocks.join("\n")}\n</g>)
40
+ end
41
+
42
+ # Returns the exact integer placement and width used for each
43
+ # emitted header line so dependent compartments can align to it.
44
+ def self.line_layout(lines, bounds:, first_y:, family:, size:,
45
+ size_unit: "pt", logical_height: nil,
46
+ weight_normal: 400, weight_bold: 700,
47
+ width_factor: 0.612, line_offset: 6)
48
+ logical_height = nil unless size_unit == "pt"
20
49
  line_h = Ea::Fonts::Metrics.compartment_line_height(
21
- size, family: family, unit: size_unit, offset: line_offset, logical_height: logical_height
50
+ size, family: family, unit: size_unit, offset: line_offset,
51
+ logical_height: logical_height
22
52
  )
23
53
  ghost_shift = HeaderLines.parent_ghost_gap(lines)
24
- text_blocks = lines.each_with_index.map do |(text, style), idx|
25
- weight = case style
26
- when :bold, :bold_italic then weight_bold
27
- else weight_normal
28
- end
54
+ lines.each_with_index.map do |(text, style), idx|
55
+ weight = %i[bold bold_italic].include?(style) ? weight_bold : weight_normal
29
56
  font_style = %i[italic italic_tight bold_italic].include?(style) ? "italic" : "normal"
57
+ text_length = TextRenderer.estimate_width(
58
+ text, size, width_factor, family: family, weight: weight,
59
+ style: font_style, logical_height: logical_height
60
+ ).round
30
61
  y = first_y + (idx * line_h) +
31
62
  (idx >= 1 && ghost_shift.positive? ? ghost_shift : 0)
32
63
  x = if %i[italic italic_tight].include?(style)
33
- # Parent ghosts end 3px inside the box's right edge.
34
- bounds.x + bounds.width - 3 -
35
- Ea::Svg::EaEmitter::TextRenderer.estimate_width(
36
- text, size, width_factor,
37
- family: family, weight: weight, style: font_style, logical_height: logical_height
38
- ).round
64
+ bounds.x + bounds.width - 3 - text_length
39
65
  else
40
- center_x_for(text, bounds, size, width_factor,
41
- family: family, weight: weight,
42
- style: font_style, logical_height: logical_height)
66
+ center_x_for(text_length, bounds)
43
67
  end
44
- TextRenderer.new(
45
- content: text,
46
- x: x, y: y,
47
- family: family, size: size, size_unit: size_unit, logical_height: logical_height,
48
- weight: weight, style: font_style, fill: fill,
49
- stroke_in_text: stroke_in_text, width_factor: width_factor
50
- ).to_svg
68
+ HeaderLine.new(text: text, style: style, x: x, y: y,
69
+ text_length: text_length, weight: weight,
70
+ font_style: font_style)
51
71
  end
52
- %(<g style="#{Style::TEXT_GROUP}">\n#{text_blocks.join("\n")}\n</g>)
53
72
  end
54
73
 
55
74
  # EA centers text by its INTEGER textLength and floors the
56
75
  # resulting x (verified exact against EA-published SVGs).
57
- def self.center_x_for(text, bounds, size, width_factor,
58
- family: nil, weight: nil, style: nil, logical_height: nil)
59
- len = Ea::Svg::EaEmitter::TextRenderer.estimate_width(
60
- text, size, width_factor, family: family, weight: weight, style: style, logical_height: logical_height
61
- ).round
62
- (bounds.x + (bounds.width - len) / 2.0).floor
76
+ def self.center_x_for(text_length, bounds)
77
+ (bounds.x + (bounds.width - text_length) / 2.0).floor
63
78
  end
64
79
  private_class_method :center_x_for
65
80
  end
@@ -26,9 +26,10 @@ module Ea
26
26
  text_blocks = []
27
27
  has_receptions = operations.any?(&:is_reception)
28
28
  if has_receptions
29
+ heading_width = reception_heading_width(family, size, size_unit, logical_height)
29
30
  text_blocks << TextRenderer.new(
30
31
  content: "receptions",
31
- x: bounds.x + (bounds.width / 2.0) - (8 * size * 0.65) / 2,
32
+ x: bounds.x + (bounds.width / 2.0) - heading_width / 2.0,
32
33
  y: first_y,
33
34
  family: family, size: size, size_unit: size_unit, logical_height: logical_height,
34
35
  style: "italic", fill: "#000000"
@@ -36,12 +37,14 @@ module Ea
36
37
  end
37
38
  ops = has_receptions ? operations : operations
38
39
  offset = has_receptions ? 1 : 0
40
+ content_x_offset = CONTENT_X_OFFSET + 2 * (size - 7)
41
+ content_x_offset -= 1 if Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit)
39
42
  ops.each_with_index do |op, idx|
40
43
  y = first_y + ((idx + offset) * line_h)
41
44
  text_blocks << build_text(bounds.x + VISIBILITY_X_OFFSET, y,
42
45
  visibility_prefix(op), family, size, size_unit, logical_height: logical_height)
43
46
  text = operation_text(op, parameter_detail: parameter_detail)
44
- text_blocks << build_text(bounds.x + AttributeRenderer.content_x_offset(size), y,
47
+ text_blocks << build_text(bounds.x + content_x_offset, y,
45
48
  text, family, size, size_unit, logical_height: logical_height)
46
49
  end
47
50
  %(<g style="#{Style::TEXT_GROUP}">\n#{text_blocks.join("\n")}\n</g>)
@@ -102,6 +105,15 @@ module Ea
102
105
  end
103
106
  private_class_method :reception_params
104
107
 
108
+ def self.reception_heading_width(family, size, size_unit, logical_height)
109
+ TextRenderer.estimate_width(
110
+ "receptions", size,
111
+ family: family, weight: 400, style: "italic",
112
+ logical_height: size_unit == "pt" ? logical_height : nil
113
+ ).round
114
+ end
115
+ private_class_method :reception_heading_width
116
+
105
117
  def self.build_text(x, y, content, family, size, size_unit, logical_height: nil)
106
118
  TextRenderer.new(content: content, x: x, y: y,
107
119
  family: family, size: size, size_unit: size_unit,
@@ -10,13 +10,12 @@ module Ea
10
10
  # type.
11
11
  #
12
12
  # Layout (element bounds cover tab + body together; the tab
13
- # occupies the TOP of the stored rect — corpus-verified
14
- # against EA-published package polygons, single tab 20px and
15
- # stereotype tab 36px, both inside):
13
+ # occupies the TOP of the stored rect, with its height adjusted
14
+ # by the font line height for each header row):
16
15
  #
17
16
  # ┌───┐
18
- # │tab│ ← top TAB_HEIGHT rows of the bounds;
19
- # │ │ 36px when a stereotype is present
17
+ # │tab│ ← one name row, or two rows with a stereotype
18
+ # │ │
20
19
  # ├───┴──────────┐
21
20
  # │ │ the name renders bold, left-aligned;
22
21
  # │ body │ the body always starts where the
@@ -46,6 +45,21 @@ module Ea
46
45
  family, size, size_unit, text_fill).to_svg
47
46
  end
48
47
 
48
+ def self.tab_height_for(stereotype:, family:, size:, size_unit: "pt")
49
+ rows = stereotype && !stereotype.empty? ? 2 : 1
50
+ nominal_height = rows == 2 ? TAB_HEIGHT_DOUBLE : TAB_HEIGHT_SINGLE
51
+ line_height = if size_unit == "pt"
52
+ Ea::Fonts::Metrics.compartment_line_height(
53
+ size, family: family, unit: size_unit, offset: TAB_TEXT_PADDING
54
+ )
55
+ else
56
+ TAB_LABEL_Y_OFFSET
57
+ end
58
+ # Preserve the nominal padding while each row follows the same
59
+ # font line height used to place the package header text.
60
+ nominal_height + rows * (line_height - TAB_LABEL_Y_OFFSET)
61
+ end
62
+
49
63
  def initialize(bounds, fill, stroke, stroke_width, label,
50
64
  stereotype, family, size, size_unit, text_fill)
51
65
  @bounds = bounds
@@ -148,7 +162,8 @@ module Ea
148
162
  end
149
163
 
150
164
  def tab_height
151
- has_stereotype? ? TAB_HEIGHT_DOUBLE : TAB_HEIGHT_SINGLE
165
+ self.class.tab_height_for(stereotype: stereotype, family: family,
166
+ size: size, size_unit: size_unit)
152
167
  end
153
168
 
154
169
  def body_points
@@ -152,8 +152,10 @@ module Ea
152
152
  []
153
153
  end
154
154
  # Titles wrap; content rows grow the drawn box to fit their insets.
155
- row_w = [row_content_width(attr_lines, 5, size, family, logical_height),
156
- row_content_width(op_lines, 3, size, family, logical_height),
155
+ row_w = [row_content_width(attr_lines, 5, size, family,
156
+ logical_height: logical_height,
157
+ content_x_offset: attribute_content_x_offset(size, family, size_unit)),
158
+ row_content_width(op_lines, 3, size, family, logical_height: logical_height),
157
159
  Compartment::EnumLiterals.content_width(
158
160
  enum_literals_for(classifier),
159
161
  theme: theme, size: size, family: family,
@@ -249,7 +251,8 @@ module Ea
249
251
  # Real attributes grow the DRAWN box past the stored
250
252
  # height instead of being dropped (0CABA7D7: stored h44
251
253
  # draws at h59). Grown height = last row baseline + EA's
252
- # ~8px bottom padding. Render-local: the diagram-level
254
+ # trailing padding (7px for compact Cambria, 8px otherwise).
255
+ # Render-local: the diagram-level
253
256
  # canvas still derives from stored bounds.
254
257
  # content_bottom_y is nil when no compartment has rows;
255
258
  # op_bottom_y is NOT a sentinel (it reports the op-divider
@@ -268,7 +271,7 @@ module Ea
268
271
  bounds = Ea::Model::Bounds.new(
269
272
  x: bounds.x, y: bounds.y,
270
273
  width: bounds.width,
271
- height: grown_bottom + 8 - bounds.y
274
+ height: grown_bottom + compartment_bottom_padding(size, family, size_unit) - bounds.y
272
275
  )
273
276
  end
274
277
  RenderContext.new(
@@ -298,18 +301,30 @@ module Ea
298
301
  )
299
302
  end
300
303
 
301
- def row_content_width(lines, right_pad, size, family, logical_height)
304
+ def attribute_content_x_offset(size, family, size_unit)
305
+ offset = (theme.attribute_spec.content_x_offset || Element::AttributeRenderer::DEFAULT_CONTENT_X_OFFSET) +
306
+ 2 * (size - 7)
307
+ offset -= 1 if Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit)
308
+ offset
309
+ end
310
+
311
+ def compartment_bottom_padding(size, family, size_unit)
312
+ Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit) ? 7 : 8
313
+ end
314
+
315
+ def row_content_width(lines, right_pad, size, family, **font_options)
302
316
  return nil if lines.to_a.empty?
303
317
 
304
- tl = lines.filter_map do |line|
318
+ lines.filter_map do |line|
305
319
  text = line.sub(/\A\S /, "")
306
- Ea::Fonts::Metrics.text_length(text, size,
307
- family: family,
308
- weight: "400", logical_height: logical_height)&.round
309
- end.max
310
- return nil unless tl
320
+ length = Ea::Fonts::Metrics.text_length(text, size,
321
+ family: family,
322
+ weight: "400", logical_height: font_options[:logical_height])&.round
323
+ next unless length
311
324
 
312
- 22 + tl + right_pad
325
+ offset = line.match?(/\A[-+~#]\s/) ? font_options.fetch(:content_x_offset, 22) : 22
326
+ offset + length + right_pad
327
+ end.max
313
328
  end
314
329
 
315
330
  def compartment_geometry(bounds, size, attr_lines, op_lines,
@@ -79,6 +79,7 @@ module Ea
79
79
  text_pos, mult_pos = tree_positions(end_kind, anchor, role,
80
80
  text_pos, mult_pos)
81
81
  mult_pos = tree_left_multiplicity(end_kind, heading, anchor, mult_box, mult) || mult_pos
82
+ text_pos = tree_left_property_role(connector, end_kind, heading, anchor, text_box, role) || text_pos
82
83
  elsif mult_box && stored_offsets_zero?(mult_box)
83
84
  # Start at the legacy default; measured directional seats
84
85
  # below replace it for default Cambria labels.
@@ -210,8 +211,9 @@ module Ea
210
211
 
211
212
  def source_property_seat?(connector, direction, box)
212
213
  phantom_property_box?(connector, direction, box) ||
213
- (%i[up left right down].include?(direction) && property_role_box?(box, direction) &&
214
- source_property_font?(connector, direction))
214
+ (%i[up left right down].include?(direction) &&
215
+ property_role_box?(box, direction, connector.style.to_h) &&
216
+ source_property_font?(connector, direction, box))
215
217
  end
216
218
 
217
219
  # Phantom outlines carry synthesized zero boxes without alignment.
@@ -392,9 +394,23 @@ module Ea
392
394
  [nil, "V"].include?(style[:tree])
393
395
  end
394
396
 
395
- def property_role_box?(box, direction)
396
- alignments = direction == :down ? [1] : [0, 1]
397
- box && alignments.include?(box["alignment"]) && default_presentation?(box)
397
+ def property_role_box?(box, direction, style = {})
398
+ return false unless box && default_presentation?(box)
399
+ return [0, 1].include?(box["alignment"]) unless direction == :down
400
+
401
+ case box["alignment"]
402
+ when 1 then true
403
+ when 0 then persisted_plain_down_box?(box, style)
404
+ else false
405
+ end
406
+ end
407
+
408
+ # Persisted alignment-0 boxes on untreed routes use the downward
409
+ # seat, including saved rays; the "OS" route also seats absent boxes.
410
+ def persisted_plain_down_box?(box, style)
411
+ return true if orthogonal_stub_route?(style)
412
+
413
+ !stored_offsets_zero?(box) && style[:tree].nil?
398
414
  end
399
415
 
400
416
  def property_role_offsets(direction)
@@ -405,26 +421,58 @@ module Ea
405
421
  end
406
422
  end
407
423
 
408
- def source_property_font?(connector, direction)
424
+ def source_property_font?(connector, direction, box)
409
425
  source_property_cambria?(connector) &&
410
- source_property_route?(connector.style.to_h, direction)
426
+ source_property_route?(connector.style.to_h, direction, box)
411
427
  end
412
428
 
413
429
  def source_property_cambria?(connector)
414
430
  font_family.to_s.casecmp("Cambria").zero? && property_label?(connector, :source)
415
431
  end
416
432
 
417
- def source_property_route?(style, direction)
433
+ def source_property_route?(style, direction, box)
418
434
  case direction
419
- when :left then left_property_route?(style)
420
435
  when :right then style.values_at(:mode, :lwidth, :tree) == %w[3 2 H]
421
- when :down then downward_property_route?(style)
422
- else %w[0 2].include?(style[:lwidth]) && style[:tree] != "OS"
436
+ when :up then upward_property_route?(style)
437
+ when :left then orthogonal_stub_route?(style) || left_property_route?(style)
438
+ else downward_source_route?(style, box)
423
439
  end
424
440
  end
425
441
 
442
+ def downward_source_route?(style, box)
443
+ [orthogonal_stub_route?(style), lateral_horizontal_route?(style), mode_two_route?(style),
444
+ downward_property_route?(style), persisted_width_zero_down_box?(box, style)].any?
445
+ end
446
+
447
+ def persisted_width_zero_down_box?(box, style)
448
+ box["alignment"]&.zero? && !stored_offsets_zero?(box) && style[:stored_association_ray] != true &&
449
+ style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil]
450
+ end
451
+
452
+ # Mode 3, width 2 on the horizontal lateral tree.
453
+ def lateral_horizontal_route?(style)
454
+ style.values_at(:mode, :lwidth, :tree) == %w[3 2 LH]
455
+ end
456
+
457
+ # Mode 2, width 2 without a tree seats like the other width-2 modes.
458
+ def mode_two_route?(style)
459
+ style.values_at(:mode, :lwidth, :tree) == ["2", "2", nil]
460
+ end
461
+
462
+ def upward_property_route?(style)
463
+ return orthogonal_stub_route?(style) if style[:tree] == "OS"
464
+
465
+ %w[0 2].include?(style[:lwidth])
466
+ end
467
+
468
+ # The "OS" route seats its Property roles at the same directional
469
+ # corner seats as the untreed width-2 route.
470
+ def orthogonal_stub_route?(style)
471
+ style.values_at(:mode, :lwidth, :tree) == %w[3 2 OS]
472
+ end
473
+
426
474
  def left_property_route?(style)
427
- downward_property_route?(style) ||
475
+ downward_property_route?(style) || mode_two_route?(style) ||
428
476
  (style[:mode] == "3" && %w[0 2].include?(style[:lwidth]) &&
429
477
  [nil, "H", "LV"].include?(style[:tree]))
430
478
  end
@@ -456,6 +504,15 @@ module Ea
456
504
  [x + SEAT_X_INSET + width, y + 15] if width
457
505
  end
458
506
 
507
+ # An absent source box on a leftward tree leg seats a Property role
508
+ # at the leftward Property seat, not the rightward tree default.
509
+ def tree_left_property_role(connector, end_kind, heading, anchor, box, role)
510
+ return nil unless leg_direction(end_kind, heading) == :left && box&.[]("alignment")&.zero?
511
+ return nil unless connector.style.to_h.values_at(:mode, :lwidth) == %w[3 2]
512
+
513
+ source_property_seat(connector, :left, anchor, box, role)
514
+ end
515
+
459
516
  # A tree-route source leaving left seats its multiplicity like any
460
517
  # other leftward source rather than at the rightward tree default.
461
518
  def tree_left_multiplicity(end_kind, heading, anchor, box, mult)
@@ -125,7 +125,7 @@ module Ea
125
125
  # Unknown faces retain the stored-upward fallback.
126
126
  def source_heading(connector, points)
127
127
  heading = leg_heading(points)
128
- return heading unless points.size == 2 && heading.none?(&:zero?)
128
+ return heading unless heading.none?(&:zero?) && source_face_route?(connector, points)
129
129
 
130
130
  box = source_dock_bounds(connector)
131
131
  face = box && source_dock_heading(connector, points.first, box)
@@ -134,6 +134,15 @@ module Ea
134
134
  stored_upward_heading(connector, heading)
135
135
  end
136
136
 
137
+ # Explicit source boxes preserve the heading of a manual bend. An
138
+ # omitted source box uses the same dock seat as a straight route.
139
+ def source_face_route?(connector, points)
140
+ return true if points.size == 2
141
+
142
+ boxes = connector.label_boxes || {}
143
+ end_boxes(boxes, :llt, :llb).nil? && !end_boxes(boxes, :lrt, :lrb).nil?
144
+ end
145
+
137
146
  # A target dock that clips an axis-aligned stored leg into a slight
138
147
  # diagonal keeps the stored leg's direction.
139
148
  def target_heading(connector, points)
@@ -150,15 +159,24 @@ module Ea
150
159
  # A saved external ray enters the source through the face it first
151
160
  # meets, even when the clipped leg then runs back across the box.
152
161
  def face_leads_leg?(connector, face, heading, box)
153
- anchor = connector.source_anchor
154
- if face == [0, 1] && anchor && anchor.y > box.y + box.height &&
155
- connector.style.to_h[:stored_association_ray] == true
156
- return true
157
- end
162
+ return true if saved_ray_beyond_face?(connector, face, box)
158
163
 
159
164
  face.zip(heading).any? { |axis, delta| (axis * delta).positive? }
160
165
  end
161
166
 
167
+ # A saved ray from beyond the bottom or right face meets that face
168
+ # first. The top face is excluded: its clipped leg keeps the anchor seat.
169
+ def saved_ray_beyond_face?(connector, face, box)
170
+ anchor = connector.source_anchor
171
+ return false unless anchor && connector.style.to_h[:stored_association_ray] == true
172
+
173
+ case face
174
+ when [0, 1] then anchor.y > box.y + box.height
175
+ when [1, 0] then anchor.x > box.x + box.width
176
+ else false
177
+ end
178
+ end
179
+
162
180
  def stored_upward_heading(connector, heading)
163
181
  return heading unless heading[1].negative?
164
182
 
@@ -20,6 +20,7 @@ module Ea
20
20
  anchor: whole_anchor,
21
21
  base: whole_base)]
22
22
  return specs unless whole_at_target
23
+ return specs if arrow_on_target_diamond?(connector, relationship)
23
24
 
24
25
  specs.concat(direction_specs(connector, source, target,
25
26
  before_target, after_source))
@@ -67,6 +68,14 @@ module Ea
67
68
  !whole_end_at_source?(connector)
68
69
  end
69
70
 
71
+ # EA draws no arrow where a directed, dest-flagged aggregation
72
+ # would put it on its own target diamond.
73
+ def self.arrow_on_target_diamond?(connector, relationship)
74
+ connector.direction == "Source -> Destination" &&
75
+ dest_flagged?(relationship)
76
+ end
77
+ private_class_method :arrow_on_target_diamond?
78
+
70
79
  def self.dest_flagged?(relationship)
71
80
  relationship &&
72
81
  %w[shared composite].include?(relationship.target_aggregation.to_s)
@@ -22,7 +22,7 @@ module Ea
22
22
  DIAMOND_HALF_W = 5
23
23
  DIAMOND_HALF_H = 10
24
24
  TRI_HALF_BASE = 6
25
- TRI_HEIGHT = 11
25
+ TRI_HEIGHT = 16
26
26
  # EA's path arrowheads are height 15 corpus-wide, with
27
27
  # distinct bases per connector kind: association
28
28
  # navigability arrows base 12, dependency arrows base 10
@@ -364,10 +364,10 @@ module Ea
364
364
  left_x = tx - ux * DIAMOND_HALF_H - (-uy) * DIAMOND_HALF_W
365
365
  left_y = ty - uy * DIAMOND_HALF_H - ux * DIAMOND_HALF_W
366
366
  pts = [
367
- Canvas.coord(tx), Canvas.coord(ty),
368
- Canvas.coord(right_x), Canvas.coord(right_y),
369
- Canvas.coord(back_x), Canvas.coord(back_y),
370
- Canvas.coord(left_x), Canvas.coord(left_y)
367
+ Canvas.coord(tx.round), Canvas.coord(ty.round),
368
+ Canvas.coord(right_x.round), Canvas.coord(right_y.round),
369
+ Canvas.coord(back_x.round), Canvas.coord(back_y.round),
370
+ Canvas.coord(left_x.round), Canvas.coord(left_y.round)
371
371
  ].join(" ")
372
372
  %(<polygon points="#{pts}" shape-rendering="auto" style="fill-rule:evenodd;"/>)
373
373
  end
@@ -385,9 +385,9 @@ module Ea
385
385
  w2_x = back_x - (-uy) * TRI_HALF_BASE
386
386
  w2_y = back_y - ux * TRI_HALF_BASE
387
387
  pts = [
388
- Canvas.coord(tx), Canvas.coord(ty),
389
- Canvas.coord(w1_x), Canvas.coord(w1_y),
390
- Canvas.coord(w2_x), Canvas.coord(w2_y)
388
+ Canvas.coord(tx.round), Canvas.coord(ty.round),
389
+ Canvas.coord(w1_x.round), Canvas.coord(w1_y.round),
390
+ Canvas.coord(w2_x.round), Canvas.coord(w2_y.round)
391
391
  ].join(" ")
392
392
  %(<polygon points="#{pts}" shape-rendering="auto" style="fill-rule:evenodd;"/>)
393
393
  end
@@ -404,7 +404,9 @@ module Ea
404
404
  w1_y = back_y + ux * ARROW_HALF_BASE
405
405
  w2_x = back_x - (-uy) * ARROW_HALF_BASE
406
406
  w2_y = back_y - ux * ARROW_HALF_BASE
407
- d = "M #{Canvas.coord(w1_x)} #{Canvas.coord(w1_y)} L #{Canvas.coord(tx)} #{Canvas.coord(ty)} L #{Canvas.coord(w2_x)} #{Canvas.coord(w2_y)}"
407
+ d = "M #{Canvas.coord(w1_x.round)} #{Canvas.coord(w1_y.round)} " \
408
+ "L #{Canvas.coord(tx.round)} #{Canvas.coord(ty.round)} " \
409
+ "L #{Canvas.coord(w2_x.round)} #{Canvas.coord(w2_y.round)}"
408
410
  %(<path d="#{d}" shape-rendering="auto"/>)
409
411
  end
410
412
 
data/lib/ea/version.rb CHANGED
@@ -4,5 +4,5 @@ module Ea
4
4
  # Gem release version. Extracted to its own file so the
5
5
  # `gem-release` tool's `gem bump` can find and update it via the
6
6
  # standard `lib/<gem>/version.rb` convention.
7
- VERSION = "0.6.121"
7
+ VERSION = "0.6.123"
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.121
4
+ version: 0.6.123
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.