ea 0.6.127 → 0.6.128

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: 78f923bf1924106f7e6853841f3470c198e6ed0c9198e4501acd63aa3e84b3b6
4
- data.tar.gz: 46975a9dd5d9f80c027a4b7f906abb2dfaff6d7bfdcc8b0af82f3ce390f6a2c6
3
+ metadata.gz: 306887ac0320a438c1248971e4143fab06ab22982e08f2c61b19c31b7b64cc37
4
+ data.tar.gz: 2a73ad0edf0d4eac704f563f679946f19615e906c3d28dc30b9f25815fd8ed83
5
5
  SHA512:
6
- metadata.gz: e9b5fcacddafe90bac1b21015a6649227fd9676e01e8f5367e9eec96c0eac8712ff6303e49fadfa1d3db0c19c648eec056030ddcdb0e8d68609191502fd26193
7
- data.tar.gz: 3f95877c62d6dc05b74096bba8ecd49961a2c40bd363a98fde2d5e27f53a31e43ecb871a210e010ef5721b7c6e4d58c2cd94c6bf16e3826a712e80c1f0f7055e
6
+ metadata.gz: eef820b29153599ad35fc3de5bb047fa18fc824e6a09b8eb6f95c17a27d2c5126e350341c27649ebe9e861676bbb0b3437de0d9db7f4046ab964e11ce3923fa1
7
+ data.tar.gz: 32e7e0d157472804ada0ddb04a487d26180f14bf1e5c1254dff1b3de68353cff4d22270aee4a8fa9b28e6d103c27f0fe449ec5b0c65411579434a22f1f5bdb67
@@ -34,13 +34,14 @@ module Ea
34
34
  # A Mode 2 Association with equal SX/EX (vertical EDGE 1/3) or
35
35
  # equal SY/EY (horizontal EDGE 2/4) and no custom Path docks as a
36
36
  # straight line through the middle of the boxes' overlap, moved
37
- # by the shared stored offset. Stored SY counts upward.
37
+ # by the shared stored offset. Empty flush Mode 3 routes facing
38
+ # right use the same row. Stored SY counts upward.
38
39
  def overlap_ray_origins(model, link_row, diagram_row, geom, context)
39
40
  return model if context
40
41
  return model unless overlap_ray_candidate?(model, link_row, geom)
41
42
 
42
43
  source, target = stored_ray_boxes(link_row, diagram_row)
43
- origins = overlap_origins(source, target, geom)
44
+ origins = overlap_origins(source, target, geom, model.style[:mode])
44
45
  return model unless origins && overlap_correction?(source, target, geom)
45
46
 
46
47
  model.source_anchor, model.target_anchor = origins.map do |ox, oy|
@@ -50,9 +51,20 @@ module Ea
50
51
  model.waypoints = port_waypoints(model, [model.source_anchor, model.target_anchor])
51
52
  end
52
53
  model.style[:overlap_ray] = true
54
+ flush_overlap_ports(model, source, target)
53
55
  model
54
56
  end
55
57
 
58
+ def flush_overlap_ports(model, source, target)
59
+ return unless model.style[:mode] == "3"
60
+
61
+ row = model.source_anchor.y
62
+ model.source_port = Ea::Model::Point.new(x: source.x + source.width, y: row)
63
+ model.target_port = Ea::Model::Point.new(x: target.x, y: row)
64
+ model.waypoints = port_waypoints(model, [model.source_port, model.target_port])
65
+ model.style[:flush_overlap_ray] = true
66
+ end
67
+
56
68
  # Aligned, zero-offset EDGE 2 routes already have their legacy docks.
57
69
  def overlap_correction?(source, target, geom)
58
70
  geom[:edge] != 2 || !geom[:sy].zero? ||
@@ -61,7 +73,9 @@ module Ea
61
73
 
62
74
  # Both ray origins share the overlap coordinate on the axis the
63
75
  # offsets move and sit at each box's centre on the other axis.
64
- def overlap_origins(source, target, geom)
76
+ def overlap_origins(source, target, geom, mode = nil)
77
+ return nil if mode == "3" && !source_row_faces_target?(source, target, geom[:edge])
78
+
65
79
  along, across, offset = overlap_axes(geom)
66
80
  coordinate = overlap_ray_coordinate(*box_span(source, along), *box_span(target, along), offset)
67
81
  return nil unless coordinate && disjoint_span?(*box_span(source, across), *box_span(target, across))
@@ -85,11 +99,19 @@ module Ea
85
99
  end
86
100
 
87
101
  def overlap_ray_candidate?(model, link_row, geom)
88
- model.connector_type == "Association" && model.style[:mode] == "2" &&
102
+ model.connector_type == "Association" && overlap_ray_style?(model.style, geom) &&
89
103
  [2, 3].include?(model.waypoints.size) && link_row.path.to_s.empty? && !model.style[:tree] &&
90
104
  model.has_geometry_offsets && uniform_overlap_offsets?(geom)
91
105
  end
92
106
 
107
+ def overlap_ray_style?(style, geom)
108
+ return true if style[:mode] == "2"
109
+
110
+ # All-zero offsets retain the default center ray.
111
+ style[:mode] == "3" && style[:lwidth] == "0" && geom[:edge] == 2 &&
112
+ geom.values_at(:sx, :sy, :ex, :ey).any? { |offset| offset.to_i.nonzero? }
113
+ end
114
+
93
115
  def uniform_overlap_offsets?(geom)
