ea 0.6.140 → 0.6.142

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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/config/themes/default.yml +7 -0
  3. data/lib/ea/cli/app.rb +10 -0
  4. data/lib/ea/cli/command/diagrams.rb +4 -1
  5. data/lib/ea/cli/command/render.rb +8 -1
  6. data/lib/ea/cli/command/spa.rb +2 -1
  7. data/lib/ea/cli/command/svg.rb +9 -6
  8. data/lib/ea/fonts/metrics.rb +44 -16
  9. data/lib/ea/model/diagram.rb +6 -0
  10. data/lib/ea/qea/services/database_loader.rb +4 -1
  11. data/lib/ea/sources/qea/diagram_builder/routing.rb +64 -22
  12. data/lib/ea/sources/qea/diagram_builder.rb +4 -1
  13. data/lib/ea/sources/xmi/adapter.rb +20 -0
  14. data/lib/ea/sources/xmi/connector_index.rb +30 -0
  15. data/lib/ea/sources/xmi/constraint_builder.rb +81 -0
  16. data/lib/ea/sources/xmi/diagram_builder.rb +22 -11
  17. data/lib/ea/sources/xmi/extension_style_parser.rb +1 -1
  18. data/lib/ea/sources/xmi/extension_types.rb +9 -2
  19. data/lib/ea/sources/xmi/relationship_builder.rb +17 -11
  20. data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +10 -26
  21. data/lib/ea/sources/xmi.rb +2 -0
  22. data/lib/ea/spa/generator.rb +4 -2
  23. data/lib/ea/spa/projector.rb +4 -2
  24. data/lib/ea/svg/ea_emitter/bottom_margin.rb +16 -12
  25. data/lib/ea/svg/ea_emitter/bounds_calculator.rb +22 -14
  26. data/lib/ea/svg/ea_emitter/canvas.rb +43 -5
  27. data/lib/ea/svg/ea_emitter/canvas_anchor.rb +12 -4
  28. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -0
  30. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +5 -0
  31. data/lib/ea/svg/ea_emitter/compartment/header.rb +8 -1
  32. data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +6 -1
  33. data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +5 -0
  34. data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +6 -1
  35. data/lib/ea/svg/ea_emitter/compartment/marker.rb +5 -0
  36. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +7 -2
  37. data/lib/ea/svg/ea_emitter/compartment/note_text.rb +5 -0
  38. data/lib/ea/svg/ea_emitter/compartment/operations.rb +5 -0
  39. data/lib/ea/svg/ea_emitter/compartment/package_contents.rb +5 -0
  40. data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +12 -1
  41. data/lib/ea/svg/ea_emitter/compartment/shape.rb +14 -1
  42. data/lib/ea/svg/ea_emitter/compartment/stereotype_icon.rb +6 -0
  43. data/lib/ea/svg/ea_emitter/compartment/tagged_values.rb +5 -0
  44. data/lib/ea/svg/ea_emitter/compartment.rb +13 -0
  45. data/lib/ea/svg/ea_emitter/connector_bridges.rb +18 -4
  46. data/lib/ea/svg/ea_emitter/connector_redock.rb +13 -6
  47. data/lib/ea/svg/ea_emitter/content_extent.rb +11 -2
  48. data/lib/ea/svg/ea_emitter/diagram_frame.rb +7 -0
  49. data/lib/ea/svg/ea_emitter/document.rb +22 -6
  50. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +6 -1
  51. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +9 -0
  52. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +7 -0
  53. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +18 -14
  54. data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +1 -8
  55. data/lib/ea/svg/ea_emitter/elements.rb +36 -12
  56. data/lib/ea/svg/ea_emitter/font_resolver.rb +3 -1
  57. data/lib/ea/svg/ea_emitter/label/end_label.rb +113 -19
  58. data/lib/ea/svg/ea_emitter/label/midpoint_label.rb +66 -0
  59. data/lib/ea/svg/ea_emitter/label/plain_source_seat.rb +25 -2
  60. data/lib/ea/svg/ea_emitter/label/plain_target_seat.rb +64 -1
  61. data/lib/ea/svg/ea_emitter/labels.rb +64 -35
  62. data/lib/ea/svg/ea_emitter/layer_sequencer.rb +14 -7
  63. data/lib/ea/svg/ea_emitter/markers.rb +1 -1
  64. data/lib/ea/svg/ea_emitter/paint_order.rb +5 -4
  65. data/lib/ea/svg/ea_emitter/pen.rb +19 -0
  66. data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
  67. data/lib/ea/svg/ea_emitter/text_renderer.rb +24 -8
  68. data/lib/ea/svg/ea_emitter.rb +1 -0
  69. data/lib/ea/svg/parity/source.rb +1 -1
  70. data/lib/ea/version.rb +1 -1
  71. metadata +5 -2
@@ -22,6 +22,9 @@ module Ea
22
22
  SEAT_FONT_SIZE = 7
23
23
  SEAT_X_INSET = 3
24
24
  SEAT_STEREOTYPE_GAP = 12
