From 252828bb4be2001e4fe1a30d77fb184625f2e5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Tue, 26 Oct 2021 01:08:36 -0700 Subject: [PATCH 01/15] Localize numbers in numeric fields --- ACCESSIBILITY.md | 4 +-- modules/core/localizer.js | 19 +++++++++++++ modules/ui/fields/input.js | 50 ++++++++++++++++++++++----------- modules/ui/fields/roadheight.js | 39 +++++++++++++++++-------- modules/ui/fields/roadspeed.js | 31 ++++++++++++-------- test/spec/core/localizer.js | 34 ++++++++++++++++++++++ 6 files changed, 135 insertions(+), 42 deletions(-) diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md index b48557f01..8fd90adc3 100644 --- a/ACCESSIBILITY.md +++ b/ACCESSIBILITY.md @@ -165,12 +165,12 @@ for more info. | ✅ | Browser language preference | iD tries to use the language set in the browser | | ✅ | Base language fallback | E.g. if `pt_BR` is incomplete, `pt` should be tried before `en` | [#7996](https://github.com/openstreetmap/iD/issues/7996) | ✅ | Custom fallback languages | If the preferred language is incomplete, user-specified ones should be tried before `en` (e.g. `kk` → `ru`) | [#7996](https://github.com/openstreetmap/iD/issues/7996) -| 🟠 | [`lang` HTML attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang) | Helps with text-to-speech, text formatting, and auto-transliteration, particularly when iD mixes strings from different languages | [#7963](https://github.com/openstreetmap/iD/issues/7963) +| ✅ | [`lang` HTML attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang) | Helps with text-to-speech, text formatting, and auto-transliteration, particularly when iD mixes strings from different languages | [#7998](https://github.com/openstreetmap/iD/pull/7998) | ✅ | Locale URL parameters | `locale` and `rtl` can be used to manually set iD's locale preferences. See the [API](API.md#url-parameters) | | ❌ | Language selection in UI | The mapper should be able to view and change iD's language in the interface at any time. Useful for public computers with fixed browser languages | [#3120](https://github.com/openstreetmap/iD/issues/3120) | | 🟩 | Right-to-left layouts | The [`dir` HTML attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/dir) is properly set for languages like Hebrew and Arabic | | ✅ | [Language-specific plurals](https://docs.transifex.com/localization-tips-workflows/plurals-and-genders#how-pluralized-strings-are-handled-by-transifex) | English has two plural forms, but some languages need more to be grammatically correct | [#597](https://github.com/openstreetmap/iD/issues/597), [#7991](https://github.com/openstreetmap/iD/issues/7991) | -| 🟠 | [Localized number formats](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) | Most in-text numbers are localized. Numeric fields are not | [#3615](https://github.com/openstreetmap/iD/issues/3615), [#7993](https://github.com/openstreetmap/iD/issues/7993) | +| ✅ | [Localized number formats](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) | Most in-text numbers are localized, including numeric fields | [#8769](https://github.com/openstreetmap/iD/pull/8769), [#7993](https://github.com/openstreetmap/iD/issues/7993) | | 🟠 | Label icons | Icons should accompany text labels to illustrate the meaning of untranslated terms | ### Translatability diff --git a/modules/core/localizer.js b/modules/core/localizer.js index a072b41ad..21e19aedd 100644 --- a/modules/core/localizer.js +++ b/modules/core/localizer.js @@ -424,5 +424,24 @@ export function coreLocalizer() { return code; // if not found, use the code }; + localizer.floatParser = (locale) => { + // https://stackoverflow.com/a/55366435/4585461 + const format = new Intl.NumberFormat(locale); + const parts = format.formatToParts(12345.6); + const numerals = Array.from({ length: 10 }).map((_, i) => format.format(i)); + const index = new Map(numerals.map((d, i) => [d, i])); + const group = new RegExp(`[${parts.find(d => d.type === 'group').value}]`, 'g'); + const decimal = new RegExp(`[${parts.find(d => d.type === 'decimal').value}]`); + const numeral = new RegExp(`[${numerals.join('')}]`, 'g'); + const getIndex = d => index.get(d); + return (string) => { + string = string.trim() + .replace(group, '') + .replace(decimal, '.') + .replace(numeral, getIndex); + return string ? +string : NaN; + }; + }; + return localizer; } diff --git a/modules/ui/fields/input.js b/modules/ui/fields/input.js index 0ae46234c..50ac3e6a3 100644 --- a/modules/ui/fields/input.js +++ b/modules/ui/fields/input.js @@ -32,6 +32,7 @@ export function uiFieldText(field, context) { var _tags; var _phoneFormats = {}; const isDirectionField = field.key.split(':').some(keyPart => keyPart === 'direction'); + const parseLocaleFloat = localizer.floatParser(localizer.languageCode()); if (field.type === 'tel') { fileFetcher.get('phone_formats') @@ -132,7 +133,8 @@ export function uiFieldText(field, context) { var raw_vals = input.node().value || '0'; var vals = raw_vals.split(';'); vals = vals.map(function(v) { - var num = Number(v); + v = v.trim(); + var num = parseLocaleFloat(v); if (isDirectionField) { const compassDir = cardinal[v.trim().toLowerCase()]; if (compassDir !== undefined) { @@ -140,10 +142,10 @@ export function uiFieldText(field, context) { } } - if (!isFinite(num)) { - // do nothing if the value is neither a number, nor a cardinal direction - return v.trim(); - } + // do nothing if the value is neither a number, nor a cardinal direction + if (!isFinite(num)) return v; + num = parseFloat(num, 10); + if (!isFinite(num)) return v; num += d; // clamp to 0..359 degree range if it's a direction field @@ -153,7 +155,7 @@ export function uiFieldText(field, context) { } // make sure no extra decimals are introduced const numDecimals = v.includes('.') ? v.split('.')[1].length : 0; - return clamped(num).toFixed(numDecimals); + return clamped(num).toFixed(numDecimals).toLocaleString(localizer.languageCode()); }); input.node().value = vals.join(';'); change()(); @@ -393,17 +395,20 @@ export function uiFieldText(field, context) { // don't override multiple values with blank string if (!val && Array.isArray(_tags[field.key])) return; - if (!onInput) { - if (field.type === 'number' && val) { - var vals = val.split(';'); - vals = vals.map(function(v) { - var num = Number(v); - return isFinite(num) ? clamped(num) : v.trim(); - }); - val = vals.join(';'); - } - utilGetSetValue(input, val); + var displayVal = val; + if (field.type === 'number' && val) { + var vals = val.split(';'); + vals = vals.map(function(v) { + v = v.trim(); + var num = parseLocaleFloat(v); + if (!isFinite(num)) return v; + num = parseFloat(num, 10); + if (!isFinite(num)) return v; + return clamped(num); + }); + val = vals.join(';'); } + if (!onInput) utilGetSetValue(input, displayVal); t[field.key] = val || undefined; dispatch.call('change', this, t, onInput); }; @@ -422,7 +427,18 @@ export function uiFieldText(field, context) { var isMixed = Array.isArray(tags[field.key]); - utilGetSetValue(input, !isMixed && tags[field.key] ? tags[field.key] : '') + var val = !isMixed && tags[field.key] ? tags[field.key] : ''; + if (field.type === 'number' && val) { + var vals = val.split(';'); + vals = vals.map(function(v) { + v = v.trim(); + var num = parseFloat(v, 10); + if (!isFinite(num)) return v; + return clamped(num).toLocaleString(localizer.languageCode()); + }); + val = vals.join(';'); + } + utilGetSetValue(input, val) .attr('title', isMixed ? tags[field.key].filter(Boolean).join('\n') : undefined) .attr('placeholder', isMixed ? t('inspector.multiple_values') : (field.placeholder() || t('inspector.unknown'))) .classed('mixed', isMixed); diff --git a/modules/ui/fields/roadheight.js b/modules/ui/fields/roadheight.js index 023219db4..182745804 100644 --- a/modules/ui/fields/roadheight.js +++ b/modules/ui/fields/roadheight.js @@ -3,7 +3,7 @@ import { select as d3_select } from 'd3-selection'; import * as countryCoder from '@ideditor/country-coder'; import { uiCombobox } from '../combobox'; -import { t } from '../../core/localizer'; +import { t, localizer } from '../../core/localizer'; import { utilGetSetValue, utilNoAuto, utilRebind, utilTotalExtent } from '../../util'; @@ -16,6 +16,7 @@ export function uiFieldRoadheight(field, context) { var _entityIDs = []; var _tags; var _isImperial; + var parseLocaleFloat = localizer.floatParser(localizer.languageCode()); var primaryUnits = [ { @@ -129,16 +130,23 @@ export function uiFieldRoadheight(field, context) { if (!primaryValue && !secondaryValue) { tag[field.key] = undefined; - } else if (isNaN(primaryValue) || isNaN(secondaryValue) || !_isImperial) { - tag[field.key] = context.cleanTagValue(primaryValue); } else { - if (primaryValue !== '') { - primaryValue = context.cleanTagValue(primaryValue + '\''); + var rawPrimaryValue = parseLocaleFloat(primaryValue); + if (isNaN(rawPrimaryValue)) rawPrimaryValue = primaryValue; + var rawSecondaryValue = parseLocaleFloat(secondaryValue); + if (isNaN(rawSecondaryValue)) rawSecondaryValue = secondaryValue; + + if (isNaN(rawPrimaryValue) || isNaN(rawSecondaryValue) || !_isImperial) { + tag[field.key] = context.cleanTagValue(rawPrimaryValue); + } else { + if (rawPrimaryValue !== '') { + rawPrimaryValue = context.cleanTagValue(rawPrimaryValue + '\''); + } + if (rawSecondaryValue !== '') { + rawSecondaryValue = context.cleanTagValue(rawSecondaryValue + '"'); + } + tag[field.key] = rawPrimaryValue + rawSecondaryValue; } - if (secondaryValue !== '') { - secondaryValue = context.cleanTagValue(secondaryValue + '"'); - } - tag[field.key] = primaryValue + secondaryValue; } dispatch.call('change', this, tag); @@ -156,26 +164,33 @@ export function uiFieldRoadheight(field, context) { if (primaryValue && (primaryValue.indexOf('\'') >= 0 || primaryValue.indexOf('"') >= 0)) { secondaryValue = primaryValue.match(/(-?[\d.]+)"/); if (secondaryValue !== null) { - secondaryValue = secondaryValue[1]; + secondaryValue = parseFloat(secondaryValue[1], 10).toLocaleString(localizer.languageCode()); } primaryValue = primaryValue.match(/(-?[\d.]+)'/); if (primaryValue !== null) { - primaryValue = primaryValue[1]; + primaryValue = parseFloat(primaryValue[1], 10).toLocaleString(localizer.languageCode()); } _isImperial = true; } else if (primaryValue) { + var rawValue = primaryValue; + primaryValue = parseFloat(rawValue, 10); + if (isNaN(primaryValue)) primaryValue = rawValue; + primaryValue = primaryValue.toLocaleString(localizer.languageCode()); _isImperial = false; } } setUnitSuggestions(); + // If feet are specified but inches are omitted, assume zero inches. + var inchesPlaceholder = (0).toLocaleString(localizer.languageCode()); + utilGetSetValue(primaryInput, typeof primaryValue === 'string' ? primaryValue : '') .attr('title', isMixed ? primaryValue.filter(Boolean).join('\n') : null) .attr('placeholder', isMixed ? t('inspector.multiple_values') : t('inspector.unknown')) .classed('mixed', isMixed); utilGetSetValue(secondaryInput, typeof secondaryValue === 'string' ? secondaryValue : '') - .attr('placeholder', isMixed ? t('inspector.multiple_values') : (_isImperial ? '0' : null)) + .attr('placeholder', isMixed ? t('inspector.multiple_values') : (_isImperial ? inchesPlaceholder : null)) .classed('mixed', isMixed) .classed('disabled', !_isImperial) .attr('readonly', _isImperial ? null : 'readonly'); diff --git a/modules/ui/fields/roadspeed.js b/modules/ui/fields/roadspeed.js index b4c277f3a..637e61255 100644 --- a/modules/ui/fields/roadspeed.js +++ b/modules/ui/fields/roadspeed.js @@ -3,7 +3,7 @@ import { select as d3_select } from 'd3-selection'; import * as countryCoder from '@ideditor/country-coder'; import { uiCombobox } from '../combobox'; -import { t } from '../../core/localizer'; +import { t, localizer } from '../../core/localizer'; import { utilGetSetValue, utilNoAuto, utilRebind, utilTotalExtent } from '../../util'; @@ -14,6 +14,7 @@ export function uiFieldRoadspeed(field, context) { var _entityIDs = []; var _tags; var _isImperial; + var parseLocaleFloat = localizer.floatParser(localizer.languageCode()); var speedCombo = uiCombobox(context, 'roadspeed'); var unitCombo = uiCombobox(context, 'roadspeed-unit') @@ -91,8 +92,8 @@ export function uiFieldRoadspeed(field, context) { function comboValues(d) { return { - value: d.toString(), - title: d.toString() + value: d.toLocaleString(localizer.languageCode()), + title: d.toLocaleString(localizer.languageCode()) }; } @@ -106,10 +107,14 @@ export function uiFieldRoadspeed(field, context) { if (!value) { tag[field.key] = undefined; - } else if (isNaN(value) || !_isImperial) { - tag[field.key] = context.cleanTagValue(value); } else { - tag[field.key] = context.cleanTagValue(value + ' mph'); + var rawValue = parseLocaleFloat(value); + if (isNaN(rawValue)) rawValue = value; + if (isNaN(rawValue) || !_isImperial) { + tag[field.key] = context.cleanTagValue(rawValue); + } else { + tag[field.key] = context.cleanTagValue(rawValue + ' mph'); + } } dispatch.call('change', this, tag); @@ -119,16 +124,20 @@ export function uiFieldRoadspeed(field, context) { roadspeed.tags = function(tags) { _tags = tags; - var value = tags[field.key]; + var rawValue = tags[field.key]; + var value = rawValue; var isMixed = Array.isArray(value); if (!isMixed) { - if (value && value.indexOf('mph') >= 0) { - value = parseInt(value, 10).toString(); - _isImperial = true; - } else if (value) { + if (rawValue && rawValue.indexOf('mph') >= 0) { + _isImperial = rawValue && rawValue.indexOf('mph') >= 0; + } else if (rawValue) { _isImperial = false; } + + value = parseInt(value, 10); + if (isNaN(value)) value = rawValue; + value = value.toLocaleString(localizer.languageCode()); } setUnitSuggestions(); diff --git a/test/spec/core/localizer.js b/test/spec/core/localizer.js index cf30d5ab7..08ef71cec 100644 --- a/test/spec/core/localizer.js +++ b/test/spec/core/localizer.js @@ -6,4 +6,38 @@ describe('iD.coreLocalizer', function() { expect(selection.selectChild().classed('localized-text')).to.be.true; }); }); + describe('#floatParser', function () { + it('roundtrips English numbers', function () { + var localizer = iD.coreLocalizer(); + var parseFloat = localizer.floatParser('en'); + expect(parseFloat((-0.1).toLocaleString(localizer.languageCode()))).to.eql(-0.1); + expect(parseFloat((1.234).toLocaleString(localizer.languageCode()))).to.eql(1.234); + expect(parseFloat(1234).toLocaleString(localizer.languageCode())).to.eql(1234); + expect(parseFloat(1234.56).toLocaleString(localizer.languageCode())).to.eql(1234.56); + }); + it('roundtrips Spanish numbers', function () { + var localizer = iD.coreLocalizer(); + var parseFloat = localizer.floatParser('es'); + expect(parseFloat((-0.1).toLocaleString(localizer.languageCode()))).to.eql(-0.1); + expect(parseFloat((1.234).toLocaleString(localizer.languageCode()))).to.eql(1.234); + expect(parseFloat(1234).toLocaleString(localizer.languageCode())).to.eql(1234); + expect(parseFloat(1234.56).toLocaleString(localizer.languageCode())).to.eql(1234.56); + }); + it('roundtrips Arabic numbers', function () { + var localizer = iD.coreLocalizer(); + var parseFloat = localizer.floatParser('ar-EG'); + expect(parseFloat((-0.1).toLocaleString(localizer.languageCode()))).to.eql(-0.1); + expect(parseFloat((1.234).toLocaleString(localizer.languageCode()))).to.eql(1.234); + expect(parseFloat(1234).toLocaleString(localizer.languageCode())).to.eql(1234); + expect(parseFloat(1234.56).toLocaleString(localizer.languageCode())).to.eql(1234.56); + }); + it('roundtrips Bengali numbers', function () { + var localizer = iD.coreLocalizer(); + var parseFloat = localizer.floatParser('bn'); + expect(parseFloat((-0.1).toLocaleString(localizer.languageCode()))).to.eql(-0.1); + expect(parseFloat((1.234).toLocaleString(localizer.languageCode()))).to.eql(1.234); + expect(parseFloat(1234).toLocaleString(localizer.languageCode())).to.eql(1234); + expect(parseFloat(1234.56).toLocaleString(localizer.languageCode())).to.eql(1234.56); + }); + }); }); From 2ba7177080f0bac44fe812bb5890536aa21da68c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Tue, 26 Oct 2021 18:26:14 -0700 Subject: [PATCH 02/15] Polyfill inadequate Intl support Ensure that formatting is balanced with parsing to avoid truncating numbers. --- modules/core/localizer.js | 11 ++++++++++ modules/ui/fields/input.js | 5 +++-- modules/ui/fields/roadheight.js | 9 +++++---- modules/ui/fields/roadspeed.js | 7 ++++--- test/spec/core/localizer.js | 36 ++++++++++++++++++--------------- 5 files changed, 43 insertions(+), 25 deletions(-) diff --git a/modules/core/localizer.js b/modules/core/localizer.js index 21e19aedd..9961dea32 100644 --- a/modules/core/localizer.js +++ b/modules/core/localizer.js @@ -424,9 +424,20 @@ export function coreLocalizer() { return code; // if not found, use the code }; + localizer.floatFormatter = (locale) => { + if (!('Intl' in window && 'NumberFormat' in Intl && + 'formatToParts' in Intl.NumberFormat.prototype)) { + return (number) => number.toString(); + } else { + return (number) => number.toLocaleString(locale); + } + }; localizer.floatParser = (locale) => { // https://stackoverflow.com/a/55366435/4585461 + const polyfill = (string) => parseFloat(string, 10); + if (!('Intl' in window && 'NumberFormat' in Intl)) return polyfill; const format = new Intl.NumberFormat(locale); + if (!('formatToParts' in format)) return polyfill; const parts = format.formatToParts(12345.6); const numerals = Array.from({ length: 10 }).map((_, i) => format.format(i)); const index = new Map(numerals.map((d, i) => [d, i])); diff --git a/modules/ui/fields/input.js b/modules/ui/fields/input.js index 50ac3e6a3..b444251cd 100644 --- a/modules/ui/fields/input.js +++ b/modules/ui/fields/input.js @@ -32,6 +32,7 @@ export function uiFieldText(field, context) { var _tags; var _phoneFormats = {}; const isDirectionField = field.key.split(':').some(keyPart => keyPart === 'direction'); + const formatFloat = localizer.floatFormatter(localizer.languageCode()); const parseLocaleFloat = localizer.floatParser(localizer.languageCode()); if (field.type === 'tel') { @@ -155,7 +156,7 @@ export function uiFieldText(field, context) { } // make sure no extra decimals are introduced const numDecimals = v.includes('.') ? v.split('.')[1].length : 0; - return clamped(num).toFixed(numDecimals).toLocaleString(localizer.languageCode()); + return formatFloat(clamped(num).toFixed(numDecimals)); }); input.node().value = vals.join(';'); change()(); @@ -434,7 +435,7 @@ export function uiFieldText(field, context) { v = v.trim(); var num = parseFloat(v, 10); if (!isFinite(num)) return v; - return clamped(num).toLocaleString(localizer.languageCode()); + return formatFloat(clamped(num)); }); val = vals.join(';'); } diff --git a/modules/ui/fields/roadheight.js b/modules/ui/fields/roadheight.js index 182745804..b33c89c8f 100644 --- a/modules/ui/fields/roadheight.js +++ b/modules/ui/fields/roadheight.js @@ -16,6 +16,7 @@ export function uiFieldRoadheight(field, context) { var _entityIDs = []; var _tags; var _isImperial; + var formatFloat = localizer.floatFormatter(localizer.languageCode()); var parseLocaleFloat = localizer.floatParser(localizer.languageCode()); var primaryUnits = [ @@ -164,18 +165,18 @@ export function uiFieldRoadheight(field, context) { if (primaryValue && (primaryValue.indexOf('\'') >= 0 || primaryValue.indexOf('"') >= 0)) { secondaryValue = primaryValue.match(/(-?[\d.]+)"/); if (secondaryValue !== null) { - secondaryValue = parseFloat(secondaryValue[1], 10).toLocaleString(localizer.languageCode()); + secondaryValue = formatFloat(parseFloat(secondaryValue[1], 10)); } primaryValue = primaryValue.match(/(-?[\d.]+)'/); if (primaryValue !== null) { - primaryValue = parseFloat(primaryValue[1], 10).toLocaleString(localizer.languageCode()); + primaryValue = formatFloat(parseFloat(primaryValue[1], 10)); } _isImperial = true; } else if (primaryValue) { var rawValue = primaryValue; primaryValue = parseFloat(rawValue, 10); if (isNaN(primaryValue)) primaryValue = rawValue; - primaryValue = primaryValue.toLocaleString(localizer.languageCode()); + primaryValue = formatFloat(primaryValue); _isImperial = false; } } @@ -183,7 +184,7 @@ export function uiFieldRoadheight(field, context) { setUnitSuggestions(); // If feet are specified but inches are omitted, assume zero inches. - var inchesPlaceholder = (0).toLocaleString(localizer.languageCode()); + var inchesPlaceholder = formatFloat(0); utilGetSetValue(primaryInput, typeof primaryValue === 'string' ? primaryValue : '') .attr('title', isMixed ? primaryValue.filter(Boolean).join('\n') : null) diff --git a/modules/ui/fields/roadspeed.js b/modules/ui/fields/roadspeed.js index 637e61255..a077c9bf0 100644 --- a/modules/ui/fields/roadspeed.js +++ b/modules/ui/fields/roadspeed.js @@ -14,6 +14,7 @@ export function uiFieldRoadspeed(field, context) { var _entityIDs = []; var _tags; var _isImperial; + var formatFloat = localizer.floatFormatter(localizer.languageCode()); var parseLocaleFloat = localizer.floatParser(localizer.languageCode()); var speedCombo = uiCombobox(context, 'roadspeed'); @@ -92,8 +93,8 @@ export function uiFieldRoadspeed(field, context) { function comboValues(d) { return { - value: d.toLocaleString(localizer.languageCode()), - title: d.toLocaleString(localizer.languageCode()) + value: formatFloat(d), + title: formatFloat(d) }; } @@ -137,7 +138,7 @@ export function uiFieldRoadspeed(field, context) { value = parseInt(value, 10); if (isNaN(value)) value = rawValue; - value = value.toLocaleString(localizer.languageCode()); + value = formatFloat(value); } setUnitSuggestions(); diff --git a/test/spec/core/localizer.js b/test/spec/core/localizer.js index 08ef71cec..0dbe82c71 100644 --- a/test/spec/core/localizer.js +++ b/test/spec/core/localizer.js @@ -9,35 +9,39 @@ describe('iD.coreLocalizer', function() { describe('#floatParser', function () { it('roundtrips English numbers', function () { var localizer = iD.coreLocalizer(); + var formatFloat = localizer.floatFormatter('en'); var parseFloat = localizer.floatParser('en'); - expect(parseFloat((-0.1).toLocaleString(localizer.languageCode()))).to.eql(-0.1); - expect(parseFloat((1.234).toLocaleString(localizer.languageCode()))).to.eql(1.234); - expect(parseFloat(1234).toLocaleString(localizer.languageCode())).to.eql(1234); - expect(parseFloat(1234.56).toLocaleString(localizer.languageCode())).to.eql(1234.56); + expect(parseFloat(formatFloat(-0.1))).to.eql(-0.1); + expect(parseFloat(formatFloat(1.234))).to.eql(1.234); + expect(parseFloat(formatFloat(1234))).to.eql(1234); + expect(parseFloat(formatFloat(1234.56))).to.eql(1234.56); }); it('roundtrips Spanish numbers', function () { var localizer = iD.coreLocalizer(); + var formatFloat = localizer.floatFormatter('es'); var parseFloat = localizer.floatParser('es'); - expect(parseFloat((-0.1).toLocaleString(localizer.languageCode()))).to.eql(-0.1); - expect(parseFloat((1.234).toLocaleString(localizer.languageCode()))).to.eql(1.234); - expect(parseFloat(1234).toLocaleString(localizer.languageCode())).to.eql(1234); - expect(parseFloat(1234.56).toLocaleString(localizer.languageCode())).to.eql(1234.56); + expect(parseFloat(formatFloat(-0.1))).to.eql(-0.1); + expect(parseFloat(formatFloat(1.234))).to.eql(1.234); + expect(parseFloat(formatFloat(1234))).to.eql(1234); + expect(parseFloat(formatFloat(1234.56))).to.eql(1234.56); }); it('roundtrips Arabic numbers', function () { var localizer = iD.coreLocalizer(); + var formatFloat = localizer.floatFormatter('ar-EG'); var parseFloat = localizer.floatParser('ar-EG'); - expect(parseFloat((-0.1).toLocaleString(localizer.languageCode()))).to.eql(-0.1); - expect(parseFloat((1.234).toLocaleString(localizer.languageCode()))).to.eql(1.234); - expect(parseFloat(1234).toLocaleString(localizer.languageCode())).to.eql(1234); - expect(parseFloat(1234.56).toLocaleString(localizer.languageCode())).to.eql(1234.56); + expect(parseFloat(formatFloat(-0.1))).to.eql(-0.1); + expect(parseFloat(formatFloat(1.234))).to.eql(1.234); + expect(parseFloat(formatFloat(1234))).to.eql(1234); + expect(parseFloat(formatFloat(1234.56))).to.eql(1234.56); }); it('roundtrips Bengali numbers', function () { var localizer = iD.coreLocalizer(); + var formatFloat = localizer.floatFormatter('bn'); var parseFloat = localizer.floatParser('bn'); - expect(parseFloat((-0.1).toLocaleString(localizer.languageCode()))).to.eql(-0.1); - expect(parseFloat((1.234).toLocaleString(localizer.languageCode()))).to.eql(1.234); - expect(parseFloat(1234).toLocaleString(localizer.languageCode())).to.eql(1234); - expect(parseFloat(1234.56).toLocaleString(localizer.languageCode())).to.eql(1234.56); + expect(parseFloat(formatFloat(-0.1))).to.eql(-0.1); + expect(parseFloat(formatFloat(1.234))).to.eql(1.234); + expect(parseFloat(formatFloat(1234))).to.eql(1234); + expect(parseFloat(formatFloat(1234.56))).to.eql(1234.56); }); }); }); From 0dadd8b31c710ffaef4d54a20470b1071f246729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Wed, 3 Nov 2021 13:00:10 -0700 Subject: [PATCH 03/15] Clean roadheight value after concatenation --- modules/ui/fields/roadheight.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/ui/fields/roadheight.js b/modules/ui/fields/roadheight.js index b33c89c8f..37863be60 100644 --- a/modules/ui/fields/roadheight.js +++ b/modules/ui/fields/roadheight.js @@ -141,12 +141,12 @@ export function uiFieldRoadheight(field, context) { tag[field.key] = context.cleanTagValue(rawPrimaryValue); } else { if (rawPrimaryValue !== '') { - rawPrimaryValue = context.cleanTagValue(rawPrimaryValue + '\''); + rawPrimaryValue = rawPrimaryValue + '\''; } if (rawSecondaryValue !== '') { - rawSecondaryValue = context.cleanTagValue(rawSecondaryValue + '"'); + rawSecondaryValue = rawSecondaryValue + '"'; } - tag[field.key] = rawPrimaryValue + rawSecondaryValue; + tag[field.key] = context.cleanTagValue(rawPrimaryValue + rawSecondaryValue); } } From 9817894752f55a941b5c2f846dc6824b559433b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Fri, 12 Nov 2021 23:00:50 -0800 Subject: [PATCH 04/15] Apply suggestions from code review Co-authored-by: Martin Raifer --- modules/core/localizer.js | 2 +- modules/ui/fields/input.js | 8 ++------ modules/ui/fields/roadspeed.js | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/modules/core/localizer.js b/modules/core/localizer.js index 9961dea32..9c9e656b3 100644 --- a/modules/core/localizer.js +++ b/modules/core/localizer.js @@ -434,7 +434,7 @@ export function coreLocalizer() { }; localizer.floatParser = (locale) => { // https://stackoverflow.com/a/55366435/4585461 - const polyfill = (string) => parseFloat(string, 10); + const polyfill = (string) => parseFloat(string.trim()); if (!('Intl' in window && 'NumberFormat' in Intl)) return polyfill; const format = new Intl.NumberFormat(locale); if (!('formatToParts' in format)) return polyfill; diff --git a/modules/ui/fields/input.js b/modules/ui/fields/input.js index b444251cd..086809ff2 100644 --- a/modules/ui/fields/input.js +++ b/modules/ui/fields/input.js @@ -400,12 +400,8 @@ export function uiFieldText(field, context) { if (field.type === 'number' && val) { var vals = val.split(';'); vals = vals.map(function(v) { - v = v.trim(); var num = parseLocaleFloat(v); - if (!isFinite(num)) return v; - num = parseFloat(num, 10); - if (!isFinite(num)) return v; - return clamped(num); + return isFinite(num)) ? clamped(num) : v; }); val = vals.join(';'); } @@ -433,7 +429,7 @@ export function uiFieldText(field, context) { var vals = val.split(';'); vals = vals.map(function(v) { v = v.trim(); - var num = parseFloat(v, 10); + var num = parseFloat(v); if (!isFinite(num)) return v; return formatFloat(clamped(num)); }); diff --git a/modules/ui/fields/roadspeed.js b/modules/ui/fields/roadspeed.js index a077c9bf0..25b47ea06 100644 --- a/modules/ui/fields/roadspeed.js +++ b/modules/ui/fields/roadspeed.js @@ -131,7 +131,7 @@ export function uiFieldRoadspeed(field, context) { if (!isMixed) { if (rawValue && rawValue.indexOf('mph') >= 0) { - _isImperial = rawValue && rawValue.indexOf('mph') >= 0; + _isImperial = true; } else if (rawValue) { _isImperial = false; } From 74fdf174779aa5cc4b98ec81274072c5d443b09c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Fri, 12 Nov 2021 23:05:19 -0800 Subject: [PATCH 05/15] More minor corrections from code review --- modules/core/localizer.js | 1 + modules/ui/fields/input.js | 6 +++--- modules/ui/fields/roadheight.js | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/modules/core/localizer.js b/modules/core/localizer.js index 9c9e656b3..2e100a4eb 100644 --- a/modules/core/localizer.js +++ b/modules/core/localizer.js @@ -432,6 +432,7 @@ export function coreLocalizer() { return (number) => number.toLocaleString(locale); } }; + localizer.floatParser = (locale) => { // https://stackoverflow.com/a/55366435/4585461 const polyfill = (string) => parseFloat(string.trim()); diff --git a/modules/ui/fields/input.js b/modules/ui/fields/input.js index 086809ff2..45e48d9de 100644 --- a/modules/ui/fields/input.js +++ b/modules/ui/fields/input.js @@ -137,7 +137,7 @@ export function uiFieldText(field, context) { v = v.trim(); var num = parseLocaleFloat(v); if (isDirectionField) { - const compassDir = cardinal[v.trim().toLowerCase()]; + const compassDir = cardinal[v.toLowerCase()]; if (compassDir !== undefined) { num = compassDir; } @@ -145,7 +145,7 @@ export function uiFieldText(field, context) { // do nothing if the value is neither a number, nor a cardinal direction if (!isFinite(num)) return v; - num = parseFloat(num, 10); + num = parseFloat(num); if (!isFinite(num)) return v; num += d; @@ -401,7 +401,7 @@ export function uiFieldText(field, context) { var vals = val.split(';'); vals = vals.map(function(v) { var num = parseLocaleFloat(v); - return isFinite(num)) ? clamped(num) : v; + return isFinite(num) ? clamped(num) : v; }); val = vals.join(';'); } diff --git a/modules/ui/fields/roadheight.js b/modules/ui/fields/roadheight.js index 37863be60..696d8c119 100644 --- a/modules/ui/fields/roadheight.js +++ b/modules/ui/fields/roadheight.js @@ -165,16 +165,16 @@ export function uiFieldRoadheight(field, context) { if (primaryValue && (primaryValue.indexOf('\'') >= 0 || primaryValue.indexOf('"') >= 0)) { secondaryValue = primaryValue.match(/(-?[\d.]+)"/); if (secondaryValue !== null) { - secondaryValue = formatFloat(parseFloat(secondaryValue[1], 10)); + secondaryValue = formatFloat(parseFloat(secondaryValue[1])); } primaryValue = primaryValue.match(/(-?[\d.]+)'/); if (primaryValue !== null) { - primaryValue = formatFloat(parseFloat(primaryValue[1], 10)); + primaryValue = formatFloat(parseFloat(primaryValue[1])); } _isImperial = true; } else if (primaryValue) { var rawValue = primaryValue; - primaryValue = parseFloat(rawValue, 10); + primaryValue = parseFloat(rawValue); if (isNaN(primaryValue)) primaryValue = rawValue; primaryValue = formatFloat(primaryValue); _isImperial = false; From ecb7c20bbff5148671c42d972b0ff7b0aa1e2797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Tue, 16 Nov 2021 17:04:29 -0800 Subject: [PATCH 06/15] Avoid truncation when formatting numbers --- modules/core/localizer.js | 4 ++-- test/spec/core/localizer.js | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/modules/core/localizer.js b/modules/core/localizer.js index 2e100a4eb..e87bfde21 100644 --- a/modules/core/localizer.js +++ b/modules/core/localizer.js @@ -429,7 +429,7 @@ export function coreLocalizer() { 'formatToParts' in Intl.NumberFormat.prototype)) { return (number) => number.toString(); } else { - return (number) => number.toLocaleString(locale); + return (number) => number.toLocaleString(locale, { maximumFractionDigits: 20 }); } }; @@ -437,7 +437,7 @@ export function coreLocalizer() { // https://stackoverflow.com/a/55366435/4585461 const polyfill = (string) => parseFloat(string.trim()); if (!('Intl' in window && 'NumberFormat' in Intl)) return polyfill; - const format = new Intl.NumberFormat(locale); + const format = new Intl.NumberFormat(locale, { maximumFractionDigits: 20 }); if (!('formatToParts' in format)) return polyfill; const parts = format.formatToParts(12345.6); const numerals = Array.from({ length: 10 }).map((_, i) => format.format(i)); diff --git a/test/spec/core/localizer.js b/test/spec/core/localizer.js index 0dbe82c71..8f773663f 100644 --- a/test/spec/core/localizer.js +++ b/test/spec/core/localizer.js @@ -15,6 +15,7 @@ describe('iD.coreLocalizer', function() { expect(parseFloat(formatFloat(1.234))).to.eql(1.234); expect(parseFloat(formatFloat(1234))).to.eql(1234); expect(parseFloat(formatFloat(1234.56))).to.eql(1234.56); + expect(parseFloat(formatFloat(3.14159))).to.eql(3.14159); }); it('roundtrips Spanish numbers', function () { var localizer = iD.coreLocalizer(); @@ -24,6 +25,7 @@ describe('iD.coreLocalizer', function() { expect(parseFloat(formatFloat(1.234))).to.eql(1.234); expect(parseFloat(formatFloat(1234))).to.eql(1234); expect(parseFloat(formatFloat(1234.56))).to.eql(1234.56); + expect(parseFloat(formatFloat(3.14159))).to.eql(3.14159); }); it('roundtrips Arabic numbers', function () { var localizer = iD.coreLocalizer(); @@ -33,6 +35,7 @@ describe('iD.coreLocalizer', function() { expect(parseFloat(formatFloat(1.234))).to.eql(1.234); expect(parseFloat(formatFloat(1234))).to.eql(1234); expect(parseFloat(formatFloat(1234.56))).to.eql(1234.56); + expect(parseFloat(formatFloat(3.14159))).to.eql(3.14159); }); it('roundtrips Bengali numbers', function () { var localizer = iD.coreLocalizer(); @@ -42,6 +45,7 @@ describe('iD.coreLocalizer', function() { expect(parseFloat(formatFloat(1.234))).to.eql(1.234); expect(parseFloat(formatFloat(1234))).to.eql(1234); expect(parseFloat(formatFloat(1234.56))).to.eql(1234.56); + expect(parseFloat(formatFloat(3.14159))).to.eql(3.14159); }); }); }); From 5d224a41cd4b838cb085ef96e63045d736cbe264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Tue, 16 Nov 2021 17:09:27 -0800 Subject: [PATCH 07/15] Strip literal (bidi) characters when parsing numbers --- modules/core/localizer.js | 4 +++- test/spec/core/localizer.js | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/modules/core/localizer.js b/modules/core/localizer.js index e87bfde21..f840874e1 100644 --- a/modules/core/localizer.js +++ b/modules/core/localizer.js @@ -439,15 +439,17 @@ export function coreLocalizer() { if (!('Intl' in window && 'NumberFormat' in Intl)) return polyfill; const format = new Intl.NumberFormat(locale, { maximumFractionDigits: 20 }); if (!('formatToParts' in format)) return polyfill; - const parts = format.formatToParts(12345.6); + const parts = format.formatToParts(-12345.6); const numerals = Array.from({ length: 10 }).map((_, i) => format.format(i)); const index = new Map(numerals.map((d, i) => [d, i])); + const literal = new RegExp(`[${parts.find(d => d.type === 'literal').value}]`, 'g'); const group = new RegExp(`[${parts.find(d => d.type === 'group').value}]`, 'g'); const decimal = new RegExp(`[${parts.find(d => d.type === 'decimal').value}]`); const numeral = new RegExp(`[${numerals.join('')}]`, 'g'); const getIndex = d => index.get(d); return (string) => { string = string.trim() + .replace(literal, '') .replace(group, '') .replace(decimal, '.') .replace(numeral, getIndex); diff --git a/test/spec/core/localizer.js b/test/spec/core/localizer.js index 8f773663f..6a77e0ef8 100644 --- a/test/spec/core/localizer.js +++ b/test/spec/core/localizer.js @@ -27,6 +27,16 @@ describe('iD.coreLocalizer', function() { expect(parseFloat(formatFloat(1234.56))).to.eql(1234.56); expect(parseFloat(formatFloat(3.14159))).to.eql(3.14159); }); + it('roundtrips Hebrew numbers', function () { + var localizer = iD.coreLocalizer(); + var formatFloat = localizer.floatFormatter('he'); + var parseFloat = localizer.floatParser('he'); + expect(parseFloat(formatFloat(-0.1))).to.eql(-0.1); + expect(parseFloat(formatFloat(1.234))).to.eql(1.234); + expect(parseFloat(formatFloat(1234))).to.eql(1234); + expect(parseFloat(formatFloat(1234.56))).to.eql(1234.56); + expect(parseFloat(formatFloat(3.14159))).to.eql(3.14159); + }); it('roundtrips Arabic numbers', function () { var localizer = iD.coreLocalizer(); var formatFloat = localizer.floatFormatter('ar-EG'); From 6419def20f584698c41726df0991a5f5efef4c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Sat, 21 May 2022 03:25:15 -0700 Subject: [PATCH 08/15] Fixed numeric fields in locales without literals --- modules/core/localizer.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/modules/core/localizer.js b/modules/core/localizer.js index f840874e1..f8e5a6df9 100644 --- a/modules/core/localizer.js +++ b/modules/core/localizer.js @@ -442,17 +442,20 @@ export function coreLocalizer() { const parts = format.formatToParts(-12345.6); const numerals = Array.from({ length: 10 }).map((_, i) => format.format(i)); const index = new Map(numerals.map((d, i) => [d, i])); - const literal = new RegExp(`[${parts.find(d => d.type === 'literal').value}]`, 'g'); - const group = new RegExp(`[${parts.find(d => d.type === 'group').value}]`, 'g'); - const decimal = new RegExp(`[${parts.find(d => d.type === 'decimal').value}]`); + const literalPart = parts.find(d => d.type === 'literal'); + const literal = literalPart && new RegExp(`[${literalPart.value}]`, 'g'); + const groupPart = parts.find(d => d.type === 'group'); + const group = groupPart && new RegExp(`[${groupPart.value}]`, 'g'); + const decimalPart = parts.find(d => d.type === 'decimal'); + const decimal = decimalPart && new RegExp(`[${decimalPart.value}]`); const numeral = new RegExp(`[${numerals.join('')}]`, 'g'); const getIndex = d => index.get(d); return (string) => { - string = string.trim() - .replace(literal, '') - .replace(group, '') - .replace(decimal, '.') - .replace(numeral, getIndex); + string = string.trim(); + if (literal) string = string.replace(literal, ''); + if (group) string = string.replace(group, ''); + if (decimal) string = string.replace(decimal, '.'); + string = string.replace(numeral, getIndex); return string ? +string : NaN; }; }; From 4e1129709ce03bcb13c2f6a5afce87e6e1d2a80c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Fri, 3 Mar 2023 22:47:00 -0800 Subject: [PATCH 09/15] Fixed exception formatting nonexistent value Co-authored-by: Martin Raifer --- modules/ui/fields/roadheight.js | 7 +++++-- modules/ui/fields/roadspeed.js | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/modules/ui/fields/roadheight.js b/modules/ui/fields/roadheight.js index 696d8c119..f04bc0e97 100644 --- a/modules/ui/fields/roadheight.js +++ b/modules/ui/fields/roadheight.js @@ -175,8 +175,11 @@ export function uiFieldRoadheight(field, context) { } else if (primaryValue) { var rawValue = primaryValue; primaryValue = parseFloat(rawValue); - if (isNaN(primaryValue)) primaryValue = rawValue; - primaryValue = formatFloat(primaryValue); + if (isNaN(primaryValue)) { + primaryValue = rawValue; + } else { + primaryValue = formatFloat(primaryValue); + } _isImperial = false; } } diff --git a/modules/ui/fields/roadspeed.js b/modules/ui/fields/roadspeed.js index 25b47ea06..52799d86a 100644 --- a/modules/ui/fields/roadspeed.js +++ b/modules/ui/fields/roadspeed.js @@ -137,8 +137,11 @@ export function uiFieldRoadspeed(field, context) { } value = parseInt(value, 10); - if (isNaN(value)) value = rawValue; - value = formatFloat(value); + if (isNaN(value)) { + value = rawValue; + } else { + value = formatFloat(value); + } } setUnitSuggestions(); From 023907f9e26797b35e9c8bb91e94ff0d902a2861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Sat, 4 Mar 2023 18:36:06 -0800 Subject: [PATCH 10/15] Fixed incrementing/decrementing formatted numbers The float formatter function now takes a number of fraction digits to return. --- modules/core/localizer.js | 44 +++++++++++++++++++++++++++++++++++-- modules/ui/fields/input.js | 4 ++-- test/spec/core/localizer.js | 19 ++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/modules/core/localizer.js b/modules/core/localizer.js index f8e5a6df9..9e7d9850c 100644 --- a/modules/core/localizer.js +++ b/modules/core/localizer.js @@ -424,15 +424,28 @@ export function coreLocalizer() { return code; // if not found, use the code }; + /** + * Returns a function that formats a floating-point number in the given + * locale. + */ localizer.floatFormatter = (locale) => { if (!('Intl' in window && 'NumberFormat' in Intl && 'formatToParts' in Intl.NumberFormat.prototype)) { - return (number) => number.toString(); + return (number, fractionDigits) => { + return fractionDigits === undefined ? number.toString() : number.toFixed(fractionDigits); + }; } else { - return (number) => number.toLocaleString(locale, { maximumFractionDigits: 20 }); + return (number, fractionDigits) => number.toLocaleString(locale, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits === undefined ? 20 : fractionDigits, + }); } }; + /** + * Returns a function that parses a number formatted according to the given + * locale as a floating-point number. + */ localizer.floatParser = (locale) => { // https://stackoverflow.com/a/55366435/4585461 const polyfill = (string) => parseFloat(string.trim()); @@ -460,5 +473,32 @@ export function coreLocalizer() { }; }; + /** + * Returns a function that returns the number of decimal places in a + * formatted number string. + */ + localizer.decimalPlaceCounter = (locale) => { + var literal, group, decimal; + if ('Intl' in window && 'NumberFormat' in Intl) { + const format = new Intl.NumberFormat(locale, { maximumFractionDigits: 20 }); + if (('formatToParts' in format)) { + const parts = format.formatToParts(-12345.6); + const literalPart = parts.find(d => d.type === 'literal'); + literal = literalPart && new RegExp(`[${literalPart.value}]`, 'g'); + const groupPart = parts.find(d => d.type === 'group'); + group = groupPart && new RegExp(`[${groupPart.value}]`, 'g'); + const decimalPart = parts.find(d => d.type === 'decimal'); + decimal = decimalPart && new RegExp(`[${decimalPart.value}]`); + } + } + return (string) => { + string = string.trim(); + if (literal) string = string.replace(literal, ''); + if (group) string = string.replace(group, ''); + const parts = string.split(decimal || '.'); + return parts && parts[1] && parts[1].length || 0; + }; + }; + return localizer; } diff --git a/modules/ui/fields/input.js b/modules/ui/fields/input.js index 45e48d9de..7bbfd5380 100644 --- a/modules/ui/fields/input.js +++ b/modules/ui/fields/input.js @@ -34,6 +34,7 @@ export function uiFieldText(field, context) { const isDirectionField = field.key.split(':').some(keyPart => keyPart === 'direction'); const formatFloat = localizer.floatFormatter(localizer.languageCode()); const parseLocaleFloat = localizer.floatParser(localizer.languageCode()); + const countDecimalPlaces = localizer.decimalPlaceCounter(localizer.languageCode()); if (field.type === 'tel') { fileFetcher.get('phone_formats') @@ -155,8 +156,7 @@ export function uiFieldText(field, context) { num = ((num % 360) + 360) % 360; } // make sure no extra decimals are introduced - const numDecimals = v.includes('.') ? v.split('.')[1].length : 0; - return formatFloat(clamped(num).toFixed(numDecimals)); + return formatFloat(clamped(num), countDecimalPlaces(v)); }); input.node().value = vals.join(';'); change()(); diff --git a/test/spec/core/localizer.js b/test/spec/core/localizer.js index 6a77e0ef8..01e2f20e7 100644 --- a/test/spec/core/localizer.js +++ b/test/spec/core/localizer.js @@ -6,6 +6,15 @@ describe('iD.coreLocalizer', function() { expect(selection.selectChild().classed('localized-text')).to.be.true; }); }); + describe('#floatFormatter', function () { + it('uses the specified number of fraction digits', function () { + var localizer = iD.coreLocalizer(); + var formatFloat = localizer.floatFormatter('en'); + expect(formatFloat(-0.1)).to.eql('-0.1'); + expect(formatFloat(-0.1, 0)).to.eql('-0'); + expect(formatFloat(-0.1, 2)).to.eql('-0.10'); + }); + }); describe('#floatParser', function () { it('roundtrips English numbers', function () { var localizer = iD.coreLocalizer(); @@ -58,4 +67,14 @@ describe('iD.coreLocalizer', function() { expect(parseFloat(formatFloat(3.14159))).to.eql(3.14159); }); }); + describe('#decimalPlaceCounter', function () { + it('counts decimal places in English numbers', function () { + var localizer = iD.coreLocalizer(); + var countDecimalPlaces = localizer.decimalPlaceCounter('en'); + expect(countDecimalPlaces('-0')).to.eql(0); + expect(countDecimalPlaces('-0.1')).to.eql(1); + expect(countDecimalPlaces('1.234')).to.eql(3); + expect(countDecimalPlaces('10')).to.eql(0); + }); + }); }); From 653582f26853427d79d49108c732c175d305bd8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Sat, 4 Mar 2023 18:52:50 -0800 Subject: [PATCH 11/15] Added tests of padding zero --- test/spec/core/localizer.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/spec/core/localizer.js b/test/spec/core/localizer.js index 01e2f20e7..de157710a 100644 --- a/test/spec/core/localizer.js +++ b/test/spec/core/localizer.js @@ -13,6 +13,19 @@ describe('iD.coreLocalizer', function() { expect(formatFloat(-0.1)).to.eql('-0.1'); expect(formatFloat(-0.1, 0)).to.eql('-0'); expect(formatFloat(-0.1, 2)).to.eql('-0.10'); + expect(formatFloat(0.0, 1)).to.eql('0.0'); + }); + it('roundtrips English numbers', function () { + var localizer = iD.coreLocalizer(); + var parseFloat = localizer.floatParser('en'); + var formatFloat = localizer.floatFormatter('en'); + expect(formatFloat(parseFloat('0.1'))).to.eql('0.1'); + expect(formatFloat(parseFloat('.1'))).to.eql('0.1'); + expect(formatFloat(parseFloat('-0.1'))).to.eql('-0.1'); + expect(formatFloat(parseFloat('1.234'))).to.eql('1.234'); + expect(formatFloat(parseFloat('1234'))).to.eql('1,234'); + expect(formatFloat(parseFloat('1234.56'))).to.eql('1,234.56'); + expect(formatFloat(parseFloat('3.14159'))).to.eql('3.14159'); }); }); describe('#floatParser', function () { From ab442b76ac4a61ed12d52e383648e74ece24d0fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Sat, 4 Mar 2023 23:43:40 -0800 Subject: [PATCH 12/15] Preserve precision in raw tags --- modules/core/localizer.js | 4 ++-- modules/ui/fields/input.js | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/core/localizer.js b/modules/core/localizer.js index 9e7d9850c..59067f699 100644 --- a/modules/core/localizer.js +++ b/modules/core/localizer.js @@ -448,7 +448,7 @@ export function coreLocalizer() { */ localizer.floatParser = (locale) => { // https://stackoverflow.com/a/55366435/4585461 - const polyfill = (string) => parseFloat(string.trim()); + const polyfill = (string) => +string.trim(); if (!('Intl' in window && 'NumberFormat' in Intl)) return polyfill; const format = new Intl.NumberFormat(locale, { maximumFractionDigits: 20 }); if (!('formatToParts' in format)) return polyfill; @@ -481,7 +481,7 @@ export function coreLocalizer() { var literal, group, decimal; if ('Intl' in window && 'NumberFormat' in Intl) { const format = new Intl.NumberFormat(locale, { maximumFractionDigits: 20 }); - if (('formatToParts' in format)) { + if ('formatToParts' in format) { const parts = format.formatToParts(-12345.6); const literalPart = parts.find(d => d.type === 'literal'); literal = literalPart && new RegExp(`[${literalPart.value}]`, 'g'); diff --git a/modules/ui/fields/input.js b/modules/ui/fields/input.js index 7bbfd5380..cc8c84f96 100644 --- a/modules/ui/fields/input.js +++ b/modules/ui/fields/input.js @@ -401,7 +401,8 @@ export function uiFieldText(field, context) { var vals = val.split(';'); vals = vals.map(function(v) { var num = parseLocaleFloat(v); - return isFinite(num) ? clamped(num) : v; + const fractionDigits = countDecimalPlaces(v); + return isFinite(num) ? clamped(num).toFixed(fractionDigits) : v; }); val = vals.join(';'); } @@ -429,9 +430,10 @@ export function uiFieldText(field, context) { var vals = val.split(';'); vals = vals.map(function(v) { v = v.trim(); - var num = parseFloat(v); + var num = Number(v); if (!isFinite(num)) return v; - return formatFloat(clamped(num)); + const fractionDigits = v.includes('.') ? v.split('.')[1].length : 0; + return formatFloat(clamped(num), fractionDigits); }); val = vals.join(';'); } From 249771d747b96554b8d6e3b377933d597e5a37a1 Mon Sep 17 00:00:00 2001 From: Martin Raifer Date: Fri, 26 May 2023 13:10:06 +0200 Subject: [PATCH 13/15] fix variable declarations --- modules/ui/fields/input.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/ui/fields/input.js b/modules/ui/fields/input.js index 84ac21494..a99ee3b19 100644 --- a/modules/ui/fields/input.js +++ b/modules/ui/fields/input.js @@ -409,13 +409,13 @@ export function uiFieldText(field, context) { var displayVal = val; if (field.type === 'number' && val) { - var vals = val.split(';'); - vals = vals.map(function(v) { + var numbers = val.split(';'); + numbers = numbers.map(function(v) { var num = parseLocaleFloat(v); const fractionDigits = countDecimalPlaces(v); return isFinite(num) ? clamped(num).toFixed(fractionDigits) : v; }); - val = vals.join(';'); + val = numbers.join(';'); } if (!onInput) utilGetSetValue(input, displayVal); t[field.key] = val || undefined; @@ -452,18 +452,18 @@ export function uiFieldText(field, context) { const vals = getVals(tags); const isMixed = vals.size > 1; - const val = vals.size === 1 ? [...vals][0] : ''; + var val = vals.size === 1 ? [...vals][0] : ''; if (field.type === 'number' && val) { - var vals = val.split(';'); - vals = vals.map(function(v) { + var numbers = val.split(';'); + numbers = numbers.map(function(v) { v = v.trim(); var num = Number(v); if (!isFinite(num)) return v; const fractionDigits = v.includes('.') ? v.split('.')[1].length : 0; return formatFloat(clamped(num), fractionDigits); }); - val = vals.join(';'); + val = numbers.join(';'); } utilGetSetValue(input, val) .attr('title', isMixed ? [...vals].join('\n') : undefined) From 6d01c05f76363213605fbaa2da42ecc0a02471a4 Mon Sep 17 00:00:00 2001 From: Martin Raifer Date: Fri, 26 May 2023 13:39:31 +0200 Subject: [PATCH 14/15] allow numbers to be input in "raw" format --- modules/ui/fields/input.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/ui/fields/input.js b/modules/ui/fields/input.js index a99ee3b19..e6b409ccf 100644 --- a/modules/ui/fields/input.js +++ b/modules/ui/fields/input.js @@ -411,6 +411,10 @@ export function uiFieldText(field, context) { if (field.type === 'number' && val) { var numbers = val.split(';'); numbers = numbers.map(function(v) { + if (/^\d+\.\d{1}$/.test(v)) { + // ignore numbers entered in "raw" format + return v; + } var num = parseLocaleFloat(v); const fractionDigits = countDecimalPlaces(v); return isFinite(num) ? clamped(num).toFixed(fractionDigits) : v; From b266ec57ad4cf07fcbda269c1c08cd00d6be526e Mon Sep 17 00:00:00 2001 From: Martin Raifer Date: Fri, 26 May 2023 15:00:10 +0200 Subject: [PATCH 15/15] treat "empty" numbers in semicolon separated "list" as invalid --- modules/ui/fields/input.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ui/fields/input.js b/modules/ui/fields/input.js index e6b409ccf..51b83b647 100644 --- a/modules/ui/fields/input.js +++ b/modules/ui/fields/input.js @@ -463,7 +463,7 @@ export function uiFieldText(field, context) { numbers = numbers.map(function(v) { v = v.trim(); var num = Number(v); - if (!isFinite(num)) return v; + if (!isFinite(num) || v === '') return v; const fractionDigits = v.includes('.') ? v.split('.')[1].length : 0; return formatFloat(clamped(num), fractionDigits); });