94
116
  return false unless geom.values_at(:sx, :sy, :ex, :ey).all?
95
117
 
@@ -164,9 +164,23 @@ module Ea
164
164
 
165
165
  set_stored_ray_origins(model, source, target, geom)
166
166
  model.style[:association_ray_outline] = true if model.connector_type == "Association"
167
+ model.style[:stored_origin_ray] = true if stored_origin_ray?(model, source, target)
167
168
  model
168
169
  end
169
170
 
171
+ # Certify only a straight, width-2 custom-line ray whose corrected
172
+ # stored origins lie inside their own outlines. Its clipped row is
173
+ # authored geometry, even when it coincides with a header divider.
174
+ def stored_origin_ray?(model, source, target)
175
+ style = model.style.to_h
176
+ return false unless model.connector_type == "Generalization" &&
177
+ style[:lwidth] == "2" && association_ray_style?(style)
178
+
179
+ [model.source_anchor, model.target_anchor].zip([source, target]).all? do |origin, box|
180
+ inside_box?(origin, grown_bounds(box, 1))
181
+ end
182
+ end
183
+
170
184
  # An empty-Path Mode 3 Association follows the line between its
171
185
  # stored endpoint origins; an omitted offset defaults to the center.
172
186
  # The target origin must lie inside its own stroked outline.
@@ -176,6 +190,8 @@ module Ea
176
190
  return model unless association_ray_candidate?(model, link_row)
177
191
 
178
192
  source, target = stored_ray_boxes(link_row, diagram_row)
193
+ return model if default_center_ray?(model) && !horizontal_ray_gap?(source, target)
194
+
179
195
  width = association_outline_width(model, link_row, diagram_row)
180
196
  origins = association_ray_origins(source, target, geom, width)
181
197
  return model unless origins
@@ -287,10 +303,26 @@ module Ea
287
303
  def association_ray_candidate?(model, link_row)
288
304
  model.connector_type == "Association" && link_row.path.to_s.empty? &&
289
305
  model.source_anchor && model.target_anchor &&
290
- (model.source_port || model.target_port || aligned_default_origins?(model)) &&
306
+ association_ray_ports?(model) &&
291
307
  association_ray_style?(model.style.to_h)
292
308
  end
293
309
 
310
+ def association_ray_ports?(model)
311
+ model.source_port || model.target_port || aligned_default_origins?(model) || default_center_ray?(model)
312
+ end
313
+
314
+ # Without stored offsets or ports, a width-2 custom line between
315
+ # horizontally separated boxes follows their centers to the stroke.
316
+ def default_center_ray?(model)
317
+ !model.has_geometry_offsets && model.style[:lwidth] == "2" && model.waypoints.size == 2 &&
318
+ [model.source_port, model.target_port].all?(&:nil?) &&
319
+ diagonal_origins?(model.source_anchor, model.target_anchor)
320
+ end
321
+
322
+ def diagonal_origins?(first, last)
323
+ first.x != last.x && first.y != last.y
324
+ end
325
+
294
326
  # Zero offsets omit ports, but an explicitly stored axis-aligned
295
327
  # width-2 ray still uses the logical centers and stroked outlines.
296
328
  def aligned_default_origins?(model)
@@ -396,9 +396,10 @@ module Ea
396
396
  # Internal producer flag, like :direct. A model relationship with no
397
397
  # link row is stroked at the 2px box outline, so its ends dock there.
398
398
  def phantom_style(connector_type)
399
- return { direct: true } if PHANTOM_DOUBLE_BACK_TYPES.include?(connector_type)
400
-
401
- %w[Aggregation Association].include?(connector_type) ? { phantom_outline: true } : {}
399
+ style = { lwidth: "1" }
400
+ style[:direct] = true if PHANTOM_DOUBLE_BACK_TYPES.include?(connector_type)
401
+ style[:phantom_outline] = true if %w[Aggregation Association].include?(connector_type)
402
+ style
402
403
  end
403
404
 
404
405
  # Computes the edge attachment point on source bounds that
@@ -424,6 +425,24 @@ module Ea
424
425
  end
425
426
  end
426
427
 
428
+ # Preserve original Path absence: authored two-point paths can look
429
+ # identical after routing but retain their stored label offsets.
430
+ def mark_plain_source_seat(model, link_row, context)
431
+ return if context || !link_row.path.to_s.empty? || model.connector_type != "Aggregation"
432
+ return unless model.style.values_at(:mode, :lwidth, :tree) == ["3", "2", nil]
433
+ return unless model.source_stereotype.to_s.empty?
434
+
435
+ model.style[:plain_source_seat] = true
436
+ end
437
+
438
+ def mark_plain_target_seat(model, link_row, context)
439
+ return if context || !link_row.path.to_s.empty? || model.connector_type != "Association"
440
+ return unless model.style.values_at(:mode, :lwidth, :tree) == ["3", "2", nil]
441
+ return unless model.target_stereotype.to_s.empty?
442
+
443
+ model.style[:plain_target_seat] = true
444
+ end
445
+
427
446
  def build_connector(link_row, diagram_row)