25
+ SEAT_STEREOTYPE_ONLY_INSET = 8
26
+ SEAT_STEREOTYPE_ONLY_DROP = 15
27
+ SEAT_MULT_DROP = 15
25
28
  # EA keeps an end-label role's left edge at diagram x >= 3; the
26
29
  # stereotype line moves with the role. Measured on the Plateau
27
30
  # v5.1 export: of 776 saved end labels, the only three whose
@@ -131,7 +134,8 @@ module Ea
131
134
  saved_ray_target_property_role(connector, heading, anchor, text_box, role) ||
132
135
  target_diagonal_property_role(connector, heading, anchor, text_box, role) ||
133
136
  target_vertical_role(connector, heading, anchor, text_box, role) ||
134
- target_rightward_role(heading, anchor, text_box, role) || text_pos
137
+ target_rightward_role(heading, anchor, text_box, role) ||
138
+ aligned_target_role(connector, heading, text_pos, text_box, role) || text_pos
135
139
  if horizontal_aligned_target_baseline?(connector, heading, text_box, role)
136
140
  text_pos = [text_pos[0], seat_corner(anchor, text_box)[1] - 7]
137
141
  end
@@ -142,6 +146,7 @@ module Ea
142
146
  mult_pos = target_multiplicity_seat(connector, heading, anchor, mult_box) ||
143
147
  target_diagonal_property_multiplicity(connector, heading, anchor, text_box, mult_box) ||
144
148
  face_multiplicity || target_horizontal_multiplicity(heading, anchor, mult_box, mult) ||
149
+ target_sloped_rightward_multiplicity(connector, heading, anchor, mult_box, role, mult) ||
145
150
  mult_pos
146
151
  end
147
152
 
@@ -152,10 +157,14 @@ module Ea
152
157
  [text_box, mult_box], mult) || mult_pos
153
158
  if external_upward_property_source?(connector, heading, anchor, text_box, source_bounds)
154
159
  text_pos = external_upward_property_role(anchor, role) || text_pos
160
+ mult_pos = external_upward_property_multiplicity(anchor, mult) || mult_pos
155
161
  end
156
162
  end
157
163
 
158
- text_box = text_box.merge("alignment" => 1) if phantom_property_box?(connector, direction, text_box)
164
+ if phantom_property_box?(connector, direction, text_box) ||
165
+ rightward_phantom_property_box?(connector, direction, text_box)
166
+ text_box = text_box.merge("alignment" => 1)
167
+ end
159
168
 
160
169
  if end_kind == :target && !from_property
161
170
  bend_positions = target_bend_positions(connector, bend, text_box, mult_box, role)
@@ -167,8 +176,11 @@ module Ea
167
176
  end
168
177
 
169
178
  role_bounds = umldi_bounds[end_kind == :source ? :source_role : :target_role]
179
+ # Unmoved lateral roles keep their measured seat: the saved UMLDI
180
+ # rectangle is stale for them (test B58D1A53 +role2 saved 24px low).
181
+ role_bounds = nil if PlainTargetSeat.unmoved_lateral_role?(connector, text_box)
170
182
  mult_bounds = umldi_bounds[end_kind == :source ? :source_multiplicity : :target_multiplicity]
171
- text_pos = umldi_label_position(role_bounds) if role_bounds
183
+ text_pos = umldi_label_position(role_bounds, text_box) if role_bounds
172
184
  centering = role_bounds ? bounds_role_centering(connector, end_kind, role, stereo, text_box) : 0
173
185
  text_pos = [text_pos[0] + centering, text_pos[1]] if centering.positive?
174
186
  mult_pos = umldi_label_position(mult_bounds) if mult_bounds
@@ -188,6 +200,12 @@ module Ea
188
200
 
189
201
  private
190
202
 
203
+ # The width the renderer prints (real metrics, else its glyph estimate),
204
+ # so seats do not vanish for glyphs the metrics table lacks.
205
+ def seat_width(text)
206
+ TextRenderer.estimate_width(text, font_size, family: font_family)
207
+ end
208
+
191
209
  # Measured only for default Cambria 7pt Property roles on a vertical
192
210
  # leg rising from the end; other fonts, boxes and legs keep their
193
211
  # unclamped seats.
@@ -286,12 +304,13 @@ module Ea
286
304
  end
287
305
 
288
306
  def plain_leftward_source_role(connector, direction, anchor, box, role)
289
- return nil unless direction == :left && seat_font? && cambria_box?(box) && !role_empty?(role)
307
+ return nil unless direction == :left && seat_font? && !role_empty?(role)
308
+ return nil unless box && cambria_box?(box.merge("alignment" => 1))
290
309
  return nil unless connector_source_dest_stereotype(connector, :source).to_s.empty?
291
310
 
292
311
  PlainSourceSeat.position(
293
312
  connector: connector, anchor: anchor, box: box,
294
- width: Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
313
+ width: seat_width(role)
295
314
  )
296
315
  end
297
316
 
@@ -325,12 +344,30 @@ module Ea
325
344
  # fallback.
