ea 0.6.137 → 0.6.138
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/fonts/cambria_7_regular_widths.json +1 -0
- data/lib/ea/model/diagram_connector.rb +4 -0
- data/lib/ea/sources/xmi/diagram_builder/umldi_edges/property_roles.rb +71 -0
- data/lib/ea/sources/xmi/diagram_builder/umldi_edges.rb +56 -0
- data/lib/ea/sources/xmi/diagram_builder.rb +125 -14
- data/lib/ea/sources/xmi/stored_route.rb +166 -0
- data/lib/ea/svg/ea_emitter/label/end_label.rb +119 -20
- data/lib/ea/svg/ea_emitter/labels.rb +15 -2
- data/lib/ea/svg/ea_emitter/render_context.rb +7 -2
- data/lib/ea/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c2231b8d009e8d99c841920776cbe8539e09b76d512beed109f073fb7904282
|
|
4
|
+
data.tar.gz: 3feade927368b299842047c9c0e31aa0cb8473e7dad6870e5626fb369e1503de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 025f36b456316a6fcd801318f17c27363d62d3ee7355cd303c1b7147081f44d3b70fdf711966ae4a5a7fc33e022dc097ba4d6f15f6c3ad03e237d59318b89fac
|
|
7
|
+
data.tar.gz: d2487edce57559735c9cdf42984dd16ad3597985fb5d2f01ddeb9289c893b423a986f8587b3788d0cd759aaca93661c8e0a86262a4939f503d21e9495e29db9f
|
|
@@ -38,6 +38,9 @@ module Ea
|
|
|
38
38
|
attribute :hidden, :boolean
|
|
39
39
|
attribute :is_substitutable, :boolean, default: false
|
|
40
40
|
attribute :label_boxes, :hash, default: -> { {} }
|
|
41
|
+
# Absolute labels saved by UML-DI when the EA extension omits the
|
|
42
|
+
# corresponding relative label boxes.
|
|
43
|
+
attribute :umldi_label_bounds, :hash, default: -> { {} }
|
|
41
44
|
attribute :ghost_labels, GhostLabel, collection: true, initialize_empty: true
|
|
42
45
|
# True when the t_diagramlinks.Geometry string carries explicit
|
|
43
46
|
# SX/SY/EX/EY offset fields. EA renders the «import» package_anchor
|
|
@@ -86,6 +89,7 @@ module Ea
|
|
|
86
89
|
map "lineWidth", to: :line_width
|
|
87
90
|
map "hidden", to: :hidden, render_default: true
|
|
88
91
|
map "labelBoxes", to: :label_boxes
|
|
92
|
+
map "umldiLabelBounds", to: :umldi_label_bounds
|
|
89
93
|
map "ghostLabels", to: :ghost_labels, render_empty: true
|
|
90
94
|
map "hasGeometryOffsets", to: :has_geometry_offsets, render_default: true
|
|
91
95
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Sources
|
|
5
|
+
module Xmi
|
|
6
|
+
class DiagramBuilder
|
|
7
|
+
class UmldiEdges
|
|
8
|
+
# Association membership proves which saved role bounds an edge owns.
|
|
9
|
+
class PropertyRoles
|
|
10
|
+
def initialize(doc)
|
|
11
|
+
@doc = doc
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def keys_for(diagram_id, connector_id)
|
|
15
|
+
property_roles.dig(diagram_id, connector_id) || []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def property_roles
|
|
21
|
+
@property_roles ||= begin
|
|
22
|
+
document = @doc
|
|
23
|
+
declared = declared_properties(document)
|
|
24
|
+
document.xpath("//*[local-name()=\"Diagram\"]").to_h do |diagram|
|
|
25
|
+
[diagram["id"] || diagram["xmi:id"], diagram_property_roles(diagram, declared)]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def declared_properties(document)
|
|
31
|
+
members = document.xpath("//*[local-name()='memberEnd']").to_set do |node|
|
|
32
|
+
[node["xmi:idref"], node.parent["xmi:id"]]
|
|
33
|
+
end
|
|
34
|
+
document.xpath("//*[@association][@xmi:id]", "xmi" => "http://www.omg.org/spec/XMI/20131001")
|
|
35
|
+
.filter_map do |node|
|
|
36
|
+
pair = [node["xmi:id"], node["association"]]
|
|
37
|
+
pair if node.attributes["type"]&.value == "uml:Property" && members.include?(pair)
|
|
38
|
+
end.to_set
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def diagram_property_roles(diagram, declared)
|
|
42
|
+
unique_edges(diagram).transform_values do |edge|
|
|
43
|
+
edge.xpath("./ownedElement[@modelElement]").filter_map do |label|
|
|
44
|
+
key = role_key(label)
|
|
45
|
+
key if key &&
|
|
46
|
+
declared.include?([label["modelElement"], edge["modelElement"]])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def unique_edges(diagram)
|
|
52
|
+
diagram.xpath("./ownedElement[@modelElement]")
|
|
53
|
+
.select { |node| node.attributes["type"]&.value.to_s.end_with?("UMLEdge") }
|
|
54
|
+
.group_by { |edge| edge["modelElement"] }
|
|
55
|
+
.select { |_, edges| edges.one? }.transform_values(&:first)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def role_key(label)
|
|
59
|
+
return unless label.attributes["type"]&.value.to_s.end_with?("UMLAssociationEndLabel")
|
|
60
|
+
|
|
61
|
+
case label["xmi:id"] || label["id"]
|
|
62
|
+
when /\AEAID_SEL/ then :source_role
|
|
63
|
+
when /\AEAID_TEL/ then :target_role
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -11,6 +11,8 @@ module Ea
|
|
|
11
11
|
# drawn by more than one edge in the same diagram has neither; an edge
|
|
12
12
|
# with fewer than two waypoints has no route.
|
|
13
13
|
class UmldiEdges
|
|
14
|
+
autoload :PropertyRoles, "ea/sources/xmi/diagram_builder/umldi_edges/property_roles"
|
|
15
|
+
|
|
14
16
|
def initialize(xmi_path)
|
|
15
17
|
@xmi_path = xmi_path
|
|
16
18
|
end
|
|
@@ -23,6 +25,20 @@ module Ea
|
|
|
23
25
|
ends.dig(diagram_id, connector_id)
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
# Absolute saved UMLDI bounds for connector label boxes. These are
|
|
29
|
+
# kept separate from extension label offsets: callers may use them
|
|
30
|
+
# when extension geometry is absent or the association owns the role.
|
|
31
|
+
def label_bounds_for(diagram_id, connector_id)
|
|
32
|
+
label_bounds.dig(diagram_id, connector_id)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Role label keys whose UMLDI label is bound to the property that
|
|
36
|
+
# the connector's association declares, so the bounds describe that
|
|
37
|
+
# end's own role label rather than an unrelated label.
|
|
38
|
+
def property_role_keys_for(diagram_id, connector_id)
|
|
39
|
+
(@property_roles ||= PropertyRoles.new(doc)).keys_for(diagram_id, connector_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
26
42
|
private
|
|
27
43
|
|
|
28
44
|
def ends
|
|
@@ -31,6 +47,46 @@ module Ea
|
|
|
31
47
|
end
|
|
32
48
|
end
|
|
33
49
|
|
|
50
|
+
def label_bounds
|
|
51
|
+
@label_bounds ||= doc.xpath("//*[local-name()=\"Diagram\"]").to_h do |diagram|
|
|
52
|
+
[diagram["id"] || diagram["xmi:id"], diagram_label_bounds(diagram)]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def diagram_label_bounds(diagram)
|
|
57
|
+
boxes_by_connector = unique_edges(diagram).to_h do |connector_id, edge|
|
|
58
|
+
[connector_id, edge_label_bounds(edge)]
|
|
59
|
+
end
|
|
60
|
+
boxes_by_connector.reject { |_, boxes| boxes.empty? }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def edge_label_bounds(edge)
|
|
64
|
+
edge.xpath("./ownedElement[@modelElement]").each_with_object({}) do |label, found|
|
|
65
|
+
bounds = label.at_xpath("./*[local-name()=\"bounds\"]")
|
|
66
|
+
key = label_key(label)
|
|
67
|
+
next unless key && bounds
|
|
68
|
+
|
|
69
|
+
found[key] = bounds_values(bounds)
|
|
70
|
+
rescue ArgumentError, TypeError
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def bounds_values(bounds)
|
|
76
|
+
%w[x y width height].to_h { |attribute| [attribute.to_sym, Integer(bounds[attribute])] }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def label_key(label)
|
|
80
|
+
type = label.attributes["type"]&.value.to_s
|
|
81
|
+
role = (label["id"] || label["xmi:id"]).to_s
|
|
82
|
+
end_name = { "EAID_SEL" => "source", "EAID_SML" => "source",
|
|
83
|
+
"EAID_TEL" => "target", "EAID_TML" => "target" }
|
|
84
|
+
side = end_name.find { |prefix, _| role.start_with?(prefix) }&.last
|
|
85
|
+
suffix = { "UMLAssociationEndLabel" => "role", "UMLMultiplicityLabel" => "multiplicity" }
|
|
86
|
+
label_kind = suffix[type.split(":").last]
|
|
87
|
+
"#{side}_#{label_kind}".to_sym if side && label_kind
|
|
88
|
+
end
|
|
89
|
+
|
|
34
90
|
def diagram_ends(diagram)
|
|
35
91
|
unique_edges(diagram).each_with_object({}) do |(connector_id, edge), acc|
|
|
36
92
|
acc[connector_id] = [edge["source"], edge["target"]] if edge["source"] && edge["target"]
|
|
@@ -50,6 +50,7 @@ module Ea
|
|
|
50
50
|
placed_connectors = ext_elements.placed_connectors
|
|
51
51
|
props = ext_diagram.properties
|
|
52
52
|
umldi_keywords = keywords_for(id)
|
|
53
|
+
context = context_diagram?(ext_diagram)
|
|
53
54
|
|
|
54
55
|
Ea::Model::Diagram.new(
|
|
55
56
|
id: id,
|
|
@@ -65,8 +66,7 @@ module Ea
|
|
|
65
66
|
show_parents: !style_flag?(ext_diagram, "HideParents"),
|
|
66
67
|
hand_draw: style_flag?(ext_diagram, "HandDraw"),
|
|
67
68
|
elements: build_elements(placed_elements, id, umldi_keywords),
|
|
68
|
-
connectors: build_connectors(placed_connectors,
|
|
69
|
-
placed_elements, id),
|
|
69
|
+
connectors: build_connectors(placed_connectors, placed_elements, id, context),
|
|
70
70
|
annotations: []
|
|
71
71
|
)
|
|
72
72
|
end
|
|
@@ -102,6 +102,13 @@ module Ea
|
|
|
102
102
|
value.to_i.nonzero? ? true : false
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
+
def context_diagram?(ext_diagram)
|
|
106
|
+
name = ext_diagram.properties&.name.to_s
|
|
107
|
+
return true if name.match?(/\AContext Diagram: Package (Clients|Suppliers) of /i)
|
|
108
|
+
|
|
109
|
+
style_ex_for(ext_diagram).to_s.match(/(?:\A|;)SuppressFOC=(\d)/)&.public_send(:[], 1) != "1"
|
|
110
|
+
end
|
|
111
|
+
|
|
105
112
|
def page_bounds(ext_diagram)
|
|
106
113
|
style = style_for(ext_diagram).to_s
|
|
107
114
|
width = style[/(?:\A|;)DocSize\.cx=(\d+)/, 1]
|
|
@@ -176,7 +183,7 @@ module Ea
|
|
|
176
183
|
height: bottom - top)
|
|
177
184
|
end
|
|
178
185
|
|
|
179
|
-
def build_connectors(placed_connectors, placed_elements, owner_id)
|
|
186
|
+
def build_connectors(placed_connectors, placed_elements, owner_id, context)
|
|
180
187
|
# Index elements by DUID so we can resolve SOID/EOID.
|
|
181
188
|
by_duid = placed_elements.each_with_object({}) do |pe, acc|
|
|
182
189
|
acc[pe.style.duid] = pe if pe.style.duid
|
|
@@ -184,40 +191,46 @@ module Ea
|
|
|
184
191
|
by_subject = placed_elements.group_by { |pe| guid_key(pe.subject) }
|
|
185
192
|
|
|
186
193
|
placed_connectors.each_with_index.map do |placed, index|
|
|
187
|
-
connector = build_connector(placed, by_duid, by_subject, owner_id, index)
|
|
188
|
-
mark_association_class(connector, placed, placed_elements)
|
|
194
|
+
connector = build_connector(placed, by_duid, by_subject, owner_id, rank: index, context: context)
|
|
195
|
+
mark_association_class(connector, placed, placed_elements, context)
|
|
189
196
|
end
|
|
190
197
|
end
|
|
191
198
|
|
|
192
|
-
def mark_association_class(connector, placed, placed_elements)
|
|
199
|
+
def mark_association_class(connector, placed, placed_elements, context)
|
|
200
|
+
mark_plain_end_seats(connector, placed, context)
|
|
193
201
|
reference = association_class_metadata.for(
|
|
194
202
|
connector_index[placed.subject],
|
|
195
203
|
placed_subjects: placed_elements.map(&:subject),
|
|
196
204
|
waypoints: connector.waypoints
|
|
197
205
|
)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
206
|
+
return connector unless reference
|
|
207
|
+
|
|
208
|
+
connector.style[:association_class_ref] = reference
|
|
209
|
+
connector.source_anchor = connector.waypoints.first.position
|
|
202
210
|
connector
|
|
203
211
|
end
|
|
204
212
|
|
|
213
|
+
def mark_plain_end_seats(connector, placed, context)
|
|
214
|
+
mark_plain_source_seat(connector, placed, context)
|
|
215
|
+
mark_plain_target_seat(connector, placed, context)
|
|
216
|
+
end
|
|
217
|
+
|
|
205
218
|
def association_class_metadata
|
|
206
219
|
@association_class_metadata ||= AssociationClassMetadata.new(xmi_path)
|
|
207
220
|
end
|
|
208
221
|
|
|
209
|
-
def build_connector(placed, by_duid, by_subject, owner_id,
|
|
222
|
+
def build_connector(placed, by_duid, by_subject, owner_id, options)
|
|
210
223
|
geom = placed.geometry
|
|
211
224
|
style = placed.style
|
|
212
225
|
edge_source, edge_target = connector_ends(owner_id, placed)
|
|
213
226
|
source, target = placed_ends(style, by_duid, by_subject, edge_source, edge_target)
|
|
214
227
|
ext_meta = connector_meta(placed.subject)
|
|
215
228
|
route = connector_route(placed, source, target, owner_id, ext_meta[:ea_type])
|
|
216
|
-
Ea::Model::DiagramConnector.new(
|
|
229
|
+
connector = Ea::Model::DiagramConnector.new(
|
|
217
230
|
id: IdNormalizer.synthetic_id(owner_id, "conn", placed.subject),
|
|
218
231
|
diagram_id: owner_id,
|
|
219
232
|
relationship_ref: placed.subject,
|
|
220
|
-
paint_order: [rank],
|
|
233
|
+
paint_order: [options.fetch(:rank)],
|
|
221
234
|
source_duid: style.soid,
|
|
222
235
|
target_duid: style.eoid,
|
|
223
236
|
source_element_ref: source && synthetic_element_id(owner_id, source),
|
|
@@ -234,8 +247,94 @@ module Ea
|
|
|
234
247
|
line_color: style.color,
|
|
235
248
|
line_width: style.line_width,
|
|
236
249
|
hidden: style.hidden,
|
|
237
|
-
label_boxes:
|
|
250
|
+
label_boxes: connector_label_boxes(placed, ext_meta[:ea_type],
|
|
251
|
+
options.fetch(:context) ? nil : owner_id)
|
|
238
252
|
)
|
|
253
|
+
connector.umldi_label_bounds = if options.fetch(:context)
|
|
254
|
+
{}
|
|
255
|
+
else
|
|
256
|
+
umldi_label_bounds_for(owner_id, placed.subject,
|
|
257
|
+
connector.label_boxes)
|
|
258
|
+
end
|
|
259
|
+
mark_stored_ray(connector, placed, source, target) unless options.fetch(:context)
|
|
260
|
+
mark_association_route(connector, placed, source, target) unless options.fetch(:context)
|
|
261
|
+
connector
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def umldi_label_bounds_for(owner_id, connector_id, label_boxes)
|
|
265
|
+
return {} unless xmi_path
|
|
266
|
+
|
|
267
|
+
bounds = umldi_edges.label_bounds_for(owner_id, connector_id)
|
|
268
|
+
return {} unless bounds
|
|
269
|
+
|
|
270
|
+
property_roles = umldi_edges.property_role_keys_for(owner_id, connector_id)
|
|
271
|
+
bounds.reject do |key, _|
|
|
272
|
+
extension_key = { source_role: :llt, source_multiplicity: :llb,
|
|
273
|
+
target_role: :lrt, target_multiplicity: :lrb }.fetch(key)
|
|
274
|
+
label_boxes.key?(extension_key) && !property_roles.include?(key)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def mark_stored_ray(connector, placed, source, target)
|
|
279
|
+
return if connector.style[:umldi_route]
|
|
280
|
+
|
|
281
|
+
metadata = stored_ray_metadata(connector, placed, source, target)
|
|
282
|
+
metadata.each { |name, value| connector.public_send("#{name}=", value) }
|
|
283
|
+
connector.style[:association_ray_outline] = true unless metadata.empty?
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def stored_ray_metadata(connector, placed, source, target, stage: :ray_metadata)
|
|
287
|
+
geom = placed.geometry
|
|
288
|
+
link = StoredRoute::Link.new(placed.style.raw, geom.path.to_s.tr("$", ";"))
|
|
289
|
+
bounds = { source: bounds_from_placed(source), target: bounds_from_placed(target) }
|
|
290
|
+
offsets = { source: [geom.sx, geom.sy], target: [geom.ex, geom.ey] }
|
|
291
|
+
StoredRoute.new.public_send(stage, connector, link: link, bounds: bounds, offsets: offsets, edge: geom.edge)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def mark_association_route(connector, placed, source, target)
|
|
295
|
+
metadata = stored_ray_metadata(connector, placed, source, target, stage: :association_route)
|
|
296
|
+
metadata.each { |name, value| connector.public_send("#{name}=", value) }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def mark_plain_source_seat(connector, placed, context)
|
|
300
|
+
return if context || !placed.geometry.path.to_s.empty? || connector.connector_type != "Aggregation"
|
|
301
|
+
return unless connector.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "2", nil]
|
|
302
|
+
return unless connector.source_stereotype.to_s.empty?
|
|
303
|
+
|
|
304
|
+
connector.style[:plain_source_seat] = true
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def mark_plain_target_seat(connector, placed, context)
|
|
308
|
+
return if context || !placed.geometry.path.to_s.empty? || connector.connector_type != "Association"
|
|
309
|
+
return unless connector.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "2", nil]
|
|
310
|
+
return unless connector.target_stereotype.to_s.empty?
|
|
311
|
+
|
|
312
|
+
connector.style[:plain_target_seat] = true
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
PHANTOM_TYPES = %w[Association Aggregation].freeze
|
|
316
|
+
|
|
317
|
+
# EA exports a model relationship drawn without a diagram link row as
|
|
318
|
+
# an Association or Aggregation link with the default style and an
|
|
319
|
+
# all-zero, EDGE-less, bendless geometry; the QEA source builds it as
|
|
320
|
+
# a phantom connector.
|
|
321
|
+
def phantom_link?(placed, type)
|
|
322
|
+
PHANTOM_TYPES.include?(type) && placed.style.raw == ";Hidden=0;" && default_geometry?(placed.geometry)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def default_geometry?(geom)
|
|
326
|
+
geom.edge.nil? && [geom.sx, geom.sy, geom.ex, geom.ey].all?(&:zero?) &&
|
|
327
|
+
geom.path.to_s.empty? && geom.label_boxes.to_h.empty?
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def connector_label_boxes(placed, type, owner_id)
|
|
331
|
+
return serialize_label_boxes(placed.geometry.label_boxes) unless phantom_link?(placed, type)
|
|
332
|
+
|
|
333
|
+
# An empty extension placement can still have saved UMLDI labels.
|
|
334
|
+
# Synthetic phantom offsets must not suppress those authored bounds.
|
|
335
|
+
return {} if owner_id && xmi_path && umldi_edges.label_bounds_for(owner_id, placed.subject)
|
|
336
|
+
|
|
337
|
+
Qea::DiagramBuilder::DEFAULT_LABEL_BOXES
|
|
239
338
|
end
|
|
240
339
|
|
|
241
340
|
# The saved SOID/EOID can name the model connector's ends in reverse
|
|
@@ -296,6 +395,10 @@ module Ea
|
|
|
296
395
|
end
|
|
297
396
|
|
|
298
397
|
def connector_route(placed, source, target, owner_id, type)
|
|
398
|
+
phantom_route(placed, source, target, type) || saved_route(placed, source, target, owner_id, type)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def saved_route(placed, source, target, owner_id, type)
|
|
299
402
|
native = umldi_waypoints(owner_id, placed) if placed.geometry.bend_points.to_a.empty?
|
|
300
403
|
points = native || compute_waypoints(placed.geometry, source, target,
|
|
301
404
|
style: placed.style, connector_type: type)
|
|
@@ -303,6 +406,14 @@ module Ea
|
|
|
303
406
|
{ waypoints: native || points, style: connector_route_style(placed, native) }
|
|
304
407
|
end
|
|
305
408
|
|
|
409
|
+
def phantom_route(placed, source, target, type)
|
|
410
|
+
return unless source && target && phantom_link?(placed, type)
|
|
411
|
+
|
|
412
|
+
points = StoredRoute.new.phantom_waypoints(bounds_from_placed(source), bounds_from_placed(target))
|
|
413
|
+
{ waypoints: points.map { |x, y| Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y)) },
|
|
414
|
+
style: { lwidth: "1", phantom_outline: true } }
|
|
415
|
+
end
|
|
416
|
+
|
|
306
417
|
def connector_route_style(placed, native)
|
|
307
418
|
route_style = native ? { umldi_route: true } : {}
|
|
308
419
|
# Label seats use EA's saved routing mode on every route source;
|
|
@@ -26,8 +26,174 @@ module Ea
|
|
|
26
26
|
finish(points, link, connector, source, target, bends, ports, offsets, edge)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# Direct custom-line associations and aggregations retain ray origins as
|
|
30
|
+
# well as their computed boundary ports, just as the QEA builder does.
|
|
31
|
+
def ray_metadata(model, link:, bounds:, offsets:, edge:)
|
|
32
|
+
return {} unless direct_association_ray?(model, bounds, offsets)
|
|
33
|
+
|
|
34
|
+
source, target = bounds.values_at(:source, :target)
|
|
35
|
+
geom = ray_geometry(offsets, edge)
|
|
36
|
+
return {} unless saved_association_ray?(model, link, source, target, geom)
|
|
37
|
+
|
|
38
|
+
ray_points(source, target, offsets, geom)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def phantom_waypoints(source, target)
|
|
42
|
+
[bounds_edge_point(source, target), bounds_edge_point(target, source)]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Apply QEA's saved Association stages after the initial route, including
|
|
46
|
+
# UMLDI fallback. A single stored corner and an empty-path stored ray
|
|
47
|
+
# carry mathematical origins that the generic facing-edge route loses.
|
|
48
|
+
def association_route(model, link:, bounds:, offsets:, edge:)
|
|
49
|
+
overlap = umldi_association_metadata(model, link, bounds, offsets, edge)
|
|
50
|
+
return overlap unless overlap.empty?
|
|
51
|
+
|
|
52
|
+
partial = partial_port_metadata(model, link, bounds, offsets, edge)
|
|
53
|
+
return partial unless partial.empty?
|
|
54
|
+
|
|
55
|
+
return {} unless stored_association_candidate?(model, bounds)
|
|
56
|
+
|
|
57
|
+
candidate = association_model(model, bounds, offsets)
|
|
58
|
+
initial = candidate.waypoints
|
|
59
|
+
association_stored_ray(candidate, link, bounds, ray_geometry(offsets, edge), false)
|
|
60
|
+
return {} if candidate.waypoints.equal?(initial)
|
|
61
|
+
|
|
62
|
+
association_metadata(candidate)
|
|
63
|
+
end
|
|
64
|
+
|
|
29
65
|
private
|
|
30
66
|
|
|
67
|
+
def umldi_association_metadata(model, link, bounds, offsets, edge)
|
|
68
|
+
overlap = umldi_overlap_metadata(model, link, bounds, offsets, edge)
|
|
69
|
+
return overlap unless overlap.empty?
|
|
70
|
+
|
|
71
|
+
umldi_stored_ray_metadata(model, link, bounds, offsets, edge)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# UMLDI owns the route and ports. Classify a thin saved ray only when
|
|
75
|
+
# its origins pass the shared unstroked-box checks; XMI has no QEA
|
|
76
|
+
# database from which to infer a wider element outline.
|
|
77
|
+
def umldi_stored_ray_metadata(model, link, bounds, offsets, edge)
|
|
78
|
+
return {} unless umldi_thin_ray_candidate?(model, bounds, offsets)
|
|
79
|
+
|
|
80
|
+
candidate = association_model(model, bounds, offsets)
|
|
81
|
+
return {} unless association_ray_candidate?(candidate, link)
|
|
82
|
+
return {} unless association_ray_origins(*bounds.values_at(:source, :target),
|
|
83
|
+
ray_geometry(offsets, edge), "0")
|
|
84
|
+
|
|
85
|
+
{ style: model.style.to_h.merge(stored_association_ray: true, association_ray_outline: true) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def umldi_thin_ray_candidate?(model, bounds, offsets)
|
|
89
|
+
model.style[:umldi_route] && model.style[:lwidth] == "0" &&
|
|
90
|
+
bounds.values.all? && offsets.values.flatten.none?(&:nil?)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# UMLDI owns the drawn route; retain QEA's overlap origins only for
|
|
94
|
+
# interpreting the stored relative end labels against that route.
|
|
95
|
+
def umldi_overlap_metadata(model, link, bounds, offsets, edge)
|
|
96
|
+
return {} unless umldi_overlap_candidate?(model, bounds, offsets)
|
|
97
|
+
|
|
98
|
+
candidate = association_model(model, bounds, offsets)
|
|
99
|
+
overlap_ray_origins(candidate, link, bounds, ray_geometry(offsets, edge), false)
|
|
100
|
+
return {} unless candidate.style[:overlap_ray]
|
|
101
|
+
|
|
102
|
+
{ source_anchor: candidate.source_anchor, target_anchor: candidate.target_anchor,
|
|
103
|
+
has_geometry_offsets: true, style: candidate.style.to_h }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def umldi_overlap_candidate?(model, bounds, offsets)
|
|
107
|
+
model.style.to_h.values_at(:umldi_route, :mode, :lwidth) == [true, "2", "0"] &&
|
|
108
|
+
model.waypoints.size == 2 && bounds.values.all? && offsets.values.flatten.none?(&:nil?)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A zero-offset end has no explicit port, but QEA still carries both
|
|
112
|
+
# mathematical origins for the emitter's final boundary redocking.
|
|
113
|
+
def partial_port_metadata(model, link, bounds, offsets, edge)
|
|
114
|
+
return {} unless partial_port_candidate?(model, link, bounds, offsets)
|
|
115
|
+
|
|
116
|
+
source, target = bounds.values_at(:source, :target)
|
|
117
|
+
return {} unless saved_association_ray?(model, link, source, target, ray_geometry(offsets, edge))
|
|
118
|
+
|
|
119
|
+
association_ends(bounds, offsets).merge(has_geometry_offsets: true)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def partial_port_candidate?(model, link, bounds, offsets)
|
|
123
|
+
model.connector_type == "Aggregation" && !model.style[:umldi_route] && !link.path.to_s.empty? &&
|
|
124
|
+
direct_association_ray?(model, bounds, offsets) &&
|
|
125
|
+
offsets.values.count { |offset| offset.all?(&:zero?) } == 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def stored_association_candidate?(model, bounds)
|
|
129
|
+
model.connector_type == "Association" && bounds.values.all? &&
|
|
130
|
+
association_ray_style?(model.style.to_h) && model.style[:lwidth] == "2"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def association_metadata(model)
|
|
134
|
+
fields = %i[waypoints source_anchor target_anchor source_port target_port has_geometry_offsets]
|
|
135
|
+
fields.to_h { |name| [name, model.public_send(name)] }.merge(
|
|
136
|
+
style: model.style.to_h.reject { |key, _| key == :umldi_route }
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def association_model(model, bounds, offsets)
|
|
141
|
+
Ea::Model::DiagramConnector.new(
|
|
142
|
+
{ connector_type: model.connector_type, style: model.style.to_h.dup, waypoints: model.waypoints,
|
|
143
|
+
has_geometry_offsets: !offsets[:source][0].nil? }.merge(association_ends(bounds, offsets))
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def association_ends(bounds, offsets)
|
|
148
|
+
%i[source target].each_with_object({}) do |kind, result|
|
|
149
|
+
box = bounds.fetch(kind)
|
|
150
|
+
offset = offsets.fetch(kind)
|
|
151
|
+
port = port_for(box, offset)
|
|
152
|
+
result["#{kind}_port".to_sym] = point_for(port) if port
|
|
153
|
+
result["#{kind}_anchor".to_sym] = association_anchor(box, offset)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def association_anchor(box, offset)
|
|
158
|
+
Ea::Model::Point.new(x: box.x + box.width / 2 + offset[0].to_i,
|
|
159
|
+
y: box.y + box.height / 2 + offset[1].to_i)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def stored_ray_boxes(_link, bounds)
|
|
163
|
+
bounds.values_at(:source, :target)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def direct_association_ray?(model, bounds, offsets)
|
|
167
|
+
%w[Association Aggregation].include?(model.connector_type) && model.waypoints.size == 2 &&
|
|
168
|
+
model.style.to_h.values_at(:mode, :lwidth, :tree) == ["1", "2", nil] &&
|
|
169
|
+
bounds.values.all? && offsets.values.flatten.none?(&:nil?)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def ray_geometry(offsets, edge)
|
|
173
|
+
{ sx: offsets[:source][0], sy: offsets[:source][1],
|
|
174
|
+
ex: offsets[:target][0], ey: offsets[:target][1], edge: edge }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def saved_association_ray?(model, link, source, target, geom)
|
|
178
|
+
(!geom[:sy].zero? || !geom[:ey].zero?) &&
|
|
179
|
+
stored_ray_gap?(source, target, "1", geom[:edge]) &&
|
|
180
|
+
stored_association_path?(model, link, source, target, geom)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def ray_points(source, target, offsets, geom)
|
|
184
|
+
ports = [port_for(source, offsets[:source]), port_for(target, offsets[:target])]
|
|
185
|
+
return {} unless ports.all?
|
|
186
|
+
|
|
187
|
+
{ source_port: point_for(ports.first), target_port: point_for(ports.last),
|
|
188
|
+
source_anchor: stored_ray_origin(source, geom[:sx], geom[:sy]),
|
|
189
|
+
target_anchor: stored_ray_origin(target, geom[:ex], geom[:ey]),
|
|
190
|
+
has_geometry_offsets: true }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def point_for(pair)
|
|
194
|
+
Ea::Model::Point.new(x: pair[0], y: pair[1])
|
|
195
|
+
end
|
|
196
|
+
|
|
31
197
|
# Placements here are already Ea::Model::Bounds.
|
|
32
198
|
def bounds_from_rect(bounds)
|
|
33
199
|
bounds
|
|
@@ -22,9 +22,11 @@ module Ea
|
|
|
22
22
|
SEAT_FONT_SIZE = 7
|
|
23
23
|
SEAT_X_INSET = 3
|
|
24
24
|
SEAT_STEREOTYPE_GAP = 12
|
|
25
|
-
# EA keeps an
|
|
26
|
-
#
|
|
27
|
-
#
|
|
25
|
+
# EA keeps an end-label role's left edge at diagram x >= 3; the
|
|
26
|
+
# stereotype line moves with the role. Measured on the Plateau
|
|
27
|
+
# v5.1 export: of 776 saved end labels, the only three whose
|
|
28
|
+
# UMLDI bounds start left of 3 (47E4600F, CB34B578, 159E9DA0) are
|
|
29
|
+
# drawn at x = 3, and no label is drawn left of it.
|
|
28
30
|
MIN_ROLE_X = 3
|
|
29
31
|
|
|
30
32
|
attr_reader :canvas, :model_index, :document, :theme,
|
|
@@ -61,12 +63,13 @@ module Ea
|
|
|
61
63
|
# fallback
|
|
62
64
|
def texts(text_box:, mult_box:, anchor:, connector:, end_kind:,
|
|
63
65
|
tree: false, heading: nil, stored_labels: false, bend: nil, source_bounds: nil,
|
|
64
|
-
target_face: nil)
|
|
66
|
+
target_face: nil, umldi_bounds: {})
|
|
65
67
|
from_property, role, mult = role_and_multiplicity(connector, end_kind)
|
|
66
68
|
if role_empty?(role) && mult_empty?(mult)
|
|
67
69
|
other_from_prop, other_role, other_mult = role_and_multiplicity(connector,
|
|
68
70
|
opposite(end_kind))
|
|
69
|
-
if stored_labels || connector.style.to_h[:explicit_end_roles]
|
|
71
|
+
if stored_labels || connector.style.to_h[:explicit_end_roles] ||
|
|
72
|
+
connector.style.to_h[:phantom_outline] == true
|
|
70
73
|
return [] if role_empty?(other_role) && mult_empty?(other_mult)
|
|
71
74
|
|
|
72
75
|
return own_stereotype_only(text_box, anchor, connector,
|
|
@@ -104,7 +107,7 @@ module Ea
|
|
|
104
107
|
plain_vertical_role(connector, direction, anchor, text_box, role) ||
|
|
105
108
|
source_property_seat(connector, direction, anchor, text_box, role) ||
|
|
106
109
|
plain_upward_seat(connector, direction, anchor, text_box, role) ||
|
|
107
|
-
role_seat(direction, anchor, text_box, role) || text_pos
|
|
110
|
+
role_seat(connector, direction, anchor, text_box, role) || text_pos
|
|
108
111
|
mult_pos = directional_multiplicity(direction, anchor, mult_box, mult) ||
|
|
109
112
|
horizontal_tree_multiplicity(connector, direction, anchor, mult_box, mult) ||
|
|
110
113
|
mult_seat(direction, anchor, mult_box) || mult_pos
|
|
@@ -149,12 +152,53 @@ module Ea
|
|
|
149
152
|
text_pos, mult_pos = stair_positions if stair_positions
|
|
150
153
|
end
|
|
151
154
|
|
|
155
|
+
role_bounds = umldi_bounds[end_kind == :source ? :source_role : :target_role]
|
|
156
|
+
mult_bounds = umldi_bounds[end_kind == :source ? :source_multiplicity : :target_multiplicity]
|
|
157
|
+
text_pos = umldi_label_position(role_bounds) if role_bounds
|
|
158
|
+
centering = role_bounds ? bounds_role_centering(connector, end_kind, role, stereo, text_box) : 0
|
|
159
|
+
text_pos = [text_pos[0] + centering, text_pos[1]] if centering.positive?
|
|
160
|
+
mult_pos = umldi_label_position(mult_bounds) if mult_bounds
|
|
161
|
+
if stereo && role_bounds
|
|
162
|
+
stereo_pos = umldi_stereotype_position(role_bounds, role, stereo, text_box)
|
|
163
|
+
stereo_pos = [role_bounds[:x], stereo_pos[1]] if centering.positive?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
if text_pos && !role_empty?(role) && min_role_x_cluster?(connector, end_kind, heading, text_box)
|
|
167
|
+
text_pos, stereo_pos = clamp_role_x(text_pos, stereo_pos)
|
|
168
|
+
end
|
|
169
|
+
|
|
152
170
|
build_texts(role:, mult:, standalone:,
|
|
153
|
-
text_pos:, mult_pos:, stereo:, text_box:)
|
|
171
|
+
text_pos:, mult_pos:, stereo:, text_box:, stereo_pos:)
|
|
154
172
|
end
|
|
155
173
|
|
|
156
174
|
private
|
|
157
175
|
|
|
176
|
+
# Measured only for default Cambria 7pt Property roles on a vertical
|
|
177
|
+
# leg rising from the end; other fonts, boxes and legs keep their
|
|
178
|
+
# unclamped seats.
|
|
179
|
+
def min_role_x_cluster?(connector, end_kind, heading, text_box)
|
|
180
|
+
rising_vertical?(heading) && plain_seat_font? &&
|
|
181
|
+
cambria_box?(text_box) && property_label?(connector, end_kind)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def rising_vertical?(heading)
|
|
185
|
+
heading && heading[0].zero? && heading[1].negative?
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def plain_seat_font?
|
|
189
|
+
seat_font? && font_weight == 400 && font_style == "normal"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Shift the role and its stereotype line together when the role
|
|
193
|
+
# would start left of EA's minimum label x.
|
|
194
|
+
def clamp_role_x(text_pos, stereo_pos)
|
|
195
|
+
shift = MIN_ROLE_X - text_pos[0]
|
|
196
|
+
return [text_pos, stereo_pos] unless shift.positive?
|
|
197
|
+
|
|
198
|
+
[[text_pos[0] + shift, text_pos[1]],
|
|
199
|
+
stereo_pos && [stereo_pos[0] + shift, stereo_pos[1]]]
|
|
200
|
+
end
|
|
201
|
+
|
|
158
202
|
# ALN2 single-line target roles retain their horizontal placement
|
|
159
203
|
# and use the saved corner's baseline on a straight stored ray.
|
|
160
204
|
def horizontal_aligned_target_baseline?(connector, heading, box, role)
|
|
@@ -190,8 +234,11 @@ module Ea
|
|
|
190
234
|
[x - width - SEAT_X_INSET, y - 7] if width
|
|
191
235
|
end
|
|
192
236
|
|
|
237
|
+
# UMLDI routes carry the inferred saved-ray flag; their stored LRT
|
|
238
|
+
# box keeps the plain target seat.
|
|
193
239
|
def moved_plain_target_ray?(connector, box)
|
|
194
|
-
saved_target_ray?(connector) &&
|
|
240
|
+
saved_target_ray?(connector) && connector.style.to_h[:umldi_route] != true &&
|
|
241
|
+
box.values_at("alignment", "cy") == [1, 16] &&
|
|
195
242
|
!stored_offsets_zero?(box)
|
|
196
243
|
end
|
|
197
244
|
|
|
@@ -318,15 +365,33 @@ module Ea
|
|
|
318
365
|
nil
|
|
319
366
|
end
|
|
320
367
|
|
|
368
|
+
# Saved bounds seat a source Property role at the stereotype's left
|
|
369
|
+
# edge. A centred stored box (ALN=1), or a phantom's synthesized box
|
|
370
|
+
# without alignment, centres the shorter role over it; a
|
|
371
|
+
# left-aligned box keeps both lines on that edge.
|
|
372
|
+
def bounds_role_centering(connector, end_kind, role, stereo, text_box)
|
|
373
|
+
return 0 unless end_kind == :source && stereo && seat_font?
|
|
374
|
+
return 0 unless centred_bounds_box?(connector, text_box)
|
|
375
|
+
return 0 unless property_label?(connector, :source)
|
|
376
|
+
|
|
377
|
+
-[stereotype_offset(role, stereo, { "alignment" => 1 }), 0].min
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Phantom outlines have no saved diagram link, so their boxes are
|
|
381
|
+
# synthesized and EA draws its centred default label.
|
|
382
|
+
def centred_bounds_box?(connector, text_box)
|
|
383
|
+
connector.style.to_h[:phantom_outline] == true || text_box.to_h["alignment"] == 1
|
|
384
|
+
end
|
|
385
|
+
|
|
321
386
|
# Role text on a rightward leg is left-aligned at the corner,
|
|
322
387
|
# 7px above it.
|
|
323
|
-
def role_seat(direction, anchor, box, role)
|
|
388
|
+
def role_seat(connector, direction, anchor, box, role)
|
|
324
389
|
return nil unless direction == :right
|
|
325
390
|
return nil if box.nil? || role_empty?(role)
|
|
326
391
|
return nil unless default_presentation?(box)
|
|
327
392
|
|
|
328
393
|
x, y = seat_corner(anchor, box)
|
|
329
|
-
[x + SEAT_X_INSET, y - 7]
|
|
394
|
+
[x + SEAT_X_INSET + source_property_centering(connector, direction, box, role), y - 7]
|
|
330
395
|
end
|
|
331
396
|
|
|
332
397
|
# Property-role clusters use a direction-specific horizontal seat;
|
|
@@ -348,12 +413,26 @@ module Ea
|
|
|
348
413
|
# Center a short role above the wider stereotype without moving
|
|
349
414
|
# the directional cluster's left edge. Other boxes keep their seat.
|
|
350
415
|
def source_property_centering(connector, direction, box, role)
|
|
351
|
-
return 0 unless
|
|
416
|
+
return 0 unless centered_property_box?(connector, direction, box)
|
|
417
|
+
|
|
418
|
+
property_role_centering(connector, :source, box, role)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def property_role_centering(connector, end_kind, box, role)
|
|
352
422
|
return 0 unless seat_font? && font_weight == 400 && font_style == "normal"
|
|
353
|
-
return 0 unless
|
|
423
|
+
return 0 unless property_label?(connector, end_kind)
|
|
424
|
+
|
|
425
|
+
stereo = "«#{connector_source_dest_stereotype(connector, end_kind)}»"
|
|
426
|
+
-[stereotype_offset(role, stereo, box.merge("alignment" => 1)), 0].min
|
|
427
|
+
end
|
|
354
428
|
|
|
355
|
-
|
|
356
|
-
|
|
429
|
+
# Measured box heights: left/down Property clusters store CY 35,
|
|
430
|
+
# right-seat clusters CY 31. Phantom outlines leaving down carry a
|
|
431
|
+
# synthesized zero box that centers the same way.
|
|
432
|
+
def centered_property_box?(connector, direction, box)
|
|
433
|
+
cy = { left: 35, down: 35, right: 31 }[direction]
|
|
434
|
+
(cy && box.values_at("cy", "alignment") == [cy, 1]) ||
|
|
435
|
+
(direction == :down && phantom_property_box?(connector, direction, box))
|
|
357
436
|
end
|
|
358
437
|
|
|
359
438
|
def source_property_seat?(connector, direction, box)
|
|
@@ -367,7 +446,7 @@ module Ea
|
|
|
367
446
|
# Their default Property cluster uses the directional seat with a
|
|
368
447
|
# centered stereotype; persisted or moved boxes keep their own seat.
|
|
369
448
|
def phantom_property_box?(connector, direction, box)
|
|
370
|
-
%i[up left].include?(direction) && seat_font? && cambria_box?(box) &&
|
|
449
|
+
%i[up left down].include?(direction) && seat_font? && cambria_box?(box) &&
|
|
371
450
|
box["alignment"].nil? && stored_offsets_zero?(box) &&
|
|
372
451
|
connector.style.to_h[:phantom_outline] == true &&
|
|
373
452
|
property_label?(connector, :source)
|
|
@@ -522,7 +601,7 @@ module Ea
|
|
|
522
601
|
|
|
523
602
|
x, y = seat_corner(anchor, box)
|
|
524
603
|
inset, = property_role_offsets(:down)
|
|
525
|
-
[x - inset - width, y - 7]
|
|
604
|
+
[x - inset - width + property_role_centering(connector, :target, box, role), y - 7]
|
|
526
605
|
end
|
|
527
606
|
|
|
528
607
|
def upward_target_property?(connector, heading)
|
|
@@ -665,7 +744,8 @@ module Ea
|
|
|
665
744
|
|
|
666
745
|
def downward_source_route?(style, box)
|
|
667
746
|
[orthogonal_stub_route?(style), lateral_horizontal_route?(style), mode_two_route?(style),
|
|
668
|
-
downward_property_route?(style), persisted_width_zero_down_box?(box, style)
|
|
747
|
+
downward_property_route?(style), persisted_width_zero_down_box?(box, style),
|
|
748
|
+
centered_width_zero_down_box?(box, style)].any?
|
|
669
749
|
end
|
|
670
750
|
|
|
671
751
|
def persisted_width_zero_down_box?(box, style)
|
|
@@ -673,6 +753,11 @@ module Ea
|
|
|
673
753
|
style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil]
|
|
674
754
|
end
|
|
675
755
|
|
|
756
|
+
def centered_width_zero_down_box?(box, style)
|
|
757
|
+
box["alignment"] == 1 && stored_offsets_zero?(box) &&
|
|
758
|
+
style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil]
|
|
759
|
+
end
|
|
760
|
+
|
|
676
761
|
# Mode 3, width 2 on the horizontal lateral tree.
|
|
677
762
|
def lateral_horizontal_route?(style)
|
|
678
763
|
style.values_at(:mode, :lwidth, :tree) == %w[3 2 LH]
|
|
@@ -820,13 +905,14 @@ module Ea
|
|
|
820
905
|
end
|
|
821
906
|
|
|
822
907
|
def build_texts(role:, mult:, standalone:, text_pos:, mult_pos:,
|
|
823
|
-
stereo:, text_box:)
|
|
908
|
+
stereo:, text_box:, stereo_pos: nil)
|
|
824
909
|
if role && !role.empty? && text_pos
|
|
825
910
|
x, y = text_pos
|
|
826
911
|
texts = [text_at(x, y, role)]
|
|
827
912
|
if stereo
|
|
828
|
-
stereo_x = x + stereotype_offset(role, stereo, text_box)
|
|
829
|
-
|
|
913
|
+
stereo_x, stereo_y = stereo_pos || [x + stereotype_offset(role, stereo, text_box),
|
|
914
|
+
y + property_y_offset(text_box)]
|
|
915
|
+
texts << text_at(stereo_x, stereo_y, stereo)
|
|
830
916
|
end
|
|
831
917
|
if mult && !mult.empty? && mult_pos
|
|
832
918
|
texts << text_at(*mult_pos, mult)
|
|
@@ -839,6 +925,19 @@ module Ea
|
|
|
839
925
|
end
|
|
840
926
|
end
|
|
841
927
|
|
|
928
|
+
def umldi_label_position(bounds)
|
|
929
|
+
[bounds[:x], bounds[:y] + font_pixel_size.ceil]
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
def umldi_stereotype_position(bounds, role, stereo, text_box)
|
|
933
|
+
[bounds[:x] + stereotype_offset(role, stereo, text_box),
|
|
934
|
+
bounds[:y] + font_pixel_size.ceil + (font_pixel_size.ceil + 2)]
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
def font_pixel_size
|
|
938
|
+
font_unit == "pt" ? font_size * 4.0 / 3 : font_size
|
|
939
|
+
end
|
|
940
|
+
|
|
842
941
|
def stereotype_offset(role, stereo, text_box)
|
|
843
942
|
return 0 unless text_box && text_box["alignment"] == 1
|
|
844
943
|
|
|
@@ -83,7 +83,8 @@ module Ea
|
|
|
83
83
|
|
|
84
84
|
stored_src = end_boxes(boxes, :llt, :llb)
|
|
85
85
|
stored_tgt = end_boxes(boxes, :lrt, :lrb)
|
|
86
|
-
|
|
86
|
+
umldi_bounds = connector.umldi_label_bounds || {}
|
|
87
|
+
stored_labels = !(stored_src.nil? && stored_tgt.nil?) || !umldi_bounds.empty?
|
|
87
88
|
src_tb, src_mb = stored_src || unstored_label_boxes(points, :source, stored_labels)
|
|
88
89
|
tgt_tb, tgt_mb = stored_tgt || unstored_label_boxes(points, :target, stored_labels)
|
|
89
90
|
|
|
@@ -93,6 +94,7 @@ module Ea
|
|
|
93
94
|
end_kind: :source, tree: tree && tree[:source],
|
|
94
95
|
heading: (stored_src || stored_labels) && source_heading(connector, points),
|
|
95
96
|
stored_labels: stored_labels,
|
|
97
|
+
umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :source),
|
|
96
98
|
source_bounds: source_dock_bounds(connector)).each { |t| texts << t }
|
|
97
99
|
end_renderer.texts(text_box: tgt_tb,
|
|
98
100
|
mult_box: tgt_mb,
|
|
@@ -101,10 +103,21 @@ module Ea
|
|
|
101
103
|
bend: Label::TargetBendSeat.reference(points),
|
|
102
104
|
heading: (stored_tgt || stored_labels) && target_heading(connector, points),
|
|
103
105
|
target_face: target_dock_heading(connector, target_pt, target_heading(connector, points)),
|
|
104
|
-
stored_labels: stored_labels
|
|
106
|
+
stored_labels: stored_labels,
|
|
107
|
+
umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :target)).each { |t| texts << t }
|
|
105
108
|
texts
|
|
106
109
|
end
|
|
107
110
|
|
|
111
|
+
# Role bounds the XMI builder keeps belong to this connector's own
|
|
112
|
+
# memberEnd and win over a stored role box; a stored multiplicity box
|
|
113
|
+
# wins over multiplicity bounds.
|
|
114
|
+
def available_umldi_bounds(bounds, label_boxes, end_kind)
|
|
115
|
+
mult_key = end_kind == :source ? :llb : :lrb
|
|
116
|
+
result = bounds.dup
|
|
117
|
+
result.delete(end_kind == :source ? :source_multiplicity : :target_multiplicity) if label_boxes.key?(mult_key)
|
|
118
|
+
result
|
|
119
|
+
end
|
|
120
|
+
|
|
108
121
|
# A connector that stores label boxes for only one end seats the
|
|
109
122
|
# other end's labels at its anchor, like a stored zero offset.
|
|
110
123
|
def unstored_label_boxes(points, end_kind, stored_labels)
|
|
@@ -52,11 +52,16 @@ module Ea
|
|
|
52
52
|
# feature compartment — even one whose rows are all hidden
|
|
53
53
|
# by visibility flags (10E3E08A: CodeList with 3 attributes
|
|
54
54
|
# toggled off still renders the divider over an empty body;
|
|
55
|
-
# only feature-less elements omit it).
|
|
55
|
+
# only feature-less elements omit it). Association-end
|
|
56
|
+
# properties are not EA attributes (QEA keeps them as connector
|
|
57
|
+
# roles; the XMI reads them as ownedAttribute), so a class whose
|
|
58
|
+
# only properties are association ends has no feature compartment
|
|
59
|
+
# and no divider (A59065AF urf::Administration).
|
|
56
60
|
def hidden_features_present?
|
|
57
61
|
return false unless classifier.respond_to?(:properties)
|
|
58
62
|
|
|
59
|
-
classifier.properties.any?
|
|
63
|
+
classifier.properties.any? { |property| !property.association_id } ||
|
|
64
|
+
classifier.operations.any?
|
|
60
65
|
end
|
|
61
66
|
end
|
|
62
67
|
end
|
data/lib/ea/version.rb
CHANGED
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.138
|
|
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-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: lutaml-model
|
|
@@ -857,6 +857,7 @@ files:
|
|
|
857
857
|
- lib/ea/sources/xmi/diagram_builder/font_settings.rb
|
|
858
858
|
- lib/ea/sources/xmi/diagram_builder/stereotype_profiles.rb
|
|
859
859
|
- lib/ea/sources/xmi/diagram_builder/umldi_edges.rb
|
|
860
|
+
- lib/ea/sources/xmi/diagram_builder/umldi_edges/property_roles.rb
|
|
860
861
|
- lib/ea/sources/xmi/extension_elements.rb
|
|
861
862
|
- lib/ea/sources/xmi/extension_geometry_parser.rb
|
|
862
863
|
- lib/ea/sources/xmi/extension_style_parser.rb
|