428
447
  connector = database.find_connector(link_row.connectorid)
429
448
  geom = parse_geometry_fields(link_row.geometry)
@@ -454,6 +473,8 @@ module Ea
454
473
  has_geometry_offsets: geometry_has_offsets?(link_row.geometry),
455
474
  is_substitutable: substitutable_connector?(connector)
456
475
  ), link_row, diagram_row, geom)
476
+ mark_plain_source_seat(model, link_row, context)
477
+ mark_plain_target_seat(model, link_row, context)
457
478
  model = collapse_partial_port_jog(model, link_row, diagram_row)
458
479
  model = correct_partial_port_origins(model, link_row, diagram_row, geom, context)
459
480
  model = direct_empty_path_ray(model, link_row, diagram_row, geom, context)
@@ -20,7 +20,7 @@ module Ea
20
20
  size_unit: context.size_unit, logical_height: context.logical_height,
21
21
  fill: context.theme.attribute_text_color,
22
22
  visibility_x_offset: context.theme.attribute_spec.visibility_x_offset,
23
- content_x_offset: context.theme.attribute_spec.content_x_offset
23
+ content_x_offset: context.theme.attribute_content_x_offset
24
24
  )
25
25
  end
26
26
  end
@@ -24,9 +24,18 @@ module Ea
24
24
  weight_bold: context.theme.text_weight_bold,
25
25
  stroke_in_text: context.theme.stroke_in_text_color,
26
26
  width_factor: context.theme.text_width_factor,
27
- line_offset: context.theme.compartments.header_line_offset
27
+ line_offset: context.theme.compartments.header_line_offset,
28
+ default_signal_header: default_signal_header?(context)
28
29
  )
29
30
  end
31
+
32
+ def default_signal_header?(context)
33
+ context.classifier.is_a?(Ea::Model::Signal) &&
34
+ context.classifier.stereotype_refs.empty? &&
35
+ context.element.font_size_inherited == true &&
36
+ context.element.font_family.nil? &&
37
+ !context.element.font_bold && !context.element.font_italic
38
+ end
30
39
  end
31
40
  end
32
41
  end
@@ -13,7 +13,7 @@ module Ea
13
13
  # classifier.
14
14
  module InstanceSlots
15
15
  ROW_OFFSET = 13
16
- SUBTITLE_EXTRA_OFFSET = 10
16
+ SUBTITLE_OFFSET_FROM_BOTTOM = 15
17
17
 
18
18
  module_function
19
19
 
@@ -30,11 +30,11 @@ module Ea
30
30
  fill = context.theme.attribute_text_color
31
31
  blocks = []
32
32
  slots.each_with_index do |slot, idx|
33
- y = first_y + (idx * ROW_OFFSET)
33
+ y = first_y + (idx * row_offset(context))
34
34
  blocks << line_text(slot, bounds, y, context, fill)
35
35
  end
36
36
  if subtitle
37
- y = first_y + (slots.size * ROW_OFFSET) + SUBTITLE_EXTRA_OFFSET
37
+ y = bounds.y + bounds.height + SUBTITLE_OFFSET_FROM_BOTTOM
38
38
  blocks << subtitle_text(subtitle, bounds, y, context, fill)
39
39
  end
40
40
  wrap(blocks.join("\n"), fill)
@@ -88,9 +88,19 @@ module Ea
88
88
  end
89
89
  module_function :content_x_offset
90
90
 
91
+ def row_offset(context)
92
+ return ROW_OFFSET unless Ea::Fonts::Metrics.compact_compartment_font?(
93
+ context.size, family: context.family, unit: context.size_unit
94
+ )
95
+
96
+ Ea::Fonts::Metrics.compartment_line_height(
97
+ context.size, family: context.family, unit: context.size_unit
98
+ )
99
+ end
100
+ module_function :row_offset
101
+
91
102
  def subtitle_text(text, bounds, y_pos, context, fill)
92
- estimated_width = text.length * context.size * 0.5
93
- x_pos = bounds.x + (bounds.width - estimated_width) / 2.0
103
+ x_pos = (bounds.x + (bounds.width - subtitle_width(text, context)) / 2.0).floor
94
104
  TextRenderer.new(
95
105
  content: text,
96
106
  x: x_pos,
@@ -102,6 +112,15 @@ module Ea
102
112
  end
103
113
  module_function :subtitle_text
104
114
 
115
+ def subtitle_width(text, context)
116
+ TextRenderer.estimate_width(
117
+ text, context.size, nil,
118
+ family: context.family, style: "italic",
119
+ logical_height: context.size_unit == "pt" ? context.logical_height : nil
120
+ ).round
121
+ end
122
+ module_function :subtitle_width
123
+
105
124
  def wrap(body, fill)
106
125
  group_style = "stroke-width:1;stroke-linecap:round;" \
107
126
  "stroke-linejoin:bevel; fill:#{fill};" \
@@ -34,6 +34,9 @@ module Ea
34
34
  target_bounds = bounds_map[target_ref]
35
35
  return nil unless source_bounds && target_bounds
36
36
  return nil if source_ref == target_ref
37
+ if flush_overlap_ray?(connector)
38
+ return clipped_pairs([source_bounds, target_bounds], [connector.source_anchor, connector.target_anchor])
39
+ end
37
40
  return trunk_pairs(points, source_bounds, target_bounds, connector.style[:tree]) if trunk
38
41
 
39
42
  square = OrthogonalRay.pairs_for(connector, source_bounds, target_bounds)
@@ -240,9 +243,13 @@ module Ea
240
243
  end
241
244
  private_class_method :stroke_outline?
242
245
 
243
- # The QEA builder marks the Mode 2 Association overlap rays it
244
- # produced; EA docks them on the outer stroke edge whatever the
245
- # line width. Naturally aligned centres keep the legacy docking.
246
+ # The builder certifies the empty flush route's shared row and nominal docks.
247
+ def flush_overlap_ray?(connector)
248
+ connector.connector_type == "Association" && connector.style.to_h[:flush_overlap_ray] == true
249
+ end
250
+ private_class_method :flush_overlap_ray?
251
+
252
+ # The QEA builder marks Mode 2 overlap rays for outer-stroke docking.
246
253
  def overlap_ray?(connector)
247
254
  connector.connector_type == "Association" && connector.style.to_h[:overlap_ray] == true
248
255
  end
@@ -429,7 +436,9 @@ module Ea
429
436
  bounds_map, divider_y_by_ref)
