-
Notifications
You must be signed in to change notification settings - Fork 9.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
i18n: localize units in report formatter #13830
Changes from all commits
504e62a
0b88142
7dd7d01
08bc98b
b4a8e23
61c23f5
6232d93
9e82306
325d84b
0d3cbc6
350a33a
a9c7388
7db81d2
fc20aad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,25 +21,59 @@ export class I18n { | |
// When testing, use a locale with more exciting numeric formatting. | ||
if (locale === 'en-XA') locale = 'de'; | ||
|
||
this._numberDateLocale = locale; | ||
this._numberFormatter = new Intl.NumberFormat(locale); | ||
this._percentFormatter = new Intl.NumberFormat(locale, {style: 'percent'}); | ||
this._locale = locale; | ||
this._strings = strings; | ||
} | ||
|
||
get strings() { | ||
return this._strings; | ||
} | ||
|
||
/** | ||
* @param {number} number | ||
* @param {number} granularity | ||
* @param {Intl.NumberFormatOptions} opts | ||
* @return {string} | ||
*/ | ||
_formatNumberWithGranularity(number, granularity, opts = {}) { | ||
opts = {...opts}; | ||
const log10 = -Math.log10(granularity); | ||
if (!Number.isFinite(log10) || (granularity > 1 && !Number.isInteger(log10))) { | ||
console.warn(`granularity of ${granularity} is invalid, defaulting to value of 1`); | ||
granularity = 1; | ||
} | ||
|
||
if (granularity < 1) { | ||
opts.minimumFractionDigits = opts.maximumFractionDigits = Math.ceil(log10); | ||
} | ||
|
||
number = Math.round(number / granularity) * granularity; | ||
|
||
// Avoid displaying a negative value that rounds to zero as "0". | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
if (Object.is(number, -0)) number = 0; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I could have done There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if JS added There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IEEE-754 requires that they compare as equal 🤓 |
||
|
||
return new Intl.NumberFormat(this._locale, opts).format(number).replace(' ', NBSP2); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems like it should be fine to do the replace thing, though it feels dirty and maybe there's somehow an issue with rtl or something? 🤷 Do we still need it? I assume we do, but it would be cool if our various layout initiatives over the years made it unnecessary to keep them from breaking across lines There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interestingly, for
Also, languages disagree on spacing of units :/ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. omg this is infuriatingly inconsistent. no way this is due to locale differences, these control characters are formatting and what locale would expect numbers to be disassociated from their unit by newlines?? this has to be a mistake in the localization library... your One can sort of see reason for there being a non-breaking space between the number and the short string but not for the longer one, as without that number the shorter strings being further from their number gives them far less context. (your last example is the same two lines of code btw. i'd expect narrow to never include any whitespace. I see There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
totally, but why isn't it the same across languages?? Or at least, why wouldn't english (or german or spanish or...) do it as well?
oops, I meant
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
|
||
/** | ||
* Format number. | ||
* @param {number} number | ||
* @param {number=} granularity Number of decimal places to include. Defaults to 0.1. | ||
* @return {string} | ||
*/ | ||
formatNumber(number, granularity = 0.1) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. perhaps this default should be 1? it would match the previous behavior, such that integers are formatted without a fractional component There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. instead I made a new function formatInteger There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah, it's reasonable to show the extra fractional digit when granularity is 0.1 (which is what #11489 did for bytes), and maybe we don't really want 0.1 as the default based on how we usually don't do anything about it, but that's annoying to audit and |
||
const coarseValue = Math.round(number / granularity) * granularity; | ||
return this._numberFormatter.format(coarseValue); | ||
return this._formatNumberWithGranularity(number, granularity); | ||
} | ||
|
||
/** | ||
* Format integer. | ||
* Just like {@link formatNumber} but uses a granularity of 1, rounding to the nearest | ||
* whole number. | ||
* @param {number} number | ||
* @return {string} | ||
*/ | ||
formatInteger(number) { | ||
return this._formatNumberWithGranularity(number, 1); | ||
} | ||
|
||
/** | ||
|
@@ -48,7 +82,7 @@ export class I18n { | |
* @return {string} | ||
*/ | ||
formatPercent(number) { | ||
return this._percentFormatter.format(number); | ||
return new Intl.NumberFormat(this._locale, {style: 'percent'}).format(number); | ||
} | ||
|
||
/** | ||
|
@@ -57,9 +91,7 @@ export class I18n { | |
* @return {string} | ||
*/ | ||
formatBytesToKiB(size, granularity = 0.1) { | ||
const formatter = this._byteFormatterForGranularity(granularity); | ||
const kbs = formatter.format(Math.round(size / 1024 / granularity) * granularity); | ||
return `${kbs}${NBSP2}KiB`; | ||
return this._formatNumberWithGranularity(size / KiB, granularity) + `${NBSP2}KiB`; | ||
} | ||
|
||
/** | ||
|
@@ -68,9 +100,7 @@ export class I18n { | |
* @return {string} | ||
*/ | ||
formatBytesToMiB(size, granularity = 0.1) { | ||
const formatter = this._byteFormatterForGranularity(granularity); | ||
const kbs = formatter.format(Math.round(size / (1024 ** 2) / granularity) * granularity); | ||
return `${kbs}${NBSP2}MiB`; | ||
return this._formatNumberWithGranularity(size / MiB, granularity) + `${NBSP2}MiB`; | ||
} | ||
|
||
/** | ||
|
@@ -79,9 +109,11 @@ export class I18n { | |
* @return {string} | ||
*/ | ||
formatBytes(size, granularity = 1) { | ||
const formatter = this._byteFormatterForGranularity(granularity); | ||
const kbs = formatter.format(Math.round(size / granularity) * granularity); | ||
return `${kbs}${NBSP2}bytes`; | ||
return this._formatNumberWithGranularity(size, granularity, { | ||
style: 'unit', | ||
unit: 'byte', | ||
unitDisplay: 'long', | ||
}); | ||
} | ||
|
||
/** | ||
|
@@ -92,25 +124,23 @@ export class I18n { | |
formatBytesWithBestUnit(size, granularity = 0.1) { | ||
if (size >= MiB) return this.formatBytesToMiB(size, granularity); | ||
if (size >= KiB) return this.formatBytesToKiB(size, granularity); | ||
return this.formatNumber(size, granularity) + '\xa0B'; | ||
return this._formatNumberWithGranularity(size, granularity, { | ||
style: 'unit', | ||
unit: 'byte', | ||
unitDisplay: 'narrow', | ||
}); | ||
} | ||
|
||
/** | ||
* Format bytes with a constant number of fractional digits, i.e. for a granularity of 0.1, 10 becomes '10.0' | ||
* @param {number} granularity Controls how coarse the displayed value is | ||
* @return {Intl.NumberFormat} | ||
* @param {number} size | ||
* @param {number=} granularity Controls how coarse the displayed value is, defaults to 1 | ||
* @return {string} | ||
*/ | ||
_byteFormatterForGranularity(granularity) { | ||
// assume any granularity above 1 will not contain fractional parts, i.e. will never be 1.5 | ||
let numberOfFractionDigits = 0; | ||
if (granularity < 1) { | ||
numberOfFractionDigits = -Math.floor(Math.log10(granularity)); | ||
} | ||
|
||
return new Intl.NumberFormat(this._numberDateLocale, { | ||
...this._numberFormatter.resolvedOptions(), | ||
maximumFractionDigits: numberOfFractionDigits, | ||
minimumFractionDigits: numberOfFractionDigits, | ||
formatKbps(size, granularity = 1) { | ||
return this._formatNumberWithGranularity(size, granularity, { | ||
style: 'unit', | ||
unit: 'kilobit-per-second', | ||
unitDisplay: 'short', | ||
}); | ||
} | ||
|
||
|
@@ -120,10 +150,11 @@ export class I18n { | |
* @return {string} | ||
*/ | ||
formatMilliseconds(ms, granularity = 10) { | ||
const coarseTime = Math.round(ms / granularity) * granularity; | ||
return coarseTime === 0 | ||
? `${this._numberFormatter.format(0)}${NBSP2}ms` | ||
: `${this._numberFormatter.format(coarseTime)}${NBSP2}ms`; | ||
return this._formatNumberWithGranularity(ms, granularity, { | ||
style: 'unit', | ||
unit: 'millisecond', | ||
unitDisplay: 'short', | ||
}); | ||
} | ||
|
||
/** | ||
|
@@ -132,8 +163,11 @@ export class I18n { | |
* @return {string} | ||
*/ | ||
formatSeconds(ms, granularity = 0.1) { | ||
const coarseTime = Math.round(ms / 1000 / granularity) * granularity; | ||
return `${this._numberFormatter.format(coarseTime)}${NBSP2}s`; | ||
return this._formatNumberWithGranularity(ms / 1000, granularity, { | ||
style: 'unit', | ||
unit: 'second', | ||
unitDisplay: 'short', | ||
}); | ||
} | ||
|
||
/** | ||
|
@@ -153,10 +187,10 @@ export class I18n { | |
// and https://github.com/GoogleChrome/lighthouse/pull/9822 | ||
let formatter; | ||
try { | ||
formatter = new Intl.DateTimeFormat(this._numberDateLocale, options); | ||
formatter = new Intl.DateTimeFormat(this._locale, options); | ||
} catch (err) { | ||
options.timeZone = 'UTC'; | ||
formatter = new Intl.DateTimeFormat(this._numberDateLocale, options); | ||
formatter = new Intl.DateTimeFormat(this._locale, options); | ||
} | ||
|
||
return formatter.format(new Date(date)); | ||
|
@@ -168,6 +202,10 @@ export class I18n { | |
* @return {string} | ||
*/ | ||
formatDuration(timeInMilliseconds) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. just realized the comment for this function was wrong
we had NBSP all up in there... |
||
// There is a proposal for a Intl.DurationFormat. | ||
// https://github.com/tc39/proposal-intl-duration-format | ||
// Until then, we do things a bit more manually. | ||
|
||
let timeInSeconds = timeInMilliseconds / 1000; | ||
if (Math.round(timeInSeconds) === 0) { | ||
return 'None'; | ||
|
@@ -176,19 +214,24 @@ export class I18n { | |
/** @type {Array<string>} */ | ||
const parts = []; | ||
/** @type {Record<string, number>} */ | ||
const unitLabels = { | ||
d: 60 * 60 * 24, | ||
h: 60 * 60, | ||
m: 60, | ||
s: 1, | ||
const unitToSecondsPer = { | ||
day: 60 * 60 * 24, | ||
hour: 60 * 60, | ||
minute: 60, | ||
second: 1, | ||
}; | ||
|
||
Object.keys(unitLabels).forEach(label => { | ||
const unit = unitLabels[label]; | ||
const numberOfUnits = Math.floor(timeInSeconds / unit); | ||
Object.keys(unitToSecondsPer).forEach(unit => { | ||
const secondsPerUnit = unitToSecondsPer[unit]; | ||
const numberOfUnits = Math.floor(timeInSeconds / secondsPerUnit); | ||
if (numberOfUnits > 0) { | ||
timeInSeconds -= numberOfUnits * unit; | ||
parts.push(`${numberOfUnits}\xa0${label}`); | ||
timeInSeconds -= numberOfUnits * secondsPerUnit; | ||
const part = this._formatNumberWithGranularity(numberOfUnits, 1, { | ||
style: 'unit', | ||
unit, | ||
unitDisplay: 'narrow', | ||
}); | ||
parts.push(part); | ||
} | ||
}); | ||
|
||
|
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah i had my cs prof yelling in my head "don't mutate input parameters!" so i copied it