heatmap-builder 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.
@@ -0,0 +1,295 @@
1
+ require "date"
2
+
3
+ module HeatmapBuilder
4
+ class CalendarHeatmapBuilder
5
+ DEFAULT_OPTIONS = {
6
+ cell_size: 12,
7
+ cell_spacing: 1,
8
+ font_size: 8,
9
+ border_width: 1,
10
+ colors: %w[#ebedf0 #9be9a8 #40c463 #30a14e #216e39],
11
+ start_of_week: :monday, # :sunday, :monday, :tuesday, etc.
12
+ month_spacing: 5, # extra vertical space between months
13
+ show_month_labels: true,
14
+ show_day_labels: true,
15
+ show_outside_cells: false # show cells outside the timeframe with inactive styling
16
+ }.freeze
17
+
18
+ def initialize(scores_by_date, options = {})
19
+ @scores_by_date = scores_by_date
20
+ @options = DEFAULT_OPTIONS.merge(options)
21
+ validate_options!
22
+ @start_date = parse_date_range.first
23
+ @end_date = parse_date_range.last
24
+ end
25
+
26
+ def generate
27
+ build_svg
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :scores_by_date, :options, :start_date, :end_date
33
+
34
+ def validate_options!
35
+ raise Error, "scores_by_date must be a hash" unless scores_by_date.is_a?(Hash)
36
+ raise Error, "cell_size must be positive" unless options[:cell_size] > 0
37
+ raise Error, "font_size must be positive" unless options[:font_size] > 0
38
+ raise Error, "colors must be an array" unless options[:colors].is_a?(Array)
39
+ raise Error, "must have at least 2 colors" unless options[:colors].length >= 2
40
+
41
+ valid_start_days = %i[sunday monday tuesday wednesday thursday friday saturday]
42
+ unless valid_start_days.include?(options[:start_of_week])
43
+ raise Error, "start_of_week must be one of: #{valid_start_days.join(", ")}"
44
+ end
45
+ end
46
+
47
+ def parse_date_range
48
+ dates = scores_by_date.keys.map { |d| d.is_a?(Date) ? d : Date.parse(d.to_s) }
49
+ return [Date.today - 365, Date.today] if dates.empty?
50
+
51
+ [dates.min, dates.max]
52
+ end
53
+
54
+ def build_svg
55
+ width = svg_width
56
+ height = svg_height
57
+
58
+ svg_content = []
59
+
60
+ # Add day labels if enabled
61
+ if options[:show_day_labels]
62
+ svg_content << day_labels_svg
63
+ end
64
+
65
+ # Add month labels and cells
66
+ svg_content << calendar_cells_svg
67
+
68
+ if options[:show_month_labels]
69
+ svg_content << month_labels_svg
70
+ end
71
+
72
+ <<~SVG
73
+ <svg width="#{width}" height="#{height}" xmlns="http://www.w3.org/2000/svg">
74
+ #{svg_content.join}
75
+ </svg>
76
+ SVG
77
+ end
78
+
79
+ def calendar_cells_svg
80
+ svg = ""
81
+ current_date = calendar_start_date
82
+ week_index = 0
83
+ last_month = nil
84
+ current_x_offset = 0
85
+ calendar_end_date = calendar_end_date_with_full_weeks
86
+
87
+ while current_date <= calendar_end_date
88
+ # Check if we need to add month spacing
89
+ if current_date.month != last_month && !last_month.nil?
90
+ current_x_offset += options[:month_spacing]
91
+ end
92
+ last_month = current_date.month
93
+
94
+ # Generate week column - always fill all 7 days
95
+ 7.times do |day_index|
96
+ x = label_offset + week_index * (options[:cell_size] + options[:cell_spacing]) + current_x_offset
97
+ y = day_label_offset + day_index * (options[:cell_size] + options[:cell_spacing])
98
+
99
+ if current_date.between?(start_date, end_date)
100
+ # Active cell within the specified timeframe
101
+ score = scores_by_date[current_date] || scores_by_date[current_date.to_s] || 0
102
+ svg << cell_svg(score, x, y, false)
103
+ elsif options[:show_outside_cells]
104
+ # Inactive cell outside the specified timeframe
105
+ svg << cell_svg(0, x, y, true)
106
+ end
107
+
108
+ current_date += 1
109
+ end
110
+
111
+ week_index += 1
112
+ end
113
+
114
+ svg
115
+ end
116
+
117
+ def cell_svg(score, x, y, inactive = false)
118
+ color = score_to_color(score)
119
+
120
+ # Make inactive cells duller by reducing opacity
121
+ if inactive
122
+ color = make_color_inactive(color)
123
+ end
124
+
125
+ # Create colored square
126
+ colored_rect = "<rect x=\"#{x}\" y=\"#{y}\" width=\"#{options[:cell_size]}\" height=\"#{options[:cell_size]}\" fill=\"#{color}\"/>"
127
+
128
+ # Create border overlay
129
+ border_rect = if options[:border_width] > 0
130
+ inset = options[:border_width] / 2.0
131
+ border_x = x + inset
132
+ border_y = y + inset
133
+ border_size = options[:cell_size] - options[:border_width]
134
+ border_color = darker_color(color)
135
+ "<rect x=\"#{border_x}\" y=\"#{border_y}\" width=\"#{border_size}\" height=\"#{border_size}\" fill=\"none\" stroke=\"#{border_color}\" stroke-width=\"#{options[:border_width]}\"/>"
136
+ else
137
+ ""
138
+ end
139
+
140
+ "#{colored_rect}#{border_rect}"
141
+ end
142
+
143
+ def day_labels_svg
144
+ return "" unless options[:show_day_labels]
145
+
146
+ day_names = day_names_for_week_start
147
+ svg = ""
148
+
149
+ day_names.each_with_index do |day_name, index|
150
+ y = day_label_offset + index * (options[:cell_size] + options[:cell_spacing]) + options[:cell_size] / 2 + options[:font_size] * 0.35
151
+ svg << "<text x=\"#{options[:font_size]}\" y=\"#{y}\" text-anchor=\"middle\" font-family=\"Arial, sans-serif\" font-size=\"#{options[:font_size]}\" fill=\"#666666\">#{day_name}</text>"
152
+ end
153
+
154
+ svg
155
+ end
156
+
157
+ def month_labels_svg
158
+ return "" unless options[:show_month_labels]
159
+
160
+ svg = ""
161
+ current_date = calendar_start_date
162
+ week_index = 0
163
+ last_month = nil
164
+ displayed_months = {}
165
+ current_x_offset = 0
166
+ calendar_end_date = calendar_end_date_with_full_weeks
167
+
168
+ while current_date <= calendar_end_date
169
+ # Check if we need to add month spacing
170
+ if current_date.month != last_month && !last_month.nil?
171
+ current_x_offset += options[:month_spacing]
172
+ end
173
+
174
+ # Add month label at start of each month, but only if the month overlaps with our specified timeframe
175
+ if current_date.month != last_month && !displayed_months[current_date.month]
176
+ # Check if this month has any days within our specified timeframe
177
+ month_start = Date.new(current_date.year, current_date.month, 1)
178
+ month_end = Date.new(current_date.year, current_date.month, -1)
179
+
180
+ if month_start <= end_date && month_end >= start_date
181
+ x = label_offset + week_index * (options[:cell_size] + options[:cell_spacing]) + current_x_offset
182
+ y = options[:font_size] + 2
183
+ month_name = current_date.strftime("%b")
184
+ svg << "<text x=\"#{x}\" y=\"#{y}\" font-family=\"Arial, sans-serif\" font-size=\"#{options[:font_size]}\" fill=\"#666666\">#{month_name}</text>"
185
+ end
186
+
187
+ displayed_months[current_date.month] = true
188
+ end
189
+
190
+ last_month = current_date.month
191
+ current_date += 7
192
+ week_index += 1
193
+ end
194
+
195
+ svg
196
+ end
197
+
198
+ def score_to_color(score)
199
+ return options[:colors].first if score == 0
200
+
201
+ max_color_index = options[:colors].length - 1
202
+ color_index = 1 + (score - 1) % max_color_index
203
+ options[:colors][color_index]
204
+ end
205
+
206
+ def darker_color(hex_color)
207
+ hex = hex_color.delete("#")
208
+ r = hex[0..1].to_i(16)
209
+ g = hex[2..3].to_i(16)
210
+ b = hex[4..5].to_i(16)
211
+
212
+ # Much more subtle border - only 10% darker instead of 30%
213
+ r = (r * 0.9).to_i
214
+ g = (g * 0.9).to_i
215
+ b = (b * 0.9).to_i
216
+
217
+ "#%02x%02x%02x" % [r, g, b]
218
+ end
219
+
220
+ def calendar_start_date
221
+ # Find the start of the week containing start_date
222
+ days_back = (start_date.wday - week_start_wday) % 7
223
+ start_date - days_back
224
+ end
225
+
226
+ def calendar_end_date_with_full_weeks
227
+ # Find the end of the week containing end_date
228
+ days_forward = (6 - (end_date.wday - week_start_wday)) % 7
229
+ end_date + days_forward
230
+ end
231
+
232
+ def make_color_inactive(hex_color)
233
+ # Make color duller by reducing saturation and increasing lightness
234
+ hex = hex_color.delete("#")
235
+ r = hex[0..1].to_i(16)
236
+ g = hex[2..3].to_i(16)
237
+ b = hex[4..5].to_i(16)
238
+
239
+ # Blend with light gray to make it appear duller/inactive
240
+ gray = 230
241
+ mix_ratio = 0.6 # 60% original color, 40% gray
242
+
243
+ r = (r * mix_ratio + gray * (1 - mix_ratio)).to_i
244
+ g = (g * mix_ratio + gray * (1 - mix_ratio)).to_i
245
+ b = (b * mix_ratio + gray * (1 - mix_ratio)).to_i
246
+
247
+ "#%02x%02x%02x" % [r, g, b]
248
+ end
249
+
250
+ def week_start_wday
251
+ case options[:start_of_week]
252
+ when :sunday then 0
253
+ when :monday then 1
254
+ when :tuesday then 2
255
+ when :wednesday then 3
256
+ when :thursday then 4
257
+ when :friday then 5
258
+ when :saturday then 6
259
+ end
260
+ end
261
+
262
+ def day_names_for_week_start
263
+ all_days = %w[S M T W T F S]
264
+ start_index = week_start_wday
265
+ all_days.rotate(start_index)
266
+ end
267
+
268
+ def month_spacing_weeks
269
+ options[:month_spacing] / (options[:cell_size] + options[:cell_spacing])
270
+ end
271
+
272
+ def label_offset
273
+ options[:show_day_labels] ? options[:font_size] * 2 : 0
274
+ end
275
+
276
+ def day_label_offset
277
+ options[:show_month_labels] ? options[:font_size] + 5 : 0
278
+ end
279
+
280
+ def svg_width
281
+ weeks_count = ((calendar_end_date_with_full_weeks - calendar_start_date) / 7).ceil
282
+ month_spacing_total = (months_in_range - 1) * options[:month_spacing]
283
+
284
+ label_offset + weeks_count * (options[:cell_size] + options[:cell_spacing]) + month_spacing_total
285
+ end
286
+
287
+ def svg_height
288
+ day_label_offset + 7 * (options[:cell_size] + options[:cell_spacing])
289
+ end
290
+
291
+ def months_in_range
292
+ ((end_date.year - start_date.year) * 12 + end_date.month - start_date.month + 1)
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,128 @@
1
+ module HeatmapBuilder
2
+ class LinearHeatmapBuilder
3
+ DEFAULT_OPTIONS = {
4
+ cell_size: 10,
5
+ cell_spacing: 1,
6
+ font_size: 8,
7
+ cells_per_row: 7,
8
+ border_width: 1,
9
+ colors: %w[#ebedf0 #9be9a8 #40c463 #30a14e #216e39]
10
+ }.freeze
11
+
12
+ def initialize(scores, options = {})
13
+ @scores = scores
14
+ @options = DEFAULT_OPTIONS.merge(options)
15
+ validate_options!
16
+ end
17
+
18
+ def generate
19
+ build_svg
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :scores, :options
25
+
26
+ def validate_options!
27
+ raise Error, "scores must be an array" unless scores.is_a?(Array)
28
+ raise Error, "cell_size must be positive" unless options[:cell_size] > 0
29
+ raise Error, "font_size must be positive" unless options[:font_size] > 0
30
+ raise Error, "cells_per_row must be positive" unless options[:cells_per_row] > 0
31
+ raise Error, "colors must be an array" unless options[:colors].is_a?(Array)
32
+ raise Error, "must have at least 2 colors" unless options[:colors].length >= 2
33
+ end
34
+
35
+ def build_svg
36
+ width = svg_width
37
+ height = svg_height
38
+
39
+ svg_content = scores.first(options[:cells_per_row]).map.with_index do |score, index|
40
+ cell_svg(score, index)
41
+ end.join
42
+
43
+ <<~SVG
44
+ <svg width="#{width}" height="#{height}" xmlns="http://www.w3.org/2000/svg">
45
+ #{svg_content}
46
+ </svg>
47
+ SVG
48
+ end
49
+
50
+ def cell_svg(score, index)
51
+ # Calculate x position - each cell takes cell_size + spacing
52
+ x = index * (options[:cell_size] + options[:cell_spacing])
53
+ y = 0
54
+
55
+ color = score_to_color(score)
56
+
57
+ # Create colored square (full cell size)
58
+ colored_rect = "<rect x=\"#{x}\" y=\"#{y}\" width=\"#{options[:cell_size]}\" height=\"#{options[:cell_size]}\" fill=\"#{color}\"/>"
59
+
60
+ # Create border overlay completely inside the colored square
61
+ border_rect = if options[:border_width] > 0
62
+ # Inset the border rect by half the stroke width so stroke stays inside
63
+ inset = options[:border_width] / 2.0
64
+ border_x = x + inset
65
+ border_y = y + inset
66
+ border_size = options[:cell_size] - options[:border_width]
67
+ border_color = darker_color(color)
68
+ "<rect x=\"#{border_x}\" y=\"#{border_y}\" width=\"#{border_size}\" height=\"#{border_size}\" fill=\"none\" stroke=\"#{border_color}\" stroke-width=\"#{options[:border_width]}\"/>"
69
+ else
70
+ ""
71
+ end
72
+
73
+ # Calculate text position (center of cell)
74
+ text_x = x + options[:cell_size] / 2
75
+ # For better vertical centering: cell center + font_size * 0.35 (accounts for baseline)
76
+ text_y = y + options[:cell_size] / 2 + options[:font_size] * 0.35
77
+
78
+ text_element = "<text x=\"#{text_x}\" y=\"#{text_y}\" text-anchor=\"middle\" font-family=\"Arial, sans-serif\" font-size=\"#{options[:font_size]}\" fill=\"#{text_color(color)}\">#{score}</text>"
79
+
80
+ "#{colored_rect}#{border_rect}#{text_element}"
81
+ end
82
+
83
+ def score_to_color(score)
84
+ return options[:colors].first if score == 0
85
+
86
+ max_color_index = options[:colors].length - 1
87
+ # Map score to color index, ensuring we don't exceed available colors
88
+ color_index = 1 + (score - 1) % max_color_index
89
+ options[:colors][color_index]
90
+ end
91
+
92
+ def text_color(background_color)
93
+ # Simple contrast check - if dark background, use white text
94
+ hex = background_color.delete("#")
95
+ r = hex[0..1].to_i(16)
96
+ g = hex[2..3].to_i(16)
97
+ b = hex[4..5].to_i(16)
98
+ brightness = (r * 299 + g * 587 + b * 114) / 1000
99
+ (brightness > 128) ? "#000000" : "#ffffff"
100
+ end
101
+
102
+ def svg_width
103
+ options[:cells_per_row] * options[:cell_size] +
104
+ (options[:cells_per_row] - 1) * options[:cell_spacing]
105
+ end
106
+
107
+ def svg_height
108
+ options[:cell_size]
109
+ end
110
+
111
+ def darker_color(hex_color)
112
+ # Remove # if present
113
+ hex = hex_color.delete("#")
114
+
115
+ # Extract RGB values
116
+ r = hex[0..1].to_i(16)
117
+ g = hex[2..3].to_i(16)
118
+ b = hex[4..5].to_i(16)
119
+
120
+ # Make 30% darker
121
+ r = (r * 0.7).to_i
122
+ g = (g * 0.7).to_i
123
+ b = (b * 0.7).to_i
124
+
125
+ "#%02x%02x%02x" % [r, g, b]
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,3 @@
1
+ module HeatmapBuilder
2
+ VERSION = "0.1.0"
3
+ end
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.3"
metadata ADDED
@@ -0,0 +1,128 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: heatmap-builder
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Alex Musayev
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-09-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: standard
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.0'
69
+ description: A Ruby gem that generates embeddable GitHub-style heatmap visualization
70
+ in SVG format.
71
+ email:
72
+ - alex.musayev@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - ".github/workflows/ci.yml"
78
+ - ".gitignore"
79
+ - ".standard.yml"
80
+ - CHANGELOG.md
81
+ - CODE_OF_CONDUCT.md
82
+ - Gemfile
83
+ - Gemfile.lock
84
+ - LICENSE.txt
85
+ - README.md
86
+ - Rakefile
87
+ - bin/console
88
+ - bin/setup
89
+ - examples/calendar_github_style.svg
90
+ - examples/calendar_sunday_start.svg
91
+ - examples/calendar_with_outside_cells.svg
92
+ - examples/generate_samples.rb
93
+ - examples/large_cells.svg
94
+ - examples/weekly_progress.svg
95
+ - heatmap-builder.gemspec
96
+ - lib/heatmap-builder.rb
97
+ - lib/heatmap_builder/calendar_heatmap_builder.rb
98
+ - lib/heatmap_builder/linear_heatmap_builder.rb
99
+ - lib/heatmap_builder/version.rb
100
+ - mise.toml
101
+ homepage: https://github.com/dreikanter/heatmap-builder
102
+ licenses:
103
+ - MIT
104
+ metadata:
105
+ allowed_push_host: https://rubygems.org
106
+ homepage_uri: https://github.com/dreikanter/heatmap-builder
107
+ source_code_uri: https://github.com/dreikanter/heatmap-builder.git
108
+ changelog_uri: https://github.com/dreikanter/heatmap-builder/blob/main/CHANGELOG.md
109
+ post_install_message:
110
+ rdoc_options: []
111
+ require_paths:
112
+ - lib
113
+ required_ruby_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '3.0'
118
+ required_rubygems_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ requirements: []
124
+ rubygems_version: 3.5.22
125
+ signing_key:
126
+ specification_version: 4
127
+ summary: Generate SVG heatmap visualizations
128
+ test_files: []