430
437
  return nil unless bounds_map && divider_y_by_ref
431
438
  return nil unless pairs.size == 2
439
+ return nil if flush_overlap_ray?(connector)
432
440
  return nil if explicit_association_ray?(pairs, connector)
441
+ return nil if connector.style.to_h[:stored_origin_ray] == true
433
442
 
434
443
  rel = relationship_for(connector, model_index)
435
444
  return nil unless rel
@@ -18,13 +18,14 @@ module Ea
18
18
  size:, size_unit: "pt", logical_height: nil,
19
19
  fill: "#000000", weight_normal: 400, weight_bold: 700,
20
20
  stroke_in_text: "#000000", width_factor: 0.612,
21
- line_offset: 6)
21
+ line_offset: 6, default_signal_header: false)
22
22
  logical_height = nil unless size_unit == "pt"
23
23
  layout = line_layout(
24
24
  lines, bounds: bounds, first_y: first_y, family: family,
25
25
  size: size, size_unit: size_unit, logical_height: logical_height,
26
26
  weight_normal: weight_normal, weight_bold: weight_bold,
27
- width_factor: width_factor, line_offset: line_offset
27
+ width_factor: width_factor, line_offset: line_offset,
28
+ default_signal_header: default_signal_header
28
29
  )
29
30
  text_blocks = layout.map do |line|
30
31
  TextRenderer.new(
@@ -44,12 +45,21 @@ module Ea
44
45
  def self.line_layout(lines, bounds:, first_y:, family:, size:,
45
46
  size_unit: "pt", logical_height: nil,
46
47
  weight_normal: 400, weight_bold: 700,
47
- width_factor: 0.612, line_offset: 6)
48
+ width_factor: 0.612, line_offset: 6, default_signal_header: false)
48
49
  logical_height = nil unless size_unit == "pt"
49
50
  line_h = Ea::Fonts::Metrics.compartment_line_height(
50
51
  size, family: family, unit: size_unit, offset: line_offset,
51
52
  logical_height: logical_height
52
53
  )
54
+ # Native default Cambria 7pt Signals use a 15px header pitch.
55
+ # Keep explicit fonts, logical heights and layout overrides intact.
56
+ if default_signal_header && family == "Cambria" && size == 7 &&
57
+ size_unit == "pt" && logical_height.nil? && line_offset == 6 &&
58
+ first_y == bounds.y + 13 && lines.size == 2 &&
59
+ lines.first == ["«signal»", :normal] && lines.last.last == :bold
60
+ first_y -= 1
61
+ line_h = 15
62
+ end
53
63
  ghost_shift = HeaderLines.parent_ghost_gap(lines)
54
64
  lines.each_with_index.map do |(text, style), idx|
55
65
  weight = %i[bold bold_italic].include?(style) ? weight_bold : weight_normal
@@ -19,8 +19,8 @@ module Ea
19
19
  def self.render(tagged_values, bounds:, first_y:, family:, size:, fill: "#000000",
20
20
  size_unit: "px", logical_height: nil)
21
21
  line_h = line_height(size, family, size_unit, logical_height)
