diff --git a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/Decimal.kt b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/Decimal.kt new file mode 100644 index 00000000000..43c63720dbc --- /dev/null +++ b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/Decimal.kt @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2024. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +package org.jetbrains.letsPlot.commons.formatting.number + +import kotlin.math.absoluteValue +import kotlin.math.sign + +internal class Decimal internal constructor( + wholePart: String, + decimalPart: String, + sign: String +) { + val wholePart: String // never empty. "0" for zero, never contains leading zeros + val decimalPart: String // never empty. "0" for zero, never contains trailing zeros + val sign: String // empty for positive, "-" for negative + val isNegative = sign == "-" + + init { + require(wholePart.all { it.isDigit() }) { + "Invalid wholePart: $wholePart" + } + + require(decimalPart.all { it.isDigit() }) { + "Invalid decimalPart: $decimalPart" + } + + require(sign == "" || sign == "-") { + "Sign should be empty or '-'" + } + + this.wholePart = wholePart.trimStart('0').takeIf { it.isNotEmpty() } ?: "0" + this.decimalPart = decimalPart.trimEnd('0').takeIf { it.isNotEmpty() } ?: "0" + this.sign = sign + } + + val isWholePartZero = this.wholePart == "0" + val isDecimalPartZero = this.decimalPart == "0" + val isZero = isWholePartZero && isDecimalPartZero + + // Returns the whole part as a Long or null if the number is larger than Long.MAX_VALUE. + val wholeValue: Long? by lazy { wholePart.toLongOrNull() } + + fun toDouble(): Double { + return "$sign$wholePart.$decimalPart".toDouble() + } + + val asFloating: Floating by lazy { toFloating() } + + fun toFloating(): Floating { + if (wholePart == "0") { + val significandDigitPos = decimalPart.indexOfFirst { it != '0' } + if (significandDigitPos == -1) { + return Floating(0, "0", 0) + } + + val i = decimalPart[significandDigitPos].digitToInt() + val e = -(significandDigitPos + 1) + val fracPart = decimalPart.drop(significandDigitPos + 1) + return Floating(i, fracPart, e) + } else { + val i = wholePart[0].digitToInt() + val e = wholePart.length - 1 + val fracPart = wholePart.drop(1) + decimalPart.toInt() + return Floating(i, fracPart, e) + } + } + + // Shift decimal point to the left (shift < 0) or to the right (shift > 0). + fun shiftDecimalPoint(shift: Int): Decimal { + if (shift == 0) { + return this + } + + if (shift > 0) { + if (decimalPart.length <= shift) { + val zeros = "0".repeat(shift - decimalPart.length) + return Decimal(wholePart + decimalPart + zeros, "0", sign) + } else { + val newIntPart = wholePart + decimalPart.take(shift) + val newFracPart = decimalPart.drop(shift) + return Decimal(newIntPart, newFracPart, sign) + } + } else { + if (shift.absoluteValue >= wholePart.length) { + val zeros = "0".repeat(shift.absoluteValue - wholePart.length) + return Decimal("0", zeros + wholePart + decimalPart, sign) + } else { + val newIntPart = wholePart.take(wholePart.length - shift.absoluteValue) + val newFracPart = wholePart.takeLast(shift.absoluteValue) + decimalPart + return Decimal(newIntPart, newFracPart, sign) + } + } + } + + fun iRound(precision: Int): Decimal { + val number = wholePart + decimalPart + val (roundedNumber, _) = iRound(number, precision) + + val decimalPoint = wholePart.length + if (roundedNumber.length > number.length) 1 else 0 + val roundedIntPart = roundedNumber.take(decimalPoint) + val roundedFracPart = roundedNumber.drop(decimalPoint) + + return Decimal(roundedIntPart, roundedFracPart, sign) + } + + fun fRound(precision: Int): Decimal { + if (decimalPart.length <= precision) { + return this + } + + val (roundedFracPart, carry) = round(decimalPart, precision) + val roundedIntPart = if (carry) add(wholePart, "1").first else wholePart + return Decimal(roundedIntPart, roundedFracPart, sign) + } + + + override fun toString(): String = "$sign$wholePart.$decimalPart" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as Decimal + + if (wholePart != other.wholePart) return false + if (decimalPart != other.decimalPart) return false + if (sign != other.sign) return false + + return true + } + + override fun hashCode(): Int { + var result = wholePart.hashCode() + result = 31 * result + decimalPart.hashCode() + result = 31 * result + sign.hashCode() + return result + } + + + companion object { + val ZERO: Decimal = Decimal("0", "0", "") + + fun fromNumber(value: Number): Decimal { + val dbl = value.toDouble() + + if (dbl.isNaN()) { + return Decimal("NaN", "", "") + } + if (dbl.isInfinite()) { + return Decimal("Infinity", "", if (dbl < 0) "-" else "") + } + + val (intStr, fracStr, exponentString) = + "^(\\d+)\\.?(\\d+)?e?([+-]?\\d+)?\$" + .toRegex() + .find(dbl.absoluteValue.toString().lowercase()) + ?.destructured + ?: error("Wrong number: $value") + + val exp = exponentString.toIntOrNull() ?: 0 + val sign = if (dbl.sign < 0) "-" else "" + + val (intPart, fracPart) = when { + exp == 0 -> intStr to fracStr + exp > 0 -> { + if (exp < fracStr.length) { + val intPart = intStr + fracStr.take(exp) + val fracPart = fracStr.drop(exp) + intPart to fracPart + } else { + val intPart = intStr + fracStr + "0".repeat(exp - fracStr.length) + intPart to "0" + } + } + + exp < 0 -> { + val fracPart = "0".repeat(-exp - intStr.length) + intStr + fracStr + "0" to fracPart + } + + else -> error("Unexpected state. value: $exp") + } + + return Decimal(intPart, fracPart, sign) + } + + // Add two numbers represented as strings. + // Returns a pair of the result and a carry flag (true if the result has an additional digit). + private fun add(a: String, b: String): Pair { + val maxLength = maxOf(a.length, b.length) + val range = (0 until maxLength).reversed() + + val deltaA = a.length - maxLength + val deltaB = b.length - maxLength + + var carry = 0 + val result = CharArray(maxLength) + for (i in range) { + val va = a.getOrNull(i + deltaA)?.digitToInt() ?: 0 + val vb = b.getOrNull(i + deltaB)?.digitToInt() ?: 0 + val sum = va + vb + carry + result[i] = '0' + (sum % 10) + carry = sum / 10 + } + + return if (carry == 0) { + result.concatToString() to false + } else { + ("1" + result.concatToString()) to true + } + } + + private fun carryOnRound(number: String): Boolean { + when (number.length) { + 0 -> return false + 1 -> return number.single() >= '5' + else -> { + if (number.first() >= '5') return true + if (number.first() == '5' && number.asSequence().drop(1).any { it > '0' }) return true + return false + } + } + } + + private fun round(number: String, precision: Int): Pair { + val trailingPart = number.takeLast(number.length - precision) + val significantPart = number.take(precision) + + val carry = carryOnRound(trailingPart) + + val (fRoundedRestPart, carryFromFracToInt) = when { + significantPart.isEmpty() -> "" to carry // round to integer - no fractional part + else -> add(significantPart, if (carry) "1" else "0") + } + + val resultFracPart = if (carryFromFracToInt) fRoundedRestPart.drop(1) else fRoundedRestPart + + return resultFracPart to carryFromFracToInt + } + + private fun iRound(number: String, precision: Int): Pair { + // special cases like: + // round(16.5, 0) => 20.0 + // Without this line, it would be 0.0 + @Suppress("NAME_SHADOWING") + val precision = if (precision == 0) 1 else precision + + if (number.length <= precision) { + return number to false + } + + return when (val roundingPartLength = number.length - precision) { + 0 -> number to false + else -> when (number[precision] >= '5') { + true -> { + val valuePart = number.take(precision) + "0".repeat(roundingPartLength) // zeroing the rounding part + val carryValue = "1" + "0".repeat(roundingPartLength) // carry to the value part + add(valuePart, carryValue).first to true + } + false -> { + val valuePart = number.take(precision) + "0".repeat(roundingPartLength) + valuePart to false + } + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/Floating.kt b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/Floating.kt new file mode 100644 index 00000000000..42fc72a502d --- /dev/null +++ b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/Floating.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +package org.jetbrains.letsPlot.commons.formatting.number + +class Floating(i: Int, fraction: String, exp: Int) { + val i: Int // 1..9 or 0 for 0.0 + val fraction: String // never empty + val exp: Int + + init { + require(i in 0..9) { "i should be in 0..9" } + this.i = i + this.fraction = fraction.takeIf { it.isNotEmpty() } ?: "0" + this.exp = exp + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as Floating + + if (i != other.i) return false + if (fraction != other.fraction) return false + if (exp != other.exp) return false + + return true + } + + override fun hashCode(): Int { + var result = i + result = 31 * result + fraction.hashCode() + result = 31 * result + exp + return result + } + + override fun toString(): String { + return "Floating(i=$i, fraction='$fraction', e=$exp)" + } +} \ No newline at end of file diff --git a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormat.kt b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormat.kt index 3f8173ce399..98c34890cb8 100644 --- a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormat.kt +++ b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormat.kt @@ -5,8 +5,10 @@ package org.jetbrains.letsPlot.commons.formatting.number -import org.jetbrains.letsPlot.commons.formatting.number.NumberInfo.Companion.createNumberInfo -import kotlin.math.* +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.roundToLong +import kotlin.math.sign class NumberFormat(spec: Spec) { @@ -36,13 +38,13 @@ class NumberFormat(spec: Spec) { return nonNumberString } - val numberInfo = createNumberInfo(num) + val number = Decimal.fromNumber(num) var output = Output() - output = computeBody(output, numberInfo) + output = computeBody(output, number) output = trimFraction(output) - output = computeSign(output, numberInfo) + output = computeSign(output, number) output = computePrefix(output) output = computeSuffix(output) @@ -85,7 +87,6 @@ class NumberFormat(spec: Spec) { } private fun applyGroup(output: Output): Output { - val zeroPadding = output.padding.takeIf { spec.zero } ?: "" val body = output.body @@ -110,170 +111,130 @@ class NumberFormat(spec: Spec) { ) } - private fun computeBody(res: Output, numberInfo: NumberInfo): Output { + private fun computeBody(res: Output, number: Decimal): Output { val formattedNumber = when (spec.type) { - "%" -> toFixedFormat(createNumberInfo(numberInfo.number * 100), spec.precision) - "c" -> FormattedNumber(numberInfo.number.toString()) - "d" -> toSimpleFormat(numberInfo, 0) - "e" -> toSimpleFormat(toExponential(numberInfo, spec.precision), spec.precision) - "f" -> toFixedFormat(numberInfo, spec.precision) - "g" -> toPrecisionFormat(numberInfo, spec.precision) - "b" -> FormattedNumber(numberInfo.number.roundToLong().toString(2)) - "o" -> FormattedNumber(numberInfo.number.roundToLong().toString(8)) - "X" -> FormattedNumber(numberInfo.number.roundToLong().toString(16).uppercase()) - "x" -> FormattedNumber(numberInfo.number.roundToLong().toString(16)) - "s" -> toSiFormat(numberInfo, spec.precision) + "e" -> formatExponentNotation(number, spec.precision) // scientific notation, e.g. 12345 -> "1.234500e+4" + "f" -> formatDecimalNotation(number, spec.precision) // fixed-point notation, e.g. 1.5 -> "1.500000" + "d" -> formatDecimalNotation( + number, + precision = 0 + ).copy(expType = spec.expType) // rounded to integer, e.g. 1.5 -> "2" + "%" -> formatDecimalNotation( + number.shiftDecimalPoint(2), + spec.precision + ) // percentage, e.g. 0.015 -> "1.500000%" + "g" -> generalFormat( + number, + spec.precision + ) // general format, e.g. 1e3 -> "1000.00, 1e10 -> "1.00000e+10", 1e-3 -> "0.00100000", 1e-10 -> "1.00000e-10" + "s" -> siPrefixFormat(number, spec.precision) // SI-prefix notation, e.g. 1e3 -> "1.00000k" + "c" -> FormattedNumber(number.wholePart) + "b" -> FormattedNumber(number.toDouble().absoluteValue.roundToLong().toString(2)) + "o" -> FormattedNumber(number.toDouble().absoluteValue.roundToLong().toString(8)) + "X" -> FormattedNumber(number.toDouble().absoluteValue.roundToLong().toString(16).uppercase()) + "x" -> FormattedNumber(number.toDouble().absoluteValue.roundToLong().toString(16)) else -> throw IllegalArgumentException("Wrong type: ${spec.type}") } return res.copy(body = formattedNumber) } - private fun toExponential(numberInfo: NumberInfo, precision: Int = -1): NumberInfo { - val num = numberInfo.number - if (num < TYPE_E_MIN) { - return NumberInfo.ZERO + private fun generalFormat(number: Decimal, precision: Int): FormattedNumber { + // Can't be both zero - rounding in decimal notation will give incorrect results + // Yet it's ok to have precision > 0 and spec.maxExp == 0 to trigger exponential notation for all numbers, + // including integers (e.g. 1 -> 1e+0 to trigger power notation) + val (significantDigitsCount, maxExp) = when { + precision == 0 && spec.maxExp == 0 -> 1 to 1 + else -> precision to spec.maxExp } - var e = if (numberInfo.isIntegerZero) { - -(numberInfo.fractionLeadingZeros + 1) - } else { - numberInfo.integerLength - 1 + - (numberInfo.exponent ?: 0) + if (number.isZero) { + return formatDecimalNotation(Decimal.ZERO, significantDigitsCount - 1) } - val n = num / 10.0.pow(e) - - var newInfo = createNumberInfo(n) - if (precision > -1) { - newInfo = roundToPrecision(newInfo, precision) + if (number.isWholePartZero && number.toFloating().exp > spec.minExp) { + // -1 for the zero in the whole part + return formatDecimalNotation(number, significantDigitsCount - 1 - number.asFloating.exp) } - if (newInfo.integerLength > 1) { - e += 1 - newInfo = createNumberInfo(n / 10) + if (!number.isWholePartZero && number.wholePart.length <= maxExp) { + return formatDecimalNotation(number, significantDigitsCount - number.wholePart.length) } - return newInfo.copy(exponent = e) - } - - private fun toPrecisionFormat(numberInfo: NumberInfo, precision: Int = -1): FormattedNumber { - if (numberInfo.isIntegerZero) { - if (numberInfo.fractionalPart == 0L) { - return toFixedFormat(numberInfo, precision - 1) - } else if (numberInfo.fractionLeadingZeros >= -spec.minExp - 1) { - return toSimpleFormat(toExponential(numberInfo, precision - 1), precision - 1) - } - return toFixedFormat(numberInfo, precision + numberInfo.fractionLeadingZeros) - } else { - if (numberInfo.integerLength > spec.maxExp) { - return toSimpleFormat(toExponential(numberInfo, precision - 1), precision - 1) - } - return toFixedFormat(numberInfo, precision - numberInfo.integerLength) - } + return formatExponentNotation(number, (significantDigitsCount - 1).coerceAtLeast(0)) } - private fun toFixedFormat(numberInfo: NumberInfo, precision: Int = 0): FormattedNumber { + private fun formatDecimalNotation(number: Decimal, precision: Int): FormattedNumber { if (precision <= 0) { - return FormattedNumber(numberInfo.number.roundToLong().toString()) + return FormattedNumber(number.fRound(0).wholePart) } - val newNumberInfo = roundToPrecision(numberInfo, precision) + val rounded = number.fRound(precision) - val completePrecision = if (numberInfo.integerLength < newNumberInfo.integerLength) { + val completePrecision = if (number.wholePart.length < rounded.wholePart.length) { precision - 1 } else { precision } - if (newNumberInfo.fractionalPart == 0L) { - return FormattedNumber(newNumberInfo.integerString, "0".repeat(completePrecision), expType = spec.expType) + if (rounded.isDecimalPartZero) { + return FormattedNumber(rounded.wholePart, "0".repeat(completePrecision), expType = spec.expType) } - val fractionString = newNumberInfo.fractionString.padEnd(completePrecision, '0') + val fractionString = rounded.decimalPart.padEnd(completePrecision, '0') - return FormattedNumber(newNumberInfo.integerString, fractionString) + return FormattedNumber(rounded.wholePart, fractionString) } - private fun toSimpleFormat(numberInfo: NumberInfo, precision: Int = -1): FormattedNumber { - val exponentString = buildExponentString(numberInfo.exponent) + private fun formatExponentNotation(number: Decimal, precision: Int = -1): FormattedNumber { + // TODO: move zero check to the NumericBreakFormatter + val exponentString = if (!number.isZero) buildExponentString(number) else "" - val expNumberInfo = - createNumberInfo(numberInfo.integerPart + numberInfo.fractionalPart / NumberInfo.MAX_DECIMAL_VALUE.toDouble()) + val normalized = normalize(number) if (precision > -1) { - val formattedNumber = toFixedFormat(expNumberInfo, precision) + val formattedNumber = formatDecimalNotation(normalized, precision) return formattedNumber.copy(exponentialPart = exponentString, expType = spec.expType) } - val integerString = expNumberInfo.integerString - val fractionString = if (expNumberInfo.fractionalPart == 0L) "" else expNumberInfo.fractionString - return FormattedNumber(integerString, fractionString, exponentString, spec.expType) + val fractionString = if (normalized.isDecimalPartZero) "" else normalized.decimalPart + return FormattedNumber(normalized.wholePart, fractionString, exponentString, spec.expType) } - private fun buildExponentString(exponent: Int?): String { - if (exponent == null) { - return "" - } + private fun buildExponentString(number: Decimal): String { return if (spec.expType != ExponentNotationType.E) { when { - exponent == 0 && spec.minExp < 0 && spec.maxExp > 0 -> "" - exponent == 1 && spec.minExp < 1 && spec.maxExp > 1 -> MULT_SIGN + "10" - else -> MULT_SIGN + "\\(10^{${exponent}}\\)" + number.asFloating.exp == 0 && spec.minExp < 0 && spec.maxExp > 0 -> "" + number.asFloating.exp == 1 && spec.minExp < 1 && spec.maxExp > 1 -> MULT_SIGN + "10" + else -> MULT_SIGN + "\\(10^{${number.asFloating.exp}}\\)" } } else { - val expSign = if (exponent.sign >= 0) "+" else "" - "e$expSign${exponent}" + val expSign = if (number.asFloating.exp.sign >= 0) "+" else "" + "e$expSign${number.asFloating.exp}" } } - private fun toSiFormat(numberInfo: NumberInfo, precision: Int = -1): FormattedNumber { - val expNumberInfo = if (numberInfo.exponent == null) { - toExponential(numberInfo, precision - 1) + private fun siPrefixFormat(number: Decimal, precision: Int = -1): FormattedNumber { + val siPrefix = siPrefixFromExp(number.toFloating().exp) + + // 23_456.789 -> 23.456_789k + // 0.000_123_456 -> 123.456u + val siNormalizedNumber = number.shiftDecimalPoint(-siPrefix.baseExp) + val roundedNumber = siNormalizedNumber.iRound(precision) + + val (finalNumber, finalSiPrefix) = if ( + // !! is safe - int part of the si normalized number can't be bigger than 1000 + roundedNumber.wholeValue!! == 1000L // 999.999 -> 1000 rounding happened + && hasNextSiPrefix(siPrefix) + ) { + // 1000.0k -> 1.0M + roundedNumber.shiftDecimalPoint(-3) to getNextSiPrefix(siPrefix) } else { - numberInfo + roundedNumber to siPrefix } - val exponent = expNumberInfo.exponent ?: 0 - val suffixExp = floor(exponent / 3.0).coerceAtLeast(-8.0).coerceAtMost(8.0).toInt() * 3 - val newNumberInfo = createNumberInfo(numberInfo.number * 10.0.pow(-suffixExp)) - - val suffixIndex = 8 + suffixExp / 3 - val exponentString = SI_SUFFIXES[suffixIndex] - val formattedNumber = toFixedFormat(newNumberInfo, precision - newNumberInfo.integerLength) - return formattedNumber.copy(exponentialPart = exponentString, expType = spec.expType) - } - - private fun roundToPrecision(numberInfo: NumberInfo, precision: Int = 0): NumberInfo { - val exp = numberInfo.exponent ?: 0 - val totalPrecision = precision + exp - var fractionalPart: Long // TODO: likely wont overflow, but better to use Double - var integerPart: Double - - if (totalPrecision < 0) { - fractionalPart = 0L - val intShift = totalPrecision.absoluteValue - integerPart = if (numberInfo.integerLength <= intShift) { - 0.0 - } else { - numberInfo.integerPart / 10.0.pow(intShift) * 10.0.pow(intShift) - } - } else { - val precisionExp = NumberInfo.MAX_DECIMAL_VALUE / 10.0.pow(totalPrecision).toLong() - fractionalPart = if (precisionExp == 0L) { - numberInfo.fractionalPart - } else { - (numberInfo.fractionalPart.toDouble() / precisionExp).roundToLong() * precisionExp - } - integerPart = numberInfo.integerPart - if (fractionalPart == NumberInfo.MAX_DECIMAL_VALUE) { - fractionalPart = 0 - ++integerPart - } - } - - val num = integerPart + fractionalPart.toDouble() / NumberInfo.MAX_DECIMAL_VALUE - - return createNumberInfo(num) + val restPrecision = precision - finalNumber.wholePart.length + val formattedNumber = formatDecimalNotation(finalNumber, restPrecision) + return formattedNumber.copy(exponentialPart = finalSiPrefix.symbol) } private fun trimFraction(output: Output): Output { @@ -289,10 +250,10 @@ class NumberFormat(spec: Spec) { ) } - private fun computeSign(output: Output, numberInfo: NumberInfo): Output { + private fun computeSign(output: Output, number: Decimal): Output { val isBodyZero = output.body.run { (integerPart.asSequence() + fractionalPart.asSequence()).all { it == '0' } } - val isNegative = numberInfo.negative && !isBodyZero + val isNegative = number.isNegative && !isBodyZero val signStr = if (isNegative) { "-" } else { @@ -359,7 +320,8 @@ class NumberFormat(spec: Spec) { } // Number of the form 1·10^n should be transformed to 10^n if expType is POW - private fun omitUnit(): Boolean = expType == ExponentNotationType.POW && integerPart == "1" && fractionalPart.isEmpty() && exponentialPart.isNotEmpty() + private fun omitUnit(): Boolean = + expType == ExponentNotationType.POW && integerPart == "1" && fractionalPart.isEmpty() && exponentialPart.isNotEmpty() companion object { @Suppress("RegExpRedundantEscape") // breaks tests @@ -380,6 +342,11 @@ class NumberFormat(spec: Spec) { } companion object { + // 123.456 -> 1.23456E+2 + internal fun normalize(decimal: Decimal): Decimal { + return decimal.shiftDecimalPoint(-decimal.toFloating().exp) + } + fun isValidPattern(spec: String) = NUMBER_REGEX.matches(spec) fun parseSpec(spec: String): Spec { @@ -397,7 +364,8 @@ class NumberFormat(spec: Spec) { precision = precision, trim = matchResult.groups["trim"] != null, type = matchResult.groups["type"]?.value ?: "", - expType = matchResult.groups["exptype"]?.value?.let { ExponentNotationType.bySymbol(it) } ?: DEF_EXPONENT_NOTATION_TYPE, + expType = matchResult.groups["exptype"]?.value?.let { ExponentNotationType.bySymbol(it) } + ?: DEF_EXPONENT_NOTATION_TYPE, minExp = matchResult.groups["minexp"]?.value?.toInt() ?: DEF_MIN_EXP, maxExp = matchResult.groups["maxexp"]?.value?.toInt() ?: precision, ) @@ -405,9 +373,9 @@ class NumberFormat(spec: Spec) { return normalizeSpec(formatSpec) } - const val DEF_MIN_EXP = -7 // Number that triggers exponential notation (too small value to be formatted as a simple number). Same as in JS (see toPrecision) and D3.format. + const val DEF_MIN_EXP = + -7 // Number that triggers exponential notation (too small value to be formatted as a simple number). Same as in JS (see toPrecision) and D3.format. - internal const val TYPE_E_MIN = 1E-323 // Will likely crash on smaller numbers. internal const val TYPE_S_MAX = 1E26 // The largest supported SI-prefix is Y - yotta (1.E24). private const val CURRENCY = "$" @@ -416,8 +384,6 @@ class NumberFormat(spec: Spec) { private const val FRACTION_DELIMITER = "." private const val MULT_SIGN = "·" private const val GROUP_SIZE = 3 - private val SI_SUFFIXES = - arrayOf("y", "z", "a", "f", "p", "n", "µ", "m", "", "k", "M", "G", "T", "P", "E", "Z", "Y") private val EXPONENT_TYPES_REGEX = "[${ExponentNotationType.entries.joinToString("") { it.symbol }}]" private val NUMBER_REGEX = """^(?:(?[^{}])?(?[<>=^]))?(?[+ -])?(?[#$])?(?0)?(?\d+)?(?,)?(?:\.(?\d+))?(?~)?(?[%bcdefgosXx])?(?:&(?$EXPONENT_TYPES_REGEX))?(?:\{(?-?\d+)?,(?-?\d+)?\})?$""".toRegex() @@ -425,6 +391,29 @@ class NumberFormat(spec: Spec) { private const val DEF_PRECISION = 6 private val DEF_EXPONENT_NOTATION_TYPE = ExponentNotationType.E + internal fun siPrefixFromExp(exp: Int): SiPrefix { + val prefix = SiPrefix.entries.firstOrNull { exp in it.expRange } + if (prefix != null) return prefix + + return if (exp < 0) { + SiPrefix.entries.minBy { it.expRange.first } + } else { + SiPrefix.entries.maxBy { it.expRange.last } + } + } + + internal fun hasNextSiPrefix(prefix: SiPrefix): Boolean { + return prefix.ordinal > 0 + } + + internal fun getNextSiPrefix(prefix: SiPrefix): SiPrefix { + return if (prefix.ordinal > 0) { + SiPrefix.entries[prefix.ordinal - 1] + } else { + prefix + } + } + internal fun normalizeSpec(spec: Spec): Spec { var precision = spec.precision var type = spec.type @@ -434,9 +423,6 @@ class NumberFormat(spec: Spec) { precision = 12 } type = "g" - } - - if (type == "g") { trim = true } @@ -460,4 +446,5 @@ class NumberFormat(spec: Spec) { .joinToString(COMMA) // 432,1 .reversed() // 1,234 } + } diff --git a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberInfo.kt b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberInfo.kt deleted file mode 100644 index 5a1c07454b9..00000000000 --- a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberInfo.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (c) 2024. JetBrains s.r.o. - * Use of this source code is governed by the MIT license that can be found in the LICENSE file. - */ - -package org.jetbrains.letsPlot.commons.formatting.number - -import kotlin.math.absoluteValue -import kotlin.math.pow - -// TODO: should not be data class - it may break invariants -internal data class NumberInfo( - val number: Double, - val negative: Boolean, - val fractionalPart: Long, - val integerString: String, - - // bad property - initially not null only for very big numbers like 1.0E55. - // For normal numbers (e.g., 1.234E-5, 1.234E+5) it will be null. - // This is needed for of toExponential() and buildExponentString() functions. - // Should be consistent and always present. - val exponent: Int? = null, -) { - val isIntegerZero: Boolean = integerString == "0" - val integerPart: Double = integerString.toDouble() - val integerLength = integerString.length - - val fractionLeadingZeros = MAX_DECIMALS - length(fractionalPart) - val fractionString = "0".repeat(fractionLeadingZeros) + fractionalPart.toString().trimEnd('0') - - companion object { - val ZERO = NumberInfo(0.0, false, 0, integerString = "0") - /** - * max fraction length we can format (as any other format library does) - */ - internal const val MAX_DECIMALS = 18 - internal val MAX_DECIMAL_VALUE = 10.0.pow(MAX_DECIMALS).toLong() - - internal fun createNumberInfo(num: Number): NumberInfo { - val value = num.toDouble() - // frac: "123", exp: 8, double: 0.00000123 - // -> long: 000_001_230_000_000_000 (extended to max decimal digits) - val encodeFraction = { frac: String, exp: Int -> - var fraction = frac - // cutting the fraction if it longer than max decimal digits - if (exp > MAX_DECIMALS) { - fraction = frac.substring(0 until (frac.length - (exp - MAX_DECIMALS))) - } - fraction.toLong() * 10.0.pow((MAX_DECIMALS - exp).coerceAtLeast(0)).toLong() - } - - val (intStr, fracStr, exponentString) = - "^(\\d+)\\.?(\\d+)?e?([+-]?\\d+)?\$" - .toRegex() - .find(num.toDouble().absoluteValue.toString().lowercase()) - ?.destructured - ?: error("Wrong number: $num") - - val exponent: Int = exponentString.toIntOrNull() ?: 0 - - // number = 1.23456E+55 - if (exponent.absoluteValue >= MAX_DECIMALS) { - return NumberInfo( - number = value.absoluteValue, - negative = value < 0, - // "1" -> 1 - integerString = intStr, - // fraction part ignored intentionally - fractionalPart = 0, - // 55 - exponent = exponent, - ) - } - - check(exponent < MAX_DECIMALS) - // number = 1.23E-4. double: 0.000123 - if (exponent < 0) { - return NumberInfo( - number = value.absoluteValue, - negative = value < 0, - integerString = "0", - // "1" + "23" -> 000_123_000_000_000_000L - fractionalPart = encodeFraction(intStr + fracStr, exponent.absoluteValue + fracStr.length), - ) - } - - check(exponent in 0..MAX_DECIMALS) - // number = 1.234E+5, double: 123400.0 - if (exponent >= fracStr.length) { - // "1" + "234" + "00" -> 123400 - val actualIntStr = intStr + fracStr + "0".repeat(exponent - fracStr.length) - return NumberInfo( - number = value.absoluteValue, - negative = value < 0, - integerString = actualIntStr, - fractionalPart = 0, - ) - } - - check(exponent >= 0 && exponent < fracStr.length) - // number = 1.234567E+3, double: 1234.567 - val actualIntStr = intStr + fracStr.substring(0 until exponent) - return NumberInfo( - number = value.absoluteValue, - negative = value < 0, - // "1" + "[234]567" -> 1234 - integerString = actualIntStr, - // "234[567]" -> 567_000_000_000_000_000 - fractionalPart = fracStr.substring(exponent).run { encodeFraction(this, this.length) }, - ) - } - - private fun length(v: Long): Int { - // log10 doesn't work for values 10^17 + 1, returning 17.0 instead of 17.00001 - - if (v == 0L) { - return 1 - } - - var len = 0 - var rem = v - while (rem > 0) { - len++ - rem /= 10 - } - - return len - } - } -} \ No newline at end of file diff --git a/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/SiPrefix.kt b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/SiPrefix.kt new file mode 100644 index 00000000000..db8f4e8f54f --- /dev/null +++ b/commons/src/commonMain/kotlin/org/jetbrains/letsPlot/commons/formatting/number/SiPrefix.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +package org.jetbrains.letsPlot.commons.formatting.number + +enum class SiPrefix( + val symbol: String, + val expRange: IntRange, +) { + YOTTA("Y", 24 until 27), + ZETTA("Z", 21 until 24), + EXA("E", 18 until 21), + PETA("P", 15 until 18), + TERA("T", 12 until 15), + GIGA("G", 9 until 12), + MEGA("M", 6 until 9), + KILO("k", 3 until 6), + NONE("", 0 until 3), + MILLI("m", -3 until 0), + MICRO("µ", -6 until -3), + NANO("n", -9 until -6), + PICO("p", -12 until -9), + FEMTO("f", -15 until -12), + ATTO("a", -18 until -15), + ZEPTO("z", -21 until -18), + YOCTO("y", -24 until -21); + + val baseExp = expRange.first +} \ No newline at end of file diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/DecimalTest.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/DecimalTest.kt new file mode 100644 index 00000000000..cf26333aa61 --- /dev/null +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/DecimalTest.kt @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2024. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +package org.jetbrains.letsPlot.commons.formatting.number + +import org.jetbrains.letsPlot.commons.formatting.number.Util.DOUBLE_ALMOST_MIN_VALUE +import kotlin.test.Test +import kotlin.test.assertEquals + +class DecimalTest { + @Test + fun expLessThanFracPart() { + assertEquals(Decimal("52345678", "4449", ""), Decimal.fromNumber(5.23456784449E7)) + } + + @Test + fun expMoreThanFracPart() { + assertEquals(Decimal("523", "456784449", ""), Decimal.fromNumber(5.23456784449E2)) + } + + @Test + fun noFrac() { + assertEquals(Decimal("500000", "0", ""), Decimal.fromNumber(5E5)) + } + + @Test + fun specialValues() { + assertEquals(Decimal("0", "0", ""), Decimal.fromNumber(-0.0)) + } + + @Test + fun simple() { + assertEquals(Decimal("0", "0", ""), Decimal.fromNumber(0)) + assertEquals(Decimal("1", "0", ""), Decimal.fromNumber(1)) + assertEquals(Decimal("1", "0", ""), Decimal.fromNumber(1.0)) + + assertEquals(Decimal("1", "0", "-"), Decimal.fromNumber(-1)) + assertEquals(Decimal("1", "0", "-"), Decimal.fromNumber(-1.0)) + } + + @Test + fun positiveExponent() { + assertEquals(Decimal("1000", "0", ""), Decimal.fromNumber(1e3)) + assertEquals(Decimal("1000", "0", "-"), Decimal.fromNumber(-1e3)) + } + + @Test + fun negativeExponent() { + assertEquals(Decimal("0", "001", ""), Decimal.fromNumber(1e-3)) + assertEquals(Decimal("0", "001", "-"), Decimal.fromNumber(-1e-3)) + } + + @Test + fun longNumbers() { + assertEquals(Decimal("123456789012345680000", "0", ""), Decimal.fromNumber(1.2345678901234568E20)) + assertEquals(Decimal("123456789012345680000", "0", "-"), Decimal.fromNumber(-1.2345678901234568E20)) + } + + @Test + fun minMaxDouble() { + assertEquals( + Decimal("179769313486231570000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "0", ""), + Decimal.fromNumber(Double.MAX_VALUE) + ) + assertEquals( + Decimal("179769313486231570000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "0", "-"), + Decimal.fromNumber(-Double.MAX_VALUE) + ) + assertEquals( + Decimal("0", "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001", ""), + Decimal.fromNumber(DOUBLE_ALMOST_MIN_VALUE) + ) + assertEquals( + Decimal("0", "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001", "-"), + Decimal.fromNumber(-DOUBLE_ALMOST_MIN_VALUE) + ) + } + + @Test + fun toFloating1_0() { + Decimal.fromNumber(1.0).toFloating().let { + assertEquals(Floating(1, "0", 0), it) + } + } + + @Test + fun toFloating0_1() { + Decimal.fromNumber(0.1).toFloating().let { + assertEquals(Floating(i = 1, fraction = "0", exp = -1), it) + } + } + + @Test + fun toFloating234_567() { + Decimal.fromNumber(234.567).toFloating().let { + assertEquals(Floating(i = 2, fraction = "34567", exp = 2), it) + } + } + + @Test + fun round_1_0_With_2() { + Decimal.fromNumber(1.0).fRound(2).let { + assertEquals(Decimal("1", "0", ""), it) + } + } + + @Test + fun round_1_12_With_2() { + Decimal.fromNumber(1.12).fRound(2).let { + assertEquals(Decimal("1", "12", ""), it) + } + } + + @Test + fun round_1_124_With_2() { + Decimal.fromNumber(1.124).fRound(2).let { + assertEquals(Decimal("1", "12", ""), it) + } + } + + @Test + fun round_1_125_With_2() { + Decimal.fromNumber(1.125).fRound(2).let { + assertEquals(Decimal("1", "13", ""), it) + } + } + + @Test + fun round_1_1251_With_2() { + Decimal.fromNumber(1.1251).fRound(2).let { + assertEquals(Decimal("1", "13", ""), it) + } + } + + @Test + fun round_123456_51_With_0() { + Decimal.fromNumber(123456.51).fRound(0).let { + assertEquals(Decimal("123457", "0", ""), it) + } + } + + @Test + fun round_9_51_With_0() { + Decimal.fromNumber(9.51).fRound(0).let { + assertEquals(Decimal("10", "0", ""), it) + } + } + + @Test + fun round_0_51_With_0() { + Decimal.fromNumber(0.51).fRound(0).let { + assertEquals(Decimal("1", "0", ""), it) + } + } + + @Test + fun round_123456_5_With_0() { + Decimal.fromNumber(123456.5).fRound(0).let { + assertEquals(Decimal("123457", "0", ""), it) + } + } + + @Test + fun round_0_49_With_1() { + Decimal.fromNumber(0.49).fRound(1).let { + assertEquals(Decimal("0", "5", ""), it) + } + } + + @Test + fun round_1_98_With_1() { + Decimal.fromNumber(1.98).fRound(1).let { + assertEquals(Decimal("2", "0", ""), it) + } + } + + @Test + fun round_999_98_With_1() { + Decimal.fromNumber(999.98).fRound(1).let { + assertEquals(Decimal("1000", "0", ""), it) + } + } + + @Test + fun shift_123_456_RightBy_2() { + Decimal.fromNumber(123.456).shiftDecimalPoint(2).let { + assertEquals(Decimal("12345", "6", ""), it) + } + } + + + @Test + fun shift_123_456_RightBy_4() { + Decimal.fromNumber(123.456).shiftDecimalPoint(4).let { + assertEquals(Decimal("1234560", "0", ""), it) + } + } + + @Test + fun shift_123_456_LeftBy_2() { + Decimal.fromNumber(123.456).shiftDecimalPoint(-4).let { + assertEquals(Decimal("0", "0123456", ""), it) + } + } + + @Test + fun iRound_123_456789_With_1() { + Decimal.fromNumber(123.456789).let { + assertEquals(Decimal("100", "0", ""), it.iRound(0)) + assertEquals(Decimal("100", "0", ""), it.iRound(1)) + assertEquals(Decimal("120", "0", ""), it.iRound(2)) + assertEquals(Decimal("123", "0", ""), it.iRound(3)) + assertEquals(Decimal("123", "5", ""), it.iRound(4)) + assertEquals(Decimal("123", "46", ""), it.iRound(5)) + assertEquals(Decimal("123", "457", ""), it.iRound(6)) + assertEquals(Decimal("123", "4568", ""), it.iRound(7)) + assertEquals(Decimal("123", "45679", ""), it.iRound(8)) + assertEquals(Decimal("123", "456789", ""), it.iRound(9)) + } + } + + @Test + fun iRound_959_51946() { + Decimal.fromNumber(959.51946).let { + assertEquals(Decimal("1000", "0", ""), it.iRound(0)) + assertEquals(Decimal("1000", "0", ""), it.iRound(1)) + assertEquals(Decimal("960", "0", ""), it.iRound(2)) + assertEquals(Decimal("960", "0", ""), it.iRound(3)) + assertEquals(Decimal("959", "5", ""), it.iRound(4)) + assertEquals(Decimal("959", "52", ""), it.iRound(5)) + assertEquals(Decimal("959", "519", ""), it.iRound(6)) + assertEquals(Decimal("959", "5195", ""), it.iRound(7)) + assertEquals(Decimal("959", "51946", ""), it.iRound(8)) + } + } + + @Test + fun iround0() { + assertEquals(Decimal("20", "0", ""), Decimal.fromNumber(16.5).iRound(0)) + assertEquals(Decimal("900", "0", ""), Decimal.fromNumber(929.51946).iRound(0)) + assertEquals(Decimal("1000", "0", ""), Decimal.fromNumber(959.51946).iRound(0)) + assertEquals(Decimal("100", "0", ""), Decimal.fromNumber(123.456789).iRound(0)) + + } + + @Test + fun specialCases() { + assertEquals(Decimal("0", "0", ""), Decimal.fromNumber(0.0).iRound(0)) + assertEquals(Decimal("0", "0", ""), Decimal.fromNumber(0.0).iRound(1)) + assertEquals(Decimal("0", "0", ""), Decimal.fromNumber(0.0).iRound(2)) + assertEquals(Decimal("0", "0", ""), Decimal.fromNumber(0.0).iRound(3)) + assertEquals(Decimal("0", "1", ""), Decimal.fromNumber(0.1).iRound(3)) + assertEquals(Decimal("0", "9", ""), Decimal.fromNumber(0.9).iRound(3)) + + } +} diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatAlignTest.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatAlignTest.kt index b461d0e3277..a0a671b09b5 100644 --- a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatAlignTest.kt +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatAlignTest.kt @@ -21,12 +21,12 @@ class NumberFormatAlignTest { assertEquals("0 ", format("<13,d").apply(0)) assertEquals("0 ", format("<21,d").apply(0)) assertEquals("1,000 ", format("<21,d").apply(1000)) - assertEquals("1e+21 ", format("<21,d").apply(1e21)) - assertEquals("1e-21 ", format("<21,d").apply(1e-21)) - assertEquals("\\(10^{21}\\) ", format("<21,d&P").apply(1e21)) - assertEquals("\\(10^{-21}\\) ", format("<21,d&P").apply(1e-21)) - assertEquals("1·\\(10^{21}\\) ", format("<21,d&F").apply(1e21)) - assertEquals("1·\\(10^{-21}\\) ", format("<21,d&F").apply(1e-21)) + assertEquals("1,000,000,000,000,000,000,000", format("<21,d").apply(1e21)) + assertEquals("0 ", format("<21,d").apply(1e-21)) + assertEquals("1,000,000,000,000,000,000,000", format("<21,d&P").apply(1e21)) + assertEquals("0 ", format("<21,d&P").apply(1e-21)) + assertEquals("1,000,000,000,000,000,000,000", format("<21,d&F").apply(1e21)) + assertEquals("0 ", format("<21,d&F").apply(1e-21)) } @Test @@ -39,12 +39,12 @@ class NumberFormatAlignTest { assertEquals(" 0", format(">13,d").apply(0)) assertEquals(" 0", format(">21,d").apply(0)) assertEquals(" 1,000", format(">21,d").apply(1000)) - assertEquals(" 1e+21", format(">21,d").apply(1e21)) - assertEquals(" 1e-21", format(">21,d").apply(1e-21)) - assertEquals(" \\(10^{21}\\)", format(">21,d&P").apply(1e21)) - assertEquals(" \\(10^{-21}\\)", format(">21,d&P").apply(1e-21)) - assertEquals(" 1·\\(10^{21}\\)", format(">21,d&F").apply(1e21)) - assertEquals(" 1·\\(10^{-21}\\)", format(">21,d&F").apply(1e-21)) + assertEquals("1,000,000,000,000,000,000,000", format(">21,d").apply(1e21)) + assertEquals(" 0", format(">21,d").apply(1e-21)) + assertEquals("1,000,000,000,000,000,000,000", format(">21,d&P").apply(1e21)) + assertEquals(" 0", format(">21,d&P").apply(1e-21)) + assertEquals("1,000,000,000,000,000,000,000", format(">21,d&F").apply(1e21)) + assertEquals(" 0", format(">21,d&F").apply(1e-21)) } @Test @@ -57,11 +57,16 @@ class NumberFormatAlignTest { assertEquals(" 0 ", format("^13,d").apply(0)) assertEquals(" 0 ", format("^21,d").apply(0)) assertEquals(" 1,000 ", format("^21,d").apply(1000)) - assertEquals(" 1e+21 ", format("^21,d").apply(1e21)) - assertEquals(" 1e-21 ", format("^21,d").apply(1e-21)) - assertEquals(" \\(10^{21}\\) ", format("^21,d&P").apply(1e21)) - assertEquals(" \\(10^{-21}\\) ", format("^21,d&P").apply(1e-21)) - assertEquals(" 1·\\(10^{21}\\) ", format("^21,d&F").apply(1e21)) - assertEquals(" 1·\\(10^{-21}\\) ", format("^21,d&F").apply(1e-21)) + assertEquals("1,000,000,000,000,000,000,000", format("^21,d").apply(1e21)) + assertEquals(" 0 ", format("^21,d").apply(1e-21)) + assertEquals("1,000,000,000,000,000,000,000", format("^21,d&P").apply(1e21)) + assertEquals(" 0 ", format("^21,d&P").apply(1e-21)) + assertEquals("1,000,000,000,000,000,000,000", format("^21,d&F").apply(1e21)) + assertEquals(" 0 ", format("^21,d&F").apply(1e-21)) + } + + @Test + fun overflowTest() { + assertEquals("1,000,000,000,000,000,000,000", format(">21,d").apply(1e21)) } } \ No newline at end of file diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatExtremesTest.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatExtremesTest.kt index 6c481e004ff..d8410062b0a 100644 --- a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatExtremesTest.kt +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatExtremesTest.kt @@ -6,6 +6,7 @@ package org.jetbrains.letsPlot.commons.formatting.number import org.jetbrains.letsPlot.commons.formatting.number.NumberFormat.Spec +import org.jetbrains.letsPlot.commons.formatting.number.Util.DOUBLE_ALMOST_MIN_VALUE import kotlin.test.Test import kotlin.test.assertEquals @@ -15,8 +16,8 @@ class NumberFormatExtremesTest { @Test fun typeS() { val f = format(".3s") - assertEquals("0.00y", f.apply(Double.MIN_VALUE)) assertEquals("100000000000000Y", f.apply(1E38)) + assertEquals("0.00y", f.apply(Double.MIN_VALUE)) assertEquals("0.00y", f.apply(-Double.MIN_VALUE)) assertEquals("-100000000000000Y", f.apply(-1E38)) @@ -36,16 +37,28 @@ class NumberFormatExtremesTest { fun typeE() { val f = format(".2e") - assertEquals("1.00e-323", f.apply(NumberFormat.TYPE_E_MIN)) - assertEquals("-1.00e-323", f.apply(-NumberFormat.TYPE_E_MIN)) + assertEquals("1.00e-323", f.apply(DOUBLE_ALMOST_MIN_VALUE)) + assertEquals("-1.00e-323", f.apply(-DOUBLE_ALMOST_MIN_VALUE)) + assertEquals("2.00e-323", f.apply(1.9999999E-323)) assertEquals("-2.00e-323", f.apply(-1.9999999E-323)) assertEquals("1.80e+308", f.apply(Double.MAX_VALUE)) assertEquals("-1.80e+308", f.apply(-Double.MAX_VALUE)) + } - assertEquals("0.00", f.apply(Double.MIN_VALUE)) - assertEquals("0.00", f.apply(-Double.MIN_VALUE)) + @Test + fun typeG_1e18() { + val g = format("g") + assertEquals("1.00000e+17", g.apply(1.0e17)) + assertEquals("1.00000e+18", g.apply(1.0e18)) + assertEquals("1.00000e+55", g.apply(1.0e55)) + } + + @Test + fun typeG_MAX_DOUBLE() { + val g = format("g") + assertEquals("1.00000e+300", g.apply(1e300)) } } \ No newline at end of file diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatSignTest.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatSignTest.kt index 1969549d144..84e8f13883e 100644 --- a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatSignTest.kt +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatSignTest.kt @@ -20,7 +20,7 @@ class NumberFormatSignTest { assertEquals("+ 0", format("=+8,d").apply(0)) assertEquals("+ 0", format("=+13,d").apply(0)) assertEquals("+ 0", format("=+21,d").apply(0)) - assertEquals("+ 1e+21", format("=+21,d").apply(1e21)) + assertEquals("+1,000,000,000,000,000,000,000", format("=+21,d").apply(1e21)) } @Test diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeDTest.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeDTest.kt index 49af252d048..ead1283da78 100644 --- a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeDTest.kt +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeDTest.kt @@ -24,6 +24,11 @@ class NumberFormatTypeDTest { assertEquals("4", format("d").apply(4.2)) } + @Test + fun format49_9() { + assertEquals("50", format("d").apply(49.9)) + } + @Test fun groupThousands() { assertEquals("0", format("01,d").apply(0)) @@ -37,10 +42,10 @@ class NumberFormatTypeDTest { assertEquals("0,000,000,000", format("013,d").apply(0)) assertEquals("0,000,000,000,000,000", format("021,d").apply(0)) assertEquals("-0,042,000,000", format("013,d").apply(-42000000)) - assertEquals("0,000,001e+21", format("012,d").apply(1e21)) - assertEquals("0,000,001e+21", format("013,d").apply(1e21)) - assertEquals("00,000,001e+21", format("014,d").apply(1e21)) - assertEquals("000,000,001e+21", format("015,d").apply(1e21)) + assertEquals("1,000,000,000,000,000,000,000", format("012,d").apply(1e21)) + assertEquals("1,000,000,000,000,000,000,000", format("013,d").apply(1e21)) + assertEquals("1,000,000,000,000,000,000,000", format("014,d").apply(1e21)) + assertEquals("1,000,000,000,000,000,000,000", format("015,d").apply(1e21)) } @Test @@ -86,7 +91,7 @@ class NumberFormatTypeDTest { assertEquals("+$ 0", format("=+$8,d").apply(0)) assertEquals("+$ 0", format("=+$13,d").apply(0)) assertEquals("+$ 0", format("=+$21,d").apply(0)) - assertEquals("+$ 1e+21", format("=+$21,d").apply(1e21)) + assertEquals("+\$1,000,000,000,000,000,000,000", format("=+$21,d").apply(1e21)) } @Test @@ -99,7 +104,7 @@ class NumberFormatTypeDTest { assertEquals(" 0", format(" 8,d").apply(0)) assertEquals(" 0", format(" 13,d").apply(0)) assertEquals(" 0", format(" 21,d").apply(0)) - assertEquals(" 1e+21", format(" 21,d").apply(1e21)) + assertEquals(" 1,000,000,000,000,000,000,000", format(" 21,d").apply(1e21)) } @Test diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeETest.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeETest.kt index 2726fa3152f..7a413b2729f 100644 --- a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeETest.kt +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeETest.kt @@ -5,15 +5,22 @@ package org.jetbrains.letsPlot.commons.formatting.number -import org.jetbrains.letsPlot.commons.formatting.number.NumberFormat.ExponentNotationType import kotlin.test.Test import kotlin.test.assertEquals class NumberFormatTypeETest { @Test - fun canOutputExponentNotation() { + fun hackForNumericBreaksFormatter() { val f = NumberFormat("e") assertEquals("0.000000", f.apply(0)) + + // should be + //assertEquals("0.000000e+0", f.apply(0)) + } + + @Test + fun canOutputExponentNotation() { + val f = NumberFormat("e") assertEquals("4.200000e+1", f.apply(42)) assertEquals("4.200000e+7", f.apply(42000000)) assertEquals("4.200000e+8", f.apply(420000000)) @@ -27,15 +34,14 @@ class NumberFormatTypeETest { } @Test - fun canFormatNegativeZeroAsZero() { - assertEquals("0.000000", NumberFormat("1e").apply(-0)) + fun canFormatNegative() { assertEquals("-1.000000e-12", NumberFormat("1e").apply(-1e-12)) } @Test fun canOutputScientificExponentNotation() { val f = NumberFormat("e&P") - assertEquals("0.000000", f.apply(0)) + //assertEquals("0.000000", f.apply(0)) assertEquals("1.500000", f.apply(1.5e0)) assertEquals("1.500000·10", f.apply(1.5e1)) assertEquals("1.500000·\\(10^{-1}\\)", f.apply(1.5e-1)) @@ -57,156 +63,7 @@ class NumberFormatTypeETest { } @Test - fun compactFormatOfScientificNotation() { - fun format(expType: ExponentNotationType, limits: Pair? = null): NumberFormat { - val limitsStr = limits?.let { "{${it.first},${it.second}}" } ?: "" - return NumberFormat("&${expType.symbol}$limitsStr") - } - - // - // Default limits - // - - // 10^n - - assertEquals("1e-7", format(ExponentNotationType.E).apply(0.0000001)) - assertEquals("\\(10^{-7}\\)", format(ExponentNotationType.POW).apply(0.0000001)) - assertEquals("1·\\(10^{-7}\\)", format(ExponentNotationType.POW_FULL).apply(0.0000001)) - - assertEquals("0.000001", format(ExponentNotationType.E).apply(0.000001)) - assertEquals("0.000001", format(ExponentNotationType.POW).apply(0.000001)) - assertEquals("0.000001", format(ExponentNotationType.POW_FULL).apply(0.000001)) - - assertEquals("0.1", format(ExponentNotationType.E).apply(0.1)) - assertEquals("0.1", format(ExponentNotationType.POW).apply(0.1)) - assertEquals("0.1", format(ExponentNotationType.POW_FULL).apply(0.1)) - - assertEquals("1", format(ExponentNotationType.E).apply(1)) - assertEquals("1", format(ExponentNotationType.POW).apply(1)) - assertEquals("1", format(ExponentNotationType.POW_FULL).apply(1)) - - assertEquals("10", format(ExponentNotationType.E).apply(10)) - assertEquals("10", format(ExponentNotationType.POW).apply(10)) - assertEquals("10", format(ExponentNotationType.POW_FULL).apply(10)) - - assertEquals("100000", format(ExponentNotationType.E).apply(100000)) - assertEquals("100000", format(ExponentNotationType.POW).apply(100000)) - assertEquals("100000", format(ExponentNotationType.POW_FULL).apply(100000)) - - assertEquals("1e+6", format(ExponentNotationType.E).apply(1000000)) - assertEquals("\\(10^{6}\\)", format(ExponentNotationType.POW).apply(1000000)) - assertEquals("1·\\(10^{6}\\)", format(ExponentNotationType.POW_FULL).apply(1000000)) - - // 2*10^n - - assertEquals("2e-7", format(ExponentNotationType.E).apply(0.0000002)) - assertEquals("2·\\(10^{-7}\\)", format(ExponentNotationType.POW).apply(0.0000002)) - assertEquals("2·\\(10^{-7}\\)", format(ExponentNotationType.POW_FULL).apply(0.0000002)) - - assertEquals("0.000002", format(ExponentNotationType.E).apply(0.000002)) - assertEquals("0.000002", format(ExponentNotationType.POW).apply(0.000002)) - assertEquals("0.000002", format(ExponentNotationType.POW_FULL).apply(0.000002)) - - assertEquals("200000", format(ExponentNotationType.E).apply(200000)) - assertEquals("200000", format(ExponentNotationType.POW).apply(200000)) - assertEquals("200000", format(ExponentNotationType.POW_FULL).apply(200000)) - - assertEquals("2e+6", format(ExponentNotationType.E).apply(2000000)) - assertEquals("2·\\(10^{6}\\)", format(ExponentNotationType.POW).apply(2000000)) - assertEquals("2·\\(10^{6}\\)", format(ExponentNotationType.POW_FULL).apply(2000000)) - - // Negative numbers - - assertEquals("-1e+6", format(ExponentNotationType.E).apply(-1000000)) - assertEquals("-\\(10^{6}\\)", format(ExponentNotationType.POW).apply(-1000000)) - assertEquals("-1·\\(10^{6}\\)", format(ExponentNotationType.POW_FULL).apply(-1000000)) - - assertEquals("-1e-7", format(ExponentNotationType.E).apply(-0.0000001)) - assertEquals("-\\(10^{-7}\\)", format(ExponentNotationType.POW).apply(-0.0000001)) - assertEquals("-1·\\(10^{-7}\\)", format(ExponentNotationType.POW_FULL).apply(-0.0000001)) - - assertEquals("-2e+6", format(ExponentNotationType.E).apply(-2000000)) - assertEquals("-2·\\(10^{6}\\)", format(ExponentNotationType.POW).apply(-2000000)) - assertEquals("-2·\\(10^{6}\\)", format(ExponentNotationType.POW_FULL).apply(-2000000)) - - assertEquals("-2e-7", format(ExponentNotationType.E).apply(-0.0000002)) - assertEquals("-2·\\(10^{-7}\\)", format(ExponentNotationType.POW).apply(-0.0000002)) - assertEquals("-2·\\(10^{-7}\\)", format(ExponentNotationType.POW_FULL).apply(-0.0000002)) - - // - // Limits: (-2, 3) - // - - // 10^n - - assertEquals("1e-2", format(ExponentNotationType.E, -2 to 3).apply(0.01)) - assertEquals("\\(10^{-2}\\)", format(ExponentNotationType.POW, -2 to 3).apply(0.01)) - assertEquals("1·\\(10^{-2}\\)", format(ExponentNotationType.POW_FULL, -2 to 3).apply(0.01)) - - assertEquals("0.1", format(ExponentNotationType.E, -2 to 3).apply(0.1)) - assertEquals("0.1", format(ExponentNotationType.POW, -2 to 3).apply(0.1)) - assertEquals("0.1", format(ExponentNotationType.POW_FULL, -2 to 3).apply(0.1)) - - assertEquals("1", format(ExponentNotationType.E, -2 to 3).apply(1)) - assertEquals("1", format(ExponentNotationType.POW, -2 to 3).apply(1)) - assertEquals("1", format(ExponentNotationType.POW_FULL, -2 to 3).apply(1)) - - assertEquals("10", format(ExponentNotationType.E, -2 to 3).apply(10)) - assertEquals("10", format(ExponentNotationType.POW, -2 to 3).apply(10)) - assertEquals("10", format(ExponentNotationType.POW_FULL, -2 to 3).apply(10)) - - assertEquals("100", format(ExponentNotationType.E, -2 to 3).apply(100)) - assertEquals("100", format(ExponentNotationType.POW, -2 to 3).apply(100)) - assertEquals("100", format(ExponentNotationType.POW_FULL, -2 to 3).apply(100)) - - assertEquals("1e+3", format(ExponentNotationType.E, -2 to 3).apply(1000)) - assertEquals("\\(10^{3}\\)", format(ExponentNotationType.POW, -2 to 3).apply(1000)) - assertEquals("1·\\(10^{3}\\)", format(ExponentNotationType.POW_FULL, -2 to 3).apply(1000)) - - // 2*10^n - - assertEquals("2e-2", format(ExponentNotationType.E, -2 to 3).apply(0.02)) - assertEquals("2·\\(10^{-2}\\)", format(ExponentNotationType.POW, -2 to 3).apply(0.02)) - assertEquals("2·\\(10^{-2}\\)", format(ExponentNotationType.POW_FULL, -2 to 3).apply(0.02)) - - assertEquals("0.2", format(ExponentNotationType.E, -2 to 3).apply(0.2)) - assertEquals("0.2", format(ExponentNotationType.POW, -2 to 3).apply(0.2)) - assertEquals("0.2", format(ExponentNotationType.POW_FULL, -2 to 3).apply(0.2)) - - assertEquals("200", format(ExponentNotationType.E, -2 to 3).apply(200)) - assertEquals("200", format(ExponentNotationType.POW, -2 to 3).apply(200)) - assertEquals("200", format(ExponentNotationType.POW_FULL, -2 to 3).apply(200)) - - assertEquals("2e+3", format(ExponentNotationType.E, -2 to 3).apply(2000)) - assertEquals("2·\\(10^{3}\\)", format(ExponentNotationType.POW, -2 to 3).apply(2000)) - assertEquals("2·\\(10^{3}\\)", format(ExponentNotationType.POW_FULL, -2 to 3).apply(2000)) - - // - // Limits: (0, 0) - // - - assertEquals("1e-1", format(ExponentNotationType.E, 0 to 0).apply(0.1)) - assertEquals("\\(10^{-1}\\)", format(ExponentNotationType.POW, 0 to 0).apply(0.1)) - assertEquals("1·\\(10^{-1}\\)", format(ExponentNotationType.POW_FULL, 0 to 0).apply(0.1)) - - assertEquals("1e+0", format(ExponentNotationType.E, 0 to 0).apply(1)) - assertEquals("\\(10^{0}\\)", format(ExponentNotationType.POW, 0 to 0).apply(1)) - assertEquals("1·\\(10^{0}\\)", format(ExponentNotationType.POW_FULL, 0 to 0).apply(1)) - - assertEquals("1e+1", format(ExponentNotationType.E, 0 to 0).apply(10)) - assertEquals("\\(10^{1}\\)", format(ExponentNotationType.POW, 0 to 0).apply(10)) - assertEquals("1·\\(10^{1}\\)", format(ExponentNotationType.POW_FULL, 0 to 0).apply(10)) - - assertEquals("2e-1", format(ExponentNotationType.E, 0 to 0).apply(0.2)) - assertEquals("2·\\(10^{-1}\\)", format(ExponentNotationType.POW, 0 to 0).apply(0.2)) - assertEquals("2·\\(10^{-1}\\)", format(ExponentNotationType.POW_FULL, 0 to 0).apply(0.2)) - - assertEquals("2e+0", format(ExponentNotationType.E, 0 to 0).apply(2)) - assertEquals("2·\\(10^{0}\\)", format(ExponentNotationType.POW, 0 to 0).apply(2)) - assertEquals("2·\\(10^{0}\\)", format(ExponentNotationType.POW_FULL, 0 to 0).apply(2)) - - assertEquals("2e+1", format(ExponentNotationType.E, 0 to 0).apply(20)) - assertEquals("2·\\(10^{1}\\)", format(ExponentNotationType.POW, 0 to 0).apply(20)) - assertEquals("2·\\(10^{1}\\)", format(ExponentNotationType.POW_FULL, 0 to 0).apply(20)) + fun rounding() { + assertEquals("1.234568e+0", NumberFormat("e").apply(1.23456789)) } } \ No newline at end of file diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeFTest.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeFTest.kt index fcc362dd21e..7222b68df70 100644 --- a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeFTest.kt +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeFTest.kt @@ -21,8 +21,9 @@ class NumberFormatTypeFTest { @Test fun canOutputFixedPointNotation() { - assertEquals("0.000270", NumberFormat("f").apply(2.6985974025974023E-4)) + assertEquals("1.000", NumberFormat(".3f").apply(0.999500)) assertEquals("0.5", NumberFormat(".1f").apply(0.49)) + assertEquals("0.000270", NumberFormat("f").apply(2.6985974025974023E-4)) assertEquals("0.45", NumberFormat(".2f").apply(0.449)) assertEquals("0.445", NumberFormat(".3f").apply(0.4449)) assertEquals("0.44445", NumberFormat(".5f").apply(0.444449)) @@ -44,9 +45,9 @@ class NumberFormatTypeFTest { @Test fun canGroupThousandsSpaceFillAndRoundToSignificantDigits() { + assertEquals("12,345,678.445", NumberFormat("10,.3f").apply(12345678.4449)) assertEquals(" 123,456.5", NumberFormat("10,.1f").apply(123456.49)) assertEquals("1,234,567.45", NumberFormat("10,.2f").apply(1234567.449)) - assertEquals("12,345,678.445", NumberFormat("10,.3f").apply(12345678.4449)) assertEquals("123,456,789.44445", NumberFormat("10,.5f").apply(123456789.444449)) assertEquals(" 123,456.0", NumberFormat("10,.1f").apply(123456)) assertEquals("1,234,567.00", NumberFormat("10,.2f").apply(1234567)) diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeGTest.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeGTest.kt index b533dd15684..a9ff5d1d09d 100644 --- a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeGTest.kt +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeGTest.kt @@ -5,32 +5,34 @@ package org.jetbrains.letsPlot.commons.formatting.number +import org.jetbrains.letsPlot.commons.formatting.number.NumberFormat.ExponentNotationType import kotlin.test.Test import kotlin.test.assertEquals class NumberFormatTypeGTest { + private fun format(v: Number, spec: String): String = NumberFormat(spec).apply(v) private fun format(spec: String): NumberFormat = NumberFormat(spec) @Test fun gToE() { // Not yet enough digits to use exponential notation - assertEquals("0.000001", format("g").apply(1.0e-6)) + assertEquals("0.00000100000", format("g").apply(1.0e-6)) assertEquals("123456", format("g").apply(123456)) - assertEquals("0.1", format("g{-2,3}").apply(0.1)) - assertEquals("123", format("g{-2,3}").apply(123)) + assertEquals("0.100000", format("g{-2,3}").apply(0.1)) + assertEquals("123.000", format("g{-2,3}").apply(123)) // Enough digits to use exponential notation - assertEquals("1e-7", format("g").apply(1.0e-7)) - assertEquals("1e-7", format("g&E").apply(1.0e-7)) - assertEquals("\\(10^{-7}\\)", format("g&P").apply(1.0e-7)) - assertEquals("1·\\(10^{-7}\\)", format("g&F").apply(1.0e-7)) + assertEquals("1.00000e-7", format("g").apply(1.0e-7)) + assertEquals("1.00000e-7", format("g&E").apply(1.0e-7)) + assertEquals("\\(10^{-7}\\)", format("~g&P").apply(1.0e-7)) + assertEquals("1·\\(10^{-7}\\)", format("~g&F").apply(1.0e-7)) assertEquals("1.23457e+6", format("g").apply(1234567)) - assertEquals("1e-2", format("g{-2,3}").apply(0.01)) - assertEquals("1.234e+3", format("g{-2,3}").apply(1234)) + assertEquals("1.00000e-2", format("g{-2,3}").apply(0.01)) + assertEquals("1.23400e+3", format("g{-2,3}").apply(1234)) // Rounding assertEquals("1.23457e+8", format("g").apply(123456789)) - assertEquals("1.456e-7", format("g").apply(1.456e-7)) + assertEquals("1.45600e-7", format("g").apply(1.456e-7)) // Rounding with precision assertEquals("1.23e+8", format(".3g").apply(123456789)) @@ -40,30 +42,316 @@ class NumberFormatTypeGTest { @Test fun canOutputGeneralNotation() { - assertEquals("0.00026986", format("g").apply(2.6985974025974023E-4)) + assertEquals("0.000269860", format("g").apply(2.6985974025974023E-4)) assertEquals("0.05", format(".1g").apply(0.049)) assertEquals("0.5", format(".1g").apply(0.49)) assertEquals("0.45", format(".2g").apply(0.449)) assertEquals("0.445", format(".3g").apply(0.4449)) assertEquals("0.44445", format(".5g").apply(0.444449)) assertEquals("1e+2", format(".1g").apply(100)) - assertEquals("1e+2", format(".2g").apply(100)) + assertEquals("1.0e+2", format(".2g").apply(100)) assertEquals("100", format(".3g").apply(100)) - assertEquals("100", format(".5g").apply(100)) - assertEquals("100.2", format(".5g").apply(100.2)) - assertEquals("0.002", format(".2g").apply(0.002)) + assertEquals("100.00", format(".5g").apply(100)) + assertEquals("100.20", format(".5g").apply(100.2)) + assertEquals("0.0020", format(".2g").apply(0.002)) } @Test fun canGroupThousandsWithGeneralNotation() { - val f = format(",.12g") - assertEquals("0", f.apply(0)) - assertEquals("42", f.apply(42)) - assertEquals("42,000,000", f.apply(42000000)) - assertEquals("420,000,000", f.apply(420000000)) - assertEquals("-4", f.apply(-4)) - assertEquals("-42", f.apply(-42)) - assertEquals("-4,200,000", f.apply(-4200000)) - assertEquals("-42,000,000", f.apply(-42000000)) + val format = format(",.12g")::apply + val formatTruncated = format(",.12~g")::apply + + 0.let { + assertEquals("0", formatTruncated(it)) + assertEquals("0.00000000000", format(it)) + } + + 42.let { + assertEquals("42", formatTruncated(it)) + assertEquals("42.0000000000", format(it)) + } + + 42_000_000.let { + assertEquals("42,000,000", formatTruncated(it)) + assertEquals("42,000,000.0000", format(it)) + } + + 420_000_000.let { + assertEquals("420,000,000", formatTruncated(it)) + assertEquals("420,000,000.000", format(it)) + } + + (-4).let { + assertEquals("-4", formatTruncated(it)) + assertEquals("-4.00000000000", format(it)) + } + + (-42).let { + assertEquals("-42", formatTruncated(it)) + assertEquals("-42.0000000000", format(it)) + } + + (-4_200_000).let { + assertEquals("-4,200,000", formatTruncated(it)) + assertEquals("-4,200,000.00000", format(it)) + } + + (-42_000_000).let { + assertEquals("-42,000,000", formatTruncated(it)) + assertEquals("-42,000,000.0000", format(it)) + } + } + + @Test + fun compactFormatOfScientificNotation() { + fun format(expType: ExponentNotationType, limits: Pair? = null): NumberFormat { + val limitsStr = limits?.let { "{${it.first},${it.second}}" } ?: "" + return NumberFormat("~&${expType.symbol}$limitsStr") + } + + // + // Default limits + // + + // 10^n + + assertEquals("1e-7", format(ExponentNotationType.E).apply(0.0000001)) + assertEquals("\\(10^{-7}\\)", format(ExponentNotationType.POW).apply(0.0000001)) + assertEquals("1·\\(10^{-7}\\)", format(ExponentNotationType.POW_FULL).apply(0.0000001)) + + assertEquals("0.000001", format(ExponentNotationType.E).apply(0.000001)) + assertEquals("0.000001", format(ExponentNotationType.POW).apply(0.000001)) + assertEquals("0.000001", format(ExponentNotationType.POW_FULL).apply(0.000001)) + + assertEquals("0.1", format(ExponentNotationType.E).apply(0.1)) + assertEquals("0.1", format(ExponentNotationType.POW).apply(0.1)) + assertEquals("0.1", format(ExponentNotationType.POW_FULL).apply(0.1)) + + assertEquals("1", format(ExponentNotationType.E).apply(1)) + assertEquals("1", format(ExponentNotationType.POW).apply(1)) + assertEquals("1", format(ExponentNotationType.POW_FULL).apply(1)) + + assertEquals("10", format(ExponentNotationType.E).apply(10)) + assertEquals("10", format(ExponentNotationType.POW).apply(10)) + assertEquals("10", format(ExponentNotationType.POW_FULL).apply(10)) + + assertEquals("100000", format(ExponentNotationType.E).apply(100000)) + assertEquals("100000", format(ExponentNotationType.POW).apply(100000)) + assertEquals("100000", format(ExponentNotationType.POW_FULL).apply(100000)) + + assertEquals("1e+6", format(ExponentNotationType.E).apply(1000000)) + assertEquals("\\(10^{6}\\)", format(ExponentNotationType.POW).apply(1000000)) + assertEquals("1·\\(10^{6}\\)", format(ExponentNotationType.POW_FULL).apply(1000000)) + + // 2*10^n + + assertEquals("2e-7", format(ExponentNotationType.E).apply(0.0000002)) + assertEquals("2·\\(10^{-7}\\)", format(ExponentNotationType.POW).apply(0.0000002)) + assertEquals("2·\\(10^{-7}\\)", format(ExponentNotationType.POW_FULL).apply(0.0000002)) + + assertEquals("0.000002", format(ExponentNotationType.E).apply(0.000002)) + assertEquals("0.000002", format(ExponentNotationType.POW).apply(0.000002)) + assertEquals("0.000002", format(ExponentNotationType.POW_FULL).apply(0.000002)) + + assertEquals("200000", format(ExponentNotationType.E).apply(200000)) + assertEquals("200000", format(ExponentNotationType.POW).apply(200000)) + assertEquals("200000", format(ExponentNotationType.POW_FULL).apply(200000)) + + assertEquals("2e+6", format(ExponentNotationType.E).apply(2000000)) + assertEquals("2·\\(10^{6}\\)", format(ExponentNotationType.POW).apply(2000000)) + assertEquals("2·\\(10^{6}\\)", format(ExponentNotationType.POW_FULL).apply(2000000)) + + // Negative numbers + + assertEquals("-1e+6", format(ExponentNotationType.E).apply(-1000000)) + assertEquals("-\\(10^{6}\\)", format(ExponentNotationType.POW).apply(-1000000)) + assertEquals("-1·\\(10^{6}\\)", format(ExponentNotationType.POW_FULL).apply(-1000000)) + + assertEquals("-1e-7", format(ExponentNotationType.E).apply(-0.0000001)) + assertEquals("-\\(10^{-7}\\)", format(ExponentNotationType.POW).apply(-0.0000001)) + assertEquals("-1·\\(10^{-7}\\)", format(ExponentNotationType.POW_FULL).apply(-0.0000001)) + + assertEquals("-2e+6", format(ExponentNotationType.E).apply(-2000000)) + assertEquals("-2·\\(10^{6}\\)", format(ExponentNotationType.POW).apply(-2000000)) + assertEquals("-2·\\(10^{6}\\)", format(ExponentNotationType.POW_FULL).apply(-2000000)) + + assertEquals("-2e-7", format(ExponentNotationType.E).apply(-0.0000002)) + assertEquals("-2·\\(10^{-7}\\)", format(ExponentNotationType.POW).apply(-0.0000002)) + assertEquals("-2·\\(10^{-7}\\)", format(ExponentNotationType.POW_FULL).apply(-0.0000002)) + + // + // Limits: (-2, 3) + // + + // 10^n + + assertEquals("1e-2", format(ExponentNotationType.E, -2 to 3).apply(0.01)) + assertEquals("\\(10^{-2}\\)", format(ExponentNotationType.POW, -2 to 3).apply(0.01)) + assertEquals("1·\\(10^{-2}\\)", format(ExponentNotationType.POW_FULL, -2 to 3).apply(0.01)) + + assertEquals("0.1", format(ExponentNotationType.E, -2 to 3).apply(0.1)) + assertEquals("0.1", format(ExponentNotationType.POW, -2 to 3).apply(0.1)) + assertEquals("0.1", format(ExponentNotationType.POW_FULL, -2 to 3).apply(0.1)) + + assertEquals("1", format(ExponentNotationType.E, -2 to 3).apply(1)) + assertEquals("1", format(ExponentNotationType.POW, -2 to 3).apply(1)) + assertEquals("1", format(ExponentNotationType.POW_FULL, -2 to 3).apply(1)) + + assertEquals("10", format(ExponentNotationType.E, -2 to 3).apply(10)) + assertEquals("10", format(ExponentNotationType.POW, -2 to 3).apply(10)) + assertEquals("10", format(ExponentNotationType.POW_FULL, -2 to 3).apply(10)) + + assertEquals("100", format(ExponentNotationType.E, -2 to 3).apply(100)) + assertEquals("100", format(ExponentNotationType.POW, -2 to 3).apply(100)) + assertEquals("100", format(ExponentNotationType.POW_FULL, -2 to 3).apply(100)) + + assertEquals("1e+3", format(ExponentNotationType.E, -2 to 3).apply(1000)) + assertEquals("\\(10^{3}\\)", format(ExponentNotationType.POW, -2 to 3).apply(1000)) + assertEquals("1·\\(10^{3}\\)", format(ExponentNotationType.POW_FULL, -2 to 3).apply(1000)) + + // 2*10^n + + assertEquals("2e-2", format(ExponentNotationType.E, -2 to 3).apply(0.02)) + assertEquals("2·\\(10^{-2}\\)", format(ExponentNotationType.POW, -2 to 3).apply(0.02)) + assertEquals("2·\\(10^{-2}\\)", format(ExponentNotationType.POW_FULL, -2 to 3).apply(0.02)) + + assertEquals("0.2", format(ExponentNotationType.E, -2 to 3).apply(0.2)) + assertEquals("0.2", format(ExponentNotationType.POW, -2 to 3).apply(0.2)) + assertEquals("0.2", format(ExponentNotationType.POW_FULL, -2 to 3).apply(0.2)) + + assertEquals("200", format(ExponentNotationType.E, -2 to 3).apply(200)) + assertEquals("200", format(ExponentNotationType.POW, -2 to 3).apply(200)) + assertEquals("200", format(ExponentNotationType.POW_FULL, -2 to 3).apply(200)) + + assertEquals("2e+3", format(ExponentNotationType.E, -2 to 3).apply(2000)) + assertEquals("2·\\(10^{3}\\)", format(ExponentNotationType.POW, -2 to 3).apply(2000)) + assertEquals("2·\\(10^{3}\\)", format(ExponentNotationType.POW_FULL, -2 to 3).apply(2000)) + + // + // Limits: (0, 0) + // + + assertEquals("1e-1", format(ExponentNotationType.E, 0 to 0).apply(0.1)) + assertEquals("\\(10^{-1}\\)", format(ExponentNotationType.POW, 0 to 0).apply(0.1)) + assertEquals("1·\\(10^{-1}\\)", format(ExponentNotationType.POW_FULL, 0 to 0).apply(0.1)) + + assertEquals("1e+0", format(ExponentNotationType.E, 0 to 0).apply(1)) + assertEquals("\\(10^{0}\\)", format(ExponentNotationType.POW, 0 to 0).apply(1)) + assertEquals("1·\\(10^{0}\\)", format(ExponentNotationType.POW_FULL, 0 to 0).apply(1)) + + assertEquals("1e+1", format(ExponentNotationType.E, 0 to 0).apply(10)) + assertEquals("\\(10^{1}\\)", format(ExponentNotationType.POW, 0 to 0).apply(10)) + assertEquals("1·\\(10^{1}\\)", format(ExponentNotationType.POW_FULL, 0 to 0).apply(10)) + + assertEquals("2e-1", format(ExponentNotationType.E, 0 to 0).apply(0.2)) + assertEquals("2·\\(10^{-1}\\)", format(ExponentNotationType.POW, 0 to 0).apply(0.2)) + assertEquals("2·\\(10^{-1}\\)", format(ExponentNotationType.POW_FULL, 0 to 0).apply(0.2)) + + assertEquals("2e+0", format(ExponentNotationType.E, 0 to 0).apply(2)) + assertEquals("2·\\(10^{0}\\)", format(ExponentNotationType.POW, 0 to 0).apply(2)) + assertEquals("2·\\(10^{0}\\)", format(ExponentNotationType.POW_FULL, 0 to 0).apply(2)) + + assertEquals("2e+1", format(ExponentNotationType.E, 0 to 0).apply(20)) + assertEquals("2·\\(10^{1}\\)", format(ExponentNotationType.POW, 0 to 0).apply(20)) + assertEquals("2·\\(10^{1}\\)", format(ExponentNotationType.POW_FULL, 0 to 0).apply(20)) + } + + + @Test + fun minExpPrecision() { + assertEquals("6e-10", format(6e-10, ".0g{-10,}")) + assertEquals("0.0000000006", format(6e-10, ".0g{-11,}")) + assertEquals("0.0000000006", format(6e-10, ".1g{-11,}")) + assertEquals("0.00000000060", format(6e-10, ".2g{-11,}")) + + assertEquals("1", format(1.000000006, ".0g")) + assertEquals("1", format(1.000000006, ".0g{-10,}")) // "1e-9" ? + assertEquals("1", format(1.000000006, ".0g{-11,}")) // "1e-10" ? + } + + @Test + fun decimalWithWholePartWithDifferentPrecision() { + val number = 1.0006 + assertEquals("1.00060", format(number, "g")) + assertEquals("1", format(number, ".0g")) + assertEquals("1", format(number, ".1g")) + assertEquals("1.0", format(number, ".2g")) + assertEquals("1.00", format(number, ".3g")) + assertEquals("1.001", format(number, ".4g")) + assertEquals("1.0006", format(number, ".5g")) + assertEquals("1.00060", format(number, ".6g")) + } + + @Test + fun decimalWithTwoDigitsWholePartWithDifferentPrecision() { + val number = 21.0006 + assertEquals("21.0006", format(number, "g")) + assertEquals("2e+1", format(number, ".0g")) + assertEquals("2e+1", format(number, ".1g")) + assertEquals("21", format(number, ".2g")) + assertEquals("21.0", format(number, ".3g")) + assertEquals("21.00", format(number, ".4g")) + assertEquals("21.001", format(number, ".5g")) + assertEquals("21.0006", format(number, ".6g")) + } + + @Test + fun p0() { + val format = format(".0g")::apply + assertEquals("0", format(0.0)) + assertEquals("5e-13", format(0.000_000_000_000_5)) + assertEquals("6e-13", format(0.000_000_000_000_55)) + assertEquals("6e-13", format(0.000_000_000_000_555)) + assertEquals("0.005", format(0.005)) + assertEquals("0.05", format(0.05)) + assertEquals("5", format(5.0)) + assertEquals("5e+1", format(50.0)) + assertEquals("6e+1", format(55.0)) + assertEquals("5e+2", format(500.0)) + assertEquals("5e+2", format(505.0)) + assertEquals("6e+2", format(550.0)) + assertEquals("6e+2", format(555.0)) + } + + @Test + fun p1() { + val format = format(".1g")::apply + assertEquals("0", format(0.0)) + assertEquals("5e-13", format(0.000_000_000_000_5)) + assertEquals("6e-13", format(0.000_000_000_000_55)) + assertEquals("6e-13", format(0.000_000_000_000_555)) + assertEquals("0.005", format(0.005)) + assertEquals("0.05", format(0.05)) + assertEquals("5", format(5.0)) + assertEquals("5e+1", format(50.0)) + assertEquals("6e+1", format(55.0)) + assertEquals("5e+2", format(500.0)) + assertEquals("5e+2", format(505.0)) + assertEquals("6e+2", format(550.0)) + assertEquals("6e+2", format(555.0)) + } + + @Test + fun decimalWithoutWholePartWithDifferentPrecision() { + val number = 0.000006 + assertEquals("0.00000600000", format(number, "g")) + assertEquals("0.000006", format(number, ".0g")) + assertEquals("0.000006", format(number, ".1g")) + assertEquals("0.0000060", format(number, ".2g")) + assertEquals("0.00000600", format(number, ".3g")) + assertEquals("0.000006000", format(number, ".4g")) + assertEquals("0.0000060000", format(number, ".5g")) + assertEquals("0.00000600000", format(number, ".6g")) + } + + @Test + fun zeroWithPrecision() { + assertEquals("0.00000", format(0.0, "g")) + assertEquals("0", format(0.0, ".0g")) + assertEquals("0", format(0.0, ".1g")) + assertEquals("0.0", format(0.0, ".2g")) + assertEquals("0.00", format(0.0, ".3g")) + assertEquals("0.000", format(0.0, ".4g")) + assertEquals("0.0000", format(0.0, ".5g")) } } \ No newline at end of file diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeSTest.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeSTest.kt index 2f7056a8da8..0a0758314c6 100644 --- a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeSTest.kt +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeSTest.kt @@ -29,18 +29,18 @@ class NumberFormatTypeSTest { @Test fun withPrecision() { val f1 = NumberFormat(".3s") - assertEquals("0.00", f1.apply(0)) - assertEquals("1.00", f1.apply(1)) + assertEquals("1.00k", f1.apply(999.5)) assertEquals("10.0", f1.apply(10)) + assertEquals("1.00µ", f1.apply(.000001)) + assertEquals("0.00", f1.apply(0)) assertEquals("100", f1.apply(100)) - assertEquals("1.00k", f1.apply(999.5)) + assertEquals("1.00", f1.apply(1)) assertEquals("1.00M", f1.apply(999500)) assertEquals("1.00k", f1.apply(1000)) assertEquals("1.50k", f1.apply(1500.5)) assertEquals("146M", f1.apply(145500000)) assertEquals("146M", f1.apply(145999999.999999347)) assertEquals("100Y", f1.apply(1e26)) - assertEquals("1.00µ", f1.apply(.000001)) assertEquals("10.0m", f1.apply(.009995)) val f2 = NumberFormat(".4s") assertEquals("999.5", f2.apply(999.5)) @@ -76,13 +76,13 @@ class NumberFormatTypeSTest { @Test fun veryLargeWithYotta() { val f = NumberFormat(".8s") + assertEquals("1230.0000Y", f.apply(1.23e+27)) assertEquals("1.2300000Z", f.apply(1.23e+21)) assertEquals("12.300000Z", f.apply(1.23e+22)) assertEquals("123.00000Z", f.apply(1.23e+23)) assertEquals("1.2300000Y", f.apply(1.23e+24)) assertEquals("12.300000Y", f.apply(1.23e+25)) assertEquals("123.00000Y", f.apply(1.23e+26)) - assertEquals("1230.0000Y", f.apply(1.23e+27)) assertEquals("12300.000Y", f.apply(1.23e+28)) assertEquals("123000.00Y", f.apply(1.23e+29)) assertEquals("1230000.0Y", f.apply(1.23e+30)) @@ -128,6 +128,7 @@ class NumberFormatTypeSTest { @Test fun consistentForSmallAndLargeNumbers() { val f1 = NumberFormat(".0s") + assertEquals("20µ", f1.apply(1.6e-5)) assertEquals("10µ", f1.apply(1e-5)) assertEquals("100µ", f1.apply(1e-4)) assertEquals("1m", f1.apply(1e-3)) diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeXTest.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeXTest.kt index ea223159430..6d853b42f51 100644 --- a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeXTest.kt +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/NumberFormatTypeXTest.kt @@ -76,4 +76,9 @@ class NumberFormatTypeXTest { assertEquals("000000000000deadbeef", NumberFormat("020x").apply(0xdeadbeef)) assertEquals("0x0000000000deadbeef", NumberFormat("#020x").apply(0xdeadbeef)) } + + @Test + fun overflowTes() { + //assertEquals("INFINITY", NumberFormat("x").apply(1.23e200)) + } } \ No newline at end of file diff --git a/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/Util.kt b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/Util.kt new file mode 100644 index 00000000000..4a9cd944f86 --- /dev/null +++ b/commons/src/commonTest/kotlin/org/jetbrains/letsPlot/commons/formatting/number/Util.kt @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2024. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +package org.jetbrains.letsPlot.commons.formatting.number + +object Util { + const val DOUBLE_ALMOST_MIN_VALUE = 1.00e-323 // MIN_VALUE in JVM and JS has diff value -4.90e-324 vs 5.00e-324. Use own value to avoid platform-specific tests +} \ No newline at end of file diff --git a/future_changes.md b/future_changes.md index 500f77e1316..4d79bec1e76 100644 --- a/future_changes.md +++ b/future_changes.md @@ -7,3 +7,7 @@ ### Fixed - Kandy toPNG reports NullPointerException [[#1228](https://github.com/JetBrains/lets-plot/issues/1228)] +- Wrong formatting when type='g' for small values [[#1238](https://github.com/JetBrains/lets-plot/issues/1238)]. +- Formatting when type='g' for large values throws exception [[#1239](https://github.com/JetBrains/lets-plot/issues/1239)]. +- Wrong formatting when type='s' with explicit precision [[#1240](https://github.com/JetBrains/lets-plot/issues/1240)]. +- Extra trim in formatted number when type='g' [[#1241](https://github.com/JetBrains/lets-plot/issues/1241)]. \ No newline at end of file diff --git a/plot-base/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/plot/base/scale/breaks/NumberTickFormatTest.kt b/plot-base/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/plot/base/scale/breaks/NumberTickFormatTest.kt index e6910be5d04..1d7b72968c4 100644 --- a/plot-base/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/plot/base/scale/breaks/NumberTickFormatTest.kt +++ b/plot-base/src/jvmTest/kotlin/org/jetbrains/letsPlot/core/plot/base/scale/breaks/NumberTickFormatTest.kt @@ -202,10 +202,10 @@ class NumberTickFormatTest { @Test fun both_ultraLarge_metricPrefix() { val domainAndStep = doubleArrayOf(1e8, 5e6) + assertEquals("110M", format(1e8 + 5e6, domainAndStep)) assertEquals("50M", format(5e7, domainAndStep)) assertEquals("50M", format(5e7 + 5, domainAndStep)) assertEquals( "55M", format(5e7 + 5e6, domainAndStep)) - assertEquals("105M", format(1e8 + 5e6, domainAndStep)) assertEquals("5·\\(10^{7}\\)", format(5e7, domainAndStep, ExponentFormat(ExponentNotationType.POW))) assertEquals("5·\\(10^{7}\\)", format(5e7 + 5, domainAndStep, ExponentFormat(ExponentNotationType.POW)))