ea 0.6.128 → 0.6.129
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/carlito_gdi_widths.json +1 -0
- data/lib/ea/fonts/cjk_fallback_widths.json +1 -0
- data/lib/ea/fonts/metrics.rb +73 -15
- data/lib/ea/fonts/vertical_metrics.json +154 -0
- data/lib/ea/sources/qea/diagram_builder/association_class.rb +52 -0
- data/lib/ea/sources/qea/diagram_builder/routing.rb +57 -3
- data/lib/ea/sources/qea/diagram_builder.rb +5 -2
- data/lib/ea/sources/qea/relationship_builder.rb +6 -6
- data/lib/ea/sources/xmi/relationship_builder.rb +15 -8
- data/lib/ea/svg/ea_emitter/compartment/note_body.rb +56 -20
- data/lib/ea/svg/ea_emitter/compartment/note_html.rb +18 -31
- data/lib/ea/svg/ea_emitter/connector_redock.rb +40 -0
- data/lib/ea/svg/ea_emitter/connectors.rb +1 -0
- data/lib/ea/svg/ea_emitter/font_resolver.rb +6 -1
- data/lib/ea/svg/ea_emitter/label/end_label.rb +92 -7
- data/lib/ea/svg/ea_emitter/label/midpoint_label.rb +28 -8
- data/lib/ea/svg/ea_emitter/labels.rb +18 -3
- data/lib/ea/svg/ea_emitter/marker/diamond.rb +29 -5
- data/lib/ea/version.rb +1 -1
- metadata +5 -1
|
@@ -80,9 +80,9 @@ module Ea
|
|
|
80
80
|
target_multiplicity_raw: raw_card(row.destcard),
|
|
81
81
|
source_aggregation: source_aggregation_for(row),
|
|
82
82
|
target_aggregation: aggregation_from(row.destisaggregate),
|
|
83
|
-
source_navigable: navigable?(row.
|
|
83
|
+
source_navigable: navigable?(row.sourceisnavigable, row.direction,
|
|
84
84
|
:source),
|
|
85
|
-
target_navigable: navigable?(row.
|
|
85
|
+
target_navigable: navigable?(row.destisnavigable, row.direction, :target)
|
|
86
86
|
}
|
|
87
87
|
end
|
|
88
88
|
|
|
@@ -148,10 +148,10 @@ module Ea
|
|
|
148
148
|
ConnectorRelationshipMap.source_aggregation_for(row.connector_type)
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
def navigable?(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
#
|
|
151
|
+
def navigable?(flag, direction, end_label)
|
|
152
|
+
return flag == 1 if [0, 1].include?(flag)
|
|
153
|
+
|
|
154
|
+
# Missing or unspecified flags retain the direction fallback.
|
|
155
155
|
case direction.to_s
|
|
156
156
|
when "<->", "" then true
|
|
157
157
|
when "->" then end_label == :target
|
|
@@ -76,8 +76,8 @@ module Ea
|
|
|
76
76
|
target_multiplicity_upper: parse_upper(target_end),
|
|
77
77
|
source_aggregation: source_end&.aggregation || "none",
|
|
78
78
|
target_aggregation: target_end&.aggregation || "none",
|
|
79
|
-
source_navigable: navigable?(
|
|
80
|
-
target_navigable: navigable?(
|
|
79
|
+
source_navigable: navigable?(element, :source),
|
|
80
|
+
target_navigable: navigable?(element, :target)
|
|
81
81
|
}
|
|
82
82
|
end
|
|
83
83
|
|
|
@@ -128,13 +128,20 @@ module Ea
|
|
|
128
128
|
Integer(raw) rescue 1
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
-
def
|
|
132
|
-
|
|
131
|
+
def connector_index
|
|
132
|
+
@connector_index ||= begin
|
|
133
|
+
connectors = root.extension&.connectors&.connector || []
|
|
134
|
+
connectors.each_with_object({}) do |connector, index|
|
|
135
|
+
index[connector.idref] = connector if connector.idref
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
133
139
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
def navigable?(element, endpoint)
|
|
141
|
+
connector = connector_index[element.id]
|
|
142
|
+
end_element = connector&.public_send(endpoint)
|
|
143
|
+
flag = end_element&.modifiers&.is_navigable
|
|
144
|
+
flag.nil? || flag
|
|
138
145
|
end
|
|
139
146
|
end
|
|
140
147
|
end
|
|
@@ -8,6 +8,11 @@ module Ea
|
|
|
8
8
|
# Renders only when the host element is a Note with a
|
|
9
9
|
# non-empty body.
|
|
10
10
|
module NoteBody
|
|
11
|
+
# Native notes put the first baseline this far below the note
|
|
12
|
+
# top plus the font's GDI cell ascent (Arial 9pt/10pt +27,
|
|
13
|
+
# Cambria 7pt +24, Carlito 9pt +26).
|
|
14
|
+
TEXT_TOP = 14
|
|
15
|
+
|
|
11
16
|
module_function
|
|
12
17
|
|
|
13
18
|
def render(context)
|
|
@@ -22,17 +27,15 @@ module Ea
|
|
|
22
27
|
end
|
|
23
28
|
|
|
24
29
|
def wrapped_lines(context, body)
|
|
25
|
-
# Lines fit bounds.width - 4. Pitch
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# paragraph 1).
|
|
30
|
+
# Lines fit bounds.width - 4. Pitch is the GDI cell height
|
|
31
|
+
# of the note font and the first baseline sits TEXT_TOP
|
|
32
|
+
# plus the cell ascent below the top; fonts without
|
|
33
|
+
# vertical data keep pitch size+7 and offset size+9. Each
|
|
34
|
+
# empty source line adds one blank pitch row.
|
|
31
35
|
usable = context.bounds.width - 4
|
|
32
36
|
size = context.size
|
|
33
37
|
family = context.family
|
|
34
|
-
pitch =
|
|
35
|
-
first_offset = size + 9
|
|
38
|
+
pitch, first_offset = vertical_layout(context)
|
|
36
39
|
y_index = 0
|
|
37
40
|
paragraphs = NoteHtml.paragraphs(body)
|
|
38
41
|
paragraphs.flat_map do |para|
|
|
@@ -52,28 +55,38 @@ module Ea
|
|
|
52
55
|
width_factor: context.theme.text_width_factor
|
|
53
56
|
).to_svg
|
|
54
57
|
end
|
|
55
|
-
y_index += lines.size +
|
|
58
|
+
y_index += lines.size + para.gap
|
|
56
59
|
paragraph_svg
|
|
57
60
|
end
|
|
58
61
|
end
|
|
59
62
|
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
# [pitch, first-baseline offset] for the note font.
|
|
64
|
+
def vertical_layout(context)
|
|
65
|
+
size = context.size
|
|
66
|
+
height = context.logical_height
|
|
67
|
+
height ||= Ea::Fonts::Metrics.gdi_logical_height(size) if context.size_unit == "pt"
|
|
68
|
+
ascent, descent = Ea::Fonts::Metrics.gdi_cell_metrics(context.family, height)
|
|
69
|
+
return [size + 7, size + 9] unless ascent
|
|
70
|
+
|
|
71
|
+
[ascent + descent, TEXT_TOP + ascent]
|
|
72
|
+
end
|
|
73
|
+
module_function :vertical_layout
|
|
74
|
+
|
|
75
|
+
# Break at whitespace and between a CJK character and its
|
|
76
|
+
# neighbours. Every atom but the paragraph's last carries a
|
|
77
|
+
# following space, when the source has one, into the fit test.
|
|
63
78
|
def wrap_paragraph(para, usable, size, family, logical_height: nil)
|
|
64
79
|
lines = []
|
|
65
80
|
current = ""
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
candidate = current.empty? ?
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
family: family, logical_height: logical_height).round
|
|
72
|
-
if current.empty? || width <= usable
|
|
81
|
+
atoms = break_atoms(para)
|
|
82
|
+
atoms.each_with_index do |atom, index|
|
|
83
|
+
candidate = current.empty? ? atom[:text] : "#{current}#{atom[:space]}#{atom[:text]}"
|
|
84
|
+
measured = "#{candidate}#{atoms.dig(index + 1, :space)}"
|
|
85
|
+
if current.empty? || fit_width(measured, size, family, logical_height) <= usable
|
|
73
86
|
current = candidate
|
|
74
87
|
else
|
|
75
88
|
lines << current
|
|
76
|
-
current =
|
|
89
|
+
current = atom[:text]
|
|
77
90
|
end
|
|
78
91
|
end
|
|
79
92
|
lines << current unless current.empty?
|
|
@@ -81,6 +94,29 @@ module Ea
|
|
|
81
94
|
end
|
|
82
95
|
module_function :wrap_paragraph
|
|
83
96
|
|
|
97
|
+
def fit_width(text, size, family, logical_height)
|
|
98
|
+
TextRenderer.estimate_width(text, size, nil, family: family, logical_height: logical_height).round
|
|
99
|
+
end
|
|
100
|
+
module_function :fit_width
|
|
101
|
+
|
|
102
|
+
# Unbreakable runs of text with the space that precedes each one.
|
|
103
|
+
def break_atoms(text)
|
|
104
|
+
words = text.split(/\s+/).reject(&:empty?)
|
|
105
|
+
words.each_with_index.flat_map { |word, index| word_atoms(word, index.positive? ? " " : "") }
|
|
106
|
+
end
|
|
107
|
+
module_function :break_atoms
|
|
108
|
+
|
|
109
|
+
def word_atoms(word, space)
|
|
110
|
+
runs = word.chars.slice_when { |a, b| cjk_char?(a) || cjk_char?(b) }.map(&:join)
|
|
111
|
+
runs.each_with_index.map { |run, i| { text: run, space: i.zero? ? space : "" } }
|
|
112
|
+
end
|
|
113
|
+
module_function :word_atoms
|
|
114
|
+
|
|
115
|
+
def cjk_char?(char)
|
|
116
|
+
Ea::Fonts::Metrics.cjk?(char.ord)
|
|
117
|
+
end
|
|
118
|
+
module_function :cjk_char?
|
|
119
|
+
|
|
84
120
|
def group_style(context)
|
|
85
121
|
"stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; " \
|
|
86
122
|
"fill:#{context.theme.text_color};fill-opacity:1.00; " \
|
|
@@ -4,13 +4,14 @@ module Ea
|
|
|
4
4
|
module Svg
|
|
5
5
|
module EaEmitter
|
|
6
6
|
module Compartment
|
|
7
|
-
# Splits a note body into paragraphs
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
7
|
+
# Splits a note body into paragraphs, one per non-empty source
|
|
8
|
+
# line. Each paragraph stacks directly under the previous one
|
|
9
|
+
# (`gap` 0); every empty source line after it adds one blank row.
|
|
10
|
+
# A body with HTML markup is shown by EA as plain text: tags are
|
|
11
|
+
# dropped, entities decoded, `<ul>`/`</ul>` lines take no row,
|
|
12
|
+
# each `<li>` becomes a "- " line and `<br>` ends a line.
|
|
12
13
|
module NoteHtml
|
|
13
|
-
Paragraph = Struct.new(:text, :
|
|
14
|
+
Paragraph = Struct.new(:text, :gap)
|
|
14
15
|
|
|
15
16
|
TAG = %r{</?(?:ul|ol|li|b|i|u|p|br|strong|em)\b[^>]*>}i
|
|
16
17
|
LINE_BREAK = /<br\b[^>]*>/i
|
|
@@ -22,37 +23,23 @@ module Ea
|
|
|
22
23
|
|
|
23
24
|
def paragraphs(body)
|
|
24
25
|
text = body.to_s
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
markup = text.match?(TAG)
|
|
27
|
+
lines = text.split(/\r?\n/)
|
|
28
|
+
lines.reject! { |raw| raw.strip.match?(LIST_WRAPPER) } if markup
|
|
29
|
+
lines.each_with_object([]) do |raw, paragraphs|
|
|
30
|
+
raw.strip.empty? ? paragraphs.last&.gap += 1 : paragraphs.concat(line_paragraphs(raw, markup))
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def html_lines(text)
|
|
38
|
-
text.split(/\r?\n/).flat_map do |raw|
|
|
39
|
-
line = raw.strip
|
|
40
|
-
next [] if line.empty? || line.match?(LIST_WRAPPER)
|
|
41
|
-
|
|
42
|
-
item = line.match(LIST_ITEM)
|
|
43
|
-
item ? broken_lines(item[1], "- ") : broken_lines(line, "")
|
|
44
|
-
end
|
|
34
|
+
def line_paragraphs(raw, markup)
|
|
35
|
+
markup ? html_lines(raw.strip) : [Paragraph.new(raw, 0)]
|
|
45
36
|
end
|
|
46
37
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
# carries the list bullet.
|
|
51
|
-
def broken_lines(line, bullet)
|
|
52
|
-
segments = line.split(LINE_BREAK).map { |segment| clean(segment) }.reject(&:empty?)
|
|
38
|
+
def html_lines(line)
|
|
39
|
+
item = line.match(LIST_ITEM)
|
|
40
|
+
segments = (item ? item[1] : line).split(LINE_BREAK).map { |segment| clean(segment) }.reject(&:empty?)
|
|
53
41
|
segments.each_with_index.map do |segment, index|
|
|
54
|
-
|
|
55
|
-
[first ? "#{bullet}#{segment}" : segment, first && !bullet.empty?, index < segments.size - 1]
|
|
42
|
+
Paragraph.new(item && index.zero? ? "- #{segment}" : segment, 0)
|
|
56
43
|
end
|
|
57
44
|
end
|
|
58
45
|
|
|
@@ -19,6 +19,10 @@ module Ea
|
|
|
19
19
|
# self-loop, or no drawn bounds for either end).
|
|
20
20
|
def pairs_for(connector, diagram, model_index, bounds_map)
|
|
21
21
|
return nil unless bounds_map
|
|
22
|
+
|
|
23
|
+
tether = association_class_pairs(connector, bounds_map)
|
|
24
|
+
return tether if tether
|
|
25
|
+
|
|
22
26
|
points = waypoint_pairs(connector)
|
|
23
27
|
lateral = lateral_tree?(connector, points)
|
|
24
28
|
trunk = trunk_tree?(connector, points)
|
|
@@ -74,6 +78,42 @@ module Ea
|
|
|
74
78
|
auto_port_elbow_pairs(connector, source, target, pairs)
|
|
75
79
|
end
|
|
76
80
|
|
|
81
|
+
# A tether joins its saved association anchor to the facing
|
|
82
|
+
# side-center of the attached class, rather than to the other proxy.
|
|
83
|
+
def association_class_pairs(connector, bounds_map)
|
|
84
|
+
bounds = bounds_map[connector.style.to_h[:association_class_ref]]
|
|
85
|
+
anchor = connector.source_anchor
|
|
86
|
+
return nil unless bounds && anchor
|
|
87
|
+
|
|
88
|
+
[[anchor.x, anchor.y], association_class_dock(bounds, anchor)]
|
|
89
|
+
end
|
|
90
|
+
private_class_method :association_class_pairs
|
|
91
|
+
|
|
92
|
+
def association_class_dock(bounds, anchor)
|
|
93
|
+
center = association_class_center(bounds)
|
|
94
|
+
if horizontal_association_class?(bounds, anchor, center)
|
|
95
|
+
[association_class_edge_coordinate(bounds.x, bounds.width, anchor.x), center[1]]
|
|
96
|
+
else
|
|
97
|
+
[center[0], association_class_edge_coordinate(bounds.y, bounds.height, anchor.y)]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
private_class_method :association_class_dock
|
|
101
|
+
|
|
102
|
+
def association_class_center(bounds)
|
|
103
|
+
[bounds.x + bounds.width / 2, bounds.y + bounds.height / 2]
|
|
104
|
+
end
|
|
105
|
+
private_class_method :association_class_center
|
|
106
|
+
|
|
107
|
+
def horizontal_association_class?(bounds, anchor, center)
|
|
108
|
+
(anchor.x - center[0]).abs * bounds.height > (anchor.y - center[1]).abs * bounds.width
|
|
109
|
+
end
|
|
110
|
+
private_class_method :horizontal_association_class?
|
|
111
|
+
|
|
112
|
+
def association_class_edge_coordinate(origin, size, anchor)
|
|
113
|
+
anchor > origin + size / 2 ? origin + size : origin
|
|
114
|
+
end
|
|
115
|
+
private_class_method :association_class_edge_coordinate
|
|
116
|
+
|
|
77
117
|
# Auto routing turns at the clipped target column when the source
|
|
78
118
|
# leaves a side edge and the target enters through its top or bottom.
|
|
79
119
|
def auto_port_elbow_pairs(connector, source, target, pairs)
|
|
@@ -17,6 +17,11 @@ module Ea
|
|
|
17
17
|
# text-emitting layer.
|
|
18
18
|
class FontResolver
|
|
19
19
|
DEFAULT_FONT_FAMILY = "Yu Gothic UI"
|
|
20
|
+
# EA draws every text kind of a hand-drawn diagram (names, notes,
|
|
21
|
+
# connector labels) in this family.
|
|
22
|
+
HAND_DRAW_FAMILY = "Source Sans Pro"
|
|
23
|
+
# Native hand-drawn connector end labels carry font-weight:0.
|
|
24
|
+
HAND_DRAW_LABEL_WEIGHT = 0
|
|
20
25
|
# EA's default element compartment font size. EA-published
|
|
21
26
|
# reference SVGs render body text at 7pt (the current EA
|
|
22
27
|
# theme size) for every element whose objectstyle fontsz is
|
|
@@ -87,7 +92,7 @@ module Ea
|
|
|
87
92
|
private
|
|
88
93
|
|
|
89
94
|
def default_family_for(explicit)
|
|
90
|
-
return
|
|
95
|
+
return HAND_DRAW_FAMILY if diagram.hand_draw
|
|
91
96
|
|
|
92
97
|
theme_family_for(explicit) || diagram_default_family || DEFAULT_FONT_FAMILY
|
|
93
98
|
end
|
|
@@ -28,10 +28,12 @@ module Ea
|
|
|
28
28
|
MIN_ROLE_X = 3
|
|
29
29
|
|
|
30
30
|
attr_reader :canvas, :model_index, :document, :theme,
|
|
31
|
-
:font_family, :font_size, :font_unit
|
|
31
|
+
:font_family, :font_size, :font_unit,
|
|
32
|
+
:font_weight, :font_style
|
|
32
33
|
|
|
33
34
|
def initialize(canvas:, model_index:, document:, theme:,
|
|
34
|
-
font_family:, font_size:, font_unit
|
|
35
|
+
font_family:, font_size:, font_unit:,
|
|
36
|
+
font_weight: 400, font_style: "normal")
|
|
35
37
|
@canvas = canvas
|
|
36
38
|
@model_index = model_index
|
|
37
39
|
@document = document
|
|
@@ -39,6 +41,8 @@ module Ea
|
|
|
39
41
|
@font_family = font_family
|
|
40
42
|
@font_size = font_size
|
|
41
43
|
@font_unit = font_unit
|
|
44
|
+
@font_weight = font_weight
|
|
45
|
+
@font_style = font_style
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
# Returns an Array of `<text>` SVG strings for one endpoint.
|
|
@@ -49,12 +53,13 @@ module Ea
|
|
|
49
53
|
# connector - the DiagramConnector (for relationship lookup)
|
|
50
54
|
# end_kind - :source or :target
|
|
51
55
|
# heading - [dx, dy] of the first leg leaving the anchor
|
|
56
|
+
# source_bounds - effective drawn source outline, in anchor coordinates
|
|
52
57
|
# bend - first bend of a saved descending vertical stair
|
|
53
58
|
# stored_labels - the connector has stored label boxes at
|
|
54
59
|
# either end; false keeps the opposite-end
|
|
55
60
|
# fallback
|
|
56
61
|
def texts(text_box:, mult_box:, anchor:, connector:, end_kind:,
|
|
57
|
-
tree: false, heading: nil, stored_labels: false, bend: nil)
|
|
62
|
+
tree: false, heading: nil, stored_labels: false, bend: nil, source_bounds: nil)
|
|
58
63
|
from_property, role, mult = role_and_multiplicity(connector, end_kind)
|
|
59
64
|
if role_empty?(role) && mult_empty?(mult)
|
|
60
65
|
other_from_prop, other_role, other_mult = role_and_multiplicity(connector,
|
|
@@ -105,6 +110,7 @@ module Ea
|
|
|
105
110
|
text_pos = plain_leftward_target_role(connector, heading, anchor, text_box, role) ||
|
|
106
111
|
target_leftward_property_role(connector, heading, anchor, text_box, role) ||
|
|
107
112
|
target_property_role(connector, heading, anchor, text_box, role) ||
|
|
113
|
+
saved_ray_target_property_role(connector, heading, anchor, text_box, role) ||
|
|
108
114
|
target_diagonal_property_role(connector, heading, anchor, text_box, role) ||
|
|
109
115
|
target_vertical_role(connector, heading, anchor, text_box, role) ||
|
|
110
116
|
target_rightward_role(heading, anchor, text_box, role) || text_pos
|
|
@@ -115,6 +121,9 @@ module Ea
|
|
|
115
121
|
if end_kind == :source
|
|
116
122
|
text_pos = source_top_property_role(connector, heading, anchor, text_box, role) ||
|
|
117
123
|
source_bottom_property_role(connector, heading, anchor, text_box, role) || text_pos
|
|
124
|
+
if external_upward_property_source?(connector, heading, anchor, text_box, source_bounds)
|
|
125
|
+
text_pos = external_upward_property_role(anchor, role) || text_pos
|
|
126
|
+
end
|
|
118
127
|
end
|
|
119
128
|
|
|
120
129
|
text_box = text_box.merge("alignment" => 1) if phantom_property_box?(connector, direction, text_box)
|
|
@@ -138,12 +147,48 @@ module Ea
|
|
|
138
147
|
return nil unless heading && heading[0].negative? && heading[1].zero?
|
|
139
148
|
return nil unless plain_target_role?(connector, box, role)
|
|
140
149
|
|
|
150
|
+
return saved_ray_plain_target_position(anchor, box, role) if moved_plain_target_ray?(connector, box)
|
|
151
|
+
|
|
141
152
|
PlainTargetSeat.position(
|
|
142
153
|
connector: connector, anchor: anchor, box: box,
|
|
143
154
|
width: Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
|
|
144
155
|
)
|
|
145
156
|
end
|
|
146
157
|
|
|
158
|
+
def saved_ray_plain_target_position(anchor, box, role)
|
|
159
|
+
width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
|
|
160
|
+
x, y = seat_corner(anchor, box)
|
|
161
|
+
[x - width - SEAT_X_INSET, y - 7] if width
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def moved_plain_target_ray?(connector, box)
|
|
165
|
+
saved_target_ray?(connector) && box.values_at("alignment", "cy") == [1, 16] &&
|
|
166
|
+
!stored_offsets_zero?(box)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def saved_target_ray?(connector)
|
|
170
|
+
connector.connector_type == "Association" &&
|
|
171
|
+
connector.style.to_h.values_at(:mode, :lwidth, :tree, :stored_association_ray) == ["3", "0", nil, true]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Moved Property boxes on saved rays arriving from below use the
|
|
175
|
+
# downward seat, relative to the persisted label corner.
|
|
176
|
+
def saved_ray_target_property_role(connector, heading, anchor, box, role)
|
|
177
|
+
return nil unless downward_saved_property?(connector, heading, box)
|
|
178
|
+
|
|
179
|
+
width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
|
|
180
|
+
return nil unless width
|
|
181
|
+
|
|
182
|
+
x, y = seat_corner(anchor, box)
|
|
183
|
+
inset, baseline = property_role_offsets(:down)
|
|
184
|
+
[x - inset - width, y + baseline]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def downward_saved_property?(connector, heading, box)
|
|
188
|
+
heading && heading[1].positive? && heading[1] > heading[0].abs &&
|
|
189
|
+
saved_target_ray?(connector) && moved_target_property_box?(connector, box)
|
|
190
|
+
end
|
|
191
|
+
|
|
147
192
|
def plain_target_role?(connector, box, role)
|
|
148
193
|
seat_font? && cambria_box?(box) && !role_empty?(role) &&
|
|
149
194
|
connector_source_dest_stereotype(connector, :target).to_s.empty?
|
|
@@ -302,6 +347,43 @@ module Ea
|
|
|
302
347
|
source_property_seat(connector, :up, anchor, box, role)
|
|
303
348
|
end
|
|
304
349
|
|
|
350
|
+
# A persisted origin above the drawn source outline keeps the
|
|
351
|
+
# upward seat's inset and the shared upper baseline, including
|
|
352
|
+
# steep diagonals. Origins clipped onto the face retain -6.
|
|
353
|
+
def external_upward_property_role(anchor, role)
|
|
354
|
+
width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
|
|
355
|
+
return nil unless width
|
|
356
|
+
|
|
357
|
+
inset, = property_role_offsets(:up)
|
|
358
|
+
_, baseline = property_role_offsets(:left)
|
|
359
|
+
[[anchor[0] - inset - width, MIN_ROLE_X].max, anchor[1] + baseline]
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def external_upward_property_source?(connector, heading, anchor, box, bounds)
|
|
363
|
+
upward_source_heading?(heading) &&
|
|
364
|
+
zero_source_property_box?(box) && cambria_box?(box) && source_property_cambria?(connector) &&
|
|
365
|
+
external_source_route?(connector, anchor) && above_source_outline?(anchor, bounds)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def upward_source_heading?(heading)
|
|
369
|
+
source_seat?(:source, heading) && heading[1].negative? && heading[1].abs > heading[0].abs
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def external_source_route?(connector, anchor)
|
|
373
|
+
origin = connector.source_anchor
|
|
374
|
+
connector.connector_type == "Association" && connector.waypoints.size == 3 &&
|
|
375
|
+
connector.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "0", nil] &&
|
|
376
|
+
connector.label_boxes.to_h.values_at(:llt, :llb).all?(&:nil?) &&
|
|
377
|
+
origin && [origin.x, origin.y] == anchor
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def above_source_outline?(anchor, bounds)
|
|
381
|
+
return false unless bounds&.width&.positive? && bounds.height.positive?
|
|
382
|
+
|
|
383
|
+
anchor[1] < bounds.y &&
|
|
384
|
+
anchor[0].between?(bounds.x, bounds.x + bounds.width)
|
|
385
|
+
end
|
|
386
|
+
|
|
305
387
|
def zero_source_property_box?(box)
|
|
306
388
|
box && box["alignment"]&.zero? && stored_offsets_zero?(box)
|
|
307
389
|
end
|
|
@@ -371,7 +453,8 @@ module Ea
|
|
|
371
453
|
end
|
|
372
454
|
|
|
373
455
|
def horizontal_target_property_route?(style)
|
|
374
|
-
style.values_at(:mode, :lwidth) ==
|
|
456
|
+
style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil] ||
|
|
457
|
+
(style.values_at(:mode, :lwidth) == %w[3 2] && [nil, "LH"].include?(style[:tree]))
|
|
375
458
|
end
|
|
376
459
|
|
|
377
460
|
# An upward target uses the downward property's right edge and
|
|
@@ -453,9 +536,8 @@ module Ea
|
|
|
453
536
|
seat_font? && cambria_box?(box) && [0, 1].include?(box["alignment"])
|
|
454
537
|
end
|
|
455
538
|
|
|
456
|
-
#
|
|
457
|
-
#
|
|
458
|
-
# width-0 route.
|
|
539
|
+
# Downward target seats require LV width 2; upward target seats
|
|
540
|
+
# also accept LV width 0. Source seats require a plain width-0 route.
|
|
459
541
|
def plain_vertical_connector?(connector, end_kind, direction)
|
|
460
542
|
return false unless connector.connector_type == "Association" &&
|
|
461
543
|
connector_source_dest_stereotype(connector, end_kind).empty?
|
|
@@ -468,6 +550,8 @@ module Ea
|
|
|
468
550
|
|
|
469
551
|
if direction == :target_down
|
|
470
552
|
style[:lwidth] == "2" && style[:tree] == "LV"
|
|
553
|
+
elsif direction == :target_up
|
|
554
|
+
style[:lwidth] == "0" && [nil, "LV"].include?(style[:tree])
|
|
471
555
|
else
|
|
472
556
|
style[:lwidth] == "0" && !style[:tree]
|
|
473
557
|
end
|
|
@@ -915,6 +999,7 @@ module Ea
|
|
|
915
999
|
y_t = canvas ? canvas.translate_y(y) : y
|
|
916
1000
|
TextRenderer.new(content:, x: x_t, y: y_t,
|
|
917
1001
|
family: font_family, size: font_size,
|
|
1002
|
+
weight: font_weight, style: font_style,
|
|
918
1003
|
size_unit: font_unit,
|
|
919
1004
|
fill: fill_color).to_svg
|
|
920
1005
|
end
|
|
@@ -15,15 +15,17 @@ module Ea
|
|
|
15
15
|
# Stereotype wins when both are present.
|
|
16
16
|
class MidpointLabel
|
|
17
17
|
attr_reader :canvas, :model_index, :font_family, :font_size,
|
|
18
|
-
:font_unit
|
|
18
|
+
:font_unit, :font_weight, :font_style
|
|
19
19
|
|
|
20
20
|
def initialize(canvas:, model_index:, font_family:, font_size:,
|
|
21
|
-
font_unit:)
|
|
21
|
+
font_unit:, font_weight: 400, font_style: "normal")
|
|
22
22
|
@canvas = canvas
|
|
23
23
|
@model_index = model_index
|
|
24
24
|
@font_family = font_family
|
|
25
25
|
@font_size = font_size
|
|
26
26
|
@font_unit = font_unit
|
|
27
|
+
@font_weight = font_weight
|
|
28
|
+
@font_style = font_style
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
# Returns the SVG `<text>` for the connector's midpoint
|
|
@@ -112,14 +114,15 @@ module Ea
|
|
|
112
114
|
return nil unless stereotype_label(connector)
|
|
113
115
|
|
|
114
116
|
box = connector.label_boxes.to_h[:lmb]
|
|
115
|
-
return nil unless default_name_box?(box)
|
|
117
|
+
return nil unless default_name_box?(box, alignments: [0, 1])
|
|
116
118
|
|
|
117
119
|
stored_position(connector, points, box, LINE_PITCH)
|
|
118
120
|
end
|
|
119
121
|
|
|
120
122
|
# At 7pt, a centered name's stored corner is 3px below its baseline.
|
|
121
123
|
def stored_name_position(connector, points)
|
|
122
|
-
|
|
124
|
+
seat_points = horizontal_bend_seat(points) || points
|
|
125
|
+
stored_position(connector, seat_points, stored_name_box(connector, points), -3)
|
|
123
126
|
end
|
|
124
127
|
|
|
125
128
|
def stored_position(connector, points, box, baseline)
|
|
@@ -133,21 +136,37 @@ module Ea
|
|
|
133
136
|
[x + offset_x - (width / 2.0).floor, y + offset_y + baseline]
|
|
134
137
|
end
|
|
135
138
|
|
|
136
|
-
def stored_name_box(connector)
|
|
139
|
+
def stored_name_box(connector, points)
|
|
137
140
|
return nil unless connector.is_a?(Ea::Model::DiagramConnector) && measured_name_font?
|
|
138
141
|
return nil if connector.is_substitutable || stereotype_label(connector)
|
|
139
142
|
return nil if midpoint_content(connector).include?(": ")
|
|
140
143
|
|
|
141
144
|
box = connector.label_boxes.to_h[:lmt]
|
|
142
|
-
|
|
145
|
+
alignments = name_box_alignments(points)
|
|
146
|
+
box if default_name_box?(box, alignments: alignments)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def name_box_alignments(points)
|
|
150
|
+
horizontal_bend_seat(points) ? [0, 1] : [1]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A vertical-to-horizontal bend seats its name on the horizontal leg.
|
|
154
|
+
def horizontal_bend_seat(points)
|
|
155
|
+
return nil unless points.size == 3
|
|
156
|
+
|
|
157
|
+
source, bend, target = points
|
|
158
|
+
return nil unless source[0] == bend[0] && source[1] != bend[1] &&
|
|
159
|
+
bend[1] == target[1] && bend[0] != target[0]
|
|
160
|
+
|
|
161
|
+
[bend, target]
|
|
143
162
|
end
|
|
144
163
|
|
|
145
164
|
def measured_name_font?
|
|
146
165
|
font_family.to_s.casecmp("Cambria").zero? && font_size == 7 && font_unit == "pt"
|
|
147
166
|
end
|
|
148
167
|
|
|
149
|
-
def default_name_box?(box)
|
|
150
|
-
return false unless box && box["alignment"]
|
|
168
|
+
def default_name_box?(box, alignments: [1])
|
|
169
|
+
return false unless box && alignments.include?(box["alignment"])
|
|
151
170
|
return false unless box.values_at("ox", "oy").all?
|
|
152
171
|
return false unless [nil, 0, 1].include?(box["direction"]) && [nil, -1].include?(box["color"])
|
|
153
172
|
|
|
@@ -191,6 +210,7 @@ module Ea
|
|
|
191
210
|
y_t = canvas ? canvas.translate_y(y) : y
|
|
192
211
|
TextRenderer.new(content:, x: x_t, y: y_t,
|
|
193
212
|
family: font_family, size: font_size,
|
|
213
|
+
weight: font_weight, style: font_style,
|
|
194
214
|
size_unit: font_unit,
|
|
195
215
|
fill: "#000000").to_svg
|
|
196
216
|
end
|