Merge branch '1ec5-number-field-format-3615' into develop

This commit is contained in:
Martin Raifer
2023-05-26 18:01:13 +02:00
7 changed files with 256 additions and 43 deletions
+2 -2
View File
@@ -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
+2
View File
@@ -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
+76
View File
@@ -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;
}
+38 -19
View File
@@ -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')))
+31 -12
View File
@@ -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');
+23 -10
View File
@@ -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();
+84
View File
@@ -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);
});
});
});