heatmap-builder 0.2.0 → 0.4.3

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -5
  3. data/CHANGELOG.md +70 -3
  4. data/Gemfile.lock +27 -24
  5. data/README.md +105 -135
  6. data/bin/generate_examples +42 -99
  7. data/examples/calendar_blue_ocean.svg +1 -1
  8. data/examples/calendar_cell_borders.svg +1 -0
  9. data/examples/calendar_default.svg +1 -1
  10. data/examples/calendar_github_style.svg +1 -1
  11. data/examples/calendar_month_spacing_rounded.svg +1 -0
  12. data/examples/calendar_no_borders.svg +1 -0
  13. data/examples/calendar_purple_vibes.svg +1 -1
  14. data/examples/calendar_red_to_green.svg +1 -1
  15. data/examples/calendar_rounded_corners.svg +1 -1
  16. data/examples/calendar_rounded_corners_max_radius.svg +1 -1
  17. data/examples/calendar_sunday_start.svg +1 -1
  18. data/examples/calendar_warm_sunset.svg +1 -1
  19. data/examples/calendar_with_outside_cells.svg +1 -1
  20. data/heatmap-builder.gemspec +4 -4
  21. data/lib/heatmap-builder.rb +5 -31
  22. data/lib/heatmap_builder/calendar.rb +426 -0
  23. data/lib/heatmap_builder/color_helpers.rb +121 -141
  24. data/lib/heatmap_builder/svg_helpers.rb +11 -4
  25. data/lib/heatmap_builder/value_conversion.rb +9 -5
  26. data/lib/heatmap_builder/version.rb +1 -1
  27. metadata +13 -22
  28. data/examples/large_cells.svg +0 -1
  29. data/examples/linear_blue_ocean.svg +0 -1
  30. data/examples/linear_github_green.svg +0 -1
  31. data/examples/linear_neon_gradient.svg +0 -1
  32. data/examples/linear_purple_vibes.svg +0 -1
  33. data/examples/linear_red_to_green.svg +0 -1
  34. data/examples/linear_rounded_corners.svg +0 -1
  35. data/examples/linear_rounded_corners_max_radius.svg +0 -1
  36. data/examples/linear_warm_sunset.svg +0 -1
  37. data/examples/weekly_progress.svg +0 -1
  38. data/lib/heatmap_builder/builder.rb +0 -100
  39. data/lib/heatmap_builder/calendar_heatmap_builder.rb +0 -310
  40. data/lib/heatmap_builder/linear_heatmap_builder.rb +0 -74
