ea 0.3.0 → 0.3.1
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.rb +103 -19
- data/lib/ea/spa/configuration.rb +17 -3
- data/lib/ea/spa/projector.rb +6 -1
- data/lib/ea/svg/connector_path.rb +109 -17
- data/lib/ea/svg/element_box.rb +202 -17
- data/lib/ea/svg/renderer.rb +8 -1
- data/lib/ea/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf343d41c80cffbd277dd67bf172d0c947ddae898f51dacc1a2fffc3af871b0a
|
|
4
|
+
data.tar.gz: b554c52026c03c0a431e31140f840fedd495fae447b738c4d9fda4fae30c9eda
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bdec06da4470d865e75c52cbe8c5bec83ae4f242b223e98b436e5c244e8dcf8fd3c9a03bfe3f1ef3eedd9d2443fa52c21544755e8ff9cb3c5e698ee0a4b685f3
|
|
7
|
+
data.tar.gz: 5db5b284986bd79b3c1165f491d95cb6a5b9cd88d0bcdcab116e77abd92ebd84b267c8f3614850fa669d544085eca6eba52a6eea45c992e42439aa0b38803194
|
|
@@ -63,11 +63,113 @@ module Ea
|
|
|
63
63
|
link_row.instance_id),
|
|
64
64
|
diagram_id: IdNormalizer.from_guid(diagram_row.ea_guid),
|
|
65
65
|
relationship_ref: ref_for_connector(link_row),
|
|
66
|
-
waypoints:
|
|
66
|
+
waypoints: waypoints_for_link(link_row, diagram_row),
|
|
67
67
|
style: DiagramStyleParser.parse(link_row.style)
|
|
68
68
|
)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
# EA's t_diagramlinks.Geometry field stores connector routing
|
|
72
|
+
# in a packed format. The leading "X1,Y1,X2,Y2" pair (when
|
|
73
|
+
# present) is in a coordinate frame that does NOT match the
|
|
74
|
+
# element rect frame (elements use RectTop < RectBottom with
|
|
75
|
+
# negative y; geometry uses positive y). Rather than trust
|
|
76
|
+
# the stored absolute coords, we compute the polyline from
|
|
77
|
+
# the actual placed-element bounds + the SX/SY/EX/EY delta
|
|
78
|
+
# fields, which describe the bend routing relative to the
|
|
79
|
+
# element edges.
|
|
80
|
+
#
|
|
81
|
+
# If we can't resolve the source or target element placement
|
|
82
|
+
# for this diagram, we emit no waypoints (the connector will
|
|
83
|
+
# be invisible) — better than drawing garbage lines.
|
|
84
|
+
def waypoints_for_link(link_row, diagram_row)
|
|
85
|
+
connector = database.find_connector(link_row.connectorid)
|
|
86
|
+
return [] unless connector
|
|
87
|
+
|
|
88
|
+
source_placement = diagram_object_placement(diagram_row.diagram_id,
|
|
89
|
+
connector.start_object_id)
|
|
90
|
+
target_placement = diagram_object_placement(diagram_row.diagram_id,
|
|
91
|
+
connector.end_object_id)
|
|
92
|
+
return [] unless source_placement && target_placement
|
|
93
|
+
|
|
94
|
+
geom = parse_geometry_fields(link_row.geometry)
|
|
95
|
+
edge_out = geom[:edge] || 0
|
|
96
|
+
|
|
97
|
+
source_point = element_edge_point(source_placement, :source, edge_out)
|
|
98
|
+
target_point = element_edge_point(target_placement, :target, edge_out)
|
|
99
|
+
points = [source_point]
|
|
100
|
+
# SX/SY: delta from source point to first bend.
|
|
101
|
+
if geom[:sx] && geom[:sy] && (geom[:sx].nonzero? || geom[:sy].nonzero?)
|
|
102
|
+
points << [source_point[0] + geom[:sx], source_point[1] + geom[:sy]]
|
|
103
|
+
end
|
|
104
|
+
# EX/EY: delta from second bend to target point. Reverse
|
|
105
|
+
# to get from target back to the bend.
|
|
106
|
+
if geom[:ex] && geom[:ey] && (geom[:ex].nonzero? || geom[:ey].nonzero?)
|
|
107
|
+
points << [target_point[0] - geom[:ex], target_point[1] - geom[:ey]]
|
|
108
|
+
end
|
|
109
|
+
points << target_point
|
|
110
|
+
|
|
111
|
+
points.map do |x, y|
|
|
112
|
+
Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Parse the SX, SY, EX, EY, EDGE key=value pairs out of the
|
|
117
|
+
# geometry string. Each is `KEY=<int>;`. We deliberately do
|
|
118
|
+
# NOT extract the leading X1,Y1,X2,Y2 numbers because those
|
|
119
|
+
# are in a different coordinate frame (see note above).
|
|
120
|
+
def parse_geometry_fields(geometry)
|
|
121
|
+
return {} if geometry.nil? || geometry.empty?
|
|
122
|
+
|
|
123
|
+
s = geometry.to_s
|
|
124
|
+
{
|
|
125
|
+
sx: pick_int(s, /SX=(-?\d+)/),
|
|
126
|
+
sy: pick_int(s, /SY=(-?\d+)/),
|
|
127
|
+
ex: pick_int(s, /EX=(-?\d+)/),
|
|
128
|
+
ey: pick_int(s, /EY=(-?\d+)/),
|
|
129
|
+
edge: pick_int(s, /EDGE=(-?\d+)/)
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def pick_int(str, pattern)
|
|
134
|
+
match = str.match(pattern)
|
|
135
|
+
return nil unless match
|
|
136
|
+
|
|
137
|
+
Integer(match[1])
|
|
138
|
+
rescue ArgumentError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Find where a given EA object is placed on this diagram.
|
|
143
|
+
def diagram_object_placement(diagram_id, ea_object_id)
|
|
144
|
+
objects = database.diagram_objects_for(diagram_id) || []
|
|
145
|
+
objects.find { |o| o.ea_object_id == ea_object_id }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Compute the connection point on an element's edge for the
|
|
149
|
+
# given side. EA's `EDGE` field on t_diagramlinks tells us
|
|
150
|
+
# which edge the connector attaches to:
|
|
151
|
+
# 1 = top (source), 2 = right, 3 = bottom, 4 = left,
|
|
152
|
+
# plus 5/6/7/8 for diagonals (treated as the cardinal here).
|
|
153
|
+
# We use the center of the chosen edge as the connection point.
|
|
154
|
+
def element_edge_point(placement, end_kind, edge_code)
|
|
155
|
+
b = bounds_from_rect(placement)
|
|
156
|
+
case effective_edge(edge_code, end_kind)
|
|
157
|
+
when :top then [b.x + b.width / 2, b.y]
|
|
158
|
+
when :right then [b.x + b.width, b.y + b.height / 2]
|
|
159
|
+
when :bottom then [b.x + b.width / 2, b.y + b.height]
|
|
160
|
+
when :left then [b.x, b.y + b.height / 2]
|
|
161
|
+
else [b.x + b.width / 2, b.y + b.height / 2]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def effective_edge(edge_code, end_kind)
|
|
166
|
+
mapping = {
|
|
167
|
+
1 => :top, 2 => :right, 3 => :bottom, 4 => :left,
|
|
168
|
+
5 => :top, 6 => :right, 7 => :bottom, 8 => :left
|
|
169
|
+
}
|
|
170
|
+
mapping[edge_code.to_i] || :center
|
|
171
|
+
end
|
|
172
|
+
|
|
71
173
|
def package_id_for(diagram_row)
|
|
72
174
|
pkg = database.find_package(diagram_row.package_id)
|
|
73
175
|
return nil unless pkg
|
|
@@ -106,24 +208,6 @@ module Ea
|
|
|
106
208
|
|
|
107
209
|
IdNormalizer.from_guid(conn.ea_guid)
|
|
108
210
|
end
|
|
109
|
-
|
|
110
|
-
# EA stores connector geometry as a packed string of x,y
|
|
111
|
-
# pairs separated by semicolons and pipes. Conservative
|
|
112
|
-
# parse: extract digit pairs, take first/last as endpoints.
|
|
113
|
-
def parse_waypoints(geometry)
|
|
114
|
-
return [] if geometry.nil? || geometry.empty?
|
|
115
|
-
|
|
116
|
-
numbers = geometry.to_s.scan(/-?\d+/).map(&:to_i)
|
|
117
|
-
return [] if numbers.size < 4
|
|
118
|
-
|
|
119
|
-
numbers.each_slice(2).filter_map do |xy|
|
|
120
|
-
next nil if xy.size < 2
|
|
121
|
-
|
|
122
|
-
Ea::Model::Waypoint.new(
|
|
123
|
-
position: Ea::Model::Point.new(x: xy[0], y: xy[1])
|
|
124
|
-
)
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
211
|
end
|
|
128
212
|
end
|
|
129
213
|
end
|
data/lib/ea/spa/configuration.rb
CHANGED
|
@@ -51,6 +51,17 @@ module Ea
|
|
|
51
51
|
@data["appearance"] || {}
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Diagram rendering control. Default: true (preserve historical
|
|
55
|
+
# behavior). Set `diagrams.enabled: false` in the YAML to skip
|
|
56
|
+
# diagram shards entirely — useful when the renderer output is
|
|
57
|
+
# not yet production-quality and you'd rather hide diagrams
|
|
58
|
+
# than ship broken visuals.
|
|
59
|
+
def render_diagrams?
|
|
60
|
+
diagrams_cfg = @data["diagrams"].is_a?(Hash) ? @data["diagrams"] : {}
|
|
61
|
+
enabled = diagrams_cfg.key?("enabled") ? diagrams_cfg["enabled"] : true
|
|
62
|
+
enabled != false
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
# Apply overrides to a model Metadata instance, returning a new
|
|
55
66
|
# one. The original is left untouched.
|
|
56
67
|
def apply_to_metadata(model_metadata)
|
|
@@ -61,10 +72,13 @@ module Ea
|
|
|
61
72
|
hash_to_metadata(merged)
|
|
62
73
|
end
|
|
63
74
|
|
|
64
|
-
# Surface the relevant config (ui, appearance) into
|
|
65
|
-
# SPA skeleton embeds in its metadata payload
|
|
75
|
+
# Surface the relevant config (ui, appearance, diagrams) into
|
|
76
|
+
# a hash the SPA skeleton embeds in its metadata payload so
|
|
77
|
+
# the frontend can hide its diagram UI when disabled.
|
|
66
78
|
def view_extras
|
|
67
|
-
{ "ui" => ui, "appearance" => appearance }
|
|
79
|
+
extras = { "ui" => ui, "appearance" => appearance }
|
|
80
|
+
extras["diagrams"] = { "enabled" => render_diagrams? }
|
|
81
|
+
extras.reject { |_, v| v.nil? || v.empty? }
|
|
68
82
|
end
|
|
69
83
|
|
|
70
84
|
private
|
data/lib/ea/spa/projector.rb
CHANGED
|
@@ -43,12 +43,17 @@ module Ea
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# Enumerate every (id, kind, shard) triple the SPA can address.
|
|
46
|
+
# Diagrams are skipped when the configuration disables them.
|
|
46
47
|
def each_shard
|
|
47
48
|
return enum_for(:each_shard) unless block_given?
|
|
48
49
|
|
|
49
50
|
document.classifiers.each { |c| yield shard_for(c) }
|
|
50
51
|
document.packages.each { |p| yield shard_for(p) }
|
|
51
|
-
document.diagrams.each { |d| yield shard_for(d) }
|
|
52
|
+
document.diagrams.each { |d| yield shard_for(d) } if render_diagrams?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def render_diagrams?
|
|
56
|
+
configuration ? configuration.render_diagrams? : true
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
private
|
|
@@ -2,26 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
module Ea
|
|
4
4
|
module Svg
|
|
5
|
-
# Renders one DiagramConnector as an SVG <
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
# Renders one DiagramConnector as an SVG <path> through its
|
|
6
|
+
# waypoints, with an arrowhead at the target end. Matches EA's
|
|
7
|
+
# convention: filled triangle for navigable associations, filled
|
|
8
|
+
# diamond for aggregations, open diamond for shared aggregations.
|
|
9
9
|
class ConnectorPath
|
|
10
|
-
|
|
10
|
+
ARROW_SIZE = 8
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
attr_reader :connector, :relationship
|
|
13
|
+
|
|
14
|
+
def initialize(connector, relationship: nil)
|
|
13
15
|
@connector = connector
|
|
16
|
+
@relationship = relationship
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def render
|
|
17
20
|
points = waypoints
|
|
18
|
-
return "" if points.
|
|
21
|
+
return "" if points.size < 2
|
|
22
|
+
|
|
23
|
+
path_d = build_path_d(points)
|
|
24
|
+
style = StyleResolver.new(connector.style)
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
parts = []
|
|
27
|
+
parts << %(<g class="connector" data-connector-id="#{escape(connector.id)}">)
|
|
28
|
+
parts << %( <path d="#{path_d}" stroke="#{style.stroke_color}" stroke-width="#{style.stroke_width}" fill="none"/>)
|
|
29
|
+
parts << render_source_marker(points) if source_marker?
|
|
30
|
+
parts << render_target_marker(points)
|
|
31
|
+
parts << %(</g>)
|
|
32
|
+
parts.join("\n ")
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
private
|
|
@@ -30,16 +38,100 @@ module Ea
|
|
|
30
38
|
connector.waypoints.filter_map do |wp|
|
|
31
39
|
next unless wp.position
|
|
32
40
|
|
|
33
|
-
|
|
41
|
+
[wp.position.x, wp.position.y]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_path_d(points)
|
|
46
|
+
moves = points.each_with_index.map do |p, idx|
|
|
47
|
+
prefix = idx.zero? ? "M" : "L"
|
|
48
|
+
"#{prefix} #{p[0]} #{p[1]}"
|
|
49
|
+
end
|
|
50
|
+
moves.join(" ")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def source_marker?
|
|
54
|
+
return false unless relationship
|
|
55
|
+
|
|
56
|
+
relationship.is_a?(Ea::Model::Association) &&
|
|
57
|
+
relationship.source_aggregation != "none"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def render_source_marker(points)
|
|
61
|
+
case relationship.source_aggregation
|
|
62
|
+
when "composite" then filled_diamond(points.first, points[1])
|
|
63
|
+
when "shared" then open_diamond(points.first, points[1])
|
|
64
|
+
else ""
|
|
34
65
|
end
|
|
35
66
|
end
|
|
36
67
|
|
|
37
|
-
def
|
|
38
|
-
|
|
68
|
+
def render_target_marker(points)
|
|
69
|
+
filled_arrow(points.last, points[-2])
|
|
39
70
|
end
|
|
40
71
|
|
|
41
|
-
def
|
|
42
|
-
|
|
72
|
+
def filled_arrow(tip, base)
|
|
73
|
+
return "" unless tip && base
|
|
74
|
+
|
|
75
|
+
# Build a triangle pointing from base toward tip
|
|
76
|
+
bx, by = base
|
|
77
|
+
tx, ty = tip
|
|
78
|
+
# Perpendicular vector for the wings
|
|
79
|
+
dx = tx - bx
|
|
80
|
+
dy = ty - by
|
|
81
|
+
len = Math.sqrt(dx * dx + dy * dy)
|
|
82
|
+
return "" if len.zero?
|
|
83
|
+
|
|
84
|
+
ux = dx / len
|
|
85
|
+
uy = dy / len
|
|
86
|
+
# Wing points are ARROW_SIZE back from tip, perpendicular
|
|
87
|
+
back_x = tx - ux * ARROW_SIZE
|
|
88
|
+
back_y = ty - uy * ARROW_SIZE
|
|
89
|
+
perp_x = -uy * (ARROW_SIZE / 2.0)
|
|
90
|
+
perp_y = ux * (ARROW_SIZE / 2.0)
|
|
91
|
+
w1_x = back_x + perp_x
|
|
92
|
+
w1_y = back_y + perp_y
|
|
93
|
+
w2_x = back_x - perp_x
|
|
94
|
+
w2_y = back_y - perp_y
|
|
95
|
+
points_str = "#{tx} #{ty} #{w1_x.round(1)} #{w1_y.round(1)} #{w2_x.round(1)} #{w2_y.round(1)}"
|
|
96
|
+
%( <polygon points="#{points_str}" fill="black" stroke="black" stroke-width="1"/>)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def filled_diamond(tip, base)
|
|
100
|
+
diamond_polygon(tip, base, fill: "black")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def open_diamond(tip, base)
|
|
104
|
+
diamond_polygon(tip, base, fill: "white")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def diamond_polygon(tip, base, fill:)
|
|
108
|
+
bx, by = base
|
|
109
|
+
tx, ty = tip
|
|
110
|
+
dx = tx - bx
|
|
111
|
+
dy = ty - by
|
|
112
|
+
len = Math.sqrt(dx * dx + dy * dy)
|
|
113
|
+
return "" if len.zero?
|
|
114
|
+
|
|
115
|
+
ux = dx / len
|
|
116
|
+
uy = dy / len
|
|
117
|
+
back_x = tx - ux * ARROW_SIZE
|
|
118
|
+
back_y = ty - uy * ARROW_SIZE
|
|
119
|
+
perp_x = -uy * (ARROW_SIZE / 2.0)
|
|
120
|
+
perp_y = ux * (ARROW_SIZE / 2.0)
|
|
121
|
+
far_x = tx - ux * (2 * ARROW_SIZE)
|
|
122
|
+
far_y = ty - uy * (2 * ARROW_SIZE)
|
|
123
|
+
w1_x = back_x + perp_x
|
|
124
|
+
w1_y = back_y + perp_y
|
|
125
|
+
w2_x = back_x - perp_x
|
|
126
|
+
w2_y = back_y - perp_y
|
|
127
|
+
points_str = "#{far_x.round(1)} #{far_y.round(1)} #{w1_x.round(1)} #{w1_y.round(1)} #{tx} #{ty} #{w2_x.round(1)} #{w2_y.round(1)}"
|
|
128
|
+
%( <polygon points="#{points_str}" fill="#{fill}" stroke="black" stroke-width="1"/>)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def escape(text)
|
|
132
|
+
return "" if text.nil?
|
|
133
|
+
|
|
134
|
+
text.to_s.gsub("\"", """)
|
|
43
135
|
end
|
|
44
136
|
end
|
|
45
137
|
end
|
data/lib/ea/svg/element_box.rb
CHANGED
|
@@ -4,11 +4,41 @@ require "ostruct"
|
|
|
4
4
|
|
|
5
5
|
module Ea
|
|
6
6
|
module Svg
|
|
7
|
-
# Renders one DiagramElement as
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# Renders one DiagramElement as a 3-compartment UML class box,
|
|
8
|
+
# matching EA's drawing convention:
|
|
9
|
+
#
|
|
10
|
+
# ┌─────────────────────────┐
|
|
11
|
+
# │ «Stereotype» │ ← header (stereotype + name)
|
|
12
|
+
# │ qualified::Name │
|
|
13
|
+
# ├─────────────────────────┤
|
|
14
|
+
# │ + attr1: Type [0..1] │ ← attributes
|
|
15
|
+
# │ + attr2: Type [0..*] │
|
|
16
|
+
# ├─────────────────────────┤
|
|
17
|
+
# │ + op1(arg: T): T │ ← operations
|
|
18
|
+
# └─────────────────────────┘
|
|
19
|
+
#
|
|
20
|
+
# The stored element bounds are honored as the outer rectangle;
|
|
21
|
+
# internal layout (header / attrs / ops heights) is computed
|
|
22
|
+
# proportionally based on line count.
|
|
11
23
|
class ElementBox
|
|
24
|
+
HEADER_LINE_HEIGHT = 17
|
|
25
|
+
ATTR_LINE_HEIGHT = 17
|
|
26
|
+
OP_LINE_HEIGHT = 17
|
|
27
|
+
TOP_PADDING = 17
|
|
28
|
+
|
|
29
|
+
STEREOTYPE_COLORS = {
|
|
30
|
+
"featuretype" => "#FFFFCC", # yellow
|
|
31
|
+
"feature" => "#FFFFCC",
|
|
32
|
+
"type" => "#CCFFCC", # green
|
|
33
|
+
"datatype" => "#FFCCFF", # pink
|
|
34
|
+
"basictype" => "#FFCCFF",
|
|
35
|
+
"codelist" => "#FFCCFF",
|
|
36
|
+
"enumeration" => "#FFCCFF",
|
|
37
|
+
"union" => "#F0E68C", # khaki
|
|
38
|
+
"adeelement" => "#F5F5DC" # beige
|
|
39
|
+
}.freeze
|
|
40
|
+
DEFAULT_FILL = "#FFFFFF"
|
|
41
|
+
|
|
12
42
|
attr_reader :element, :model_index
|
|
13
43
|
|
|
14
44
|
def initialize(element, model_index:)
|
|
@@ -20,31 +50,186 @@ module Ea
|
|
|
20
50
|
return "" unless element.bounds
|
|
21
51
|
|
|
22
52
|
b = normalize_bounds(element.bounds)
|
|
23
|
-
|
|
24
|
-
|
|
53
|
+
classifier = bound_classifier
|
|
54
|
+
return render_simple_box(b) unless classifier
|
|
55
|
+
|
|
56
|
+
render_class_box(b, classifier)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
25
60
|
|
|
61
|
+
def bound_classifier
|
|
62
|
+
ref = element.model_element_ref
|
|
63
|
+
return nil unless ref
|
|
64
|
+
|
|
65
|
+
candidate = model_index[ref]
|
|
66
|
+
return nil unless candidate.is_a?(Ea::Model::Classifier)
|
|
67
|
+
|
|
68
|
+
candidate
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def render_simple_box(bounds)
|
|
72
|
+
style = StyleResolver.new(element.style)
|
|
26
73
|
<<~SVG.chomp
|
|
27
74
|
<g class="element" data-element-id="#{escape(element.id)}">
|
|
28
|
-
<rect x="#{
|
|
29
|
-
fill="#{style
|
|
75
|
+
<rect x="#{bounds.x}" y="#{bounds.y}" width="#{bounds.width}" height="#{bounds.height}"
|
|
76
|
+
fill="#{fill_color(style)}" stroke="#{style.stroke_color}"
|
|
30
77
|
stroke-width="#{style.stroke_width}"/>
|
|
31
|
-
<text x="#{
|
|
32
|
-
y="#{
|
|
78
|
+
<text x="#{bounds.x + (bounds.width / 2.0)}"
|
|
79
|
+
y="#{bounds.y + (bounds.height / 2.0)}"
|
|
33
80
|
text-anchor="middle" dominant-baseline="middle"
|
|
34
81
|
fill="#{style.font_color}"
|
|
35
|
-
font-family="sans-serif" font-size="14">#{escape(
|
|
82
|
+
font-family="sans-serif" font-size="14">#{escape(element.id)}</text>
|
|
36
83
|
</g>
|
|
37
84
|
SVG
|
|
38
85
|
end
|
|
39
86
|
|
|
40
|
-
|
|
87
|
+
def render_class_box(bounds, classifier)
|
|
88
|
+
stereotype = primary_stereotype(classifier)
|
|
89
|
+
fill = STEREOTYPE_COLORS[stereotype&.downcase] || DEFAULT_FILL
|
|
90
|
+
style = StyleResolver.new(element.style)
|
|
91
|
+
stroke = style.stroke_color
|
|
92
|
+
stroke_width = style.stroke_width
|
|
93
|
+
font_color = style.font_color
|
|
41
94
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
95
|
+
header_lines = build_header_lines(classifier, stereotype)
|
|
96
|
+
attr_lines = build_attribute_lines(classifier)
|
|
97
|
+
op_lines = build_operation_lines(classifier)
|
|
98
|
+
|
|
99
|
+
header_bottom = bounds.y + header_height(header_lines.size)
|
|
100
|
+
attrs_bottom = header_bottom + compartment_height(attr_lines.size)
|
|
101
|
+
ops_bottom = attrs_bottom + compartment_height(op_lines.size)
|
|
102
|
+
|
|
103
|
+
parts = []
|
|
104
|
+
parts << %(<g class="element" data-element-id="#{escape(element.id)}">)
|
|
105
|
+
parts << render_outer_rect(bounds, fill, stroke, stroke_width)
|
|
106
|
+
parts << render_header_divider(bounds, header_bottom, stroke, stroke_width)
|
|
107
|
+
parts << render_attrs_divider(bounds, attrs_bottom, stroke, stroke_width) if op_lines.any?
|
|
108
|
+
parts << render_header_text(bounds, header_lines, font_color)
|
|
109
|
+
parts << render_attribute_text(bounds, header_bottom, attr_lines, font_color)
|
|
110
|
+
parts << render_operation_text(bounds, attrs_bottom, op_lines, font_color)
|
|
111
|
+
parts << %(</g>)
|
|
112
|
+
parts.join("\n ")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_header_lines(classifier, stereotype)
|
|
116
|
+
lines = []
|
|
117
|
+
lines << "«#{stereotype}»" if stereotype && !stereotype.empty?
|
|
118
|
+
lines << (classifier.qualified_name&.split("::")&.last || classifier.name || "(unnamed)")
|
|
119
|
+
lines
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_attribute_lines(classifier)
|
|
123
|
+
return [] unless classifier.properties
|
|
124
|
+
|
|
125
|
+
classifier.properties.map do |prop|
|
|
126
|
+
vis = visibility_marker(prop.visibility)
|
|
127
|
+
mult = multiplicity_text(prop)
|
|
128
|
+
type = prop.type_name ? ": #{prop.type_name}" : ""
|
|
129
|
+
"#{vis} #{prop.name}#{type}#{mult}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_operation_lines(classifier)
|
|
134
|
+
return [] unless classifier.operations
|
|
135
|
+
|
|
136
|
+
classifier.operations.map do |op|
|
|
137
|
+
vis = visibility_marker(op.visibility)
|
|
138
|
+
ret = op.return_type_name ? ": #{op.return_type_name}" : ""
|
|
139
|
+
params = (op.parameters || []).map { |p| "#{p.name}: #{p.type_name || 'T'}" }.join(", ")
|
|
140
|
+
"#{vis} #{op.name}(#{params})#{ret}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def visibility_marker(visibility)
|
|
145
|
+
case visibility&.downcase
|
|
146
|
+
when "public" then "+"
|
|
147
|
+
when "private" then "-"
|
|
148
|
+
when "protected" then "#"
|
|
149
|
+
when "package" then "~"
|
|
150
|
+
else "+"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def multiplicity_text(prop)
|
|
155
|
+
lower = prop.multiplicity_lower
|
|
156
|
+
upper = prop.multiplicity_upper
|
|
157
|
+
return "" if lower.nil? && upper.nil?
|
|
158
|
+
return "" if lower == 1 && upper == 1
|
|
159
|
+
|
|
160
|
+
upper_text = upper == -1 ? "*" : upper
|
|
161
|
+
lower_text = lower || 0
|
|
162
|
+
return "[0..*]" if lower_text == 0 && upper == -1
|
|
163
|
+
return "[#{lower_text}]" if lower_text == upper
|
|
164
|
+
|
|
165
|
+
"[#{lower_text}..#{upper_text}]"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def primary_stereotype(classifier)
|
|
169
|
+
refs = classifier.stereotype_refs
|
|
170
|
+
return nil if refs.nil? || refs.empty?
|
|
171
|
+
|
|
172
|
+
refs.first.to_s.split("::").last
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def fill_color(style)
|
|
176
|
+
stereotype = primary_stereotype_from_style
|
|
177
|
+
STEREOTYPE_COLORS[stereotype&.downcase] || style.fill_color
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def primary_stereotype_from_style
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def header_height(line_count)
|
|
185
|
+
TOP_PADDING + (line_count * HEADER_LINE_HEIGHT)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def compartment_height(line_count)
|
|
189
|
+
return 0 if line_count.zero?
|
|
190
|
+
|
|
191
|
+
10 + (line_count * (line_count == 0 ? 0 : (line_count < 3 ? 17 : 17)))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def render_outer_rect(bounds, fill, stroke, stroke_width)
|
|
195
|
+
%( <rect x="#{bounds.x}" y="#{bounds.y}" width="#{bounds.width}" height="#{bounds.height}" fill="#{fill}" stroke="#{stroke}" stroke-width="#{stroke_width}"/>)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def render_header_divider(bounds, y, stroke, stroke_width)
|
|
199
|
+
%( <path d="M #{bounds.x} #{y} L #{bounds.x + bounds.width} #{y}" stroke="#{stroke}" stroke-width="#{stroke_width}" fill="none"/>)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def render_attrs_divider(bounds, y, stroke, stroke_width)
|
|
203
|
+
%( <path d="M #{bounds.x} #{y} L #{bounds.x + bounds.width} #{y}" stroke="#{stroke}" stroke-width="#{stroke_width}" fill="none"/>)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def render_header_text(bounds, lines, font_color)
|
|
207
|
+
center_x = bounds.x + (bounds.width / 2.0)
|
|
208
|
+
lines.each_with_index.map do |line, idx|
|
|
209
|
+
y = bounds.y + TOP_PADDING + (idx * HEADER_LINE_HEIGHT) - 4
|
|
210
|
+
weight = (idx == lines.size - 1) ? "bold" : "normal"
|
|
211
|
+
%( <text x="#{center_x}" y="#{y}" text-anchor="middle" fill="#{font_color}" font-family="sans-serif" font-size="13" font-weight="#{weight}">#{escape(line)}</text>)
|
|
212
|
+
end.join("\n ")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def render_attribute_text(bounds, header_bottom, lines, font_color)
|
|
216
|
+
return "" if lines.empty?
|
|
217
|
+
|
|
218
|
+
left = bounds.x + 5
|
|
219
|
+
lines.each_with_index.map do |line, idx|
|
|
220
|
+
y = header_bottom + 14 + (idx * ATTR_LINE_HEIGHT)
|
|
221
|
+
%( <text x="#{left}" y="#{y}" text-anchor="start" fill="#{font_color}" font-family="sans-serif" font-size="12">#{escape(line)}</text>)
|
|
222
|
+
end.join("\n ")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def render_operation_text(bounds, attrs_bottom, lines, font_color)
|
|
226
|
+
return "" if lines.empty?
|
|
45
227
|
|
|
46
|
-
|
|
47
|
-
|
|
228
|
+
left = bounds.x + 5
|
|
229
|
+
lines.each_with_index.map do |line, idx|
|
|
230
|
+
y = attrs_bottom + 14 + (idx * OP_LINE_HEIGHT)
|
|
231
|
+
%( <text x="#{left}" y="#{y}" text-anchor="start" fill="#{font_color}" font-family="sans-serif" font-size="12">#{escape(line)}</text>)
|
|
232
|
+
end.join("\n ")
|
|
48
233
|
end
|
|
49
234
|
|
|
50
235
|
def normalize_bounds(bounds)
|
data/lib/ea/svg/renderer.rb
CHANGED
|
@@ -52,10 +52,17 @@ module Ea
|
|
|
52
52
|
|
|
53
53
|
def render_connectors
|
|
54
54
|
diagram.connectors.map do |conn|
|
|
55
|
-
ConnectorPath.new(conn
|
|
55
|
+
ConnectorPath.new(conn,
|
|
56
|
+
relationship: relationship_for(conn)).render
|
|
56
57
|
end.join("\n ")
|
|
57
58
|
end
|
|
58
59
|
|
|
60
|
+
def relationship_for(connector)
|
|
61
|
+
return nil unless connector.relationship_ref
|
|
62
|
+
|
|
63
|
+
model_index[connector.relationship_ref]
|
|
64
|
+
end
|
|
65
|
+
|
|
59
66
|
def pad(bounds)
|
|
60
67
|
p = options[:padding]
|
|
61
68
|
OpenStruct.new(
|
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.3.
|
|
4
|
+
version: 0.3.1
|
|
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-07-
|
|
11
|
+
date: 2026-07-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: lutaml-model
|