packages/charts/src/utils/common.tsx (500 lines of code) (raw):
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { ComponentType, ReactNode } from 'react';
import React, { isValidElement } from 'react';
import type { $Values } from 'utility-types';
import { isPrimitive } from 'utility-types';
import { v1 as uuidv1 } from 'uuid';
import type { AdditiveNumber } from './accessor';
import type { Point } from './point';
import type { PrimitiveValue } from '../chart_types/partition_chart/layout/utils/group_by_rollup';
import type { Color } from '../common/colors';
import { Colors } from '../common/colors';
import type { Degrees, Radian } from '../common/geometry';
import type { BaseDatum } from '../specs';
/** @public */
export const Position = Object.freeze({
Top: 'top' as const,
Bottom: 'bottom' as const,
Left: 'left' as const,
Right: 'right' as const,
});
/** @public */
export type Position = $Values<typeof Position>;
/** @public */
export const LayoutDirection = Object.freeze({
Horizontal: 'horizontal' as const,
Vertical: 'vertical' as const,
});
/** @public */
export type LayoutDirection = $Values<typeof LayoutDirection>;
/**
* Color variants that are unique to `@elastic/charts`. These go beyond the standard
* static color allocations.
* @public
*/
export const ColorVariant = Object.freeze({
/**
* Uses series color. Rather than setting a static color, this will use the
* default series color for a given series.
*/
Series: '__use__series__color__' as const,
/**
* Uses empty color, similar to transparent.
*/
None: '__use__empty__color__' as const,
/**
* Computes best color based on background contrast
*/
Adaptive: '__use__adaptive__color__' as const,
});
/** @public */
export type ColorVariant = $Values<typeof ColorVariant>;
/** @public */
export const HorizontalAlignment = Object.freeze({
Center: 'center' as const,
Right: Position.Right,
Left: Position.Left,
/**
* Aligns to near side of axis depending on position
*
* Examples:
* - Left Axis, `Near` will push the label to the `Right`, _near_ the axis
* - Right Axis, `Near` will push the axis labels to the `Left`
* - Top/Bottom Axes, `Near` will default to `Center`
*/
Near: 'near' as const,
/**
* Aligns to far side of axis depending on position
*
* Examples:
* - Left Axis, `Far` will push the label to the `Left`, _far_ from the axis
* - Right Axis, `Far` will push the axis labels to the `Right`
* - Top/Bottom Axes, `Far` will default to `Center`
*/
Far: 'far' as const,
});
/**
* Horizontal text alignment
* @public
*/
export type HorizontalAlignment = $Values<typeof HorizontalAlignment>;
/** @public */
export const VerticalAlignment = Object.freeze({
Middle: 'middle' as const,
Top: Position.Top,
Bottom: Position.Bottom,
/**
* Aligns to near side of axis depending on position
*
* Examples:
* - Top Axis, `Near` will push the label to the `Right`, _near_ the axis
* - Bottom Axis, `Near` will push the axis labels to the `Left`
* - Left/Right Axes, `Near` will default to `Middle`
*/
Near: 'near' as const,
/**
* Aligns to far side of axis depending on position
*
* Examples:
* - Top Axis, `Far` will push the label to the `Top`, _far_ from the axis
* - Bottom Axis, `Far` will push the axis labels to the `Bottom`
* - Left/Right Axes, `Far` will default to `Middle`
*/
Far: 'far' as const,
});
/**
* Vertical text alignment
* @public
*/
export type VerticalAlignment = $Values<typeof VerticalAlignment>;
/** @public */
export type Datum = any; // unknown;
/** @public */
export type Rotation = 0 | 90 | -90 | 180;
/** @public */
export type Rendering = 'canvas' | 'svg';
/** @public */
export type StrokeStyle = Color; // now narrower than string | CanvasGradient | CanvasPattern
/** @internal */
export function compareByValueAsc(a: number | string, b: number | string): number {
return a > b ? 1 : a < b ? -1 : 0;
}
/** @internal */
export function clamp(value: number, lowerBound: number, upperBound: number): number {
return Math.min(Math.max(value, lowerBound), upperBound);
}
/**
* Returns color given any color variant
*
* @internal
*/
export function getColorFromVariant(seriesColor: Color, color?: Color | ColorVariant): Color {
if (color === ColorVariant.Series) {
return seriesColor;
}
if (color === ColorVariant.None) {
return Colors.Transparent.keyword;
}
return color || seriesColor;
}
/** @internal */
export const degToRad = (angle: Degrees): Radian => (angle / 180) * Math.PI;
/** @internal */
export const radToDeg = (radian: Radian): Degrees => (radian * 180) / Math.PI;
/**
* This function returns a function to generate ids.
* This can be used to generate unique, but predictable ids to pair labels
* with their inputs. It takes an optional prefix as a parameter. If you don't
* specify it, it generates a random id prefix. If you specify a custom prefix
* it should begin with an letter to be HTML4 compliant.
* @internal
*/
export function htmlIdGenerator(idPrefix?: string) {
const prefix = idPrefix || `i${uuidv1()}`;
return (suffix?: string) => `${prefix}_${suffix || uuidv1()}`;
}
/**
* Helper function to identify never type for conditionals
* See https://github.com/microsoft/TypeScript/issues/31751#issuecomment-498526919
* @internal
*/
export type ExtendsNever<T, Y, N> = [T] extends [never] ? Y : N;
/**
* Replaces all properties on any type as optional, includes nested types
*
* example:
* ```ts
* interface Person {
* name: string;
* age?: number;
* spouse: Person;
* children: Person[];
* }
* type PartialPerson = RecursivePartial<Person>;
* // results in
* interface PartialPerson {
* name?: string;
* age?: number;
* spouse?: RecursivePartial<Person>;
* children?: RecursivePartial<Person>[]
* }
* ```
* @public
*/
export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends NonAny[] // checks for nested any[]
? T[P]
: T[P] extends ReadonlyArray<NonAny> // checks for nested ReadonlyArray<any>
? T[P]
: T[P] extends (infer U)[]
? RecursivePartial<U>[]
: T[P] extends ReadonlyArray<infer U> // eslint-disable-line @typescript-eslint/array-type
? ReadonlyArray<RecursivePartial<U>> // eslint-disable-line @typescript-eslint/array-type
: T[P] extends Set<infer V> // checks for Sets
? Set<RecursivePartial<V>>
: T[P] extends Map<infer K, infer V> // checks for Maps
? Map<K, RecursivePartial<V>>
: T[P] extends NonAny // checks for primitive values
? T[P]
: IsUnknown<T[P], 1, 0> extends 1
? T[P]
: RecursivePartial<T[P]>; // recurse for all non-array and non-primitive values
};
/**
* return True if T is `any`, otherwise return False
* @public
*/
export type IsAny<T, True, False = never> = True | False extends (T extends never ? True : False) ? True : False;
/**
* return True if T is `unknown`, otherwise return False
* @public
*/
export type IsUnknown<T, True, False = never> = unknown extends T ? IsAny<T, False, True> : False;
/** @public */
export type NonAny = number | boolean | string | symbol | null;
/** @public */
export interface MergeOptions {
/**
* Includes all available keys of every provided partial at a given level.
* This is opposite to normal behavior, which only uses keys from the base
* object to merge values.
*
* @defaultValue false
*/
mergeOptionalPartialValues?: boolean;
/**
* Merges Maps same as objects. By default this is disabled and Maps are replaced on the base
* with a defined Map on any partial.
*
* @defaultValue false
*/
mergeMaps?: boolean;
}
/** @internal */
export function getPartialValue<T>(base: T, partial?: RecursivePartial<T>, partials: RecursivePartial<T>[] = []): T {
const partialWithValue = partial !== undefined ? partial : partials.find((v) => v !== undefined);
return partialWithValue !== undefined ? (partialWithValue as T) : base;
}
/**
* Returns all top-level keys from one or more objects
* @param object - first object to get keys
* @param objects
* @internal
*/
export function getAllKeys(object?: any, objects: any[] = []): Set<any> {
return new Set(
[object, ...objects].filter(Boolean).reduce((keys: any[], obj) => {
if (obj && typeof obj === 'object') {
const newKeys = obj instanceof Map ? obj.keys() : Object.keys(obj);
keys.push(...newKeys);
}
return keys;
}, []),
);
}
/** @internal */
export function isArrayOrSet<T>(value: any): value is Array<T> | Set<T> {
return Array.isArray(value) || value instanceof Set;
}
/** @internal */
export function isNil(value: any): value is null | undefined {
return value === null || value === undefined;
}
/** @internal */
export function hasPartialObjectToMerge<T>(
base: T,
partial?: RecursivePartial<T>,
additionalPartials: RecursivePartial<T>[] = [],
): boolean {
if (isArrayOrSet(base)) {
return false;
}
if (typeof base === 'object' && base !== null) {
if (typeof partial === 'object' && !isArrayOrSet(partial) && partial !== null) {
return true;
}
return additionalPartials.some((p) => typeof p === 'object' && !Array.isArray(p));
}
return false;
}
/** @internal */
export function shallowClone(value: any) {
if (Array.isArray(value)) {
return [...value];
}
if (value instanceof Set) {
return new Set(value);
}
if (typeof value === 'object' && value !== null) {
if (value instanceof Map) {
return new Map(value.entries());
}
return { ...value };
}
return value;
}
function isReactNode(el: any): el is ReactNode {
return isNil(el) || isPrimitive(el) || isValidElement(el);
}
function isReactComponent<P extends Record<string, any>>(el: any): el is ComponentType<P> {
return !isReactNode(el);
}
/**
* Renders simple react node or react component with props
* @internal
*/
export function renderWithProps<P extends Record<string, any>>(El: ReactNode | ComponentType<P>, props: P): ReactNode {
return isReactComponent<P>(El) ? React.createElement(El, props) : El;
}
/**
* Aligns component children to the correct output type
* @internal */
export function renderComplexChildren(children: ReactNode): JSX.Element {
return (() => <>{children}</>)();
}
/**
* Merges values of a partial structure with a base structure.
*
* @note No nested array merging
*
* @param base structure to be duplicated, must have all props of `partial`
* @param partial structure to override values from base
* @param options options to control merge behaviour
* @param additionalPartials partials to be used before base and after partial
*
* @returns new base structure with updated partial values
* @internal
*/
export function mergePartial<T>(
base: T,
partial?: RecursivePartial<T>,
options: MergeOptions = {},
additionalPartials: RecursivePartial<T>[] = [],
): T {
const baseClone = shallowClone(base);
if (hasPartialObjectToMerge(base, partial, additionalPartials)) {
const mapCondition = !(baseClone instanceof Map) || options.mergeMaps;
const partialKeys = getAllKeys(partial, additionalPartials);
if (partialKeys.size > 0 && (options.mergeOptionalPartialValues ?? true) && mapCondition) {
partialKeys.forEach((key) => {
if (baseClone instanceof Map) {
if (!baseClone.has(key)) {
baseClone.set(
key,
(partial as any).get(key) !== undefined
? (partial as any).get(key)
: additionalPartials.find((v: any) => v.get(key) !== undefined) || new Map().get(key),
);
}
} else if (!(key in baseClone)) {
baseClone[key] =
(partial as any)?.[key] !== undefined
? (partial as any)[key]
: (additionalPartials.find((v: any) => v?.[key] !== undefined) ?? ({} as any))[key];
}
});
}
if (baseClone instanceof Map) {
if (options.mergeMaps) {
return [...baseClone.keys()].reduce((newBase: Map<any, any>, key) => {
const partialValue = partial && (partial as any).get(key);
const partialValues = additionalPartials.map((v) =>
typeof v === 'object' && v instanceof Map ? v.get(key) : undefined,
);
const baseValue = (base as any).get(key);
newBase.set(key, mergePartial(baseValue, partialValue, options, partialValues));
return newBase;
}, baseClone as any);
}
if (partial !== undefined) {
return partial as any;
}
const additional = additionalPartials.find((p: any) => p !== undefined);
if (additional) {
return additional as any;
}
return baseClone as any;
}
return Object.keys(baseClone).reduce((newBase, key) => {
const partialValue = partial && (partial as any)[key];
const partialValues = additionalPartials.map((v) => (typeof v === 'object' ? (v as any)[key] : undefined));
const baseValue = (base as any)[key];
newBase[key] = mergePartial(baseValue, partialValue, options, partialValues);
return newBase;
}, baseClone);
}
return getPartialValue<T>(baseClone, partial, additionalPartials);
}
/** @public */
export type ValueFormatter<T = number> = (value: T) => string;
/** @public */
export type ValueAccessor<D extends BaseDatum = Datum> = (d: D) => AdditiveNumber;
/** @public */
export type LabelAccessor<T = PrimitiveValue> = (value: T) => string;
/** @public */
export type ShowAccessor = (value: PrimitiveValue) => boolean;
/**
* Returns planar distance bewtween two points
* @internal
*/
export function getDistance(a: Point, b: Point): number {
return Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2));
}
/** @internal */
export function stringifyNullsUndefined(value?: PrimitiveValue): string | number {
if (value === undefined) {
return 'undefined';
}
if (value === null) {
return 'null';
}
return value;
}
/**
* Determines if an array has all unique values
*
* examples:
* ```ts
* isUniqueArray([1, 2]) // => true
* isUniqueArray([1, 1, 2]) // => false
* isUniqueArray([{ n: 1 }, { n: 1 }, { n: 2 }], ({ n }) => n) // => false
* ```
*
* @internal
* @param {B[]} arr
* @param {(d:B)=>T} extractor? extract the value from B
*/
export function isUniqueArray<B, T>(arr: B[], extractor?: (value: B) => T) {
const values = new Set<B | T>();
return (function isUniqueArrayFn() {
return arr.every((v) => {
const value = extractor ? extractor(v) : v;
if (values.has(value)) {
return false;
}
values.add(value);
return true;
});
})();
}
/**
* Sorts array of numbers
* @internal
*/
export function sortNumbers<T extends any[]>(arr: T, descending = false): T {
return arr.slice().sort(descending ? (a, b) => b - a : (a, b) => a - b) as T;
}
type SortTestFn = (n1?: number, n2?: number) => boolean;
/**
* Returns true if array of numbers is sorted
* @internal
*/
export function isSorted<T extends number[]>(
arr: T,
options?: { allowDuplicates?: boolean; order?: 'ascending' | 'descending' },
): boolean {
if (arr.length <= 1) return true;
const ascending = options?.order === 'ascending' ? true : options?.order === 'descending' ? false : arr[0]! < arr[1]!;
const isOrderedPair: SortTestFn =
options?.allowDuplicates ?? false
? ascending
? (n1 = NaN, n2 = NaN) => n1 <= n2
: (n1 = NaN, n2 = NaN) => n1 >= n2
: ascending
? (n1 = NaN, n2 = NaN) => n1 < n2
: (n1 = NaN, n2 = NaN) => n1 > n2;
for (let i = 0; i < arr.length - 1; i++) {
if (!isOrderedPair(arr[i], arr[i + 1])) return false;
}
return true;
}
/**
* Returns true if _most_ chars in a string are rtl, exluding spaces and numbers
* @internal
*/
export function isRTLString(s: string, ratio: number = 0.5) {
const stripped = s.replaceAll(/[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]|\s|\d/gi, '');
return stripped.length / s.replaceAll(/\s|\d/gi, '').length < ratio;
}
/** @internal */
export function hasMostlyRTLItems<T extends string>(items: T[], ratio: number = 0.5): boolean {
const filteredItems = items.filter(Boolean);
const rtlItemCount = filteredItems.filter((s) => isRTLString(s)).length;
return rtlItemCount / filteredItems.length > ratio;
}
/**
* Returns defined value type if not null nor undefined
*
* @internal
*/
export function isDefined<T>(value?: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
/**
* Returns defined value type if value from getter function is not null nor undefined
*
* **IMPORTANT**: You must provide an accurate typeCheck function that will filter out _EVERY_
* item in the array that is not of type `T`. If not, the type check will override the
* type as `T` which may be incorrect.
*
* @internal
*/
export function isDefinedFrom<T>(typeCheck: (value: RecursivePartial<T>) => boolean) {
return (value?: RecursivePartial<T>): value is NonNullable<T> => {
if (value === undefined) {
return false;
}
try {
return typeCheck(value);
} catch {
return false;
}
};
}
/**
* Returns rounded number to given decimals
*
* @internal
*/
export const round = (value: number, fractionDigits = 0): number => {
const precision = Math.pow(10, Math.max(fractionDigits, 0));
const scaledValue = Math.floor(value * precision);
return scaledValue / precision;
};
/**
* Returns rounded number to nearest/lowest/highest interval
*
* @internal
*/
export const roundTo = (
value: number,
interval: number,
options: { min?: number; max?: number; type?: 'round' | 'ceil' | 'floor' } = {},
): number => {
const roundedValue = Math[options.type ?? 'round'](value / interval) * interval;
return clamp(roundedValue, options?.min ?? -Infinity, options?.max ?? Infinity);
};
/**
* Get number/percentage value from string
*
* i.e. `'90%'` with relative value of `100` returns `90`
* @internal
*/
export function getPercentageValue<T>(ratio: string | number, relativeValue: number, defaultValue: T): number | T {
if (typeof ratio === 'number') {
return Math.abs(ratio);
}
const ratioStr = ratio.trim();
if (/\d+%$/.test(ratioStr)) {
const percentage = Math.abs(Number.parseInt(ratioStr.slice(0, -1), 10));
return relativeValue * (percentage / 100);
}
const num = Number.parseFloat(ratioStr);
return Number.isFinite(num) ? Math.abs(num) : defaultValue;
}
/**
* Predicate function, eg. to be called with [].filter, to keep distinct values
* @example [1, 2, 4, 2, 4, 0, 3, 2].filter(keepDistinct) ==> [1, 2, 4, 0, 3]
* @internal
*/
export function keepDistinct<T>(d: T, i: number, a: T[]): boolean {
return a.indexOf(d) === i;
}
/**
* Return an object which keys are values of an object and the value is the
* static one provided
* @public
*/
export function toEntries<T extends Record<string, string>, S>(
array: T[],
accessor: keyof T,
staticValue: S,
): Record<string, S> {
return array.reduce<Record<string, S>>((acc, curr) => {
acc[curr[accessor]] = staticValue;
return acc;
}, {});
}
/**
* Safely format values with error handling
* @internal
*/
export function safeFormat<V = any>(value: V, formatter?: (value: V) => string): string {
if (formatter) {
try {
return formatter(value);
} catch {
// fallthrough
}
}
return `${value}`;
}
/** @internal */
export const range = (from: number, to: number, step: number): number[] =>
Array.from({ length: Math.abs(Math.round((to - from) / (step || 1))) }, (_, i) => from + i * step);
const oppositeAlignmentMap: Record<string, HorizontalAlignment | VerticalAlignment> = {
[HorizontalAlignment.Left]: HorizontalAlignment.Right,
[HorizontalAlignment.Right]: HorizontalAlignment.Left,
[VerticalAlignment.Top]: VerticalAlignment.Bottom,
[VerticalAlignment.Bottom]: VerticalAlignment.Top,
};
/** @internal */
export function getOppositeAlignment<A extends HorizontalAlignment | VerticalAlignment>(alignment: A): A {
return (oppositeAlignmentMap[alignment] as A) ?? alignment;
}
/** @internal */
export function isFiniteNumber(value: unknown): value is number {
return Number.isFinite(value);
}
/** @internal */
export function isNonNullablePrimitiveValue(value: unknown): value is NonNullable<PrimitiveValue> {
return typeof value === 'string' || typeof value === 'number';
}
/**
* Strips all undefined properties from object
* @internal
*/
export function stripUndefined<R extends Record<string, unknown>>(source: R): R {
return Object.keys(source).reduce((acc, key) => {
const val = source[key];
if (val !== undefined) {
// @ts-ignore - building new R from {}
acc[key] = val;
}
return acc;
}, {} as R);
}
/**
* Returns `Array.filter` callback for values between a min and max
* @internal
*/
export const isBetween = (min: number, max: number, exclusive = false): ((n: number) => boolean) =>
exclusive ? (n) => n < max && n > min : (n) => n <= max && n >= min;
/**
* Returns `Array.filter` callback for values between two unordered values
* @internal
*/
export const isWithinRange = (r: [number, number], exclusive = false) => {
const [min, max] = sortNumbers(r);
return isBetween(min, max, exclusive);
};
/**
* Returns utilities for a given range from start to end
* @internal
*/
export const inRange = (start: number, end: number, exclusive = false) => {
const diff = Math.abs(start - end);
const [min, max] = sortNumbers([start, end]);
const isHalfFromMin = isBetween(min, max - diff / 2, exclusive);
const isHalfFromMax = isBetween(min + diff / 2, max, exclusive);
const isWithin = isBetween(min, max, exclusive);
return {
/**
* Returns true if values are within the first half of range, from start halfway to end
*/
firstHalf: (n: number) => {
return start === min ? isHalfFromMin(n) : isHalfFromMax(n);
},
/**
* Returns true if values are within the last half of range, from end halfway to start
*/
lastHalf: (n: number) => {
return end === max ? isHalfFromMax(n) : isHalfFromMin(n);
},
/**
* Returns true if value is within the entire range
*/
within: (n: number) => {
return isWithin(n);
},
};
};
/**
* Returns `Array.reduce` callback to clamp values and remove duplicates
* @internal
*/
export const clampAll = (
min: number,
max: number,
): [callbackfn: (acc: number[], value: number) => number[], initialAcc: number[]] => {
const seen = new Set<number>();
return [
(acc: number[], n: number) => {
const clampValue = clamp(n, min, max);
if (!seen.has(clampValue)) acc.push(clampValue);
seen.add(clampValue);
return acc;
},
[],
];
};