326
345
  def own_stereotype_only(text_box, anchor, connector, end_kind)
327
346
  stereo = connector_source_dest_stereotype(connector, end_kind)
328
- pos = position_at(text_box, anchor)
347
+ pos = stereotype_only_seat(text_box, anchor, connector, end_kind, stereo) ||
348
+ position_at(text_box, anchor)
329
349
  return [] if stereo.nil? || stereo.empty? || pos.nil?
330
350
 
331
351
  [text_at(pos[0], pos[1], "«#{stereo}»")]
332
352
  end
333
353
 
354
+ # EMPIRICAL FIT (34FF671B and 5038EEE3, the only two ends in the
355
+ # Plateau v5.1 corpus that draw a Property stereotype with no role
356
+ # or multiplicity): a one-line centred target box is seated at its
357
+ # corner, anchor + (OX, OY) with no sign flip, like the source-end
358
+ # corner seat; the stereotype ends 8px left of the corner and its
359
+ # baseline sits 15px below it. Both reproduce the native SVG exactly.
360
+ def stereotype_only_seat(text_box, anchor, connector, end_kind, stereo)
361
+ return nil unless end_kind == :target && property_label?(connector, end_kind)
362
+ return nil unless seat_font? && cambria_box?(text_box) && text_box.values_at("alignment", "cy") == [1, 16]
363
+
364
+ width = Ea::Fonts::Metrics.text_length("«#{stereo}»", font_size, family: font_family)
365
+ return nil unless width
366
+
367
+ x, y = seat_corner(anchor, text_box)
368
+ [x - width - SEAT_STEREOTYPE_ONLY_INSET, y + SEAT_STEREOTYPE_ONLY_DROP]
369
+ end
370
+
334
371
  # EA's tree-route label clusters (hand-verified against the
335
372
  # EA-published reference, SY 8542 with canvas=(0,0)):
336
373
  # source-end pairs stack at the exit point (role +3/-23,
@@ -374,8 +411,8 @@ module Ea
374
411
  role.to_s.length * 6
375
412
  mult_width = Ea::Fonts::Metrics.text_length(mult.to_s, font_size, family: font_family) ||
376
413
  mult.to_s.length * 6
377
- [[anchor[0] - 60 - role_width, anchor[1] - 7],
378
- [anchor[0] - mult_width - 3, anchor[1] + 15]]
414
+ [[[anchor[0] - 60 - role_width, MIN_ROLE_X].max, anchor[1] - 7],
415
+ [[anchor[0] - mult_width - 3, MIN_ROLE_X].max, anchor[1] + 15]]
379
416
  end
380
417
 
381
418
  # Stored source-end boxes on a 7pt label whose first leg
@@ -461,10 +498,10 @@ module Ea
461
498
  end
462
499
 
463
500
  # Measured box heights: left/down Property clusters store CY 35,
464
- # right-seat clusters CY 31. Phantom outlines leaving down carry a
465
- # synthesized zero box that centers the same way.
501
+ # right-seat and up clusters CY 31. Phantom outlines leaving down
502
+ # carry a synthesized zero box that centers the same way.
466
503
  def centered_property_box?(connector, direction, box)
467
- cy = { left: 35, down: 35, right: 31 }[direction]
504
+ cy = { left: 35, down: 35, right: 31, up: 31 }[direction]
468
505
  (cy && box.values_at("cy", "alignment") == [cy, 1]) ||
469
506
  (direction == :down && phantom_property_box?(connector, direction, box))
470
507
  end
@@ -486,6 +523,16 @@ module Ea
486
523
  property_label?(connector, :source)
487
524
  end
488
525
 
526
+ # A rightward phantom keeps the plain role seat; only its stereotype
527
+ # is centred under the role (0BE81AC5: native stereotype 4px right of
528
+ # the role for a 52px role over a 44px stereotype).
529
+ def rightward_phantom_property_box?(connector, direction, box)
530
+ direction == :right && seat_font? && cambria_box?(box) &&
531
+ box["alignment"].nil? && stored_offsets_zero?(box) &&
532
+ connector.style.to_h[:phantom_outline] == true &&
533
+ property_label?(connector, :source)
534
+ end
535
+
489
536
  def property_role_x(direction, origin, offset)
490
537
  direction == :right ? origin + offset : origin - offset
491
538
  end
@@ -526,6 +573,17 @@ module Ea
526
573
  [[anchor[0] - inset - width, MIN_ROLE_X].max, anchor[1] + baseline]
527
574
  end
528
575
 
576
+ # Multiplicity of a floating upward source (5038EEE3: both such
577
+ # ends, QEA; native "0..1" at anchor.x - width - 3, anchor.y + 15).
578
+ def external_upward_property_multiplicity(anchor, mult)
579
+ return nil if mult_empty?(mult)
580
+
581
+ width = Ea::Fonts::Metrics.text_length(mult, font_size, family: font_family)
582
+ return nil unless width
583
+
584
+ [anchor[0] - width - SEAT_X_INSET, anchor[1] + SEAT_MULT_DROP]
585
+ end
586
+
529
587
  def external_upward_property_source?(connector, heading, anchor, box, bounds)
