Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,10 @@ gemspec

gem "rspec"
gem "rubocop"
gem "rake"
gem "rake"
# ELK layout for the `--layout elk` diagram engine (soft dependency:
# the gemspec does not require it; Formatter::Elk guides installation)
gem "elkrb", "~> 1.0"
# Diagram output conformance gate (spec/lutaml/lml/svg_conformance_spec.rb)
gem "svg_conform"
gem "nokogiri" # svg_conform resolves its lutaml-model XML adapter through it
9 changes: 8 additions & 1 deletion lib/lutaml/lml/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ class LmlCommands < Thor

def initialize(*args)
super
@formatter = ::Lutaml::Formatter::Graphviz.new if defined?(::Lutaml::Formatter::Graphviz)
@formatter = case options[:layout]
when 'elk'
::Lutaml::Formatter::Elk.new
else
::Lutaml::Formatter::Graphviz.new
end
@out_object = $stdout
end

Expand Down Expand Up @@ -71,6 +76,8 @@ def initialize(*args)
desc: 'Node attributes (key=value,key2=value2)'
method_option :all, type: :string, aliases: '-a',
desc: 'Set attributes for graph, edge, and node'
method_option :layout, type: :string, enum: %w[graphviz elk], default: 'graphviz',
desc: 'Layout engine (elk requires the elkrb gem)'
def generate(*paths)
assert_input_paths(paths)

Expand Down
1 change: 1 addition & 0 deletions lib/lutaml/lml/formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ module Lutaml
module Formatter
autoload :Base, "lutaml/lml/formatter/base"
autoload :Graphviz, "lutaml/lml/formatter/graphviz"
autoload :Elk, "lutaml/lml/formatter/elk"
end
end
22 changes: 22 additions & 0 deletions lib/lutaml/lml/formatter/elk.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Lutaml
module Formatter
# ELK-layout diagram formatter: builds an Elkrb::Graph from the
# document, lays it out in-process (no external tools) and emits SVG.
class Elk
VALID_TYPES = %i[svg].freeze

attr_reader :type

def initialize(_attributes = {})
@type = :svg
end

def format(document)
graph = Lutaml::Layout::ElkGraphBuilder.new.build(document)
Lutaml::Layout::ElkEngine.new(input: graph).render(@type)
end
end
end
end
4 changes: 4 additions & 0 deletions lib/lutaml/lml/layout.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@ module Lutaml
module Layout
autoload :Engine, "lutaml/lml/layout/engine"
autoload :GraphVizEngine, "lutaml/lml/layout/graph_viz_engine"
autoload :ElkEngine, "lutaml/lml/layout/elk_engine"
autoload :ElkGraphBuilder, "lutaml/lml/layout/elk_graph_builder"
autoload :ElkSvgRenderer, "lutaml/lml/layout/elk_svg_renderer"
autoload :ElkSvgMarkers, "lutaml/lml/layout/elk_svg_markers"
end
end
15 changes: 15 additions & 0 deletions lib/lutaml/lml/layout/elk_engine.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module Lutaml
module Layout
# In-process layout engine backed by elkrb (pure-Ruby ELK). The input
# is an already-built Elkrb::Graph; rendering produces standalone SVG.
class ElkEngine < Engine
def render(_type)
require 'elkrb'
Elkrb::Layout::LayoutEngine.layout(@input, algorithm: 'layered')
ElkSvgRenderer.new.render(@input)
end
end
end
end
129 changes: 129 additions & 0 deletions lib/lutaml/lml/layout/elk_graph_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# frozen_string_literal: true

module Lutaml
module Layout
# Builds an Elkrb::Graph: a node per classifier (name, definition,
# members); an edge per association and parent specializer.
class ElkGraphBuilder
CHAR_WIDTH = 7.2
LINE_HEIGHT = 16.0
PADDING = 14.0
MIN_WIDTH = 120.0
COLLECTIONS = %i[classes enums primitives data_types].freeze
def build(document)
graph = elkrb_graph
add_entities(graph, document)
add_association_edges(graph, document)
add_parent_edges(graph, document)
graph
end

private

