Commit f9a6c596 authored by Miguel Rincon's avatar Miguel Rincon

Adds a number formatter that supports several formats

- numbers
- percentage (0 - 1) and (0 - 100)
- digital units (bytes, kilobytes, etc...)
parent 394ab119
/**
* Formats a number as string using `toLocaleString`.
*
* @param {Number} number to be converted
* @param {params} Parameters
* @param {params.fractionDigits} Number of decimal digits
* to display, defaults to using `toLocaleString` defaults.
* @param {params.maxLength} Max output char lenght at the
* expense of precision, if the output is longer than this,
* the formatter switches to using exponential notation.
* @param {params.factor} Value is multiplied by this factor,
* useful for value normalization.
* @returns Formatted value
*/
function formatNumber(
value,
{ fractionDigits = undefined, valueFactor = 1, style = undefined, maxLength = undefined },
) {
if (value === null) {
return '';
}
const num = value * valueFactor;
const formatted = num.toLocaleString(undefined, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
style,
});
if (maxLength !== undefined && formatted.length > maxLength) {
// 123456 becomes 1.23e+8
return num.toExponential(2);
}
return formatted;
}
/**
* Formats a number as a string scaling it up according to units.
*
* While the number is scaled down, the units are scaled up.
*
* @param {Array} List of units of the scale
* @param {Number} unitFactor - Factor of the scale for each
* unit after which the next unit is used scaled.
*/
const scaledFormatter = (units, unitFactor = 1000) => {
if (unitFactor === 0) {
return new RangeError(`unitFactor cannot have the value 0.`);
}
return (value, fractionDigits) => {
if (value === null) {
return '';
}
if (
value === Number.NEGATIVE_INFINITY ||
value === Number.POSITIVE_INFINITY ||
Number.isNaN(value)
) {
return value.toLocaleString(undefined);
}
let num = value;
let scale = 0;
const limit = units.length;
while (Math.abs(num) >= unitFactor) {
scale += 1;
num /= unitFactor;
if (scale >= limit) {
return 'NA';
}
}
const unit = units[scale];
return `${formatNumber(num, { fractionDigits })}${unit}`;
};
};
/**
* Returns a function that formats a number as a string.
*/
export const numberFormatter = (style = 'decimal', valueFactor = 1) => {
return (value, fractionDigits, maxLength) => {
return `${formatNumber(value, { fractionDigits, maxLength, valueFactor, style })}`;
};
};
/**
* Returns a function that formats a number as a string with a suffix.
*/
export const suffixFormatter = (unit = '', valueFactor = 1) => {
return (value, fractionDigits, maxLength) => {
const length = maxLength !== undefined ? maxLength - unit.length : undefined;
return `${formatNumber(value, { fractionDigits, maxLength: length, valueFactor })}${unit}`;
};
};
/**
* Returns a function that formats a number scaled using SI units notation.
*/
export const scaledSIFormatter = (unit = '', prefixOffset = 0) => {
const fractional = ['y', 'z', 'a', 'f', 'p', 'n', 'µ', 'm'];
const multiplicative = ['k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
const symbols = [...fractional, '', ...multiplicative];
const units = symbols.slice(fractional.length + prefixOffset).map(prefix => {
return `${prefix}${unit}`;
});
if (!units.length) {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
throw new RangeError('The unit cannot be converted, please try a different scale');
}
return scaledFormatter(units);
};
import { s__ } from '~/locale';
import { suffixFormatter, scaledSIFormatter, numberFormatter } from './formatter_factory';
/**
* Supported formats
*/
export const SUPPORTED_FORMATS = {
// Number
number: 'number',
percent: 'percent',
percentHundred: 'percentHundred',
// Duration
seconds: 'seconds',
miliseconds: 'miliseconds',
// Digital
bytes: 'bytes',
kilobytes: 'kilobytes',
megabytes: 'megabytes',
gigabytes: 'gigabytes',
terabytes: 'terabytes',
petabytes: 'petabytes',
};
/**
* Returns a function that formats number to different units
* @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to number.
*
*
*/
export const getFormatter = (format = SUPPORTED_FORMATS.number) => {
// Number
if (format === SUPPORTED_FORMATS.number) {
/**
* Formats a number
*
* @function
* @param {Number} value - Number to format
* @param {Number} fractionDigits - precision decimals
* @param {Number} maxLength - Max lenght of formatted number
* if lenght is exceeded, exponential format is used.
*/
return numberFormatter();
}
if (format === SUPPORTED_FORMATS.percent) {
/**
* Formats a percentge (0 - 1)
*
* @function
* @param {Number} value - Number to format, `1` is rendered as `100%`
* @param {Number} fractionDigits - number of precision decimals
* @param {Number} maxLength - Max lenght of formatted number
* if lenght is exceeded, exponential format is used.
*/
return numberFormatter('percent');
}
if (format === SUPPORTED_FORMATS.percentHundred) {
/**
* Formats a percentge (0 to 100)
*
* @function
* @param {Number} value - Number to format, `100` is rendered as `100%`
* @param {Number} fractionDigits - number of precision decimals
* @param {Number} maxLength - Max lenght of formatted number
* if lenght is exceeded, exponential format is used.
*/
return numberFormatter('percent', 1 / 100);
}
// Durations
if (format === SUPPORTED_FORMATS.seconds) {
/**
* Formats a number of seconds
*
* @function
* @param {Number} value - Number to format, `1` is rendered as `1s`
* @param {Number} fractionDigits - number of precision decimals
* @param {Number} maxLength - Max lenght of formatted number
* if lenght is exceeded, exponential format is used.
*/
return suffixFormatter(s__('Units|s'));
}
if (format === SUPPORTED_FORMATS.miliseconds) {
/**
* Formats a number of miliseconds with ms as units
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1ms`
* @param {Number} fractionDigits - number of precision decimals
* @param {Number} maxLength - Max lenght of formatted number
* if lenght is exceeded, exponential format is used.
*/
return suffixFormatter(s__('Units|ms'));
}
// Digital
if (format === SUPPORTED_FORMATS.bytes) {
/**
* Formats a number of bytes scaled up to larger digital
* units for larger numbers.
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1B`
* @param {Number} fractionDigits - number of precision decimals
*/
return scaledSIFormatter('B');
}
if (format === SUPPORTED_FORMATS.kilobytes) {
/**
* Formats a number of kilobytes scaled up to larger digital
* units for larger numbers.
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1kB`
* @param {Number} fractionDigits - number of precision decimals
*/
return scaledSIFormatter('B', 1);
}
if (format === SUPPORTED_FORMATS.megabytes) {
/**
* Formats a number of megabytes scaled up to larger digital
* units for larger numbers.
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1MB`
* @param {Number} fractionDigits - number of precision decimals
*/
return scaledSIFormatter('B', 2);
}
if (format === SUPPORTED_FORMATS.gigabytes) {
/**
* Formats a number of gigabytes scaled up to larger digital
* units for larger numbers.
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1GB`
* @param {Number} fractionDigits - number of precision decimals
*/
return scaledSIFormatter('B', 3);
}
if (format === SUPPORTED_FORMATS.terabytes) {
/**
* Formats a number of terabytes scaled up to larger digital
* units for larger numbers.
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1GB`
* @param {Number} fractionDigits - number of precision decimals
*/
return scaledSIFormatter('B', 4);
}
if (format === SUPPORTED_FORMATS.petabytes) {
/**
* Formats a number of petabytes scaled up to larger digital
* units for larger numbers.
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1PB`
* @param {Number} fractionDigits - number of precision decimals
*/
return scaledSIFormatter('B', 5);
}
// Fail so client library addresses issue
throw TypeError(`${format} is not a valid number format`);
};
...@@ -4,7 +4,7 @@ import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ ...@@ -4,7 +4,7 @@ import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { roundOffFloat } from '~/lib/utils/common_utils'; import { getFormatter } from '~/lib/utils/unit_format';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { import {
...@@ -37,6 +37,8 @@ const events = { ...@@ -37,6 +37,8 @@ const events = {
datazoom: 'datazoom', datazoom: 'datazoom',
}; };
const yValFormatter = getFormatter('number');
export default { export default {
components: { components: {
GlAreaChart, GlAreaChart,
...@@ -171,7 +173,7 @@ export default { ...@@ -171,7 +173,7 @@ export default {
boundaryGap: [0.1, 0.1], boundaryGap: [0.1, 0.1],
scale: true, scale: true,
axisLabel: { axisLabel: {
formatter: num => roundOffFloat(num, 3).toString(), formatter: num => yValFormatter(num, 3),
}, },
...yAxis, ...yAxis,
}; };
...@@ -313,7 +315,8 @@ export default { ...@@ -313,7 +315,8 @@ export default {
this.tooltip.commitUrl = deploy.commitUrl; this.tooltip.commitUrl = deploy.commitUrl;
} else { } else {
const { seriesName, color, dataIndex } = dataPoint; const { seriesName, color, dataIndex } = dataPoint;
const value = yVal.toFixed(3); const value = yValFormatter(yVal, 3);
this.tooltip.content.push({ this.tooltip.content.push({
name: seriesName, name: seriesName,
dataIndex, dataIndex,
......
...@@ -20753,6 +20753,12 @@ msgstr "" ...@@ -20753,6 +20753,12 @@ msgstr ""
msgid "Uninstalling" msgid "Uninstalling"
msgstr "" msgstr ""
msgid "Units|ms"
msgstr ""
msgid "Units|s"
msgstr ""
msgid "Unknown" msgid "Unknown"
msgstr "" msgstr ""
......
import {
numberFormatter,
suffixFormatter,
scaledSIFormatter,
} from '~/lib/utils/unit_format/formatter_factory';
describe('unit_format/formatter_factory', () => {
describe('numberFormatter', () => {
let formatNumber;
beforeEach(() => {
formatNumber = numberFormatter();
});
it('formats a integer', () => {
expect(formatNumber(1)).toEqual('1');
expect(formatNumber(100)).toEqual('100');
expect(formatNumber(1000)).toEqual('1,000');
expect(formatNumber(10000)).toEqual('10,000');
expect(formatNumber(1000000)).toEqual('1,000,000');
});
it('formats a floating point number', () => {
expect(formatNumber(0.1)).toEqual('0.1');
expect(formatNumber(0.1, 0)).toEqual('0');
expect(formatNumber(0.1, 2)).toEqual('0.10');
expect(formatNumber(0.1, 3)).toEqual('0.100');
expect(formatNumber(12.345)).toEqual('12.345');
expect(formatNumber(12.345, 2)).toEqual('12.35');
expect(formatNumber(12.345, 4)).toEqual('12.3450');
});
it('formats a large integer with a length limit', () => {
expect(formatNumber(10 ** 7, undefined)).toEqual('10,000,000');
expect(formatNumber(10 ** 7, undefined, 9)).toEqual('1.00e+7');
expect(formatNumber(10 ** 7, undefined, 10)).toEqual('10,000,000');
});
});
describe('suffixFormatter', () => {
let formatSuffix;
beforeEach(() => {
formatSuffix = suffixFormatter('pop.', undefined);
});
it('formats a integer', () => {
expect(formatSuffix(1)).toEqual('1pop.');
expect(formatSuffix(100)).toEqual('100pop.');
expect(formatSuffix(1000)).toEqual('1,000pop.');
expect(formatSuffix(10000)).toEqual('10,000pop.');
expect(formatSuffix(1000000)).toEqual('1,000,000pop.');
});
it('formats a floating point number', () => {
expect(formatSuffix(0.1)).toEqual('0.1pop.');
expect(formatSuffix(0.1, 0)).toEqual('0pop.');
expect(formatSuffix(0.1, 2)).toEqual('0.10pop.');
expect(formatSuffix(0.1, 3)).toEqual('0.100pop.');
expect(formatSuffix(12.345)).toEqual('12.345pop.');
expect(formatSuffix(12.345, 2)).toEqual('12.35pop.');
expect(formatSuffix(12.345, 4)).toEqual('12.3450pop.');
});
it('formats a negative integer', () => {
expect(formatSuffix(-1)).toEqual('-1pop.');
expect(formatSuffix(-100)).toEqual('-100pop.');
expect(formatSuffix(-1000)).toEqual('-1,000pop.');
expect(formatSuffix(-10000)).toEqual('-10,000pop.');
expect(formatSuffix(-1000000)).toEqual('-1,000,000pop.');
});
it('formats a floating point nugative number', () => {
expect(formatSuffix(-0.1)).toEqual('-0.1pop.');
expect(formatSuffix(-0.1, 0)).toEqual('-0pop.');
expect(formatSuffix(-0.1, 2)).toEqual('-0.10pop.');
expect(formatSuffix(-0.1, 3)).toEqual('-0.100pop.');
expect(formatSuffix(-12.345)).toEqual('-12.345pop.');
expect(formatSuffix(-12.345, 2)).toEqual('-12.35pop.');
expect(formatSuffix(-12.345, 4)).toEqual('-12.3450pop.');
});
it('formats a large integer', () => {
expect(formatSuffix(10 ** 7)).toEqual('10,000,000pop.');
expect(formatSuffix(10 ** 10)).toEqual('10,000,000,000pop.');
});
it('formats a large integer with a length limit', () => {
expect(formatSuffix(10 ** 7, undefined, 10)).toEqual('1.00e+7pop.');
expect(formatSuffix(10 ** 10, undefined, 10)).toEqual('1.00e+10pop.');
});
});
describe('scaledSIFormatter', () => {
describe('scaled format', () => {
let formatScaled;
beforeEach(() => {
formatScaled = scaledSIFormatter('B');
});
it('formats bytes', () => {
expect(formatScaled(12.345)).toEqual('12.345B');
expect(formatScaled(12.345, 0)).toEqual('12B');
expect(formatScaled(12.345, 1)).toEqual('12.3B');
expect(formatScaled(12.345, 2)).toEqual('12.35B');
});
it('formats bytes in a scale', () => {
expect(formatScaled(1)).toEqual('1B');
expect(formatScaled(10)).toEqual('10B');
expect(formatScaled(10 ** 2)).toEqual('100B');
expect(formatScaled(10 ** 3)).toEqual('1kB');
expect(formatScaled(10 ** 4)).toEqual('10kB');
expect(formatScaled(10 ** 5)).toEqual('100kB');
expect(formatScaled(10 ** 6)).toEqual('1MB');
expect(formatScaled(10 ** 7)).toEqual('10MB');
expect(formatScaled(10 ** 8)).toEqual('100MB');
expect(formatScaled(10 ** 9)).toEqual('1GB');
expect(formatScaled(10 ** 10)).toEqual('10GB');
expect(formatScaled(10 ** 11)).toEqual('100GB');
});
});
describe('scaled format with offset', () => {
let formatScaled;
beforeEach(() => {
// formats gigabytes
formatScaled = scaledSIFormatter('B', 3);
});
it('formats floating point numbers', () => {
expect(formatScaled(12.345)).toEqual('12.345GB');
expect(formatScaled(12.345, 0)).toEqual('12GB');
expect(formatScaled(12.345, 1)).toEqual('12.3GB');
expect(formatScaled(12.345, 2)).toEqual('12.35GB');
});
it('formats large numbers scaled', () => {
expect(formatScaled(1)).toEqual('1GB');
expect(formatScaled(1, 1)).toEqual('1.0GB');
expect(formatScaled(10)).toEqual('10GB');
expect(formatScaled(10 ** 2)).toEqual('100GB');
expect(formatScaled(10 ** 3)).toEqual('1TB');
expect(formatScaled(10 ** 4)).toEqual('10TB');
expect(formatScaled(10 ** 5)).toEqual('100TB');
expect(formatScaled(10 ** 6)).toEqual('1PB');
expect(formatScaled(10 ** 7)).toEqual('10PB');
expect(formatScaled(10 ** 8)).toEqual('100PB');
expect(formatScaled(10 ** 9)).toEqual('1EB');
});
it('formatting of too large numbers is not suported', () => {
// formatting YB is out of range
expect(() => scaledSIFormatter('B', 9)).toThrow();
});
});
describe('scaled format with negative offset', () => {
let formatScaled;
beforeEach(() => {
formatScaled = scaledSIFormatter('g', -1);
});
it('formats floating point numbers', () => {
expect(formatScaled(12.345)).toEqual('12.345mg');
expect(formatScaled(12.345, 0)).toEqual('12mg');
expect(formatScaled(12.345, 1)).toEqual('12.3mg');
expect(formatScaled(12.345, 2)).toEqual('12.35mg');
});
it('formats large numbers scaled', () => {
expect(formatScaled(1)).toEqual('1mg');
expect(formatScaled(1, 1)).toEqual('1.0mg');
expect(formatScaled(10)).toEqual('10mg');
expect(formatScaled(10 ** 2)).toEqual('100mg');
expect(formatScaled(10 ** 3)).toEqual('1g');
expect(formatScaled(10 ** 4)).toEqual('10g');
expect(formatScaled(10 ** 5)).toEqual('100g');
expect(formatScaled(10 ** 6)).toEqual('1kg');
expect(formatScaled(10 ** 7)).toEqual('10kg');
expect(formatScaled(10 ** 8)).toEqual('100kg');
});
it('formats negative numbers scaled', () => {
expect(formatScaled(-12.345)).toEqual('-12.345mg');
expect(formatScaled(-12.345, 0)).toEqual('-12mg');
expect(formatScaled(-12.345, 1)).toEqual('-12.3mg');
expect(formatScaled(-12.345, 2)).toEqual('-12.35mg');
expect(formatScaled(-10)).toEqual('-10mg');
expect(formatScaled(-100)).toEqual('-100mg');
expect(formatScaled(-(10 ** 4))).toEqual('-10g');
});
});
});
});
import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
describe('unit_format', () => {
describe('when a supported format is provided, the returned function formats', () => {
it('numbers, by default', () => {
expect(getFormatter()(1)).toEqual('1');
});
it('numbers', () => {
const formatNumber = getFormatter(SUPPORTED_FORMATS.number);
expect(formatNumber(1)).toEqual('1');
expect(formatNumber(100)).toEqual('100');
expect(formatNumber(1000)).toEqual('1,000');
expect(formatNumber(10000)).toEqual('10,000');
expect(formatNumber(1000000)).toEqual('1,000,000');
});
it('percent', () => {
const formatPercent = getFormatter(SUPPORTED_FORMATS.percent);
expect(formatPercent(1)).toEqual('100%');
expect(formatPercent(1, 2)).toEqual('100.00%');
expect(formatPercent(0.1)).toEqual('10%');
expect(formatPercent(0.5)).toEqual('50%');
expect(formatPercent(0.888888)).toEqual('89%');
expect(formatPercent(0.888888, 2)).toEqual('88.89%');
expect(formatPercent(0.888888, 5)).toEqual('88.88880%');
expect(formatPercent(2)).toEqual('200%');
expect(formatPercent(10)).toEqual('1,000%');
});
it('percentunit', () => {
const formatPercentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred);
expect(formatPercentHundred(1)).toEqual('1%');
expect(formatPercentHundred(1, 2)).toEqual('1.00%');
expect(formatPercentHundred(88.8888)).toEqual('89%');
expect(formatPercentHundred(88.8888, 2)).toEqual('88.89%');
expect(formatPercentHundred(88.8888, 5)).toEqual('88.88880%');
expect(formatPercentHundred(100)).toEqual('100%');
expect(formatPercentHundred(100, 2)).toEqual('100.00%');
expect(formatPercentHundred(200)).toEqual('200%');
expect(formatPercentHundred(1000)).toEqual('1,000%');
});
it('seconds', () => {
expect(getFormatter(SUPPORTED_FORMATS.seconds)(1)).toEqual('1s');
});
it('miliseconds', () => {
const formatMiliseconds = getFormatter(SUPPORTED_FORMATS.miliseconds);
expect(formatMiliseconds(1)).toEqual('1ms');
expect(formatMiliseconds(100)).toEqual('100ms');
expect(formatMiliseconds(1000)).toEqual('1,000ms');
expect(formatMiliseconds(10000)).toEqual('10,000ms');
expect(formatMiliseconds(1000000)).toEqual('1,000,000ms');
});
it('bytes', () => {
const formatBytes = getFormatter(SUPPORTED_FORMATS.bytes);
expect(formatBytes(1)).toEqual('1B');
expect(formatBytes(1, 1)).toEqual('1.0B');
expect(formatBytes(10)).toEqual('10B');
expect(formatBytes(10 ** 2)).toEqual('100B');
expect(formatBytes(10 ** 3)).toEqual('1kB');
expect(formatBytes(10 ** 4)).toEqual('10kB');
expect(formatBytes(10 ** 5)).toEqual('100kB');
expect(formatBytes(10 ** 6)).toEqual('1MB');
expect(formatBytes(10 ** 7)).toEqual('10MB');
expect(formatBytes(10 ** 8)).toEqual('100MB');
expect(formatBytes(10 ** 9)).toEqual('1GB');
expect(formatBytes(10 ** 10)).toEqual('10GB');
expect(formatBytes(10 ** 11)).toEqual('100GB');
});
it('kilobytes', () => {
expect(getFormatter(SUPPORTED_FORMATS.kilobytes)(1)).toEqual('1kB');
expect(getFormatter(SUPPORTED_FORMATS.kilobytes)(1, 1)).toEqual('1.0kB');
});
it('megabytes', () => {
expect(getFormatter(SUPPORTED_FORMATS.megabytes)(1)).toEqual('1MB');
expect(getFormatter(SUPPORTED_FORMATS.megabytes)(1, 1)).toEqual('1.0MB');
});
it('gigabytes', () => {
expect(getFormatter(SUPPORTED_FORMATS.gigabytes)(1)).toEqual('1GB');
expect(getFormatter(SUPPORTED_FORMATS.gigabytes)(1, 1)).toEqual('1.0GB');
});
it('terabytes', () => {
expect(getFormatter(SUPPORTED_FORMATS.terabytes)(1)).toEqual('1TB');
expect(getFormatter(SUPPORTED_FORMATS.terabytes)(1, 1)).toEqual('1.0TB');
});
it('petabytes', () => {
expect(getFormatter(SUPPORTED_FORMATS.petabytes)(1)).toEqual('1PB');
expect(getFormatter(SUPPORTED_FORMATS.petabytes)(1, 1)).toEqual('1.0PB');
});
});
describe('when get formatter format is incorrect', () => {
it('formatter fails', () => {
expect(() => getFormatter('not-supported')(1)).toThrow();
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment