Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

"number-format" expression #7626

Merged
merged 1 commit into from
Dec 20, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/components/expression-metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ const types = {
'...',
'input_n: string, options_n: { "font-scale": number, "text-font": array<string> }'
]
}],
'number-format': [{
type: 'string',
parameters: [
'input: number',
'options: { "locale": string, "currency": string, "min-fraction-digits": number, "max-fraction-digits": number }'
]
}]
};

Expand Down
2 changes: 2 additions & 0 deletions src/style-spec/expression/definitions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
GreaterThanOrEqual
} from './comparison';
import CollatorExpression from './collator';
import NumberFormat from './number_format';
import FormatExpression from './format';
import Length from './length';

Expand Down Expand Up @@ -66,6 +67,7 @@ const expressions: ExpressionRegistry = {
'literal': Literal,
'match': Match,
'number': Assertion,
'number-format': NumberFormat,
'object': Assertion,
'step': Step,
'string': Assertion,
Expand Down
142 changes: 142 additions & 0 deletions src/style-spec/expression/definitions/number_format.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// @flow

import { StringType, NumberType } from '../types';

import type { Expression } from '../expression';
import type EvaluationContext from '../evaluation_context';
import type ParsingContext from '../parsing_context';
import type { Type } from '../types';

declare var Intl: {
NumberFormat: Class<Intl$NumberFormat>
};

declare class Intl$NumberFormat {
constructor (
locales?: string | string[],
options?: NumberFormatOptions
): Intl$NumberFormat;

static (
locales?: string | string[],
options?: NumberFormatOptions
): Intl$NumberFormat;

format(a: number): string;

resolvedOptions(): any;
}

type NumberFormatOptions = {
style?: 'decimal' | 'currency' | 'percent';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NSNumberFormatterStyle actually supports a number of styles, including NSNumberFormatterSpellOutStyle and NSNumberFormatterOrdinalStyle. However, I recognize the need to stick to JavaScript’s Intl as a common denominator, without implementing our own custom types that could conflict with future JavaScript types.

This does mean that the style author will need to hard-code some logic to implement short formats, such as the “1k” for 1,000 that Supercluster implements (only for English) in the point_count_abbreviated property (mapbox/supercluster#110).

/ref tc39/ecma402#37 tc39/ecma402#215.

currency?: null | string;
minimumFractionDigits?: null | string;
maximumFractionDigits?: null | string;
};

export default class NumberFormat implements Expression {
type: Type;
number: Expression;
locale: Expression | null; // BCP 47 language tag
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we support any extensions, such as zh-Hans-CN-u-nu-hanidec to specify Chinese decimal numbering? Perhaps it would be an “if it works, it works” situation, with no availability guarantees from one platform to another.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it would be an “if it works, it works” situation, with no availability guarantees from one platform to another.

That sounds right to me.

currency: Expression | null; // ISO 4217 currency code, required if style=currency
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intl.NumberFormat requires this in "currency" mode, it doesn't just guess based on the locale

Yeah, that’s the right approach. Operating systems allow you to choose the preferred currency independently of the system region. By default, the system region typically affects mechanics like decimal separators, but not semantics or content.

Copy link
Contributor

@1ec5 1ec5 Nov 30, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here’s a correspondence to other implementation languages:

Style specification Objective-C
NumberFormat.style NSNumberFormatter.numberStyle
NumberFormat.locale NSNumberFormatter.locale
NumberFormat.currency NSNumberFormatter.currencyCode
Minimum/maximum fraction digits NSNumberFormatter.minimumFractionDigits, maximumFractionDigits

In Java, use NumberFormat.get{Currency,Number,Percent}Instance() and NumberFormat.setCurrency().

minFractionDigits: Expression | null; // Default 0
maxFractionDigits: Expression | null; // Default 3

constructor(number: Expression,
locale: Expression | null,
currency: Expression | null,
minFractionDigits: Expression | null,
maxFractionDigits: Expression | null) {
this.type = StringType;
this.number = number;
this.locale = locale;
this.currency = currency;
this.minFractionDigits = minFractionDigits;
this.maxFractionDigits = maxFractionDigits;
}

static parse(args: Array<mixed>, context: ParsingContext): ?Expression {
if (args.length !== 3)
return context.error(`Expected two arguments.`);

const number = context.parse(args[1], 1, NumberType);
if (!number) return null;

const options = (args[2]: any);
if (typeof options !== "object" || Array.isArray(options))
return context.error(`NumberFormat options argument must be an object.`);

let locale = null;
if (options['locale']) {
locale = context.parse(options['locale'], 1, StringType);
if (!locale) return null;
}

let currency = null;
if (options['currency']) {
currency = context.parse(options['currency'], 1, StringType);
if (!currency) return null;
}

let minFractionDigits = null;
if (options['min-fraction-digits']) {
minFractionDigits = context.parse(options['min-fraction-digits'], 1, NumberType);
if (!minFractionDigits) return null;
}

let maxFractionDigits = null;
if (options['max-fraction-digits']) {
maxFractionDigits = context.parse(options['max-fraction-digits'], 1, NumberType);
if (!maxFractionDigits) return null;
}

return new NumberFormat(number, locale, currency, minFractionDigits, maxFractionDigits);
}

evaluate(ctx: EvaluationContext) {
return new Intl.NumberFormat(this.locale ? this.locale.evaluate(ctx) : [],
{
style: this.currency ? "currency" : "decimal",
currency: this.currency ? this.currency.evaluate(ctx) : undefined,
minimumFractionDigits: this.minFractionDigits ? this.minFractionDigits.evaluate(ctx) : undefined,
maximumFractionDigits: this.maxFractionDigits ? this.maxFractionDigits.evaluate(ctx) : undefined,
}).format(this.number.evaluate(ctx));
}

eachChild(fn: (Expression) => void) {
fn(this.number);
if (this.locale) {
fn(this.locale);
}
if (this.currency) {
fn(this.currency);
}
if (this.minFractionDigits) {
fn(this.minFractionDigits);
}
if (this.maxFractionDigits) {
fn(this.maxFractionDigits);
}
}

possibleOutputs() {
return [undefined];
}

serialize() {
const options = {};
if (this.locale) {
options['locale'] = this.locale.serialize();
}
if (this.currency) {
options['currency'] = this.currency.serialize();
}
if (this.minFractionDigits) {
options['min-fraction-digits'] = this.minFractionDigits.serialize();
}
if (this.maxFractionDigits) {
options['max-fraction-digits'] = this.maxFractionDigits.serialize();
}
return ["number-format", this.number.serialize(), options];
}
}
9 changes: 9 additions & 0 deletions src/style-spec/reference/v8.json
Original file line number Diff line number Diff line change
Expand Up @@ -2593,6 +2593,15 @@
}
}
},
"number-format": {
"doc": "Converts the input number into a string representation using the providing formatting rules. If set, the `locale` argument specifies the locale to use, as a BCP 47 language tag. If set, the `currency` argument specifies an ISO 4217 code to use for currency-style formatting. If set, the `min-fraction-digits` and `max-fraction-digits` arguments specify the minimum and maximum number of fractional digits to include.",
"group": "Types",
"sdk-support": {
"basic functionality": {
"js": "0.54.0"
}
}
},
"to-string": {
"doc": "Converts the input value to a string. If the input is `null`, the result is `\"\"`. If the input is a boolean, the result is `\"true\"` or `\"false\"`. If the input is a number, it is converted to a string as specified by the [\"NumberToString\" algorithm](https://tc39.github.io/ecma262/#sec-tostring-applied-to-the-number-type) of the ECMAScript Language Specification. If the input is a color, it is converted to a string of the form `\"rgba(r,g,b,a)\"`, where `r`, `g`, and `b` are numerals ranging from 0 to 255, and `a` ranges from 0 to 1. Otherwise, the input is converted to a string in the format specified by the [`JSON.stringify`](https://tc39.github.io/ecma262/#sec-json.stringify) function of the ECMAScript Language Specification.",
"group": "Types",
Expand Down
31 changes: 31 additions & 0 deletions test/integration/expression-tests/number-format/currency/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"expression": [
"number-format",
123456.789,
{
"locale": ["get", "locale"],
"currency": ["get", "currency"]
}
],
"inputs": [
[{}, {"properties": {"locale": "ja-JP", "currency": "JPY"}}],
[{}, {"properties": {"locale": "de-DE", "currency": "EUR"}}]
],
"expected": {
"compiled": {
"result": "success",
"isFeatureConstant": false,
"isZoomConstant": true,
"type": "string"
},
"outputs": ["JP¥ 123,457", "€ 123,456.79"],
"serialized": [
"number-format",
123456.789,
{
"locale": ["string", ["get", "locale"]],
"currency": ["string", ["get", "currency"]]
}
]
}
}
21 changes: 21 additions & 0 deletions test/integration/expression-tests/number-format/default/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@

{
"expression": [
"number-format",
123456.789,
{}
],
"inputs": [
[{}, {}]
],
"expected": {
"compiled": {
"result": "success",
"isFeatureConstant": true,
"isZoomConstant": true,
"type": "string"
},
"outputs": ["123,456.789"],
"serialized": "123,456.789"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"expression": [
"number-format",
987654321.23456789,
{
"locale": ["get", "locale"],
"min-fraction-digits": ["get", "min"],
"max-fraction-digits": ["get", "max"]
}
],
"inputs": [
[{}, {"properties": {"locale": "en-US", "min": 15, "max": 20}}],
[{}, {"properties": {"locale": "en-US", "min": 2, "max": 4}}]
],
"expected": {
"compiled": {
"result": "success",
"isFeatureConstant": false,
"isZoomConstant": true,
"type": "string"
},
"outputs": ["987,654,321.234568000000000", "987,654,321.2346"],
"serialized": [
"number-format",
987654321.2345679,
{
"locale": ["string", ["get", "locale"]],
"min-fraction-digits": ["number", ["get", "min"]],
"max-fraction-digits": ["number", ["get", "max"]]
}
]
}
}