human_number 0.1.9 → 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.
@@ -1,25 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../locale_support"
4
+ require_relative "../number_system"
4
5
 
5
6
  module HumanNumber
6
7
  module Formatters
7
8
  class Number
8
9
  extend ActionView::Helpers::NumberHelper
9
10
 
10
- # Constants for string literals and magic numbers
11
- ZERO_STRING = "0"
12
- EMPTY_SEPARATOR = ""
13
- SPACE_SEPARATOR = " "
14
- DECIMAL_FORMAT_TEMPLATE = "%.%df"
15
- MINIMUM_UNIT_COUNT = 1.0
16
- SINGLE_DIGIT_LIMIT = 1
17
-
18
- # I18n key templates
19
- I18N_DECIMAL_UNITS_KEY = "number.human.decimal_units"
20
- I18N_ABBR_UNITS_SECTION = "abbr_units"
21
- I18N_UNITS_SECTION = "units"
22
-
23
11
  class << self
24
12
  # Internal formatter for numbers with intelligent abbreviations.
25
13
  # All parameters are required - use HumanNumber.human_number for user-friendly interface.
@@ -67,14 +55,14 @@ module HumanNumber
67
55
  currency_options[:locale] = precision_locale
68
56
  currency_options[:format] = "%n" # Always format as just the number (no currency symbol)
69
57
 
70
- number_to_currency(number, **currency_options) || ZERO_STRING
58
+ number_to_currency(number, **currency_options) || NumberSystem::ZERO_STRING
71
59
  end
72
60
 
73
61
  # Default options for human number formatting
74
62
  def default_options
75
63
  {
76
64
  abbr_units: true,
77
- max_digits: 2,
65
+ max_digits: nil,
78
66
  min_unit: nil,
79
67
  trim_zeros: true,
80
68
  }.freeze
@@ -82,396 +70,21 @@ module HumanNumber
82
70
 
83
71
  # Format numbers below min_unit threshold with locale-appropriate delimiters
84
72
  def format_below_min_unit(number, locale)
85
- number_with_delimiter(number, locale: locale) || number.to_s
73
+ NumberSystem::DefaultSystem.format_below_min_unit(number, locale)
86
74
  end
87
75
 
88
76
  private
89
77
 
90
78
  def determine_system_class(locale)
91
- locale_sym = locale.to_sym
79
+ system_type = NumberSystem.number_system(locale: locale)
92
80
 
93
- case locale_sym
94
- when *EastAsianSystem::TARGET_LOCALES
95
- EastAsianSystem
96
- when *IndianSystem::TARGET_LOCALES
97
- IndianSystem
81
+ case system_type
82
+ when :east_asian
83
+ NumberSystem::EastAsianSystem
84
+ when :indian
85
+ NumberSystem::IndianSystem
98
86
  else
