heatmap-builder 0.1.0 → 0.2.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.gitignore +3 -0
  4. data/CHANGELOG.md +33 -2
  5. data/Gemfile.lock +9 -1
  6. data/README.md +340 -37
  7. data/Rakefile +15 -0
  8. data/bin/generate_examples +234 -0
  9. data/examples/calendar_blue_ocean.svg +1 -0
  10. data/examples/calendar_default.svg +1 -0
  11. data/examples/calendar_github_style.svg +1 -3
  12. data/examples/calendar_purple_vibes.svg +1 -0
  13. data/examples/calendar_red_to_green.svg +1 -0
  14. data/examples/calendar_rounded_corners.svg +1 -0
  15. data/examples/calendar_rounded_corners_max_radius.svg +1 -0
  16. data/examples/calendar_sunday_start.svg +1 -3
  17. data/examples/calendar_warm_sunset.svg +1 -0
  18. data/examples/calendar_with_outside_cells.svg +1 -3
  19. data/examples/large_cells.svg +1 -3
  20. data/examples/linear_blue_ocean.svg +1 -0
  21. data/examples/linear_github_green.svg +1 -0
  22. data/examples/linear_neon_gradient.svg +1 -0
  23. data/examples/linear_purple_vibes.svg +1 -0
  24. data/examples/linear_red_to_green.svg +1 -0
  25. data/examples/linear_rounded_corners.svg +1 -0
  26. data/examples/linear_rounded_corners_max_radius.svg +1 -0
  27. data/examples/linear_warm_sunset.svg +1 -0
  28. data/examples/weekly_progress.svg +1 -3
  29. data/heatmap-builder.gemspec +1 -0
  30. data/lib/heatmap-builder.rb +47 -3
  31. data/lib/heatmap_builder/builder.rb +100 -0
  32. data/lib/heatmap_builder/calendar_heatmap_builder.rb +177 -162
  33. data/lib/heatmap_builder/color_helpers.rb +170 -0
  34. data/lib/heatmap_builder/linear_heatmap_builder.rb +49 -103
  35. data/lib/heatmap_builder/svg_helpers.rb +75 -0
  36. data/lib/heatmap_builder/value_conversion.rb +64 -0
  37. data/lib/heatmap_builder/version.rb +1 -1
  38. metadata +36 -4
  39. data/examples/generate_samples.rb +0 -114
  40. data/mise.toml +0 -2
