Skip to content

Commit

Permalink
reworked implementation for efficiency & flexibility
Browse files Browse the repository at this point in the history
  • Loading branch information
Reed Es committed Nov 17, 2021
1 parent 17faa49 commit 03580fb
Show file tree
Hide file tree
Showing 4 changed files with 168 additions and 138 deletions.
128 changes: 71 additions & 57 deletions Sources/NumberCompactor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,13 @@ import Foundation

public class NumberCompactor: NumberFormatter {

public let blankIfZero: Bool
public let roundSmallToWhole: Bool

let threshold: Double
let netKiloExtent: Double
let netMegaExtent: Double
let netGigaExtent: Double
let netTeraExtent: Double
let netPetaExtent: Double
let netExaExtent: Double
public var blankIfZero: Bool
public var roundSmallToWhole: Bool

public init(blankIfZero: Bool = false,
roundSmallToWhole: Bool = false) {
self.blankIfZero = blankIfZero
self.roundSmallToWhole = roundSmallToWhole
self.threshold = roundSmallToWhole ? 0.5 : 0.05
self.netKiloExtent = Scale.kilo.extent - threshold
self.netMegaExtent = Scale.mega.extent - threshold * Scale.kilo.extent
self.netGigaExtent = Scale.giga.extent - threshold * Scale.mega.extent
self.netTeraExtent = Scale.tera.extent - threshold * Scale.giga.extent
self.netPetaExtent = Scale.peta.extent - threshold * Scale.tera.extent
self.netExaExtent = Scale.exa.extent - threshold * Scale.peta.extent
super.init()
}

Expand All @@ -50,59 +35,88 @@ public class NumberCompactor: NumberFormatter {
}

public override func string(from value: NSNumber) -> String? {
let absValue = abs(Double(truncating: value))
let rawValue: Double = Double(truncating: value)
let absValue = abs(rawValue)
let threshold = NumberCompactor.getThreshold(roundSmallToWhole)

if blankIfZero, absValue <= threshold { return "" }

let (scaledValue, scaleSymbol) = NumberCompactor.getScaledValue(rawValue, roundSmallToWhole)

var netValue = Double(truncating: value)
var scaleSymbol: Scale = .none

switch absValue {
case 0.0 ... threshold:
// if inside threshold, drop the fraction, to avoid awkward "-$0"
netValue = 0.0
case threshold ..< netKiloExtent:
_ = 0 // verbatim netValue
case netKiloExtent ..< netMegaExtent:
netValue /= Scale.kilo.extent
scaleSymbol = .kilo
case netMegaExtent ..< netGigaExtent:
netValue /= Scale.mega.extent
scaleSymbol = .mega
case netGigaExtent ..< netTeraExtent:
netValue /= Scale.giga.extent
scaleSymbol = .giga
case netTeraExtent ..< netPetaExtent:
netValue /= Scale.tera.extent
scaleSymbol = .tera
case netPetaExtent ..< netExaExtent:
netValue /= Scale.peta.extent
scaleSymbol = .peta
default:
netValue /= Scale.exa.extent
scaleSymbol = .exa
}

let smallValueThreshold = 100 - threshold
let isSmallAbsValue = absValue < smallValueThreshold
let isLargeNetValue = smallValueThreshold <= abs(netValue)
let roundToWhole = isSmallAbsValue && roundSmallToWhole
let fractionDigitCount = roundToWhole || isLargeNetValue ? 0 : 1
let showWholeValue: Bool = {
let smallValueThreshold = 100 - threshold
if smallValueThreshold <= abs(scaledValue) { return true }
let isSmallAbsValue = absValue < smallValueThreshold
return isSmallAbsValue && roundSmallToWhole
}()

let fractionDigitCount = showWholeValue ? 0 : 1
self.maximumFractionDigits = fractionDigitCount
self.minimumFractionDigits = fractionDigitCount
self.usesGroupingSeparator = false

guard let raw = super.string(from: netValue as NSNumber) else { return nil }

guard let lastDigitIndex = raw.lastIndex(where: { $0.isNumber }) else { return nil }
guard let raw = super.string(from: scaledValue as NSNumber),
let lastDigitIndex = raw.lastIndex(where: { $0.isNumber })
else { return nil }

let afterLastDigitIndex = raw.index(after: lastDigitIndex)

let prefix = raw.prefix(upTo: afterLastDigitIndex)
let suffix = raw.suffix(from: afterLastDigitIndex)

return "\(prefix)\(scaleSymbol.abbreviation)\(suffix)"
}
}