@@ -1,100 +0,0 @@
1
- require_relative "svg_helpers"
2
- require_relative "color_helpers"
3
-
4
- module HeatmapBuilder
5
- class Builder
6
- include SvgHelpers
7
- include ColorHelpers
8
-
9
- GITHUB_GREEN = %w[#ebedf0 #9be9a8 #40c463 #30a14e #216e39].freeze
10
- BLUE_OCEAN = %w[#f0f9ff #bae6fd #7dd3fc #38bdf8 #0ea5e9].freeze
11
- WARM_SUNSET = %w[#fef3e2 #fed7aa #fdba74 #fb923c #f97316].freeze
12
- PURPLE_VIBES = %w[#f3e8ff #d8b4fe #c084fc #a855f7 #9333ea].freeze
13
- RED_TO_GREEN = %w[#f5f5f5 #ff9999 #f7ad6a #d2c768 #99dd99].freeze
14
-
15
- DEFAULT_OPTIONS = {
16
- cell_size: 10,
17
- cell_spacing: 1,
18
- font_size: 8,
19
- border_width: 1,
20
- corner_radius: 0,
21
- colors: GITHUB_GREEN,
22
- text_color: "#000000"
23
- }.freeze
24
-
25
- def initialize(scores: nil, values: nil, **options)
26
- @scores = scores
27
- @values = values
28
- @options = default_options.merge(options)
29
- validate_options!
30
- normalize_options!
31
- end
32
-
33
- def build
34
- raise NotImplementedError, "Subclasses must implement #build"
35
- end
36
-
37
- private
38
-
39
- attr_reader :scores, :values, :options
40
-
41
- def normalize_options!
42
- max_radius = (options[:cell_size] / 2.0).floor
43
- @options[:corner_radius] = options[:corner_radius].clamp(0, max_radius)
44
- end
45
-
46
- # Override in subclasses to add specific validations by calling super first
47
- def validate_options!
48
- raise Error, "cell_size must be positive" unless options[:cell_size].positive?
49
- raise Error, "font_size must be positive" unless options[:font_size].positive?
50
- validate_colors_option!
51
- validate_scores_or_values!
52
- validate_value_boundaries! if values
53
- end
54
-
55
- def validate_scores_or_values!
56
- if scores && values
57
- raise Error, "cannot provide both scores and values"
58
- end
59
-
60
- unless scores || values
61
- raise Error, "must provide either scores or values"
62
- end
63
- end
64
-
65
- def validate_value_boundaries!
66
- return unless options[:value_min] && options[:value_max]
67
- return unless options[:value_min] > options[:value_max]
68
- raise Error, "value_min must be less than or equal to value_max"
69
- end
70
-
71
- def validate_colors_option!
72
- colors = options[:colors]
73
-
74
- if colors.is_a?(Array)
75
- raise Error, "must have at least 2 colors" unless colors.length >= 2
76
- elsif colors.is_a?(Hash)
77
- raise Error, "colors hash must have from, to, and steps keys" unless colors.key?(:from) && colors.key?(:to) && colors.key?(:steps)
78
- raise Error, "steps must be a number" unless colors[:steps].is_a?(Integer)
79
- raise Error, "steps must be at least 2" unless colors[:steps] >= 2
80
- else
81
- raise Error, "colors must be an array or hash with from/to/steps"
82
- end
83
- end
84
-
85
- def default_options
86
- DEFAULT_OPTIONS
87
- end
88
-
89
- def color_palette
90
- @color_palette ||= begin
91
- colors_option = options[:colors]
92
- if colors_option.is_a?(Hash)
93
- generate_color_palette(colors_option[:from], colors_option[:to], colors_option[:steps])
94
- else
95
- colors_option
96
- end
97
- end
98
- end
99
- end
100
- end
@@ -1,310 +0,0 @@
1
- require "date"
2
- require_relative "builder"
3
- require_relative "value_conversion"
4
-
5
- module HeatmapBuilder
6
- class CalendarHeatmapBuilder < Builder
7
- include ValueConversion
8
-
9
- VALID_START_DAYS = %i[sunday monday tuesday wednesday thursday friday saturday].freeze
10
-
11
- WEEK_START_WDAY = {
12
- sunday: 0,
13
- monday: 1,
14
- tuesday: 2,
15
- wednesday: 3,
16
- thursday: 4,
17
- friday: 5,
18
- saturday: 6
19
- }.freeze
20
-
21
- def build
22
- svg_content = []
23
-
24
- if options[:show_day_labels]
25
- svg_content << day_labels_svg
26
- end
27
-
28
- svg_content << calendar_cells_svg
29
-
30
- if options[:show_month_labels]
31
- svg_content << month_labels_svg
32
- end
33
-
34
- weeks_count = ((calendar_end_date_with_full_weeks - calendar_start_date) / 7).ceil
35
- month_spacing_total = (months_in_range - 1) * options[:month_spacing]
36
-
37
- cell_size_with_spacing = options[:cell_size] + options[:cell_spacing]
38
- width = dow_label_offset + weeks_count * cell_size_with_spacing + month_spacing_total
39
- height = month_label_offset + 7 * cell_size_with_spacing
40
-
41
- svg_container(width: width, height: height) { svg_content.join }
42
- end
43
-
44
- private
45
-
46
- def start_date
47
- @start_date ||= parsed_date_range.first
48
- end
49
-
50
- def end_date
51
- @end_date ||= parsed_date_range.last
52
- end
53
-
54
- def validate_options!
55
- super
56
-
57
- raise Error, "scores must be a hash" if scores && !scores.is_a?(Hash)
58
- raise Error, "values must be a hash" if values && !values.is_a?(Hash)
59
-
60
- unless VALID_START_DAYS.include?(options[:start_of_week])
61
- raise Error, "start_of_week must be one of: #{VALID_START_DAYS.join(", ")}"
62
- end
63
- end
64
-
65
- def scores_by_date
66
- @scores_by_date ||= if scores
67
- scores
68
- else
69
- result = {}
70
- current_date = start_date
71
-
72
- while current_date <= end_date
73
- value = values[current_date] || values[current_date.to_s]
74
- result[current_date] = convert_value_to_score(value, date: current_date)
75
- current_date += 1
76
- end
77
-
78
- result
79
- end
80
- end
81
-
82
- def calculated_min_from_values
83
- non_nil_values = values.values.compact
84
- non_nil_values.empty? ? 0 : non_nil_values.min
85
- end
86
-
87
- def calculated_max_from_values
88
- non_nil_values = values.values.compact
89
- non_nil_values.empty? ? 0 : non_nil_values.max
90
- end
91
-
92
- def parsed_date_range
93
- @parsed_date_range ||= begin
94
- data_keys = (scores || values || {}).keys
95
- dates = data_keys.map { |d| d.is_a?(Date) ? d : Date.parse(d.to_s) }
96
- return [Date.today - 365, Date.today] if dates.empty?
97
-
98
- [dates.min, dates.max]
99
- end
100
- end
101
-
102
- def calendar_cells_svg
103
- svg = ""
104
- current_date = calendar_start_date
105
- week_index = 0
106
- last_month = nil
107
- current_x_offset = 0
108
- calendar_end_date = calendar_end_date_with_full_weeks
109
-
110
- while current_date <= calendar_end_date
111
- # Check if we need to add month spacing
112
- if current_date.month != last_month && !last_month.nil?
113
- current_x_offset += options[:month_spacing]
114
- end
115
- last_month = current_date.month
116
-
117
- # Generate week column - always fill all 7 days
118
- 7.times do |day_index|
119
- x = dow_label_offset + week_index * (options[:cell_size] + options[:cell_spacing]) + current_x_offset
120
- y = month_label_offset + day_index * (options[:cell_size] + options[:cell_spacing])
121
-
122
- if current_date.between?(start_date, end_date)
123
- # Active cell within the specified timeframe
124
- score = scores_by_date[current_date] || scores_by_date[current_date.to_s] || 0
125
- svg << cell_svg(score, x, y, false)
126
- elsif options[:show_outside_cells]
127
- # Inactive cell outside the specified timeframe
128
- svg << cell_svg(0, x, y, true)
129
- end
130
-
131
- current_date += 1
132
- end
133
-
134
- week_index += 1
135
- end
136
-
137
- svg
138
- end
139
-
140
- def cell_svg(score, x, y, inactive = false)
141
- color = score_to_color(score, colors: color_palette)
142
-
143
- if inactive
144
- color = make_color_inactive(color)
145
- end
146
-
147
- colored_rect = svg_rect(
148
- x: x, y: y,
149
- width: options[:cell_size], height: options[:cell_size],
150
- rx: options[:corner_radius],
151
- fill: color
152
- )
153
-
154
- border_rect = cell_border(
155
- x, y, color,
156
- cell_size: options[:cell_size],
157
- border_width: options[:border_width],
158
- corner_radius: options[:corner_radius]
159
- )
160
-
161
- "#{colored_rect}#{border_rect}"
162
- end
163
-
164
- def day_labels_svg
165
- return "" unless options[:show_day_labels]
166
-
167
- day_names = day_names_for_week_start
168
- svg = ""
169
-
170
- day_names.each_with_index do |day_name, index|
171
- y = month_label_offset + index * (options[:cell_size] + options[:cell_spacing]) + options[:cell_size] / 2 + options[:font_size] * 0.35
172
- svg << svg_text(
173
- day_name,
174
- x: options[:font_size], y: y,
175
- font_size: options[:font_size], fill: "#666666"
176
- )
177
- end
178
-
179
- svg
180
- end
181
-
182
- def month_labels_svg
183
- return "" unless options[:show_month_labels]
184
-
185
- svg = ""
186
- last_month = nil
187
-
188
- each_week do |current_date, _week_index|
189
- current_month = [current_date.year, current_date.month]
190
-
191
- if current_month != last_month && month_overlaps_timeframe?(current_date)
192
- svg << render_month_label(current_date)
193
- last_month = current_month
194
- end
195
- end
196
-
197
- svg
198
- end
199
-
200
- def month_overlaps_timeframe?(current_date)
201
- month_start = Date.new(current_date.year, current_date.month, 1)
202
- month_end = Date.new(current_date.year, current_date.month, -1)
203
-
204
- month_start <= end_date && month_end >= start_date
205
- end
206
-
207
- def render_month_label(current_date)
208
- first_day_of_month = Date.new(current_date.year, current_date.month, 1)
209
- month_name = options[:month_labels][current_date.month - 1]
210
-
211
- svg_text(
212
- month_name,
213
- x: calculate_month_label_x(first_day_of_month),
214
- y: calculate_month_label_y,
215
- text_anchor: "start", font_family: "Arial, sans-serif", font_size: options[:font_size], fill: "#666666"
216
- )
217
- end
218
-
219
- def calculate_month_label_y
220
- options[:font_size] * 1.25
221
- end
222
-
223
- def calculate_month_label_x(first_day_of_month)
224
- days_from_start = (first_day_of_month - calendar_start_date).to_i
225
- week_index = days_from_start / 7
226
- x_offset = calculate_x_offset_for_week(week_index)
227
-
228
- dow_label_offset + week_index * (options[:cell_size] + options[:cell_spacing]) + x_offset + options[:cell_size] * 0.1
229
- end
230
-
231
- def calculate_x_offset_for_week(target_week_index)
232
- x_offset = 0
233
- last_month = nil
234
-
235
- each_week do |current_date, week_index|
236
- break if week_index >= target_week_index
237
-
238
- if current_date.month != last_month && !last_month.nil?
239
- x_offset += options[:month_spacing]
240
- end
241
-
242
- last_month = current_date.month
243
- end
244
-
245
- x_offset
246
- end
247
-
248
- def each_week
249
- current_date = calendar_start_date
250
- week_index = 0
251
-
252
- while current_date <= calendar_end_date_with_full_weeks
253
- yield current_date, week_index
254
-
255
- current_date += 7
256
- week_index += 1
257
- end
258
- end
259
-
260
- # Find the start of the week containing start_date
261
- def calendar_start_date
262
- days_back = (start_date.wday - week_start_wday) % 7
263
- start_date - days_back
264
- end
265
-
266
- # Find the end of the week containing end_date
267
- def calendar_end_date_with_full_weeks
268
- days_forward = (6 - (end_date.wday - week_start_wday)) % 7
269
- end_date + days_forward
270
- end
271
-
272
- def week_start_wday
273
- WEEK_START_WDAY[options[:start_of_week]]
274
- end
275
-
276
- def day_names_for_week_start
277
- start_index = week_start_wday
278
- options[:day_labels].rotate(start_index)
279
- end
280
-
281
- def month_spacing_weeks
282
- options[:month_spacing] / (options[:cell_size] + options[:cell_spacing])
283
- end
284
-
285
- def dow_label_offset
286
- options[:show_day_labels] ? options[:font_size] * 2 : 0
287
- end
288
-
289
- def month_label_offset
290
- options[:show_month_labels] ? options[:font_size] * 1.625 : 0
291
- end
292
-
293
- def months_in_range
294
- ((end_date.year - start_date.year) * 12 + end_date.month - start_date.month + 1)
295
- end
296
-
297
- def default_options
298
- DEFAULT_OPTIONS.merge({
299
- cell_size: 12,
300
- start_of_week: :monday,
301
- month_spacing: 5, # extra horizontal space between months
302
- show_month_labels: true,
303
- show_day_labels: true,
304
- show_outside_cells: false, # show cells outside the timeframe with inactive styling
305
- day_labels: %w[S M T W T F S], # day abbreviations starting from Sunday
306
- month_labels: %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec] # month abbreviations
307
- })
308
- end
309
- end
310
- end
@@ -1,74 +0,0 @@
1
- require_relative "builder"
2
- require_relative "value_conversion"
3
-
4
- module HeatmapBuilder
5
- class LinearHeatmapBuilder < Builder
6
- include ValueConversion
7
-
8
- def build
9
- svg_content = computed_scores.map.with_index do |score, index|
10
- cell_svg(score, index)
11
- end.join
12
-
13
- svg_container(
14
- width: computed_scores.length * options[:cell_size] + (computed_scores.length - 1) * options[:cell_spacing],
15
- height: options[:cell_size]
16
- ) { svg_content }
17
- end
18
-
19
- private
20
-
21
- def validate_options!
22
- super
23
-
24
- raise Error, "scores must be an array" if scores && !scores.is_a?(Array)
25
- raise Error, "values must be an array" if values && !values.is_a?(Array)
26
- end
27
-
28
- def computed_scores
29
- @computed_scores ||= scores || values.map.with_index { |value, index| convert_value_to_score(value, index: index) }
30
- end
31
-
32
- def calculated_min_from_values
33
- non_nil_values = values.compact
34
- non_nil_values.empty? ? 0 : non_nil_values.min
35
- end
36
-
37
- def calculated_max_from_values
38
- non_nil_values = values.compact
39
- non_nil_values.empty? ? 0 : non_nil_values.max
40
- end
41
-
42
- def cell_svg(score, index)
43
- x = index * (options[:cell_size] + options[:cell_spacing])
44
- y = 0
45
-
46
- color = score_to_color(score, colors: color_palette)
47
-
48
- colored_rect = svg_rect(
49
- x: x, y: y,
50
- width: options[:cell_size], height: options[:cell_size],
51
- rx: options[:corner_radius],
52
- fill: color
53
- )
54
-
55
- border_rect = cell_border(
56
- x, y, color,
57
- cell_size: options[:cell_size],
58
- border_width: options[:border_width],
59
- corner_radius: options[:corner_radius]
60
- )
61
-
62
- text_x = x + options[:cell_size] / 2
63
- text_y = y + options[:cell_size] / 2 + options[:font_size] * 0.35
64
-
65
- text_element = svg_text(
66
- score,
67
- x: text_x, y: text_y,
68
- font_size: options[:font_size], fill: options[:text_color]
69
- )
70
-
71
- "#{colored_rect}#{border_rect}#{text_element}"
72
- end
73
- end
74
- end