@@ -1,3 +1 @@
1
- <svg width="132" height="18" xmlns="http://www.w3.org/2000/svg">
2
- <rect x="0" y="0" width="18" height="18" fill="#ebedf0"/><rect x="0.5" y="0.5" width="17" height="17" fill="none" stroke="#a4a5a8" stroke-width="1"/><text x="9" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#000000">0</text><rect x="19" y="0" width="18" height="18" fill="#9be9a8"/><rect x="19.5" y="0.5" width="17" height="17" fill="none" stroke="#6ca375" stroke-width="1"/><text x="28" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#000000">1</text><rect x="38" y="0" width="18" height="18" fill="#30a14e"/><rect x="38.5" y="0.5" width="17" height="17" fill="none" stroke="#217036" stroke-width="1"/><text x="47" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#ffffff">3</text><rect x="57" y="0" width="18" height="18" fill="#40c463"/><rect x="57.5" y="0.5" width="17" height="17" fill="none" stroke="#2c8945" stroke-width="1"/><text x="66" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#000000">2</text><rect x="76" y="0" width="18" height="18" fill="#216e39"/><rect x="76.5" y="0.5" width="17" height="17" fill="none" stroke="#174d27" stroke-width="1"/><text x="85" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#ffffff">4</text><rect x="95" y="0" width="18" height="18" fill="#9be9a8"/><rect x="95.5" y="0.5" width="17" height="17" fill="none" stroke="#6ca375" stroke-width="1"/><text x="104" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#000000">1</text><rect x="114" y="0" width="18" height="18" fill="#ebedf0"/><rect x="114.5" y="0.5" width="17" height="17" fill="none" stroke="#a4a5a8" stroke-width="1"/><text x="123" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#000000">0</text>
3
- </svg>
1
+ <svg width="132" height="18" xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="18" height="18" fill="#ebedf0"/><rect x="0.5" y="0.5" width="17" height="17" fill="none" stroke="#ccced1" stroke-width="1"/><text x="9" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#000000">0</text><rect x="19" y="0" width="18" height="18" fill="#9be9a8"/><rect x="19.5" y="0.5" width="17" height="17" fill="none" stroke="#7fcc8d" stroke-width="1"/><text x="28" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#000000">1</text><rect x="38" y="0" width="18" height="18" fill="#30a14e"/><rect x="38.5" y="0.5" width="17" height="17" fill="none" stroke="#108d3b" stroke-width="1"/><text x="47" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#000000">3</text><rect x="57" y="0" width="18" height="18" fill="#40c463"/><rect x="57.5" y="0.5" width="17" height="17" fill="none" stroke="#1dac4d" stroke-width="1"/><text x="66" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#000000">2</text><rect x="76" y="0" width="18" height="18" fill="#216e39"/><rect x="76.5" y="0.5" width="17" height="17" fill="none" stroke="#0d602c" stroke-width="1"/><text x="85" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#000000">4</text><rect x="95" y="0" width="18" height="18" fill="#9be9a8"/><rect x="95.5" y="0.5" width="17" height="17" fill="none" stroke="#7fcc8d" stroke-width="1"/><text x="104" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#000000">1</text><rect x="114" y="0" width="18" height="18" fill="#ebedf0"/><rect x="114.5" y="0.5" width="17" height="17" fill="none" stroke="#ccced1" stroke-width="1"/><text x="123" y="11.8" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="#000000">0</text></svg>
@@ -40,4 +40,5 @@ Gem::Specification.new do |spec|
40
40
  spec.add_development_dependency "rake", "~> 13.0"
41
41
  spec.add_development_dependency "minitest", "~> 5.0"
42
42
  spec.add_development_dependency "standard", "~> 1.0"
43
+ spec.add_development_dependency "simplecov", "~> 0.22"
43
44
  end
@@ -1,15 +1,59 @@
1
1
  require_relative "heatmap_builder/version"
2
+ require_relative "heatmap_builder/svg_helpers"
3
+ require_relative "heatmap_builder/color_helpers"
4
+ require_relative "heatmap_builder/value_conversion"
5
+ require_relative "heatmap_builder/builder"
2
6
  require_relative "heatmap_builder/linear_heatmap_builder"
3
7
  require_relative "heatmap_builder/calendar_heatmap_builder"
4
8
 
5
9
  module HeatmapBuilder
6
10
  class Error < StandardError; end
7
11
 
