serialbench 0.1.0

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/benchmark.yml +125 -0
  3. data/.github/workflows/ci.yml +74 -0
  4. data/.rspec +4 -0
  5. data/Gemfile +34 -0
  6. data/README.adoc +592 -0
  7. data/Rakefile +63 -0
  8. data/exe/serialbench +6 -0
  9. data/lib/serialbench/benchmark_runner.rb +540 -0
  10. data/lib/serialbench/chart_generator.rb +821 -0
  11. data/lib/serialbench/cli.rb +438 -0
  12. data/lib/serialbench/memory_profiler.rb +31 -0
  13. data/lib/serialbench/result_formatter.rb +182 -0
  14. data/lib/serialbench/result_merger.rb +1201 -0
  15. data/lib/serialbench/serializers/base_serializer.rb +63 -0
  16. data/lib/serialbench/serializers/json/base_json_serializer.rb +67 -0
  17. data/lib/serialbench/serializers/json/json_serializer.rb +58 -0
  18. data/lib/serialbench/serializers/json/oj_serializer.rb +102 -0
  19. data/lib/serialbench/serializers/json/yajl_serializer.rb +67 -0
  20. data/lib/serialbench/serializers/toml/base_toml_serializer.rb +76 -0
  21. data/lib/serialbench/serializers/toml/toml_rb_serializer.rb +55 -0
  22. data/lib/serialbench/serializers/toml/tomlib_serializer.rb +50 -0
  23. data/lib/serialbench/serializers/xml/base_parser.rb +69 -0
  24. data/lib/serialbench/serializers/xml/base_xml_serializer.rb +71 -0
  25. data/lib/serialbench/serializers/xml/libxml_parser.rb +98 -0
  26. data/lib/serialbench/serializers/xml/libxml_serializer.rb +127 -0
  27. data/lib/serialbench/serializers/xml/nokogiri_parser.rb +111 -0
  28. data/lib/serialbench/serializers/xml/nokogiri_serializer.rb +118 -0
  29. data/lib/serialbench/serializers/xml/oga_parser.rb +85 -0
  30. data/lib/serialbench/serializers/xml/oga_serializer.rb +125 -0
  31. data/lib/serialbench/serializers/xml/ox_parser.rb +64 -0
  32. data/lib/serialbench/serializers/xml/ox_serializer.rb +88 -0
  33. data/lib/serialbench/serializers/xml/rexml_parser.rb +129 -0
  34. data/lib/serialbench/serializers/xml/rexml_serializer.rb +121 -0
  35. data/lib/serialbench/serializers.rb +62 -0
  36. data/lib/serialbench/version.rb +5 -0
  37. data/lib/serialbench.rb +42 -0
  38. data/serialbench.gemspec +51 -0
  39. metadata +239 -0
