ea 0.6.3 → 0.6.5

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/config/themes/119.yml +4 -4
  4. data/frontend/dist/app.iife.js +8 -8
  5. data/frontend/src/App.vue +6 -2
  6. data/frontend/src/components/AppHeader.vue +35 -8
  7. data/frontend/src/components/AppSidebar.vue +2 -2
  8. data/frontend/src/components/ClassDetails.vue +103 -120
  9. data/frontend/src/components/DiagramView.vue +19 -6
  10. data/frontend/src/components/PackageDetails.vue +67 -26
  11. data/frontend/src/components/PackageTreeNode.vue +25 -20
  12. data/frontend/src/components/SearchResults.vue +21 -8
  13. data/frontend/src/components/WelcomeView.vue +2 -2
  14. data/frontend/src/stores/dataStore.ts +142 -48
  15. data/frontend/src/stores/uiStore.ts +3 -4
  16. data/frontend/src/types.ts +160 -163
  17. data/lib/ea/model/diagram_connector.rb +9 -0
  18. data/lib/ea/model/property.rb +6 -1
  19. data/lib/ea/sources/qea/classifier_builder.rb +3 -1
  20. data/lib/ea/sources/qea/diagram_builder.rb +91 -9
  21. data/lib/ea/sources/qea/property_builder.rb +12 -2
  22. data/lib/ea/spa/output/single_file_strategy.rb +1 -1
  23. data/lib/ea/spa/projector.rb +108 -6
  24. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +1 -0
  25. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +1 -0
  26. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +9 -25
  27. data/lib/ea/svg/ea_emitter/compartment/header.rb +1 -0
  28. data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +51 -11
  30. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +12 -10
  31. data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +6 -15
  32. data/lib/ea/svg/ea_emitter/element/header_line_provider/stereotype_label.rb +25 -8
  33. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +16 -4
  34. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +29 -4
  35. data/lib/ea/svg/ea_emitter/elements.rb +70 -20
  36. data/lib/ea/svg/ea_emitter/font_resolver.rb +5 -6
  37. data/lib/ea/svg/ea_emitter/label/end_label.rb +3 -1
  38. data/lib/ea/svg/ea_emitter/labels.rb +38 -2
  39. data/lib/ea/svg/ea_emitter/text_renderer.rb +97 -8
  40. data/lib/ea/svg/parity/checker.rb +19 -9
  41. data/lib/ea/svg/parity/corpus_report.rb +83 -0
  42. data/lib/ea/svg/parity/differ.rb +231 -0
  43. data/lib/ea/svg/parity.rb +2 -0
  44. data/lib/ea/theme/definition.rb +3 -3
  45. data/lib/ea/theme/loader.rb +2 -2
  46. data/lib/ea/version.rb +1 -1
  47. metadata +4 -2
@@ -71,18 +71,29 @@ module Ea
71
71
  font_size: size,
72
72
  off_canvas_parent_name: parent_name) : []
73
73
  is_classifier = classifier.is_a?(Ea::Model::Classifier)
74
- attr_lines = (is_classifier && show_attributes?) ? Element::AttributeRenderer.lines_for(classifier, lookup: attribute_lookup) : []
74
+ attr_lines = if is_classifier && show_attributes?
75
+ Element::AttributeRenderer.lines_for(
76
+ classifier, lookup: attribute_lookup,
77
+ exclude_association_ids: drawn_association_ids
78
+ )
79
+ else
80
+ []
81
+ end
75
82
  op_lines = (is_classifier && show_operations?) ? Element::OperationRenderer.lines_for(classifier) : []
