ea 0.6.139 → 0.6.141
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/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 +54 -6
- data/lib/ea/model/note.rb +2 -0
- data/lib/ea/sources/qea/diagram_builder/routing.rb +188 -8
- data/lib/ea/sources/qea/diagram_builder.rb +14 -1
- data/lib/ea/sources/qea/note_builder.rb +7 -1
- data/lib/ea/sources/xmi/diagram_builder/element_borders.rb +27 -0
- data/lib/ea/sources/xmi/diagram_builder.rb +58 -15
- data/lib/ea/sources/xmi/extension_style_parser.rb +1 -1
- data/lib/ea/sources/xmi/note_builder.rb +2 -1
- data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +78 -0
- data/lib/ea/sources/xmi/stored_route.rb +83 -6
- 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 +104 -0
- data/lib/ea/svg/ea_emitter/bounds_calculator.rb +25 -15
- data/lib/ea/svg/ea_emitter/canvas.rb +80 -14
- data/lib/ea/svg/ea_emitter/canvas_anchor.rb +132 -0
- 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 +5 -0
- data/lib/ea/svg/ea_emitter/compartment/header_divider.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/instance_slots.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/instance_underline.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/marker.rb +5 -0
- data/lib/ea/svg/ea_emitter/compartment/note_body.rb +11 -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 +34 -4
- data/lib/ea/svg/ea_emitter/compartment/shape.rb +30 -5
- 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 +117 -29
- data/lib/ea/svg/ea_emitter/connectors.rb +67 -17
- data/lib/ea/svg/ea_emitter/content_extent.rb +104 -0
- data/lib/ea/svg/ea_emitter/diagram_frame.rb +7 -0
- data/lib/ea/svg/ea_emitter/document.rb +76 -31
- data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +22 -10
- data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +57 -32
- data/lib/ea/svg/ea_emitter/elements.rb +24 -5
- data/lib/ea/svg/ea_emitter/font_resolver.rb +3 -1
- data/lib/ea/svg/ea_emitter/label/end_label.rb +134 -14
- data/lib/ea/svg/ea_emitter/labels.rb +16 -3
- data/lib/ea/svg/ea_emitter/layer_sequencer.rb +54 -33
- data/lib/ea/svg/ea_emitter/markers.rb +22 -10
- data/lib/ea/svg/ea_emitter/paint_order.rb +30 -0
- data/lib/ea/svg/ea_emitter/pen.rb +19 -0
- data/lib/ea/svg/ea_emitter/render_context.rb +14 -3
- data/lib/ea/svg/ea_emitter/text_renderer.rb +24 -8
- data/lib/ea/svg/ea_emitter.rb +5 -0
- data/lib/ea/svg/parity/source.rb +1 -1
- data/lib/ea/version.rb +1 -1
- metadata +9 -2
|
@@ -9,6 +9,8 @@ module Ea
|
|
|
9
9
|
# dog-eared rectangle (top-right corner folded in) with the
|
|
10
10
|
# note text wrapped inside.
|
|
11
11
|
#
|
|
12
|
+
# Native EA emits the body and the folded corner as two polygons.
|
|
13
|
+
#
|
|
12
14
|
# Layout:
|
|
13
15
|
#
|
|
14
16
|
# ┌──────────────────┐
|
|
@@ -19,15 +21,25 @@ module Ea
|
|
|
19
21
|
#
|
|
20
22
|
class NoteShapeRenderer
|
|
21
23
|
FOLD_SIZE = 12
|
|
24
|
+
FOLD_FILL = "#DCDCDC"
|
|
22
25
|
TEXT_X_OFFSET = 5
|
|
23
26
|
TEXT_Y_OFFSET = 12
|
|
24
27
|
LINE_HEIGHT = 12
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
# EA never sets the note pen: the outline takes the cap and join
|
|
30
|
+
# left by the previous drawing, or the initial pen (square/miter)
|
|
31
|
+
# when the note is the first thing painted. Checked on all six
|
|
32
|
+
# native notes: 7C44AC09 first (square/miter), 7D960BD8 after
|
|
33
|
+
# square/bevel icons (square/bevel), 676B9C9A, 926AFE38, 9DA6C553
|
|
34
|
+
# and C028B286 after round/bevel shapes (round/bevel).
|
|
35
|
+
INITIAL_PEN = Pen::INITIAL
|
|
36
|
+
|
|
37
|
+
def self.render(bounds, body:, fill:, stroke:, stroke_width:, family:, size:, text_fill:, pen: nil)
|
|
38
|
+
new(bounds, body, fill, stroke, stroke_width, family, size, text_fill, pen || INITIAL_PEN).to_svg
|
|
28
39
|
end
|
|
29
40
|
|
|
30
|
-
def initialize(bounds, body, fill, stroke, stroke_width, family, size, text_fill)
|
|
41
|
+
def initialize(bounds, body, fill, stroke, stroke_width, family, size, text_fill, pen)
|
|
42
|
+
@pen = pen
|
|
31
43
|
@bounds = bounds
|
|
32
44
|
@body = body.to_s
|
|
33
45
|
@fill = fill
|
|
@@ -39,7 +51,7 @@ module Ea
|
|
|
39
51
|
end
|
|
40
52
|
|
|
41
53
|
def to_svg
|
|
42
|
-
|
|
54
|
+
[body_layer, fold_layer, text_layer].reject(&:empty?).join("\n")
|
|
43
55
|
end
|
|
44
56
|
|
|
45
57
|
private
|
|
@@ -47,39 +59,52 @@ module Ea
|
|
|
47
59
|
attr_reader :bounds, :body, :fill, :stroke, :stroke_width,
|
|
48
60
|
:family, :size, :text_fill
|
|
49
61
|
|
|
50
|
-
def group_style
|
|
51
|
-
"stroke-width:#{stroke_width};stroke-linecap
|
|
52
|
-
"fill:#{
|
|
62
|
+
def group_style(layer_fill)
|
|
63
|
+
"stroke-width:#{stroke_width};stroke-linecap:#{@pen[0]};stroke-linejoin:#{@pen[1]}; " \
|
|
64
|
+
"fill:#{layer_fill};fill-opacity:1.00; stroke:#{stroke}; stroke-opacity:1.00"
|
|
53
65
|
end
|
|
54
66
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"L #{Canvas.coord(x + w)} #{Canvas.coord(y + fold)} " \
|
|
67
|
-
"L #{Canvas.coord(x + w)} #{Canvas.coord(y + h)} " \
|
|
68
|
-
"L #{Canvas.coord(x)} #{Canvas.coord(y + h)} " \
|
|
69
|
-
"L #{Canvas.coord(x)} #{Canvas.coord(y)} Z"
|
|
70
|
-
%(<path d="#{d}" shape-rendering="auto"/>)
|
|
67
|
+
def body_layer
|
|
68
|
+
%(<g style="#{group_style(fill)}">\n #{polygon(body_points)}\n</g>)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def fold_layer
|
|
72
|
+
%(<g style="#{group_style(FOLD_FILL)}">\n #{polygon(fold_points)}\n</g>)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def text_layer
|
|
76
|
+
text = text_block
|
|
77
|
+
text.empty? ? "" : %(<g style="#{group_style(fill)}">\n #{text}\n</g>)
|
|
71
78
|
end
|
|
72
79
|
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
# EA encodes the note silhouette as a <polygon> (all six
|
|
81
|
+
# native Note placements in the PLATEAU and ArcGIS corpora),
|
|
82
|
+
# starting at the top-left corner and running down first.
|
|
83
|
+
def body_points
|
|
84
|
+
left, top, right, bottom = edges
|
|
85
|
+
[[left, top], [left, bottom], [right, bottom],
|
|
86
|
+
[right, top + fold_size], [right - fold_size, top]]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def edges
|
|
90
|
+
[bounds.x, bounds.y, bounds.x + bounds.width, bounds.y + bounds.height]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The folded corner is a separate triangle, filled light
|
|
94
|
+
# grey regardless of the note's own fill.
|
|
95
|
+
def fold_points
|
|
96
|
+
r = bounds.x + bounds.width
|
|
76
97
|
y = bounds.y
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
98
|
+
[[r - fold_size, y], [r - fold_size, y + fold_size], [r, y + fold_size]]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fold_size
|
|
102
|
+
[FOLD_SIZE, bounds.width / 3, bounds.height / 3].min
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def polygon(points)
|
|
106
|
+
list = points.map { |px, py| "#{Canvas.coord(px)} #{Canvas.coord(py)}" }.join(" ")
|
|
107
|
+
%(<polygon points="#{list}" shape-rendering="auto" style="fill-rule:evenodd;"/>)
|
|
83
108
|
end
|
|
84
109
|
|
|
85
110
|
def text_block
|
|
@@ -33,7 +33,12 @@ module Ea
|
|
|
33
33
|
def groups
|
|
34
34
|
@drawn_bounds = {}
|
|
35
35
|
@divider_y = {}
|
|
36
|
-
|
|
36
|
+
@previous_pen = nil
|
|
37
|
+
ordered_elements.flat_map do |e|
|
|
38
|
+
painted = groups_for(e)
|
|
39
|
+
@previous_pen = painted.last&.pen || @previous_pen
|
|
40
|
+
painted.map(&:svg)
|
|
41
|
+
end
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
# Final drawn Bounds per model element reference, captured
|
|
@@ -50,8 +55,15 @@ module Ea
|
|
|
50
55
|
|
|
51
56
|
private
|
|
52
57
|
|
|
58
|
+
# EA paints placements by descending Sequence (the lowest
|
|
59
|
+
# Sequence is on top); equal keys keep their stored order. A
|
|
60
|
+
# placement with no Sequence (no such row in the corpus, so EA's
|
|
61
|
+
# treatment is unmeasured) is painted first, as before the
|
|
62
|
+
# descending order, rather than ahead of the numbered ones.
|
|
53
63
|
def ordered_elements
|
|
54
|
-
(diagram.elements || []).sort_by
|
|
64
|
+
(diagram.elements || []).sort_by.with_index do |e, i|
|
|
65
|
+
e.z_order ? [1, -e.z_order, i] : [0, 0, i]
|
|
66
|
+
end
|
|
55
67
|
end
|
|
56
68
|
|
|
57
69
|
def groups_for(element)
|
|
@@ -63,7 +75,7 @@ module Ea
|
|
|
63
75
|
record_drawn_bounds(element, context)
|
|
64
76
|
return [] if element.style&.fetch("connector_proxy", false)
|
|
65
77
|
|
|
66
|
-
Compartment.
|
|
78
|
+
Compartment.paint_all(context)
|
|
67
79
|
end
|
|
68
80
|
|
|
69
81
|
# Logical-space drawn bounds: stored x/y with the RENDERED
|
|
@@ -300,7 +312,8 @@ module Ea
|
|
|
300
312
|
canvas: canvas,
|
|
301
313
|
diagram: diagram,
|
|
302
314
|
model_index: model_index,
|
|
303
|
-
off_canvas_parent_name: parent_name
|
|
315
|
+
off_canvas_parent_name: parent_name,
|
|
316
|
+
previous_pen: @previous_pen
|
|
304
317
|
)
|
|
305
318
|
end
|
|
306
319
|
|
|
@@ -380,10 +393,16 @@ module Ea
|
|
|
380
393
|
attr_group_lead: attr_group_lead)
|
|
381
394
|
end
|
|
382
395
|
|
|
396
|
+
# A ghost-led header (italic parent label above the stereotype /
|
|
397
|
+
# name) takes the same compact offsets: EA seats its divider at
|
|
398
|
+
# the last header baseline + 7 (empirical fit, 21 QEA dividers
|
|
399
|
+
# that were +1: 0114FD24 uro::AbstractConstruction etc.; 20 of
|
|
400
|
+
# 21 now exact, no other compared item moved). The tight ghost
|
|
401
|
+
# variant has no corpus case and keeps the default offsets.
|
|
383
402
|
def compact_header_offsets?(classifier, header_lines, size, **options)
|
|
384
403
|
classifier.is_a?(Ea::Model::Klass) &&
|
|
385
404
|
Ea::Fonts::Metrics.compact_compartment_font?(size, family: options[:family], unit: options[:size_unit]) &&
|
|
386
|
-
|
|
405
|
+
header_lines.dig(0, 1) != :italic_tight &&
|
|
387
406
|
options.values_at(:logical_height, :marker_count, :enum_literals_count,
|
|
388
407
|
:divider_offset, :attr_first_offset) == [nil, 0, 0, 8, 7] &&
|
|
389
408
|
theme.compartments.header_line_offset == 6 && theme.compartments.attr_line_offset == 6
|
|
@@ -68,7 +68,9 @@ module Ea
|
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
def logical_height_for(element)
|
|
71
|
-
|
|
71
|
+
return nil unless size_unit_for(element) == "pt"
|
|
72
|
+
|
|
73
|
+
Ea::Fonts::Metrics.source_logical_height(element&.font_logical_height)
|
|
72
74
|
end
|
|
73
75
|
|
|
74
76
|
def weight_for(element)
|
|
@@ -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
|
|
@@ -45,6 +48,15 @@ module Ea
|
|
|
45
48
|
@font_unit = font_unit
|
|
46
49
|
@font_weight = font_weight
|
|
47
50
|
@font_style = font_style
|
|
51
|
+
@reach = []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# [right, bottom] edge, in model coordinates, of the label boxes
|
|
55
|
+
# (position + stored CX/CY) emitted so far; nil before any.
|
|
56
|
+
def reach
|
|
57
|
+
return nil if @reach.empty?
|
|
58
|
+
|
|
59
|
+
[@reach.map(&:first).max, @reach.map(&:last).max]
|
|
48
60
|
end
|
|
49
61
|
|
|
50
62
|
# Returns an Array of `<text>` SVG strings for one endpoint.
|
|
@@ -102,6 +114,10 @@ module Ea
|
|
|
102
114
|
stereo = connector_source_dest_stereotype(connector, end_kind)
|
|
103
115
|
stereo = stereo.nil? || stereo.empty? ? nil : "«#{stereo}»"
|
|
104
116
|
direction = leg_direction(end_kind, heading)
|
|
117
|
+
if left_seated_source?(connector, end_kind)
|
|
118
|
+
text_pos, mult_pos = left_seated_positions(anchor, role, mult)
|
|
119
|
+
direction = nil
|
|
120
|
+
end
|
|
105
121
|
if direction && !(tree && zero_offset?(text_box, mult_box))
|
|
106
122
|
text_pos = plain_leftward_source_role(connector, direction, anchor, text_box, role) ||
|
|
107
123
|
plain_vertical_role(connector, direction, anchor, text_box, role) ||
|
|
@@ -127,7 +143,9 @@ module Ea
|
|
|
127
143
|
directional_multiplicity(:left, anchor, mult_box, mult)
|
|
128
144
|
end
|
|
129
145
|
mult_pos = target_multiplicity_seat(connector, heading, anchor, mult_box) ||
|
|
146
|
+
target_diagonal_property_multiplicity(connector, heading, anchor, text_box, mult_box) ||
|
|
130
147
|
face_multiplicity || target_horizontal_multiplicity(heading, anchor, mult_box, mult) ||
|
|
148
|
+
target_sloped_rightward_multiplicity(connector, heading, anchor, mult_box, role, mult) ||
|
|
131
149
|
mult_pos
|
|
132
150
|
end
|
|
133
151
|
|
|
@@ -138,10 +156,14 @@ module Ea
|
|
|
138
156
|
[text_box, mult_box], mult) || mult_pos
|
|
139
157
|
if external_upward_property_source?(connector, heading, anchor, text_box, source_bounds)
|
|
140
158
|
text_pos = external_upward_property_role(anchor, role) || text_pos
|
|
159
|
+
mult_pos = external_upward_property_multiplicity(anchor, mult) || mult_pos
|
|
141
160
|
end
|
|
142
161
|
end
|
|
143
162
|
|
|
144
|
-
|
|
163
|
+
if phantom_property_box?(connector, direction, text_box) ||
|
|
164
|
+
rightward_phantom_property_box?(connector, direction, text_box)
|
|
165
|
+
text_box = text_box.merge("alignment" => 1)
|
|
166
|
+
end
|
|
145
167
|
|
|
146
168
|
if end_kind == :target && !from_property
|
|
147
169
|
bend_positions = target_bend_positions(connector, bend, text_box, mult_box, role)
|
|
@@ -168,7 +190,8 @@ module Ea
|
|
|
168
190
|
end
|
|
169
191
|
|
|
170
192
|
build_texts(role:, mult:, standalone:,
|
|
171
|
-
text_pos:, mult_pos:, stereo:, text_box:, stereo_pos
|
|
193
|
+
text_pos:, mult_pos:, stereo:, text_box:, stereo_pos:,
|
|
194
|
+
mult_box:)
|
|
172
195
|
end
|
|
173
196
|
|
|
174
197
|
private
|
|
@@ -310,12 +333,30 @@ module Ea
|
|
|
310
333
|
# fallback.
|
|
311
334
|
def own_stereotype_only(text_box, anchor, connector, end_kind)
|
|
312
335
|
stereo = connector_source_dest_stereotype(connector, end_kind)
|
|
313
|
-
pos =
|
|
336
|
+
pos = stereotype_only_seat(text_box, anchor, connector, end_kind, stereo) ||
|
|
337
|
+
position_at(text_box, anchor)
|
|
314
338
|
return [] if stereo.nil? || stereo.empty? || pos.nil?
|
|
315
339
|
|
|
316
340
|
[text_at(pos[0], pos[1], "«#{stereo}»")]
|
|
317
341
|
end
|
|
318
342
|
|
|
343
|
+
# EMPIRICAL FIT (34FF671B and 5038EEE3, the only two ends in the
|
|
344
|
+
# Plateau v5.1 corpus that draw a Property stereotype with no role
|
|
345
|
+
# or multiplicity): a one-line centred target box is seated at its
|
|
346
|
+
# corner, anchor + (OX, OY) with no sign flip, like the source-end
|
|
347
|
+
# corner seat; the stereotype ends 8px left of the corner and its
|
|
348
|
+
# baseline sits 15px below it. Both reproduce the native SVG exactly.
|
|
349
|
+
def stereotype_only_seat(text_box, anchor, connector, end_kind, stereo)
|
|
350
|
+
return nil unless end_kind == :target && property_label?(connector, end_kind)
|
|
351
|
+
return nil unless seat_font? && cambria_box?(text_box) && text_box.values_at("alignment", "cy") == [1, 16]
|
|
352
|
+
|
|
353
|
+
width = Ea::Fonts::Metrics.text_length("«#{stereo}»", font_size, family: font_family)
|
|
354
|
+
return nil unless width
|
|
355
|
+
|
|
356
|
+
x, y = seat_corner(anchor, text_box)
|
|
357
|
+
[x - width - SEAT_STEREOTYPE_ONLY_INSET, y + SEAT_STEREOTYPE_ONLY_DROP]
|
|
358
|
+
end
|
|
359
|
+
|
|
319
360
|
# EA's tree-route label clusters (hand-verified against the
|
|
320
361
|
# EA-published reference, SY 8542 with canvas=(0,0)):
|
|
321
362
|
# source-end pairs stack at the exit point (role +3/-23,
|
|
@@ -345,6 +386,24 @@ module Ea
|
|
|
345
386
|
end
|
|
346
387
|
end
|
|
347
388
|
|
|
389
|
+
def left_seated_source?(connector, end_kind)
|
|
390
|
+
end_kind == :source && connector.style.to_h[:left_seated_source] == true
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# EMPIRICAL FIT (5038EEE3 connectors 207 and 212, the only reversed
|
|
394
|
+
# links the builder flags): the source cluster sits left of the
|
|
395
|
+
# anchor like a source leaving left, whichever way the route leaves:
|
|
396
|
+
# role right edge 60px out, 7px above; multiplicity ends 3px short
|
|
397
|
+
# of the anchor, 15px below.
|
|
398
|
+
def left_seated_positions(anchor, role, mult)
|
|
399
|
+
role_width = Ea::Fonts::Metrics.text_length(role.to_s, font_size, family: font_family) ||
|
|
400
|
+
role.to_s.length * 6
|
|
401
|
+
mult_width = Ea::Fonts::Metrics.text_length(mult.to_s, font_size, family: font_family) ||
|
|
402
|
+
mult.to_s.length * 6
|
|
403
|
+
[[[anchor[0] - 60 - role_width, MIN_ROLE_X].max, anchor[1] - 7],
|
|
404
|
+
[[anchor[0] - mult_width - 3, MIN_ROLE_X].max, anchor[1] + 15]]
|
|
405
|
+
end
|
|
406
|
+
|
|
348
407
|
# Stored source-end boxes on a 7pt label whose first leg
|
|
349
408
|
# leaves straight up or straight right are seated at the box
|
|
350
409
|
# corner, anchor + (OX, OY) with no sign flip, 3px to its
|
|
@@ -365,14 +424,15 @@ module Ea
|
|
|
365
424
|
nil
|
|
366
425
|
end
|
|
367
426
|
|
|
368
|
-
# Saved bounds seat a source Property role at the stereotype's
|
|
369
|
-
# edge
|
|
427
|
+
# Saved bounds seat a source or target Property role at the stereotype's
|
|
428
|
+
# left edge (target: +endPoint on 159E9DA0 and 90BCA409, the only
|
|
429
|
+
# corpus targets narrower than the stereotype). A centred stored box (ALN=1), or a phantom's synthesized box
|
|
370
430
|
# without alignment, centres the shorter role over it; a
|
|
371
431
|
# left-aligned box keeps both lines on that edge.
|
|
372
432
|
def bounds_role_centering(connector, end_kind, role, stereo, text_box)
|
|
373
|
-
return 0 unless
|
|
433
|
+
return 0 unless stereo && seat_font?
|
|
374
434
|
return 0 unless centred_bounds_box?(connector, text_box)
|
|
375
|
-
return 0 unless property_label?(connector,
|
|
435
|
+
return 0 unless property_label?(connector, end_kind)
|
|
376
436
|
|
|
377
437
|
-[stereotype_offset(role, stereo, { "alignment" => 1 }), 0].min
|
|
378
438
|
end
|
|
@@ -427,10 +487,10 @@ module Ea
|
|
|
427
487
|
end
|
|
428
488
|
|
|
429
489
|
# Measured box heights: left/down Property clusters store CY 35,
|
|
430
|
-
# right-seat clusters CY 31. Phantom outlines leaving down
|
|
431
|
-
# synthesized zero box that centers the same way.
|
|
490
|
+
# right-seat and up clusters CY 31. Phantom outlines leaving down
|
|
491
|
+
# carry a synthesized zero box that centers the same way.
|
|
432
492
|
def centered_property_box?(connector, direction, box)
|
|
433
|
-
cy = { left: 35, down: 35, right: 31 }[direction]
|
|
493
|
+
cy = { left: 35, down: 35, right: 31, up: 31 }[direction]
|
|
434
494
|
(cy && box.values_at("cy", "alignment") == [cy, 1]) ||
|
|
435
495
|
(direction == :down && phantom_property_box?(connector, direction, box))
|
|
436
496
|
end
|
|
@@ -452,6 +512,16 @@ module Ea
|
|
|
452
512
|
property_label?(connector, :source)
|
|
453
513
|
end
|
|
454
514
|
|
|
515
|
+
# A rightward phantom keeps the plain role seat; only its stereotype
|
|
516
|
+
# is centred under the role (0BE81AC5: native stereotype 4px right of
|
|
517
|
+
# the role for a 52px role over a 44px stereotype).
|
|
518
|
+
def rightward_phantom_property_box?(connector, direction, box)
|
|
519
|
+
direction == :right && seat_font? && cambria_box?(box) &&
|
|
520
|
+
box["alignment"].nil? && stored_offsets_zero?(box) &&
|
|
521
|
+
connector.style.to_h[:phantom_outline] == true &&
|
|
522
|
+
property_label?(connector, :source)
|
|
523
|
+
end
|
|
524
|
+
|
|
455
525
|
def property_role_x(direction, origin, offset)
|
|
456
526
|
direction == :right ? origin + offset : origin - offset
|
|
457
527
|
end
|
|
@@ -492,6 +562,17 @@ module Ea
|
|
|
492
562
|
[[anchor[0] - inset - width, MIN_ROLE_X].max, anchor[1] + baseline]
|
|
493
563
|
end
|
|
494
564
|
|
|
565
|
+
# Multiplicity of a floating upward source (5038EEE3: both such
|
|
566
|
+
# ends, QEA; native "0..1" at anchor.x - width - 3, anchor.y + 15).
|
|
567
|
+
def external_upward_property_multiplicity(anchor, mult)
|
|
568
|
+
return nil if mult_empty?(mult)
|
|
569
|
+
|
|
570
|
+
width = Ea::Fonts::Metrics.text_length(mult, font_size, family: font_family)
|
|
571
|
+
return nil unless width
|
|
572
|
+
|
|
573
|
+
[anchor[0] - width - SEAT_X_INSET, anchor[1] + SEAT_MULT_DROP]
|
|
574
|
+
end
|
|
575
|
+
|
|
495
576
|
def external_upward_property_source?(connector, heading, anchor, box, bounds)
|
|
496
577
|
upward_source_heading?(heading) &&
|
|
497
578
|
zero_source_property_box?(box) && cambria_box?(box) && source_property_cambria?(connector) &&
|
|
@@ -634,6 +715,16 @@ module Ea
|
|
|
634
715
|
property_role_offsets(:down).first if dy.negative? && dy.abs > dx.abs
|
|
635
716
|
end
|
|
636
717
|
|
|
718
|
+
# With no stored multiplicity box, a Property target on a steep
|
|
719
|
+
# upward diagonal seats it beside the anchor, above the role baseline.
|
|
720
|
+
def target_diagonal_property_multiplicity(connector, heading, anchor, text_box, mult_box)
|
|
721
|
+
return nil if mult_box
|
|
722
|
+
return nil unless diagonal_property_box?(connector, text_box)
|
|
723
|
+
return nil unless heading && heading[1].negative? && heading[1].abs > heading[0].abs
|
|
724
|
+
|
|
725
|
+
[anchor[0] + 8, anchor[1] - 7]
|
|
726
|
+
end
|
|
727
|
+
|
|
637
728
|
def target_vertical_role(connector, heading, anchor, box, role)
|
|
638
729
|
return nil unless heading && heading[0].zero? && !heading[1].zero?
|
|
639
730
|
|
|
@@ -842,6 +933,18 @@ module Ea
|
|
|
842
933
|
mult_seat(:right, anchor, box)
|
|
843
934
|
end
|
|
844
935
|
|
|
936
|
+
# EMPIRICAL FIT (5038EEE3, the only connector in the Plateau v5.1
|
|
937
|
+
# corpus with a Property source and a role-less target whose leg
|
|
938
|
+
# arrives sloped from the right):
|
|
939
|
+
# the multiplicity takes the rightward corner seat, as on a
|
|
940
|
+
# horizontal rightward leg. Reproduces the native SVG exactly.
|
|
941
|
+
def target_sloped_rightward_multiplicity(connector, heading, anchor, box, role, mult)
|
|
942
|
+
return nil unless seat_font? && role_empty?(role) && !mult_empty?(mult)
|
|
943
|
+
return nil unless heading && heading[0] > heading[1].abs && property_label?(connector, :source)
|
|
944
|
+
|
|
945
|
+
mult_seat(:right, anchor, box)
|
|
946
|
+
end
|
|
947
|
+
|
|
845
948
|
def target_left_face_multiplicity?(connector, heading, box, mult, target_face)
|
|
846
949
|
seat_font? && !mult_empty?(mult) && property_label?(connector, :target) && cambria_box?(box) &&
|
|
847
950
|
stored_offsets_zero?(box) && target_left_face_heading?(heading, target_face)
|
|
@@ -861,9 +964,12 @@ module Ea
|
|
|
861
964
|
end
|
|
862
965
|
|
|
863
966
|
def target_multiplicity_box?(connector, box)
|
|
864
|
-
# Property targets
|
|
865
|
-
# retain their explicit
|
|
866
|
-
|
|
967
|
+
# Property targets and vertical tree targets (5038EEE3) seat at
|
|
968
|
+
# the stored corner; plain moved labels retain their explicit
|
|
969
|
+
# offsets.
|
|
970
|
+
cambria_box?(box) &&
|
|
971
|
+
(stored_offsets_zero?(box) || property_label?(connector, :target) ||
|
|
972
|
+
connector.style.to_h[:tree] == "LV")
|
|
867
973
|
end
|
|
868
974
|
|
|
869
975
|
def cambria_box?(box)
|
|
@@ -905,9 +1011,11 @@ module Ea
|
|
|
905
1011
|
end
|
|
906
1012
|
|
|
907
1013
|
def build_texts(role:, mult:, standalone:, text_pos:, mult_pos:,
|
|
908
|
-
stereo:, text_box:, stereo_pos: nil)
|
|
1014
|
+
stereo:, text_box:, stereo_pos: nil, mult_box: nil)
|
|
909
1015
|
if role && !role.empty? && text_pos
|
|
910
1016
|
x, y = text_pos
|
|
1017
|
+
note_reach(text_pos, text_box)
|
|
1018
|
+
note_reach(mult_pos, mult_box) if mult && !mult.empty? && mult_pos
|
|
911
1019
|
texts = [text_at(x, y, role)]
|
|
912
1020
|
if stereo
|
|
913
1021
|
stereo_x, stereo_y = stereo_pos || [x + stereotype_offset(role, stereo, text_box),
|
|
@@ -919,12 +1027,24 @@ module Ea
|
|
|
919
1027
|
end
|
|
920
1028
|
texts
|
|
921
1029
|
elsif standalone && !standalone.empty? && mult_pos
|
|
1030
|
+
note_reach(mult_pos, mult_box)
|
|
922
1031
|
[text_at(*mult_pos, standalone)]
|
|
923
1032
|
else
|
|
924
1033
|
[]
|
|
925
1034
|
end
|
|
926
1035
|
end
|
|
927
1036
|
|
|
1037
|
+
# A label box starts at the text's x and at its first baseline
|
|
1038
|
+
# less the rounded font height (EA's text top).
|
|
1039
|
+
def note_reach(position, box)
|
|
1040
|
+
width = box && box["cx"]
|
|
1041
|
+
height = box && box["cy"]
|
|
1042
|
+
return unless width && height
|
|
1043
|
+
|
|
1044
|
+
@reach << [position[0] + width,
|
|
1045
|
+
position[1] - font_pixel_size.ceil + height]
|
|
1046
|
+
end
|
|
1047
|
+
|
|
928
1048
|
def umldi_label_position(bounds)
|
|
929
1049
|
[bounds[:x], bounds[:y] + font_pixel_size.ceil]
|
|
930
1050
|
end
|
|
@@ -43,6 +43,12 @@ module Ea
|
|
|
43
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>)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Right edge of the stored end-label boxes (model coordinates)
|
|
47
|
+
# of the last render, or nil when none carry a width.
|
|
48
|
+
def reach
|
|
49
|
+
end_renderer.reach
|
|
50
|
+
end
|
|
51
|
+
|
|
46
52
|
private
|
|
47
53
|
|
|
48
54
|
def visible_connectors
|
|
@@ -85,8 +91,8 @@ module Ea
|
|
|
85
91
|
stored_tgt = end_boxes(boxes, :lrt, :lrb)
|
|
86
92
|
umldi_bounds = connector.umldi_label_bounds || {}
|
|
87
93
|
stored_labels = !(stored_src.nil? && stored_tgt.nil?) || !umldi_bounds.empty?
|
|
88
|
-
src_tb, src_mb = stored_src || unstored_label_boxes(points, :source, stored_labels)
|
|
89
|
-
tgt_tb, tgt_mb = stored_tgt || unstored_label_boxes(points, :target, stored_labels)
|
|
94
|
+
src_tb, src_mb = stored_src || unstored_label_boxes(points, :source, stored_labels, connector)
|
|
95
|
+
tgt_tb, tgt_mb = stored_tgt || unstored_label_boxes(points, :target, stored_labels, connector)
|
|
90
96
|
|
|
91
97
|
texts = []
|
|
92
98
|
end_renderer.texts(text_box: src_tb, mult_box: src_mb,
|
|
@@ -120,8 +126,15 @@ module Ea
|
|
|
120
126
|
|
|
121
127
|
# A connector that stores label boxes for only one end seats the
|
|
122
128
|
# other end's labels at its anchor, like a stored zero offset.
|
|
123
|
-
|
|
129
|
+
#
|
|
130
|
+
# A phantom outline has no saved link row, so EA draws its default
|
|
131
|
+
# (centred) label: the synthesized zero box carries no alignment,
|
|
132
|
+
# exactly like the QEA phantom's DEFAULT_LABEL_BOXES. Measured on
|
|
133
|
+
# the Plateau v5.1 export (A081C63E: 4 «property» lines, centred
|
|
134
|
+
# under the role in the native SVG, left-aligned from XMI).
|
|
135
|
+
def unstored_label_boxes(points, end_kind, stored_labels, connector)
|
|
124
136
|
return default_label_boxes(points, end_kind) unless stored_labels
|
|
137
|
+
return [{ "ox" => 0, "oy" => 0 }, { "ox" => 0, "oy" => 0 }] if connector.style.to_h[:phantom_outline] == true
|
|
125
138
|
|
|
126
139
|
[{ "ox" => 0, "oy" => 0, "alignment" => 0 }, { "ox" => 0, "oy" => 0, "alignment" => 0 }]
|
|
127
140
|
end
|
|
@@ -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,
|
|
@@ -49,12 +46,34 @@ module Ea
|
|
|
49
46
|
@elements&.drawn_bounds
|
|
50
47
|
end
|
|
51
48
|
|
|
49
|
+
# Row-slot seating stays off. Elements records each divider in canvas
|
|
50
|
+
# space, so it only matched logical connector ends when the canvas
|
|
51
|
+
# origin was 0 (the content-anchored default) and then moved one end
|
|
52
|
+
# 13 px in all four corpus cases it matched (5D6DB5A0 Aggregation and
|
|
53
|
+
# Generalization, ADC8773C, F46E8F19), none of which EA seats on a
|
|
54
|
+
# row; it never matched an XMI route. Under the framed canvas it was
|
|
55
|
+
# inert, which is the behaviour native parity was measured against.
|
|
52
56
|
def divider_y_by_ref
|
|
53
|
-
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Right edge of the connector end-label boxes, model coordinates.
|
|
61
|
+
def label_reach
|
|
62
|
+
@labels&.reach
|
|
54
63
|
end
|
|
55
64
|
|
|
56
65
|
private
|
|
57
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
|
+
|
|
58
77
|
def frame_layers
|
|
59
78
|
DiagramFrame.new(canvas: canvas, theme: theme).layers(diagram).map(&:to_svg)
|
|
60
79
|
end
|
|
@@ -66,46 +85,48 @@ module Ea
|
|
|
66
85
|
@elements.groups
|
|
67
86
|
end
|
|
68
87
|
|
|
88
|
+
# EA paints connector by connector: each line is followed by
|
|
89
|
+
# its own heads, and only adjacent same-style groups merge.
|
|
69
90
|
def connector_layers
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
91
|
+
lines = Connectors.new(diagram, canvas: canvas,
|
|
92
|
+
grouped: true,
|
|
93
|
+
stroke_width: DEFAULT_STROKE_WIDTH,
|
|
94
|
+
drawn_bounds: drawn_bounds,
|
|
95
|
+
divider_y_by_ref: divider_y_by_ref,
|
|
96
|
+
model_index: model_index).connector_layers
|
|
97
|
+
markers = Markers.new(diagram, model_index: model_index,
|
|
98
|
+
canvas: canvas, grouped: true,
|
|
99
|
+
stroke_width: DEFAULT_STROKE_WIDTH,
|
|
100
|
+
drawn_bounds: drawn_bounds,
|
|
101
|
+
divider_y_by_ref: divider_y_by_ref).connector_layers
|
|
102
|
+
by_owner = {}.compare_by_identity
|
|
103
|
+
(lines + markers).each { |owner, layer| (by_owner[owner] ||= []) << layer }
|
|
104
|
+
ordered = PaintOrder.connectors(diagram).flat_map { |connector| by_owner.fetch(connector, []) }
|
|
105
|
+
merge_adjacent_by_style(ordered).map(&:to_svg)
|
|
82
106
|
end
|
|
83
107
|
|
|
84
108
|
def labels_layer
|
|
85
|
-
Labels.new(diagram, canvas: canvas, model_index: model_index,
|
|
109
|
+
@labels = Labels.new(diagram, canvas: canvas, model_index: model_index,
|
|
86
110
|
theme: theme, document: document,
|
|
87
111
|
drawn_bounds: drawn_bounds,
|
|
88
|
-
divider_y_by_ref: divider_y_by_ref)
|
|
112
|
+
divider_y_by_ref: divider_y_by_ref)
|
|
113
|
+
@labels.render
|
|
89
114
|
end
|
|
90
115
|
|
|
91
116
|
def ghost_labels_layer
|
|
92
117
|
GhostLabels.new(diagram, canvas: canvas).render
|
|
93
118
|
end
|
|
94
119
|
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if
|
|
99
|
-
merged[
|
|
100
|
-
|
|
101
|
-
style: layer.style,
|
|
102
|
-
body: "#{merged[layer.style_key].body}\n #{layer.body}"
|
|
103
|
-
)
|
|
120
|
+
def merge_adjacent_by_style(layers)
|
|
121
|
+
layers.each_with_object([]) do |layer, merged|
|
|
122
|
+
last = merged.last
|
|
123
|
+
if last && last.style_key == layer.style_key
|
|
124
|
+
merged[-1] = Layer.new(style_key: last.style_key, style: last.style,
|
|
125
|
+
body: "#{last.body}\n #{layer.body}")
|
|
104
126
|
else
|
|
105
|
-
merged
|
|
127
|
+
merged << layer
|
|
106
128
|
end
|
|
107
129
|
end
|
|
108
|
-
merged.values
|
|
109
130
|
end
|
|
110
131
|
end
|
|
111
132
|
end
|