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