Skip to content

Commit 77d38c1

Browse files
committed
Align annotations vertically with their section labels and deduplicate duplicates
1 parent 597ac7e commit 77d38c1

File tree

10 files changed

+137
-8
lines changed

10 files changed

+137
-8
lines changed

examples/basic_usage.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55

66
aa_text = <<~'AA'
77
┌─────────────────────────────────────┐
8-
│ REPL (UI) │
8+
│ REPL (UI) │ ← ユーザーとの対話
99
├─────────────────────────────────────┤
10-
│ Command Parser │
10+
│ Command Parser │ ← コマンド解析
1111
├─────────────────────────────────────┤
12-
│ Database │
12+
│ Database │ ← コア機能
1313
│ ┌─────────────────────────────┐ │
14-
│ │ StringHashMap │ │
14+
│ │ StringHashMap │ │ ← データ保存
1515
│ └─────────────────────────────┘ │
1616
└─────────────────────────────────────┘
1717
AA

lib/aa2img/ast/box.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def all_labels
3636
def all_annotations
3737
own = annotations || []
3838
from_sections = (sections || []).flat_map { |s| s.annotation ? [s.annotation] : [] }
39-
own + from_sections
39+
(own + from_sections).uniq { |ann| [ann.row, ann.arrow_col, ann.text] }
4040
end
4141
end
4242
end

lib/aa2img/layout/calculator.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def annotation_position(annotation, box)
5959
rect = box_rect(box)
6060
{
6161
x: rect[:x] + rect[:width] + 15,
62-
y: to_px(annotation.row, 0)[:y] + @metrics.cell_height * 0.7
62+
y: to_px(annotation.row, 0)[:y] + @metrics.cell_height * 0.5
6363
}
6464
end
6565

lib/aa2img/parser.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def parse
2828
section.labels = text_extractor.extract_labels(@grid, section, box, children: box.children)
2929
end
3030

31-
box.annotations = text_extractor.extract_annotations(@grid, box)
31+
box.annotations = text_extractor.extract_annotations(@grid, box, children: box.children)
3232

3333
box.annotations.each do |ann|
3434
matching_section = sections.find { |s| ann.row > s.top && ann.row < s.bottom }

lib/aa2img/parser/text_extractor.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ def extract_labels(grid, section, box, children: [])
2828
labels
2929
end
3030

