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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33c72ba92cdcdc2bf8b7ab6791c9654c5a36b26535c8b3a9aa45f94c67722e80
4
- data.tar.gz: 71e4e4a5a81f44574323b00eed52dde368f635711f52a19bf8487217cab10531
3
+ metadata.gz: cf343d41c80cffbd277dd67bf172d0c947ddae898f51dacc1a2fffc3af871b0a
4
+ data.tar.gz: b554c52026c03c0a431e31140f840fedd495fae447b738c4d9fda4fae30c9eda
5
5
  SHA512:
6
- metadata.gz: cc6dd9c4e78ae8420d26ca2206915844cd8c6b42a8997c21b16574e20d8f66927b13930436b19f4766752cd409a854f3164032992b11bd950556e17433c12cab
7
- data.tar.gz: 561e6da3a121cfc641dc901a0e841a8d9e661aaf9c7832c74e6271723a1d62c4fe85fbb31c43048966783c06aebaf9cc0dd6d081d6479e69708294f6fd68963b
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: parse_waypoints(link_row.geometry),
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
@@ -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 a hash the
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 }.reject { |_, v| v.nil? || v.empty? }
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
@@ -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 <polyline> through
6
- # its waypoints. Arrowheads and per-direction routing are
7
- # future work; today we emit a straight-segment polyline
8
- # matching the source layout.
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
- attr_reader :connector
10
+ ARROW_SIZE = 8
11
11
 
12
- def initialize(connector)
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.empty?
21
+ return "" if points.size < 2
22
+
23
+ path_d = build_path_d(points)
24
+ style = StyleResolver.new(connector.style)
19
25
 
20
- <<~SVG.chomp
21
- <polyline points="#{points.join(' ')}"
22
- fill="none" stroke="#{stroke_color}"
23
- stroke-width="#{stroke_width}"/>
24
- SVG
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
- "#{wp.position.x},#{wp.position.y}"
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 stroke_color
38
- StyleResolver.new(connector.style).stroke_color
68
+ def render_target_marker(points)
69
+ filled_arrow(points.last, points[-2])
39
70
  end
40
71
 
41
- def stroke_width
42
- StyleResolver.new(connector.style).stroke_width
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("\"", "&quot;")
43
135
  end
44
136
  end
45
137
  end
@@ -4,11 +4,41 @@ require "ostruct"
4
4
 
5
5
  module Ea
6
6
  module Svg
7
- # Renders one DiagramElement as an SVG <g> containing the rect
8
- # and a name label. Class-shape rendering (with attribute /
9
- # operation compartments) is future work; for now we emit a
10
- # simple labeled box that mirrors EA's logical placement.
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
- style = StyleResolver.new(element.style)
24
- label_text = model_element_name
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="#{b.x}" y="#{b.y}" width="#{b.width}" height="#{b.height}"
29
- fill="#{style.fill_color}" stroke="#{style.stroke_color}"
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="#{b.x + (b.width / 2.0)}"
32
- y="#{b.y + (b.height / 2.0)}"
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(label_text)}</text>
82
+ font-family="sans-serif" font-size="14">#{escape(element.id)}</text>
36
83
  </g>
37
84
  SVG
38
85
  end
39
86
 
40
- private
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
- def model_element_name
43
- ref = element.model_element_ref
44
- return "(unbound)" unless ref
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
- model = model_index[ref]
47
- model&.name || "(missing #{ref})"
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)
@@ -52,10 +52,17 @@ module Ea
52
52
 
53
53
  def render_connectors
54
54
  diagram.connectors.map do |conn|
55
- ConnectorPath.new(conn).render
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ea
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
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.0
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-19 00:00:00.000000000 Z
11
+ date: 2026-07-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model