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
|
@@ -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>
|
data/heatmap-builder.gemspec
CHANGED
data/lib/heatmap-builder.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
46
|
+
def start_date
|
|
47
|
+
@start_date ||= parsed_date_range.first
|
|
48
|
+
end
|
|
33
49
|
|
|
34
|
-
def
|
|
35
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
if options[:show_day_labels]
|
|
62
|
-
svg_content << day_labels_svg
|
|
78
|
+
result
|
|
63
79
|
end
|
|
80
|
+
end
|
|
64
81
|
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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 =
|
|
97
|
-
y =
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 =
|
|
151
|
-
svg <<
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
|
199
|
-
|
|
200
|
+
def month_overlaps_timeframe?(current_date)
|
|
201
|
+
month_start = Date.new(current_date.year, current_date.month, 1)
|
|
202
|
+
month_end = Date.new(current_date.year, current_date.month, -1)
|
|
203
|
+
|
|
204
|
+
month_start <= end_date && month_end >= start_date
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def render_month_label(current_date)
|
|
208
|
+
first_day_of_month = Date.new(current_date.year, current_date.month, 1)
|
|
209
|
+
month_name = options[:month_labels][current_date.month - 1]
|
|
210
|
+
|
|
211
|
+
svg_text(
|
|
212
|
+
month_name,
|
|
213
|
+
x: calculate_month_label_x(first_day_of_month),
|
|
214
|
+
y: calculate_month_label_y,
|
|
215
|
+
text_anchor: "start", font_family: "Arial, sans-serif", font_size: options[:font_size], fill: "#666666"
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def calculate_month_label_y
|
|
220
|
+
options[:font_size] * 1.25
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def calculate_month_label_x(first_day_of_month)
|
|
224
|
+
days_from_start = (first_day_of_month - calendar_start_date).to_i
|
|
225
|
+
week_index = days_from_start / 7
|
|
226
|
+
x_offset = calculate_x_offset_for_week(week_index)
|
|
200
227
|
|
|
201
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
285
|
+
def dow_label_offset
|
|
273
286
|
options[:show_day_labels] ? options[:font_size] * 2 : 0
|
|
274
287
|
end
|
|
275
288
|
|
|
276
|
-
def
|
|
277
|
-
options[:show_month_labels] ? options[:font_size]
|
|
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
|