highcharts
Version:
JavaScript charting framework
428 lines (427 loc) • 15.4 kB
JavaScript
/* *
*
* (c) 2010-2025 Torstein Honsi
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
;
import D from './Defaults.js';
const { defaultOptions, defaultTime } = D;
import G from './Globals.js';
const { pageLang } = G;
import U from './Utilities.js';
const { extend, getNestedProperty, isArray, isNumber, isObject, isString, pick, ucfirst } = U;
const helpers = {
// Built-in helpers
add: (a, b) => a + b,
divide: (a, b) => (b !== 0 ? a / b : ''),
// eslint-disable-next-line eqeqeq
eq: (a, b) => a == b,
each: function (arr) {
const match = arguments[arguments.length - 1];
return isArray(arr) ?
arr.map((item, i) => format(match.body, extend(isObject(item) ? item : { '@this': item }, {
'@index': i,
'@first': i === 0,
'@last': i === arr.length - 1
}))).join('') :
false;
},
ge: (a, b) => a >= b,
gt: (a, b) => a > b,
'if': (condition) => !!condition,
le: (a, b) => a <= b,
lt: (a, b) => a < b,
multiply: (a, b) => a * b,
// eslint-disable-next-line eqeqeq
ne: (a, b) => a != b,
subtract: (a, b) => a - b,
ucfirst,
unless: (condition) => !condition
};
const numberFormatCache = {};
/* *
*
* Functions
*
* */
// Internal convenience function
const isQuotedString = (str) => /^["'].+["']$/.test(str);
/**
* Formats a JavaScript date timestamp (milliseconds since Jan 1st 1970) into a
* human readable date string. The format is a subset of the formats for PHP's
* [strftime](https://www.php.net/manual/en/function.strftime.php) function.
* Additional formats can be given in the {@link Highcharts.dateFormats} hook.
*
* Since v6.0.5, all internal dates are formatted through the
* {@link Highcharts.Chart#time} instance to respect chart-level time settings.
* The `Highcharts.dateFormat` function only reflects global time settings set
* with `setOptions`.
*
* Supported format keys:
* - `%a`: Short weekday, like 'Mon'
* - `%A`: Long weekday, like 'Monday'
* - `%d`: Two digit day of the month, 01 to 31
* - `%e`: Day of the month, 1 through 31
* - `%w`: Day of the week, 0 through 6
* - `%b`: Short month, like 'Jan'
* - `%B`: Long month, like 'January'
* - `%m`: Two digit month number, 01 through 12
* - `%y`: Two digits year, like 09 for 2009
* - `%Y`: Four digits year, like 2009
* - `%H`: Two digits hours in 24h format, 00 through 23
* - `%k`: Hours in 24h format, 0 through 23
* - `%I`: Two digits hours in 12h format, 00 through 11
* - `%l`: Hours in 12h format, 1 through 12
* - `%M`: Two digits minutes, 00 through 59
* - `%p`: Upper case AM or PM
* - `%P`: Lower case AM or PM
* - `%S`: Two digits seconds, 00 through 59
* - `%L`: Milliseconds (naming from Ruby)
*
* @function Highcharts.dateFormat
*
* @param {string} format
* The desired format where various time representations are prefixed
* with `%`.
*
* @param {number} timestamp
* The JavaScript timestamp.
*
* @param {boolean} [upperCaseFirst=false]
* Upper case first letter in the return.
*
* @return {string}
* The formatted date.
*/
function dateFormat(format, timestamp, upperCaseFirst) {
return defaultTime.dateFormat(format, timestamp, upperCaseFirst);
}
/**
* Format a string according to a subset of the rules of Python's String.format
* method.
*
* @example
* let s = Highcharts.format(
* 'The {color} fox was {len:.2f} feet long',
* { color: 'red', len: Math.PI }
* );
* // => The red fox was 3.14 feet long
*
* @function Highcharts.format
*
* @param {string} str
* The string to format.
*
* @param {Record<string, *>} ctx
* The context, a collection of key-value pairs where each key is
* replaced by its value.
*
* @param {Highcharts.Chart} [owner]
* A `Chart` or `DataGrid` instance used to get numberFormatter and time.
*
* @return {string}
* The formatted string.
*/
function format(str = '', ctx, owner) {
// Notice: using u flag will require a refactor for ES5 (#22450).
const regex = /\{([a-zA-Z\u00C0-\u017F\d:\.,;\-\/<>\[\]%_@+"'’= #\(\)]+)\}/g, // eslint-disable-line max-len
// The sub expression regex is the same as the top expression regex,
// but except parens and block helpers (#), and surrounded by parens
// instead of curly brackets.
subRegex = /\(([a-zA-Z\u00C0-\u017F\d:\.,;\-\/<>\[\]%_@+"'= ]+)\)/g, matches = [], floatRegex = /f$/, decRegex = /\.(\d)/, lang = owner?.options?.lang || defaultOptions.lang, time = owner?.time || defaultTime, numberFormatter = owner?.numberFormatter || numberFormat;
/*
* Get a literal or variable value inside a template expression. May be
* extended with other types like string or null if needed, but keep it
* small for now.
*/
const resolveProperty = (key = '') => {
let n;
// Literals
if (key === 'true') {
return true;
}
if (key === 'false') {
return false;
}
if ((n = Number(key)).toString() === key) {
return n;
}
if (isQuotedString(key)) {
return key.slice(1, -1);
}
// Variables and constants
return getNestedProperty(key, ctx);
};
let match, currentMatch, depth = 0, hasSub;
// Parse and create tree
while ((match = regex.exec(str)) !== null) {
// When a sub expression is found, it is evaluated first, and the
// results recursively evaluated until no subexpression exists.
const mainMatch = match, subMatch = subRegex.exec(match[1]);
if (subMatch) {
match = subMatch;
hasSub = true;
}
if (!currentMatch?.isBlock) {
currentMatch = {
ctx,
expression: match[1],
find: match[0],
isBlock: match[1].charAt(0) === '#',
start: match.index,
startInner: match.index + match[0].length,
length: match[0].length
};
}
// Identify helpers
const fn = (currentMatch.isBlock ? mainMatch : match)[1].split(' ')[0].replace('#', '');
if (helpers[fn]) {
// Block helper, only 0 level is handled
if (currentMatch.isBlock && fn === currentMatch.fn) {
depth++;
}
if (!currentMatch.fn) {
currentMatch.fn = fn;
}
}
// Closing a block helper
const startingElseSection = match[1] === 'else';
if (currentMatch.isBlock &&
currentMatch.fn && (match[1] === `/${currentMatch.fn}` ||
startingElseSection)) {
if (!depth) { // === 0
const start = currentMatch.startInner, body = str.substr(start, match.index - start);
// Either closing without an else section, or when encountering
// an else section
if (currentMatch.body === void 0) {
currentMatch.body = body;
currentMatch.startInner = match.index + match[0].length;
// The body exists already, so this is the else section
}
else {
currentMatch.elseBody = body;
}
currentMatch.find += body + match[0];
if (!startingElseSection) {
matches.push(currentMatch);
currentMatch = void 0;
}
}
else if (!startingElseSection) {
depth--;
}
// Common expression
}
else if (!currentMatch.isBlock) {
matches.push(currentMatch);
}
// Evaluate sub-matches one by one to prevent orphaned block closers
if (subMatch && !currentMatch?.isBlock) {
break;
}
}
// Execute
matches.forEach((match) => {
const { body, elseBody, expression, fn } = match;
let replacement, i;
// Helper function
if (fn) {
// Pass the helpers the amount of arguments defined by the function,
// then the match as the last argument.
const args = [match], parts = [], len = expression.length;
let start = 0, startChar;
for (i = 0; i <= len; i++) {
const char = expression.charAt(i);
// Start of string
if (!startChar && (char === '"' || char === '\'')) {
startChar = char;
// End of string
}
else if (startChar === char) {
startChar = '';
}
if (!startChar &&
(char === ' ' || i === len)) {
parts.push(expression.substr(start, i - start));
start = i + 1;
}
}
i = helpers[fn].length;
while (i--) {
args.unshift(resolveProperty(parts[i + 1]));
}
replacement = helpers[fn].apply(ctx, args);
// Block helpers may return true or false. They may also return a
// string, like the `each` helper.
if (match.isBlock && typeof replacement === 'boolean') {
replacement = format(replacement ? body : elseBody, ctx, owner);
}
// Simple variable replacement
}
else {
const valueAndFormat = isQuotedString(expression) ?
[expression] : expression.split(':');
replacement = resolveProperty(valueAndFormat.shift() || '');
// Format the replacement
if (valueAndFormat.length && typeof replacement === 'number') {
const segment = valueAndFormat.join(':');
if (floatRegex.test(segment)) { // Float
const decimals = parseInt((segment.match(decRegex) || ['', '-1'])[1], 10);
if (replacement !== null) {
replacement = numberFormatter(replacement, decimals, lang.decimalPoint, segment.indexOf(',') > -1 ? lang.thousandsSep : '');
}
}
else {
replacement = time.dateFormat(segment, replacement);
}
}
// Use string literal in order to be preserved in the outer
// expression
subRegex.lastIndex = 0;
if (subRegex.test(match.find) && isString(replacement)) {
replacement = `"${replacement}"`;
}
}
str = str.replace(match.find, pick(replacement, ''));
});
return hasSub ? format(str, ctx, owner) : str;
}
/**
* Format a number and return a string based on input settings.
*
* @sample highcharts/members/highcharts-numberformat/
* Custom number format
*
* @function Highcharts.numberFormat
*
* @param {number} number
* The input number to format.
*
* @param {number} decimals
* The amount of decimals. A value of -1 preserves the amount in the
* input number.
*
* @param {string} [decimalPoint]
* The decimal point, defaults to the one given in the lang options, or
* a dot.
*
* @param {string} [thousandsSep]
* The thousands separator, defaults to the one given in the lang
* options, or a space character.
*
* @return {string}
* The formatted number.
*/
function numberFormat(number, decimals, decimalPoint, thousandsSep) {
number = +number || 0;
decimals = +decimals;
let ret, fractionDigits, [mantissa, exp] = number.toString().split('e').map(Number);
const lang = this?.options?.lang || defaultOptions.lang, origDec = (number.toString().split('.')[1] || '').split('e')[0].length, firstDecimals = decimals, options = {};
decimalPoint ?? (decimalPoint = lang.decimalPoint);
thousandsSep ?? (thousandsSep = lang.thousandsSep);
if (decimals === -1) {
// Preserve decimals. Not huge numbers (#3793).
decimals = Math.min(origDec, 20);
}
else if (!isNumber(decimals)) {
decimals = 2;
}
else if (decimals && exp < 0) {
// Expose decimals from exponential notation (#7042)
fractionDigits = decimals + exp;
if (fractionDigits >= 0) {
// Remove too small part of the number while keeping the notation
mantissa = +mantissa.toExponential(fractionDigits).split('e')[0];
decimals = fractionDigits;
}
else {
// `fractionDigits < 0`
mantissa = Math.floor(mantissa);
if (decimals < 20) {
// Use number instead of exponential notation (#7405)
number = +(mantissa * Math.pow(10, exp)).toFixed(decimals);
}
else {
// Or zero
number = 0;
}
exp = 0;
}
}
if (exp) {
decimals ?? (decimals = 2);
number = mantissa;
}
if (isNumber(decimals) && decimals >= 0) {
options.minimumFractionDigits = decimals;
options.maximumFractionDigits = decimals;
}
if (thousandsSep === '') {
options.useGrouping = false;
}
const hasSeparators = thousandsSep || decimalPoint, locale = hasSeparators ?
'en' :
(this?.locale || lang.locale || pageLang), cacheKey = JSON.stringify(options) + locale, nf = numberFormatCache[cacheKey] ?? (numberFormatCache[cacheKey] = new Intl.NumberFormat(locale, options));
ret = nf.format(number);
// If thousandsSep or decimalPoint are set, fall back to using English
// format with string replacement for the separators.
if (hasSeparators) {
ret = ret
// Preliminary step to avoid re-swapping (#22402)
.replace(/([,\.])/g, '_$1')
.replace(/_\,/g, thousandsSep ?? ',')
.replace('_.', decimalPoint ?? '.');
}
if (
// Remove signed zero (#20564)
(!decimals && +ret === 0) ||
// Small numbers, no decimals (#14023)
(exp < 0 && !firstDecimals)) {
ret = '0';
}
if (exp && +ret !== 0) {
ret += 'e' + (exp < 0 ? '' : '+') + exp;
}
return ret;
}
/* *
*
* Default Export
*
* */
const Templating = {
dateFormat,
format,
helpers,
numberFormat
};
export default Templating;
/* *
* API Declarations
* */
/**
* @interface Highcharts.Templating
*
* The Highcharts.Templating interface provides a structure for defining
* helpers. Helpers can be used as conditional blocks or functions within
* expressions. Highcharts includes several built-in helpers and supports
* the addition of custom helpers.
*
* @see [More information](
* https://www.highcharts.com/docs/chart-concepts/templating#helpers)
*
* @example
* // Define a custom helper to return the absolute value of a number
* Highcharts.Templating.helpers.abs = value => Math.abs(value);
*
* // Usage in a format string
* format: 'Absolute value: {abs point.y}'
*
* @name Highcharts.Templating#helpers
* @type {Record<string, Function>}
*/
(''); // Keeps doclets above in file