ea 0.6.105 → 0.6.107
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/sources/qea/diagram_builder/stored_square_ports.rb +54 -1
- data/lib/ea/sources/qea/diagram_builder.rb +1 -0
- data/lib/ea/sources/qea/property_builder.rb +11 -1
- data/lib/ea/svg/ea_emitter/compartment/constraints.rb +5 -14
- data/lib/ea/svg/ea_emitter/connector_redock.rb +35 -5
- data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +18 -9
- data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +28 -8
- data/lib/ea/svg/ea_emitter/element/constraint_renderer.rb +28 -0
- data/lib/ea/svg/ea_emitter/elements.rb +2 -1
- data/lib/ea/svg/ea_emitter/marker/diamond.rb +17 -23
- data/lib/ea/svg/ea_emitter/orthogonal_ray.rb +111 -0
- data/lib/ea/svg/ea_emitter.rb +3 -0
- data/lib/ea/version.rb +1 -1
- data/memory/deviations.md +3 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 931597d4cff3d8f491f63e12f3ed1a5ba31f8cc68d535b060222ca704388bda7
|
|
4
|
+
data.tar.gz: 70054ffff1db544c415879495386b4b45c7a1dafb88dfcc7d680325f37e4d019
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8881133d7a2443adc4dcde58dd1290b532cea8c575f22b274dc818985d6540e22d11927f0aada28a5b27f5e76748be1fd2874bbde30018c09e3dd6422a3f3263
|
|
7
|
+
data.tar.gz: c91711384dc7ebefa97bdff19923697a5eacf8f22c362dfe1ee737e8d281a3c08224d63d66d411ff77ac7edb503db1245f7d40b443c82c4468d09885fa9487f8
|
|
@@ -10,7 +10,16 @@ module Ea
|
|
|
10
10
|
private
|
|
11
11
|
|
|
12
12
|
def restore_stored_square_ports(model, link_row, diagram_row, geom, context)
|
|
13
|
-
return model if context
|
|
13
|
+
return model if context
|
|
14
|
+
|
|
15
|
+
model = mark_orthogonal_origin_ray(model, link_row, diagram_row, geom)
|
|
16
|
+
return model if model.style.to_h[:orthogonal_origin_ray] == true
|
|
17
|
+
|
|
18
|
+
restore_global_square_ports(model, link_row, diagram_row, geom)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def restore_global_square_ports(model, link_row, diagram_row, geom)
|
|
22
|
+
return model unless stored_square_port_style?(model, link_row, geom)
|
|
14
23
|
|
|
15
24
|
connector = database.find_connector(link_row.connectorid)
|
|
16
25
|
points = stored_square_port_coordinates(connector)
|
|
@@ -25,6 +34,50 @@ module Ea
|
|
|
25
34
|
model
|
|
26
35
|
end
|
|
27
36
|
|
|
37
|
+
# A square route with no stored bends uses the original mathematical
|
|
38
|
+
# ray to choose its orthogonal dock axis at render time.
|
|
39
|
+
def mark_orthogonal_origin_ray(model, link_row, diagram_row, geom)
|
|
40
|
+
return model unless orthogonal_origin_style?(model, link_row, geom)
|
|
41
|
+
|
|
42
|
+
origins = orthogonal_math_origins(link_row, diagram_row, geom)
|
|
43
|
+
return model unless origins
|
|
44
|
+
|
|
45
|
+
model.source_anchor, model.target_anchor = origins
|
|
46
|
+
model.waypoints = origins.map { |point| Ea::Model::Waypoint.new(position: point) }
|
|
47
|
+
model.style = model.style.to_h.merge(orthogonal_origin_ray: true)
|
|
48
|
+
model
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def orthogonal_math_origins(link_row, diagram_row, geom)
|
|
52
|
+
connector = database.find_connector(link_row.connectorid)
|
|
53
|
+
boxes = stored_square_port_boxes(connector, diagram_row)
|
|
54
|
+
return unless boxes && orthogonal_box_gap?(*boxes)
|
|
55
|
+
|
|
56
|
+
offsets = [[geom[:sx], geom[:sy]], [geom[:ex], geom[:ey]]]
|
|
57
|
+
origins = boxes.zip(offsets).map { |box, offset| stored_ray_origin(box, *offset) }
|
|
58
|
+
origins if orthogonal_origins_inside?(origins, boxes)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def orthogonal_origins_inside?(origins, boxes)
|
|
62
|
+
origins.zip(boxes).all? { |point, box| origin_inside_outline?(point, box) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def orthogonal_origin_style?(model, link_row, geom)
|
|
66
|
+
%w[Association Generalization].include?(model.connector_type) && link_row.path.to_s.empty? &&
|
|
67
|
+
model.style.to_h.values_at(:mode, :tree, :lwidth) == %w[3 OS 2] &&
|
|
68
|
+
%i[sx sy ex ey].all? { |key| !geom[key].nil? }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def orthogonal_box_gap?(source, target)
|
|
72
|
+
[source, target].all? { |box| box.width.positive? && box.height.positive? } &&
|
|
73
|
+
(horizontal_ray_gap?(source, target) ^ vertical_ray_gap?(source, target))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def origin_inside_outline?(point, box)
|
|
77
|
+
point.x.between?(box.x - 1, box.x + box.width + 1) &&
|
|
78
|
+
point.y.between?(box.y - 1, box.y + box.height + 1)
|
|
79
|
+
end
|
|
80
|
+
|
|
28
81
|
def stored_square_port_style?(model, link_row, geom)
|
|
29
82
|
style = model.style.to_h
|
|
30
83
|
model.connector_type == "Association" && link_row.path.to_s.empty? &&
|
|
@@ -123,6 +123,7 @@ module Ea
|
|
|
123
123
|
def build_element(obj_row, diagram_row)
|
|
124
124
|
@current_obj_row = obj_row
|
|
125
125
|
style_hash = DiagramStyleParser.parse(obj_row.objectstyle)
|
|
126
|
+
style_hash[:stored_attribute_order] = true
|
|
126
127
|
Ea::Model::DiagramElement.new(
|
|
127
128
|
id: IdNormalizer.synthetic("de", diagram_row.diagram_id,
|
|
128
129
|
obj_row.instance_id),
|
|
@@ -16,7 +16,8 @@ module Ea
|
|
|
16
16
|
|
|
17
17
|
def build_all_for(owner_object)
|
|
18
18
|
attrs = database.attributes_for_object(owner_object.ea_object_id) || []
|
|
19
|
-
attrs.
|
|
19
|
+
attrs.each_with_index.sort_by { |row, index| attribute_sort_key(row, index) }
|
|
20
|
+
.map { |row, _index| build_one(row, owner_object) }
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def build_one(row, owner_object)
|
|
@@ -48,6 +49,15 @@ module Ea
|
|
|
48
49
|
|
|
49
50
|
private
|
|
50
51
|
|
|
52
|
+
# Equal explicit Pos values use name order in EA's published diagrams.
|
|
53
|
+
# Missing positions retain source order before the explicit zero cohort.
|
|
54
|
+
def attribute_sort_key(row, index)
|
|
55
|
+
return [row.sort_position, 0, "", "", index] if row.pos.nil?
|
|
56
|
+
|
|
57
|
+
name = row.name.to_s
|
|
58
|
+
[row.sort_position, 1, name.downcase, name, index]
|
|
59
|
+
end
|
|
60
|
+
|
|
51
61
|
# EA stores some bounds as literal text ("n"); those render
|
|
52
62
|
# verbatim in attribute lines while computed-unbounded bounds
|
|
53
63
|
# render as "*" (verified against EA-published SVGs).
|
|
@@ -11,26 +11,17 @@ module Ea
|
|
|
11
11
|
# content and suppresses them when they would overflow the
|
|
12
12
|
# stored box height (never truncates rows).
|
|
13
13
|
module Constraints
|
|
14
|
-
DEFAULT_TEXT_COLOR = "#000000"
|
|
15
|
-
|
|
16
14
|
module_function
|
|
17
15
|
|
|
18
16
|
def render(context)
|
|
19
17
|
return nil unless context.constraints&.any?
|
|
20
|
-
return nil unless context.geometry.
|
|
21
|
-
|
|
22
|
-
first_y = context.geometry.tagged_value_first_y
|
|
23
|
-
line_h = context.size + 6
|
|
24
|
-
last_y = first_y + (context.constraints.size - 1) * line_h
|
|
25
|
-
return nil if last_y + 4 > context.bounds.y + context.bounds.height
|
|
18
|
+
return nil unless context.geometry.constraints_first_y
|
|
26
19
|
|
|
27
|
-
Element::
|
|
28
|
-
context.
|
|
29
|
-
|
|
30
|
-
first_y: first_y,
|
|
31
|
-
family: context.family, size: context.size,
|
|
32
|
-
fill: DEFAULT_TEXT_COLOR
|
|
20
|
+
divider = Element::DividerRenderer.render(
|
|
21
|
+
context.bounds, y: context.geometry.constraints_divider_y,
|
|
22
|
+
stroke: context.stroke, stroke_width: context.stroke_width
|
|
33
23
|
)
|
|
24
|
+
"#{divider}\n#{Element::ConstraintRenderer.render_compartment(context)}"
|
|
34
25
|
end
|
|
35
26
|
end
|
|
36
27
|
end
|
|
@@ -34,6 +34,9 @@ module Ea
|
|
|
34
34
|
return nil unless source_bounds && target_bounds
|
|
35
35
|
return nil if source_ref == target_ref
|
|
36
36
|
|
|
37
|
+
square = OrthogonalRay.pairs_for(connector, source_bounds, target_bounds)
|
|
38
|
+
return square if square
|
|
39
|
+
|
|
37
40
|
if lateral
|
|
38
41
|
target = lateral_target(points[1], target_bounds, connector.style[:tree])
|
|
39
42
|
return target && [points[0], points[1], target]
|
|
@@ -56,6 +59,7 @@ module Ea
|
|
|
56
59
|
width: box.width + 2, height: box.height + 2)
|
|
57
60
|
end
|
|
58
61
|
candidate = clipped_pairs(outline, anchors)
|
|
62
|
+
candidate[0] = external_source_point(connector, outline.first, anchors, candidate.first)
|
|
59
63
|
expanded = forward_route?(original, candidate)
|
|
60
64
|
pairs = expanded ? candidate : original
|
|
61
65
|
target = expanded ? outline.last : target_bounds
|
|
@@ -80,6 +84,20 @@ module Ea
|
|
|
80
84
|
end
|
|
81
85
|
private_class_method :auto_port_elbow_pairs
|
|
82
86
|
|
|
87
|
+
# An external saved ray can cross two source edges; retain its
|
|
88
|
+
# clockwise perimeter choice instead of the nearest crossing.
|
|
89
|
+
def external_source_point(connector, source, anchors, fallback)
|
|
90
|
+
style = connector.style.to_h
|
|
91
|
+
return fallback unless style[:stored_association_ray] == true && style[:lwidth] == "2"
|
|
92
|
+
|
|
93
|
+
first, last = anchors
|
|
94
|
+
return fallback if first.x.between?(source.x, source.x + source.width) &&
|
|
95
|
+
first.y.between?(source.y, source.y + source.height)
|
|
96
|
+
|
|
97
|
+
anchor_exit(first, source, last, perimeter: true)
|
|
98
|
+
end
|
|
99
|
+
private_class_method :external_source_point
|
|
100
|
+
|
|
83
101
|
# EDGE=1 explicitly seats an external source ray on the top edge;
|
|
84
102
|
# its first contact can instead be the bottom when its origin is below.
|
|
85
103
|
def stored_top_pairs(connector, source, pairs)
|
|
@@ -290,10 +308,9 @@ module Ea
|
|
|
290
308
|
end
|
|
291
309
|
private_class_method :forward_route?
|
|
292
310
|
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
|
|
296
|
-
def anchor_exit(anchor, bounds, other)
|
|
311
|
+
# Interior rays use the nearest forward exit. External saved sources
|
|
312
|
+
# can request the first intersecting edge in clockwise perimeter order.
|
|
313
|
+
def anchor_exit(anchor, bounds, other, perimeter: false)
|
|
297
314
|
ax, ay = anchor.x.to_f, anchor.y.to_f
|
|
298
315
|
ox, oy = other.x.to_f, other.y.to_f
|
|
299
316
|
dx, dy = ox - ax, oy - ay
|
|
@@ -314,12 +331,25 @@ module Ea
|
|
|
314
331
|
x >= bounds.x - 0.01 && x <= bounds.x + bounds.width + 0.01 &&
|
|
315
332
|
y >= bounds.y - 0.01 && y <= bounds.y + bounds.height + 0.01
|
|
316
333
|
end
|
|
317
|
-
best =
|
|
334
|
+
best = if perimeter
|
|
335
|
+
on_perimeter.min_by { |_t, x, y| perimeter_order(x, y, bounds) }
|
|
336
|
+
else
|
|
337
|
+
on_perimeter.min_by(&:first)
|
|
338
|
+
end
|
|
318
339
|
# EA floors the dock coordinates (F851A657: 297.75 -> 297,
|
|
319
340
|
# 433.7 -> 433) - a rounded-up dock is a visible miss.
|
|
320
341
|
best ? [best[1].floor, best[2].floor] : [ax.floor, ay.floor]
|
|
321
342
|
end
|
|
322
343
|
|
|
344
|
+
def perimeter_order(point_x, point_y, bounds)
|
|
345
|
+
return 0 if point_y == bounds.y
|
|
346
|
+
return 1 if point_x == bounds.x + bounds.width
|
|
347
|
+
return 2 if point_y == bounds.y + bounds.height
|
|
348
|
+
|
|
349
|
+
3
|
|
350
|
+
end
|
|
351
|
+
private_class_method :perimeter_order
|
|
352
|
+
|
|
323
353
|
# EA never docks a horizontal connector line at the header
|
|
324
354
|
# divider: endpoints landing on (box side edge, divider_y)
|
|
325
355
|
# move down one row slot to divider_y + 13 - the first
|
|
@@ -87,7 +87,7 @@ module Ea
|
|
|
87
87
|
# Pass a `lookup` (any callable returning a Classifier for
|
|
88
88
|
# an id) to enable inherited-property namespace prefixing.
|
|
89
89
|
def self.lines_for(classifier, lookup: nil,
|
|
90
|
-
|
|
90
|
+
exclude_association_ids: nil, stored_order: false)
|
|
91
91
|
props = displayable_properties(classifier, exclude_association_ids)
|
|
92
92
|
return [] unless props
|
|
93
93
|
|
|
@@ -95,19 +95,14 @@ module Ea
|
|
|
95
95
|
if stereotypes.size == 1 && props.all? { |p| property_stereotype(p) }
|
|
96
96
|
# EA renders ONE compartment stereotype header for a
|
|
97
97
|
# uniformly-stereotyped attribute set (e.g. «Property»
|
|
98
|
-
# above GML property rows)
|
|
99
|
-
# alphabetically - verified against EA-published SVGs.
|
|
98
|
+
# above GML property rows).
|
|
100
99
|
header = "«#{canonical_stereotype(stereotypes.first)}»"
|
|
101
|
-
[header] + props
|
|
100
|
+
[header] + ordered_properties(props, stored_order, case_sensitive: true).map do |prop|
|
|
102
101
|
AttributeLineBuilder.new(prop, host: classifier,
|
|
103
102
|
lookup: lookup).to_s
|
|
104
103
|
end
|
|
105
104
|
else
|
|
106
|
-
|
|
107
|
-
# insensitive) — corpus-verified: 991 boxes strictly
|
|
108
|
-
# alphabetical vs 24 strictly stored-Pos order.
|
|
109
|
-
props.sort_by { |p| [p.name.to_s.downcase, p.name.to_s] }
|
|
110
|
-
.flat_map do |prop|
|
|
105
|
+
ordered_properties(props, stored_order).flat_map do |prop|
|
|
111
106
|
lines = []
|
|
112
107
|
stereotype = property_stereotype(prop)
|
|
113
108
|
lines << "«#{stereotype}»" if stereotype
|
|
@@ -117,6 +112,20 @@ module Ea
|
|
|
117
112
|
end
|
|
118
113
|
end
|
|
119
114
|
|
|
115
|
+
# QEA properties arrive in stored Pos order. Association ends retain
|
|
116
|
+
# their alphabetical fallback rather than using connector Pos values.
|
|
117
|
+
def self.ordered_properties(props, stored_order, case_sensitive: false)
|
|
118
|
+
alphabetical = lambda do |prop|
|
|
119
|
+
name = prop.name.to_s
|
|
120
|
+
case_sensitive ? [name] : [name.downcase, name]
|
|
121
|
+
end
|
|
122
|
+
return props.sort_by(&alphabetical) unless stored_order
|
|
123
|
+
|
|
124
|
+
own, ends = props.partition { |prop| !prop.association_id }
|
|
125
|
+
own + ends.sort_by(&alphabetical)
|
|
126
|
+
end
|
|
127
|
+
private_class_method :ordered_properties
|
|
128
|
+
|
|
120
129
|
STEREOTYPE_DISPLAY = {
|
|
121
130
|
"property" => "Property",
|
|
122
131
|
"featuretype" => "FeatureType",
|
|
@@ -150,14 +150,30 @@ module Ea
|
|
|
150
150
|
divider_y + size + (attr_first_offset || 7)
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
153
|
+
# OCL constraints follow the regular content with a divider and caption.
|
|
154
|
+
# Suppress the whole compartment when its last row cannot fit.
|
|
155
|
+
def constraints_first_y
|
|
156
|
+
return nil unless constraints_count.to_i.positive?
|
|
157
|
+
|
|
158
|
+
first_y = constraints_caption_y
|
|
159
|
+
first_y if first_y + constraints_count * (size + 5) + 4 <= bounds.y + bounds.height
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def constraints_caption_y
|
|
163
|
+
(regular_content_bottom_y || raw_attr_first_y) + size + 13
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def constraints_divider_y
|
|
167
|
+
constraints_first_y - size - 7 if constraints_first_y
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def constraints_bottom_y
|
|
171
|
+
constraints_first_y + constraints_count * (size + 5) if constraints_first_y
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Tagged values follow the last visible content compartment.
|
|
158
175
|
def tagged_value_first_y
|
|
159
|
-
return nil unless tagged_values_count.to_i.positive?
|
|
160
|
-
constraints_count.to_i.positive?
|
|
176
|
+
return nil unless tagged_values_count.to_i.positive?
|
|
161
177
|
|
|
162
178
|
(content_bottom_y || raw_attr_first_y) + size + 5
|
|
163
179
|
end
|
|
@@ -169,6 +185,10 @@ module Ea
|
|
|
169
185
|
# the autosize gate must not treat the empty-compartment
|
|
170
186
|
# anchor as content.
|
|
171
187
|
def content_bottom_y
|
|
188
|
+
[regular_content_bottom_y, constraints_bottom_y].compact.max
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def regular_content_bottom_y
|
|
172
192
|
bottoms = []
|
|
173
193
|
bottoms << attr_bottom_y if attr_lines_count.to_i.positive?
|
|
174
194
|
bottoms << enum_literals_bottom_y if enum_literals_count.to_i.positive?
|
|
@@ -185,4 +205,4 @@ module Ea
|
|
|
185
205
|
end
|
|
186
206
|
end
|
|
187
207
|
end
|
|
188
|
-
end
|
|
208
|
+
end
|
|
@@ -29,6 +29,34 @@ module Ea
|
|
|
29
29
|
%(<g style="#{Style::TEXT_GROUP}">\n#{text_blocks.join("\n")}\n</g>)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def self.render_compartment(context)
|
|
33
|
+
rows = context.constraints.each_with_index.map do |constraint, idx|
|
|
34
|
+
compartment_row(constraint, context, idx)
|
|
35
|
+
end
|
|
36
|
+
%(<g style="#{Style::TEXT_GROUP}">\n#{([compartment_caption(context)] + rows).join("\n")}\n</g>)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.compartment_row(constraint, context, index)
|
|
40
|
+
build_text(context.bounds.x + 5,
|
|
41
|
+
context.geometry.constraints_first_y + (index + 1) * (context.size + 5),
|
|
42
|
+
"{#{constraint.name}}", context.family, context.size, context.text_fill)
|
|
43
|
+
end
|
|
44
|
+
private_class_method :compartment_row
|
|
45
|
+
|
|
46
|
+
def self.compartment_caption(context)
|
|
47
|
+
TextRenderer.new(content: "constraints", x: caption_x(context),
|
|
48
|
+
y: context.geometry.constraints_first_y, family: context.family,
|
|
49
|
+
size: context.size, fill: context.text_fill, style: "italic").to_svg
|
|
50
|
+
end
|
|
51
|
+
private_class_method :compartment_caption
|
|
52
|
+
|
|
53
|
+
def self.caption_x(context)
|
|
54
|
+
width = TextRenderer.estimate_width("constraints", context.size, nil,
|
|
55
|
+
family: context.family, style: "italic").round
|
|
56
|
+
context.bounds.x + ((context.bounds.width - width) / 2).floor
|
|
57
|
+
end
|
|
58
|
+
private_class_method :caption_x
|
|
59
|
+
|
|
32
60
|
def self.build_text(x, y, content, family, size, fill)
|
|
33
61
|
TextRenderer.new(content: content, x: x, y: y,
|
|
34
62
|
family: family, size: size, fill: fill).to_svg
|
|
@@ -135,7 +135,8 @@ module Ea
|
|
|
135
135
|
classifier.properties.to_a.any? { |pr| !pr.association_id }))
|
|
136
136
|
Element::AttributeRenderer.lines_for(
|
|
137
137
|
classifier, lookup: attribute_lookup,
|
|
138
|
-
|
|
138
|
+
exclude_association_ids: drawn_association_ids,
|
|
139
|
+
stored_order: element.style&.fetch(:stored_attribute_order, false)
|
|
139
140
|
)
|
|
140
141
|
else
|
|
141
142
|
[]
|
|
@@ -4,27 +4,11 @@ module Ea
|
|
|
4
4
|
module Svg
|
|
5
5
|
module EaEmitter
|
|
6
6
|
module Marker
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# WHOLE-END SELECTION: EA flags the whole end either through
|
|
11
|
-
# t_connector.DestAccess (1 shared / 2 composite -> whole at
|
|
12
|
-
# the TARGET; corpus-verified 333EFCCF: PersonalRight_E is
|
|
13
|
-
# the DestAccess=2 end and every diamond sits there) or
|
|
14
|
-
# through the Connector_Type itself ("Aggregation"/
|
|
15
|
-
# "Composition" -> whole at the SOURCE; basic.qea's
|
|
16
|
-
# composition diagram). DestAccess wins when both appear.
|
|
17
|
-
# Without relationship data the direction heuristic applies
|
|
18
|
-
# ("Destination -> Source" -> whole at target).
|
|
19
|
-
#
|
|
20
|
-
# ARROW: dest-flagged aggregations draw a base-12 height-15
|
|
21
|
-
# open arrow stacked on the diamond tip pointing into the
|
|
22
|
-
# whole (333EFCCF: 6 diamonds + 6 path arrows). Type-inferred
|
|
23
|
-
# source-side aggregations draw the diamond only (basic.qea:
|
|
24
|
-
# 17 diamonds, 0 arrows).
|
|
7
|
+
# EA endpoint fields select the diamond end; direction selects
|
|
8
|
+
# the arrow end for target-owned aggregations.
|
|
25
9
|
class Diamond < Kind
|
|
26
10
|
def self.handles?(effective_type)
|
|
27
|
-
|
|
11
|
+
%w[Aggregation Composition].include?(effective_type)
|
|
28
12
|
end
|
|
29
13
|
|
|
30
14
|
def self.specs_for(connector, source, target, before_target, after_source, relationship: nil)
|
|
@@ -36,10 +20,8 @@ module Ea
|
|
|
36
20
|
base: whole_base)]
|
|
37
21
|
return specs unless whole_at_target
|
|
38
22
|
|
|
39
|
-
specs
|
|
40
|
-
|
|
41
|
-
base: whole_base)
|
|
42
|
-
specs
|
|
23
|
+
specs.concat(direction_specs(connector, source, target,
|
|
24
|
+
before_target, after_source))
|
|
43
25
|
end
|
|
44
26
|
|
|
45
27
|
def self.whole_at_target?(connector, relationship)
|
|
@@ -58,6 +40,18 @@ module Ea
|
|
|
58
40
|
relationship &&
|
|
59
41
|
%w[shared composite].include?(relationship.source_aggregation.to_s)
|
|
60
42
|
end
|
|
43
|
+
|
|
44
|
+
def self.direction_specs(connector, source, target, before_target,
|
|
45
|
+
after_source)
|
|
46
|
+
if connector.direction == "Destination -> Source"
|
|
47
|
+
return [Registry::Spec.new(shape: :arrow, anchor: source,
|
|
48
|
+
base: after_source)]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
[Registry::Spec.new(shape: :arrow, anchor: target,
|
|
52
|
+
base: before_target)]
|
|
53
|
+
end
|
|
54
|
+
private_class_method :direction_specs
|
|
61
55
|
end
|
|
62
56
|
end
|
|
63
57
|
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Svg
|
|
5
|
+
module EaEmitter
|
|
6
|
+
# Orthogonal docking from the nearer physical intersection of a stored ray.
|
|
7
|
+
module OrthogonalRay
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def pairs_for(connector, source, target)
|
|
11
|
+
return unless eligible?(connector, source, target)
|
|
12
|
+
|
|
13
|
+
origins = [connector.source_anchor, connector.target_anchor]
|
|
14
|
+
|
|
15
|
+
boxes = [source, target]
|
|
16
|
+
physical = boxes.map { |box| outline(box) }
|
|
17
|
+
clipped = physical_intersections(origins, physical)
|
|
18
|
+
selected = nearer_origin(clipped, origins)
|
|
19
|
+
orthogonal_pairs(clipped, selected, boxes, physical, origins) unless selected.nil?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def eligible?(connector, source, target)
|
|
23
|
+
connector.style.to_h[:orthogonal_origin_ray] == true && source && target &&
|
|
24
|
+
connector.source_anchor && connector.target_anchor
|
|
25
|
+
end
|
|
26
|
+
private_class_method :eligible?
|
|
27
|
+
|
|
28
|
+
def physical_intersections(origins, physical)
|
|
29
|
+
origins.each_with_index.map do |origin, index|
|
|
30
|
+
ConnectorRedock.anchor_exit(origin, physical[index], origins[1 - index])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
private_class_method :physical_intersections
|
|
34
|
+
|
|
35
|
+
def outline(box)
|
|
36
|
+
Ea::Model::Bounds.new(x: box.x - 1, y: box.y - 1,
|
|
37
|
+
width: box.width + 2, height: box.height + 2)
|
|
38
|
+
end
|
|
39
|
+
private_class_method :outline
|
|
40
|
+
|
|
41
|
+
def nearer_origin(clipped, origins)
|
|
42
|
+
distances = clipped.zip(origins).map do |(x, y), origin|
|
|
43
|
+
(x - origin.x)**2 + (y - origin.y)**2
|
|
44
|
+
end
|
|
45
|
+
distances.each_with_index.min.last unless distances.first == distances.last
|
|
46
|
+
end
|
|
47
|
+
private_class_method :nearer_origin
|
|
48
|
+
|
|
49
|
+
def orthogonal_pairs(clipped, selected, boxes, physical, origins)
|
|
50
|
+
point = clipped[selected]
|
|
51
|
+
projected = project_opposite(point, physical[selected], boxes[1 - selected])
|
|
52
|
+
return unless projected && forward_projection?(point, projected, origins, selected)
|
|
53
|
+
|
|
54
|
+
selected.zero? ? [point, projected] : [projected, point]
|
|
55
|
+
end
|
|
56
|
+
private_class_method :orthogonal_pairs
|
|
57
|
+
|
|
58
|
+
def project_opposite(point, own, other)
|
|
59
|
+
case physical_edge_axis(point, own)
|
|
60
|
+
when :horizontal then horizontal_projection(point, other)
|
|
61
|
+
when :vertical then vertical_projection(point, other)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
private_class_method :project_opposite
|
|
65
|
+
|
|
66
|
+
def physical_edge_axis(point, own)
|
|
67
|
+
horizontal = [own.y, own.y + own.height].include?(point[1])
|
|
68
|
+
vertical = [own.x, own.x + own.width].include?(point[0])
|
|
69
|
+
return unless horizontal ^ vertical
|
|
70
|
+
|
|
71
|
+
horizontal ? :horizontal : :vertical
|
|
72
|
+
end
|
|
73
|
+
private_class_method :physical_edge_axis
|
|
74
|
+
|
|
75
|
+
def horizontal_projection(point, other)
|
|
76
|
+
x, y = point
|
|
77
|
+
return unless x.between?(other.x, other.x + other.width)
|
|
78
|
+
|
|
79
|
+
edge = facing_edge(y, other.y, other.height)
|
|
80
|
+
[x, edge] if edge
|
|
81
|
+
end
|
|
82
|
+
private_class_method :horizontal_projection
|
|
83
|
+
|
|
84
|
+
def vertical_projection(point, other)
|
|
85
|
+
x, y = point
|
|
86
|
+
return unless y.between?(other.y, other.y + other.height)
|
|
87
|
+
|
|
88
|
+
edge = facing_edge(x, other.x, other.width)
|
|
89
|
+
[edge, y] if edge
|
|
90
|
+
end
|
|
91
|
+
private_class_method :vertical_projection
|
|
92
|
+
|
|
93
|
+
def facing_edge(coordinate, start, extent)
|
|
94
|
+
return start if coordinate < start
|
|
95
|
+
return start + extent if coordinate > start + extent
|
|
96
|
+
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
private_class_method :facing_edge
|
|
100
|
+
|
|
101
|
+
def forward_projection?(point, projected, origins, selected)
|
|
102
|
+
first, last = origins.values_at(selected, 1 - selected)
|
|
103
|
+
x, y = point
|
|
104
|
+
target_x, target_y = projected
|
|
105
|
+
((last.x - first.x) * (target_x - x) + (last.y - first.y) * (target_y - y)).positive?
|
|
106
|
+
end
|
|
107
|
+
private_class_method :forward_projection?
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/ea/svg/ea_emitter.rb
CHANGED
|
@@ -45,6 +45,9 @@ module Ea
|
|
|
45
45
|
autoload :Document, "ea/svg/ea_emitter/document"
|
|
46
46
|
autoload :Label, "ea/svg/ea_emitter/label"
|
|
47
47
|
autoload :Compartment, "ea/svg/ea_emitter/compartment"
|
|
48
|
+
autoload :OrthogonalRay, "ea/svg/ea_emitter/orthogonal_ray"
|
|
49
|
+
private_constant :OrthogonalRay
|
|
50
|
+
|
|
48
51
|
autoload :ConnectorRedock, "ea/svg/ea_emitter/connector_redock"
|
|
49
52
|
autoload :RenderContext, "ea/svg/ea_emitter/render_context"
|
|
50
53
|
autoload :VisibilitySymbol, "ea/svg/ea_emitter/visibility_symbol"
|
data/lib/ea/version.rb
CHANGED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# Focused Ruby deviations
|
|
2
|
+
|
|
3
|
+
- Issue 135 attribute ordering: RuboCop Metrics/MethodLength flags the 14-line real-QEA fixture builder, and Metrics/BlockLength flags its six-example public pipeline regression group. Keep the fixture together and the directly related controls in one group; splitting them would obscure the input collections and ordering contract.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ea
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.107
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: lutaml-model
|
|
@@ -950,6 +950,7 @@ files:
|
|
|
950
950
|
- lib/ea/svg/ea_emitter/marker/plus.rb
|
|
951
951
|
- lib/ea/svg/ea_emitter/marker/registry.rb
|
|
952
952
|
- lib/ea/svg/ea_emitter/markers.rb
|
|
953
|
+
- lib/ea/svg/ea_emitter/orthogonal_ray.rb
|
|
953
954
|
- lib/ea/svg/ea_emitter/render_context.rb
|
|
954
955
|
- lib/ea/svg/ea_emitter/style.rb
|
|
955
956
|
- lib/ea/svg/ea_emitter/text_renderer.rb
|
|
@@ -1011,6 +1012,7 @@ files:
|
|
|
1011
1012
|
- lib/ea/xml_escape.rb
|
|
1012
1013
|
- lib/tasks/fonts.rake
|
|
1013
1014
|
- lib/tasks/svg_bench.rake
|
|
1015
|
+
- memory/deviations.md
|
|
1014
1016
|
- reference-docs/enterprise-architect-appearance-dialogs/appearance-1-diagram-1-themes-list.png
|
|
1015
1017
|
- reference-docs/enterprise-architect-appearance-dialogs/appearance-1-diagram-1-themes.png
|
|
1016
1018
|
- reference-docs/enterprise-architect-appearance-dialogs/appearance-1-diagram-2-gradients.png
|