76
- geometry = CompartmentGeometry.new(bounds: bounds, size: size,
77
- header_lines_count: header_lines.size,
78
- attr_lines_count: attr_lines.size,
79
- op_lines_count: op_lines.size,
80
- tagged_values_count: tagged_values_for(classifier).size,
81
- header_top_padding: theme.compartments.header_top_padding,
82
- header_line_offset: theme.compartments.header_line_offset,
83
- divider_offset: theme.compartments.divider_offset,
84
- attr_line_offset: theme.compartments.attr_line_offset,
85
- attr_first_offset: theme.compartments.attr_first_offset)
83
+ geometry = compartment_geometry(bounds, size, attr_lines, op_lines,
84
+ tagged_values_for(classifier).size,
85
+ header_lines)
86
+ # EA suppresses the whole attribute compartment when it does
87
+ # not fit the stored box height (never truncates rows) - the
88
+ # AOS box on CityFurniture hides its association property
89
+ # this way. Verified against EA-published reference SVGs.
90
+ if attr_lines.any? &&
91
+ geometry.attr_bottom_y.to_i + size > bounds.y + bounds.height
92
+ attr_lines = []
93
+ geometry = compartment_geometry(bounds, size, attr_lines, op_lines,
94
+ tagged_values_for(classifier).size,
95
+ header_lines)
96
+ end
86
97
  RenderContext.new(
87
98
  element: element,
88
99
  bounds: bounds,
@@ -109,6 +120,47 @@ module Ea
109
120
  )
110
121
  end
111
122
 
123
+ def compartment_geometry(bounds, size, attr_lines, op_lines,
124
+ tagged_count, header_lines)
125
+ CompartmentGeometry.new(bounds: bounds, size: size,
126
+ header_lines_count: header_lines.size,
127
+ attr_lines_count: attr_lines.size,
128
+ op_lines_count: op_lines.size,
129
+ tagged_values_count: tagged_count,
130
+ header_top_padding: header_padding_for(header_lines),
131
+ header_line_offset: theme.compartments.header_line_offset,
132
+ divider_offset: theme.compartments.divider_offset,
133
+ attr_line_offset: theme.compartments.attr_line_offset,
134
+ attr_first_offset: attr_first_offset_for(attr_lines))
135
+ end
136
+
137
+ # EA seats a stereotype line 3px higher than a bare class name
138
+ # (first baseline +16 vs +19 at 7pt) - verified against the
139
+ # EA-published reference SVGs.
140
+ def header_padding_for(header_lines)
141
+ first = header_lines.first
142
+ return theme.compartments.header_top_padding unless first
143
+
144
+ if first.last == :italic
145
+ 6 # ghost-led: EA seats the right-aligned ghost at +13
146
+ elsif first.first.to_s.start_with?("«")
147
+ 9
148
+ else
149
+ theme.compartments.header_top_padding
150
+ end
151
+ end
152
+
153
+ # A stereotype group header («Property») sits 4px below where
154
+ # plain rows start (divider+18 vs divider+14 at 7pt).
155
+ def attr_first_offset_for(attr_lines)
156
+ attr_lines.first.to_s.start_with?("«") ? 11 : 7
157
+ end
158
+
159
+ def drawn_association_ids
160
+ (diagram.connectors || []).select(&:renderable?)
161
+ .map(&:relationship_ref).compact.to_set
162
+ end
163
+
112
164
  def enum_literals_for(classifier)
113
165
  return [] unless classifier.is_a?(Ea::Model::Enumeration)
114
166
 
@@ -166,19 +218,17 @@ module Ea
166
218
  (diagram.elements || []).any? { |e| e.model_element_ref == model_element_ref }
167
219
  end
168
220
 
169
- # Returns true when any element placed on the current diagram
170
- # lives in the same package as `parent`. EA suppresses the
171
- # parent-class ghost line in that case — the parent's package
172
- # is already "represented" on the diagram, so the off-canvas
173
- # parent is implicitly visible to the reader.
221
+ # EA suppresses the parent-class ghost line only when the
222
+ # parent's package is itself placed on the diagram as a
223
+ # package element. A mere same-package classifier does NOT
224
+ # suppress it — verified against EA-published reference SVGs
225
+ # (CityFurniture renders the ghost for AbstractOccupiedSpace
226
+ # although the class shares its parent's package).
174
227
  def parent_package_represented_on_diagram?(parent)
