Skip to content

Commit 252828b

Browse files
committedMar 4, 2023
Localize numbers in numeric fields
1 parent f818cfd commit 252828b

File tree

6 files changed

+135
-42
lines changed

6 files changed

+135
-42
lines changed
 

‎ACCESSIBILITY.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,12 @@ for more info.
165165
|| Browser language preference | iD tries to use the language set in the browser |
166166
| ✅ | Base language fallback | E.g. if `pt_BR` is incomplete, `pt` should be tried before `en` | [#7996](https://github.com/openstreetmap/iD/issues/7996)
167167
| ✅ | 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)
168-
| 🟠 | [`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)
168+
| | [`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)
169169
|| Locale URL parameters | `locale` and `rtl` can be used to manually set iD's locale preferences. See the [API](API.md#url-parameters) |
170170
|| 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) |
171171
| 🟩 | 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 |
172172
|| [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) |
173-
| 🟠 | [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) |
173+
| | [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) |
174174
| 🟠 | Label icons | Icons should accompany text labels to illustrate the meaning of untranslated terms |
175175

176176
### Translatability

‎modules/core/localizer.js

+19
Original file line numberDiff line numberDiff line change
@@ -424,5 +424,24 @@ export function coreLocalizer() {
424424
return code; // if not found, use the code
425425
};
426426

427+
localizer.floatParser = (locale) => {
428+
// https://stackoverflow.com/a/55366435/4585461
429+
const format = new Intl.NumberFormat(locale);
430+
const parts = format.formatToParts(12345.6);
431+
const numerals = Array.from({ length: 10 }).map((_, i) => format.format(i));
432+
const index = new Map(numerals.map((d, i) => [d, i]));
433+
const group = new RegExp(`[${parts.find(d => d.type === 'group').value}]`, 'g');
434+
const decimal = new RegExp(`[${parts.find(d => d.type === 'decimal').value}]`);
435+
const numeral = new RegExp(`[${numerals.join('')}]`, 'g');
436+
const getIndex = d => index.get(d);
437+
return (string) => {
438+
string = string.trim()
439+
.replace(group, '')
440+
.replace(decimal, '.')
441+
.replace(numeral, getIndex);
442+
return string ? +string : NaN;
443+
};
444+
};
445+
427446
return localizer;
428447
}

‎modules/ui/fields/input.js