530
588
  upward_source_heading?(heading) &&
531
589
  zero_source_property_box?(box) && cambria_box?(box) && source_property_cambria?(connector) &&
@@ -678,6 +736,23 @@ module Ea
678
736
  [anchor[0] + 8, anchor[1] - 7]
679
737
  end
680
738
 
739
+ # Stored ALN=2 role boxes sit outside their corner; see
740
+ # PlainTargetSeat.aligned_position.
741
+ def aligned_target_role(connector, heading, text_pos, box, role)
742
+ PlainTargetSeat.aligned_role(
743
+ connector: connector, heading: heading, text_pos: text_pos,
744
+ box: box, role: role, font_eligible: aligned_seat_font?,
745
+ width: seat_width(role.to_s),
746
+ target_stereotype: connector_source_dest_stereotype(connector, :target)
747
+ )
748
+ end
749
+
750
+ # 7pt regular Cambria, the only face the ALN=2 samples were taken in.
751
+ def aligned_seat_font?
752
+ seat_font? && font_family.to_s.casecmp("Cambria").zero? &&
753
+ font_weight == 400 && font_style == "normal"
754
+ end
755
+
681
756
  def target_vertical_role(connector, heading, anchor, box, role)
682
757
  return nil unless heading && heading[0].zero? && !heading[1].zero?
683
758
 
@@ -886,6 +961,18 @@ module Ea
886
961
  mult_seat(:right, anchor, box)
887
962
  end
888
963
 
964
+ # EMPIRICAL FIT (5038EEE3, the only connector in the Plateau v5.1
965
+ # corpus with a Property source and a role-less target whose leg
966
+ # arrives sloped from the right):
967
+ # the multiplicity takes the rightward corner seat, as on a
968
+ # horizontal rightward leg. Reproduces the native SVG exactly.
969
+ def target_sloped_rightward_multiplicity(connector, heading, anchor, box, role, mult)
970
+ return nil unless seat_font? && role_empty?(role) && !mult_empty?(mult)
971
+ return nil unless heading && heading[0] > heading[1].abs && property_label?(connector, :source)
972
+
973
+ mult_seat(:right, anchor, box)
974
+ end
975
+
889
976
  def target_left_face_multiplicity?(connector, heading, box, mult, target_face)
890
977
  seat_font? && !mult_empty?(mult) && property_label?(connector, :target) && cambria_box?(box) &&
891
978
  stored_offsets_zero?(box) && target_left_face_heading?(heading, target_face)
@@ -905,9 +992,12 @@ module Ea
905
992
  end
906
993
 
907
994
  def target_multiplicity_box?(connector, box)
908
- # Property targets seat at the stored corner; plain moved labels
909
- # retain their explicit offsets.
910
- cambria_box?(box) && (stored_offsets_zero?(box) || property_label?(connector, :target))
995
+ # Property targets and vertical tree targets (5038EEE3) seat at
996
+ # the stored corner; plain moved labels retain their explicit
997
+ # offsets.
998
+ cambria_box?(box) &&
999
+ (stored_offsets_zero?(box) || property_label?(connector, :target) ||
1000
+ connector.style.to_h[:tree] == "LV")
911
1001
  end
912
1002
 
913
1003
  def cambria_box?(box)
@@ -983,8 +1073,11 @@ module Ea
983
1073
  position[1] - font_pixel_size.ceil + height]
984
1074
  end
985
1075
 
986
- def umldi_label_position(bounds)
987
- [bounds[:x], bounds[:y] + font_pixel_size.ceil]
1076
+ # EA draws an ALN=2 role 1px right of its saved UMLDI bounds
1077
+ # (basic: 9 roles in 8 diagrams, all exactly +1).
1078
+ def umldi_label_position(bounds, box = nil)
1079
+ inset = box && box["alignment"] == 2 ? 1 : 0
1080
+ [bounds[:x] + inset, bounds[:y] + font_pixel_size.ceil]
988
1081
  end
989
1082
 
990
1083
  def umldi_stereotype_position(bounds, role, stereo, text_box)
@@ -1072,12 +1165,13 @@ module Ea
1072
1165
  property unless property && opposite_role_property?(association, end_kind, property)
1073
1166
  end
1074
1167
 
1075
- # A UML attribute belongs to the classifier opposite its role.
1076
- # Do not draw that same role again at its anonymous owner end.
1168
+ # A UML attribute belongs to the classifier opposite its role: a
1169
+ # property named after the opposite end's role, and not after this
1170
+ # end's own, is that end's label. Do not draw it again here.
1077
1171
  def opposite_role_property?(association, end_kind, property)
1078
1172
  own = end_kind == :source ? association.source_role_name : association.target_role_name
1079
1173
  other = end_kind == :source ? association.target_role_name : association.source_role_name