def elkrb_graph
require 'elkrb'
Elkrb::Graph::Graph.new(id: 'diagram')
rescue LoadError
raise Error, 'The elkrb gem is required for ELK layout: add `gem "elkrb"` to your Gemfile'
end

def entities(document)
([document] + Array(document.packages)).flat_map { |scope| scope_entities(scope) }.flatten.compact
end

def scope_entities(scope)
COLLECTIONS.filter_map do |collection|
next unless scope.respond_to?(collection)

scope.public_send(collection)
end
end

def node_for(entity)
lines = label_lines(entity)
Elkrb::Graph::Node.new(
id: entity_id_for(entity.name),
width: node_width(lines),
height: PADDING + lines.size * LINE_HEIGHT,
labels: lines.each_with_index.map do |line, i|
Elkrb::Graph::Label.new(text: line, x: 0, y: i * LINE_HEIGHT)
end
)
end

def label_lines(entity)
lines = [entity.name.to_s]
if entity.respond_to?(:definition) && !entity.definition.to_s.empty?
lines << entity.definition.to_s.gsub("\n", ' ')
end
Array(entity.attributes).each do |attr|
lines << attr_line(attr)
end
lines
end

def attr_line(attr)
line = "+ #{attr.name}"
line += ": #{attr.type}" if attr.type && !attr.type.to_s.empty?
line
end

def node_width(lines)
widest = lines.map(&:length).max || 0
[widest * CHAR_WIDTH + PADDING, MIN_WIDTH].max
end

def entity_id(entity)
entity.name.to_s.gsub(/[^0-9a-zA-Z_]/, '_')
end

def associations(document)
Array(document.associations)
end

def parent_edges(document)
Array(document.classes).filter_map do |klass|
next unless klass.respond_to?(:parent_class) && klass.parent_class

{ owner: klass.parent_class, member: klass.name.to_s,
member_end_type: 'generalization', name: nil }
end
end

def add_entities(graph, document)
entities(document).each { |entity| graph.children << node_for(entity) }
end

def add_association_edges(graph, document)
associations(document).each { |assoc| graph.edges << edge_for(association_spec(assoc)) }
end

def add_parent_edges(graph, document)
parent_edges(document).each { |edge| graph.edges << edge_for(edge) }
end

def edge_for(spec)
edge = elkrb_edge(spec)
edge.properties = { 'member_end_type' => spec[:member_end_type].to_s }
edge.labels = [Elkrb::Graph::Label.new(text: spec[:name].to_s)] if spec[:name]
edge
end

def elkrb_edge(spec)
require 'elkrb'
Elkrb::Graph::Edge.new(
id: "edge_#{spec[:owner]}_#{spec[:member]}",
sources: [entity_id_for(spec[:owner])],
targets: [entity_id_for(spec[:member])]
)
end

def association_spec(assoc)
{ owner: assoc.owner_end.to_s, member: assoc.member_end.to_s,
member_end_type: assoc.member_end_type.to_s, name: assoc.name }
end

def entity_id_for(name) = name.to_s.gsub(/[^0-9a-zA-Z_]/, '_')
end
end
end
45 changes: 45 additions & 0 deletions lib/lutaml/lml/layout/elk_svg_markers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

module Lutaml
module Layout
# SVG arrow-marker vocabulary and the <defs> block for ElkSvgRenderer.
# Marker ids are keyed by association member_end_type.
module ElkSvgMarkers
EDGE_STROKE = '#666666'

ARROW_MARKERS = {
'association' => 'arrow',
'aggregation' => 'diamond',
'composition' => 'diamond_filled',
'generalization' => 'triangle',
'uses' => 'arrow',
'direct' => 'arrow'
}.freeze

MARKER_SHAPES = {
'arrow' => %(<path d="M 0 0 L 10 5 L 0 10 z" fill="#{EDGE_STROKE}"/>),
'triangle' => %(<path d="M 0 0 L 12 5 L 0 10 z" fill="white" stroke="#{EDGE_STROKE}"/>),
'diamond' => %(<path d="M 0 4 L 7 0 L 14 4 L 7 8 z" fill="white" stroke="#{EDGE_STROKE}"/>),
'diamond_filled' => %(<path d="M 0 4 L 7 0 L 14 4 L 7 8 z" fill="#{EDGE_STROKE}"/>)
}.freeze