+33-17
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function uiFieldText(field, context) {
3232
var _tags;
3333
var _phoneFormats = {};
3434
const isDirectionField = field.key.split(':').some(keyPart => keyPart === 'direction');
35+
const parseLocaleFloat = localizer.floatParser(localizer.languageCode());
3536

3637
if (field.type === 'tel') {
3738
fileFetcher.get('phone_formats')
@@ -132,18 +133,19 @@ export function uiFieldText(field, context) {
132133
var raw_vals = input.node().value || '0';
133134
var vals = raw_vals.split(';');
134135
vals = vals.map(function(v) {
135-
var num = Number(v);
136+
v = v.trim();
137+
var num = parseLocaleFloat(v);
136138
if (isDirectionField) {
137139
const compassDir = cardinal[v.trim().toLowerCase()];
138140
if (compassDir !== undefined) {
139141
num = compassDir;
140142
}
141143
}
142144

143-
if (!isFinite(num)) {
144-
// do nothing if the value is neither a number, nor a cardinal direction
145-
return v.trim();
146-
}
145+
// do nothing if the value is neither a number, nor a cardinal direction
146+
if (!isFinite(num)) return v;
147+
num = parseFloat(num, 10);
148+
if (!isFinite(num)) return v;
147149

148150
num += d;
149151
// clamp to 0..359 degree range if it's a direction field
@@ -153,7 +155,7 @@ export function uiFieldText(field, context) {
153155
}
154156
// make sure no extra decimals are introduced
155157
const numDecimals = v.includes('.') ? v.split('.')[1].length : 0;
156-
return clamped(num).toFixed(numDecimals);
158+
return clamped(num).toFixed(numDecimals).toLocaleString(localizer.languageCode());
157159
});
158160
input.node().value = vals.join(';');
159161
change()();
@@ -393,17 +395,20 @@ export function uiFieldText(field, context) {
393395
// don't override multiple values with blank string
394396
if (!val && Array.isArray(_tags[field.key])) return;
395397

396-
if (!onInput) {
397-
if (field.type === 'number' && val) {
398-
var vals = val.split(';');
399-
vals = vals.map(function(v) {
400-
var num = Number(v);
401-
return isFinite(num) ? clamped(num) : v.trim();
402-
});
403-
val = vals.join(';');
404-
}
405-
utilGetSetValue(input, val);
398+
var displayVal = val;
399+
if (field.type === 'number' && val) {
400+
var vals = val.split(';');
401+
vals = vals.map(function(v) {
402+
v = v.trim();
403+
var num = parseLocaleFloat(v);
404+
if (!isFinite(num)) return v;
405+
num = parseFloat(num, 10);
406+
if (!isFinite(num)) return v;
407+
return clamped(num);
408+
});
409+
val = vals.join(';');
406410
}
411+
if (!onInput) utilGetSetValue(input, displayVal);
407412
t[field.key] = val || undefined;
408413
dispatch.call('change', this, t, onInput);
409414
};
@@ -422,7 +427,18 @@ export function uiFieldText(field, context) {
422427

423428
var isMixed = Array.isArray(tags[field.key]);
424429

425-
utilGetSetValue(input, !isMixed && tags[field.key] ? tags[field.key] : '')
430+
var val = !isMixed && tags[field.key] ? tags[field.key] : '';
431+
if (field.type === 'number' && val) {
432+
var vals = val.split(';');
433+
vals = vals.map(function(v) {
434+
v = v.trim();
435+
var num = parseFloat(v, 10);
436+
if (!isFinite(num)) return v;
437+
return clamped(num).toLocaleString(localizer.languageCode());
438+
});
439+
val = vals.join(';');
440+
}
441+
utilGetSetValue(input, val)
426442
.attr('title', isMixed ? tags[field.key].filter(Boolean).join('\n') : undefined)
427443
.attr('placeholder', isMixed ? t('inspector.multiple_values') : (field.placeholder() || t('inspector.unknown')))
428444
.classed('mixed', isMixed);

‎modules/ui/fields/roadheight.js

+27-12
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { select as d3_select } from 'd3-selection';
33
import * as countryCoder from '@ideditor/country-coder';
44

55
import { uiCombobox } from '../combobox';
6-
import { t } from '../../core/localizer';
6+
import { t, localizer } from '../../core/localizer';
77
import { utilGetSetValue, utilNoAuto, utilRebind, utilTotalExtent } from '../../util';
88

99

@@ -16,6 +16,7 @@ export function uiFieldRoadheight(field, context) {
1616
var _entityIDs = [];
1717
var _tags;
1818
var _isImperial;
19+
var parseLocaleFloat = localizer.floatParser(localizer.languageCode());
1920

2021
var primaryUnits = [
2122
{
@@ -129,16 +130,23 @@ export function uiFieldRoadheight(field, context) {
129130

130131
if (!primaryValue && !secondaryValue) {
131132
tag[field.key] = undefined;
132-
} else if (isNaN(primaryValue) || isNaN(secondaryValue) || !_isImperial) {
133-
tag[field.key] = context.cleanTagValue(primaryValue);
134133
} else {
135-
if (primaryValue !== '') {
136-
primaryValue = context.cleanTagValue(primaryValue + '\'');
137-
}
138-
if (secondaryValue !== '') {
139-
secondaryValue = context.cleanTagValue(secondaryValue + '"');
134+
var rawPrimaryValue = parseLocaleFloat(primaryValue);
135+
if (isNaN(rawPrimaryValue)) rawPrimaryValue = primaryValue;
136+
var rawSecondaryValue = parseLocaleFloat(secondaryValue);
137+
if (isNaN(rawSecondaryValue)) rawSecondaryValue = secondaryValue;
138+
139+
if (isNaN(rawPrimaryValue) || isNaN(rawSecondaryValue) || !_isImperial) {
140+
tag[field.key] = context.cleanTagValue(rawPrimaryValue);
141+
} else {
142+
if (rawPrimaryValue !== '') {
143+
rawPrimaryValue = context.cleanTagValue(rawPrimaryValue + '\'');
144+
}
145+
if (rawSecondaryValue !== '') {
146+
rawSecondaryValue = context.cleanTagValue(rawSecondaryValue + '"');
147+
}
148+
tag[field.key] = rawPrimaryValue + rawSecondaryValue;
140149
}
141-
tag[field.key] = primaryValue + secondaryValue;
142150
}
143151

144152
dispatch.call('change', this, tag);
@@ -156,26 +164,33 @@ export function uiFieldRoadheight(field, context) {
156164
if (primaryValue && (primaryValue.indexOf('\'') >= 0 || primaryValue.indexOf('"') >= 0)) {
157165
secondaryValue = primaryValue.match(/(-?[\d.]+)"/);
158166
if (secondaryValue !== null) {
159-
secondaryValue = secondaryValue[1];
167+
secondaryValue = parseFloat(secondaryValue[1], 10).toLocaleString(localizer.languageCode());
160168
}
161169
primaryValue = primaryValue.match(/(-?[\d.]+)'/);
162170
if (primaryValue !== null) {
163-
primaryValue = primaryValue[1];
171+
primaryValue = parseFloat(primaryValue[1], 10).toLocaleString(localizer.languageCode());
164172
}
165173
_isImperial = true;
166174
} else if (primaryValue) {
175+
var rawValue = primaryValue;
176+
primaryValue = parseFloat(rawValue, 10);
177+
if (isNaN(primaryValue)) primaryValue = rawValue;
178+
primaryValue = primaryValue.toLocaleString(localizer.languageCode());
167179
_isImperial = false;
168180
}
169181
}
170182

171183
setUnitSuggestions();
172184

185+
// If feet are specified but inches are omitted, assume zero inches.
186+
var inchesPlaceholder = (0).toLocaleString(localizer.languageCode());
187+
173188
utilGetSetValue(primaryInput, typeof primaryValue === 'string' ? primaryValue : '')
174189
.attr('title', isMixed ? primaryValue.filter(Boolean).join('\n') : null)
175190
.attr('placeholder', isMixed ? t('inspector.multiple_values') : t('inspector.unknown'))
176191
.classed('mixed', isMixed);
177192
utilGetSetValue(secondaryInput, typeof secondaryValue === 'string' ? secondaryValue : '')
178-
.attr('placeholder', isMixed ? t('inspector.multiple_values') : (_isImperial ? '0' : null))
193+
.attr('placeholder', isMixed ? t('inspector.multiple_values') : (_isImperial ? inchesPlaceholder : null))
179194
.classed('mixed', isMixed)
180195
.classed('disabled', !_isImperial)
181196
.attr('readonly', _isImperial ? null : 'readonly');

‎modules/ui/fields/roadspeed.js

+20-11
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { select as d3_select } from 'd3-selection';
33
import * as countryCoder from '@ideditor/country-coder';
44

55
import { uiCombobox } from '../combobox';
6-
import { t } from '../../core/localizer';
6+
import { t, localizer } from '../../core/localizer';
77
import { utilGetSetValue, utilNoAuto, utilRebind, utilTotalExtent } from '../../util';
88

99

@@ -14,6 +14,7 @@ export function uiFieldRoadspeed(field, context) {
1414
var _entityIDs = [];
1515
var _tags;
1616
var _isImperial;
17+
var parseLocaleFloat = localizer.floatParser(localizer.languageCode());
1718

1819
var speedCombo = uiCombobox(context, 'roadspeed');
1920
var unitCombo = uiCombobox(context, 'roadspeed-unit')
@@ -91,8 +92,8 @@ export function uiFieldRoadspeed(field, context) {
9192

9293
function comboValues(d) {
9394
return {
94-
value: d.toString(),
95-
title: d.toString()
95+
value: d.toLocaleString(localizer.languageCode()),
96+
title: d.toLocaleString(localizer.languageCode())
9697
};
9798
}
9899

@@ -106,10 +107,14 @@ export function uiFieldRoadspeed(field, context) {
106107

107108
if (!value) {
108109
tag[field.key] = undefined;
109-
} else if (isNaN(value) || !_isImperial) {
110-
tag[field.key] = context.cleanTagValue(value);
111110
} else {
112-
tag[field.key] = context.cleanTagValue(value + ' mph');
111+
var rawValue = parseLocaleFloat(value);
112+
if (isNaN(rawValue)) rawValue = value;
113+
if (isNaN(rawValue) || !_isImperial) {
114+
tag[field.key] = context.cleanTagValue(rawValue);
115+
} else {
116+
tag[field.key] = context.cleanTagValue(rawValue + ' mph');
117+
}
113118
}
114119

115120
dispatch.call('change', this, tag);
@@ -119,16 +124,20 @@ export function uiFieldRoadspeed(field, context) {
119124
roadspeed.tags = function(tags) {
120125
_tags = tags;
121126

122-
var value = tags[field.key];
127+
var rawValue = tags[field.key];
128+
var value = rawValue;
123129
var isMixed = Array.isArray(value);
124130

125131
if (!isMixed) {
126-
if (value && value.indexOf('mph') >= 0) {
127-
value = parseInt(value, 10).toString();
128-
_isImperial = true;
129-
} else if (value) {
132+
if (rawValue && rawValue.indexOf('mph') >= 0) {
133+
_isImperial = rawValue && rawValue.indexOf('mph') >= 0;
134+
} else if (rawValue) {
130135
_isImperial = false;
131136
}
137+
138+
value = parseInt(value, 10);
139+
if (isNaN(value)) value = rawValue;
140+
value = value.toLocaleString(localizer.languageCode());
132141
}
133142

134143
setUnitSuggestions();

‎test/spec/core/localizer.js

+34
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,38 @@ describe('iD.coreLocalizer', function() {
66
expect(selection.selectChild().classed('localized-text')).to.be.true;
77
});
88
});
9+
describe('#floatParser', function () {
10+
it('roundtrips English numbers', function () {
11+
var localizer = iD.coreLocalizer();
12+
var parseFloat = localizer.floatParser('en');
13+
expect(parseFloat((-0.1).toLocaleString(localizer.languageCode()))).to.eql(-0.1);
14+
expect(parseFloat((1.234).toLocaleString(localizer.languageCode()))).to.eql(1.234);
15+
expect(parseFloat(1234).toLocaleString(localizer.languageCode())).to.eql(1234);
16+
expect(parseFloat(1234.56).toLocaleString(localizer.languageCode())).to.eql(1234.56);
17+
});
18+
it('roundtrips Spanish numbers', function () {
19+
var localizer = iD.coreLocalizer();
20+
var parseFloat = localizer.floatParser('es');
21+
expect(parseFloat((-0.1).toLocaleString(localizer.languageCode()))).to.eql(-0.1);
22+
expect(parseFloat((1.234).toLocaleString(localizer.languageCode()))).to.eql(1.234);
23+
expect(parseFloat(1234).toLocaleString(localizer.languageCode())).to.eql(1234);
24+
expect(parseFloat(1234.56).toLocaleString(localizer.languageCode())).to.eql(1234.56);
25+
});
26+
it('roundtrips Arabic numbers', function () {
27+
var localizer = iD.coreLocalizer();
28+
var parseFloat = localizer.floatParser('ar-EG');
29+
expect(parseFloat((-0.1).toLocaleString(localizer.languageCode()))).to.eql(-0.1);
30+
expect(parseFloat((1.234).toLocaleString(localizer.languageCode()))).to.eql(1.234);
31+
expect(parseFloat(1234).toLocaleString(localizer.languageCode())).to.eql(1234);
32+
expect(parseFloat(1234.56).toLocaleString(localizer.languageCode())).to.eql(1234.56);
33+
});
34+
it('roundtrips Bengali numbers', function () {
35+
var localizer = iD.coreLocalizer();
36+
var parseFloat = localizer.floatParser('bn');
37+
expect(parseFloat((-0.1).toLocaleString(localizer.languageCode()))).to.eql(-0.1);
38+
expect(parseFloat((1.234).toLocaleString(localizer.languageCode()))).to.eql(1.234);
39+
expect(parseFloat(1234).toLocaleString(localizer.languageCode())).to.eql(1234);
40+
expect(parseFloat(1234.56).toLocaleString(localizer.languageCode())).to.eql(1234.56);
41+
});
42+
});
943
});

0 commit comments

Comments
 (0)