1080
- role_empty?(own) && other == property.name
1174
+ other == property.name && own != property.name
1081
1175
  end
1082
1176
 
1083
1177
  def placed_classifier_id(association, end_kind)
@@ -49,6 +49,35 @@ module Ea
49
49
  render_text(mx, my, content)
50
50
  end
51
51
 
52
+ # Returns the name-direction triangle `<polygon>` for a connector
53
+ # whose stored LMT box carries DIR=1, or nil. EA seats it beside
54
+ # the name, pointing along the source-to-target direction at the
55
+ # midpoint. Measured only on Direction="Unspecified" links (all 10
56
+ # basic.qea DIR=1 links; no DIR=1 link with another Direction exists
57
+ # in basic, test, simple, ArcGIS or PLATEAU), so other directions
58
+ # draw no triangle until a native sample shows their form. Right/down
59
+ # geometry is measured on basic.qea's native SVGs (2AAB709F,
60
+ # 4DBDAB9E, 4F421236, D2FA55D3 and 6E95B74E: tip 16px past the
61
+ # name, 10px long, 6px wide; below the name for a vertical leg).
62
+ # Left/up are the mirrored extrapolation: no corpus case.
63
+ def direction_marker(connector, points)
64
+ return nil unless midpoint_content(connector)
65
+
66
+ box = stored_name_box(connector, points)
67
+ return nil unless box && box["direction"] == 1
68
+ return nil unless connector.direction == "Unspecified"
69
+
70
+ # A name with an unmeasured glyph is drawn at the default seat, so
71
+ # the triangle follows where the name is drawn, by its printed width.
72
+ x, y = stored_name_position(connector, points) || label_position(connector, points)
73
+ width = TextRenderer.estimate_width(midpoint_content(connector), font_size, family: font_family)
74
+
75
+ tri = direction_triangle(x, y, width, travel(points))
76
+ coords = tri.flat_map { |px, py| [translate_x(px), translate_y(py)] }
77
+ %(<polygon points="#{coords.map { |v| Canvas.coord(v) }.join(" ")}" ) +
78
+ %(shape-rendering="auto" style="fill-rule:evenodd;"/>)
79
+ end
80
+
52
81
  # Returns the formatted «stereotype» string or nil. Used
53
82
  # by Label::Registry to dispatch end-label vs midpoint.
54
83
  def stereotype_label(connector)
@@ -194,6 +223,43 @@ module Ea
194
223
  target[1] < last_bend[1]
195
224
  end
196
225
 
226
+ # Unit travel direction [dx, dy] at the midpoint: dominant axis only.
227
+ def travel(points)
228
+ mid_idx = points.size / 2
229
+ from, to = if points.size.odd?
230
+ [points[mid_idx - 1], points[mid_idx + 1]]
231
+ else
232
+ [points[mid_idx - 1], points[mid_idx]]
233
+ end
234
+ dx = to[0] - from[0]
235
+ dy = to[1] - from[1]
236
+ return [dx.negative? ? -1 : 1, 0] if dx.abs >= dy.abs
237
+
238
+ [0, dy.negative? ? -1 : 1]
239
+ end
240
+
241
+ def direction_triangle(x, y, width, travel)
242
+ dx, dy = travel
243
+ if dy.zero?
244
+ tip_x = dx.positive? ? x + width + 16 : x - 16
245
+ base_x = tip_x - (dx * 10)
246
+ [[tip_x, y - 4], [base_x, y - 7], [base_x, y - 1]]
247
+ else
248
+ center_x = x + (width / 2.0).floor
249
+ tip_y = dy.positive? ? y + 13 : y - 21
250
+ base_y = tip_y - (dy * 10)
251
+ [[center_x, tip_y], [center_x - 3, base_y], [center_x + 3, base_y]]
252
+ end
253
+ end
254
+
255
+ def translate_x(value)
256
+ canvas ? canvas.translate_x(value) : value
257
+ end
258
+
259
+ def translate_y(value)
260
+ canvas ? canvas.translate_y(value) : value
261
+ end
262
+
197
263
  def midpoint(points)
198
264
  mid_idx = points.size / 2
199
265
  if points.size.odd?
@@ -7,10 +7,33 @@ module Ea
7
7
  # Plain leftward roles end before the stored corner, mirroring
8
8
  # the 3px gutter and baseline of the rightward role seat.
9
9
  module PlainSourceSeat
10
+ # EMPIRICAL FIT, stored 13px role boxes moved left of a leftward
11
+ # straight source on mode 3, width 0: ALN=0 ends 3px before the
12
+ # corner (basic D351FC34, F32E5AFE roleTwo, Association), ALN=2
13
+ # 2px before it (basic 6D0349D5 role b, Aggregation); both sit
14
+ # 7px above the corner like the Aggregation case below.
15
+ # Only cy=13 boxes are measured: every negative-OX LLT box with ALN 0 or
16
+ # 2 in basic, test, simple, ArcGIS and PLATEAU has cy=13 (cy=14 occurs
17
+ # only with ALN=1), so the ALN=2 target seat's 13-or-14 tolerance is
18
+ # not extended to the source.
19
+ STORED_INSETS = { 0 => 3, 2 => 2 }.freeze
20
+
10
21
  def self.position(connector:, anchor:, box:, width:)