extension NumberCompactor {

private typealias LOOKUP = (range: Range<Double>, divisor: Double, scale: Scale)

// thresholds
private static let halfDollar: Double = 0.5
private static let nickel: Double = 0.05

// cached lookup tables
private static let halfDollarLookup: [LOOKUP] = NumberCompactor.generateLookup(threshold: halfDollar)
private static let nickelLookup: [LOOKUP] = NumberCompactor.generateLookup(threshold: nickel)

static func getThreshold(_ roundSmallToWhole: Bool) -> Double {
roundSmallToWhole ? NumberCompactor.halfDollar : NumberCompactor.nickel
}

static func getScaledValue(_ rawValue: Double, _ roundSmallToWhole: Bool) -> (Double, Scale) {
let threshold = getThreshold(roundSmallToWhole)
let absValue = abs(rawValue)
if !(0.0...threshold).contains(absValue) {
if let (divisor, scale) = NumberCompactor.lookup(roundSmallToWhole, absValue) {
let netValue = rawValue / divisor
return (netValue, scale)
}
}
return (0.0, .none)
}

private static func lookup(_ roundSmallToWhole: Bool, _ absValue: Double) -> (divisor: Double, scale: Scale)? {
let records = roundSmallToWhole ? NumberCompactor.halfDollarLookup : NumberCompactor.nickelLookup
guard let record = records.first(where: { $0.range.contains(absValue) }) else { return nil }
return (record.divisor, record.scale)
}

private static func generateLookup(threshold: Double) -> [LOOKUP] {
let netKiloExtent: Double = Scale.kilo.extent - threshold
let netMegaExtent: Double = Scale.mega.extent - threshold * Scale.kilo.extent
let netGigaExtent: Double = Scale.giga.extent - threshold * Scale.mega.extent
let netTeraExtent: Double = Scale.tera.extent - threshold * Scale.giga.extent
let netPetaExtent: Double = Scale.peta.extent - threshold * Scale.tera.extent
let netExaExtent : Double = Scale.exa.extent - threshold * Scale.peta.extent

return [
(threshold ..< netKiloExtent, 1.0, .none),
(netKiloExtent ..< netMegaExtent, Scale.kilo.extent, .kilo),
(netMegaExtent ..< netGigaExtent, Scale.mega.extent, .mega),
(netGigaExtent ..< netTeraExtent, Scale.giga.extent, .giga),
(netTeraExtent ..< netPetaExtent, Scale.tera.extent, .tera),
(netPetaExtent ..< netExaExtent, Scale.peta.extent, .peta),
(netExaExtent ..< Double.greatestFiniteMagnitude, Scale.exa.extent, .exa),
]
}
}

129 changes: 71 additions & 58 deletions Sources/TimeCompactor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,16 @@ import Foundation

