heatmap-builder 0.1.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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -5
- data/.gitignore +3 -0
- data/CHANGELOG.md +101 -3
- data/Gemfile.lock +35 -24
- data/README.md +325 -52
- data/Rakefile +15 -0
- data/bin/generate_examples +177 -0
- data/examples/calendar_blue_ocean.svg +1 -0
- data/examples/calendar_cell_borders.svg +1 -0
- data/examples/calendar_default.svg +1 -0
- data/examples/calendar_github_style.svg +1 -3
- data/examples/calendar_month_spacing_rounded.svg +1 -0
- data/examples/calendar_no_borders.svg +1 -0
- data/examples/calendar_purple_vibes.svg +1 -0
- data/examples/calendar_red_to_green.svg +1 -0
- data/examples/calendar_rounded_corners.svg +1 -0
- data/examples/calendar_rounded_corners_max_radius.svg +1 -0
- data/examples/calendar_sunday_start.svg +1 -3
- data/examples/calendar_warm_sunset.svg +1 -0
- data/examples/calendar_with_outside_cells.svg +1 -3
- data/heatmap-builder.gemspec +5 -4
- data/lib/heatmap-builder.rb +24 -6
- data/lib/heatmap_builder/calendar.rb +426 -0
- data/lib/heatmap_builder/color_helpers.rb +150 -0
- data/lib/heatmap_builder/svg_helpers.rb +82 -0
- data/lib/heatmap_builder/value_conversion.rb +68 -0
- data/lib/heatmap_builder/version.rb +1 -1
- metadata +38 -15
- data/examples/generate_samples.rb +0 -114
- data/examples/large_cells.svg +0 -3
- data/examples/weekly_progress.svg +0 -3
- data/lib/heatmap_builder/calendar_heatmap_builder.rb +0 -295
- data/lib/heatmap_builder/linear_heatmap_builder.rb +0 -128
- data/mise.toml +0 -2
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module HeatmapBuilder
|
|
2
|
+
module ValueConversion
|
|
3
|
+
private
|
|
4
|
+
|
|
5
|
+
def value_min
|
|
6
|
+
@value_min ||= options[:value_min] || calculated_min_from_values
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def value_max
|
|
10
|
+
@value_max ||= options[:value_max] || calculated_max_from_values
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def calculated_min_from_values
|
|
14
|
+
raise NotImplementedError, "Subclasses must implement #calculated_min_from_values"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def calculated_max_from_values
|
|
18
|
+
raise NotImplementedError, "Subclasses must implement #calculated_max_from_values"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def color_count
|
|
22
|
+
@color_count ||= begin
|
|
23
|
+
colors_option = options[:colors]
|
|
24
|
+
if colors_option.is_a?(Array)
|
|
25
|
+
colors_option.length
|
|
26
|
+
elsif colors_option.is_a?(Hash)
|
|
27
|
+
colors_option[:steps]
|
|
28
|
+
else
|
|
29
|
+
raise Error, "colors must be an array or hash"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def convert_value_to_score(value, **params)
|
|
35
|
+
if options[:value_to_score]
|
|
36
|
+
score = options[:value_to_score].call(
|
|
37
|
+
value: value.nil? ? value_min : value,
|
|
38
|
+
min: value_min,
|
|
39
|
+
max: value_max,
|
|
40
|
+
max_score: color_count - 1,
|
|
41
|
+
**params
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
unless score.is_a?(Integer) && score >= 0 && score < color_count
|
|
45
|
+
raise Error, "value_to_score must return an integer between 0 and #{color_count - 1}, got #{score.inspect}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return score
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Score 0 is reserved for empty cells (zero or missing values). Any
|
|
52
|
+
# non-zero value maps into 1..max_score so even the smallest activity is
|
|
53
|
+
# visibly distinct from an empty day.
|
|
54
|
+
return 0 if value.nil? || value.zero?
|
|
55
|
+
|
|
56
|
+
max_score = color_count - 1
|
|
57
|
+
clamped_value = value.clamp(value_min, value_max)
|
|
58
|
+
|
|
59
|
+
if value_min == value_max
|
|
60
|
+
max_score
|
|
61
|
+
else
|
|
62
|
+
range = value_max - value_min
|
|
63
|
+
normalized = (clamped_value - value_min).to_f / range
|
|
64
|
+
1 + (normalized * (max_score - 1)).round
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
metadata
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: heatmap-builder
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Musayev
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-06-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
|
-
- - "
|
|
17
|
+
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
19
|
version: '2.0'
|
|
20
20
|
type: :development
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
|
-
- - "
|
|
24
|
+
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '2.0'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: rake
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
|
-
- - "
|
|
31
|
+
- - ">="
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
33
|
version: '13.0'
|
|
34
34
|
type: :development
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
|
-
- - "
|
|
38
|
+
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '13.0'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
42
|
name: minitest
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
44
44
|
requirements:
|
|
45
|
-
- - "
|
|
45
|
+
- - ">="
|
|
46
46
|
- !ruby/object:Gem::Version
|
|
47
47
|
version: '5.0'
|
|
48
48
|
type: :development
|
|
49
49
|
prerelease: false
|
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
|
-
- - "
|
|
52
|
+
- - ">="
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
54
|
version: '5.0'
|
|
55
55
|
- !ruby/object:Gem::Dependency
|
|
@@ -66,6 +66,20 @@ dependencies:
|
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
68
|
version: '1.0'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: simplecov
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0.22'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0.22'
|
|
69
83
|
description: A Ruby gem that generates embeddable GitHub-style heatmap visualization
|
|
70
84
|
in SVG format.
|
|
71
85
|
email:
|
|
@@ -85,19 +99,28 @@ files:
|
|
|
85
99
|
- README.md
|
|
86
100
|
- Rakefile
|
|
87
101
|
- bin/console
|
|
102
|
+
- bin/generate_examples
|
|
88
103
|
- bin/setup
|
|
104
|
+
- examples/calendar_blue_ocean.svg
|
|
105
|
+
- examples/calendar_cell_borders.svg
|
|
106
|
+
- examples/calendar_default.svg
|
|
89
107
|
- examples/calendar_github_style.svg
|
|
108
|
+
- examples/calendar_month_spacing_rounded.svg
|
|
109
|
+
- examples/calendar_no_borders.svg
|
|
110
|
+
- examples/calendar_purple_vibes.svg
|
|
111
|
+
- examples/calendar_red_to_green.svg
|
|
112
|
+
- examples/calendar_rounded_corners.svg
|
|
113
|
+
- examples/calendar_rounded_corners_max_radius.svg
|
|
90
114
|
- examples/calendar_sunday_start.svg
|
|
115
|
+
- examples/calendar_warm_sunset.svg
|
|
91
116
|
- examples/calendar_with_outside_cells.svg
|
|
92
|
-
- examples/generate_samples.rb
|
|
93
|
-
- examples/large_cells.svg
|
|
94
|
-
- examples/weekly_progress.svg
|
|
95
117
|
- heatmap-builder.gemspec
|
|
96
118
|
- lib/heatmap-builder.rb
|
|
97
|
-
- lib/heatmap_builder/
|
|
98
|
-
- lib/heatmap_builder/
|
|
119
|
+
- lib/heatmap_builder/calendar.rb
|
|
120
|
+
- lib/heatmap_builder/color_helpers.rb
|
|
121
|
+
- lib/heatmap_builder/svg_helpers.rb
|
|
122
|
+
- lib/heatmap_builder/value_conversion.rb
|
|
99
123
|
- lib/heatmap_builder/version.rb
|
|
100
|
-
- mise.toml
|
|
101
124
|
homepage: https://github.com/dreikanter/heatmap-builder
|
|
102
125
|
licenses:
|
|
103
126
|
- MIT
|
|
@@ -114,7 +137,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
114
137
|
requirements:
|
|
115
138
|
- - ">="
|
|
116
139
|
- !ruby/object:Gem::Version
|
|
117
|
-
version: '3.
|
|
140
|
+
version: '3.3'
|
|
118
141
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
119
142
|
requirements:
|
|
120
143
|
- - ">="
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
|
|
3
|
-
require "date"
|
|
4
|
-
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
5
|
-
require "heatmap-builder"
|
|
6
|
-
|
|
7
|
-
def generate_sample_svgs
|
|
8
|
-
# Create examples directory for SVG files
|
|
9
|
-
Dir.mkdir("examples") unless Dir.exist?("examples")
|
|
10
|
-
|
|
11
|
-
generated_files = []
|
|
12
|
-
|
|
13
|
-
# Basic weekly progress
|
|
14
|
-
puts "Generating basic weekly progress..."
|
|
15
|
-
weekly_scores = [0, 1, 3, 2, 4, 1, 0]
|
|
16
|
-
svg = HeatmapBuilder.generate(weekly_scores, cell_size: 18)
|
|
17
|
-
filepath = "examples/weekly_progress.svg"
|
|
18
|
-
File.write(filepath, svg)
|
|
19
|
-
generated_files << filepath
|
|
20
|
-
|
|
21
|
-
# Large cells example
|
|
22
|
-
puts "Generating large cells example..."
|
|
23
|
-
large_cell_scores = [1, 2, 3, 4, 5, 6, 7]
|
|
24
|
-
svg = HeatmapBuilder.generate(large_cell_scores, {
|
|
25
|
-
cell_size: 35,
|
|
26
|
-
cell_spacing: 1,
|
|
27
|
-
font_size: 20
|
|
28
|
-
})
|
|
29
|
-
filepath = "examples/large_cells.svg"
|
|
30
|
-
File.write(filepath, svg)
|
|
31
|
-
generated_files << filepath
|
|
32
|
-
|
|
33
|
-
# GitHub-style calendar
|
|
34
|
-
puts "Generating GitHub-style calendar..."
|
|
35
|
-
calendar_data = sample_calendar_data
|
|
36
|
-
svg = HeatmapBuilder.generate_calendar(calendar_data, {
|
|
37
|
-
cell_size: 14,
|
|
38
|
-
month_spacing: 0
|
|
39
|
-
})
|
|
40
|
-
filepath = "examples/calendar_github_style.svg"
|
|
41
|
-
File.write(filepath, svg)
|
|
42
|
-
generated_files << filepath
|
|
43
|
-
|
|
44
|
-
# Calendar with Sunday start
|
|
45
|
-
puts "Generating calendar with Sunday start..."
|
|
46
|
-
svg = HeatmapBuilder.generate_calendar(calendar_data, {
|
|
47
|
-
cell_size: 14,
|
|
48
|
-
start_of_week: :sunday,
|
|
49
|
-
month_spacing: 0
|
|
50
|
-
})
|
|
51
|
-
filepath = "examples/calendar_sunday_start.svg"
|
|
52
|
-
File.write(filepath, svg)
|
|
53
|
-
generated_files << filepath
|
|
54
|
-
|
|
55
|
-
# Calendar with outside cells
|
|
56
|
-
puts "Generating calendar with outside cells..."
|
|
57
|
-
svg = HeatmapBuilder.generate_calendar(calendar_data, {
|
|
58
|
-
cell_size: 14,
|
|
59
|
-
show_outside_cells: true,
|
|
60
|
-
month_spacing: 0
|
|
61
|
-
})
|
|
62
|
-
filepath = "examples/calendar_with_outside_cells.svg"
|
|
63
|
-
File.write(filepath, svg)
|
|
64
|
-
generated_files << filepath
|
|
65
|
-
|
|
66
|
-
puts "\n✅ Sample SVG files generated successfully!"
|
|
67
|
-
puts "📁 Generated files:"
|
|
68
|
-
generated_files.each do |file|
|
|
69
|
-
puts " - #{file}"
|
|
70
|
-
end
|
|
71
|
-
puts "📂 Total files: #{generated_files.length}"
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def sample_calendar_data
|
|
75
|
-
# Generate sample data for a full year
|
|
76
|
-
end_date = Date.today
|
|
77
|
-
start_date = Date.new(end_date.year, 1, 1)
|
|
78
|
-
|
|
79
|
-
data = {}
|
|
80
|
-
current_date = start_date
|
|
81
|
-
|
|
82
|
-
while current_date <= end_date
|
|
83
|
-
# Create some realistic activity patterns with seasonal variation
|
|
84
|
-
base_activity = case current_date.month
|
|
85
|
-
when 1, 2, 12 # Winter - lower activity
|
|
86
|
-
[0, 0, 0, 1, 1, 2]
|
|
87
|
-
when 3, 4, 5 # Spring - increasing activity
|
|
88
|
-
[0, 1, 1, 2, 2, 3, 3]
|
|
89
|
-
when 6, 7, 8 # Summer - peak activity
|
|
90
|
-
[1, 2, 2, 3, 3, 4, 4, 5]
|
|
91
|
-
when 9, 10, 11 # Fall - moderate activity
|
|
92
|
-
[0, 1, 2, 2, 3, 3]
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Weekend vs weekday patterns
|
|
96
|
-
score = case current_date.wday
|
|
97
|
-
when 0, 6 # Weekend - reduced activity
|
|
98
|
-
([0, 0] + base_activity.take(3)).sample
|
|
99
|
-
else # Weekday - normal patterns
|
|
100
|
-
base_activity.sample
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
data[current_date.to_s] = score
|
|
104
|
-
|
|
105
|
-
current_date += 1
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
data
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Run the generator
|
|
112
|
-
if __FILE__ == $0
|
|
113
|
-
generate_sample_svgs
|
|
114
|
-
end
|
data/examples/large_cells.svg
DELETED
|
@@ -1,3 +0,0 @@
|
|
|
1
|
-
<svg width="251" height="35" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
-
<rect x="0" y="0" width="35" height="35" fill="#9be9a8"/><rect x="0.5" y="0.5" width="34" height="34" fill="none" stroke="#6ca375" stroke-width="1"/><text x="17" y="24.0" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" fill="#000000">1</text><rect x="36" y="0" width="35" height="35" fill="#40c463"/><rect x="36.5" y="0.5" width="34" height="34" fill="none" stroke="#2c8945" stroke-width="1"/><text x="53" y="24.0" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" fill="#000000">2</text><rect x="72" y="0" width="35" height="35" fill="#30a14e"/><rect x="72.5" y="0.5" width="34" height="34" fill="none" stroke="#217036" stroke-width="1"/><text x="89" y="24.0" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" fill="#ffffff">3</text><rect x="108" y="0" width="35" height="35" fill="#216e39"/><rect x="108.5" y="0.5" width="34" height="34" fill="none" stroke="#174d27" stroke-width="1"/><text x="125" y="24.0" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" fill="#ffffff">4</text><rect x="144" y="0" width="35" height="35" fill="#9be9a8"/><rect x="144.5" y="0.5" width="34" height="34" fill="none" stroke="#6ca375" stroke-width="1"/><text x="161" y="24.0" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" fill="#000000">5</text><rect x="180" y="0" width="35" height="35" fill="#40c463"/><rect x="180.5" y="0.5" width="34" height="34" fill="none" stroke="#2c8945" stroke-width="1"/><text x="197" y="24.0" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" fill="#000000">6</text><rect x="216" y="0" width="35" height="35" fill="#30a14e"/><rect x="216.5" y="0.5" width="34" height="34" fill="none" stroke="#217036" stroke-width="1"/><text x="233" y="24.0" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" fill="#ffffff">7</text>
|
|
3
|
-
</svg>
|
|
@@ -1,3 +0,0 @@
|
|
|
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,295 +0,0 @@
|
|
|
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
|