11
- return nil unless width && eligible_connector?(connector) && moved_role_box?(box)
22
+ return nil unless width
23
+
24
+ inset = stored_inset(connector, box)
25
+ inset = 3 if inset.nil? && eligible_connector?(connector) && moved_role_box?(box)
26
+ return nil unless inset
27
+
28
+ [anchor[0] + box["ox"].to_i - width - inset, anchor[1] + box["oy"].to_i - 7]
29
+ end
30
+
31
+ def self.stored_inset(connector, box)
32
+ return nil unless %w[Association Aggregation].include?(connector.connector_type)
33
+ return nil unless connector.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "0", nil]
34
+ return nil unless box["cy"] == 13 && box["ox"].to_i.negative?
12
35
 
13
- [anchor[0] + box["ox"].to_i - width - 3, anchor[1] + box["oy"].to_i - 7]
36
+ STORED_INSETS[box["alignment"]]
14
37
  end
15
38
 
16
39
  def self.eligible_connector?(connector)
@@ -7,11 +7,74 @@ module Ea
7
7
  # Plain leftward target roles end three pixels before the anchor.
8
8
  module PlainTargetSeat
9
9
  def self.position(connector:, anchor:, box:, width:)
10
- return nil unless width && eligible_connector?(connector) && zero_role_box?(box)
10
+ return nil unless width
11
+ return nil unless (eligible_connector?(connector) && zero_role_box?(box)) ||
12
+ (lateral_connector?(connector) && lateral_zero_box?(box))
11
13
 
12
14
  [anchor[0] - width - 3, anchor[1] - 7]
13
15
  end
14
16
 
17
+ # EMPIRICAL FIT: an unmoved ALN=0 13px role on a width-0 derived
18
+ # lateral-horizontal association seats like the plain target above
19
+ # (test B58D1A53 +role2, the only such leftward target).
20
+ def self.unmoved_lateral_role?(connector, box)
21
+ !box.nil? && lateral_connector?(connector) && lateral_zero_box?(box)
22
+ end
23
+
24
+ def self.lateral_connector?(connector)
25
+ style = connector.style.to_h
26
+ connector.connector_type == "Association" &&
27
+ style.values_at(:mode, :lwidth, :tree) == %w[3 0 LH] &&
28
+ (style[:derived_lateral_line] || style[:umldi_route]) == true
29
+ end
30
+
31
+ def self.lateral_zero_box?(box)
32
+ box.values_at("alignment", "cy") == [0, 13] &&
33
+ box.values_at("ox", "oy").map(&:to_i) == [0, 0]
34
+ end
35
+
36
+ # EMPIRICAL FIT: a stored ALN=2 role box on a horizontal
37
+ # arrival keeps its mirrored corner, text_pos, and is drawn
38
+ # outside it: a leftward arrival (OX < 0) ends 2px short of the
39
+ # corner (7 samples in basic: 6E95B74E, 4F421236, 2AAB709F,
40
+ # 4DBDAB9E, D2FA55D3 x2, width 30); a rightward arrival (OX > 0)
41
+ # starts 4px past it (basic D351FC34, F32E5AFE roleOne, width 41).
42
+ # Eligibility and placement of a stored ALN=2 target role: a plain
43
+ # Association, mode 3 / width 0 stored ray, a single-line (CY 13/14)
44
+ # unstyled ALN=2 box, no target stereotype, in the eligible font.
45
+ def self.aligned_role(connector:, heading:, text_pos:, box:, role:, width:,
46
+ font_eligible:, target_stereotype:)
47
+ return nil unless font_eligible && !role.to_s.empty? && target_stereotype.to_s.empty?
48
+ return nil unless aligned_connector?(connector) && aligned_box?(box)
49
+
50
+ aligned_position(heading: heading, text_pos: text_pos, box: box, width: width)
51
+ end
52
+
53
+ def self.aligned_connector?(connector)
54
+ style = connector.style.to_h
55
+ connector.connector_type == "Association" &&
56
+ style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil] && style[:stored_association_ray]
57
+ end
58
+
59
+ def self.aligned_box?(box)
60
+ return false unless box && box["alignment"] == 2 && [13, 14].include?(box["cy"])
61
+ return false unless box["ox"] && box["oy"] && [nil, -1].include?(box["color"])
62
+
63
+ box["rotation"].to_i.zero? && box["direction"].to_i.zero? &&
64
+ box.values_at("hidden", "bold", "italic", "underline").none?
65
+ end
66
+
67
+ def self.aligned_position(heading:, text_pos:, box:, width:)
68
+ return nil unless width && text_pos && heading && heading[1].zero?
69
+
70
+ ox = box["ox"].to_i
71
+ if heading[0].negative? && ox.negative?
72
+ [text_pos[0] - width - 2, text_pos[1]]
73
+ elsif heading[0].positive? && ox.positive?
74
+ [text_pos[0] + 4, text_pos[1]]
75
+ end
76
+ end
77
+
15
78
  def self.eligible_connector?(connector)