31-
def extract_annotations(grid, box)
31+
def extract_annotations(grid, box, children: [])
3232
annotations = []
3333
(box.top..box.bottom).each do |row|
34+
next if row_inside_child?(row, children)
35+
3436
right_text = extract_row_text(grid, row, box.right + 1, grid.width - 1)
3537
if (match = right_text.match(ANNOTATION_PATTERN))
3638
annotations << AST::Annotation.new(

lib/aa2img/renderer/svg_renderer.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,11 @@ def render_box(xml, box, calc, theme, depth)
113113

114114
box.all_annotations.each do |ann|
115115
pos = calc.annotation_position(ann, box)
116+
label_y = aligned_annotation_y(ann, box, calc)
117+
pos[:y] = label_y if label_y
116118
xml.text_(
117119
x: pos[:x], y: pos[:y],
120+
"dominant-baseline": "middle",
118121
"font-family": theme.font_family,
119122
"font-size": theme.annotation_font_size,
120123
fill: theme.annotation_color,
@@ -143,6 +146,34 @@ def render_edge(xml, edge, calc, theme)
143146

144147
xml.line(**attrs)
145148
end
149+
150+
def aligned_annotation_y(annotation, box, calc)
151+
section = matching_annotation_section(annotation, box)
152+
return nil unless section
153+
return nil if (section.labels || []).empty?
154+
155+
label = section.labels.first
156+
calc.label_position(
157+
label, box,
158+
section: section,
159+
valign: @valign,
160+
label_index: 0,
161+
label_count: section.labels.size,
162+
children: box.children || []
163+
)[:y]
164+
end
165+
166+
def matching_annotation_section(annotation, box)
167+
key = annotation_key(annotation)
168+
(box.sections || []).find do |section|
169+
ann = section.annotation
170+
ann && annotation_key(ann) == key
171+
end
172+
end
173+
174+
def annotation_key(annotation)
175+
[annotation.row, annotation.arrow_col, annotation.text]
176+
end
146177
end
147178
end
148179
end

spec/ast/box_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe AA2img::AST::Box do
4+
it "deduplicates annotations collected from both box and section" do
5+
annotation = AA2img::AST::Annotation.new(text: "note", row: 1, arrow_col: 10)
6+
section = AA2img::AST::Section.new(top: 0, bottom: 2, annotation: annotation)
7+
8+
box = described_class.new(
9+
top: 0, left: 0, bottom: 2, right: 10,
10+
annotations: [annotation],
11+
sections: [section]
12+
)
13+
14+
expect(box.all_annotations).to eq([annotation])
15+
end
16+
end

spec/layout/calculator_spec.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,18 @@ def make_label(text, row:)
100100
end
101101
end
102102
end
103+
104+
describe "#annotation_position" do
105+
let(:scene) { build_scene }
106+
let(:calc) { described_class.new(scene, theme) }
107+
let(:box) { AA2img::AST::Box.new(top: 0, left: 0, bottom: 4, right: 20) }
108+
let(:annotation) { AA2img::AST::Annotation.new(text: "note", row: 2, arrow_col: 21) }
109+
110+
it "centers annotation vertically within its row" do
111+
pos = calc.annotation_position(annotation, box)
112+
expected_center_y = padding + annotation.row * cell_height + cell_height * 0.5
113+
114+
expect(pos[:y]).to eq(expected_center_y)
115+
end
116+
end
103117
end

spec/parser/text_extractor_spec.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,26 @@
2020
expect(annotations.first.text).to eq("comment")
2121
end
2222

23+
it "ignores child-row annotations when extracting parent annotations" do
24+
text = <<~DIAGRAM
25+
┌─────────────────────────────────────┐
26+
│ Database │
27+
│ ┌─────────────────────────────┐ │
28+
│ │ StringHashMap │ │ ← データ保存
29+
│ └─────────────────────────────┘ │
30+
└─────────────────────────────────────┘
31+
DIAGRAM
32+
grid = AA2img::Grid.new(text)
33+
34+
corner_detector = AA2img::Parser::CornerDetector.new
35+
corners = corner_detector.detect(grid)
36+
boxes = AA2img::Parser::BoxBuilder.new.detect(grid, corners)
37+
parent = AA2img::Parser::NestingAnalyzer.new.analyze(boxes).first
38+
39+
annotations = extractor.extract_annotations(grid, parent, children: parent.children)
40+
expect(annotations).to be_empty
41+
end
42+
2343
it "returns empty for no text" do
2444
grid = AA2img::Grid.new("┌──┐\n│ │\n└──┘")
2545
box = AA2img::AST::Box.new(top: 0, left: 0, bottom: 2, right: 3)

spec/renderer/svg_renderer_spec.rb

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,52 @@ def parse_fixture(name)
3939
expect(rects.size).to be >= 3
4040
end
4141

42+
it "renders layered architecture annotations once each" do
43+
scene = parse_fixture("layered_architecture.txt")
44+
svg = renderer.render(scene, theme: theme)
45+
doc = Nokogiri::XML(svg)
46+
47+
annotations = doc.css("text").map(&:text).select { |text| text.start_with?("← ") }
48+
counts = annotations.each_with_object(Hash.new(0)) { |text, tally| tally[text] += 1 }
49+
50+
expect(counts["← ユーザーとの対話"]).to eq(1)
51+
expect(counts["← コマンド解析"]).to eq(1)
52+
expect(counts["← コア機能"]).to eq(1)
53+
expect(counts["← データ保存"]).to eq(1)
54+
end
55+
56+
it "renders annotations with middle baseline alignment" do
57+
scene = parse_fixture("layered_architecture.txt")
58+
svg = renderer.render(scene, theme: theme)
59+
doc = Nokogiri::XML(svg)
60+
61+
annotation_texts = doc.css("text").select { |node| node.text.start_with?("← ") }
62+
expect(annotation_texts).not_to be_empty
63+
expect(annotation_texts.map { |node| node["dominant-baseline"] }.uniq).to eq(["middle"])
64+
end
65+
66+
it "aligns annotation y with corresponding labels" do
67+
scene = parse_fixture("layered_architecture.txt")
68+
svg = renderer.render(scene, theme: theme, valign: :center)
69+
doc = Nokogiri::XML(svg)
70+
71+
pairs = {
72+
"REPL (UI)" => "← ユーザーとの対話",
73+
"Command Parser" => "← コマンド解析",
74+
"Database" => "← コア機能",
75+
"StringHashMap" => "← データ保存"
76+
}
77+
78+
pairs.each do |label_text, annotation_text|
79+
label_node = doc.css("text").find { |node| node.text == label_text }
80+
annotation_node = doc.css("text").find { |node| node.text == annotation_text }
81+
82+
expect(label_node).not_to be_nil
83+
expect(annotation_node).not_to be_nil
84+
expect(annotation_node["y"].to_f).to be_within(0.1).of(label_node["y"].to_f)
85+
end
86+
end
87+
4288
it "includes section divider lines" do
4389
scene = parse_fixture("sectioned_box.txt")
4490
svg = renderer.render(scene, theme: theme)

0 commit comments

Comments
 (0)