heatmap-builder 0.2.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/CHANGELOG.md +70 -3
- data/Gemfile.lock +27 -24
- data/README.md +105 -135
- data/bin/generate_examples +42 -99
- data/examples/calendar_blue_ocean.svg +1 -1
- data/examples/calendar_cell_borders.svg +1 -0
- data/examples/calendar_default.svg +1 -1
- data/examples/calendar_github_style.svg +1 -1
- data/examples/calendar_month_spacing_rounded.svg +1 -0
- data/examples/calendar_no_borders.svg +1 -0
- data/examples/calendar_purple_vibes.svg +1 -1
- data/examples/calendar_red_to_green.svg +1 -1
- data/examples/calendar_rounded_corners.svg +1 -1
- data/examples/calendar_rounded_corners_max_radius.svg +1 -1
- data/examples/calendar_sunday_start.svg +1 -1
- data/examples/calendar_warm_sunset.svg +1 -1
- data/examples/calendar_with_outside_cells.svg +1 -1
- data/heatmap-builder.gemspec +4 -4
- data/lib/heatmap-builder.rb +5 -31
- data/lib/heatmap_builder/calendar.rb +426 -0
- data/lib/heatmap_builder/color_helpers.rb +121 -141
- data/lib/heatmap_builder/svg_helpers.rb +11 -4
- data/lib/heatmap_builder/value_conversion.rb +9 -5
- data/lib/heatmap_builder/version.rb +1 -1
- metadata +13 -22
- data/examples/large_cells.svg +0 -1
- data/examples/linear_blue_ocean.svg +0 -1
- data/examples/linear_github_green.svg +0 -1
- data/examples/linear_neon_gradient.svg +0 -1
- data/examples/linear_purple_vibes.svg +0 -1
- data/examples/linear_red_to_green.svg +0 -1
- data/examples/linear_rounded_corners.svg +0 -1
- data/examples/linear_rounded_corners_max_radius.svg +0 -1
- data/examples/linear_warm_sunset.svg +0 -1
- data/examples/weekly_progress.svg +0 -1
- data/lib/heatmap_builder/builder.rb +0 -100
- data/lib/heatmap_builder/calendar_heatmap_builder.rb +0 -310
- data/lib/heatmap_builder/linear_heatmap_builder.rb +0 -74
|
@@ -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
|