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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.gitignore +3 -0
- data/CHANGELOG.md +33 -2
- data/Gemfile.lock +9 -1
- data/README.md +340 -37
- data/Rakefile +15 -0
- data/bin/generate_examples +234 -0
- data/examples/calendar_blue_ocean.svg +1 -0
- data/examples/calendar_default.svg +1 -0
- data/examples/calendar_github_style.svg +1 -3
- 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/examples/large_cells.svg +1 -3
- data/examples/linear_blue_ocean.svg +1 -0
- data/examples/linear_github_green.svg +1 -0
- data/examples/linear_neon_gradient.svg +1 -0
- data/examples/linear_purple_vibes.svg +1 -0
- data/examples/linear_red_to_green.svg +1 -0
- data/examples/linear_rounded_corners.svg +1 -0
- data/examples/linear_rounded_corners_max_radius.svg +1 -0
- data/examples/linear_warm_sunset.svg +1 -0
- data/examples/weekly_progress.svg +1 -3
- data/heatmap-builder.gemspec +1 -0
- data/lib/heatmap-builder.rb +47 -3
- data/lib/heatmap_builder/builder.rb +100 -0
- data/lib/heatmap_builder/calendar_heatmap_builder.rb +177 -162
- data/lib/heatmap_builder/color_helpers.rb +170 -0
- data/lib/heatmap_builder/linear_heatmap_builder.rb +49 -103
- data/lib/heatmap_builder/svg_helpers.rb +75 -0
- data/lib/heatmap_builder/value_conversion.rb +64 -0
- data/lib/heatmap_builder/version.rb +1 -1
- metadata +36 -4
- data/examples/generate_samples.rb +0 -114
- data/mise.toml +0 -2
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
module HeatmapBuilder
|
|
2
|
+
module ColorHelpers
|
|
3
|
+
private
|
|
4
|
+
|
|
5
|
+
def score_to_color(score, colors:)
|
|
6
|
+
# Generate color palette if colors is a hash
|
|
7
|
+
if colors.is_a?(Hash)
|
|
8
|
+
colors = generate_color_palette(colors[:from], colors[:to], colors[:steps])
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
return colors.first if score == 0
|
|
12
|
+
|
|
13
|
+
max_color_index = colors.length - 1
|
|
14
|
+
color_index = 1 + (score - 1) % max_color_index
|
|
15
|
+
colors[color_index]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def darker_color(hex_color, factor: 0.9)
|
|
19
|
+
rgb = hex_to_rgb(hex_color)
|
|
20
|
+
oklch = rgb_to_oklch(*rgb)
|
|
21
|
+
|
|
22
|
+
# Reduce lightness (L component) by factor
|
|
23
|
+
darker_oklch = [oklch[0] * factor, oklch[1], oklch[2]]
|
|
24
|
+
darker_rgb = oklch_to_rgb(*darker_oklch)
|
|
25
|
+
|
|
26
|
+
rgb_to_hex(*darker_rgb)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def make_color_inactive(hex_color)
|
|
30
|
+
# Convert to OKLCH for blending
|
|
31
|
+
rgb = hex_to_rgb(hex_color)
|
|
32
|
+
oklch = rgb_to_oklch(*rgb)
|
|
33
|
+
|
|
34
|
+
# Reduce chroma (saturation) to make it more muted
|
|
35
|
+
# Also slightly reduce lightness
|
|
36
|
+
inactive_oklch = [
|
|
37
|
+
oklch[0] * 0.85, # Slightly reduce lightness
|
|
38
|
+
oklch[1] * 0.4, # Significantly reduce chroma (saturation)
|
|
39
|
+
oklch[2] # Keep hue unchanged
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
inactive_rgb = oklch_to_rgb(*inactive_oklch)
|
|
43
|
+
rgb_to_hex(*inactive_rgb)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def rgb_to_oklch(r, g, b)
|
|
47
|
+
# Convert to linear RGB first
|
|
48
|
+
r_linear = srgb_to_linear(r / 255.0)
|
|
49
|
+
g_linear = srgb_to_linear(g / 255.0)
|
|
50
|
+
b_linear = srgb_to_linear(b / 255.0)
|
|
51
|
+
|
|
52
|
+
# Linear RGB to OKLab using the Oklab transformation matrix
|
|
53
|
+
l = 0.4122214708 * r_linear + 0.5363325363 * g_linear + 0.0514459929 * b_linear
|
|
54
|
+
m = 0.2119034982 * r_linear + 0.6806995451 * g_linear + 0.1073969566 * b_linear
|
|
55
|
+
s = 0.0883024619 * r_linear + 0.2817188376 * g_linear + 0.6299787005 * b_linear
|
|
56
|
+
|
|
57
|
+
# Apply cube root
|
|
58
|
+
l_root = (l >= 0) ? l**(1.0 / 3) : -((-l)**(1.0 / 3))
|
|
59
|
+
m_root = (m >= 0) ? m**(1.0 / 3) : -((-m)**(1.0 / 3))
|
|
60
|
+
s_root = (s >= 0) ? s**(1.0 / 3) : -((-s)**(1.0 / 3))
|
|
61
|
+
|
|
62
|
+
# Convert to OKLab
|
|
63
|
+
ok_l = 0.2104542553 * l_root + 0.7936177850 * m_root - 0.0040720468 * s_root
|
|
64
|
+
ok_a = 1.9779984951 * l_root - 2.4285922050 * m_root + 0.4505937099 * s_root
|
|
65
|
+
ok_b = 0.0259040371 * l_root + 0.7827717662 * m_root - 0.8086757660 * s_root
|
|
66
|
+
|
|
67
|
+
# Convert OKLab to OKLCH
|
|
68
|
+
chroma = Math.sqrt(ok_a * ok_a + ok_b * ok_b)
|
|
69
|
+
hue = Math.atan2(ok_b, ok_a) * 180.0 / Math::PI
|
|
70
|
+
hue += 360 if hue < 0
|
|
71
|
+
|
|
72
|
+
[ok_l, chroma, hue]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def oklch_to_rgb(ok_l, chroma, hue)
|
|
76
|
+
# Convert OKLCH to OKLab
|
|
77
|
+
hue_rad = hue * Math::PI / 180.0
|
|
78
|
+
ok_a = chroma * Math.cos(hue_rad)
|
|
79
|
+
ok_b = chroma * Math.sin(hue_rad)
|
|
80
|
+
|
|
81
|
+
# OKLab to linear RGB
|
|
82
|
+
l_root = ok_l + 0.3963377774 * ok_a + 0.2158037573 * ok_b
|
|
83
|
+
m_root = ok_l - 0.1055613458 * ok_a - 0.0638541728 * ok_b
|
|
84
|
+
s_root = ok_l - 0.0894841775 * ok_a - 1.2914855480 * ok_b
|
|
85
|
+
|
|
86
|
+
# Cube the values
|
|
87
|
+
l = l_root * l_root * l_root
|
|
88
|
+
m = m_root * m_root * m_root
|
|
89
|
+
s = s_root * s_root * s_root
|
|
90
|
+
|
|
91
|
+
# Convert to linear RGB
|
|
92
|
+
r_linear = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
|
|
93
|
+
g_linear = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
|
|
94
|
+
b_linear = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
|
|
95
|
+
|
|
96
|
+
# Convert to sRGB
|
|
97
|
+
r = linear_to_srgb(r_linear)
|
|
98
|
+
g = linear_to_srgb(g_linear)
|
|
99
|
+
b = linear_to_srgb(b_linear)
|
|
100
|
+
|
|
101
|
+
# Clamp to 0-255 and convert to integers
|
|
102
|
+
r = (r * 255).clamp(0, 255).round
|
|
103
|
+
g = (g * 255).clamp(0, 255).round
|
|
104
|
+
b = (b * 255).clamp(0, 255).round
|
|
105
|
+
|
|
106
|
+
[r, g, b]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def srgb_to_linear(component)
|
|
110
|
+
(component <= 0.04045) ? component / 12.92 : ((component + 0.055) / 1.055)**2.4
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def linear_to_srgb(component)
|
|
114
|
+
(component <= 0.0031308) ? component * 12.92 : 1.055 * (component**(1.0 / 2.4)) - 0.055
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def interpolate_oklch(oklch1, oklch2, ratio)
|
|
118
|
+
# Handle hue interpolation (shortest path around the circle)
|
|
119
|
+
hue1, hue2 = oklch1[2], oklch2[2]
|
|
120
|
+
hue_diff = hue2 - hue1
|
|
121
|
+
|
|
122
|
+
# Take shorter path around the circle
|
|
123
|
+
if hue_diff > 180
|
|
124
|
+
hue_diff -= 360
|
|
125
|
+
elsif hue_diff < -180
|
|
126
|
+
hue_diff += 360
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
interpolated_hue = hue1 + hue_diff * ratio
|
|
130
|
+
interpolated_hue += 360 if interpolated_hue < 0
|
|
131
|
+
interpolated_hue -= 360 if interpolated_hue >= 360
|
|
132
|
+
|
|
133
|
+
[
|
|
134
|
+
oklch1[0] + (oklch2[0] - oklch1[0]) * ratio, # L (lightness)
|
|
135
|
+
oklch1[1] + (oklch2[1] - oklch1[1]) * ratio, # C (chroma)
|
|
136
|
+
interpolated_hue # H (hue)
|
|
137
|
+
]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def hex_to_rgb(hex_color)
|
|
141
|
+
hex = hex_color.delete("#")
|
|
142
|
+
r = hex[0..1].to_i(16)
|
|
143
|
+
g = hex[2..3].to_i(16)
|
|
144
|
+
b = hex[4..5].to_i(16)
|
|
145
|
+
[r, g, b]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def rgb_to_hex(r, g, b)
|
|
149
|
+
"#%02x%02x%02x" % [r, g, b]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def generate_color_palette(from_color, to_color, steps)
|
|
153
|
+
from_rgb = hex_to_rgb(from_color)
|
|
154
|
+
to_rgb = hex_to_rgb(to_color)
|
|
155
|
+
|
|
156
|
+
from_oklch = rgb_to_oklch(*from_rgb)
|
|
157
|
+
to_oklch = rgb_to_oklch(*to_rgb)
|
|
158
|
+
|
|
159
|
+
colors = []
|
|
160
|
+
(0...steps).each do |i|
|
|
161
|
+
ratio = i.to_f / (steps - 1)
|
|
162
|
+
interpolated_oklch = interpolate_oklch(from_oklch, to_oklch, ratio)
|
|
163
|
+
interpolated_rgb = oklch_to_rgb(*interpolated_oklch)
|
|
164
|
+
colors << rgb_to_hex(*interpolated_rgb)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
colors
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -1,128 +1,74 @@
|
|
|
1
|
+
require_relative "builder"
|
|
2
|
+
require_relative "value_conversion"
|
|
3
|
+
|
|
1
4
|
module HeatmapBuilder
|
|
2
|
-
class LinearHeatmapBuilder
|
|
3
|
-
|
|
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
|
|
5
|
+
class LinearHeatmapBuilder < Builder
|
|
6
|
+
include ValueConversion
|
|
17
7
|
|
|
18
|
-
def
|
|
19
|
-
|
|
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 }
|
|
20
17
|
end
|
|
21
18
|
|
|
22
19
|
private
|
|
23
20
|
|
|
24
|
-
attr_reader :scores, :options
|
|
25
|
-
|
|
26
21
|
def validate_options!
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
raise Error, "
|
|
30
|
-
raise Error, "
|
|
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
|
|
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)
|
|
33
26
|
end
|
|
34
27
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
def computed_scores
|
|
29
|
+
@computed_scores ||= scores || values.map.with_index { |value, index| convert_value_to_score(value, index: index) }
|
|
30
|
+
end
|
|
38
31
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
def calculated_min_from_values
|
|
33
|
+
non_nil_values = values.compact
|
|
34
|
+
non_nil_values.empty? ? 0 : non_nil_values.min
|
|
35
|
+
end
|
|
42
36
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
</svg>
|
|
47
|
-
SVG
|
|
37
|
+
def calculated_max_from_values
|
|
38
|
+
non_nil_values = values.compact
|
|
39
|
+
non_nil_values.empty? ? 0 : non_nil_values.max
|
|
48
40
|
end
|
|
49
41
|
|
|
50
42
|
def cell_svg(score, index)
|
|
51
|
-
# Calculate x position - each cell takes cell_size + spacing
|
|
52
43
|
x = index * (options[:cell_size] + options[:cell_spacing])
|
|
53
44
|
y = 0
|
|
54
45
|
|
|
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>"
|
|
46
|
+
color = score_to_color(score, colors: color_palette)
|
|
79
47
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
+
)
|
|
101
54
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
options[:cell_size]
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def darker_color(hex_color)
|
|
112
|
-
# Remove # if present
|
|
113
|
-
hex = hex_color.delete("#")
|
|
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
|
+
)
|
|
114
61
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
g = hex[2..3].to_i(16)
|
|
118
|
-
b = hex[4..5].to_i(16)
|
|
62
|
+
text_x = x + options[:cell_size] / 2
|
|
63
|
+
text_y = y + options[:cell_size] / 2 + options[:font_size] * 0.35
|
|
119
64
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
)
|
|
124
70
|
|
|
125
|
-
"
|
|
71
|
+
"#{colored_rect}#{border_rect}#{text_element}"
|
|
126
72
|
end
|
|
127
73
|
end
|
|
128
74
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module HeatmapBuilder
|
|
2
|
+
module SvgHelpers
|
|
3
|
+
private
|
|
4
|
+
|
|
5
|
+
def svg_element(tag, attributes = {}, &block)
|
|
6
|
+
attr_string = attributes.map do |key, value|
|
|
7
|
+
"#{kebab_case(key)}=\"#{value}\""
|
|
8
|
+
end.join(" ")
|
|
9
|
+
|
|
10
|
+
attr_string = " #{attr_string}" unless attr_string.empty?
|
|
11
|
+
|
|
12
|
+
if block_given?
|
|
13
|
+
content = block.call
|
|
14
|
+
"<#{tag}#{attr_string}>#{content}</#{tag}>"
|
|
15
|
+
else
|
|
16
|
+
"<#{tag}#{attr_string}/>"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def svg_rect(x:, y:, width:, height:, rx: nil, **attributes)
|
|
21
|
+
attrs = {
|
|
22
|
+
x: x,
|
|
23
|
+
y: y,
|
|
24
|
+
width: width,
|
|
25
|
+
height: height
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
attrs[:rx] = rx if rx && rx > 0
|
|
29
|
+
svg_element("rect", attrs.merge(attributes))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def svg_text(content, x:, y:, **attributes)
|
|
33
|
+
default_attrs = {
|
|
34
|
+
text_anchor: "middle",
|
|
35
|
+
font_family: "Arial, sans-serif"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
svg_element("text", {x: x, y: y}.merge(default_attrs).merge(attributes)) { content }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def svg_container(width:, height:, &block)
|
|
42
|
+
svg_element("svg", {
|
|
43
|
+
width: width,
|
|
44
|
+
height: height,
|
|
45
|
+
xmlns: "http://www.w3.org/2000/svg"
|
|
46
|
+
}, &block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def kebab_case(key)
|
|
50
|
+
key.to_s.tr("_", "-")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cell_border(x, y, color, cell_size:, border_width:, corner_radius:)
|
|
54
|
+
return "" unless border_width > 0
|
|
55
|
+
|
|
56
|
+
inset = border_width / 2.0
|
|
57
|
+
border_x = x + inset
|
|
58
|
+
border_y = y + inset
|
|
59
|
+
border_size = cell_size - border_width
|
|
60
|
+
border_color = darker_color(color)
|
|
61
|
+
border_radius = (corner_radius > 0) ? [corner_radius - inset, 0].max : 0
|
|
62
|
+
|
|
63
|
+
svg_rect(
|
|
64
|
+
x: border_x,
|
|
65
|
+
y: border_y,
|
|
66
|
+
width: border_size,
|
|
67
|
+
height: border_size,
|
|
68
|
+
rx: border_radius,
|
|
69
|
+
fill: "none",
|
|
70
|
+
stroke: border_color,
|
|
71
|
+
stroke_width: border_width
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
value = value_min if value.nil?
|
|
36
|
+
|
|
37
|
+
if options[:value_to_score]
|
|
38
|
+
score = options[:value_to_score].call(
|
|
39
|
+
value: value,
|
|
40
|
+
min: value_min,
|
|
41
|
+
max: value_max,
|
|
42
|
+
max_score: color_count - 1,
|
|
43
|
+
**params
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
unless score.is_a?(Integer) && score >= 0 && score < color_count
|
|
47
|
+
raise Error, "value_to_score must return an integer between 0 and #{color_count - 1}, got #{score.inspect}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return score
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
clamped_value = value.clamp(value_min, value_max)
|
|
54
|
+
|
|
55
|
+
if value_min == value_max
|
|
56
|
+
0
|
|
57
|
+
else
|
|
58
|
+
range = value_max - value_min
|
|
59
|
+
normalized = (clamped_value - value_min).to_f / range
|
|
60
|
+
(normalized * (color_count - 1)).round
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: heatmap-builder
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Musayev
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-
|
|
11
|
+
date: 2025-10-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|
|
@@ -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,37 @@ 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_default.svg
|
|
89
106
|
- examples/calendar_github_style.svg
|
|
107
|
+
- examples/calendar_purple_vibes.svg
|
|
108
|
+
- examples/calendar_red_to_green.svg
|
|
109
|
+
- examples/calendar_rounded_corners.svg
|
|
110
|
+
- examples/calendar_rounded_corners_max_radius.svg
|
|
90
111
|
- examples/calendar_sunday_start.svg
|
|
112
|
+
- examples/calendar_warm_sunset.svg
|
|
91
113
|
- examples/calendar_with_outside_cells.svg
|
|
92
|
-
- examples/generate_samples.rb
|
|
93
114
|
- examples/large_cells.svg
|
|
115
|
+
- examples/linear_blue_ocean.svg
|
|
116
|
+
- examples/linear_github_green.svg
|
|
117
|
+
- examples/linear_neon_gradient.svg
|
|
118
|
+
- examples/linear_purple_vibes.svg
|
|
119
|
+
- examples/linear_red_to_green.svg
|
|
120
|
+
- examples/linear_rounded_corners.svg
|
|
121
|
+
- examples/linear_rounded_corners_max_radius.svg
|
|
122
|
+
- examples/linear_warm_sunset.svg
|
|
94
123
|
- examples/weekly_progress.svg
|
|
95
124
|
- heatmap-builder.gemspec
|
|
96
125
|
- lib/heatmap-builder.rb
|
|
126
|
+
- lib/heatmap_builder/builder.rb
|
|
97
127
|
- lib/heatmap_builder/calendar_heatmap_builder.rb
|
|
128
|
+
- lib/heatmap_builder/color_helpers.rb
|
|
98
129
|
- lib/heatmap_builder/linear_heatmap_builder.rb
|
|
130
|
+
- lib/heatmap_builder/svg_helpers.rb
|
|
131
|
+
- lib/heatmap_builder/value_conversion.rb
|
|
99
132
|
- lib/heatmap_builder/version.rb
|
|
100
|
-
- mise.toml
|
|
101
133
|
homepage: https://github.com/dreikanter/heatmap-builder
|
|
102
134
|
licenses:
|
|
103
135
|
- MIT
|
|
@@ -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/mise.toml
DELETED