public class TimeCompactor: NumberFormatter {

public let blankIfZero: Bool
public let style: Style
public let roundSmallToWhole: Bool
public var blankIfZero: Bool
public var style: Style
public var roundSmallToWhole: Bool

let threshold: TimeInterval
let netMinuteExtent: TimeInterval
let netHourExtent: TimeInterval
let netDayExtent: TimeInterval
let netYearExtent: TimeInterval
let netCenturyExtent: TimeInterval
let netMilleniumExtent: TimeInterval

public init(blankIfZero: Bool = false,
style: Style = .short,
roundSmallToWhole: Bool = false) {
self.blankIfZero = blankIfZero
self.style = style
self.roundSmallToWhole = roundSmallToWhole
self.threshold = roundSmallToWhole ? 0.5 : 0.05
self.netMinuteExtent = Scale.minute.extent - threshold
self.netHourExtent = Scale.hour.extent - threshold * Scale.minute.extent
self.netDayExtent = Scale.day.extent - threshold * Scale.hour.extent
self.netYearExtent = Scale.year.extent - threshold * Scale.day.extent
self.netCenturyExtent = Scale.century.extent - threshold * Scale.year.extent
self.netMilleniumExtent = Scale.millenium.extent - threshold * Scale.century.extent
super.init()
}

Expand All @@ -53,55 +38,30 @@ public class TimeCompactor: NumberFormatter {
}

public override func string(from value: NSNumber) -> String? {
let absValue = abs(TimeInterval(truncating: value))
let rawValue: Double = Double(truncating: value)
let absValue = abs(rawValue)
let threshold = TimeCompactor.getThreshold(roundSmallToWhole)

if blankIfZero, absValue <= threshold { return "" }

var scaleSymbol: Scale = .second
var netValue = TimeInterval(truncating: value)

switch absValue {
case 0.0 ... threshold:
// if inside threshold, drop the fraction, to avoid awkward "-0s"
netValue = 0.0
case threshold ..< netMinuteExtent:
_ = 0 // verbatim netValue
case netMinuteExtent ..< netHourExtent:
netValue /= Scale.minute.extent
scaleSymbol = .minute
case netHourExtent ..< netDayExtent:
netValue /= Scale.hour.extent
scaleSymbol = .hour
case netDayExtent ..< netYearExtent:
netValue /= Scale.day.extent
scaleSymbol = .day
case netYearExtent ..< netCenturyExtent:
netValue /= Scale.year.extent
scaleSymbol = .year
case netCenturyExtent ..< netMilleniumExtent:
netValue /= Scale.century.extent
scaleSymbol = .century
default:
netValue /= Scale.millenium.extent
scaleSymbol = .millenium
}
let (scaledValue, scaleSymbol) = TimeCompactor.getScaledValue(rawValue, roundSmallToWhole)

let showWholeValue: Bool = {
let smallValueThreshold = 100 - threshold
let isLargeNetValue = smallValueThreshold <= abs(scaledValue)
let roundToWhole = !isLargeNetValue && roundSmallToWhole
return roundToWhole || isLargeNetValue
}()

let smallValueThreshold = 100 - threshold
let isLargeNetValue = smallValueThreshold <= abs(netValue)
let roundToWhole = !isLargeNetValue && roundSmallToWhole
let fractionDigitCount = roundToWhole || isLargeNetValue ? 0 : 1

self.numberStyle = .decimal
let fractionDigitCount = showWholeValue ? 0 : 1
self.minimumFractionDigits = fractionDigitCount
self.maximumFractionDigits = fractionDigitCount
self.usesGroupingSeparator = false

guard let raw = super.string(from: netValue as NSNumber) else { return nil }

guard let lastDigitIndex = raw.lastIndex(where: { $0.isNumber }) else { return nil }
guard let raw = super.string(from: scaledValue as NSNumber),
let lastDigitIndex = raw.lastIndex(where: { $0.isNumber })
else { return nil }

let afterLastDigitIndex = raw.index(after: lastDigitIndex)

let prefix = raw.prefix(upTo: afterLastDigitIndex)

switch style {
Expand All @@ -114,3 +74,56 @@ public class TimeCompactor: NumberFormatter {
}
}
}

extension TimeCompactor {
private typealias LOOKUP = (range: Range<Double>, divisor: Double, scale: Scale)

// thresholds
private static let halfDollar: Double = 0.5
private static let nickel: Double = 0.05

// cached lookup tables
private static let halfDollarLookup: [LOOKUP] = TimeCompactor.generateLookup(threshold: halfDollar)
private static let nickelLookup: [LOOKUP] = TimeCompactor.generateLookup(threshold: nickel)

static func getThreshold(_ roundSmallToWhole: Bool) -> Double {
roundSmallToWhole ? TimeCompactor.halfDollar : TimeCompactor.nickel
}

static func getScaledValue(_ rawValue: Double, _ roundSmallToWhole: Bool) -> (Double, Scale) {
let threshold = getThreshold(roundSmallToWhole)
let absValue = abs(rawValue)
if !(0.0...threshold).contains(absValue) {
if let (divisor, scale) = TimeCompactor.lookup(roundSmallToWhole, absValue) {
let netValue = rawValue / divisor
return (netValue, scale)
}
}
return (0.0, .second)
}

private static func lookup(_ roundSmallToWhole: Bool, _ absValue: Double) -> (divisor: Double, scale: Scale)? {
let records = roundSmallToWhole ? TimeCompactor.halfDollarLookup : TimeCompactor.nickelLookup
guard let record = records.first(where: { $0.range.contains(absValue) }) else { return nil }
return (record.divisor, record.scale)
}

private static func generateLookup(threshold: Double) -> [LOOKUP] {
let netMinuteExtent: Double = Scale.minute.extent - threshold
let netHourExtent: Double = Scale.hour.extent - threshold * Scale.minute.extent
let netDayExtent: Double = Scale.day.extent - threshold * Scale.hour.extent
let netYearExtent: Double = Scale.year.extent - threshold * Scale.day.extent
let netCenturyExtent: Double = Scale.century.extent - threshold * Scale.year.extent
let netMilleniumExtent : Double = Scale.millenium.extent - threshold * Scale.century.extent

return [
(threshold ..< netMinuteExtent, 1.0, .second),
(netMinuteExtent ..< netHourExtent, Scale.minute.extent, .minute),
(netHourExtent ..< netDayExtent, Scale.hour.extent, .hour),
(netDayExtent ..< netYearExtent, Scale.day.extent, .day),
(netYearExtent ..< netCenturyExtent, Scale.year.extent, .year),
(netCenturyExtent ..< netMilleniumExtent, Scale.century.extent, .century),
(netMilleniumExtent ..< Double.greatestFiniteMagnitude, Scale.millenium.extent, .millenium),
]
}
}
44 changes: 22 additions & 22 deletions Tests/CurrencyCompactorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,28 +144,28 @@ class CurrencyCompactorTests: XCTestCase {
c.currencyCode = "EUR"
c.currencyDecimalSeparator = ","

XCTAssertEqual("-€ 12,1k", c.string(from: -12050.01))
XCTAssertEqual("-€12,1k", c.string(from: -12050.01))

XCTAssertEqual(" 0", c.string(from: 0.000))
XCTAssertEqual(" 0", c.string(from: 0.050))
XCTAssertEqual(" 0", c.string(from: 0.051))
XCTAssertEqual(" 0", c.string(from: 0.250))
XCTAssertEqual(" 0", c.string(from: 0.251))
XCTAssertEqual(" 1", c.string(from: 1.250))
XCTAssertEqual(" 1", c.string(from: 1.251))
XCTAssertEqual(" 12", c.string(from: 12.050))
XCTAssertEqual(" 12", c.string(from: 12.051))
XCTAssertEqual(" 120", c.string(from: 120.50))
XCTAssertEqual(" 121", c.string(from: 120.51))
XCTAssertEqual(" 12,0k", c.string(from: 12050.00))
XCTAssertEqual(" 12,1k", c.string(from: 12050.01))
XCTAssertEqual(" 120k", c.string(from: 120_500.00))
XCTAssertEqual(" 121k", c.string(from: 120_500.01))
XCTAssertEqual(" 120M", c.string(from: 120_500_000.00))
XCTAssertEqual(" 121M", c.string(from: 120_500_000.01))
XCTAssertEqual(" 120G", c.string(from: 120_500_000_000.00))
XCTAssertEqual(" 121G", c.string(from: 120_500_000_000.01))
XCTAssertEqual(" 120T", c.string(from: 120_500_000_000_000.00))
XCTAssertEqual(" 121T", c.string(from: 120_500_000_000_000.01))
XCTAssertEqual("€0", c.string(from: 0.000))
XCTAssertEqual("€0", c.string(from: 0.050))
XCTAssertEqual("€0", c.string(from: 0.051))
XCTAssertEqual("€0", c.string(from: 0.250))
XCTAssertEqual("€0", c.string(from: 0.251))
XCTAssertEqual("€1", c.string(from: 1.250))
XCTAssertEqual("€1", c.string(from: 1.251))
XCTAssertEqual("€12", c.string(from: 12.050))
XCTAssertEqual("€12", c.string(from: 12.051))
XCTAssertEqual("€120", c.string(from: 120.50))
XCTAssertEqual("€121", c.string(from: 120.51))
XCTAssertEqual("€12,0k", c.string(from: 12050.00))
XCTAssertEqual("€12,1k", c.string(from: 12050.01))
XCTAssertEqual("€120k", c.string(from: 120_500.00))
XCTAssertEqual("€121k", c.string(from: 120_500.01))
XCTAssertEqual("€120M", c.string(from: 120_500_000.00))
XCTAssertEqual("€121M", c.string(from: 120_500_000.01))
XCTAssertEqual("€120G", c.string(from: 120_500_000_000.00))
XCTAssertEqual("€121G", c.string(from: 120_500_000_000.01))
XCTAssertEqual("€120T", c.string(from: 120_500_000_000_000.00))
XCTAssertEqual("€121T", c.string(from: 120_500_000_000_000.01))
}
}
5 changes: 4 additions & 1 deletion Tests/NumberCompactorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ class NumberCompactorTests: XCTestCase {
}

func testWholeNumber() {
let c = NumberCompactor(blankIfZero: false, roundSmallToWhole: true)
let c = NumberCompactor()
c.roundSmallToWhole = true

XCTAssertEqual("8", c.string(from: 8))
XCTAssertEqual("-8", c.string(from: -8))
Expand All @@ -56,6 +57,8 @@ class NumberCompactorTests: XCTestCase {
XCTAssertEqual("100", c.string(from: 99.50))
XCTAssertEqual("-100", c.string(from: -99.50))

XCTAssertEqual("999", c.string(from: 999))

XCTAssertEqual("999", c.string(from: 999.49))
XCTAssertEqual("-999", c.string(from: -999.49))

Expand Down

0 comments on commit 03580fb

Please sign in to comment.