def arrow_marker(edge)
edge_type = edge.properties&.fetch('member_end_type', nil)
ARROW_MARKERS.fetch(edge_type.to_s, 'arrow')
end

def defs
MARKER_SHAPES.filter_map { |id, path| marker_def(id, path) }.join("\n")
end

private

def marker_def(id, path)
w, h = id.start_with?('diamond') ? %w[14 8] : %w[10 10]
%(<marker id="#{id}" viewBox="0 0 #{w} #{h}" refX="9" refY="4" ) +
%(markerWidth="#{w}" markerHeight="#{h}" ) +
%(orient="auto-start-reverse">#{path}</marker>)
end
end
end
end
119 changes: 119 additions & 0 deletions lib/lutaml/lml/layout/elk_svg_renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# frozen_string_literal: true

module Lutaml
module Layout
# Renders a laid-out Elkrb::Graph (nodes positioned by the layout) as
# standalone SVG: rounded-rect nodes with text-line labels and
# polyline edges with per-type arrow markers.
class ElkSvgRenderer
include ElkSvgMarkers

EDGE_STROKE = ElkSvgMarkers::EDGE_STROKE

HEADER = <<~XML
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%<width>s" height="%<height>s" viewBox="0 0 %<width>s %<height>s">
XML
NODE_FILL = '#f8f8f8'
NODE_STROKE = '#333333'

def render(graph)
@graph = graph
@out = +''
@out << format(HEADER, width: bounds_width, height: bounds_height)
@out << "<defs>\n#{defs}\n</defs>\n"
nodes
edges
@out << "</svg>\n"
@out
end

private

def nodes
@graph.children.each { |node| render_node(node) }
end

def render_node(node)
@out << %(<g transform="translate(#{node.x.to_i},#{node.y.to_i})">)
@out << node_rect(node)
node.labels.each_with_index { |label, i| @out << node_label(label, i) }
@out << '</g>'
end

def node_rect(node)
%(<rect width="#{node.width.to_i}" height="#{node.height.to_i}" rx="6" ) +
%(fill="#{NODE_FILL}" stroke="#{NODE_STROKE}"/>)
end

def node_label(label, index)
y = ((index + 0.7) * ElkGraphBuilder::LINE_HEIGHT).round(1)
weight = index.zero? ? ' font-weight="bold"' : ''
%(<text x="7" y="#{y}" font-family="Helvetica" font-size="12"#{weight}>#{escape(label.text)}</text>)
end

def edges
@graph.edges.each { |edge| render_edge(edge) }
end

def render_edge(edge)
points = edge_points(edge)
return if points.empty?

@out << edge_polyline(edge, points)
edge_label(edge, points)
end

def edge_polyline(edge, points)
pts = points.map { |p| "#{p.x.round(1)},#{p.y.round(1)}" }.join(' ')
%(<polyline fill="none" stroke="#{EDGE_STROKE}" marker-end="url(##{arrow_marker(edge)})" points="#{pts}"/>)
end

def edge_label(edge, points)
label = Array(edge.labels).first
return unless label&.text

@out << edge_label_text(label, edge_anchor(points))
end

def edge_anchor(points)
mid = points[points.size / 2]
{ x: (mid.x + 4).round(1), y: (mid.y - 4).round(1) }
end

def edge_label_text(label, anchor)
%(<text x="#{anchor[:x]}" y="#{anchor[:y]}" font-family="Helvetica" ) +
%(font-size="10" fill="#{EDGE_STROKE}">#{escape(label.text)}</text>)
end

def edge_points(edge)
sections = Array(edge.sections)
return [] if sections.empty?

points = [sections.first.start_point]
sections.each do |section|
points.concat(Array(section.bend_points))
points << section.end_point
end
points.compact
end

def bounds_width
extent(:x, :width) + 20
end

def bounds_height
extent(:y, :height) + 20
end

def extent(pos, size)
values = @graph.children.map { |n| n.public_send(pos).to_f + n.public_send(size).to_f }
[values.max || 0, 0].max.round
end

def escape(text)
text.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
end
end
end
end
Loading
Loading