12
+ GITHUB_GREEN = Builder::GITHUB_GREEN
13
+ BLUE_OCEAN = Builder::BLUE_OCEAN
14
+ WARM_SUNSET = Builder::WARM_SUNSET
15
+ PURPLE_VIBES = Builder::PURPLE_VIBES
16
+ RED_TO_GREEN = Builder::RED_TO_GREEN
17
+
18
+ # Builds a linear (single-row) heatmap visualization.
19
+ #
20
+ # @param scores [Array<Integer>, nil] Pre-calculated score values (0 to num_colors-1). Required unless values provided.
21
+ # @param values [Array<Numeric>, nil] Raw numeric values to be mapped to scores. Required unless scores provided.
22
+ # @param options [Hash] Customization options
23
+ # @return [String] SVG markup
24
+ # @see https://github.com/dreikanter/heatmap-builder#linear-heatmaps Full documentation
25
+ # @example
26
+ # HeatmapBuilder.build_linear(scores: [0, 1, 2, 3, 4])
27
+ # HeatmapBuilder.build_linear(values: [10, 25, 50, 75, 100], value_min: 0, value_max: 100)
28
+ def self.build_linear(scores: nil, values: nil, **options)
29
+ LinearHeatmapBuilder.new(scores: scores, values: values, **options).build
30
+ end
31
+
32
+ # Builds a calendar (GitHub-style) heatmap visualization.
33
+ #
34
+ # @param scores [Hash<Date, Integer>, Hash<String, Integer>, nil] Pre-calculated score values by date. Required unless values provided.
35
+ # @param values [Hash<Date, Numeric>, Hash<String, Numeric>, nil] Raw numeric values by date. Required unless scores provided.
36
+ # @param options [Hash] Customization options
37
+ # @return [String] SVG markup
38
+ # @see https://github.com/dreikanter/heatmap-builder#calendar-heatmaps Full documentation
39
+ # @example
40
+ # HeatmapBuilder.build_calendar(scores: { '2024-01-01' => 2, '2024-01-02' => 4 })
41
+ # HeatmapBuilder.build_calendar(values: { Date.new(2024, 1, 1) => 45.2 })
42
+ def self.build_calendar(scores: nil, values: nil, **options)
43
+ CalendarHeatmapBuilder.new(scores: scores, values: values, **options).build
44
+ end
45
+
46
+ # @deprecated Use {.build_linear} instead
8
47
  def self.generate(scores, options = {})
9
- LinearHeatmapBuilder.new(scores, options).generate
48
+ warn "[DEPRECATION] `HeatmapBuilder.generate(scores, options)` is deprecated and will be removed in v1.0.0. " \
49
+ "Use `HeatmapBuilder.build_linear(scores: scores, **options)` instead."
50
+ build_linear(scores: scores, **options)
10
51
  end
11
52
 
12
- def self.generate_calendar(scores_by_date, options = {})
13
- CalendarHeatmapBuilder.new(scores_by_date, options).generate
53
+ # @deprecated Use {.build_calendar} instead
54
+ def self.generate_calendar(scores, options = {})
55
+ warn "[DEPRECATION] `HeatmapBuilder.generate_calendar(scores_by_date, options)` is deprecated and will be removed in v1.0.0. " \
56
+ "Use `HeatmapBuilder.build_calendar(scores: scores_by_date, **options)` instead."
57
+ build_calendar(scores: scores, **options)
14
58
  end
15
59
  end
@@ -0,0 +1,100 @@
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,79 +1,102 @@
1
1
  require "date"
2
+ require_relative "builder"
3
+ require_relative "value_conversion"
2
4
 
3
5
  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
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
16
19
  }.freeze
17
20
 
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
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
25
33
 
26
- def generate
27
- build_svg
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 }
28
42
  end
29
43
 
30
44
  private
31
45
 
32
- attr_reader :scores_by_date, :options, :start_date, :end_date
46
+ def start_date
47
+ @start_date ||= parsed_date_range.first
48
+ end
33
49
 
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
50
+ def end_date
51
+ @end_date ||= parsed_date_range.last
45
52
  end
46
53
 
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?
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)
50
59
 
51
- [dates.min, dates.max]
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
52
63
  end
53
64
 
54
- def build_svg
55
- width = svg_width
56
- height = svg_height
65
+ def scores_by_date
66
+ @scores_by_date ||= if scores
67
+ scores
68
+ else
69
+ result = {}
70
+ current_date = start_date
57
71
 
58
- svg_content = []
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
59
77
 
60
- # Add day labels if enabled
61
- if options[:show_day_labels]
62
- svg_content << day_labels_svg
78
+ result
63
79
  end
80
+ end
64
81
 
65
- # Add month labels and cells
66
- svg_content << calendar_cells_svg
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
67
86
 
68
- if options[:show_month_labels]
69
- svg_content << month_labels_svg
70
- end
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?
71
97
 
72
- <<~SVG
73
- <svg width="#{width}" height="#{height}" xmlns="http://www.w3.org/2000/svg">
74
- #{svg_content.join}
75
- </svg>
76
- SVG
98
+ [dates.min, dates.max]
99
+ end
77
100
  end