99
- DefaultSystem
100
- end
101
- end
102
- end
103
-
104
- # Default number system (thousand, million, billion, trillion)
105
- class DefaultSystem
106
- TARGET_LOCALES = %i[en es fr de it pt ru nl sv da no].freeze
107
-
108
- UNIT_DEFINITIONS = [
109
- { key: :trillion, divisor: 1_000_000_000_000 },
110
- { key: :billion, divisor: 1_000_000_000 },
111
- { key: :million, divisor: 1_000_000 },
112
- { key: :thousand, divisor: 1_000 },
113
- ].freeze
114
-
115
- class << self
116
- def format_number(number, locale:, abbr_units: true, min_unit: nil, max_digits: 2, trim_zeros: true)
117
- return ZERO_STRING if number.zero?
118
-
119
- # Check if number meets minimum unit threshold for human formatting
120
- return Number.format_below_min_unit(number, locale) if min_unit && number.abs < min_unit
121
-
122
- parts = if max_digits.nil?
123
- format_in_complete_mode(number, locale, abbr_units, min_unit)
124
- else
125
- format_in_abbreviated_mode(number, locale, abbr_units, max_digits, trim_zeros, min_unit)
126
- end
127
-
128
- return number.to_s if parts.nil?
129
-
130
- finalize_result(number, parts)
131
- end
132
-
133
- def format_in_complete_mode(number, locale, abbr_units, _min_unit)
134
- # Complete mode shows all units: "1M 234K 567"
135
- unit_breakdown = break_down_into_all_units(number)
136
- return nil if unit_breakdown.empty?
137
-
138
- format_all_unit_parts(unit_breakdown, locale, abbr_units)
139
- end
140
-
141
- def format_in_abbreviated_mode(number, locale, abbr_units, max_digits, trim_zeros, _min_unit)
142
- # Abbreviated mode shows single largest unit: "1.2M"
143
- unit_breakdown = break_down_into_largest_unit(number)
144
- return nil if unit_breakdown.empty?
145
-
146
- format_abbreviated_unit_parts(unit_breakdown, locale, abbr_units, max_digits, trim_zeros)
147
- end
148
-
149
- private
150
-
151
- def breakdown_number(number)
152
- result = []
153
- remaining = number.abs
154
-
155
- unit_definitions.each do |unit|
156
- next unless remaining >= unit[:divisor]
157
-
158
- count = remaining.to_f / unit[:divisor]
159
- result << { unit_key: unit[:key], count: count, divisor: unit[:divisor] }
160
- break # Only use the largest applicable unit
161
- end
162
-
163
- # If no unit matched, use the raw number
164
- result << { unit_key: :units, count: remaining, divisor: 1 } if result.empty?
165
-
166
- result
167
- end
168
-
169
- # Find the largest applicable unit for abbreviated display
170
- # Example: 1,234,567 -> [{million: 1.234567}]
171
- def break_down_into_largest_unit(number)
172
- absolute_amount = number.abs
173
-
174
- # Find the first (largest) unit where the count would be >= 1.0
175
- unit_definitions.each do |unit|
176
- unit_count = absolute_amount.to_f / unit[:divisor]
177
-
178
- return [create_unit_part(unit[:key], unit_count, unit[:divisor])] if unit_count >= MINIMUM_UNIT_COUNT
179
- end
180
-
181
- # If no unit is applicable, return the raw number
182
- [create_unit_part(:units, absolute_amount, 1)]
183
- end
184
-
185
- def unit_definitions
186
- UNIT_DEFINITIONS
187
- end
188
-
189
- # Helper method to create consistent unit part structure
190
- def create_unit_part(unit_key, count, divisor)
191
- { unit_key: unit_key, count: count, divisor: divisor }
192
- end
193
-
194
- # Filter out units below the minimum threshold
195
- def filter_by_minimum_unit_threshold(breakdown, min_unit)
196
- return breakdown unless min_unit
197
-
198
- breakdown.select { |unit_info| unit_info[:divisor] >= min_unit }
199
- end
200
-
201
- # Format unit parts for abbreviated display (e.g., "1.2M")
202
- def format_abbreviated_unit_parts(breakdown, locale, abbr_units, max_digits, trim_zeros)
203
- # Check if we're in human formatting mode (any non-:units parts exist)
204
- human_formatting_applied = breakdown.any? { |unit_info| unit_info[:unit_key] != :units }
205
-
206
- breakdown.map do |unit_info|
207
- if unit_info[:unit_key] == :units
208
- if human_formatting_applied
209
- # Part of human formatting - no delimiters
210
- unit_info[:count].to_s
211
- else
212
- # Numbers below minimum unit threshold - apply locale formatting
213
- Number.format_below_min_unit(unit_info[:count], locale)
214
- end
215
- else
216
- # Format with unit symbol (e.g., "1.2" + "M" = "1.2M")
217
- format_number_with_unit_symbol(unit_info, locale, abbr_units, max_digits, trim_zeros)
218
- end
219
- end
220
- end
221
-
222
- # Format raw number without unit symbols
223
- def format_raw_number_count(count, max_digits, trim_zeros)
224
- format_unit_count(count, max_digits:, trim_zeros:, apply_rounding: false)
225
- end
226
-
227
- # Format number with unit symbol (e.g., "1.2M")
228
- def format_number_with_unit_symbol(unit_info, locale, abbr_units, max_digits, trim_zeros)
229
- symbol = lookup_unit_symbol(locale, unit_info[:unit_key], abbr_units)
230
- format_with_symbol(unit_info[:count], symbol, abbr_units, max_digits:, trim_zeros:)
231
- end
232
-
233
- # Format unit with symbol for complete mode (e.g., "234K")
234
- def format_complete_unit_with_symbol(unit_info, locale, abbr_units)
235
- symbol = lookup_unit_symbol(locale, unit_info[:unit_key], abbr_units)
236
- count_text = unit_info[:count].to_i.to_s
237
- separator = determine_unit_value_separator(abbr_units)
238
- formatted_symbol = apply_symbol_formatting_rules(symbol, abbr_units)
239
- "#{count_text}#{separator}#{formatted_symbol}"
240
- end
241
-
242
- def format_unit_count(count, max_digits: 2, trim_zeros: true, apply_rounding: true)
243
- return count.to_s unless count.is_a?(Numeric)
244
-
245
- rounded = apply_rounding ? round_to_significant_digits(count, max_digits) : count
246
-
247
- format_rounded_count(rounded, max_digits, trim_zeros, apply_rounding, count)
248
- end
249
-
250
- def format_rounded_count(rounded, max_digits, trim_zeros, apply_rounding, original_count)
251
- if trim_zeros
252
- format_with_trimmed_zeros(rounded)
253
- elsif apply_rounding
254
- format_with_fixed_decimals(rounded, max_digits)
255
- else
256
- original_count.to_s
257
- end
258
- end
259
-
260
- def format_with_trimmed_zeros(rounded)
261
- if rounded == rounded.to_i
262
- rounded.to_i.to_s
263
- else
264
- rounded.to_s
265
- end
266
- end
267
-
268
- def format_with_fixed_decimals(rounded, max_digits)
269
- integer_digits = rounded.to_i.to_s.length
270
- decimal_places = [0, max_digits - integer_digits].max
271
- format("%.#{decimal_places}f", rounded)
272
- end
273
-
274
- def round_to_significant_digits(number, digits)
275
- return handle_edge_cases(number, digits) if should_handle_edge_case?(number, digits)
276
-
277
- abs_num = number.abs
278
-
279
- # Simple significant digits rounding using scientific notation
280
- magnitude = Math.log10(abs_num).floor
281
- scale_factor = 10**(digits - 1 - magnitude)
282
-
283
- rounded = (abs_num * scale_factor).round / scale_factor.to_f
284
- apply_sign(number, rounded)
285
- end
286
-
287
- def should_handle_edge_case?(number, digits)
288
- number.zero? || digits <= 0
289
- end
290
-
291
- def handle_edge_cases(number, _digits)
292
- return 0.0 if number.zero?
293
-
294
- number
295
- end
296
-
297
- def apply_sign(original_number, result)
298
- original_number.negative? ? -result : result
299
- end
300
-
301
- def count_integer_digits(int_part)
302
- int_part.zero? ? SINGLE_DIGIT_LIMIT : int_part.to_s.length
303
- end
304
-
305
- # Look up the localized symbol for a unit (e.g., 'M' for million)
306
- def lookup_unit_symbol(locale, unit_key, abbr_units)
307
- section = abbr_units ? I18N_ABBR_UNITS_SECTION : I18N_UNITS_SECTION
308
-
309
- I18n.t("#{I18N_DECIMAL_UNITS_KEY}.#{section}.#{unit_key}", locale:, default: nil) ||
310
- I18n.t("#{I18N_DECIMAL_UNITS_KEY}.#{section}.#{unit_key}", locale: :en, default: nil) ||
311
- unit_key.to_s.upcase
312
- end
313
-
314
- def format_with_symbol(value, symbol, abbr_units, max_digits: 2, trim_zeros: true)
315
- # Apply significant digits rounding to unit values
316
- formatted_count = format_unit_count(value, max_digits:, trim_zeros:, apply_rounding: true)
317
- separator = determine_unit_value_separator(abbr_units)
318
- formatted_symbol = apply_symbol_formatting_rules(symbol, abbr_units)
319
- "#{formatted_count}#{separator}#{formatted_symbol}"
320
- end
321
-
322
- # Determine separator between number and unit ("1M" vs "1 million")
323
- def determine_unit_value_separator(abbr_units)
324
- abbr_units ? EMPTY_SEPARATOR : SPACE_SEPARATOR
325
- end
326
-
327
- # Apply locale-specific formatting rules to unit symbols
328
- def apply_symbol_formatting_rules(symbol, abbr_units)
329
- return symbol if abbr_units || symbol.length <= 1
330
-
331
- symbol.downcase # Western lowercase rule for full words
332
- end
333
-
334
- # Break down number into all applicable units for complete display
335
- # Example: 1,234,567 -> [{million: 1}, {thousand: 234}, {units: 567}]
336
- def break_down_into_all_units(number)
337
- unit_breakdown = UnitBreakdown.new(number.abs, unit_definitions)
338
- unit_breakdown.extract_all_units
339
- end
340
-
341
- # Helper class to manage unit breakdown state
342
- class UnitBreakdown
343
- def initialize(amount, unit_definitions)
344
- @remaining_amount = amount
345
- @unit_definitions = unit_definitions
346
- @unit_parts = []
347
- end
348
-
349
- def extract_all_units
350
- extract_major_units
351
- add_remaining_as_units
352
- @unit_parts
353
- end
354
-
355
- private
356
-
357
- def extract_major_units
358
- @unit_definitions.each do |unit|
359
- extract_single_unit(unit) if @remaining_amount >= unit[:divisor]
360
- end
361
- end
362
-
363
- def extract_single_unit(unit)
364
- unit_count = (@remaining_amount / unit[:divisor]).to_i
365
- return if unit_count.zero?
366
-
367
- @unit_parts << create_unit_part(unit[:key], unit_count, unit[:divisor])
368
- @remaining_amount %= unit[:divisor]
369
- end
370
-
371
- def add_remaining_as_units
372
- return unless @remaining_amount.positive?
373
-
374
- @unit_parts << create_unit_part(:units, @remaining_amount, 1)
375
- end
376
-
377
- def create_unit_part(unit_key, count, divisor)
378
- { unit_key: unit_key, count: count, divisor: divisor }
379
- end
380
- end
381
-
382
- # Format unit parts for complete display (e.g., "1M 234K 567")
383
- def format_all_unit_parts(breakdown, locale, abbr_units)
384
- # Check if we're in human formatting mode (any non-:units parts exist)
385
- human_formatting_applied = breakdown.any? { |unit_info| unit_info[:unit_key] != :units }
386
-
387
- breakdown.map do |unit_info|
388
- if unit_info[:unit_key] == :units
389
- if human_formatting_applied
390
- # Part of human formatting - no delimiters
391
- unit_info[:count].to_i.to_s
392
- else
393
- # Numbers below minimum unit threshold - apply locale formatting
394
- Number.format_below_min_unit(unit_info[:count].to_i, locale)
395
- end
396
- else
397
- # Format each unit part with its symbol
398
- format_complete_unit_with_symbol(unit_info, locale, abbr_units)
399
- end
400
- end
401
- end
402
-
403
- # Apply sign and join all parts into final result
404
- def finalize_result(number, formatted_parts)
405
- combined_result = formatted_parts.join(SPACE_SEPARATOR)
406
- number.negative? ? "-#{combined_result}" : combined_result
407
- end
408
- end
409
- end
410
-
411
- # East Asian number system (thousand, ten_thousand, hundred_million, trillion)
412
- class EastAsianSystem < DefaultSystem
413
- TARGET_LOCALES = %i[ko ja zh zh-CN zh-TW].freeze
414
-
415
- UNIT_DEFINITIONS = [
416
- { key: :trillion, divisor: 1_000_000_000_000 },
417
- { key: :hundred_million, divisor: 100_000_000 },
418
- { key: :ten_thousand, divisor: 10_000 },
419
- ].freeze
420
-
421
- class << self
422
- def format_number(number, locale:, abbr_units: true, min_unit: nil, max_digits: 2, trim_zeros: true)
423
- @current_locale = locale.to_sym
424
- super
425
- end
426
-
427
- private
428
-
429
- def unit_definitions
430
- UNIT_DEFINITIONS
431
- end
432
-
433
- def format_with_symbol(value, symbol, _abbr_units, max_digits: 2, trim_zeros: true)
434
- # East Asian: no separator regardless of abbr_units
435
- # Apply significant digits rounding to unit values
436
- formatted_count = format_unit_count(value, max_digits:, trim_zeros:, apply_rounding: true)
437
- formatted_symbol = apply_symbol_formatting_rules(symbol, true)
438
- "#{formatted_count}#{formatted_symbol}"
439
- end
440
-
441
- # Keep original case for Asian characters (만, 억, 조)
442
- def apply_symbol_formatting_rules(symbol, _abbr_units)
443
- symbol
444
- end
445
-
446
- # Apply culturally appropriate separators for East Asian languages
447
- def finalize_result(number, formatted_parts)
448
- separator = lookup_decimal_separator(@current_locale)
449
- combined_result = formatted_parts.join(separator)
450
- number.negative? ? "-#{combined_result}" : combined_result
451
- end
452
-
453
- # Look up separator from locale file, default to space
454
- def lookup_decimal_separator(locale)
455
- I18n.t("#{I18N_DECIMAL_UNITS_KEY}.separator", locale: locale, default: SPACE_SEPARATOR)
456
- end
457
- end
458
- end
459
-
460
- # Indian subcontinent number system (thousand, lakh, crore)
461
- class IndianSystem < DefaultSystem
462
- TARGET_LOCALES = %i[hi ur bn en-IN].freeze
463
-
464
- UNIT_DEFINITIONS = [
465
- { key: :crore, divisor: 10_000_000 },
466
- { key: :lakh, divisor: 100_000 },
467
- { key: :thousand, divisor: 1_000 },
468
- ].freeze
469
-
470
- class << self
471
- private
472
-
473
- def unit_definitions
474
- UNIT_DEFINITIONS
87
+ NumberSystem::DefaultSystem
475
88
  end
476
89
  end
477
90
  end