diff --git a/js/dot-tests.js b/js/dot-tests.js index 175e401..3c0aaf0 100644 --- a/js/dot-tests.js +++ b/js/dot-tests.js @@ -6,6 +6,7 @@ * @author Sam Reid (PhET Interactive Simulations) */ +import './toFixedPointStringTests.js'; import './BinPackerTests.js'; import './Bounds2Tests.js'; import './ComplexTests.js'; diff --git a/js/toFixedPointString.ts b/js/toFixedPointString.ts new file mode 100644 index 0000000..e16e4f5 --- /dev/null +++ b/js/toFixedPointString.ts @@ -0,0 +1,74 @@ +// Copyright 2022, University of Colorado Boulder + +/** + * toFixedPointString is a version of Number.toFixed that avoids rounding problems and floating-point errors that exist + * in Number.toFixed and dot.Utils.toFixed. It converts a number of a string, then modifies that string based on the + * number of decimal places desired. It performs symmetric rounding based on only the 2 specific digits that should be + * considered when rounding. Values that are not finite are converted using Number.toFixed. + * + * See https://github.com/phetsims/dot/issues/113 + * + * @author Chris Malley (PixelZoom, Inc.) + */ + +import dot from './dot.js'; + +function toFixedPointString( value: number, decimalPlaces: number ): string { + assert && assert( isFinite( decimalPlaces ) && decimalPlaces >= 0 ); + + // If value is not finite, then delegate to Number.toFixed and return immediately. + if ( !isFinite( value ) ) { + return value.toFixed( decimalPlaces ); // eslint-disable-line bad-sim-text + } + + // Convert the value to a string. + let stringValue = value.toString(); + + // Find the decimal point in the string. + const decimalPointIndex = stringValue.indexOf( '.' ); + + if ( decimalPointIndex !== -1 ) { + + // If there is a decimal point... + const actualDecimalPlaces = stringValue.length - decimalPointIndex - 1; + if ( actualDecimalPlaces < decimalPlaces ) { + + // There are not enough decimal places, so pad with 0's to the right of decimal point + for ( let i = 0; i < decimalPlaces - actualDecimalPlaces; i++ ) { + stringValue += '0'; + } + } + else if ( actualDecimalPlaces > decimalPlaces ) { + + // There are too many decimal places, so round symmetric. + const digit = parseInt( stringValue[ decimalPointIndex + decimalPlaces + 1 ], 10 ); + const delta = ( digit >= 5 ) ? 1 : 0; + stringValue = stringValue.substring( 0, stringValue.length - ( actualDecimalPlaces - decimalPlaces ) ); + if ( stringValue[ stringValue.length - 1 ] === '.' ) { + stringValue = stringValue.substring( 0, stringValue.length - 1 ); + } + const lastDecimal = parseInt( stringValue[ stringValue.length - 1 ], 10 ) + delta; + stringValue = stringValue.replace( /.$/, lastDecimal.toString() ); + } + } + else { + + // There is no decimal point, so add a decimal point and pad with zeros. + if ( decimalPlaces > 0 ) { + stringValue += '.'; + for ( let i = 0; i < decimalPlaces; i++ ) { + stringValue += '0'; + } + } + } + + // Remove negative sign from -0 values. + if ( parseFloat( stringValue ) === 0 && stringValue[ 0 ] === '-' ) { + stringValue = stringValue.substring( 1, stringValue.length ); + } + + return stringValue; +} + +dot.register( 'toFixedPointString', toFixedPointString ); +export default toFixedPointString; diff --git a/js/toFixedPointStringTests.js b/js/toFixedPointStringTests.js new file mode 100644 index 0000000..3cba66a --- /dev/null +++ b/js/toFixedPointStringTests.js @@ -0,0 +1,20 @@ +// Copyright 2022, University of Colorado Boulder + +/** + * QUnit tests for toFixedPointString. + * + * @author Chris Malley (PixelZoom, Inc.) + */ + +import toFixedPointString from './toFixedPointString.js'; + +QUnit.module( 'toFixedPointString' ); + +QUnit.test( 'tests', assert => { + assert.equal( toFixedPointString( 35.855, 2 ), '35.86' ); + assert.equal( toFixedPointString( 35.854, 2 ), '35.85' ); + assert.equal( toFixedPointString( 0.005, 2 ), '0.01' ); + assert.equal( toFixedPointString( 0.004, 2 ), '0.00' ); + assert.equal( toFixedPointString( -0.005, 2 ), '-0.01' ); + assert.equal( toFixedPointString( -0.004, 2 ), '0.00' ); +} ); \ No newline at end of file