78
101
 
79
102
  def calendar_cells_svg
@@ -93,8 +116,8 @@ module HeatmapBuilder
93
116
 
94
117
  # Generate week column - always fill all 7 days
95
118
  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])
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])
98
121
 
99
122
  if current_date.between?(start_date, end_date)
100
123
  # Active cell within the specified timeframe
@@ -115,27 +138,25 @@ module HeatmapBuilder
115
138
  end
116
139
 
117
140
  def cell_svg(score, x, y, inactive = false)
118
- color = score_to_color(score)
141
+ color = score_to_color(score, colors: color_palette)
119
142
 
120
- # Make inactive cells duller by reducing opacity
121
143
  if inactive
122
144
  color = make_color_inactive(color)
123
145
  end
124
146
 
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
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
+ )
139
160
 
140
161
  "#{colored_rect}#{border_rect}"
141
162
  end
@@ -147,8 +168,12 @@ module HeatmapBuilder
147
168
  svg = ""
148
169
 
149
170
  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>"
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
+ )
152
177
  end
153
178
 
154
179
  svg
@@ -158,138 +183,128 @@ module HeatmapBuilder
158
183
  return "" unless options[:show_month_labels]
159
184
 
160
185
  svg = ""
161
- current_date = calendar_start_date
162
- week_index = 0
163
186
  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
187
 
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
188
+ each_week do |current_date, _week_index|
189
+ current_month = [current_date.year, current_date.month]
186
190
 
187
- displayed_months[current_date.month] = true
191
+ if current_month != last_month && month_overlaps_timeframe?(current_date)
192
+ svg << render_month_label(current_date)
193
+ last_month = current_month
188
194
  end
189
-
190
- last_month = current_date.month
191
- current_date += 7
192
- week_index += 1
193
195
  end
194
196
 
195
197
  svg
196
198
  end
197
199
 
198
- def score_to_color(score)
199
- return options[:colors].first if score == 0
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)
200
227
 
201
- max_color_index = options[:colors].length - 1
202
- color_index = 1 + (score - 1) % max_color_index
203
- options[:colors][color_index]
228
+ dow_label_offset + week_index * (options[:cell_size] + options[:cell_spacing]) + x_offset + options[:cell_size] * 0.1
204
229
  end
205
230
 
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)
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
211
237
 
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
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
216
244
 
217
- "#%02x%02x%02x" % [r, g, b]
245
+ x_offset
218
246
  end
219
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
220
261
  def calendar_start_date
221
- # Find the start of the week containing start_date
222
262
  days_back = (start_date.wday - week_start_wday) % 7
223
263
  start_date - days_back
224
264
  end
225
265
 
266
+ # Find the end of the week containing end_date
226
267
  def calendar_end_date_with_full_weeks
227
- # Find the end of the week containing end_date
228
268
  days_forward = (6 - (end_date.wday - week_start_wday)) % 7
229
269
  end_date + days_forward
230
270
  end
231
271
 
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
272
  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
273
+ WEEK_START_WDAY[options[:start_of_week]]
260
274
  end
261
275
 
262
276
  def day_names_for_week_start
263
- all_days = %w[S M T W T F S]
264
277
  start_index = week_start_wday
265
- all_days.rotate(start_index)
278
+ options[:day_labels].rotate(start_index)
266
279
  end
267
280
 
268
281
  def month_spacing_weeks
269
282
  options[:month_spacing] / (options[:cell_size] + options[:cell_spacing])
270
283
  end
271
284
 
272
- def label_offset
285
+ def dow_label_offset
273
286
  options[:show_day_labels] ? options[:font_size] * 2 : 0
274
287
  end
275
288
 
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
+ def month_label_offset
290
+ options[:show_month_labels] ? options[:font_size] * 1.625 : 0
289
291
  end
290
292
 
291
293
  def months_in_range
292
294
  ((end_date.year - start_date.year) * 12 + end_date.month - start_date.month + 1)
293
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
294
309
  end
295
310
  end