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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.refactor/01-ea-model-domain-layer.md +64 -0
  3. data/TODO.refactor/02-ea-model-qea-source-adapter.md +58 -0
  4. data/TODO.refactor/03-ea-model-xmi-source-adapter.md +33 -0
  5. data/TODO.refactor/04-ea-model-annotations-tagged-values.md +34 -0
  6. data/TODO.refactor/05-ea-model-diagrams-coordinates.md +37 -0
  7. data/TODO.refactor/06-spa-projection-layer.md +45 -0
  8. data/TODO.refactor/07-spa-sharded-output-strategy.md +28 -0
  9. data/TODO.refactor/08-spa-build-time-search-index.md +24 -0
  10. data/TODO.refactor/09-spa-frontend-data-contract.md +25 -0
  11. data/TODO.refactor/10-spa-cli-command-rework.md +34 -0
  12. data/TODO.refactor/11-spa-perf-benchmark.md +23 -0
  13. data/TODO.refactor/12-lutaml-uml-static-site-deprecation.md +28 -0
  14. data/lib/ea/cli/app.rb +13 -7
  15. data/lib/ea/cli/command/spa.rb +33 -48
  16. data/lib/ea/cli/command/svg.rb +68 -0
  17. data/lib/ea/cli/command.rb +1 -0
  18. data/lib/ea/model/annotation.rb +29 -0
  19. data/lib/ea/model/association.rb +40 -0
  20. data/lib/ea/model/base.rb +27 -0
  21. data/lib/ea/model/bounds.rb +22 -0
  22. data/lib/ea/model/classifier.rb +41 -0
  23. data/lib/ea/model/data_type.rb +9 -0
  24. data/lib/ea/model/dependency.rb +19 -0
  25. data/lib/ea/model/diagram.rb +28 -0
  26. data/lib/ea/model/diagram_connector.rb +30 -0
  27. data/lib/ea/model/diagram_element.rb +23 -0
  28. data/lib/ea/model/document.rb +107 -0
  29. data/lib/ea/model/enumeration.rb +15 -0
  30. data/lib/ea/model/enumeration_literal.rb +16 -0
  31. data/lib/ea/model/generalization.rb +19 -0
  32. data/lib/ea/model/interface.rb +9 -0
  33. data/lib/ea/model/klass.rb +16 -0
  34. data/lib/ea/model/metadata.rb +31 -0
  35. data/lib/ea/model/operation.rb +33 -0
  36. data/lib/ea/model/package.rb +36 -0
  37. data/lib/ea/model/parameter.rb +24 -0
  38. data/lib/ea/model/point.rb +16 -0
  39. data/lib/ea/model/polymorphic_maps.rb +30 -0
  40. data/lib/ea/model/primitive_type.rb +9 -0
  41. data/lib/ea/model/property.rb +49 -0
  42. data/lib/ea/model/realization.rb +19 -0
  43. data/lib/ea/model/relationship.rb +21 -0
  44. data/lib/ea/model/stereotype.rb +24 -0
  45. data/lib/ea/model/tagged_value.rb +20 -0
  46. data/lib/ea/model/waypoint.rb +19 -0
  47. data/lib/ea/model.rb +48 -0
  48. data/lib/ea/sources/qea/adapter.rb +63 -0
  49. data/lib/ea/sources/qea/annotation_builder.rb +36 -0
  50. data/lib/ea/sources/qea/classifier_builder.rb +119 -0
  51. data/lib/ea/sources/qea/connector_relationship_map.rb +42 -0
  52. data/lib/ea/sources/qea/diagram_builder.rb +214 -0
  53. data/lib/ea/sources/qea/diagram_style_parser.rb +43 -0
  54. data/lib/ea/sources/qea/id_normalizer.rb +24 -0
  55. data/lib/ea/sources/qea/metadata_builder.rb +42 -0
  56. data/lib/ea/sources/qea/object_classifier_map.rb +36 -0
  57. data/lib/ea/sources/qea/operation_builder.rb +79 -0
  58. data/lib/ea/sources/qea/package_builder.rb +44 -0
  59. data/lib/ea/sources/qea/property_builder.rb +86 -0
  60. data/lib/ea/sources/qea/relationship_builder.rb +113 -0
  61. data/lib/ea/sources/qea/stereotype_builder.rb +44 -0
  62. data/lib/ea/sources/qea/tagged_value_builder.rb +39 -0
  63. data/lib/ea/sources/qea.rb +31 -0
  64. data/lib/ea/sources/xmi/adapter.rb +62 -0
  65. data/lib/ea/sources/xmi/annotation_builder.rb +49 -0
  66. data/lib/ea/sources/xmi/classifier_builder.rb +89 -0
  67. data/lib/ea/sources/xmi/diagram_builder.rb +82 -0
  68. data/lib/ea/sources/xmi/id_normalizer.rb +27 -0
  69. data/lib/ea/sources/xmi/metadata_builder.rb +32 -0
  70. data/lib/ea/sources/xmi/operation_builder.rb +59 -0
  71. data/lib/ea/sources/xmi/package_builder.rb +73 -0
  72. data/lib/ea/sources/xmi/property_builder.rb +77 -0
  73. data/lib/ea/sources/xmi/relationship_builder.rb +142 -0
  74. data/lib/ea/sources/xmi/relationship_type_map.rb +34 -0
  75. data/lib/ea/sources/xmi/type_classifier_map.rb +33 -0
  76. data/lib/ea/sources/xmi.rb +29 -0
  77. data/lib/ea/sources.rb +15 -0
  78. data/lib/ea/spa/configuration.rb +110 -0
  79. data/lib/ea/spa/generator.rb +45 -0
  80. data/lib/ea/spa/lazy_ref.rb +18 -0
  81. data/lib/ea/spa/output/sharded_multi_file_strategy.rb +71 -0
  82. data/lib/ea/spa/output/single_file_strategy.rb +56 -0
  83. data/lib/ea/spa/output/strategy.rb +36 -0
  84. data/lib/ea/spa/output.rb +11 -0
  85. data/lib/ea/spa/package_tree.rb +15 -0
  86. data/lib/ea/spa/package_tree_node.rb +26 -0
  87. data/lib/ea/spa/projector.rb +199 -0
  88. data/lib/ea/spa/search_entry.rb +28 -0
  89. data/lib/ea/spa/search_index.rb +18 -0
  90. data/lib/ea/spa/shard.rb +19 -0
  91. data/lib/ea/spa/skeleton.rb +22 -0
  92. data/lib/ea/spa/skeleton_entry.rb +27 -0
  93. data/lib/ea/spa.rb +31 -0
  94. data/lib/ea/svg/bounds_calculator.rb +74 -0
  95. data/lib/ea/svg/connector_path.rb +138 -0
  96. data/lib/ea/svg/element_box.rb +256 -0
  97. data/lib/ea/svg/renderer.rb +77 -0
  98. data/lib/ea/svg/style_resolver.rb +72 -0
  99. data/lib/ea/svg.rb +21 -0
  100. data/lib/ea/version.rb +1 -1
  101. data/lib/ea.rb +4 -0
  102. 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
@@ -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("\"", "&quot;")
135
+ end
136
+ end
137
+ end
138
+ end