22
- text_blocks = [build_text(bounds.x + centered_x_offset(bounds, size), first_y,
23
- HEADER_TEXT, family, size, fill,
22
+ text_blocks = [build_text(bounds.x + centered_x_offset(bounds, family, size, size_unit, logical_height),
23
+ first_y, HEADER_TEXT, family, size, fill,
24
24
  style: "italic", size_unit: size_unit, logical_height: logical_height)]
25
25
  tagged_values.each_with_index do |tv, idx|
26
26
  text_blocks << build_text(bounds.x + LINE_X_OFFSET, first_y + ((idx + 1) * line_h),
@@ -48,9 +48,18 @@ module Ea
48
48
  private_class_method :line_height
49
49
 
50
50
  # Center the "tags" header text within the bounds width.
51
- def self.centered_x_offset(bounds, size)
52
- text_width = HEADER_TEXT.length * (size / 2)
53
- centered = (bounds.width / 2 - text_width / 2).floor
51
+ def self.centered_x_offset(bounds, family, size, size_unit, logical_height)
52
+ # QEA point captions center on the same integer width emitted in SVG.
53
+ # Retain the legacy pixel API's placement.
54
+ text_width = if size_unit == "pt"
55
+ TextRenderer.estimate_width(
56
+ HEADER_TEXT, size, family: family, weight: 400,
57
+ style: "italic", logical_height: logical_height
58
+ ).round
59
+ else
60
+ HEADER_TEXT.length * (size / 2)
61
+ end
62
+ centered = ((bounds.width - text_width) / 2.0).floor
54
63
  [HEADER_X_OFFSET, centered].max
55
64
  end
56
65
  private_class_method :centered_x_offset
@@ -156,6 +156,7 @@ module Ea
156
156
  logical_height: logical_height,
157
157
  content_x_offset: attribute_content_x_offset(size, family, size_unit)),
158
158
  row_content_width(op_lines, 3, size, family, logical_height: logical_height),
159
+ tag_content_width(element, classifier, size, family, logical_height: logical_height),
159
160
  Compartment::EnumLiterals.content_width(
160
161
  enum_literals_for(classifier),
161
162
  theme: theme, size: size, family: family,
@@ -302,7 +303,7 @@ module Ea
302
303
  end
303
304
 
304
305
  def attribute_content_x_offset(size, family, size_unit)
305
- offset = (theme.attribute_spec.content_x_offset || Element::AttributeRenderer::DEFAULT_CONTENT_X_OFFSET) +
306
+ offset = (theme.attribute_content_x_offset || Element::AttributeRenderer::DEFAULT_CONTENT_X_OFFSET) +
306
307
  2 * (size - 7)
307
308
  offset -= 1 if Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit)
308
309
  offset
@@ -312,6 +313,17 @@ module Ea
312
313
  Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit) ? 7 : 8
313
314
  end
314
315
 
316
+ def tag_content_width(element, classifier, size, family, logical_height: nil)
317
+ return nil unless element.show_tagged_values
318
+
319
+ tagged_values_for(classifier).map do |tag|
320
+ TextRenderer.estimate_width("#{tag.key} = #{tag.value}", size,
321
+ family: family, weight: 400, style: "normal",
322
+ logical_height: logical_height).round +
323
+ 2 * Element::TaggedValueRenderer::LINE_X_OFFSET
324
+ end.max
325
+ end
326
+
315
327
  def row_content_width(lines, right_pad, size, family, **font_options)
316
328
  return nil if lines.to_a.empty?
317
329
 
@@ -49,11 +49,12 @@ module Ea
49
49
  # connector - the DiagramConnector (for relationship lookup)
50
50
  # end_kind - :source or :target
51
51
  # heading - [dx, dy] of the first leg leaving the anchor
52
+ # bend - first bend of a saved descending vertical stair
52
53
  # stored_labels - the connector has stored label boxes at
53
54
  # either end; false keeps the opposite-end
54
55
  # fallback
55
56
  def texts(text_box:, mult_box:, anchor:, connector:, end_kind:,
56
- tree: false, heading: nil, stored_labels: false)
57
+ tree: false, heading: nil, stored_labels: false, bend: nil)
57
58
  from_property, role, mult = role_and_multiplicity(connector, end_kind)
58
59
  if role_empty?(role) && mult_empty?(mult)
