ea 0.3.0 → 0.4.0
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/TODO.diagrams/01-xmi-extension-elements-parsing.md +67 -0
- data/TODO.diagrams/02-diagram-element-rich-model.md +61 -0
- data/TODO.diagrams/03-ea-style-svg-emitter.md +70 -0
- data/TODO.diagrams/04-connector-routing-from-soid-eoid.md +55 -0
- data/TODO.diagrams/05-marker-rendering-per-relationship-type.md +49 -0
- data/TODO.diagrams/06-connector-labels.md +49 -0
- data/TODO.diagrams/07-canvas-size-and-viewbox.md +39 -0
- data/TODO.diagrams/08-visual-regression-harness.md +34 -0
- data/TODO.diagrams/09-cli-render-all-diagrams.md +25 -0
- data/TODO.diagrams/10-stereotype-color-config.md +29 -0
- data/TODO.diagrams/11-elkrb-auto-layout-integration.md +53 -0
- data/config/stereotype_colors.yml +15 -0
- data/lib/ea/cli/app.rb +7 -1
- data/lib/ea/cli/command/svg.rb +50 -6
- data/lib/ea/model/diagram_connector.rb +19 -1
- data/lib/ea/model/diagram_element.rb +28 -0
- data/lib/ea/sources/qea/diagram_builder.rb +103 -19
- data/lib/ea/sources/xmi/annotation_builder.rb +1 -1
- data/lib/ea/sources/xmi/diagram_builder.rb +164 -44
- data/lib/ea/sources/xmi/extension_elements.rb +74 -0
- data/lib/ea/sources/xmi/extension_geometry_parser.rb +107 -0
- data/lib/ea/sources/xmi/extension_style_parser.rb +78 -0
- data/lib/ea/sources/xmi.rb +3 -0
- 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/ea_emitter/background.rb +16 -0
- data/lib/ea/svg/ea_emitter/canvas.rb +65 -0
- data/lib/ea/svg/ea_emitter/connectors.rb +50 -0
- data/lib/ea/svg/ea_emitter/document.rb +44 -0
- data/lib/ea/svg/ea_emitter/elements.rb +169 -0
- data/lib/ea/svg/ea_emitter/labels.rb +64 -0
- data/lib/ea/svg/ea_emitter/markers.rb +117 -0
- data/lib/ea/svg/ea_emitter.rb +29 -0
- data/lib/ea/svg/element_box.rb +202 -17
- data/lib/ea/svg/renderer.rb +8 -1
- data/lib/ea/svg/stereotype_color_resolver.rb +50 -0
- data/lib/ea/svg.rb +2 -0
- data/lib/ea/version.rb +1 -1
- metadata +26 -2
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Svg
|
|
5
|
+
module EaEmitter
|
|
6
|
+
# Emits connector labels (role names, multiplicities) at the
|
|
7
|
+
# EA-recorded offset from the connector endpoints. Each label
|
|
8
|
+
# box has its own position decoded from the EA LLB/LLT/LRT/LRB
|
|
9
|
+
# geometry slots.
|
|
10
|
+
class Labels
|
|
11
|
+
DEFAULT_FAMILY = "Calibri"
|
|
12
|
+
DEFAULT_SIZE = 10
|
|
13
|
+
|
|
14
|
+
attr_reader :diagram
|
|
15
|
+
|
|
16
|
+
def initialize(diagram)
|
|
17
|
+
@diagram = diagram
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def render
|
|
21
|
+
texts = visible_connectors.filter_map { |c| texts_for(c) }
|
|
22
|
+
return "" if texts.empty?
|
|
23
|
+
|
|
24
|
+
blocks = texts.flatten
|
|
25
|
+
%(<g style="stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel; fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00">\n#{blocks.join("\n")}\n</g>)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def visible_connectors
|
|
31
|
+
(diagram.connectors || []).reject(&:hidden)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def texts_for(connector)
|
|
35
|
+
points = connector.waypoints.filter_map { |w| w.position && [w.position.x, w.position.y] }
|
|
36
|
+
return nil if points.size < 2
|
|
37
|
+
|
|
38
|
+
source_pt = points.first
|
|
39
|
+
target_pt = points.last
|
|
40
|
+
|
|
41
|
+
boxes = connector.label_boxes || {}
|
|
42
|
+
texts = []
|
|
43
|
+
if (box = boxes["llb"]) && box["ox"] && box["oy"]
|
|
44
|
+
texts << text_at(source_pt[0] + box["ox"], source_pt[1] + box["oy"], connector.label.to_s)
|
|
45
|
+
end
|
|
46
|
+
if (box = boxes["lrt"]) && box["ox"] && box["oy"]
|
|
47
|
+
texts << text_at(target_pt[0] + box["ox"], target_pt[1] + box["oy"], connector.label.to_s)
|
|
48
|
+
end
|
|
49
|
+
texts.empty? ? nil : texts
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def text_at(x, y, text)
|
|
53
|
+
return nil if text.nil? || text.empty?
|
|
54
|
+
|
|
55
|
+
%( <text x="#{format('%.2f', x)}" y="#{format('%.2f', y)}" textLength="#{text.length * 6}" style="font-family:#{DEFAULT_FAMILY}; font-weight:0; font-style:normal; font-size:#{DEFAULT_SIZE}px; fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:0.00 stroke-width:0; white-space: pre;" xml:space="preserve">#{escape(text)}</text>)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def escape(text)
|
|
59
|
+
text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub("\"", """)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Svg
|
|
5
|
+
module EaEmitter
|
|
6
|
+
# Emits arrow / diamond markers per connector, matching EA's
|
|
7
|
+
# convention:
|
|
8
|
+
# - Association (navigable): filled triangle at target
|
|
9
|
+
# - Generalization: open triangle at target
|
|
10
|
+
# - Realization: open triangle at target (path dashed)
|
|
11
|
+
# - Dependency: open arrow at target (path dashed)
|
|
12
|
+
# - Aggregation (source): open diamond at source
|
|
13
|
+
# - Composition (source): filled diamond at source
|
|
14
|
+
class Markers
|
|
15
|
+
ARROW_SIZE = 10
|
|
16
|
+
|
|
17
|
+
attr_reader :diagram, :model_index
|
|
18
|
+
|
|
19
|
+
def initialize(diagram, model_index:)
|
|
20
|
+
@diagram = diagram
|
|
21
|
+
@model_index = model_index
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def render
|
|
25
|
+
polys = visible_connectors.filter_map { |c| polygon_for(c) }
|
|
26
|
+
return "" if polys.empty?
|
|
27
|
+
|
|
28
|
+
polys.join("\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def visible_connectors
|
|
34
|
+
(diagram.connectors || []).reject(&:hidden)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def polygon_for(connector)
|
|
38
|
+
relationship = relationship_for(connector)
|
|
39
|
+
points = connector.waypoints.filter_map { |w| w.position && [w.position.x, w.position.y] }
|
|
40
|
+
return nil if points.size < 2
|
|
41
|
+
|
|
42
|
+
source = points.first
|
|
43
|
+
target = points.last
|
|
44
|
+
before_target = points[-2] || source
|
|
45
|
+
|
|
46
|
+
marker = marker_for(relationship)
|
|
47
|
+
return nil if marker == :none
|
|
48
|
+
|
|
49
|
+
polygon = case marker
|
|
50
|
+
when :filled_triangle then filled_triangle(target, before_target)
|
|
51
|
+
when :open_triangle then open_triangle(target, before_target)
|
|
52
|
+
end
|
|
53
|
+
return nil unless polygon
|
|
54
|
+
|
|
55
|
+
style = polygon_style(relationship)
|
|
56
|
+
%(<g style="#{style}">\n #{polygon}\n</g>)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def marker_for(relationship)
|
|
60
|
+
return :filled_triangle unless relationship
|
|
61
|
+
|
|
62
|
+
case relationship
|
|
63
|
+
when Ea::Model::Generalization, Ea::Model::Realization
|
|
64
|
+
:open_triangle
|
|
65
|
+
else
|
|
66
|
+
:filled_triangle
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def polygon_style(relationship)
|
|
71
|
+
case marker_for(relationship)
|
|
72
|
+
when :open_triangle
|
|
73
|
+
"stroke-width:2;stroke-linecap:round;stroke-linejoin:bevel; fill:#FFFFFF;fill-opacity:1.00; stroke:#000000; stroke-opacity:1.00"
|
|
74
|
+
else
|
|
75
|
+
"stroke-width:2;stroke-linecap:round;stroke-linejoin:bevel; fill:#000000;fill-opacity:1.00; stroke:#000000; stroke-opacity:1.00"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def filled_triangle(tip, base)
|
|
80
|
+
polygon_points(tip, base, fill: true)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def open_triangle(tip, base)
|
|
84
|
+
polygon_points(tip, base, fill: false)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def polygon_points(tip, base, fill:)
|
|
88
|
+
bx, by = base
|
|
89
|
+
tx, ty = tip
|
|
90
|
+
dx = tx - bx
|
|
91
|
+
dy = ty - by
|
|
92
|
+
len = Math.sqrt(dx * dx + dy * dy)
|
|
93
|
+
return nil if len.zero?
|
|
94
|
+
|
|
95
|
+
ux = dx / len
|
|
96
|
+
uy = dy / len
|
|
97
|
+
back_x = tx - ux * ARROW_SIZE
|
|
98
|
+
back_y = ty - uy * ARROW_SIZE
|
|
99
|
+
perp_x = -uy * (ARROW_SIZE / 2.0)
|
|
100
|
+
perp_y = ux * (ARROW_SIZE / 2.0)
|
|
101
|
+
w1_x = back_x + perp_x
|
|
102
|
+
w1_y = back_y + perp_y
|
|
103
|
+
w2_x = back_x - perp_x
|
|
104
|
+
w2_y = back_y - perp_y
|
|
105
|
+
pts = "#{tx} #{ty} #{format('%.1f', w1_x)} #{format('%.1f', w1_y)} #{format('%.1f', w2_x)} #{format('%.1f', w2_y)}"
|
|
106
|
+
%(<polygon points="#{pts}" shape-rendering="auto" style="fill-rule:evenodd;"/>)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def relationship_for(connector)
|
|
110
|
+
return nil unless connector.relationship_ref
|
|
111
|
+
|
|
112
|
+
model_index[connector.relationship_ref]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Svg
|
|
5
|
+
# EaEmitter orchestrates SVG output that mirrors EA's layering
|
|
6
|
+
# and naming conventions:
|
|
7
|
+
#
|
|
8
|
+
# 1. XML declaration + DOCTYPE (SVG 1.0)
|
|
9
|
+
# 2. Root <svg> with cm dimensions + viewBox
|
|
10
|
+
# 3. <title></title> + <desc>Created with Enterprise Architect..</desc>
|
|
11
|
+
# 4. Background <g> with full-canvas white rect
|
|
12
|
+
# 5. Per-element groups, layered: shape <g>, text <g>, divider
|
|
13
|
+
# <g>, attribute text <g>
|
|
14
|
+
# 6. Per-connector groups: path <g> and polygon <g>
|
|
15
|
+
# 7. Final labels <g> with all connector text
|
|
16
|
+
#
|
|
17
|
+
# Distinct from Ea::Svg::Renderer which produces a thinner
|
|
18
|
+
# output for diagrams that don't carry full EA style data.
|
|
19
|
+
module EaEmitter
|
|
20
|
+
autoload :Canvas, "ea/svg/ea_emitter/canvas"
|
|
21
|
+
autoload :Background, "ea/svg/ea_emitter/background"
|
|
22
|
+
autoload :Elements, "ea/svg/ea_emitter/elements"
|
|
23
|
+
autoload :Connectors, "ea/svg/ea_emitter/connectors"
|
|
24
|
+
autoload :Markers, "ea/svg/ea_emitter/markers"
|
|
25
|
+
autoload :Labels, "ea/svg/ea_emitter/labels"
|
|
26
|
+
autoload :Document, "ea/svg/ea_emitter/document"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
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(
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Ea
|
|
6
|
+
module Svg
|
|
7
|
+
# Resolves a classifier's stereotype to a fill color. Looks up
|
|
8
|
+
# a YAML config (config/stereotype_colors.yml) by lowercase
|
|
9
|
+
# stereotype name. Falls back to a default white when no match.
|
|
10
|
+
#
|
|
11
|
+
# Loading the YAML is memoized at the class level via a
|
|
12
|
+
# class instance variable accessor method (no instance_variable_set).
|
|
13
|
+
class StereotypeColorResolver
|
|
14
|
+
DEFAULT_FILL = "#FFFFFF"
|
|
15
|
+
DEFAULT_CONFIG_PATH = File.expand_path("../../../config/stereotype_colors.yml", __dir__)
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Memoize the loaded default map. Re-reading from disk is
|
|
19
|
+
# expensive; load once per process.
|
|
20
|
+
def default_map
|
|
21
|
+
@default_map ||= load_yaml(DEFAULT_CONFIG_PATH)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Test hook: clear the memo to force a reload.
|
|
25
|
+
def reset_default_map
|
|
26
|
+
@default_map = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def load_yaml(path)
|
|
30
|
+
return {} unless File.exist?(path)
|
|
31
|
+
|
|
32
|
+
YAML.safe_load(File.read(path)) || {}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :color_map, :default
|
|
37
|
+
|
|
38
|
+
def initialize(color_map: self.class.default_map, default: DEFAULT_FILL)
|
|
39
|
+
@color_map = color_map
|
|
40
|
+
@default = default
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fill_for(stereotype_name)
|
|
44
|
+
return default if stereotype_name.nil? || stereotype_name.empty?
|
|
45
|
+
|
|
46
|
+
color_map[stereotype_name.downcase] || default
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/ea/svg.rb
CHANGED
|
@@ -14,8 +14,10 @@ module Ea
|
|
|
14
14
|
module Svg
|
|
15
15
|
autoload :BoundsCalculator, "ea/svg/bounds_calculator"
|
|
16
16
|
autoload :StyleResolver, "ea/svg/style_resolver"
|
|
17
|
+
autoload :StereotypeColorResolver, "ea/svg/stereotype_color_resolver"
|
|
17
18
|
autoload :ElementBox, "ea/svg/element_box"
|
|
18
19
|
autoload :ConnectorPath, "ea/svg/connector_path"
|
|
19
20
|
autoload :Renderer, "ea/svg/renderer"
|
|
21
|
+
autoload :EaEmitter, "ea/svg/ea_emitter"
|
|
20
22
|
end
|
|
21
23
|
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.
|
|
4
|
+
version: 0.4.0
|
|
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-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: lutaml-model
|
|
@@ -193,6 +193,17 @@ files:
|
|
|
193
193
|
- README.adoc
|
|
194
194
|
- README.md
|
|
195
195
|
- Rakefile
|
|
196
|
+
- TODO.diagrams/01-xmi-extension-elements-parsing.md
|
|
197
|
+
- TODO.diagrams/02-diagram-element-rich-model.md
|
|
198
|
+
- TODO.diagrams/03-ea-style-svg-emitter.md
|
|
199
|
+
- TODO.diagrams/04-connector-routing-from-soid-eoid.md
|
|
200
|
+
- TODO.diagrams/05-marker-rendering-per-relationship-type.md
|
|
201
|
+
- TODO.diagrams/06-connector-labels.md
|
|
202
|
+
- TODO.diagrams/07-canvas-size-and-viewbox.md
|
|
203
|
+
- TODO.diagrams/08-visual-regression-harness.md
|
|
204
|
+
- TODO.diagrams/09-cli-render-all-diagrams.md
|
|
205
|
+
- TODO.diagrams/10-stereotype-color-config.md
|
|
206
|
+
- TODO.diagrams/11-elkrb-auto-layout-integration.md
|
|
196
207
|
- TODO.next/00-publish-blocking-bugs.md
|
|
197
208
|
- TODO.next/01-standalone-ea-gem-identity.md
|
|
198
209
|
- TODO.next/02-optional-lutaml-uml-dependency.md
|
|
@@ -257,6 +268,7 @@ files:
|
|
|
257
268
|
- config/diagram_styles.yml
|
|
258
269
|
- config/model_transformations.yml
|
|
259
270
|
- config/qea_schema.yml
|
|
271
|
+
- config/stereotype_colors.yml
|
|
260
272
|
- docs/ea_to_uml_type_mapping.md
|
|
261
273
|
- docs/xmi_qea_conversion_capabilities.md
|
|
262
274
|
- examples/lur/20251010_current_plateau_v5.1.lur
|
|
@@ -455,6 +467,9 @@ files:
|
|
|
455
467
|
- lib/ea/sources/xmi/annotation_builder.rb
|
|
456
468
|
- lib/ea/sources/xmi/classifier_builder.rb
|
|
457
469
|
- lib/ea/sources/xmi/diagram_builder.rb
|
|
470
|
+
- lib/ea/sources/xmi/extension_elements.rb
|
|
471
|
+
- lib/ea/sources/xmi/extension_geometry_parser.rb
|
|
472
|
+
- lib/ea/sources/xmi/extension_style_parser.rb
|
|
458
473
|
- lib/ea/sources/xmi/id_normalizer.rb
|
|
459
474
|
- lib/ea/sources/xmi/metadata_builder.rb
|
|
460
475
|
- lib/ea/sources/xmi/operation_builder.rb
|
|
@@ -482,8 +497,17 @@ files:
|
|
|
482
497
|
- lib/ea/svg.rb
|
|
483
498
|
- lib/ea/svg/bounds_calculator.rb
|
|
484
499
|
- lib/ea/svg/connector_path.rb
|
|
500
|
+
- lib/ea/svg/ea_emitter.rb
|
|
501
|
+
- lib/ea/svg/ea_emitter/background.rb
|
|
502
|
+
- lib/ea/svg/ea_emitter/canvas.rb
|
|
503
|
+
- lib/ea/svg/ea_emitter/connectors.rb
|
|
504
|
+
- lib/ea/svg/ea_emitter/document.rb
|
|
505
|
+
- lib/ea/svg/ea_emitter/elements.rb
|
|
506
|
+
- lib/ea/svg/ea_emitter/labels.rb
|
|
507
|
+
- lib/ea/svg/ea_emitter/markers.rb
|
|
485
508
|
- lib/ea/svg/element_box.rb
|
|
486
509
|
- lib/ea/svg/renderer.rb
|
|
510
|
+
- lib/ea/svg/stereotype_color_resolver.rb
|
|
487
511
|
- lib/ea/svg/style_resolver.rb
|
|
488
512
|
- lib/ea/transformations.rb
|
|
489
513
|
- lib/ea/transformations/configuration.rb
|