175
228
  parent_pkg = parent.is_a?(Ea::Model::Classifier) ? parent.package_id : nil
176
229
  return false unless parent_pkg
177
230
 
178
- (diagram.elements || []).any? do |e|
179
- sibling = model_index[e.model_element_ref]
180
- sibling.is_a?(Ea::Model::Classifier) && sibling.package_id == parent_pkg
181
- end
231
+ (diagram.elements || []).any? { |e| e.model_element_ref == parent_pkg }
182
232
  end
183
233
 
184
234
  # EA renders a package's child classifiers and sub-packages as
@@ -17,12 +17,11 @@ module Ea
17
17
  # text-emitting layer.
18
18
  class FontResolver
19
19
  DEFAULT_FONT_FAMILY = "Yu Gothic UI"
20
- # EA's default element compartment font size (matches
21
- # reference SVGs which use 9pt for classifier header,
22
- # attributes, operations, etc. — distinct from theme's
23
- # diagram-level font size which applies only to frame
24
- # label and swimlanes).
25
- DEFAULT_ELEMENT_FONT_SIZE = 9
20
+ # EA's default element compartment font size. EA-published
21
+ # reference SVGs render body text at 7pt (the current EA
22
+ # theme size) for every element whose objectstyle fontsz is
23
+ # a use-default marker (see QEA DiagramBuilder.font_size_from).
24
+ DEFAULT_ELEMENT_FONT_SIZE = 7
26
25
 
27
26
  attr_reader :diagram, :theme
28
27
 
@@ -202,7 +202,9 @@ module Ea
202
202
  offset_y = box["oy"] || box[:oy]
203
203
  return nil if offset_x.nil? && offset_y.nil?
204
204
 
205
- [anchor[0] + offset_x.to_i, anchor[1] + offset_y.to_i]
205
+ # Waypoint anchors are mirrored to screen-down space; the
206
+ # stored OY offset counts the other way, so negate it.
207
+ [anchor[0] + offset_x.to_i, anchor[1] - offset_y.to_i]
206
208
  end
207
209
 
208
210
  def property_y_offset
@@ -73,16 +73,52 @@ module Ea
73
73
  target_pt = points.last
74
74
  boxes = connector.label_boxes || {}
75
75
 
76
+ src_tb, src_mb = end_boxes(boxes, :llt, :llb) ||
77
+ default_label_boxes(points, :source)
78
+ tgt_tb, tgt_mb = end_boxes(boxes, :lrt, :lrb) ||
79
+ default_label_boxes(points, :target)
80
+
76
81
  texts = []
