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.
- checksums.yaml +4 -4
- data/config/themes/default.yml +7 -0
- data/lib/ea/cli/app.rb +10 -0
- data/lib/ea/cli/command/diagrams.rb +4 -1
- data/lib/ea/cli/command/render.rb +8 -1
- data/lib/ea/cli/command/spa.rb +2 -1
- data/lib/ea/cli/command/svg.rb +9 -6
- data/lib/ea/fonts/metrics.rb +44 -16
- data/lib/ea/model/diagram.rb +6 -0
- data/lib/ea/qea/services/database_loader.rb +4 -1
- data/lib/ea/sources/qea/diagram_builder/routing.rb +64 -22
- data/lib/ea/sources/qea/diagram_builder.rb +4 -1
- data/lib/ea/sources/xmi/adapter.rb +20 -0
- data/lib/ea/sources/xmi/connector_index.rb +30 -0
- data/lib/ea/sources/xmi/constraint_builder.rb +81 -0
- data/lib/ea/sources/xmi/diagram_builder.rb +22 -11
- data/lib/ea/sources/xmi/extension_style_parser.rb +1 -1
- data/lib/ea/sources/xmi/extension_types.rb +9 -2
- data/lib/ea/sources/xmi/relationship_builder.rb +17 -11
- data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +10 -26
- data/lib/ea/sources/xmi.rb +2 -0
- data/lib/ea/spa/generator.rb +4 -2
- data/lib/ea/spa/projector.rb +4 -2
- data/lib/ea/svg/ea_emitter/bottom_margin.rb +16 -12
- data/lib/ea/svg/ea_emitter/bounds_calculator.rb +22 -14
- data/lib/ea/svg/ea_emitter/canvas.rb +43 -5
- data/lib/ea/svg/ea_emitter/canvas_anchor.rb +12 -4
- data/lib/ea/svg/ea_emitter/compartment/attributes.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/header.rb +8 -1
- data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +6 -1
- data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +6 -1
- data/lib/ea/svg/ea_emitter/compartment/marker.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/note_body.rb +7 -2
- data/lib/ea/svg/ea_emitter/compartment/note_text.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/operations.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/package_contents.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +12 -1
- data/lib/ea/svg/ea_emitter/compartment/shape.rb +14 -1
- data/lib/ea/svg/ea_emitter/compartment/stereotype_icon.rb +6 -0
- data/lib/ea/svg/ea_emitter/compartment/tagged_values.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment.rb +13 -0
- data/lib/ea/svg/ea_emitter/connector_bridges.rb +18 -4
- data/lib/ea/svg/ea_emitter/connector_redock.rb +13 -6
- data/lib/ea/svg/ea_emitter/content_extent.rb +11 -2
- data/lib/ea/svg/ea_emitter/diagram_frame.rb +7 -0
- data/lib/ea/svg/ea_emitter/document.rb +22 -6
- data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +6 -1
- data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +9 -0
- data/lib/ea/svg/ea_emitter/element/header_renderer.rb +7 -0
- data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +18 -14
- data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +1 -8
- data/lib/ea/svg/ea_emitter/elements.rb +36 -12
- data/lib/ea/svg/ea_emitter/font_resolver.rb +3 -1
- data/lib/ea/svg/ea_emitter/label/end_label.rb +113 -19
- data/lib/ea/svg/ea_emitter/label/midpoint_label.rb +66 -0
- data/lib/ea/svg/ea_emitter/label/plain_source_seat.rb +25 -2
- data/lib/ea/svg/ea_emitter/label/plain_target_seat.rb +64 -1
- data/lib/ea/svg/ea_emitter/labels.rb +64 -35
- data/lib/ea/svg/ea_emitter/layer_sequencer.rb +14 -7
- data/lib/ea/svg/ea_emitter/markers.rb +1 -1
- data/lib/ea/svg/ea_emitter/paint_order.rb +5 -4
- data/lib/ea/svg/ea_emitter/pen.rb +19 -0
- data/lib/ea/svg/ea_emitter/render_context.rb +1 -1
- data/lib/ea/svg/ea_emitter/text_renderer.rb +24 -8
- data/lib/ea/svg/ea_emitter.rb +1 -0
- data/lib/ea/svg/parity/source.rb +1 -1
- data/lib/ea/version.rb +1 -1
- 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) ||
|
|
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
|
-
|
|
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? &&
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
|
909
|
-
# retain their explicit
|
|
910
|
-
|
|
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
|
-
|
|
987
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
[
|
|
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
|
|
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
|
-
|
|
41
|
-
return "" if
|
|
50
|
+
items = visible_connectors.flat_map { |c| texts_for(c) }
|
|
51
|
+
return "" if items.empty?
|
|
42
52
|
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
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
|