59
60
  other_from_prop, other_role, other_mult = role_and_multiplicity(connector,
@@ -92,7 +93,8 @@ module Ea
92
93
  stereo = stereo.nil? || stereo.empty? ? nil : "«#{stereo}»"
93
94
  direction = leg_direction(end_kind, heading)
94
95
  if direction && !(tree && zero_offset?(text_box, mult_box))
95
- text_pos = plain_vertical_role(connector, direction, anchor, text_box, role) ||
96
+ text_pos = plain_leftward_source_role(connector, direction, anchor, text_box, role) ||
97
+ plain_vertical_role(connector, direction, anchor, text_box, role) ||
96
98
  source_property_seat(connector, direction, anchor, text_box, role) ||
97
99
  plain_upward_seat(connector, direction, anchor, text_box, role) ||
98
100
  role_seat(direction, anchor, text_box, role) || text_pos
@@ -100,7 +102,9 @@ module Ea
100
102
  horizontal_tree_multiplicity(connector, direction, anchor, mult_box, mult) ||
101
103
  mult_seat(direction, anchor, mult_box) || mult_pos
102
104
  elsif end_kind == :target && !(tree && zero_offset?(text_box, mult_box))
103
- text_pos = target_property_role(connector, heading, anchor, text_box, role) ||
105
+ text_pos = plain_leftward_target_role(connector, heading, anchor, text_box, role) ||
106
+ target_leftward_property_role(connector, heading, anchor, text_box, role) ||
107
+ target_property_role(connector, heading, anchor, text_box, role) ||
104
108
  target_diagonal_property_role(connector, heading, anchor, text_box, role) ||
105
109
  target_vertical_role(connector, heading, anchor, text_box, role) ||
106
110
  target_rightward_role(heading, anchor, text_box, role) || text_pos
@@ -115,12 +119,69 @@ module Ea
115
119
 
116
120
  text_box = text_box.merge("alignment" => 1) if phantom_property_box?(connector, direction, text_box)
117
121
 
122
+ if end_kind == :target && !from_property
123
+ bend_positions = target_bend_positions(connector, bend, text_box, mult_box, role)
124
+ text_pos, mult_pos = bend_positions if bend_positions
125
+ end
126
+ if end_kind == :source && !from_property
127
+ stair_positions = source_stair_positions(connector, anchor, text_box, mult_box, role)
128
+ text_pos, mult_pos = stair_positions if stair_positions
129
+ end
130
+
118
131
  build_texts(role:, mult:, standalone:,
119
132
  text_pos:, mult_pos:, stereo:, text_box:)
120
133
  end
121
134
 
122
135
  private
123
136
 
137
+ def plain_leftward_target_role(connector, heading, anchor, box, role)
138
+ return nil unless heading && heading[0].negative? && heading[1].zero?
139
+ return nil unless plain_target_role?(connector, box, role)
140
+
141
+ PlainTargetSeat.position(
142
+ connector: connector, anchor: anchor, box: box,
143
+ width: Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
144
+ )
145
+ end
146
+
147
+ def plain_target_role?(connector, box, role)
148
+ seat_font? && cambria_box?(box) && !role_empty?(role) &&
149
+ connector_source_dest_stereotype(connector, :target).to_s.empty?
150
+ end
151
+
152
+ def plain_leftward_source_role(connector, direction, anchor, box, role)
153
+ return nil unless direction == :left && seat_font? && cambria_box?(box) && !role_empty?(role)
154
+ return nil unless connector_source_dest_stereotype(connector, :source).to_s.empty?
155
+
156
+ PlainSourceSeat.position(
157
+ connector: connector, anchor: anchor, box: box,
158
+ width: Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
159
+ )
160
+ end
161
+
162
+ def source_stair_positions(connector, anchor, text_box, mult_box, role)
163
+ return nil unless seat_font? && cambria_box?(text_box) && cambria_box?(mult_box)
164
+ return nil if role_empty?(role)
165
+
166
+ property = property_label?(connector, :source)
167
+ return nil unless property || connector_source_dest_stereotype(connector, :source).to_s.empty?
168
+
169
+ SourceStairSeat.positions(
170
+ connector: connector, anchor: anchor, text_box: text_box, mult_box: mult_box,
171
+ width: Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
172
+ )
173
+ end
174
+
175
+ def target_bend_positions(connector, bend, text_box, mult_box, role)
176
+ return nil unless bend && seat_font? && cambria_box?(text_box) && cambria_box?(mult_box)
177
+ return nil unless connector_source_dest_stereotype(connector, :target).to_s.empty? && !role_empty?(role)
178
+
179
+ TargetBendSeat.positions(
180
+ connector: connector, bend: bend, text_box: text_box, mult_box: mult_box,
181
+ width: Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
182
+ )
183
+ end
184
+
124
185
  # With stored boxes on the connector, an end that carries no
125
186
  # data of its own draws only its own stereotype; the data end
126
187
  # draws the role and multiplicity; with no data anywhere nothing
@@ -281,7 +342,7 @@ module Ea
281
342
  return nil unless direction == :up && cambria_box?(box)
282
343
  return nil unless [0, 1].include?(box["alignment"])
283
344
  return nil unless connector_source_dest_stereotype(connector, :source).empty?
284
- return nil unless plain_role_route?(connector.style.to_h)
345
+ return nil unless plain_role_route?(connector.style.to_h, box)
285
346
 
286
347
  width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
287
348
  return nil unless width
@@ -290,6 +351,29 @@ module Ea
290
351
  [x - SEAT_X_INSET - width, y - 6]
291
352
  end
292
353
 
354
+ # Moved target Property clusters on horizontal arrivals share
355
+ # the leftward source seat relative to their stored corner.
356
+ def target_leftward_property_role(connector, heading, anchor, box, role)
357
+ return nil unless heading && heading[0].negative? && heading[1].zero?
358
+ return nil unless moved_target_property_box?(connector, box)
359
+ return nil unless horizontal_target_property_route?(connector.style.to_h)
360
+
361
+ width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
362
+ return nil unless width
363
+
364
+ x, y = seat_corner(anchor, box)
365
+ [x - property_role_offsets(:left).first - width, y - 7]
366
+ end
367
+
368
+ def moved_target_property_box?(connector, box)
369
+ plain_vertical_box?(box) && box["alignment"] == 1 && [31, 35].include?(box["cy"]) &&
370
+ !stored_offsets_zero?(box) && property_label?(connector, :target)
371
+ end
372
+
373
+ def horizontal_target_property_route?(style)
374
+ style.values_at(:mode, :lwidth) == %w[3 2] && [nil, "LH"].include?(style[:tree])
375
+ end
376
+
293
377
  # An upward target uses the downward property's right edge and
294
378
  # the upward target baseline, both relative to its stored corner.
295
379
  def target_property_role(connector, heading, anchor, box, role)
@@ -389,7 +473,9 @@ module Ea
389
473
  end
390
474
  end
391
475
 
392
- def plain_role_route?(style)
476
+ def plain_role_route?(style, box)
477
+ return box["cy"] == 16 if orthogonal_stub_route?(style)
478
+
393
479
  %w[1 3].include?(style[:mode]) && %w[0 2].include?(style[:lwidth]) &&
394
480
  [nil, "V"].include?(style[:tree])
395
481
  end
@@ -473,6 +559,7 @@ module Ea
473
559
 
474
560
  def left_property_route?(style)
475
561
  downward_property_route?(style) || mode_two_route?(style) ||
562
+ (style[:overlap_ray] == true && style.values_at(:mode, :lwidth, :tree) == ["2", "0", nil]) ||
476
563
  (style[:mode] == "3" && %w[0 2].include?(style[:lwidth]) &&
477
564
  [nil, "H", "LV"].include?(style[:tree]))
478
565
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ module Label
7
+ # Plain leftward roles end before the stored corner, mirroring
8
+ # the 3px gutter and baseline of the rightward role seat.
9
+ module PlainSourceSeat
10
+ def self.position(connector:, anchor:, box:, width:)
11
+ return nil unless width && eligible_connector?(connector) && moved_role_box?(box)
12
+
13
+ [anchor[0] + box["ox"].to_i - width - 3, anchor[1] + box["oy"].to_i - 7]
14
+ end
15
+
16
+ def self.eligible_connector?(connector)
17
+ connector.connector_type == "Aggregation" &&
18
+ connector.style.to_h.values_at(:mode, :lwidth, :tree, :plain_source_seat) == ["3", "2", nil, true]
19
+ end
20
+
21
+ def self.moved_role_box?(box)
22
+ box.values_at("alignment", "cy") == [1, 18] &&
23
+ box.values_at("ox", "oy").map(&:to_i) != [0, 0]
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ module Label
7
+ # Plain leftward target roles end three pixels before the anchor.
8
+ module PlainTargetSeat
9
+ def self.position(connector:, anchor:, box:, width:)
10
+ return nil unless width && eligible_connector?(connector) && zero_role_box?(box)
11
+
12
+ [anchor[0] - width - 3, anchor[1] - 7]
13
+ end
14
+
15
+ def self.eligible_connector?(connector)
16
+ connector.connector_type == "Association" &&
17
+ connector.style.to_h.values_at(:mode, :lwidth, :tree, :plain_target_seat) == ["3", "2", nil, true]
18
+ end
19
+
20
+ def self.zero_role_box?(box)
21
+ box.values_at("alignment", "cy") == [1, 18] &&
22
+ box.values_at("ox", "oy").map(&:to_i) == [0, 0]
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ module Label
7
+ # Moved source pairs on descending stairs share a baseline 18px
8
+ # below the stored source dock. Property clusters reserve the
9
+ # wider role gutter; multiplicities keep the plain 3px gutter.
10
+ module SourceStairSeat
11
+ def self.positions(connector:, anchor:, text_box:, mult_box:, width:)
12
+ return nil unless width && connector.connector_type == "Aggregation"
13
+ return nil unless connector.style.to_h.values_at(:mode, :lwidth, :tree) == %w[3 0 V]
14
+ return nil unless descending_stair?(connector.waypoints)
15
+
16
+ height, inset = role_geometry(connector)
17
+ return nil unless moved_box?(text_box, height) && moved_box?(mult_box, 18)
18
+
19
+ [position(anchor, text_box, -width - inset), position(anchor, mult_box, 3)]
20
+ end
21
+
22
+ def self.role_geometry(connector)
23
+ connector.source_stereotype.to_s.strip.casecmp("property").zero? ? [35, 60] : [18, 3]
24
+ end
25
+ private_class_method :role_geometry
26
+
27
+ def self.descending_stair?(waypoints)
28
+ return false unless waypoints.size == 4 && waypoints.all?(&:position)
29
+
30
+ first, bend, corner, last = waypoints.map(&:position)
31
+ descending_leg?(first, bend) && descending_leg?(corner, last) &&
32
+ horizontal_leg?(bend, corner)
33
+ end
34
+ private_class_method :descending_stair?
35
+
36
+ def self.descending_leg?(first, last)
37
+ first.x == last.x && first.y < last.y
38
+ end
39
+ private_class_method :descending_leg?
40
+
41
+ def self.horizontal_leg?(first, last)
42
+ first.y == last.y && first.x != last.x
43
+ end
44
+ private_class_method :horizontal_leg?
45
+
46
+ def self.position(anchor, box, inset)
47
+ [anchor[0] + box["ox"].to_i + inset, anchor[1] + box["oy"].to_i + 18]
48
+ end
49
+ private_class_method :position
50
+
51
+ def self.moved_box?(box, height)
52
+ box["alignment"] == 1 && box["cy"] == height &&
53
+ [box["ox"].to_i, box["oy"].to_i] != [0, 0]
54
+ end
55
+ private_class_method :moved_box?
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ module Label
7
+ # Moved plain target pairs on saved vertical stairs use the first
8
+ # bend, opposing 8px gutters and a baseline 3px above the saved OY.
9
+ module TargetBendSeat
10
+ def self.reference(points)
11
+ return nil unless points.size == 4
12
+
13
+ first, bend, corner, last = points
14
+ bend if descending_leg?(first, bend) && descending_leg?(corner, last) &&
15
+ bend[1] == corner[1] && bend[0] != corner[0]
16
+ end
17
+
18
+ def self.descending_leg?(first, last)
19
+ first[0] == last[0] && first[1] < last[1]
20
+ end
21
+ private_class_method :descending_leg?
22
+
23
+ def self.positions(connector:, bend:, text_box:, mult_box:, width:)
24
+ return nil unless bend && width && connector.connector_type == "Association"
25
+ return nil unless connector.style.to_h.values_at(:mode, :lwidth, :tree) == %w[3 0 V]
26
+ return nil unless moved_box?(text_box, 31) && moved_box?(mult_box, 16)
27
+
28
+ [position(bend, text_box, -width - 8), position(bend, mult_box, 8)]
29
+ end
30
+
31
+ def self.position(bend, box, inset)
32
+ [bend[0] + box["ox"].to_i + inset, bend[1] + box["oy"].to_i - 3]
33
+ end
34
+ private_class_method :position
35
+
36
+ def self.moved_box?(box, height)
37
+ box["alignment"] == 1 && box["cy"] == height &&
38
+ [box["ox"].to_i, box["oy"].to_i] != [0, 0]
39
+ end
40
+ private_class_method :moved_box?
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -15,6 +15,10 @@ module Ea
15
15
  # register rather than editing the orchestrator (OCP).
16
16
  module Label
17
17
  autoload :EndLabel, "ea/svg/ea_emitter/label/end_label"
18
+ autoload :TargetBendSeat, "ea/svg/ea_emitter/label/target_bend_seat"
19
+ autoload :SourceStairSeat, "ea/svg/ea_emitter/label/source_stair_seat"
20
+ autoload :PlainSourceSeat, "ea/svg/ea_emitter/label/plain_source_seat"
21
+ autoload :PlainTargetSeat, "ea/svg/ea_emitter/label/plain_target_seat"
18
22
  autoload :MidpointLabel, "ea/svg/ea_emitter/label/midpoint_label"
19
23
  autoload :Registry, "ea/svg/ea_emitter/label/registry"
20
24
  end
@@ -97,6 +97,7 @@ module Ea
97
97
  mult_box: tgt_mb,
98
98
  anchor: target_pt, connector: connector,
99
99
  end_kind: :target, tree: tree && tree[:target],
100
+ bend: Label::TargetBendSeat.reference(points),
100
101
  heading: (stored_tgt || stored_labels) && target_heading(connector, points),
101
102
  stored_labels: stored_labels).each { |t| texts << t }
