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/CHANGELOG.md b/CHANGELOG.md index c83345c2d..9219d770b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ _Breaking developer changes, which may affect downstream projects or sites that * Combo fields for tags with `yes/no` values now also display the `no` state and allow to toggle between the two states ([#7427]) #### :sparkles: Usability & Accessibility * Make it easier to search for OSM objects by id ([#9520], thanks [@k-yle]) +* Localize numbers in numeric fields ([#8769], thanks [@1ec5]) #### :scissors: Operations #### :camera: Street-Level #### :white_check_mark: Validation @@ -69,6 +70,7 @@ _Breaking developer changes, which may affect downstream projects or sites that * Bundle `package-lock.json` file in repository for faster `clean-install` builds * Build icons from configured presets source and also process field value `icons` in `npm run build:data` +[#8769]: https://github.com/openstreetmap/iD/pull/8769 [#7427]: https://github.com/openstreetmap/iD/issues/7427 [#9233]: https://github.com/openstreetmap/iD/issues/9233 [#9482]: https://github.com/openstreetmap/iD/pull/9482 diff --git a/modules/core/localizer.js b/modules/core/localizer.js index a072b41ad..59067f699 100644 --- a/modules/core/localizer.js +++ b/modules/core/localizer.js @@ -424,5 +424,81 @@ 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, fractionDigits) => { + return fractionDigits === undefined ? number.toString() : number.toFixed(fractionDigits); + }; + } else { + 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) => +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; + 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 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(); + 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; + }; + }; + + /** + * 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 7c468f2dd..51b83b647 100644 --- a/modules/ui/fields/input.js +++ b/modules/ui/fields/input.js @@ -32,6 +32,9 @@ 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()); + const countDecimalPlaces = localizer.decimalPlaceCounter(localizer.languageCode()); if (field.type === 'tel') { fileFetcher.get('phone_formats') @@ -132,18 +135,19 @@ 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()]; + const compassDir = cardinal[v.toLowerCase()]; if (compassDir !== undefined) { num = compassDir; } } - 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); + if (!isFinite(num)) return v; num += d; // clamp to 0..359 degree range if it's a direction field @@ -152,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 clamped(num).toFixed(numDecimals); + return formatFloat(clamped(num), countDecimalPlaces(v)); }); input.node().value = vals.join(';'); change()(); @@ -404,17 +407,21 @@ export function uiFieldText(field, context) { // don't override multiple values with blank string if (!val && getVals(_tags).size > 1) 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 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; + }); + val = numbers.join(';'); } + if (!onInput) utilGetSetValue(input, displayVal); t[field.key] = val || undefined; if (field.keys) { // for multi-key fields with: handle alternative tag keys gracefully @@ -449,7 +456,19 @@ 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 numbers = val.split(';'); + numbers = numbers.map(function(v) { + v = v.trim(); + var num = Number(v); + if (!isFinite(num) || v === '') return v; + const fractionDigits = v.includes('.') ? v.split('.')[1].length : 0; + return formatFloat(clamped(num), fractionDigits); + }); + val = numbers.join(';'); + } utilGetSetValue(input, val) .attr('title', isMixed ? [...vals].join('\n') : undefined) .attr('placeholder', isMixed ? t('inspector.multiple_values') : (field.placeholder() || t('inspector.unknown'))) diff --git a/modules/ui/fields/roadheight.js b/modules/ui/fields/roadheight.js index dfe7cd0d1..edadaf2ea 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 '@rapideditor/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,8 @@ 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 = [ { @@ -129,16 +131,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 = rawPrimaryValue + '\''; + } + if (rawSecondaryValue !== '') { + rawSecondaryValue = rawSecondaryValue + '"'; + } + tag[field.key] = context.cleanTagValue(rawPrimaryValue + rawSecondaryValue); } - if (secondaryValue !== '') { - secondaryValue = context.cleanTagValue(secondaryValue + '"'); - } - tag[field.key] = primaryValue + secondaryValue; } dispatch.call('change', this, tag); @@ -156,26 +165,36 @@ export function uiFieldRoadheight(field, context) { if (primaryValue && (primaryValue.indexOf('\'') >= 0 || primaryValue.indexOf('"') >= 0)) { secondaryValue = primaryValue.match(/(-?[\d.]+)"/); if (secondaryValue !== null) { - secondaryValue = secondaryValue[1]; + secondaryValue = formatFloat(parseFloat(secondaryValue[1])); } primaryValue = primaryValue.match(/(-?[\d.]+)'/); if (primaryValue !== null) { - primaryValue = primaryValue[1]; + primaryValue = formatFloat(parseFloat(primaryValue[1])); } _isImperial = true; } else if (primaryValue) { + var rawValue = primaryValue; + primaryValue = parseFloat(rawValue); + if (isNaN(primaryValue)) { + primaryValue = rawValue; + } else { + primaryValue = formatFloat(primaryValue); + } _isImperial = false; } } setUnitSuggestions(); + // If feet are specified but inches are omitted, assume zero inches. + var inchesPlaceholder = formatFloat(0); + 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 151468c35..9f7f90a78 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 '@rapideditor/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,8 @@ 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'); var unitCombo = uiCombobox(context, 'roadspeed-unit') @@ -91,8 +93,8 @@ export function uiFieldRoadspeed(field, context) { function comboValues(d) { return { - value: d.toString(), - title: d.toString() + value: formatFloat(d), + title: formatFloat(d) }; } @@ -106,10 +108,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 +125,23 @@ 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(); + if (rawValue && rawValue.indexOf('mph') >= 0) { _isImperial = true; - } else if (value) { + } else if (rawValue) { _isImperial = false; } + + value = parseInt(value, 10); + if (isNaN(value)) { + value = rawValue; + } else { + value = formatFloat(value); + } } setUnitSuggestions(); diff --git a/test/spec/core/localizer.js b/test/spec/core/localizer.js index cf30d5ab7..de157710a 100644 --- a/test/spec/core/localizer.js +++ b/test/spec/core/localizer.js @@ -6,4 +6,88 @@ 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'); + 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 () { + it('roundtrips English numbers', function () { + var localizer = iD.coreLocalizer(); + var formatFloat = localizer.floatFormatter('en'); + var parseFloat = localizer.floatParser('en'); + 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 Spanish numbers', function () { + var localizer = iD.coreLocalizer(); + var formatFloat = localizer.floatFormatter('es'); + var parseFloat = localizer.floatParser('es'); + 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 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'); + var parseFloat = localizer.floatParser('ar-EG'); + 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 Bengali numbers', function () { + var localizer = iD.coreLocalizer(); + var formatFloat = localizer.floatFormatter('bn'); + var parseFloat = localizer.floatParser('bn'); + 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); + }); + }); + 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); + }); + }); });