ea 0.2.5 → 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/TODO.refactor/01-ea-model-domain-layer.md +64 -0
- data/TODO.refactor/02-ea-model-qea-source-adapter.md +58 -0
- data/TODO.refactor/03-ea-model-xmi-source-adapter.md +33 -0
- data/TODO.refactor/04-ea-model-annotations-tagged-values.md +34 -0
- data/TODO.refactor/05-ea-model-diagrams-coordinates.md +37 -0
- data/TODO.refactor/06-spa-projection-layer.md +45 -0
- data/TODO.refactor/07-spa-sharded-output-strategy.md +28 -0
- data/TODO.refactor/08-spa-build-time-search-index.md +24 -0
- data/TODO.refactor/09-spa-frontend-data-contract.md +25 -0
- data/TODO.refactor/10-spa-cli-command-rework.md +34 -0
- data/TODO.refactor/11-spa-perf-benchmark.md +23 -0
- data/TODO.refactor/12-lutaml-uml-static-site-deprecation.md +28 -0
- data/lib/ea/cli/app.rb +13 -7
- data/lib/ea/cli/command/spa.rb +33 -48
- data/lib/ea/cli/command/svg.rb +68 -0
- data/lib/ea/cli/command.rb +1 -0
- data/lib/ea/model/annotation.rb +29 -0
- data/lib/ea/model/association.rb +40 -0
- data/lib/ea/model/base.rb +27 -0
- data/lib/ea/model/bounds.rb +22 -0
- data/lib/ea/model/classifier.rb +41 -0
- data/lib/ea/model/data_type.rb +9 -0
- data/lib/ea/model/dependency.rb +19 -0
- data/lib/ea/model/diagram.rb +28 -0
- data/lib/ea/model/diagram_connector.rb +30 -0
- data/lib/ea/model/diagram_element.rb +23 -0
- data/lib/ea/model/document.rb +107 -0
- data/lib/ea/model/enumeration.rb +15 -0
- data/lib/ea/model/enumeration_literal.rb +16 -0
- data/lib/ea/model/generalization.rb +19 -0
- data/lib/ea/model/interface.rb +9 -0
- data/lib/ea/model/klass.rb +16 -0
- data/lib/ea/model/metadata.rb +31 -0
- data/lib/ea/model/operation.rb +33 -0
- data/lib/ea/model/package.rb +36 -0
- data/lib/ea/model/parameter.rb +24 -0
- data/lib/ea/model/point.rb +16 -0
- data/lib/ea/model/polymorphic_maps.rb +30 -0
- data/lib/ea/model/primitive_type.rb +9 -0
- data/lib/ea/model/property.rb +49 -0
- data/lib/ea/model/realization.rb +19 -0
- data/lib/ea/model/relationship.rb +21 -0
- data/lib/ea/model/stereotype.rb +24 -0
- data/lib/ea/model/tagged_value.rb +20 -0
- data/lib/ea/model/waypoint.rb +19 -0
- data/lib/ea/model.rb +48 -0
- data/lib/ea/sources/qea/adapter.rb +63 -0
- data/lib/ea/sources/qea/annotation_builder.rb +36 -0
- data/lib/ea/sources/qea/classifier_builder.rb +119 -0
- data/lib/ea/sources/qea/connector_relationship_map.rb +42 -0
- data/lib/ea/sources/qea/diagram_builder.rb +214 -0
- data/lib/ea/sources/qea/diagram_style_parser.rb +43 -0
- data/lib/ea/sources/qea/id_normalizer.rb +24 -0
- data/lib/ea/sources/qea/metadata_builder.rb +42 -0
- data/lib/ea/sources/qea/object_classifier_map.rb +36 -0
- data/lib/ea/sources/qea/operation_builder.rb +79 -0
- data/lib/ea/sources/qea/package_builder.rb +44 -0
- data/lib/ea/sources/qea/property_builder.rb +86 -0
- data/lib/ea/sources/qea/relationship_builder.rb +113 -0
- data/lib/ea/sources/qea/stereotype_builder.rb +44 -0
- data/lib/ea/sources/qea/tagged_value_builder.rb +39 -0
- data/lib/ea/sources/qea.rb +31 -0
- data/lib/ea/sources/xmi/adapter.rb +62 -0
- data/lib/ea/sources/xmi/annotation_builder.rb +49 -0
- data/lib/ea/sources/xmi/classifier_builder.rb +89 -0
- data/lib/ea/sources/xmi/diagram_builder.rb +82 -0
- data/lib/ea/sources/xmi/id_normalizer.rb +27 -0
- data/lib/ea/sources/xmi/metadata_builder.rb +32 -0
- data/lib/ea/sources/xmi/operation_builder.rb +59 -0
- data/lib/ea/sources/xmi/package_builder.rb +73 -0
- data/lib/ea/sources/xmi/property_builder.rb +77 -0
- data/lib/ea/sources/xmi/relationship_builder.rb +142 -0
- data/lib/ea/sources/xmi/relationship_type_map.rb +34 -0
- data/lib/ea/sources/xmi/type_classifier_map.rb +33 -0
- data/lib/ea/sources/xmi.rb +29 -0
- data/lib/ea/sources.rb +15 -0
- data/lib/ea/spa/configuration.rb +110 -0
- data/lib/ea/spa/generator.rb +45 -0
- data/lib/ea/spa/lazy_ref.rb +18 -0
- data/lib/ea/spa/output/sharded_multi_file_strategy.rb +71 -0
- data/lib/ea/spa/output/single_file_strategy.rb +56 -0
- data/lib/ea/spa/output/strategy.rb +36 -0
- data/lib/ea/spa/output.rb +11 -0
- data/lib/ea/spa/package_tree.rb +15 -0
- data/lib/ea/spa/package_tree_node.rb +26 -0
- data/lib/ea/spa/projector.rb +199 -0
- data/lib/ea/spa/search_entry.rb +28 -0
- data/lib/ea/spa/search_index.rb +18 -0
- data/lib/ea/spa/shard.rb +19 -0
- data/lib/ea/spa/skeleton.rb +22 -0
- data/lib/ea/spa/skeleton_entry.rb +27 -0
- data/lib/ea/spa.rb +31 -0
- data/lib/ea/svg/bounds_calculator.rb +74 -0
- data/lib/ea/svg/connector_path.rb +138 -0
- data/lib/ea/svg/element_box.rb +256 -0
- data/lib/ea/svg/renderer.rb +77 -0
- data/lib/ea/svg/style_resolver.rb +72 -0
- data/lib/ea/svg.rb +21 -0
- data/lib/ea/version.rb +1 -1
- data/lib/ea.rb +4 -0
- metadata +97 -2
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Spa
|
|
5
|
+
# One-way transform: Ea::Model::Document → SPA view artifacts.
|
|
6
|
+
#
|
|
7
|
+
# The projector walks the model once and emits:
|
|
8
|
+
# - Skeleton (with package tree + per-classifier entries)
|
|
9
|
+
# - SearchIndex (flat searchable rows)
|
|
10
|
+
# - Shards (one per entity, generated lazily via #shard_for)
|
|
11
|
+
#
|
|
12
|
+
# Sharding strategy is configurable via the `shard_url_for`
|
|
13
|
+
# proc — by default it produces "data/<kind>s/<id>.json" paths
|
|
14
|
+
# matching the sharded output strategy.
|
|
15
|
+
class Projector
|
|
16
|
+
attr_reader :document, :shard_url_for, :configuration
|
|
17
|
+
|
|
18
|
+
def initialize(document, shard_url_for: nil, configuration: nil)
|
|
19
|
+
@document = document
|
|
20
|
+
@shard_url_for = shard_url_for || default_shard_url
|
|
21
|
+
@configuration = configuration
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def skeleton
|
|
25
|
+
Skeleton.new(
|
|
26
|
+
metadata: metadata_hash,
|
|
27
|
+
package_tree: build_package_tree,
|
|
28
|
+
entries: build_entries,
|
|
29
|
+
view_extras: view_extras
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def search_index
|
|
34
|
+
SearchIndex.new(entries: build_search_entries)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def shard_for(model_element)
|
|
38
|
+
Shard.new(
|
|
39
|
+
id: model_element.id,
|
|
40
|
+
kind: kind_of(model_element),
|
|
41
|
+
payload: payload_for(model_element)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Enumerate every (id, kind, shard) triple the SPA can address.
|
|
46
|
+
# Diagrams are skipped when the configuration disables them.
|
|
47
|
+
def each_shard
|
|
48
|
+
return enum_for(:each_shard) unless block_given?
|
|
49
|
+
|
|
50
|
+
document.classifiers.each { |c| yield shard_for(c) }
|
|
51
|
+
document.packages.each { |p| yield shard_for(p) }
|
|
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
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def default_shard_url
|
|
62
|
+
lambda do |element|
|
|
63
|
+
kind = kind_of(element)
|
|
64
|
+
"data/#{pluralize(kind)}/#{element.id}.json"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def pluralize(kind)
|
|
69
|
+
case kind
|
|
70
|
+
when "class" then "classes"
|
|
71
|
+
when "property" then "properties"
|
|
72
|
+
else "#{kind}s"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def metadata_hash
|
|
77
|
+
base = document.metadata
|
|
78
|
+
merged = configuration ? configuration.apply_to_metadata(base) : base
|
|
79
|
+
JSON.parse(merged.to_json)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def view_extras
|
|
83
|
+
configuration&.view_extras || {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_package_tree
|
|
87
|
+
nodes = document.packages.map do |pkg|
|
|
88
|
+
PackageTreeNode.new(
|
|
89
|
+
id: pkg.id,
|
|
90
|
+
name: pkg.name,
|
|
91
|
+
parent_id: pkg.parent_id,
|
|
92
|
+
child_ids: pkg.sub_package_ids,
|
|
93
|
+
classifier_ids: classifiers_in_package_ids(pkg.id),
|
|
94
|
+
diagram_ids: pkg.diagram_ids
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
PackageTree.new(
|
|
98
|
+
root_ids: document.root_packages.map(&:id),
|
|
99
|
+
nodes: nodes
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def classifiers_in_package_ids(package_id)
|
|
104
|
+
document.classifiers_in_package(package_id).map(&:id)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_entries
|
|
108
|
+
document.classifiers.map do |c|
|
|
109
|
+
SkeletonEntry.new(
|
|
110
|
+
id: c.id,
|
|
111
|
+
name: c.name,
|
|
112
|
+
kind: c.model_kind,
|
|
113
|
+
package_id: c.package_id,
|
|
114
|
+
qualified_name: c.qualified_name,
|
|
115
|
+
shard_url: shard_url_for.call(c)
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_search_entries
|
|
121
|
+
entries = []
|
|
122
|
+
document.classifiers.each do |c|
|
|
123
|
+
entries << search_entry_for_classifier(c)
|
|
124
|
+
c.properties.each { |p| entries << search_entry_for_property(p, c) }
|
|
125
|
+
end
|
|
126
|
+
document.packages.each { |p| entries << search_entry_for_package(p) }
|
|
127
|
+
entries
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def search_entry_for_classifier(c)
|
|
131
|
+
SearchEntry.new(
|
|
132
|
+
id: c.id,
|
|
133
|
+
kind: c.model_kind,
|
|
134
|
+
name: c.name,
|
|
135
|
+
qualified_name: c.qualified_name,
|
|
136
|
+
package: package_qualified_name(c.package_id),
|
|
137
|
+
content: classifier_search_content(c),
|
|
138
|
+
boost: 1.5
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def search_entry_for_property(prop, owner)
|
|
143
|
+
SearchEntry.new(
|
|
144
|
+
id: prop.id,
|
|
145
|
+
kind: "property",
|
|
146
|
+
name: prop.name,
|
|
147
|
+
qualified_name: "#{owner.qualified_name}::#{prop.name}",
|
|
148
|
+
package: package_qualified_name(owner.package_id),
|
|
149
|
+
content: [prop.name, prop.type_name, prop.annotations.map(&:body)].flatten.compact.join(" "),
|
|
150
|
+
boost: 1.0
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def search_entry_for_package(pkg)
|
|
155
|
+
SearchEntry.new(
|
|
156
|
+
id: pkg.id,
|
|
157
|
+
kind: "package",
|
|
158
|
+
name: pkg.name,
|
|
159
|
+
qualified_name: pkg.qualified_name,
|
|
160
|
+
package: pkg.parent_id ? package_qualified_name(pkg.parent_id) : "",
|
|
161
|
+
content: [pkg.name, pkg.annotations.map(&:body)].flatten.compact.join(" "),
|
|
162
|
+
boost: 1.2
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def classifier_search_content(c)
|
|
167
|
+
parts = [c.name, c.qualified_name, c.stereotype_refs].compact
|
|
168
|
+
parts << c.properties.map(&:name)
|
|
169
|
+
parts << c.operations.map(&:name)
|
|
170
|
+
parts << c.annotations.map(&:body)
|
|
171
|
+
parts.flatten.compact.join(" ").gsub(/\s+/, " ").strip
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def package_qualified_name(package_id)
|
|
175
|
+
return "" if package_id.nil?
|
|
176
|
+
|
|
177
|
+
pkg = document.index_by_id[package_id]
|
|
178
|
+
pkg&.qualified_name || ""
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def kind_of(element)
|
|
182
|
+
case element
|
|
183
|
+
when Ea::Model::Klass then "class"
|
|
184
|
+
when Ea::Model::Enumeration then "enumeration"
|
|
185
|
+
when Ea::Model::DataType then "data_type"
|
|
186
|
+
when Ea::Model::PrimitiveType then "primitive_type"
|
|
187
|
+
when Ea::Model::Interface then "interface"
|
|
188
|
+
when Ea::Model::Package then "package"
|
|
189
|
+
when Ea::Model::Diagram then "diagram"
|
|
190
|
+
else "element"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def payload_for(element)
|
|
195
|
+
JSON.parse(element.to_json)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Spa
|
|
5
|
+
# One searchable row. MiniSearch-compatible shape: id, name,
|
|
6
|
+
# qualifiedName, type, package, content — all searchable, with
|
|
7
|
+
# `boost` weighting source-type importance.
|
|
8
|
+
class SearchEntry < Lutaml::Model::Serializable
|
|
9
|
+
attribute :id, :string
|
|
10
|
+
attribute :kind, :string # class|enumeration|package|...
|
|
11
|
+
attribute :name, :string
|
|
12
|
+
attribute :qualified_name, :string
|
|
13
|
+
attribute :package, :string
|
|
14
|
+
attribute :content, :string # flattened searchable text
|
|
15
|
+
attribute :boost, :float, default: -> { 1.0 }
|
|
16
|
+
|
|
17
|
+
json do
|
|
18
|
+
map "id", to: :id
|
|
19
|
+
map "kind", to: :kind
|
|
20
|
+
map "name", to: :name
|
|
21
|
+
map "qualifiedName", to: :qualified_name
|
|
22
|
+
map "package", to: :package
|
|
23
|
+
map "content", to: :content
|
|
24
|
+
map "boost", to: :boost, render_default: true
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Spa
|
|
5
|
+
class SearchIndex < Lutaml::Model::Serializable
|
|
6
|
+
attribute :version, :string, default: -> { "1.0" }
|
|
7
|
+
attribute :fields, :string, collection: true,
|
|
8
|
+
default: -> { %w[name qualifiedName kind package content] }
|
|
9
|
+
attribute :entries, SearchEntry, collection: true, initialize_empty: true
|
|
10
|
+
|
|
11
|
+
json do
|
|
12
|
+
map "version", to: :version, render_default: true
|
|
13
|
+
map "fields", to: :fields, render_default: true
|
|
14
|
+
map "entries", to: :entries, render_empty: true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/ea/spa/shard.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Spa
|
|
5
|
+
# Per-entity detail payload. Plain Ruby value object (not a
|
|
6
|
+
# lutaml-model type) because the payload is an arbitrary JSON
|
|
7
|
+
# blob whose shape depends on the model element's kind — we
|
|
8
|
+
# don't want the framework's hash-type cast getting in the way.
|
|
9
|
+
Shard = Struct.new(:id, :kind, :payload, keyword_init: true) do
|
|
10
|
+
def to_h
|
|
11
|
+
{ "id" => id, "kind" => kind, "payload" => payload }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_json(*args)
|
|
15
|
+
JSON.generate(to_h, *args)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Spa
|
|
5
|
+
# Top-level SPA skeleton. Plain Struct (not a lutaml-model
|
|
6
|
+
# type) because it's a thin transport wrapper around typed
|
|
7
|
+
# sub-objects (PackageTree, SkeletonEntry) plus a raw metadata
|
|
8
|
+
# hash extracted from the model.
|
|
9
|
+
Skeleton = Struct.new(:metadata, :package_tree, :entries, :view_extras,
|
|
10
|
+
keyword_init: true) do
|
|
11
|
+
def to_json(*args)
|
|
12
|
+
payload = {
|
|
13
|
+
"metadata" => metadata,
|
|
14
|
+
"packageTree" => package_tree,
|
|
15
|
+
"entries" => entries
|
|
16
|
+
}
|
|
17
|
+
payload["viewExtras"] = view_extras unless view_extras.nil? || view_extras.empty?
|
|
18
|
+
JSON.generate(payload, *args)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Spa
|
|
5
|
+
# One row in the SPA skeleton: the minimal info needed to
|
|
6
|
+
# render the sidebar, do name searches, and route to a shard.
|
|
7
|
+
# Everything else (properties, operations, etc.) lives in the
|
|
8
|
+
# per-entity Shard.
|
|
9
|
+
class SkeletonEntry < Lutaml::Model::Serializable
|
|
10
|
+
attribute :id, :string
|
|
11
|
+
attribute :name, :string
|
|
12
|
+
attribute :kind, :string # class|enumeration|data_type|...
|
|
13
|
+
attribute :package_id, :string
|
|
14
|
+
attribute :qualified_name, :string
|
|
15
|
+
attribute :shard_url, :string # where to fetch the full Shard
|
|
16
|
+
|
|
17
|
+
json do
|
|
18
|
+
map "id", to: :id
|
|
19
|
+
map "name", to: :name
|
|
20
|
+
map "kind", to: :kind
|
|
21
|
+
map "packageId", to: :package_id
|
|
22
|
+
map "qualifiedName", to: :qualified_name
|
|
23
|
+
map "shardUrl", to: :shard_url
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/ea/spa.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Ea
|
|
6
|
+
# Ea::Spa is the SPA consumer adapter — a one-way projection of
|
|
7
|
+
# Ea::Model into view-shaped artifacts (Skeleton, Shard,
|
|
8
|
+
# SearchIndex, PackageTree) suitable for a browser frontend.
|
|
9
|
+
#
|
|
10
|
+
# Projection vs. modeling: the SPA does not redefine what a Class
|
|
11
|
+
# is. It owns view-shaped types that refer to model elements by
|
|
12
|
+
# id and add browsing-specific metadata (shard URLs, lazy refs,
|
|
13
|
+
# search content, navigation tree position).
|
|
14
|
+
#
|
|
15
|
+
# Output strategies (single-file vs sharded) live under
|
|
16
|
+
# Ea::Spa::Output and project the same artifacts to disk.
|
|
17
|
+
module Spa
|
|
18
|
+
autoload :SkeletonEntry, "ea/spa/skeleton_entry"
|
|
19
|
+
autoload :Skeleton, "ea/spa/skeleton"
|
|
20
|
+
autoload :PackageTreeNode, "ea/spa/package_tree_node"
|
|
21
|
+
autoload :PackageTree, "ea/spa/package_tree"
|
|
22
|
+
autoload :SearchEntry, "ea/spa/search_entry"
|
|
23
|
+
autoload :SearchIndex, "ea/spa/search_index"
|
|
24
|
+
autoload :Shard, "ea/spa/shard"
|
|
25
|
+
autoload :LazyRef, "ea/spa/lazy_ref"
|
|
26
|
+
autoload :Projector, "ea/spa/projector"
|
|
27
|
+
autoload :Output, "ea/spa/output"
|
|
28
|
+
autoload :Generator, "ea/spa/generator"
|
|
29
|
+
autoload :Configuration, "ea/spa/configuration"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Svg
|
|
5
|
+
# Computes the actual drawing-area bounding box of an
|
|
6
|
+
# Ea::Model::Diagram. EA stores bounds in pixel space with two
|
|
7
|
+
# quirks: element rects can be inverted (rectbottom < recttop,
|
|
8
|
+
# producing negative height), and elements/connectors can sit
|
|
9
|
+
# outside the canvas bounds. The drawing area is the union of
|
|
10
|
+
# all element rects and connector waypoint positions.
|
|
11
|
+
class BoundsCalculator
|
|
12
|
+
attr_reader :diagram
|
|
13
|
+
|
|
14
|
+
def initialize(diagram)
|
|
15
|
+
@diagram = diagram
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def compute
|
|
19
|
+
points = all_points
|
|
20
|
+
return fallback_bounds if points.empty?
|
|
21
|
+
|
|
22
|
+
xs = points.map(&:first)
|
|
23
|
+
ys = points.map(&:last)
|
|
24
|
+
Bounds.new(x: xs.min, y: ys.min, width: xs.max - xs.min,
|
|
25
|
+
height: ys.max - ys.min)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def all_points
|
|
31
|
+
points = []
|
|
32
|
+
diagram.elements.each do |elem|
|
|
33
|
+
next unless elem.bounds
|
|
34
|
+
|
|
35
|
+
b = normalize_bounds(elem.bounds)
|
|
36
|
+
points << [b.x, b.y]
|
|
37
|
+
points << [b.x + b.width, b.y + b.height]
|
|
38
|
+
end
|
|
39
|
+
diagram.connectors.each do |conn|
|
|
40
|
+
conn.waypoints.each do |wp|
|
|
41
|
+
next unless wp.position
|
|
42
|
+
|
|
43
|
+
points << [wp.position.x, wp.position.y]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
points
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# EA occasionally stores inverted rects (rectbottom < recttop).
|
|
50
|
+
# Normalize so width/height are non-negative.
|
|
51
|
+
def normalize_bounds(bounds)
|
|
52
|
+
x = bounds.x
|
|
53
|
+
y = bounds.y
|
|
54
|
+
w = bounds.width
|
|
55
|
+
h = bounds.height
|
|
56
|
+
x, w = x + w, -w if w.negative?
|
|
57
|
+
y, h = y + h, -h if h.negative?
|
|
58
|
+
Bounds.new(x: x, y: y, width: w, height: h)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fallback_bounds
|
|
62
|
+
return Bounds.new(x: 0, y: 0, width: 1, height: 1) unless diagram.bounds
|
|
63
|
+
|
|
64
|
+
Bounds.new(x: diagram.bounds.x, y: diagram.bounds.y,
|
|
65
|
+
width: diagram.bounds.width.abs,
|
|
66
|
+
height: diagram.bounds.height.abs)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Internal value type — keep private to avoid polluting the
|
|
70
|
+
# Ea::Svg namespace.
|
|
71
|
+
Bounds = Struct.new(:x, :y, :width, :height, keyword_init: true)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Svg
|
|
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
|
+
class ConnectorPath
|
|
10
|
+
ARROW_SIZE = 8
|
|
11
|
+
|
|
12
|
+
attr_reader :connector, :relationship
|
|
13
|
+
|
|
14
|
+
def initialize(connector, relationship: nil)
|
|
15
|
+
@connector = connector
|
|
16
|
+
@relationship = relationship
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render
|
|
20
|
+
points = waypoints
|
|
21
|
+
return "" if points.size < 2
|
|
22
|
+
|
|
23
|
+
path_d = build_path_d(points)
|
|
24
|
+
style = StyleResolver.new(connector.style)
|
|
25
|
+
|
|
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 ")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def waypoints
|
|
38
|
+
connector.waypoints.filter_map do |wp|
|
|
39
|
+
next unless wp.position
|
|
40
|
+
|
|
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 ""
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def render_target_marker(points)
|
|
69
|
+
filled_arrow(points.last, points[-2])
|
|
70
|
+
end
|
|
71
|
+
|
|
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("\"", """)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|