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
@@ -0,0 +1,426 @@
1
+ require "date"
2
+ require_relative "svg_helpers"
3
+ require_relative "color_helpers"
4
+ require_relative "value_conversion"
5
+
6
+ module HeatmapBuilder
7
+ class Calendar
8
+ include SvgHelpers
9
+ include ValueConversion
10
+
11
+ GITHUB_GREEN = %w[#ebedf0 #9be9a8 #40c463 #30a14e #216e39].freeze
12
+ BLUE_OCEAN = %w[#f0f9ff #bae6fd #7dd3fc #38bdf8 #0ea5e9].freeze
13
+ WARM_SUNSET = %w[#fef3e2 #fed7aa #fdba74 #fb923c #f97316].freeze
14
+ PURPLE_VIBES = %w[#f3e8ff #d8b4fe #c084fc #a855f7 #9333ea].freeze
15
+ RED_TO_GREEN = %w[#f5f5f5 #ff9999 #f7ad6a #d2c768 #99dd99].freeze
16
+
17
+ DEFAULT_OPTIONS = {
18
+ cell_size: 12,
19
+ cell_spacing: 1,
20
+ font_size: 8,
21
+ border_width: 1,
22
+ border_lightness_factor: 0.9,
23
+ corner_radius: 0,
24
+ colors: GITHUB_GREEN,
25
+ start_of_week: :monday,
26
+ month_spacing: 0,
27
+ show_month_labels: true,
28
+ show_day_labels: true,
29
+ show_outside_cells: false,
30
+ day_labels: %w[S M T W T F S],
31
+ month_labels: %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec],
32
+ tooltip: nil,
33
+ tooltip_attribute: "data-tooltip"
34
+ }.freeze
35
+
36
+ VALID_START_DAYS = %i[sunday monday tuesday wednesday thursday friday saturday].freeze
37
+
38
+ LABEL_COLOR = "#666666"
39
+ FONT_VERTICAL_CENTER_RATIO = 0.35
40
+ MONTH_LABEL_Y_RATIO = 1.25
41
+ MONTH_LABEL_HEIGHT_RATIO = 1.625
42
+ MONTH_LABEL_INDENT_RATIO = 0.1
43
+
44
+ WEEK_START_WDAY = {
45
+ sunday: 0,
46
+ monday: 1,
47
+ tuesday: 2,
48
+ wednesday: 3,
49
+ thursday: 4,
50
+ friday: 5,
51
+ saturday: 6
52
+ }.freeze
53
+
54
+ def initialize(scores: nil, values: nil, **options)
55
+ @scores = scores
56
+ @values = values
57
+ @options = DEFAULT_OPTIONS.merge(options)
58
+ validate_options!
59
+ normalize_options!
60
+ end
61
+
62
+ def build
63
+ svg_content = []
64
+
65
+ if options[:show_day_labels]
66
+ svg_content << day_labels_svg
67
+ end
68
+
69
+ svg_content << calendar_cells_svg
70
+
71
+ if options[:show_month_labels]
72
+ svg_content << month_labels_svg
73
+ end
74
+
75
+ cell_size_with_spacing = options[:cell_size] + options[:cell_spacing]
76
+ width = dow_label_offset + total_column_count * cell_size_with_spacing + total_month_spacing
77
+ height = month_label_offset + 7 * cell_size_with_spacing
78
+
79
+ svg_container(width: width, height: height) { svg_content.join }
80
+ end
81
+
82
+ private
83
+
84
+ attr_reader :scores, :values, :options
85
+
86
+ def normalize_options!
87
+ max_radius = (options[:cell_size] / 2.0).floor
88
+ @options[:corner_radius] = options[:corner_radius].clamp(0, max_radius)
89
+ end
90
+
91
+ def validate_options!
92
+ raise Error, "cell_size must be positive" unless options[:cell_size].positive?
93
+ raise Error, "font_size must be positive" unless options[:font_size].positive?
94
+ raise Error, "border_lightness_factor must be positive" unless options[:border_lightness_factor].positive?
95
+ validate_colors_option!
96
+ validate_scores_or_values!
97
+ validate_value_boundaries! if values
98
+ validate_tooltip_option!
99
+
100
+ raise Error, "scores must be a hash" if scores && !scores.is_a?(Hash)
101
+ raise Error, "values must be a hash" if values && !values.is_a?(Hash)
102
+
103
+ unless VALID_START_DAYS.include?(options[:start_of_week])
104
+ raise Error, "start_of_week must be one of: #{VALID_START_DAYS.join(", ")}"
105
+ end
106
+ end
107
+
108
+ def validate_tooltip_option!
109
+ return unless options[:tooltip]
110
+ raise Error, "tooltip must be callable" unless options[:tooltip].respond_to?(:call)
111
+ end
112
+
113
+ def validate_scores_or_values!
114
+ if scores && values
115
+ raise Error, "cannot provide both scores and values"
116
+ end
117
+
118
+ unless scores || values
119
+ raise Error, "must provide either scores or values"
120
+ end
121
+ end
122
+
123
+ def validate_value_boundaries!
124
+ return unless options[:value_min] && options[:value_max]
125
+ return unless options[:value_min] > options[:value_max]
126
+ raise Error, "value_min must be less than or equal to value_max"
127
+ end
128
+
129
+ def validate_colors_option!
130
+ colors = options[:colors]
131
+
132
+ if colors.is_a?(Array)
133
+ raise Error, "must have at least 2 colors" unless colors.length >= 2
134
+ elsif colors.is_a?(Hash)
135
+ raise Error, "colors hash must have from, to, and steps keys" unless colors.key?(:from) && colors.key?(:to) && colors.key?(:steps)
136
+ raise Error, "steps must be a number" unless colors[:steps].is_a?(Integer)
137
+ raise Error, "steps must be at least 2" unless colors[:steps] >= 2
138
+ else
139
+ raise Error, "colors must be an array or hash with from/to/steps"
140
+ end
141
+ end
142
+
143
+ def color_palette
144
+ @color_palette ||= begin
145
+ colors_option = options[:colors]
146
+ if colors_option.is_a?(Hash)
147
+ ColorHelpers.generate_color_palette(colors_option[:from], colors_option[:to], colors_option[:steps])
148
+ else
149
+ colors_option
150
+ end
151
+ end
152
+ end
153
+
154
+ def start_date
155
+ @start_date ||= parsed_date_range.first
156
+ end
157
+
158
+ def end_date
159
+ @end_date ||= parsed_date_range.last
160
+ end
161
+
162
+ def scores_by_date
163
+ @scores_by_date ||= if scores
164
+ scores
165
+ else
166
+ result = {}
167
+ current_date = start_date
168
+
169
+ while current_date <= end_date
170
+ value = values[current_date] || values[current_date.to_s]
171
+ result[current_date] = convert_value_to_score(value, date: current_date)
172
+ current_date += 1
173
+ end
174
+
175
+ result
176
+ end
177
+ end
178
+
179
+ def calculated_min_from_values
180
+ # Zero is rendered as the reserved empty bucket, so the activity gradient
181
+ # starts at the smallest non-zero value. Anchoring the minimum here keeps
182
+ # the lightest activity color reachable.
183
+ candidates = values.values.compact.reject(&:zero?)
184
+ candidates.empty? ? 0 : candidates.min
185
+ end
186
+
187
+ def calculated_max_from_values
188
+ non_nil_values = values.values.compact
189
+ non_nil_values.empty? ? 0 : non_nil_values.max
190
+ end
191
+
192
+ def parsed_date_range
193
+ @parsed_date_range ||= begin
194
+ data_keys = (scores || values || {}).keys
195
+ dates = data_keys.map { |d| d.is_a?(Date) ? d : Date.parse(d.to_s) }
196
+ return [Date.today - 365, Date.today] if dates.empty?
197
+
198
+ [dates.min, dates.max]
199
+ end
200
+ end
201
+
202
+ def column_layout
203
+ @column_layout ||= build_column_layout
204
+ end
205
+
206
+ def build_column_layout
207
+ columns = []
208
+ current_date = calendar_start_date
209
+ cal_end = calendar_end_date_with_full_weeks
210
+ split_enabled = options[:month_spacing] > 0
211
+ col_idx = 0
212
+ x_offset = 0
213
+ last_month = nil
214
+ labeled_months = {}
215
+
216
+ while current_date <= cal_end
217
+ week_start = current_date
218
+ week_end = current_date + 6
219
+
220
+ if split_enabled && week_start.month != week_end.month
221
+ split_at = (0..6).find { |i| (week_start + i).month != week_start.month }
222
+ new_month_date = week_start + split_at
223
+
224
+ # Column A: old month days (day_index 0..split_at-1)
225
+ # Partial column — month labels are only placed on the first full
226
+ # week, so neither split column carries a label.
227
+ days_a = (0...split_at).map { |i| [week_start + i, i] }
228
+ columns << {index: col_idx, x_offset: x_offset, days: days_a, month_date: week_start, first_of_month: false}
229
+ col_idx += 1
230
+
231
+ # Spacing between columns A and B
232
+ if last_month && month_overlaps_timeframe?(new_month_date)
233
+ x_offset += options[:month_spacing]
234
+ end
235
+ last_month = new_month_date.month
236
+
237
+ # Column B: new month days (day_index split_at..6)
238
+ # Don't place month label on split column — defer to first full column
239
+ days_b = (split_at..6).map { |i| [week_start + i, i] }
240
+ columns << {index: col_idx, x_offset: x_offset, days: days_b, month_date: new_month_date, first_of_month: false}
241
+ else
242
+ end_of_week_month = week_end.month
243
+ if end_of_week_month != last_month && !last_month.nil? && month_overlaps_timeframe?(week_end)
244
+ x_offset += options[:month_spacing]
245
+ end
246
+ last_month = end_of_week_month
247
+
248
+ # Label on the first fully visible week of a month. A month whose only
249
+ # visible week is incomplete (a leading or trailing sliver) gets no
250
+ # label, so the first label can't crowd into the next month's column.
251
+ days = (0..6).map { |i| [week_start + i, i] }
252
+ month_key = [week_start.year, week_start.month]
253
+ first = !labeled_months.key?(month_key) && week_fully_visible?(week_start)
254
+ labeled_months[month_key] = true if first
255
+ columns << {index: col_idx, x_offset: x_offset, days: days, month_date: week_start, first_of_month: first}
256
+ end
257
+
258
+ col_idx += 1
259
+ current_date += 7
260
+ end
261
+
262
+ columns
263
+ end
264
+
265
+ def calendar_cells_svg
266
+ svg = ""
267
+ column_layout.each do |col|
268
+ col[:days].each do |date, day_index|
269
+ svg << render_cell(date, col[:index], day_index, col[:x_offset])
270
+ end
271
+ end
272
+ svg
273
+ end
274
+
275
+ def render_cell(current_date, column_index, day_index, x_offset)
276
+ x = dow_label_offset + column_index * (options[:cell_size] + options[:cell_spacing]) + x_offset
277
+ y = month_label_offset + day_index * (options[:cell_size] + options[:cell_spacing])
278
+
279
+ if current_date.between?(start_date, end_date)
280
+ score = scores_by_date[current_date] || scores_by_date[current_date.to_s] || 0
281
+ cell_svg(score, x, y, false, date: current_date)
282
+ elsif options[:show_outside_cells]
283
+ cell_svg(0, x, y, true)
284
+ else
285
+ ""
286
+ end
287
+ end
288
+
289
+ def cell_svg(score, x, y, inactive = false, date: nil)
290
+ color = ColorHelpers.score_to_color(score, colors: color_palette)
291
+ color = ColorHelpers.make_color_inactive(color) if inactive
292
+
293
+ colored_rect = svg_rect(
294
+ x: x, y: y,
295
+ width: options[:cell_size], height: options[:cell_size],
296
+ rx: options[:corner_radius],
297
+ fill: color
298
+ )
299
+
300
+ border_rect = cell_border(
301
+ x, y, color,
302
+ cell_size: options[:cell_size],
303
+ border_width: options[:border_width],
304
+ border_lightness_factor: options[:border_lightness_factor],
305
+ corner_radius: options[:corner_radius]
306
+ )
307
+
308
+ cell_content = "#{colored_rect}#{border_rect}"
309
+
310
+ if options[:tooltip] && !inactive && date
311
+ cell_with_tooltip(cell_content, resolve_tooltip(date, score))
312
+ else
313
+ cell_content
314
+ end
315
+ end
316
+
317
+ def resolve_tooltip(date, score)
318
+ raw_value = values ? (values[date] || values[date.to_s]) : nil
319
+ options[:tooltip].call(date: date, score: score, value: raw_value)
320
+ end
321
+
322
+ def cell_with_tooltip(content, text)
323
+ title_element = svg_element("title") { escape_xml(text) }
324
+ group_attrs = {}
325
+ group_attrs[options[:tooltip_attribute]] = text if options[:tooltip_attribute]
326
+ svg_element("g", group_attrs) { "#{title_element}#{content}" }
327
+ end
328
+
329
+ def day_labels_svg
330
+ return "" unless options[:show_day_labels]
331
+
332
+ day_names = day_names_for_week_start
333
+ svg = ""
334
+
335
+ day_names.each_with_index do |day_name, index|
336
+ y = month_label_offset + index * (options[:cell_size] + options[:cell_spacing]) + options[:cell_size] / 2 + options[:font_size] * FONT_VERTICAL_CENTER_RATIO
337
+ svg << svg_text(
338
+ day_name,
339
+ x: options[:font_size], y: y,
340
+ font_size: options[:font_size], fill: LABEL_COLOR
341
+ )
342
+ end
343
+
344
+ svg
345
+ end
346
+
347
+ def month_labels_svg
348
+ return "" unless options[:show_month_labels]
349
+
350
+ svg = ""
351
+ column_layout.each do |col|
352
+ if col[:first_of_month]
353
+ svg << month_label_at(col[:index], col[:x_offset], col[:month_date])
354
+ end
355
+ end
356
+ svg
357
+ end
358
+
359
+ # A week carries its month's label only when every one of its seven days
360
+ # falls within the data range; partial boundary weeks are not labelable.
361
+ def week_fully_visible?(week_start)
362
+ week_start >= start_date && (week_start + 6) <= end_date
363
+ end
364
+
365
+ def month_overlaps_timeframe?(date)
366
+ month_start = Date.new(date.year, date.month, 1)
367
+ month_end = Date.new(date.year, date.month, -1)
368
+
369
+ month_start <= end_date && month_end >= start_date
370
+ end
371
+
372
+ def month_label_at(column_index, x_offset, month_date)
373
+ cell_size_with_spacing = options[:cell_size] + options[:cell_spacing]
374
+ x = dow_label_offset + column_index * cell_size_with_spacing + x_offset + options[:cell_size] * MONTH_LABEL_INDENT_RATIO
375
+ month_name = options[:month_labels][month_date.month - 1]
376
+
377
+ svg_text(
378
+ month_name,
379
+ x: x,
380
+ y: calculate_month_label_y,
381
+ text_anchor: "start", font_family: "Arial, sans-serif", font_size: options[:font_size], fill: LABEL_COLOR
382
+ )
383
+ end
384
+
385
+ def calculate_month_label_y
386
+ options[:font_size] * MONTH_LABEL_Y_RATIO
387
+ end
388
+
389
+ def total_column_count
390
+ column_layout.length
391
+ end
392
+
393
+ def total_month_spacing
394
+ column_layout.last&.dig(:x_offset) || 0
395
+ end
396
+
397
+ # Find the start of the week containing start_date
398
+ def calendar_start_date
399
+ days_back = (start_date.wday - week_start_wday) % 7
400
+ start_date - days_back
401
+ end
402
+
403
+ # Find the end of the week containing end_date
404
+ def calendar_end_date_with_full_weeks
405
+ days_forward = (6 - (end_date.wday - week_start_wday)) % 7
406
+ end_date + days_forward
407
+ end
408
+
409
+ def week_start_wday
410
+ WEEK_START_WDAY[options[:start_of_week]]
411
+ end
412
+
413
+ def day_names_for_week_start
414
+ start_index = week_start_wday
415
+ options[:day_labels].rotate(start_index)
416
+ end
417
+
418
+ def dow_label_offset
419
+ options[:show_day_labels] ? options[:font_size] * 2 : 0
420
+ end
421
+
422
+ def month_label_offset
423
+ options[:show_month_labels] ? options[:font_size] * MONTH_LABEL_HEIGHT_RATIO : 0
424
+ end
425
+ end
426
+ end