16
79
  connector.connector_type == "Association" &&
17
80
  connector.style.to_h.values_at(:mode, :lwidth, :tree, :plain_target_seat) == ["3", "2", nil, true]
@@ -36,11 +36,29 @@ module Ea
36
36
  @divider_y_by_ref = divider_y_by_ref
37
37
  end
38
38
 
39
+ TEXT_GROUP = "stroke-width:1;stroke-linecap:round;stroke-linejoin:%<join>s; " \
40
+ "fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00"
41
+ MARKER_GROUP = "stroke-width:1;stroke-linecap:round;stroke-linejoin:round; " \
42
+ "fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:1.00"
43
+
44
+ # A name-direction triangle is a filled shape painted between the
45
+ # text runs; EA closes the text group around it and keeps the
46
+ # round-join pen for the text that follows.
47
+ DirectionMarker = Struct.new(:svg)
48
+
39
49
  def render
40
- texts = visible_connectors.flat_map { |c| texts_for(c) }
41
- return "" if texts.empty?
50
+ items = visible_connectors.flat_map { |c| texts_for(c) }
51
+ return "" if items.empty?
42
52
 
43
- %(<g style="stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00">\n#{texts.join("\n")}\n</g>)
53
+ join = "bevel"
54
+ items.slice_when { |a, b| a.is_a?(DirectionMarker) || b.is_a?(DirectionMarker) }.map do |run|
55
+ if run.first.is_a?(DirectionMarker)
56
+ join = "round"
57
+ %(<g style="#{MARKER_GROUP}">\n #{run.first.svg}\n</g>)
58
+ else
59
+ %(<g style="#{format(TEXT_GROUP, join: join)}">\n#{run.join("\n")}\n</g>)
60
+ end
61
+ end.join("\n")
44
62
  end
45
63
 
46
64
  # Right edge of the stored end-label boxes (model coordinates)
@@ -51,8 +69,12 @@ module Ea
51
69
 
52
70
  private
53
71
 
72
+ # Labels follow the connector paint order (as the XMI diagram order
73
+ # already does), so QEA and XMI emit the same text sequence. Against
74
+ # native on PLATEAU this is a wash (79/188 exact sequences either way);
75
+ # it is verified on basic.qea's native SVGs.
54
76
  def visible_connectors
55
- (diagram.connectors || []).select(&:renderable?)
77
+ PaintOrder.connectors(diagram).select(&:renderable?)
56
78
  end
57
79
 
58
80
  def texts_for(connector)
@@ -63,22 +85,18 @@ module Ea
63
85
  points = label_points(connector)
64
86
  return [] if points.size < 2
65
87
 
66
- texts = []
67
-
68
88
  # Midpoint label: stereotype «import», or relationship
69
89
  # Name ("Association A"). Renders at the path midpoint.
70
90
  mid_text = midpoint_renderer.text_for(connector, points)
71
- texts << mid_text if mid_text
91
+ return [mid_text, direction_marker(connector, points)].compact unless registry.end_label?(connector)
72
92
 
73
93
  # End-labels: role name + «property» + multiplicity at
74
94
  # positioned LLT/LRT boxes. Only for associations without
75
95
  # a midpoint stereotype (stereotyped associations route
76
- # to midpoint only).
77
- if registry.end_label?(connector)
78
- end_label_texts(connector, points).each { |t| texts << t }
79
- end
80
-
81
- texts
96
+ # to midpoint only). EA paints the source end, then the
97
+ # midpoint label, then the target end (basic.xmi native SVGs).
98
+ source_texts, target_texts = end_label_texts(connector, points)
99
+ [*source_texts, mid_text, direction_marker(connector, points), *target_texts].compact
82
100
  end
83
101
 
84
102
  def end_label_texts(connector, points)
@@ -91,27 +109,26 @@ module Ea
91
109
  stored_tgt = end_boxes(boxes, :lrt, :lrb)
92
110
  umldi_bounds = connector.umldi_label_bounds || {}
93
111
  stored_labels = !(stored_src.nil? && stored_tgt.nil?) || !umldi_bounds.empty?
94
- src_tb, src_mb = stored_src || unstored_label_boxes(points, :source, stored_labels)
95
- tgt_tb, tgt_mb = stored_tgt || unstored_label_boxes(points, :target, stored_labels)
96
-
97
- texts = []
98
- end_renderer.texts(text_box: src_tb, mult_box: src_mb,
99
- anchor: source_pt, connector: connector,
100
- end_kind: :source, tree: tree && tree[:source],
101
- heading: (stored_src || stored_labels) && source_heading(connector, points),
102
- stored_labels: stored_labels,
103
- umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :source),
104
- source_bounds: source_dock_bounds(connector)).each { |t| texts << t }
105
- end_renderer.texts(text_box: tgt_tb,
106
- mult_box: tgt_mb,
107
- anchor: target_pt, connector: connector,
108
- end_kind: :target, tree: tree && tree[:target],
109
- bend: Label::TargetBendSeat.reference(points),
110
- heading: (stored_tgt || stored_labels) && target_heading(connector, points),
111
- target_face: target_dock_heading(connector, target_pt, target_heading(connector, points)),
112
- stored_labels: stored_labels,
113
- umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :target)).each { |t| texts << t }
114
- texts
112
+ src_tb, src_mb = stored_src || unstored_label_boxes(points, :source, stored_labels, connector)
113
+ tgt_tb, tgt_mb = stored_tgt || unstored_label_boxes(points, :target, stored_labels, connector)
114
+
115
+ source_texts = end_renderer.texts(text_box: src_tb, mult_box: src_mb,
116
+ anchor: source_pt, connector: connector,
117
+ end_kind: :source, tree: tree && tree[:source],
118
+ heading: (stored_src || stored_labels) && source_heading(connector, points),
119
+ stored_labels: stored_labels,
120
+ umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :source),
121
+ source_bounds: source_dock_bounds(connector))
122
+ target_texts = end_renderer.texts(text_box: tgt_tb,
123
+ mult_box: tgt_mb,
124
+ anchor: target_pt, connector: connector,
125
+ end_kind: :target, tree: tree && tree[:target],
126
+ bend: Label::TargetBendSeat.reference(points),
127
+ heading: (stored_tgt || stored_labels) && target_heading(connector, points),
128
+ target_face: target_dock_heading(connector, target_pt, target_heading(connector, points)),
129
+ stored_labels: stored_labels,
130
+ umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :target))
131
+ [source_texts, target_texts]
115
132
  end