102
103
  texts
@@ -108,6 +108,7 @@ module Ea
108
108
  @geometry = geometry || DEFAULT_GEOMETRY.dup
109
109
  @frame = frame || DEFAULT_FRAME.dup
110
110
  @compartments = compartments || DEFAULT_COMPARTMENTS.dup
111
+ @attribute_spec_override = attribute_spec
111
112
  @attribute_spec = attribute_spec || DEFAULT_ATTR.dup
112
113
  @markers = markers || DEFAULT_MARKERS.dup
113
114
  @package = package || DEFAULT_PACKAGE.dup
@@ -137,6 +138,12 @@ module Ea
137
138
  @label_font_size_unit || font_size_unit
138
139
  end
139
140
 
141
+ # Attribute cells have a different inherited seat from enum literals,
142
+ # which continue to consume the legacy attribute_spec default.
143
+ def attribute_content_x_offset
144
+ @attribute_spec_override ? attribute_spec.content_x_offset : 22
145
+ end
146
+
140
147
  def with(**overrides)
141
148
  clear_label_override(overrides, :font_family, :label_font_family)
142
149
  clear_label_override(overrides, :font_family, :element_font_family)
@@ -193,7 +200,8 @@ module Ea
193
200
  marker_stroke_width: marker_stroke_width,