77
- end_renderer.texts(text_box: boxes[:llt], mult_box: boxes[:llb],
82
+ end_renderer.texts(text_box: src_tb, mult_box: src_mb,
78
83
  anchor: source_pt, connector: connector,
79
84
  end_kind: :source).each { |t| texts << t }
80
- end_renderer.texts(text_box: boxes[:lrt], mult_box: boxes[:lrb],
85
+ end_renderer.texts(text_box: tgt_tb, mult_box: tgt_mb,
81
86
  anchor: target_pt, connector: connector,
82
87
  end_kind: :target).each { |t| texts << t }
83
88
  texts
84
89
  end
85
90
 
91
+ def end_boxes(boxes, text_key, mult_key)
92
+ return nil if boxes[text_key].nil? && boxes[mult_key].nil?
93
+
94
+ [boxes[text_key], boxes[mult_key]]
95
+ end
96
+
97
+ # EA's default label placement for connectors without stored
98
+ # label boxes: role name 5px along the line and 31px below it,
99
+ # multiplicity 18px along and 21px above (screen-down coords).
100
+ # Derived from 219 labels across EA-published reference SVGs.
101
+ def default_label_boxes(points, end_kind)
102
+ e = end_kind == :source ? points.first : points.last
103
+ other = end_kind == :source ? points[1] : points[-2]
104
+ return [nil, nil] unless e && other
105
+
106
+ ux = other[0] - e[0]
107
+ uy = other[1] - e[1]
108
+ len = Math.sqrt(ux**2 + uy**2)
109
+ return [nil, nil] if len < 0.5
110
+
111
+ ux /= len
112
+ uy /= len
113
+ vx = uy
114
+ vy = -ux
115
+ role_box = { "ox" => (5 * ux - 31 * vx).round,
116
+ "oy" => (5 * uy - 31 * vy).round }
117
+ mult_box = { "ox" => (18 * ux + 21 * vx).round,
118
+ "oy" => (18 * uy + 21 * vy).round }
119
+ [role_box, mult_box]
120
+ end
121
+
86
122
  def registry
87
123
  @registry ||= Label::Registry.new(model_index: model_index)
88
124
  end
@@ -20,7 +20,7 @@ module Ea
20
20
  DEFAULT_FILL = "#000000"
21
21
  DEFAULT_ROTATION = -0.00
22
22
  DEFAULT_STROKE_IN_TEXT = "#000000"
23
- DEFAULT_WIDTH_FACTOR = 0.65
23
+ DEFAULT_WIDTH_FACTOR = 0.612
24
24
 
25
25
  attr_reader :content, :x, :y, :family, :size, :weight, :style,
26
26
  :fill, :text_length, :rotation, :size_unit,
@@ -55,19 +55,108 @@ module Ea
55
55
 
56
56
  private
57
57
 
58
+ # nil coordinates occur on degenerate placements (e.g. label
59
+ # boxes without offsets); EA always has a position, so render
60
+ # at the origin rather than crashing the whole diagram.
58
61
  def format_attrs
59
- %(x="#{format('%.2f', x)}" y="#{format('%.2f', y)}")
62
+ %(x="#{format('%.2f', x.to_f)}" y="#{format('%.2f', y.to_f)}")
60
63
  end
61
64
 
62
65
  def format_style
63
66
  "font-family:#{family}; font-weight:#{weight}; font-style:#{style}; font-size:#{size}#{size_unit}; fill:#{fill};fill-opacity:1.00; stroke:#{stroke_in_text}; stroke-opacity:0.00 stroke-width:0; white-space: pre;"
64
67
  end
65
68
 
66
- def self.estimate_width(text, size, width_factor = 0.65)
67
- content = text.to_s
68
- space_count = content.count(" ")
69
- letter_count = content.length - space_count
70
- letter_count * size * width_factor + space_count * size * 0.3
69
+ # Per-codepoint advance widths (em), least-squares fitted from
70
+ # EA's own textLength attributes across ~8k published reference
71
+ # texts (mean residual 0.097em - real-metric extraction).
72
+ GLYPH_WIDTHS = {
73
+ "e" => 0.731,
74
+ "t" => 0.438,
75
+ "a" => 0.72,
76
+ "r" => 0.446,
77
+ "i" => 0.302,
78
+ "o" => 0.717,
79
+ "n" => 0.693,
80
+ " " => 0.311,
81
+ "s" => 0.568,
82
+ "c" => 0.567,
83
+ "l" => 0.287,
84
+ "u" => 0.732,
85
+ ":" => 0.433,
86
+ "m" => 1.125,
87
+ "+" => 0.951,
88
+ "d" => 0.707,
89
+ "p" => 0.703,
90
+ "C" => 0.725,
91
+ "g" => 0.726,
92
+ "y" => 0.733,
93
+ "." => 0.458,
94
+ "S" => 0.706,
95
+ "f" => 0.426,
96
+ "_" => 0.755,
97
+ "T" => 0.702,
98
+ "L" => 0.604,
99
+ "D" => 0.871,
100
+ "1" => 0.708,
101
+ "0" => 0.703,
102
+ "P" => 0.698,
103
+ "h" => 0.699,
104
+ "\u00bb" => 0.802,
105
+ "\u00ab" => 0.566,
106
+ "b" => 0.72,
107
+ "I" => 0.422,
108
+ "A" => 0.907,
109
+ "v" => 0.685,
110
+ "M" => 1.294,
111
+ "R" => 0.764,
112
+ ")" => 0.405,
113
+ "(" => 0.289,
114
+ "*" => 0.646,
115
+ "[" => -0.029,
116
+ "]" => 0.669,
117
+ "O" => 0.968,
118
+ "E" => 0.683,
119
+ "B" => 0.801,
120
+ "V" => 0.898,
121
+ "x" => 0.628,
122
+ "G" => 0.875,
123
+ "=" => 0.629,
124
+ "F" => 0.74,
125
+ "N" => 0.893,
126
+ "U" => 0.907,
127
+ "2" => 0.743,
128
+ "w" => 0.983,
129
+ "}" => 0.345,
130
+ "{" => 0.461,
131
+ "k" => 0.792,
132
+ "9" => 0.839,
133
+ "j" => 0.366,
134
+ "q" => 0.657,
135
+ "3" => 0.694,
136
+ "5" => 0.72,
137
+ ">" => 0.682,
138
+ "," => 0.549,
139
+ "-" => 0.485,
140
+ "<" => 0.696,
141
+ "Q" => 0.967,
142
+ "6" => 0.776,
143
+ "4" => 0.658,
144
+ "/" => 0.496,
145
+ "z" => 0.59,
146
+ "H" => 0.854,
147
+ "W" => 1.292,
148
+ "\"" => 0.508,
149
+ "J" => 0.555,
150
+ "X" => 0.711,
151
+ "7" => 0.671,
152
+ "8" => 0.74
153
+ }.freeze
154
+ GLYPH_FALLBACK = 0.61
155
+
156
+ def self.estimate_width(text, size, _width_factor = nil)
157
+ text.to_s.each_char.sum do |ch|
158
+ (GLYPH_WIDTHS[ch] || GLYPH_FALLBACK) * size.to_f
159
+ end
71
160
  end
72
161
 
73
162
  def formatted_text_length
@@ -77,7 +166,7 @@ module Ea
77
166
  end
78
167
 
79
168
  def formatted_transform
80
- "rotate(#{format('%<r>.2f', r: rotation)} #{format('%.2f', x)} #{format('%.2f', y)})"
169
+ "rotate(#{format('%<r>.2f', r: rotation)} #{format('%.2f', x.to_f)} #{format('%.2f', y.to_f)})"
81
170
  end
82
171
 
83
172
  def escaped_content
@@ -25,6 +25,7 @@ module Ea
25
25
  text: count_diff("text"),
26
26
  group: top_level_group_diff,
27
27
  font_family: font_family_match,
28
+ font_size: font_size_match,
28
29
  view_box: view_box_match,
29
30
  text_overlap: text_overlap_ratio
30
31
  )
@@ -51,9 +52,23 @@ module Ea
51
52
  end
52
53
 
53
54
  def font_family_match
54
- our_family = first_text_style(our_doc)[/font-family:([^;]+)/, 1]
55
- ref_family = first_text_style(ref_doc)[/font-family:([^;]+)/, 1]
56
- our_family == ref_family
55
+ font_style_histogram(our_doc, "font-family") ==
56
+ font_style_histogram(ref_doc, "font-family")
57
+ end
58
+
59
+ # Exact histogram of every emitted font-size (e.g.
60
+ # {"9.00"=>42, "13.00"=>21}) — a first-text comparison would
61
+ # pass diagrams whose body text renders at the wrong size.
62
+ def font_size_match
63
+ font_style_histogram(our_doc, "font-size") ==
64
+ font_style_histogram(ref_doc, "font-size")
65
+ end
66
+
67
+ def font_style_histogram(doc, property)
68
+ doc.css("text").each_with_object(Hash.new(0)) do |node, acc|
69
+ value = (node["style"] || "")[/#{Regexp.quote(property)}:([^;]+)/, 1]
70
+ acc[value] += 1 if value
71
+ end
57
72
  end
58
73
 
59
74
  def view_box_match
@@ -69,11 +84,6 @@ module Ea
69
84
  (our_set & ref_set).size.to_f / (our_set | ref_set).size.to_f
70
85
  end
71
86
 
72
- def first_text_style(doc)
73
- first = doc.css("text").first
74
- first ? (first["style"] || "") : ""
75
- end
76
-
77
87
  Diff = Struct.new(:ours, :reference, keyword_init: true) do
78
88
  def delta
79
89
  ours - reference
@@ -85,7 +95,7 @@ module Ea
85
95
  end
86
96
 
87
97
  Report = Struct.new(:rect, :path, :polygon, :text, :group,
88
- :font_family, :view_box, :text_overlap,
98
+ :font_family, :font_size, :view_box, :text_overlap,
89
99
  keyword_init: true) do
90
100
  def shape_delta_total
91
101
  [rect, path, polygon].sum(&:delta).abs
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ea
6
+ module Svg
7
+ module Parity
8
+ # Renders every diagram of a Document and diffs it against the
9
+ # EA-authored reference export for that diagram (EAID_-named
10
+ # files), aggregating itemized deltas into a corpus report.
11
+ #
12
+ # References stay wherever EA published them (e.g. an EA "Save
13
+ # as image" directory); nothing is copied into the gem.
14
+ class CorpusReport
15
+ DELTA_KEYS = %i[extra_text missing_text moved_text wrong_font
16
+ wrong_size wrong_weight].freeze
17
+
18
+ attr_reader :document, :ref_dir
19
+
20
+ def initialize(document:, ref_dir:)
21
+ @document = document
22
+ @ref_dir = ref_dir
23
+ end
24
+
25
+ # Returns { diagrams: [...], totals: {...} } and writes JSON
26
+ # when output_path is given.
27
+ def run(output_path: nil)
28
+ index = document.index_by_id
29
+ rows = diagrams_with_references.map do |diagram|
30
+ measure_one(diagram, index)
31
+ end.compact
32
+
33
+ result = { totals: totals(rows), diagrams: rows }
34
+ File.write(output_path, JSON.pretty_generate(result)) if output_path
35
+ result
36
+ end
37
+
38
+ private
39
+
40
+ def diagrams_with_references
41
+ document.diagrams.select { |d| reference_path_for(d) }
42
+ end
43
+
44
+ # EA publishes images as EAID_<guid-with-underscores>.svg;
45
+ # fall back to the raw diagram id for non-QEA sources.
46
+ def reference_path_for(diagram)
47
+ [
48
+ File.join(ref_dir, "EAID_#{diagram.id.tr('-', '_')}.svg"),
49
+ File.join(ref_dir, "#{Ea::Sources::Qea::IdNormalizer.to_eaid(diagram.id)}.svg"),
50
+ File.join(ref_dir, "#{diagram.id}.svg")
51
+ ].find { |p| File.exist?(p) }
52
+ end
53
+
54
+ def measure_one(diagram, index)
55
+ ref_path = reference_path_for(diagram)
56
+ return nil unless ref_path
57
+
58
+ ours = Ea::Svg::EaEmitter::Document.new(
59
+ diagram, model_index: index, document: document
60
+ ).render
61
+ differ = Differ.new(ours: ours, reference: File.read(ref_path))
62
+ deltas = differ.text_deltas
63
+
64
+ {
65
+ id: diagram.id, name: diagram.name,
66
+ deltas: DELTA_KEYS.to_h { |k| [k, deltas[k].size] },
67
+ shapes: differ.shape_summary.slice(:matched, :missing, :extra)
68
+ }
69
+ end
70
+
71
+ def totals(rows)
72
+ sum = DELTA_KEYS.to_h { |k| [k, 0] }
73
+ shapes = { matched: 0, missing: 0, extra: 0 }
74
+ rows.each do |row|
75
+ DELTA_KEYS.each { |k| sum[k] += row[:deltas][k] }
76
+ shapes.each_key { |k| shapes[k] += row[:shapes][k] }
77
+ end
78
+ sum.merge(shapes: shapes, diagrams: rows.size)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end