116
133
 
117
134
  # Role bounds the XMI builder keeps belong to this connector's own
@@ -126,8 +143,15 @@ module Ea
126
143
 
127
144
  # A connector that stores label boxes for only one end seats the
128
145
  # other end's labels at its anchor, like a stored zero offset.
129
- def unstored_label_boxes(points, end_kind, stored_labels)
146
+ #
147
+ # A phantom outline has no saved link row, so EA draws its default
148
+ # (centred) label: the synthesized zero box carries no alignment,
149
+ # exactly like the QEA phantom's DEFAULT_LABEL_BOXES. Measured on
150
+ # the Plateau v5.1 export (A081C63E: 4 «property» lines, centred
151
+ # under the role in the native SVG, left-aligned from XMI).
152
+ def unstored_label_boxes(points, end_kind, stored_labels, connector)
130
153
  return default_label_boxes(points, end_kind) unless stored_labels
154
+ return [{ "ox" => 0, "oy" => 0 }, { "ox" => 0, "oy" => 0 }] if connector.style.to_h[:phantom_outline] == true
131
155
 
132
156
  [{ "ox" => 0, "oy" => 0, "alignment" => 0 }, { "ox" => 0, "oy" => 0, "alignment" => 0 }]
133
157
  end
@@ -341,6 +365,11 @@ module Ea
341
365
  )
342
366
  end
343
367
 
368
+ def direction_marker(connector, points)
369
+ svg = midpoint_renderer.direction_marker(connector, points)
370
+ DirectionMarker.new(svg) if svg
371
+ end
372
+
344
373
  def midpoint_renderer
345
374
  @midpoint_renderer ||= Label::MidpointLabel.new(
346
375
  canvas: canvas,
@@ -32,14 +32,11 @@ module Ea
32
32
  @document = document
33
33
  end
34
34
 
35
+ # Emitted once per sequencer: the canvas, diagram and options
36
+ # are fixed at construction, so a re-layout builds a new
37
+ # sequencer rather than re-emitting this one.
35
38
  def layers
36
- layers = [Background.render(canvas)]
37
- layers += frame_layers if frame_enabled
38
- layers += element_layers
39
- layers += connector_layers
40
- layers << labels_layer
41
- layers << ghost_labels_layer
42
- layers
39
+ @layers ||= build_layers.freeze
43
40
  end
44
41
 
45
42
  # Final drawn Bounds per model element ref (grown heights,
@@ -67,6 +64,16 @@ module Ea
67
64
 
68
65
  private
69
66
 
67
+ def build_layers
68
+ layers = [Background.render(canvas)]
69
+ layers += frame_layers if frame_enabled
70
+ layers += element_layers
71
+ layers += connector_layers
72
+ layers << labels_layer
73
+ layers << ghost_labels_layer
74
+ layers
75
+ end
76
+
70
77
  def frame_layers
71
78
  DiagramFrame.new(canvas: canvas, theme: theme).layers(diagram).map(&:to_svg)
72
79
  end
@@ -270,7 +270,7 @@ module Ea
270
270
  end
271
271
 
272
272
  def object_diagram?
273
- diagram.diagram_type == "Object"
273
+ diagram.object_diagram?
274
274
  end
275
275
 
276
276
  # Rounded [x, y] of the marker's tip (anchor) in canvas