194
201
  text_width_factor: text_width_factor,
195
202
  geometry: geometry, frame: frame, compartments: compartments,
196
- attribute_spec: attribute_spec, markers: markers,
203
+ # Preserve omission so reconstruction retains the attribute-cell default.
204
+ attribute_spec: @attribute_spec_override, markers: markers,
197
205
  package: package, note: note, fills: fills
198
206
  }
199
207
  end
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.127"
7
+ VERSION = "0.6.128"
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.127
4
+ version: 0.6.128
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -940,7 +940,11 @@ files:
940
940
  - lib/ea/svg/ea_emitter/label.rb
941
941
  - lib/ea/svg/ea_emitter/label/end_label.rb
942
942
  - lib/ea/svg/ea_emitter/label/midpoint_label.rb
943
+ - lib/ea/svg/ea_emitter/label/plain_source_seat.rb
944
+ - lib/ea/svg/ea_emitter/label/plain_target_seat.rb
943
945
  - lib/ea/svg/ea_emitter/label/registry.rb
946
+ - lib/ea/svg/ea_emitter/label/source_stair_seat.rb
947
+ - lib/ea/svg/ea_emitter/label/target_bend_seat.rb
944
948
  - lib/ea/svg/ea_emitter/labels.rb
945
949
  - lib/ea/svg/ea_emitter/layer.rb
946
950
  - lib/ea/svg/ea_emitter/layer_sequencer.rb