@@ -0,0 +1,821 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Serialbench
4
+ class ChartGenerator
5
+ COLORS = {
6
+ ox: '#2E8B57', # Sea Green
7
+ nokogiri: '#4169E1', # Royal Blue
8
+ libxml: '#DC143C', # Crimson
9
+ oga: '#FF8C00', # Dark Orange
10
+ rexml: '#9932CC' # Dark Orchid
11
+ }.freeze
12
+
13
+ def initialize(results)
14
+ @results = results
15
+ end
16
+
17
+ def generate_all_charts(output_dir = 'results/charts')
18
+ FileUtils.mkdir_p(output_dir)
19
+
20
+ charts = {
21
+ 'dom_parsing_performance.svg' => generate_dom_parsing_chart,
22
+ 'sax_parsing_performance.svg' => generate_sax_parsing_chart,
23
+ 'xml_generation_performance.svg' => generate_xml_generation_chart,
24
+ 'memory_usage_comparison.svg' => generate_memory_usage_chart,
25
+ 'performance_overview.svg' => generate_overview_chart
26
+ }
27
+
28
+ charts.each do |filename, svg_content|
29
+ File.write(File.join(output_dir, filename), svg_content)
30
+ end
31
+
32
+ charts.keys
33
+ end
34
+
35
+ def generate_multi_version_charts(output_dir = 'results/charts')
36
+ FileUtils.mkdir_p(output_dir)
37
+
38
+ charts = {
39
+ 'ruby_version_comparison.svg' => generate_ruby_version_comparison_chart,
40
+ 'multi_version_overview.svg' => generate_multi_version_overview_chart,
41
+ 'dom_parsing_performance.svg' => generate_multi_version_dom_chart,
42
+ 'sax_parsing_performance.svg' => generate_multi_version_sax_chart,
43
+ 'xml_generation_performance.svg' => generate_multi_version_generation_chart
44
+ }
45
+
46
+ charts.each do |filename, svg_content|
47
+ File.write(File.join(output_dir, filename), svg_content)
48
+ end
49
+
50
+ charts.keys
51
+ end
52
+
53
+ def generate_dom_parsing_chart
54
+ data = extract_performance_data(@results[:dom_parsing])
55
+ create_bar_chart(
56
+ title: 'DOM parsing performance comparison',
57
+ subtitle: 'Time per iteration (lower is better)',
58
+ data: data,
59
+ y_label: 'Time (milliseconds)',
60
+ value_formatter: ->(v) { "#{(v * 1000).round(2)}ms" }
61
+ )
62
+ end
63
+
64
+ def generate_sax_parsing_chart
65
+ data = extract_performance_data(@results[:sax_parsing])
66
+ create_bar_chart(
67
+ title: 'SAX parsing performance comparison',
68
+ subtitle: 'Time per iteration (lower is better)',
69
+ data: data,
70
+ y_label: 'Time (milliseconds)',
71
+ value_formatter: ->(v) { "#{(v * 1000).round(2)}ms" }
72
+ )
73
+ end
74
+
75
+ def generate_xml_generation_chart
76
+ data = extract_performance_data(@results[:xml_generation])
77
+ create_bar_chart(
78
+ title: 'XML generation performance comparison',
79
+ subtitle: 'Time per iteration (lower is better)',
80
+ data: data,
81
+ y_label: 'Time (milliseconds)',
82
+ value_formatter: ->(v) { "#{(v * 1000).round(2)}ms" }
83
+ )
84
+ end
85
+
86
+ def generate_memory_usage_chart
87
+ return create_empty_chart('Memory usage data not available') unless @results[:memory_usage]
88
+
89
+ data = extract_memory_data(@results[:memory_usage])
90
+ create_bar_chart(
91
+ title: 'Memory usage comparison',
92
+ subtitle: 'Allocated memory (lower is better)',
93
+ data: data,
94
+ y_label: 'Memory (MB)',
95
+ value_formatter: ->(v) { "#{(v / 1024.0 / 1024.0).round(2)}MB" }
96
+ )
97
+ end
98
+
99
+ def generate_overview_chart
100
+ # Create a radar chart showing relative performance across all metrics
101
+ create_radar_chart
102
+ end
103
+
104
+ private
105
+
106
+ def extract_performance_data(benchmark_results)
107
+ return {} unless benchmark_results
108
+
109
+ data = {}
110
+ benchmark_results.each do |size, parsers|
111
+ data[size] = {}
112
+ parsers.each do |parser, results|
113
+ next if results[:error]
114
+
115
+ data[size][parser] = results[:time_per_iteration]
116
+ end
117
+ end
118
+ data
119
+ end
120
+
121
+ def extract_memory_data(memory_results)
122
+ return {} unless memory_results
123
+
124
+ data = {}
125
+ memory_results.each do |size, parsers|
126
+ data[size] = {}
127
+ parsers.each do |parser, results|
128
+ next if results[:error]
129
+
130
+ data[size][parser] = results[:allocated_memory]
131
+ end
132
+ end
133
+ data
134
+ end
135
+
136
+ def create_bar_chart(title:, subtitle:, data:, y_label:, value_formatter:)
137
+ return create_empty_chart('No data available') if data.empty?
138
+
139
+ width = 800
140
+ height = 600
141
+ margin = { top: 80, right: 50, bottom: 100, left: 80 }
142
+ chart_width = width - margin[:left] - margin[:right]
143
+ chart_height = height - margin[:top] - margin[:bottom]
144
+
145
+ # Prepare data for charting
146
+ sizes = data.keys
147
+ parsers = data.values.flat_map(&:keys).uniq
148
+ max_value = data.values.flat_map(&:values).compact.max || 1
149
+
150
+ # Calculate bar dimensions
151
+ group_width = chart_width / sizes.length.to_f
152
+ bar_width = (group_width * 0.8) / parsers.length.to_f
153
+ bar_spacing = group_width * 0.1
154
+
155
+ <<~SVG
156
+ <?xml version="1.0" encoding="UTF-8"?>
157
+ <svg width="#{width}" height="#{height}" xmlns="http://www.w3.org/2000/svg">
158
+ <defs>
159
+ <style>
160
+ .chart-title { font-family: Arial, sans-serif; font-size: 20px; font-weight: bold; text-anchor: middle; }
161
+ .chart-subtitle { font-family: Arial, sans-serif; font-size: 14px; text-anchor: middle; fill: #666; }
162
+ .axis-label { font-family: Arial, sans-serif; font-size: 12px; text-anchor: middle; }
163
+ .tick-label { font-family: Arial, sans-serif; font-size: 10px; text-anchor: middle; }
164
+ .legend-text { font-family: Arial, sans-serif; font-size: 11px; }
165
+ .bar { stroke: none; }
166
+ .bar:hover { opacity: 0.8; }
167
+ .grid-line { stroke: #e0e0e0; stroke-width: 1; }
168
+ .axis-line { stroke: #333; stroke-width: 2; }
169
+ </style>
170
+ </defs>
171
+
172
+ <!-- Background -->
173
+ <rect width="#{width}" height="#{height}" fill="white"/>
174
+
175
+ <!-- Title -->
176
+ <text x="#{width / 2}" y="30" class="chart-title">#{title}</text>
177
+ <text x="#{width / 2}" y="50" class="chart-subtitle">#{subtitle}</text>
178
+
179
+ <!-- Chart area -->
180
+ <g transform="translate(#{margin[:left]}, #{margin[:top]})">
181
+ <!-- Grid lines -->
182
+ #{generate_grid_lines(chart_height, max_value, 5)}
183
+
184
+ <!-- Bars -->
185
+ #{generate_bars(data, sizes, parsers, chart_height, max_value, group_width, bar_width, bar_spacing)}
186
+
187
+ <!-- Axes -->
188
+ <line x1="0" y1="#{chart_height}" x2="#{chart_width}" y2="#{chart_height}" class="axis-line"/>
189
+ <line x1="0" y1="0" x2="0" y2="#{chart_height}" class="axis-line"/>
190
+
191
+ <!-- Y-axis labels -->
192
+ #{generate_y_axis_labels(chart_height, max_value, 5, value_formatter)}
193
+
194
+ <!-- X-axis labels -->
195
+ #{generate_x_axis_labels(sizes, chart_width, chart_height)}
196
+
197
+ <!-- Y-axis title -->
198
+ <text x="-40" y="#{chart_height / 2}" class="axis-label" transform="rotate(-90, -40, #{chart_height / 2})">#{y_label}</text>
199
+ </g>
200
+
201
+ <!-- Legend -->
202
+ #{generate_legend(parsers, width, height, margin)}
203
+ </svg>
204
+ SVG
205
+ end
206
+
207
+ def generate_grid_lines(chart_height, max_value, num_ticks)
208
+ lines = []
209
+ (0..num_ticks).each do |i|
210
+ y = chart_height - (i * chart_height / num_ticks.to_f)
211
+ lines << %(<line x1="0" y1="#{y}" x2="100%" y2="#{y}" class="grid-line"/>)
212
+ end
213
+ lines.join("\n")
214
+ end
215
+
216
+ def generate_bars(data, sizes, parsers, chart_height, max_value, group_width, bar_width, bar_spacing)
217
+ bars = []
218
+
219
+ sizes.each_with_index do |size, size_index|
220
+ group_x = size_index * group_width + bar_spacing / 2
221
+
222
+ parsers.each_with_index do |parser, parser_index|
223
+ value = data[size][parser] || 0
224
+ next if value == 0
225
+
226
+ bar_height = (value / max_value.to_f) * chart_height
227
+ bar_x = group_x + parser_index * bar_width
228
+ bar_y = chart_height - bar_height
229
+
230
+ color = COLORS[parser.to_sym] || '#999999'
231
+
232
+ bars << <<~BAR
233
+ <rect x="#{bar_x}" y="#{bar_y}" width="#{bar_width}" height="#{bar_height}"
234
+ fill="#{color}" class="bar">
235
+ <title>#{parser} (#{size}): #{value}</title>
236
+ </rect>
237
+ BAR
238
+ end
239
+ end
240
+
241
+ bars.join("\n")
242
+ end
243
+
244
+ def generate_y_axis_labels(chart_height, max_value, num_ticks, value_formatter)
245
+ labels = []
246
+ (0..num_ticks).each do |i|
247
+ value = (i * max_value / num_ticks.to_f)
248
+ y = chart_height - (i * chart_height / num_ticks.to_f)
249
+ formatted_value = value_formatter.call(value)
250
+ labels << %(<text x="-10" y="#{y + 4}" class="tick-label" text-anchor="end">#{formatted_value}</text>)
251
+ end
252
+ labels.join("\n")
253
+ end
254
+
255
+ def generate_x_axis_labels(sizes, chart_width, chart_height)
256
+ labels = []
257
+ sizes.each_with_index do |size, index|
258
+ x = (index + 0.5) * (chart_width / sizes.length.to_f)
259
+ labels << %(<text x="#{x}" y="#{chart_height + 20}" class="tick-label">#{size.to_s.capitalize}</text>)
260
+ end
261
+ labels.join("\n")
262
+ end
263
+
264
+ def generate_legend(parsers, width, height, margin)
265
+ legend_x = width - margin[:right] - 150
266
+ legend_y = margin[:top] + 20
267
+
268
+ legend_items = []
269
+ parsers.each_with_index do |parser, index|
270
+ y = legend_y + index * 20
271
+ color = COLORS[parser.to_sym] || '#999999'
272
+
273
+ legend_items << <<~ITEM
274
+ <rect x="#{legend_x}" y="#{y - 8}" width="12" height="12" fill="#{color}"/>
275
+ <text x="#{legend_x + 18}" y="#{y + 2}" class="legend-text">#{parser.capitalize}</text>
276
+ ITEM
277
+ end
278
+
279
+ legend_items.join("\n")
280
+ end
281
+
282
+ def create_radar_chart
283
+ # Simplified radar chart for overview
284
+ parsers = @results[:dom_parsing]&.values&.first&.keys || []
285
+ return create_empty_chart('No data for overview') if parsers.empty?
286
+
287
+ width = 600
288
+ height = 600
289
+ center_x = width / 2
290
+ center_y = height / 2
291
+ radius = 200
292
+
293
+ # Calculate relative scores for each parser across all metrics
294
+ scores = calculate_relative_scores(parsers)
295
+
296
+ <<~SVG
297
+ <?xml version="1.0" encoding="UTF-8"?>
298
+ <svg width="#{width}" height="#{height}" xmlns="http://www.w3.org/2000/svg">
299
+ <defs>
300
+ <style>
301
+ .chart-title { font-family: Arial, sans-serif; font-size: 18px; font-weight: bold; text-anchor: middle; }
302
+ .radar-grid { stroke: #ddd; stroke-width: 1; fill: none; }
303
+ .radar-line { stroke-width: 2; fill: none; opacity: 0.8; }
304
+ .radar-area { opacity: 0.3; }
305
+ .axis-label { font-family: Arial, sans-serif; font-size: 12px; text-anchor: middle; }
306
+ </style>
307
+ </defs>
308
+
309
+ <!-- Background -->
310
+ <rect width="#{width}" height="#{height}" fill="white"/>
311
+
312
+ <!-- Title -->
313
+ <text x="#{center_x}" y="30" class="chart-title">Performance overview (relative scores)</text>
314
+
315
+ <!-- Radar grid -->
316
+ #{generate_radar_grid(center_x, center_y, radius)}
317
+
318
+ <!-- Radar areas for each parser -->
319
+ #{generate_radar_areas(scores, center_x, center_y, radius)}
320
+
321
+ <!-- Axis labels -->
322
+ #{generate_radar_labels(center_x, center_y, radius)}
323
+ </svg>
324
+ SVG
325
+ end
326
+
327
+ def generate_radar_grid(center_x, center_y, radius)
328
+ grid = []
329
+
330
+ # Concentric circles
331
+ (1..5).each do |i|
332
+ r = radius * i / 5.0
333
+ grid << %(<circle cx="#{center_x}" cy="#{center_y}" r="#{r}" class="radar-grid"/>)
334
+ end
335
+
336
+ # Radial lines
337
+ metrics = ['DOM Parse', 'SAX Parse', 'XML Gen', 'Memory']
338
+ metrics.each_with_index do |_, index|
339
+ angle = (index * 2 * Math::PI / metrics.length) - Math::PI / 2
340
+ x = center_x + radius * Math.cos(angle)
341
+ y = center_y + radius * Math.sin(angle)
342
+ grid << %(<line x1="#{center_x}" y1="#{center_y}" x2="#{x}" y2="#{y}" class="radar-grid"/>)
343
+ end
344
+
345
+ grid.join("\n")
346
+ end
347
+
348
+ def generate_radar_areas(scores, center_x, center_y, radius)
349
+ areas = []
350
+
351
+ scores.each do |parser, parser_scores|
352
+ color = COLORS[parser.to_sym] || '#999999'
353
+ points = []
354
+
355
+ parser_scores.each_with_index do |score, index|
356
+ angle = (index * 2 * Math::PI / parser_scores.length) - Math::PI / 2
357
+ r = radius * score
358
+ x = center_x + r * Math.cos(angle)
359
+ y = center_y + r * Math.sin(angle)
360
+ points << "#{x},#{y}"
361
+ end
362
+
363
+ areas << <<~AREA
364
+ <polygon points="#{points.join(' ')}" fill="#{color}" class="radar-area"/>
365
+ <polygon points="#{points.join(' ')}" stroke="#{color}" class="radar-line"/>
366
+ AREA
367
+ end
368
+
369
+ areas.join("\n")
370
+ end
371
+
372
+ def generate_radar_labels(center_x, center_y, radius)
373
+ labels = []
374
+ metrics = ['DOM parsing', 'SAX parsing', 'XML generation', 'Memory efficiency']
375
+
376
+ metrics.each_with_index do |metric, index|
377
+ angle = (index * 2 * Math::PI / metrics.length) - Math::PI / 2
378
+ x = center_x + (radius + 30) * Math.cos(angle)
379
+ y = center_y + (radius + 30) * Math.sin(angle)
380
+ labels << %(<text x="#{x}" y="#{y}" class="axis-label">#{metric}</text>)
381
+ end
382
+
383
+ labels.join("\n")
384
+ end
385
+
386
+ def calculate_relative_scores(parsers)
387
+ scores = {}
388
+
389
+ parsers.each do |parser|
390
+ # Calculate relative performance scores (higher is better)
391
+ dom_score = calculate_performance_score(parser, @results[:dom_parsing])
392
+ sax_score = calculate_performance_score(parser, @results[:sax_parsing])
393
+ gen_score = calculate_performance_score(parser, @results[:xml_generation])
394
+ mem_score = calculate_memory_score(parser, @results[:memory_usage])
395
+
396
+ scores[parser] = [dom_score, sax_score, gen_score, mem_score]
397
+ end
398
+
399
+ scores
400
+ end
401
+
402
+ def calculate_performance_score(parser, results)
403
+ return 0.5 unless results
404
+
405
+ # Average performance across all test sizes (inverted - lower time = higher score)
406
+ times = results.values.map { |r| r[parser]&.[](:time_per_iteration) }.compact
407
+ return 0.5 if times.empty?
408
+
409
+ avg_time = times.sum / times.length.to_f
410
+ all_times = results.values.flat_map { |r| r.values.map { |v| v[:time_per_iteration] } }.compact
411
+ max_time = all_times.max
412
+
413
+ return 0.5 if max_time.nil? || max_time == 0
414
+
415
+ # Invert and normalize (faster = higher score)
416
+ 1.0 - (avg_time / max_time)
417
+ end
418
+
419
+ def calculate_memory_score(parser, results)
420
+ return 0.5 unless results
421
+
422
+ # Average memory usage across all test sizes (inverted - lower memory = higher score)
423
+ memories = results.values.map { |r| r[parser]&.[](:allocated_memory) }.compact
424
+ return 0.5 if memories.empty?
425
+
426
+ avg_memory = memories.sum / memories.length.to_f
427
+ all_memories = results.values.flat_map { |r| r.values.map { |v| v[:allocated_memory] } }.compact
428
+ max_memory = all_memories.max
429
+
430
+ return 0.5 if max_memory.nil? || max_memory == 0
431
+
432
+ # Invert and normalize (less memory = higher score)
433
+ 1.0 - (avg_memory / max_memory)
434
+ end
435
+
436
+ def generate_ruby_version_comparison_chart
437
+ return create_empty_chart('Multi-version data not available') unless @results[:ruby_versions]
438
+
439
+ ruby_versions = @results[:ruby_versions].keys.sort
440
+
441
+ # Create a line chart showing performance trends across Ruby versions
442
+ create_version_trend_chart(
443
+ title: 'Performance trends across Ruby versions',
444
+ subtitle: 'Average DOM parsing performance by Ruby version',
445
+ ruby_versions: ruby_versions,
446
+ data_extractor: ->(version_data) { extract_average_dom_performance(version_data) }
447
+ )
448
+ end
449
+
450
+ def generate_multi_version_overview_chart
451
+ return create_empty_chart('Multi-version data not available') unless @results[:ruby_versions]
452
+
453
+ # Create a comprehensive overview showing all parsers across all Ruby versions
454
+ create_multi_version_heatmap
455
+ end
456
+
457
+ def generate_multi_version_dom_chart
458
+ return create_empty_chart('Multi-version data not available') unless @results[:ruby_versions]
459
+
460
+ create_multi_version_category_chart('DOM parsing', :dom_parsing)
461
+ end
462
+
463
+ def generate_multi_version_sax_chart
464
+ return create_empty_chart('Multi-version data not available') unless @results[:ruby_versions]
465
+
466
+ create_multi_version_category_chart('SAX parsing', :sax_parsing)
467
+ end
468
+
469
+ def generate_multi_version_generation_chart
470
+ return create_empty_chart('Multi-version data not available') unless @results[:ruby_versions]
471
+
472
+ create_multi_version_category_chart('XML generation', :xml_generation)
473
+ end
474
+
475
+ def create_version_trend_chart(title:, subtitle:, ruby_versions:, data_extractor:)
476
+ width = 800
477
+ height = 600
478
+ margin = { top: 80, right: 150, bottom: 100, left: 80 }
479
+ chart_width = width - margin[:left] - margin[:right]
480
+ chart_height = height - margin[:top] - margin[:bottom]
481
+
482
+ # Extract data for all parsers across versions
483
+ parser_data = {}
484
+ ruby_versions.each do |version|
485
+ version_data = @results[:ruby_versions][version]
486
+ performance = data_extractor.call(version_data)
487
+
488
+ performance.each do |parser, value|
489
+ parser_data[parser] ||= []
490
+ parser_data[parser] << { version: version, value: value }
491
+ end
492
+ end
493
+
494
+ return create_empty_chart('No trend data available') if parser_data.empty?
495
+
496
+ max_value = parser_data.values.flat_map { |data| data.map { |d| d[:value] } }.max || 1
497
+
498
+ <<~SVG
499
+ <?xml version="1.0" encoding="UTF-8"?>
500
+ <svg width="#{width}" height="#{height}" xmlns="http://www.w3.org/2000/svg">
501
+ <defs>
502
+ <style>
503
+ .chart-title { font-family: Arial, sans-serif; font-size: 20px; font-weight: bold; text-anchor: middle; }
504
+ .chart-subtitle { font-family: Arial, sans-serif; font-size: 14px; text-anchor: middle; fill: #666; }
505
+ .axis-label { font-family: Arial, sans-serif; font-size: 12px; text-anchor: middle; }
506
+ .tick-label { font-family: Arial, sans-serif; font-size: 10px; text-anchor: middle; }
507
+ .legend-text { font-family: Arial, sans-serif; font-size: 11px; }
508
+ .trend-line { stroke-width: 3; fill: none; }
509
+ .trend-point { stroke-width: 2; fill: white; }
510
+ .grid-line { stroke: #e0e0e0; stroke-width: 1; }
511
+ .axis-line { stroke: #333; stroke-width: 2; }
512
+ </style>
513
+ </defs>
514
+
515
+ <!-- Background -->
516
+ <rect width="#{width}" height="#{height}" fill="white"/>
517
+
518
+ <!-- Title -->
519
+ <text x="#{width / 2}" y="30" class="chart-title">#{title}</text>
520
+ <text x="#{width / 2}" y="50" class="chart-subtitle">#{subtitle}</text>
521
+
522
+ <!-- Chart area -->
523
+ <g transform="translate(#{margin[:left]}, #{margin[:top]})">
524
+ <!-- Grid lines -->
525
+ #{generate_trend_grid_lines(chart_width, chart_height, ruby_versions.length, max_value)}
526
+
527
+ <!-- Trend lines -->
528
+ #{generate_trend_lines(parser_data, ruby_versions, chart_width, chart_height, max_value)}
529
+
530
+ <!-- Axes -->
531
+ <line x1="0" y1="#{chart_height}" x2="#{chart_width}" y2="#{chart_height}" class="axis-line"/>
532
+ <line x1="0" y1="0" x2="0" y2="#{chart_height}" class="axis-line"/>
533
+
534
+ <!-- Axis labels -->
535
+ #{generate_trend_x_labels(ruby_versions, chart_width, chart_height)}
536
+ #{generate_trend_y_labels(chart_height, max_value)}
537
+
538
+ <!-- Axis titles -->
539
+ <text x="#{chart_width / 2}" y="#{chart_height + 50}" class="axis-label">Ruby version</text>
540
+ <text x="-50" y="#{chart_height / 2}" class="axis-label" transform="rotate(-90, -50, #{chart_height / 2})">Time (ms)</text>
541
+ </g>
542
+
543
+ <!-- Legend -->
544
+ #{generate_trend_legend(parser_data.keys, width, margin)}
545
+ </svg>
546
+ SVG
547
+ end
548
+
549
+ def create_multi_version_heatmap
550
+ ruby_versions = @results[:ruby_versions].keys.sort
551
+ parsers = extract_all_parsers_from_versions
552
+
553
+ width = 800
554
+ height = 600
555
+ margin = { top: 80, right: 50, bottom: 100, left: 100 }
556
+
557
+ <<~SVG
558
+ <?xml version="1.0" encoding="UTF-8"?>
559
+ <svg width="#{width}" height="#{height}" xmlns="http://www.w3.org/2000/svg">
560
+ <defs>
561
+ <style>
562
+ .chart-title { font-family: Arial, sans-serif; font-size: 20px; font-weight: bold; text-anchor: middle; }
563
+ .axis-label { font-family: Arial, sans-serif; font-size: 12px; text-anchor: middle; }
564
+ .heatmap-cell { stroke: white; stroke-width: 2; }
565
+ .heatmap-text { font-family: Arial, sans-serif; font-size: 10px; text-anchor: middle; fill: white; }
566
+ </style>
567
+ </defs>
568
+
569
+ <!-- Background -->
570
+ <rect width="#{width}" height="#{height}" fill="white"/>
571
+
572
+ <!-- Title -->
573
+ <text x="#{width / 2}" y="30" class="chart-title">Multi-version performance heatmap</text>
574
+ <text x="#{width / 2}" y="50" class="chart-subtitle">Relative performance across Ruby versions (darker = better)</text>
575
+
576
+ <!-- Heatmap -->
577
+ <g transform="translate(#{margin[:left]}, #{margin[:top]})">
578
+ #{generate_heatmap_cells(ruby_versions, parsers, width - margin[:left] - margin[:right], height - margin[:top] - margin[:bottom])}
579
+ #{generate_heatmap_labels(ruby_versions, parsers, width - margin[:left] - margin[:right], height - margin[:top] - margin[:bottom])}
580
+ </g>
581
+ </svg>
582
+ SVG
583
+ end
584
+
585
+ def create_multi_version_category_chart(category_name, category_key)
586
+ ruby_versions = @results[:ruby_versions].keys.sort
587
+
588
+ # Aggregate data across versions for this category
589
+ aggregated_data = {}
590
+ ruby_versions.each do |version|
591
+ version_data = @results[:ruby_versions][version]
592
+ category_data = version_data[category_key]
593
+ next unless category_data
594
+
595
+ category_data.each do |size, parsers|
596
+ aggregated_data[size] ||= {}
597
+ parsers.each do |parser, results|
598
+ next if results[:error]
599
+
600
+ aggregated_data[size]["#{parser}_#{version}"] = results[:time_per_iteration]
601
+ end
602
+ end
603
+ end
604
+
605
+ create_bar_chart(
606
+ title: "#{category_name} performance across Ruby versions",
607
+ subtitle: 'Time per iteration by Ruby version (lower is better)',
608
+ data: aggregated_data,
609
+ y_label: 'Time (milliseconds)',
610
+ value_formatter: ->(v) { "#{(v * 1000).round(2)}ms" }
611
+ )
612
+ end
613
+
614
+ def extract_average_dom_performance(version_data)
615
+ return {} unless version_data[:dom_parsing]
616
+
617
+ averages = {}
618
+ version_data[:dom_parsing].each do |size, parsers|
619
+ parsers.each do |parser, results|
620
+ next if results[:error]
621
+
622
+ averages[parser] ||= []
623
+ averages[parser] << results[:time_per_iteration]
624
+ end
625
+ end
626
+
627
+ # Calculate averages
628
+ averages.transform_values { |times| times.sum / times.length.to_f }
629
+ end
630
+
631
+ def extract_all_parsers_from_versions
632
+ parsers = []
633
+ @results[:ruby_versions].each_value do |version_data|
634
+ version_data[:dom_parsing]&.each_value do |size_data|
635
+ parsers.concat(size_data.keys)
636
+ end
637
+ end
638
+ parsers.uniq
639
+ end
640
+
641
+ def generate_trend_grid_lines(chart_width, chart_height, num_x_ticks, max_value)
642
+ lines = []
643
+
644
+ # Horizontal grid lines
645
+ (0..5).each do |i|
646
+ y = chart_height - (i * chart_height / 5.0)
647
+ lines << %(<line x1="0" y1="#{y}" x2="#{chart_width}" y2="#{y}" class="grid-line"/>)
648
+ end
649
+
650
+ # Vertical grid lines
651
+ (0...num_x_ticks).each do |i|
652
+ x = i * chart_width / (num_x_ticks - 1).to_f
653
+ lines << %(<line x1="#{x}" y1="0" x2="#{x}" y2="#{chart_height}" class="grid-line"/>)
654
+ end
655
+
656
+ lines.join("\n")
657
+ end
658
+
659
+ def generate_trend_lines(parser_data, ruby_versions, chart_width, chart_height, max_value)
660
+ lines = []
661
+
662
+ parser_data.each do |parser, data|
663
+ color = COLORS[parser.to_sym] || '#999999'
664
+ points = []
665
+
666
+ data.each do |point|
667
+ version_index = ruby_versions.index(point[:version])
668
+ next unless version_index
669
+
670
+ x = version_index * chart_width / (ruby_versions.length - 1).to_f
671
+ y = chart_height - (point[:value] / max_value * chart_height)
672
+ points << "#{x},#{y}"
673
+ end
674
+
675
+ next if points.empty?
676
+
677
+ lines << %(<polyline points="#{points.join(' ')}" stroke="#{color}" class="trend-line"/>)
678
+
679
+ # Add points
680
+ points.each do |point|
681
+ x, y = point.split(',').map(&:to_f)
682
+ lines << %(<circle cx="#{x}" cy="#{y}" r="4" stroke="#{color}" class="trend-point"/>)
683
+ end
684
+ end
685
+
686
+ lines.join("\n")
687
+ end
688
+
689
+ def generate_trend_x_labels(ruby_versions, chart_width, chart_height)
690
+ labels = []
691
+ ruby_versions.each_with_index do |version, index|
692
+ x = index * chart_width / (ruby_versions.length - 1).to_f
693
+ labels << %(<text x="#{x}" y="#{chart_height + 20}" class="tick-label">Ruby #{version}</text>)
694
+ end
695
+ labels.join("\n")
696
+ end
697
+
698
+ def generate_trend_y_labels(chart_height, max_value)
699
+ labels = []
700
+ (0..5).each do |i|
701
+ value = i * max_value / 5.0
702
+ y = chart_height - (i * chart_height / 5.0)
703
+ formatted_value = "#{(value * 1000).round(1)}ms"
704
+ labels << %(<text x="-10" y="#{y + 4}" class="tick-label" text-anchor="end">#{formatted_value}</text>)
705
+ end
706
+ labels.join("\n")
707
+ end
708
+
709
+ def generate_trend_legend(parsers, width, margin)
710
+ legend_x = width - margin[:right] - 120
711
+ legend_y = margin[:top] + 20
712
+
713
+ legend_items = []
714
+ parsers.each_with_index do |parser, index|
715
+ y = legend_y + index * 20
716
+ color = COLORS[parser.to_sym] || '#999999'
717
+
718
+ legend_items << <<~ITEM
719
+ <line x1="#{legend_x}" y1="#{y}" x2="#{legend_x + 20}" y2="#{y}" stroke="#{color}" stroke-width="3"/>
720
+ <text x="#{legend_x + 25}" y="#{y + 4}" class="legend-text">#{parser.capitalize}</text>
721
+ ITEM
722
+ end
723
+
724
+ legend_items.join("\n")
725
+ end
726
+
727
+ def generate_heatmap_cells(ruby_versions, parsers, width, height)
728
+ cell_width = width / ruby_versions.length.to_f
729
+ cell_height = height / parsers.length.to_f
730
+
731
+ cells = []
732
+ ruby_versions.each_with_index do |version, v_index|
733
+ parsers.each_with_index do |parser, p_index|
734
+ x = v_index * cell_width
735
+ y = p_index * cell_height
736
+
737
+ # Calculate performance score for this parser/version combination
738
+ score = calculate_heatmap_score(version, parser)
739
+ color_intensity = (score * 255).to_i
740
+ color = "rgb(#{255 - color_intensity}, #{255 - color_intensity}, 255)"
741
+
742
+ cells << %(<rect x="#{x}" y="#{y}" width="#{cell_width}" height="#{cell_height}" fill="#{color}" class="heatmap-cell"/>)
743
+ end
744
+ end
745
+
746
+ cells.join("\n")
747
+ end
748
+
749
+ def generate_heatmap_labels(ruby_versions, parsers, width, height)
750
+ cell_width = width / ruby_versions.length.to_f
751
+ cell_height = height / parsers.length.to_f
752
+
753
+ labels = []
754
+
755
+ # Version labels (top)
756
+ ruby_versions.each_with_index do |version, index|
757
+ x = index * cell_width + cell_width / 2
758
+ labels << %(<text x="#{x}" y="-10" class="axis-label">Ruby #{version}</text>)
759
+ end
760
+
761
+ # Parser labels (left)
762
+ parsers.each_with_index do |parser, index|
763
+ y = index * cell_height + cell_height / 2
764
+ labels << %(<text x="-10" y="#{y}" class="axis-label" text-anchor="end">#{parser.capitalize}</text>)
765
+ end
766
+
767
+ labels.join("\n")
768
+ end
769
+
770
+ def calculate_heatmap_score(version, parser)
771
+ version_data = @results[:ruby_versions][version]
772
+ return 0.5 unless version_data && version_data[:dom_parsing]
773
+
774
+ # Calculate average performance for this parser in this version
775
+ times = []
776
+ version_data[:dom_parsing].each_value do |size_data|
777
+ next unless size_data[parser] && !size_data[parser][:error]
778
+
779
+ times << size_data[parser][:time_per_iteration]
780
+ end
781
+
782
+ return 0.5 if times.empty?
783
+
784
+ avg_time = times.sum / times.length.to_f
785
+
786
+ # Get all times across all versions for normalization
787
+ all_times = []
788
+ @results[:ruby_versions].each_value do |vd|
789
+ next unless vd[:dom_parsing]
790
+
791
+ vd[:dom_parsing].each_value do |sd|
792
+ sd.each_value do |pd|
793
+ next if pd[:error]
794
+
795
+ all_times << pd[:time_per_iteration]
796
+ end
797
+ end
798
+ end
799
+
800
+ return 0.5 if all_times.empty?
801
+
802
+ max_time = all_times.max
803
+ min_time = all_times.min
804
+
805
+ return 0.5 if max_time == min_time
806
+
807
+ # Normalize (lower time = higher score)
808
+ 1.0 - (avg_time - min_time) / (max_time - min_time)
809
+ end
810
+
811
+ def create_empty_chart(message)
812
+ <<~SVG
813
+ <?xml version="1.0" encoding="UTF-8"?>
814
+ <svg width="400" height="200" xmlns="http://www.w3.org/2000/svg">
815
+ <rect width="400" height="200" fill="white" stroke="#ddd"/>
816
+ <text x="200" y="100" text-anchor="middle" font-family="Arial" font-size="14" fill="#666">#{message}</text>
817
+ </svg>
818
+ SVG
819
+ end
820
+ end
821
+ end