diff --git a/src/api/chart-api.ts b/src/api/chart-api.ts index 75a6cd6541..3501cef922 100644 --- a/src/api/chart-api.ts +++ b/src/api/chart-api.ts @@ -1,3 +1,4 @@ +/// import { ChartWidget, MouseEventParamsImpl, MouseEventParamsImplSupplier } from '../gui/chart-widget'; import { assert, ensure, ensureDefined } from '../helpers/assertions'; @@ -264,6 +265,16 @@ export class ChartApi implements IChartApiBase, Da } public applyOptions(options: DeepPartial>): void { + if (process.env.NODE_ENV === 'development') { + const colorSpace = options.layout?.colorSpace; + if (colorSpace !== undefined && colorSpace !== this.options().layout.colorSpace) { + throw new Error(`colorSpace option should not be changed once the chart has been created.`); + } + const colorParsers = options.layout?.colorParsers; + if (colorParsers !== undefined && colorParsers !== this.options().layout.colorParsers) { + throw new Error(`colorParsers option should not be changed once the chart has been created.`); + } + } this._chartWidget.applyOptions(toInternalOptions(options)); } diff --git a/src/api/options/layout-options-defaults.ts b/src/api/options/layout-options-defaults.ts index 8729f70314..6c96af426a 100644 --- a/src/api/options/layout-options-defaults.ts +++ b/src/api/options/layout-options-defaults.ts @@ -16,4 +16,6 @@ export const layoutOptionsDefaults: LayoutOptions = { separatorHoverColor: 'rgba(178, 181, 189, 0.2)', }, attributionLogo: true, + colorSpace: 'srgb', + colorParsers: [], }; diff --git a/src/gui/attribution-logo-widget.ts b/src/gui/attribution-logo-widget.ts index 71b807dfbb..b54ee39b0a 100644 --- a/src/gui/attribution-logo-widget.ts +++ b/src/gui/attribution-logo-widget.ts @@ -1,5 +1,3 @@ -import { colorStringToGrayscale } from '../helpers/color'; - import { IChartWidgetBase } from './chart-widget'; type LogoTheme = 'dark' | 'light'; @@ -44,7 +42,12 @@ export class AttributionLogoWidget { } private _themeToUse(): LogoTheme { - return colorStringToGrayscale(this._chart.options()['layout'].textColor) > 160 ? 'dark' : 'light'; + return this._chart + .model() + .colorParser() + .colorStringToGrayscale(this._chart.options()['layout'].textColor) > 160 + ? 'dark' + : 'light'; } private _shouldBeVisible(): boolean { diff --git a/src/gui/pane-widget.ts b/src/gui/pane-widget.ts index 853f7cd622..89744efd85 100644 --- a/src/gui/pane-widget.ts +++ b/src/gui/pane-widget.ts @@ -484,9 +484,13 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { this._rightPriceAxisWidget.paint(type); } + const canvasOptions: CanvasRenderingContext2DSettings = { + colorSpace: this._chart.options().layout.colorSpace, + }; + if (type !== InvalidationLevel.Cursor) { this._canvasBinding.applySuggestedBitmapSize(); - const target = tryCreateCanvasRenderingTarget2D(this._canvasBinding); + const target = tryCreateCanvasRenderingTarget2D(this._canvasBinding, canvasOptions); if (target !== null) { target.useBitmapCoordinateSpace((scope: BitmapCoordinatesRenderingScope) => { this._drawBackground(scope); @@ -501,7 +505,7 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { } this._topCanvasBinding.applySuggestedBitmapSize(); - const topTarget = tryCreateCanvasRenderingTarget2D(this._topCanvasBinding); + const topTarget = tryCreateCanvasRenderingTarget2D(this._topCanvasBinding, canvasOptions); if (topTarget !== null) { topTarget.useBitmapCoordinateSpace(({ context: ctx, bitmapSize }: BitmapCoordinatesRenderingScope) => { ctx.clearRect(0, 0, bitmapSize.width, bitmapSize.height); diff --git a/src/gui/price-axis-stub.ts b/src/gui/price-axis-stub.ts index 9023b2890b..164b05fa49 100644 --- a/src/gui/price-axis-stub.ts +++ b/src/gui/price-axis-stub.ts @@ -101,7 +101,9 @@ export class PriceAxisStub implements IDestroyable { this._invalidated = false; this._canvasBinding.applySuggestedBitmapSize(); - const target = tryCreateCanvasRenderingTarget2D(this._canvasBinding); + const target = tryCreateCanvasRenderingTarget2D(this._canvasBinding, { + colorSpace: this._options.layout.colorSpace, + }); if (target !== null) { target.useBitmapCoordinateSpace((scope: BitmapCoordinatesRenderingScope) => { this._drawBackground(scope); diff --git a/src/gui/price-axis-widget.ts b/src/gui/price-axis-widget.ts index a1c613228d..c6b0c7fba4 100644 --- a/src/gui/price-axis-widget.ts +++ b/src/gui/price-axis-widget.ts @@ -250,7 +250,11 @@ export class PriceAxisWidget implements IDestroyable { let tickMarkMaxWidth = 0; const rendererOptions = this.rendererOptions(); - const ctx = ensureNotNull(this._canvasBinding.canvasElement.getContext('2d')); + const ctx = ensureNotNull( + this._canvasBinding.canvasElement.getContext('2d', { + colorSpace: this._pane.chart().options().layout.colorSpace, + }) + ); ctx.save(); const tickMarks = this._priceScale.marks(); @@ -345,11 +349,13 @@ export class PriceAxisWidget implements IDestroyable { if (this._size === null) { return; } - + const canvasOptions: CanvasRenderingContext2DSettings = { + colorSpace: this._pane.chart().options().layout.colorSpace, + }; if (type !== InvalidationLevel.Cursor) { this._alignLabels(); this._canvasBinding.applySuggestedBitmapSize(); - const target = tryCreateCanvasRenderingTarget2D(this._canvasBinding); + const target = tryCreateCanvasRenderingTarget2D(this._canvasBinding, canvasOptions); if (target !== null) { target.useBitmapCoordinateSpace((scope: BitmapCoordinatesRenderingScope) => { this._drawBackground(scope); @@ -363,7 +369,7 @@ export class PriceAxisWidget implements IDestroyable { } this._topCanvasBinding.applySuggestedBitmapSize(); - const topTarget = tryCreateCanvasRenderingTarget2D(this._topCanvasBinding); + const topTarget = tryCreateCanvasRenderingTarget2D(this._topCanvasBinding, canvasOptions); if (topTarget !== null) { topTarget.useBitmapCoordinateSpace(({ context: ctx, bitmapSize }: BitmapCoordinatesRenderingScope) => { ctx.clearRect(0, 0, bitmapSize.width, bitmapSize.height); diff --git a/src/gui/time-axis-widget.ts b/src/gui/time-axis-widget.ts index e0a4494bc2..1153716c8d 100644 --- a/src/gui/time-axis-widget.ts +++ b/src/gui/time-axis-widget.ts @@ -298,10 +298,12 @@ export class TimeAxisWidget implements MouseEventHandlers, IDestr if (type === InvalidationLevel.None) { return; } - + const canvasOptions: CanvasRenderingContext2DSettings = { + colorSpace: this._options.colorSpace, + }; if (type !== InvalidationLevel.Cursor) { this._canvasBinding.applySuggestedBitmapSize(); - const target = tryCreateCanvasRenderingTarget2D(this._canvasBinding); + const target = tryCreateCanvasRenderingTarget2D(this._canvasBinding, canvasOptions); if (target !== null) { target.useBitmapCoordinateSpace((scope: BitmapCoordinatesRenderingScope) => { this._drawBackground(scope); @@ -324,7 +326,7 @@ export class TimeAxisWidget implements MouseEventHandlers, IDestr } this._topCanvasBinding.applySuggestedBitmapSize(); - const topTarget = tryCreateCanvasRenderingTarget2D(this._topCanvasBinding); + const topTarget = tryCreateCanvasRenderingTarget2D(this._topCanvasBinding, canvasOptions); if (topTarget !== null) { topTarget.useBitmapCoordinateSpace(({ context: ctx, bitmapSize }: BitmapCoordinatesRenderingScope) => { ctx.clearRect(0, 0, bitmapSize.width, bitmapSize.height); diff --git a/src/helpers/color.ts b/src/helpers/color.ts deleted file mode 100644 index 85620de092..0000000000 --- a/src/helpers/color.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { Nominal } from './nominal'; - -/** - * Red component of the RGB color value - * The valid values are integers in range [0, 255] - */ -type RedComponent = Nominal; - -/** - * Green component of the RGB color value - * The valid values are integers in range [0, 255] - */ -type GreenComponent = Nominal; - -/** - * Blue component of the RGB color value - * The valid values are integers in range [0, 255] - */ -type BlueComponent = Nominal; - -/** - * Alpha component of the RGBA color value - * The valid values are integers in range [0, 1] - */ -type AlphaComponent = Nominal; - -type Rgba = [RedComponent, GreenComponent, BlueComponent, AlphaComponent]; - -/** - * Note this object should be explicitly marked as public so that dts-bundle-generator does not mangle the property names. - * - * @public - * @see https://developer.mozilla.org/en-US/docs/Web/CSS/color_value - */ -const namedColorRgbHexStrings: Record = { - // The order of properties in this Record is not important for the internal logic. - // It's just GZIPped better when props follows this order. - // Please add new colors to the end of the record. - - khaki: '#f0e68c', - azure: '#f0ffff', - aliceblue: '#f0f8ff', - ghostwhite: '#f8f8ff', - gold: '#ffd700', - goldenrod: '#daa520', - gainsboro: '#dcdcdc', - gray: '#808080', - green: '#008000', - honeydew: '#f0fff0', - floralwhite: '#fffaf0', - lightblue: '#add8e6', - lightcoral: '#f08080', - lemonchiffon: '#fffacd', - hotpink: '#ff69b4', - lightyellow: '#ffffe0', - greenyellow: '#adff2f', - lightgoldenrodyellow: '#fafad2', - limegreen: '#32cd32', - linen: '#faf0e6', - lightcyan: '#e0ffff', - magenta: '#f0f', - maroon: '#800000', - olive: '#808000', - orange: '#ffa500', - oldlace: '#fdf5e6', - mediumblue: '#0000cd', - transparent: '#0000', - lime: '#0f0', - lightpink: '#ffb6c1', - mistyrose: '#ffe4e1', - moccasin: '#ffe4b5', - midnightblue: '#191970', - orchid: '#da70d6', - mediumorchid: '#ba55d3', - mediumturquoise: '#48d1cc', - orangered: '#ff4500', - royalblue: '#4169e1', - powderblue: '#b0e0e6', - red: '#f00', - coral: '#ff7f50', - turquoise: '#40e0d0', - white: '#fff', - whitesmoke: '#f5f5f5', - wheat: '#f5deb3', - teal: '#008080', - steelblue: '#4682b4', - bisque: '#ffe4c4', - aquamarine: '#7fffd4', - aqua: '#0ff', - sienna: '#a0522d', - silver: '#c0c0c0', - springgreen: '#00ff7f', - antiquewhite: '#faebd7', - burlywood: '#deb887', - brown: '#a52a2a', - beige: '#f5f5dc', - chocolate: '#d2691e', - chartreuse: '#7fff00', - cornflowerblue: '#6495ed', - cornsilk: '#fff8dc', - crimson: '#dc143c', - cadetblue: '#5f9ea0', - tomato: '#ff6347', - fuchsia: '#f0f', - blue: '#00f', - salmon: '#fa8072', - blanchedalmond: '#ffebcd', - slateblue: '#6a5acd', - slategray: '#708090', - thistle: '#d8bfd8', - tan: '#d2b48c', - cyan: '#0ff', - darkblue: '#00008b', - darkcyan: '#008b8b', - darkgoldenrod: '#b8860b', - darkgray: '#a9a9a9', - blueviolet: '#8a2be2', - black: '#000', - darkmagenta: '#8b008b', - darkslateblue: '#483d8b', - darkkhaki: '#bdb76b', - darkorchid: '#9932cc', - darkorange: '#ff8c00', - darkgreen: '#006400', - darkred: '#8b0000', - dodgerblue: '#1e90ff', - darkslategray: '#2f4f4f', - dimgray: '#696969', - deepskyblue: '#00bfff', - firebrick: '#b22222', - forestgreen: '#228b22', - indigo: '#4b0082', - ivory: '#fffff0', - lavenderblush: '#fff0f5', - feldspar: '#d19275', - indianred: '#cd5c5c', - lightgreen: '#90ee90', - lightgrey: '#d3d3d3', - lightskyblue: '#87cefa', - lightslategray: '#789', - lightslateblue: '#8470ff', - snow: '#fffafa', - lightseagreen: '#20b2aa', - lightsalmon: '#ffa07a', - darksalmon: '#e9967a', - darkviolet: '#9400d3', - mediumpurple: '#9370d8', - mediumaquamarine: '#66cdaa', - skyblue: '#87ceeb', - lavender: '#e6e6fa', - lightsteelblue: '#b0c4de', - mediumvioletred: '#c71585', - mintcream: '#f5fffa', - navajowhite: '#ffdead', - navy: '#000080', - olivedrab: '#6b8e23', - palevioletred: '#d87093', - violetred: '#d02090', - yellow: '#ff0', - yellowgreen: '#9acd32', - lawngreen: '#7cfc00', - pink: '#ffc0cb', - paleturquoise: '#afeeee', - palegoldenrod: '#eee8aa', - darkolivegreen: '#556b2f', - darkseagreen: '#8fbc8f', - darkturquoise: '#00ced1', - peachpuff: '#ffdab9', - deeppink: '#ff1493', - violet: '#ee82ee', - palegreen: '#98fb98', - mediumseagreen: '#3cb371', - peru: '#cd853f', - saddlebrown: '#8b4513', - sandybrown: '#f4a460', - rosybrown: '#bc8f8f', - purple: '#800080', - seagreen: '#2e8b57', - seashell: '#fff5ee', - papayawhip: '#ffefd5', - mediumslateblue: '#7b68ee', - plum: '#dda0dd', - mediumspringgreen: '#00fa9a', -}; - -function normalizeRgbComponent(component: number): T { - if (component < 0) { - return 0 as T; - } - if (component > 255) { - return 255 as T; - } - // NaN values are treated as 0 - return (Math.round(component) || 0) as T; -} - -function normalizeAlphaComponent(component: AlphaComponent): AlphaComponent { - if (component <= 0 || component > 1) { - return Math.min(Math.max(component, 0), 1) as AlphaComponent; - } - // limit the precision of all numbers to at most 4 digits in fractional part - return Math.round(component * 10000) / 10000 as AlphaComponent; -} - -/** - * @example - * #fb0 - * @example - * #f0f - * @example - * #f0fa - */ -const shortHexRe = /^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?$/i; - -/** - * @example - * #00ff00 - * @example - * #336699 - * @example - * #336699FA - */ -const hexRe = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/i; - -/** - * @example - * rgb(123, 234, 45) - * @example - * rgb(255,234,245) - */ -const rgbRe = /^rgb\(\s*(-?\d{1,10})\s*,\s*(-?\d{1,10})\s*,\s*(-?\d{1,10})\s*\)$/; - -/** - * @example - * rgba(123, 234, 45, 1) - * @example - * rgba(255,234,245,0.1) - */ - -const rgbaRe = /^rgba\(\s*(-?\d{1,10})\s*,\s*(-?\d{1,10})\s*,\s*(-?\d{1,10})\s*,\s*(-?\d*\.?\d+)\s*\)$/; -function colorStringToRgba(colorString: string): Rgba { - colorString = colorString.toLowerCase(); - - // eslint-disable-next-line no-restricted-syntax - if (colorString in namedColorRgbHexStrings) { - colorString = namedColorRgbHexStrings[colorString]; - } - - { - const matches = rgbaRe.exec(colorString) || rgbRe.exec(colorString); - if (matches) { - return [ - normalizeRgbComponent(parseInt(matches[1], 10)), - normalizeRgbComponent(parseInt(matches[2], 10)), - normalizeRgbComponent(parseInt(matches[3], 10)), - normalizeAlphaComponent((matches.length < 5 ? 1 : parseFloat(matches[4])) as AlphaComponent), - ]; - } - } - - { - const matches = hexRe.exec(colorString); - if (matches) { - return [ - normalizeRgbComponent(parseInt(matches[1], 16)), - normalizeRgbComponent(parseInt(matches[2], 16)), - normalizeRgbComponent(parseInt(matches[3], 16)), - 1 as AlphaComponent, - ]; - } - } - - { - const matches = shortHexRe.exec(colorString); - if (matches) { - return [ - normalizeRgbComponent(parseInt(matches[1], 16) * 0x11), - normalizeRgbComponent(parseInt(matches[2], 16) * 0x11), - normalizeRgbComponent(parseInt(matches[3], 16) * 0x11), - 1 as AlphaComponent, - ]; - } - } - - throw new Error(`Cannot parse color: ${colorString}`); -} - -function rgbaToGrayscale(rgbValue: Rgba): number { - // Originally, the NTSC RGB to YUV formula - // perfected by @eugene-korobko's black magic - const redComponentGrayscaleWeight = 0.199; - const greenComponentGrayscaleWeight = 0.687; - const blueComponentGrayscaleWeight = 0.114; - - return ( - redComponentGrayscaleWeight * rgbValue[0] + - greenComponentGrayscaleWeight * rgbValue[1] + - blueComponentGrayscaleWeight * rgbValue[2] - ); -} - -export function applyAlpha(color: string, alpha: number): string { - // special case optimization - if (color === 'transparent') { - return color; - } - - const originRgba = colorStringToRgba(color); - const originAlpha = originRgba[3]; - return `rgba(${originRgba[0]}, ${originRgba[1]}, ${originRgba[2]}, ${alpha * originAlpha})`; -} - -export interface ContrastColors { - foreground: string; - background: string; -} - -export function generateContrastColors(backgroundColor: string): ContrastColors { - const rgb = colorStringToRgba(backgroundColor); - - return { - background: `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`, - foreground: rgbaToGrayscale(rgb) > 160 ? 'black' : 'white', - }; -} - -export function colorStringToGrayscale(backgroundColor: string): number { - return rgbaToGrayscale(colorStringToRgba(backgroundColor)); -} - -export function gradientColorAtPercent(topColor: string, bottomColor: string, percent: number): string { - const [topR, topG, topB, topA] = colorStringToRgba(topColor); - const [bottomR, bottomG, bottomB, bottomA] = colorStringToRgba(bottomColor); - - const resultRgba: Rgba = [ - normalizeRgbComponent(topR + percent * (bottomR - topR) as RedComponent), - normalizeRgbComponent(topG + percent * (bottomG - topG) as GreenComponent), - normalizeRgbComponent(topB + percent * (bottomB - topB) as BlueComponent), - normalizeAlphaComponent(topA + percent * (bottomA - topA) as AlphaComponent), - ]; - - return `rgba(${resultRgba[0]}, ${resultRgba[1]}, ${resultRgba[2]}, ${resultRgba[3]})`; -} diff --git a/src/model/chart-model.ts b/src/model/chart-model.ts index 8a7d11fc78..cc50e5c214 100644 --- a/src/model/chart-model.ts +++ b/src/model/chart-model.ts @@ -1,7 +1,6 @@ /// import { assert, ensureNotNull } from '../helpers/assertions'; -import { gradientColorAtPercent } from '../helpers/color'; import { Delegate } from '../helpers/delegate'; import { IDestroyable } from '../helpers/idestroyable'; import { ISubscription } from '../helpers/isubscription'; @@ -10,6 +9,7 @@ import { DeepPartial, merge } from '../helpers/strict-type-checks'; import { PriceAxisViewRendererOptions } from '../renderers/iprice-axis-view-renderer'; import { PriceAxisRendererOptionsProvider } from '../renderers/price-axis-renderer-options-provider'; +import { ColorParser } from './colors'; import { Coordinate } from './coordinate'; import { Crosshair, CrosshairOptions } from './crosshair'; import { DefaultPriceScaleId, isDefaultPriceScale } from './default-price-scale'; @@ -433,6 +433,8 @@ export interface IChartModelBase { swapPanes(first: number, second: number): void; removePane(index: number): void; changePanesHeight(paneIndex: number, height: number): void; + + colorParser(): ColorParser; } function isPanePrimitive(source: IPriceDataSource | IPrimitiveHitTestSource): source is IPrimitiveHitTestSource | Pane { @@ -463,10 +465,13 @@ export class ChartModel implements IDestroyable, IChartModelBase private readonly _horzScaleBehavior: IHorzScaleBehavior; + private _colorParser: ColorParser; + public constructor(invalidateHandler: InvalidateHandler, options: ChartOptionsInternal, horzScaleBehavior: IHorzScaleBehavior) { this._invalidateHandler = invalidateHandler; this._options = options; this._horzScaleBehavior = horzScaleBehavior; + this._colorParser = new ColorParser(this._options.layout.colorParsers); this._rendererOptionsProvider = new PriceAxisRendererOptionsProvider(this); @@ -1031,7 +1036,7 @@ export class ChartModel implements IDestroyable, IChartModelBase } } - const result = gradientColorAtPercent(topColor, bottomColor, percent / 100); + const result = this._colorParser.gradientColorAtPercent(topColor, bottomColor, percent / 100); this._gradientColorsCache.colors.set(percent, result); return result; } @@ -1040,6 +1045,10 @@ export class ChartModel implements IDestroyable, IChartModelBase return this._panes.indexOf(pane); } + public colorParser(): ColorParser { + return this._colorParser; + } + private _getOrCreatePane(index: number): Pane { assert(index >= 0, 'Index should be greater or equal to 0'); index = Math.min(this._panes.length, index); diff --git a/src/model/colors.ts b/src/model/colors.ts new file mode 100644 index 0000000000..315959994c --- /dev/null +++ b/src/model/colors.ts @@ -0,0 +1,194 @@ +import { Nominal } from '../helpers/nominal'; + +/** + * Red component of the RGB color value + * The valid values are integers in range [0, 255] + */ +type RedComponent = Nominal; + +/** + * Green component of the RGB color value + * The valid values are integers in range [0, 255] + */ +type GreenComponent = Nominal; + +/** + * Blue component of the RGB color value + * The valid values are integers in range [0, 255] + */ +type BlueComponent = Nominal; + +/** + * Alpha component of the RGBA color value + * The valid values are integers in range [0, 1] + */ +type AlphaComponent = Nominal; + +export type Rgba = [RedComponent, GreenComponent, BlueComponent, AlphaComponent]; + +function normalizeRgbComponent< + T extends RedComponent | GreenComponent | BlueComponent +>(component: number): T { + if (component < 0) { + return 0 as T; + } + if (component > 255) { + return 255 as T; + } + // NaN values are treated as 0 + return (Math.round(component) || 0) as T; +} + +function normalizeAlphaComponent(component: AlphaComponent): AlphaComponent { + if (component <= 0 || component > 1) { + return Math.min(Math.max(component, 0), 1) as AlphaComponent; + } + // limit the precision of all numbers to at most 4 digits in fractional part + return (Math.round(component * 10000) / 10000) as AlphaComponent; +} + +function rgbaToGrayscale(rgbValue: Rgba): number { + // Originally, the NTSC RGB to YUV formula + // perfected by @eugene-korobko's black magic + const redComponentGrayscaleWeight = 0.199; + const greenComponentGrayscaleWeight = 0.687; + const blueComponentGrayscaleWeight = 0.114; + + return ( + redComponentGrayscaleWeight * rgbValue[0] + + greenComponentGrayscaleWeight * rgbValue[1] + + blueComponentGrayscaleWeight * rgbValue[2] + ); +} + +/** + * For colors which fall within the sRGB space, the browser can + * be used to convert the color string into a rgb /rgba string. + * + * For other colors, it will be returned as specified (i.e. for + * newer formats like display-p3) + * + * See: https://www.w3.org/TR/css-color-4/#serializing-sRGB-values + */ +function getRgbStringViaBrowser(color: string): string { + const element = document.createElement('div'); + element.style.display = 'none'; + // We append to the body as it is the most reliable way to get a color reading + // appending to the chart container or similar element can result in the following + // getComputedStyle returning empty strings on each check. + document.body.appendChild(element); + element.style.color = color; + const computed = window.getComputedStyle(element).color; + document.body.removeChild(element); + return computed; +} + +export interface ContrastColors { + foreground: string; + background: string; +} + +export type CustomColorParser = (color: string) => Rgba | null; + +export class ColorParser { + private _rgbaCache: Map = new Map(); + private _customParsers: CustomColorParser[]; + + public constructor(customParsers: CustomColorParser[], initialCache?: Map) { + this._customParsers = customParsers; + if (initialCache) { + this._rgbaCache = initialCache; + } + } + + /** + * We fallback to RGBA here since supporting alpha transformations + * on wider color gamuts would currently be a lot of extra code + * for very little benefit due to actual usage. + */ + public applyAlpha(color: string, alpha: number): string { + // special case optimization + if (color === 'transparent') { + return color; + } + + const originRgba = this._parseColor(color); + const originAlpha = originRgba[3]; + return `rgba(${originRgba[0]}, ${originRgba[1]}, ${originRgba[2]}, ${ + alpha * originAlpha + })`; + } + + public generateContrastColors(background: string): ContrastColors { + const rgba = this._parseColor(background); + return { + background: `rgb(${rgba[0]}, ${rgba[1]}, ${rgba[2]})`, // no alpha + foreground: rgbaToGrayscale(rgba) > 160 ? 'black' : 'white', + }; + } + + public colorStringToGrayscale(background: string): number { + return rgbaToGrayscale(this._parseColor(background)); + } + + public gradientColorAtPercent( + topColor: string, + bottomColor: string, + percent: number + ): string { + const [topR, topG, topB, topA] = this._parseColor(topColor); + const [bottomR, bottomG, bottomB, bottomA] = this._parseColor(bottomColor); + const resultRgba: Rgba = [ + normalizeRgbComponent( + (topR + percent * (bottomR - topR)) as RedComponent + ), + normalizeRgbComponent( + (topG + percent * (bottomG - topG)) as GreenComponent + ), + normalizeRgbComponent( + (topB + percent * (bottomB - topB)) as BlueComponent + ), + normalizeAlphaComponent( + (topA + percent * (bottomA - topA)) as AlphaComponent + ), + ]; + return `rgba(${resultRgba[0]}, ${resultRgba[1]}, ${resultRgba[2]}, ${resultRgba[3]})`; + } + + private _parseColor(color: string): Rgba { + const cached = this._rgbaCache.get(color); + if (cached) { + return cached; + } + + const computed = getRgbStringViaBrowser(color); + + const match = computed.match( + /^rgba?\s*\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d*\.?\d+))?\)$/ + ); + + if (!match) { + if (this._customParsers.length) { + for (const parser of this._customParsers) { + const result = parser(color); + if (result) { + this._rgbaCache.set(color, result); + return result; + } + } + } + throw new Error(`Failed to parse color: ${color}`); + } + + const rgba: Rgba = [ + parseInt(match[1], 10) as RedComponent, + parseInt(match[2], 10) as GreenComponent, + parseInt(match[3], 10) as BlueComponent, + (match[4] ? parseFloat(match[4]) : 1) as AlphaComponent, + ]; + + this._rgbaCache.set(color, rgba); + + return rgba; + } +} diff --git a/src/model/layout-options.ts b/src/model/layout-options.ts index c77a5eec5f..eb376932e3 100644 --- a/src/model/layout-options.ts +++ b/src/model/layout-options.ts @@ -1,3 +1,5 @@ +import { CustomColorParser } from './colors'; + /** * Represents a type of color. */ @@ -48,6 +50,8 @@ export interface VerticalGradientColor { */ export type Background = SolidColor | VerticalGradientColor; +export type ColorSpace = 'display-p3' | 'srgb'; + /** * Represents panes customizations. */ @@ -124,4 +128,47 @@ export interface LayoutOptions { * @defaultValue true */ attributionLogo: boolean; + + /** + * Specifies the color space of the rendering context for the internal + * canvas elements. + * + * Note: this option should only be specified during the chart creation + * and not changed at a later stage by using `applyOptions`. + * + * @defaultValue `srgb` + * + * See [HTMLCanvasElement: getContext() method - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext#colorspace) for more info + */ + colorSpace: ColorSpace; + + /** + * Array of custom color parser functions to handle color formats outside of standard sRGB values. + * Each parser function takes a string input and should return either: + * - An {@link Rgba} array [r,g,b,a] for valid colors (with values 0-255 for rgb and 0-1 for a) + * - null if the parser cannot handle that color string, allowing the next parser to attempt it + * + * Parsers are tried in order until one returns a non-null result. This allows chaining multiple + * parsers to handle different color space formats. + * + * Note: this option should only be specified during the chart creation + * and not changed at a later stage by using `applyOptions`. + * + * The library already supports these color formats by default: + * - Hex colors (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) + * - RGB/RGBA functions (rgb(), rgba()) + * - HSL/HSLA functions (hsl(), hsla()) + * - HWB function (hwb()) + * - Named colors (red, blue, etc.) + * - 'transparent' keyword + * + * Custom parsers are only needed for other color spaces like: + * - Display P3: color(display-p3 r g b) + * - CIE Lab: lab(l a b) + * - LCH: lch(l c h) + * - Oklab: oklab(l a b) + * - Oklch: oklch(l c h) + * - ... + */ + colorParsers: CustomColorParser[]; } diff --git a/src/model/pane.ts b/src/model/pane.ts index ad4e7fb8f6..466ca43766 100644 --- a/src/model/pane.ts +++ b/src/model/pane.ts @@ -471,7 +471,8 @@ export class Pane implements IDestroyable, IPrimitiveHitTestSource { id, actualOptions, this._model.options()['layout'], - this._model.options().localization + this._model.options().localization, + this._model.colorParser() ); priceScale.setHeight(this.height()); return priceScale; diff --git a/src/model/price-scale.ts b/src/model/price-scale.ts index a65380d1c0..0439b8aa58 100644 --- a/src/model/price-scale.ts +++ b/src/model/price-scale.ts @@ -8,6 +8,7 @@ import { ISubscription } from '../helpers/isubscription'; import { DeepPartial, merge } from '../helpers/strict-type-checks'; import { BarCoordinates, BarPrice, BarPrices } from './bar'; +import { ColorParser } from './colors'; import { Coordinate } from './coordinate'; import { FirstValue, IPriceDataSource } from './iprice-data-source'; import { LayoutOptions } from './layout-options'; @@ -240,12 +241,14 @@ export class PriceScale { private _formatter: IPriceFormatter = defaultPriceFormatter; private _logFormula: LogFormula = logFormulaForPriceRange(null); + private _colorParser: ColorParser; - public constructor(id: string, options: PriceScaleOptions, layoutOptions: LayoutOptions, localizationOptions: LocalizationOptionsBase) { + public constructor(id: string, options: PriceScaleOptions, layoutOptions: LayoutOptions, localizationOptions: LocalizationOptionsBase, colorParser: ColorParser) { this._id = id; this._options = options; this._layoutOptions = layoutOptions; this._localizationOptions = localizationOptions; + this._colorParser = colorParser; this._markBuilder = new PriceTickMarkBuilder(this, 100, this._coordinateToLogical.bind(this), this._logicalToCoordinate.bind(this)); } @@ -835,6 +838,10 @@ export class PriceScale { this._cachedOrderedSources = null; } + public colorParser(): ColorParser { + return this._colorParser; + } + /** * @returns The {@link IPriceDataSource} that will be used as the "formatter source" (take minMove for formatter). */ diff --git a/src/views/pane/series-last-price-animation-pane-view.ts b/src/views/pane/series-last-price-animation-pane-view.ts index 40ac9479cf..9173e48b5b 100644 --- a/src/views/pane/series-last-price-animation-pane-view.ts +++ b/src/views/pane/series-last-price-animation-pane-view.ts @@ -1,5 +1,4 @@ import { assert } from '../../helpers/assertions'; -import { applyAlpha } from '../../helpers/color'; import { ISeries } from '../../model/iseries'; import { Point } from '../../model/point'; @@ -90,37 +89,10 @@ interface AnimationData { strokeColor: string; } -function color(seriesLineColor: string, stage: number, startAlpha: number, endAlpha: number): string { - const alpha = startAlpha + (endAlpha - startAlpha) * stage; - return applyAlpha(seriesLineColor, alpha); -} - function radius(stage: number, startRadius: number, endRadius: number): number { return startRadius + (endRadius - startRadius) * stage; } -function animationData(durationSinceStart: number, lineColor: string): AnimationData { - const globalStage = (durationSinceStart % Constants.AnimationPeriod) / Constants.AnimationPeriod; - - let currentStageData: AnimationStageData | undefined; - - for (const stageData of animationStagesData) { - if (globalStage >= stageData.start && globalStage <= stageData.end) { - currentStageData = stageData; - break; - } - } - - assert(currentStageData !== undefined, 'Last price animation internal logic error'); - - const subStage = (globalStage - currentStageData.start) / (currentStageData.end - currentStageData.start); - return { - fillColor: color(lineColor, subStage, currentStageData.startFillAlpha, currentStageData.endFillAlpha), - strokeColor: color(lineColor, subStage, currentStageData.startStrokeAlpha, currentStageData.endStrokeAlpha), - radius: radius(subStage, currentStageData.startRadius, currentStageData.endRadius), - }; -} - export class SeriesLastPriceAnimationPaneView implements IUpdatablePaneView { private readonly _series: ISeries<'Area'> | ISeries<'Line'> | ISeries<'Baseline'>; private readonly _renderer: SeriesLastPriceAnimationRenderer = new SeriesLastPriceAnimationRenderer(); @@ -215,7 +187,7 @@ export class SeriesLastPriceAnimationPaneView implements IUpdatablePaneView { const seriesLineColor = lastValue.color; const seriesLineWidth = this._series.options().lineWidth; - const data = animationData(this._duration(), seriesLineColor); + const data = this._animationData(this._duration(), seriesLineColor); this._renderer.setData({ seriesLineColor, @@ -230,7 +202,7 @@ export class SeriesLastPriceAnimationPaneView implements IUpdatablePaneView { private _updateRendererDataStage(): void { const rendererData = this._renderer.data(); if (rendererData !== null) { - const data = animationData(this._duration(), rendererData.seriesLineColor); + const data = this._animationData(this._duration(), rendererData.seriesLineColor); rendererData.fillColor = data.fillColor; rendererData.strokeColor = data.strokeColor; rendererData.radius = data.radius; @@ -240,4 +212,34 @@ export class SeriesLastPriceAnimationPaneView implements IUpdatablePaneView { private _duration(): number { return this.animationActive() ? performance.now() - this._startTime : Constants.AnimationPeriod - 1; } + + private _color(seriesLineColor: string, stage: number, startAlpha: number, endAlpha: number): string { + const alpha = startAlpha + (endAlpha - startAlpha) * stage; + return this._series + .model() + .colorParser() + .applyAlpha(seriesLineColor, alpha); + } + + private _animationData(durationSinceStart: number, lineColor: string): AnimationData { + const globalStage = (durationSinceStart % Constants.AnimationPeriod) / Constants.AnimationPeriod; + + let currentStageData: AnimationStageData | undefined; + + for (const stageData of animationStagesData) { + if (globalStage >= stageData.start && globalStage <= stageData.end) { + currentStageData = stageData; + break; + } + } + + assert(currentStageData !== undefined, 'Last price animation internal logic error'); + + const subStage = (globalStage - currentStageData.start) / (currentStageData.end - currentStageData.start); + return { + fillColor: this._color(lineColor, subStage, currentStageData.startFillAlpha, currentStageData.endFillAlpha), + strokeColor: this._color(lineColor, subStage, currentStageData.startStrokeAlpha, currentStageData.endStrokeAlpha), + radius: radius(subStage, currentStageData.startRadius, currentStageData.endRadius), + }; + } } diff --git a/src/views/price-axis/crosshair-price-axis-view.ts b/src/views/price-axis/crosshair-price-axis-view.ts index 44fc6321be..f5adbe335a 100644 --- a/src/views/price-axis/crosshair-price-axis-view.ts +++ b/src/views/price-axis/crosshair-price-axis-view.ts @@ -1,5 +1,3 @@ -import { generateContrastColors } from '../../helpers/color'; - import { Crosshair, CrosshairMode, CrosshairPriceAndCoordinate } from '../../model/crosshair'; import { PriceScale } from '../../model/price-scale'; import { PriceAxisViewRendererCommonData, PriceAxisViewRendererData } from '../../renderers/iprice-axis-view-renderer'; @@ -40,7 +38,7 @@ export class CrosshairPriceAxisView extends PriceAxisView { return; } - const colors = generateContrastColors(options.labelBackgroundColor); + const colors = this._priceScale.colorParser().generateContrastColors(options.labelBackgroundColor); commonRendererData.background = colors.background; axisRendererData.color = colors.foreground; diff --git a/src/views/price-axis/custom-price-line-price-axis-view.ts b/src/views/price-axis/custom-price-line-price-axis-view.ts index ee23e22491..63ef61423c 100644 --- a/src/views/price-axis/custom-price-line-price-axis-view.ts +++ b/src/views/price-axis/custom-price-line-price-axis-view.ts @@ -1,5 +1,3 @@ -import { generateContrastColors } from '../../helpers/color'; - import { CustomPriceLine } from '../../model/custom-price-line'; import { ISeries } from '../../model/iseries'; import { SeriesType } from '../../model/series-options'; @@ -53,7 +51,10 @@ export class CustomPriceLinePriceAxisView extends PriceAxisView { axisRendererData.text = this._formatPrice(options.price); axisRendererData.visible = true; - const colors = generateContrastColors(options.axisLabelColor || options.color); + const colors = this._series + .model() + .colorParser() + .generateContrastColors(options.axisLabelColor || options.color); commonData.background = colors.background; const textColor = options.axisLabelTextColor || colors.foreground; diff --git a/src/views/price-axis/series-price-axis-view.ts b/src/views/price-axis/series-price-axis-view.ts index 391c7dae19..4bf8ac4b75 100644 --- a/src/views/price-axis/series-price-axis-view.ts +++ b/src/views/price-axis/series-price-axis-view.ts @@ -1,5 +1,3 @@ -import { generateContrastColors } from '../../helpers/color'; - import { ISeries, LastValueDataResultWithData } from '../../model/iseries'; import { PriceAxisLastValueMode, SeriesType } from '../../model/series-options'; import { PriceAxisViewRendererCommonData, PriceAxisViewRendererData } from '../../renderers/iprice-axis-view-renderer'; @@ -50,7 +48,10 @@ export class SeriesPriceAxisView extends PriceAxisView { } const lastValueColor = source.priceLineColor(lastValueData.color); - const colors = generateContrastColors(lastValueColor); + const colors = this._source + .model() + .colorParser() + .generateContrastColors(lastValueColor); commonRendererData.background = colors.background; commonRendererData.coordinate = lastValueData.coordinate; diff --git a/src/views/time-axis/crosshair-time-axis-view.ts b/src/views/time-axis/crosshair-time-axis-view.ts index e38f85677d..0de8f5bf4e 100644 --- a/src/views/time-axis/crosshair-time-axis-view.ts +++ b/src/views/time-axis/crosshair-time-axis-view.ts @@ -1,5 +1,4 @@ import { ensureNotNull } from '../../helpers/assertions'; -import { generateContrastColors } from '../../helpers/color'; import { IChartModelBase } from '../../model/chart-model'; import { Crosshair, CrosshairMode, TimeAndCoordinateProvider } from '../../model/crosshair'; @@ -75,7 +74,7 @@ export class CrosshairTimeAxisView implements ITimeAxisView { data.text = timeScale.formatDateTime(ensureNotNull(currentTime)); data.visible = true; - const colors = generateContrastColors(options.labelBackgroundColor); + const colors = this._model.colorParser().generateContrastColors(options.labelBackgroundColor); data.background = colors.background; data.color = colors.foreground; data.tickVisible = timeScale.options().ticksVisible; diff --git a/tests/e2e/coverage/coverage-config.ts b/tests/e2e/coverage/coverage-config.ts index 7c0a1b508c..cf78fcdbcd 100644 --- a/tests/e2e/coverage/coverage-config.ts +++ b/tests/e2e/coverage/coverage-config.ts @@ -1,2 +1,2 @@ -export const expectedCoverage = 91; +export const expectedCoverage = 90; export const threshold = 1; diff --git a/tests/e2e/coverage/test-cases/chart/color-p3-support.js b/tests/e2e/coverage/test-cases/chart/color-p3-support.js new file mode 100644 index 0000000000..238b73eda0 --- /dev/null +++ b/tests/e2e/coverage/test-cases/chart/color-p3-support.js @@ -0,0 +1,109 @@ +function displayP3ToRGBA(r, g, b, a = 1) { + const linearP3 = [r, g, b].map(val => { + const abs = Math.abs(val); + if (abs <= 0.04045) { + return val / 12.92; + } + return Math.sign(val) * Math.pow((abs + 0.055) / 1.055, 2.4); + }); + + const p3ToXYZ = [ + [0.486570948648216, 0.265667687339784, 0.198217285240192], + [0.228974564336137, 0.691738605302214, 0.079286829361649], + [0.0, 0.045113381858903, 1.043944368900976], + ]; + + const xyz = p3ToXYZ.map( + row => row[0] * linearP3[0] + row[1] * linearP3[1] + row[2] * linearP3[2] + ); + + const xyzToSRGB = [ + [3.2409699419045226, -1.5373831775700939, -0.4986107602930034], + [-0.9692436362808796, 1.8759675015077204, 0.0415550574071756], + [0.0556300796969936, -0.2039769588889765, 1.0569715142428784], + ]; + + const linearSRGB = xyzToSRGB.map( + row => row[0] * xyz[0] + row[1] * xyz[1] + row[2] * xyz[2] + ); + + const srgb = linearSRGB.map(val => { + const abs = Math.abs(val); + if (abs <= 0.0031308) { + return 12.92 * val; + } + return Math.sign(val) * (1.055 * Math.pow(abs, 1 / 2.4) - 0.055); + }); + + return [...srgb, a]; +} + +const displayP3Parser = color => { + const match = + /^color\(display-p3\s+([.\d]+)\s+([.\d]+)\s+([.\d]+)(?:\s*\/\s*([.\d]+))?\)$/.exec( + color + ); + if (!match) { + return null; + } + + const r = Number(match[1]); + const g = Number(match[2]); + const b = Number(match[3]); + const a = match[4] ? Number(match[4]) : 1; + + if ([r, g, b, a].some(v => isNaN(v))) { + return null; + } + + return displayP3ToRGBA(r, g, b, a); +}; + +function generateData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 500; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + + time.setUTCDate(time.getUTCDate() + 1); + } + return res; +} + +function interactionsToPerform() { + return []; +} + +function beforeInteractions(container) { + const chart = (window.chart = LightweightCharts.createChart(container, { + layout: { + attributionLogo: false, + background: { + type: 'solid', + color: 'red', + }, + colorSpace: 'display-p3', + colorParsers: [displayP3Parser], + }, + })); + + const mainSeries = chart.addAreaSeries({ + priceFormat: { + minMove: 1, + precision: 0, + }, + topColor: 'color(display-p3 1 0 0)', + bottomColor: 'color(display-p3 1 0 0)', + lineColor: 'color(display-p3 1 0 0)', + }); + + mainSeries.setData(generateData()); + return Promise.resolve(); +} + +function afterInteractions() { + return Promise.resolve(); +} diff --git a/tests/e2e/coverage/test-cases/chart/color-support.js b/tests/e2e/coverage/test-cases/chart/color-support.js new file mode 100644 index 0000000000..3a60777390 --- /dev/null +++ b/tests/e2e/coverage/test-cases/chart/color-support.js @@ -0,0 +1,47 @@ +function generateData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 500; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + + time.setUTCDate(time.getUTCDate() + 1); + } + return res; +} + +function interactionsToPerform() { + return []; +} + +function beforeInteractions(container) { + const chart = (window.chart = LightweightCharts.createChart(container, { + layout: { + attributionLogo: false, + background: { + type: 'solid', + color: 'rgba(50,100,150, 0.1)', + }, + textColor: 'rgba(255,200,100)', + }, + })); + + const mainSeries = chart.addAreaSeries({ + priceFormat: { + minMove: 1, + precision: 0, + }, + topColor: 'hsl(180, 50%, 45%)', + bottomColor: 'hsla(160, 50%, 45%, 0%)', + lineColor: 'hwb(160 10% 20%)', + }); + + mainSeries.setData(generateData()); + return Promise.resolve(); +} + +function afterInteractions() { + return Promise.resolve(); +} diff --git a/tests/e2e/graphics/generate-golden-content.ts b/tests/e2e/graphics/generate-golden-content.ts index 9d5aa492ff..6c9cc3fa71 100644 --- a/tests/e2e/graphics/generate-golden-content.ts +++ b/tests/e2e/graphics/generate-golden-content.ts @@ -24,12 +24,12 @@ rmRf(goldenOutputPath); fs.mkdirSync(goldenOutputPath, { recursive: true }); for (const groupName of Object.keys(testCases)) { - generateTestCases(testCases[groupName]); + generateTestCases(groupName, testCases[groupName]); } -function generateTestCases(groupTestCases: TestCase[]): void { +function generateTestCases(groupName: string, groupTestCases: TestCase[]): void { for (const testCase of groupTestCases) { - const testCaseOutDir = path.join(goldenOutputPath, testCase.name); + const testCaseOutDir = path.join(goldenOutputPath, groupName, testCase.name); rmRf(testCaseOutDir); fs.mkdirSync(testCaseOutDir, { recursive: true }); path.join(testCaseOutDir, 'test-content.html'); diff --git a/tests/e2e/graphics/graphics-test-cases.ts b/tests/e2e/graphics/graphics-test-cases.ts index 9b3090e184..3a4953b25c 100644 --- a/tests/e2e/graphics/graphics-test-cases.ts +++ b/tests/e2e/graphics/graphics-test-cases.ts @@ -68,10 +68,10 @@ void describe(`Graphics tests with devicePixelRatio=${devicePixelRatioStr} (${bu const currentGroupOutDir = path.join(currentDprOutDir, groupName); if (groupName.length === 0) { - registerTestCases(testCases[groupName], screenshoter, currentGroupOutDir); + registerTestCases(testCases[groupName], screenshoter, currentGroupOutDir, groupName); } else { void describe(groupName, () => { - registerTestCases(testCases[groupName], screenshoter, currentGroupOutDir); + registerTestCases(testCases[groupName], screenshoter, currentGroupOutDir, groupName); }); } } @@ -82,7 +82,7 @@ void describe(`Graphics tests with devicePixelRatio=${devicePixelRatioStr} (${bu }); }); -function registerTestCases(testCases: TestCase[], screenshoter: Screenshoter, outDir: string): void { +function registerTestCases(testCases: TestCase[], screenshoter: Screenshoter, outDir: string, groupName: string): void { const attempts: Record = {}; testCases.forEach((testCase: TestCase) => { attempts[testCase.name] = 0; @@ -108,7 +108,7 @@ function registerTestCases(testCases: TestCase[], screenshoter: Screenshoter, ou if (goldenContentDir) { try { const content = fs.readFileSync( - path.join(goldenContentDir, testCase.name, 'test-content.html'), + path.join(goldenContentDir, groupName, testCase.name, 'test-content.html'), { encoding: 'utf-8' } ); return content.replace('PATH_TO_STANDALONE_MODULE', goldenStandalonePath); diff --git a/tests/e2e/graphics/test-cases/color-p3-support.js b/tests/e2e/graphics/test-cases/color-p3-support.js new file mode 100644 index 0000000000..951df7369f --- /dev/null +++ b/tests/e2e/graphics/test-cases/color-p3-support.js @@ -0,0 +1,132 @@ +/** + * TEST NOTE: PNG Screenshots don't support a wide color gamut + * therefore at the moment this test won't visually show a difference + * between the background and the series plot. + * + * On a screen which supports wider gamuts then you will be able to + * spot a color difference. + */ + +/** + * Converts a Display P3 color to RGBA (sRGB) + * @param r - Red component in Display P3 [0-1] + * @param g - Green component in Display P3 [0-1] + * @param b - Blue component in Display P3 [0-1] + * @param a - Alpha component [0-1], defaults to 1 + * @returns RGBA values in sRGB color space [0-1] + */ +function displayP3ToRGBA(r, g, b, a = 1) { + // First convert from gamma-corrected P3 to linear-light P3 + const linearP3 = [r, g, b].map(val => { + // Transfer function is same as sRGB + const abs = Math.abs(val); + if (abs <= 0.04045) { + return val / 12.92; + } + return Math.sign(val) * Math.pow((abs + 0.055) / 1.055, 2.4); + }); + + // Convert from linear-light P3 to CIE XYZ D65 + // Matrix from CSS Color Level 4 specification + const p3ToXYZ = [ + [0.486570948648216, 0.265667687339784, 0.198217285240192], + [0.228974564336137, 0.691738605302214, 0.079286829361649], + [0.0, 0.045113381858903, 1.043944368900976], + ]; + + const xyz = p3ToXYZ.map( + row => row[0] * linearP3[0] + row[1] * linearP3[1] + row[2] * linearP3[2] + ); + + // Convert from CIE XYZ D65 to linear-light sRGB + // Matrix from CSS Color Level 4 specification + const xyzToSRGB = [ + [3.2409699419045226, -1.5373831775700939, -0.4986107602930034], + [-0.9692436362808796, 1.8759675015077204, 0.0415550574071756], + [0.0556300796969936, -0.2039769588889765, 1.0569715142428784], + ]; + + const linearSRGB = xyzToSRGB.map( + row => row[0] * xyz[0] + row[1] * xyz[1] + row[2] * xyz[2] + ); + + // Convert from linear-light sRGB to gamma-corrected sRGB + const srgb = linearSRGB.map(val => { + const abs = Math.abs(val); + if (abs <= 0.0031308) { + return 12.92 * val; + } + return Math.sign(val) * (1.055 * Math.pow(abs, 1 / 2.4) - 0.055); + }); + + // Return RGBA values + return [...srgb, a]; +} + +/** + * Example parser for Display P3 colors in the format: color(display-p3 r g b[ / a]) + */ +const displayP3Parser = color => { + // Match color(display-p3 r g b[ / a]) format + const match = + /^color\(display-p3\s+([.\d]+)\s+([.\d]+)\s+([.\d]+)(?:\s*\/\s*([.\d]+))?\)$/.exec( + color + ); + if (!match) { + return null; + } + + // Parse components + const r = Number(match[1]); + const g = Number(match[2]); + const b = Number(match[3]); + const a = match[4] ? Number(match[4]) : 1; + + // Validate ranges + if ([r, g, b, a].some(v => isNaN(v))) { + return null; + } + + // Convert to sRGB + return displayP3ToRGBA(r, g, b, a); +}; + +function generateData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 500; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + + time.setUTCDate(time.getUTCDate() + 1); + } + return res; +} + +function runTestCase(container) { + const chart = (window.chart = LightweightCharts.createChart(container, { + layout: { + attributionLogo: false, + background: { + type: 'solid', + color: 'red', + }, + colorSpace: 'display-p3', + colorParsers: [displayP3Parser], + }, + })); + + const mainSeries = chart.addAreaSeries({ + priceFormat: { + minMove: 1, + precision: 0, + }, + topColor: 'color(display-p3 1 0 0)', /* Bright Red */ + bottomColor: 'color(display-p3 1 0 0)', /* Bright Red */ + lineColor: 'color(display-p3 1 0 0)', /* Bright Red */ + }); + + mainSeries.setData(generateData()); +} diff --git a/tests/e2e/graphics/test-cases/color-support.js b/tests/e2e/graphics/test-cases/color-support.js new file mode 100644 index 0000000000..873721580e --- /dev/null +++ b/tests/e2e/graphics/test-cases/color-support.js @@ -0,0 +1,38 @@ +function generateData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 500; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + + time.setUTCDate(time.getUTCDate() + 1); + } + return res; +} + +function runTestCase(container) { + const chart = (window.chart = LightweightCharts.createChart(container, { + layout: { + attributionLogo: false, + background: { + type: 'solid', + color: 'rgba(50,100,150, 0.1)', + }, + textColor: 'rgba(255,200,100)', + }, + })); + + const mainSeries = chart.addAreaSeries({ + priceFormat: { + minMove: 1, + precision: 0, + }, + topColor: 'hsl(180, 50%, 45%)', + bottomColor: 'hsla(160, 50%, 45%, 0%)', + lineColor: 'hwb(160 10% 20%)', + }); + + mainSeries.setData(generateData()); +} diff --git a/tests/unittests/color.spec.ts b/tests/unittests/color.spec.ts index ec8bac8a1e..bff906a91f 100644 --- a/tests/unittests/color.spec.ts +++ b/tests/unittests/color.spec.ts @@ -2,147 +2,87 @@ import { expect } from 'chai'; import { describe, it } from 'node:test'; -import { generateContrastColors, gradientColorAtPercent } from '../../src/helpers/color'; +import { ColorParser, Rgba } from '../../src/model/colors'; + +type SimpleRgba = number[] & { length: 4 }; + +function generateRgba(rgba: SimpleRgba): Rgba { + return rgba as Rgba; +} + +/** + * The initialCache is used so that we can skip the requirement + * to try create a document element and use the browser for + * color parsing. The assumption is that the browser will do + * this correctly anyway. + */ +const initialCache: Map = new Map([ + ['rgb(255, 255, 255)', generateRgba([255, 255, 255, 1])], + ['rgba(255, 255, 255, 0)', generateRgba([255, 255, 255, 0])], + ['rgba(255, 255, 255, 1)', generateRgba([255, 255, 255, 1])], + ['rgb(0, 0, 0)', generateRgba([0, 0, 0, 1])], + ['rgba(0, 0, 0, 0)', generateRgba([0, 0, 0, 0])], + ['rgba(0, 0, 0, 1)', generateRgba([0, 0, 0, 1])], + + ['rgb(150, 150, 150)', generateRgba([150, 150, 150, 1])], + ['rgb(170, 170, 170)', generateRgba([170, 170, 170, 1])], + ['rgba(150, 150, 150, 0)', generateRgba([150, 150, 150, 0])], + ['rgba(170, 170, 170, 0)', generateRgba([170, 170, 170, 0])], + ['rgb(130, 140, 160)', generateRgba([130, 140, 160, 1])], + ['rgb(190, 180, 160)', generateRgba([190, 180, 160, 1])], + + ['rgb(150, 150, 150)', generateRgba([150, 150, 150, 1])], + ['rgb(170, 170, 170)', generateRgba([170, 170, 170, 1])], + + ['#ffffff', generateRgba([255, 255, 255, 1])], + ['#000000', generateRgba([0, 0, 0, 1])], + ['#fff', generateRgba([255, 255, 255, 1])], + ['#000', generateRgba([0, 0, 0, 1])], +]); +const colorParser = new ColorParser([], initialCache); describe('generateContrastColors', () => { it('should work', () => { - expect(generateContrastColors('rgb(255, 255, 255)')).to.be.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); - expect(generateContrastColors('rgb(255, 255, 255)')).to.be.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); - expect(generateContrastColors('rgba(255, 255, 255, 0)')).to.be.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); - expect(generateContrastColors('rgba(255, 255, 255, 1)')).to.be.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); - - expect(generateContrastColors('rgb(0, 0, 0)')).to.be.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); - expect(generateContrastColors('rgb(0, 0, 0)')).to.be.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); - expect(generateContrastColors('rgba(0, 0, 0, 0)')).to.be.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); - expect(generateContrastColors('rgba(0, 0, 0, 1)')).to.be.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); - }); - - it('should correctly parse known named colors', () => { - expect(generateContrastColors('aliceblue')).to.deep.equal({ foreground: 'black', background: 'rgb(240, 248, 255)' }); - expect(generateContrastColors('coral')).to.deep.equal({ foreground: 'white', background: 'rgb(255, 127, 80)' }); - expect(generateContrastColors('darkmagenta')).to.deep.equal({ foreground: 'white', background: 'rgb(139, 0, 139)' }); - expect(generateContrastColors('linen')).to.deep.equal({ foreground: 'black', background: 'rgb(250, 240, 230)' }); - expect(generateContrastColors('whitesmoke')).to.deep.equal({ foreground: 'black', background: 'rgb(245, 245, 245)' }); - expect(generateContrastColors('white')).to.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); - expect(generateContrastColors('transparent')).to.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); - }); - - it('should correctly parse short hex colors', () => { - expect(generateContrastColors('#fff')).to.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); - expect(generateContrastColors('#000')).to.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); - expect(generateContrastColors('#fffa')).to.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); - }); - - it('should correctly parse hex colors', () => { - expect(generateContrastColors('#ffffff')).to.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); - expect(generateContrastColors('#ff0110')).to.deep.equal({ foreground: 'white', background: 'rgb(255, 1, 16)' }); - expect(generateContrastColors('#f0f0f0aa')).to.deep.equal({ foreground: 'black', background: 'rgb(240, 240, 240)' }); - }); - - it('should correctly parse RGB tuple string', () => { - expect(generateContrastColors('rgb(10, 20, 30)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgb(0,0,0)')).to.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); - expect(generateContrastColors('rgb( 10 , 20 , 30 )')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - - // RGB tuple may contain values exceeding 255, that should be clamped to 255 after parsing - expect(generateContrastColors('rgb(256, 256, 256)')).to.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); - expect(generateContrastColors('rgb(100500, 100500, 100500)')).to.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); - expect(generateContrastColors('rgb(0, 100500, 0)')).to.deep.equal({ foreground: 'black', background: 'rgb(0, 255, 0)' }); - - // RGB tuple may contain negative values, that should be clamped to zero after parsing - expect(generateContrastColors('rgb(-10, -20, -30)')).to.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); - expect(generateContrastColors('rgb(10, -20, 30)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 0, 30)' }); - - // whitespace characters before 'rgb', after 'rgb' and after the closing parenthesis are prohibited - expect(generateContrastColors.bind(null, ' rgb( 10, 20, 30 )')).to.throw(); - expect(generateContrastColors.bind(null, 'rgb ( 10, 20, 30 )')).to.throw(); - expect(generateContrastColors.bind(null, 'rgb( 10, 20, 30 ) ')).to.throw(); - - // RGB tuple should not contain non-integer values - expect(generateContrastColors.bind(null, 'rgb(10.0, 20, 30)')).to.throw(); - expect(generateContrastColors.bind(null, 'rgb(10, 20.0, 30)')).to.throw(); - expect(generateContrastColors.bind(null, 'rgb(10, 20, 30.0)')).to.throw(); - - // not enough values in the tuple - expect(generateContrastColors.bind(null, 'rgb(10, 20)')).to.throw(); - - // too much values in the tuple - expect(generateContrastColors.bind(null, 'rgb(10, 20, 30, 40)')).to.throw(); + expect(colorParser.generateContrastColors('rgb(255, 255, 255)')).to.be.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); + expect(colorParser.generateContrastColors('rgb(255, 255, 255)')).to.be.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); + expect(colorParser.generateContrastColors('rgba(255, 255, 255, 0)')).to.be.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); + expect(colorParser.generateContrastColors('rgba(255, 255, 255, 1)')).to.be.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); + + expect(colorParser.generateContrastColors('rgb(0, 0, 0)')).to.be.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); + expect(colorParser.generateContrastColors('rgb(0, 0, 0)')).to.be.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); + expect(colorParser.generateContrastColors('rgba(0, 0, 0, 0)')).to.be.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); + expect(colorParser.generateContrastColors('rgba(0, 0, 0, 1)')).to.be.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); }); - it('should correctly parse RGBA tuple string', () => { - expect(generateContrastColors('rgba(10, 20, 30, 0.40)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(0,0,0,1)')).to.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); - expect(generateContrastColors('rgba( 10 , 20 , 30 , 0.40 )')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, 0.1)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, .1)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, .001)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, .000000000001)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, .10001)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, .10005)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, .100000000005)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - - // RGB components of a tuple may contain values exceeding 255, that should be clamped to 255 after parsing - expect(generateContrastColors('rgba(256, 256, 256, 1.0)')).to.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); - expect(generateContrastColors('rgba(100500, 100500, 100500, 1.0)')).to.deep.equal({ foreground: 'black', background: 'rgb(255, 255, 255)' }); - expect(generateContrastColors('rgba(0, 100500, 0, 1.0)')).to.deep.equal({ foreground: 'black', background: 'rgb(0, 255, 0)' }); - - // RGB components of a tuple may contain negative values, that should be clamped to zero after parsing - expect(generateContrastColors('rgba(-10, -20, -30, 1.0)')).to.deep.equal({ foreground: 'white', background: 'rgb(0, 0, 0)' }); - expect(generateContrastColors('rgba(10, -20, 30, 1.0)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 0, 30)' }); - - // Alpha component of a tuple may be a value exceeding 1.0, that should be clamped to 1.0 after parsing - expect(generateContrastColors('rgba(10, 20, 30, 1.1)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, 1000.0)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, 1000000)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - - // Alpha component of a tuple may be a negative value, that should be clamped to zero after parsing - expect(generateContrastColors('rgba(10, 20, 30, -0.1)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, -1.1)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, -1000.0)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, -1000000)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - expect(generateContrastColors('rgba(10, 20, 30, -1000000.100000000005)')).to.deep.equal({ foreground: 'white', background: 'rgb(10, 20, 30)' }); - - // dangling dot is prohibited - expect(generateContrastColors.bind(null, 'rgba(10, 20, 30, 1.)')).to.throw(); - - // whitespace characters before 'rgba', after 'rgba' and after the closing parenthesis are prohibited - expect(generateContrastColors.bind(null, ' rgba( 10, 20, 30 , 0.40 )')).to.throw(); - expect(generateContrastColors.bind(null, 'rgba ( 10, 20, 30 , 0.40 )')).to.throw(); - expect(generateContrastColors.bind(null, 'rgba( 10, 20, 30 , 0.40 ) ')).to.throw(); - - // RGB components of tuple should not contain non-integer values - expect(generateContrastColors.bind(null, 'rgba(10.0, 20, 30, 0)')).to.throw(); - expect(generateContrastColors.bind(null, 'rgba(10, 20.0, 30, 0)')).to.throw(); - expect(generateContrastColors.bind(null, 'rgba(10, 20, 30.0, 0)')).to.throw(); - - // not enough values in the tuple - expect(generateContrastColors.bind(null, 'rgba(10, 20, 30)')).to.throw(); - - // too much values in the tuple - expect(generateContrastColors.bind(null, 'rgba(10, 20, 30, 1.0, 1.0)')).to.throw(); + it('correct contrast color', () => { + expect(colorParser.generateContrastColors('rgb(150, 150, 150)')).to.be.deep.equal({ foreground: 'white', background: 'rgb(150, 150, 150)' }); + expect(colorParser.generateContrastColors('rgb(170, 170, 170)')).to.be.deep.equal({ foreground: 'black', background: 'rgb(170, 170, 170)' }); + expect(colorParser.generateContrastColors('rgba(150, 150, 150, 0)')).to.be.deep.equal({ foreground: 'white', background: 'rgb(150, 150, 150)' }); + expect(colorParser.generateContrastColors('rgba(170, 170, 170, 0)')).to.be.deep.equal({ foreground: 'black', background: 'rgb(170, 170, 170)' }); + expect(colorParser.generateContrastColors('rgb(130, 140, 160)')).to.be.deep.equal({ foreground: 'white', background: 'rgb(130, 140, 160)' }); + expect(colorParser.generateContrastColors('rgb(190, 180, 160)')).to.be.deep.equal({ foreground: 'black', background: 'rgb(190, 180, 160)' }); }); }); describe('gradientColorAtPercent', () => { it('0%', () => { - expect(gradientColorAtPercent('rgb(255, 255, 255)', 'rgb(0, 0, 0)', 0)).to.be.equal('rgba(255, 255, 255, 1)'); - expect(gradientColorAtPercent('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0)', 0)).to.be.equal('rgba(255, 255, 255, 1)'); - expect(gradientColorAtPercent('#ffffff', '#000000', 0)).to.be.equal('rgba(255, 255, 255, 1)'); - expect(gradientColorAtPercent('#fff', '#000', 0)).to.be.equal('rgba(255, 255, 255, 1)'); + expect(colorParser.gradientColorAtPercent('rgb(255, 255, 255)', 'rgb(0, 0, 0)', 0)).to.be.equal('rgba(255, 255, 255, 1)'); + expect(colorParser.gradientColorAtPercent('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0)', 0)).to.be.equal('rgba(255, 255, 255, 1)'); + expect(colorParser.gradientColorAtPercent('#ffffff', '#000000', 0)).to.be.equal('rgba(255, 255, 255, 1)'); + expect(colorParser.gradientColorAtPercent('#fff', '#000', 0)).to.be.equal('rgba(255, 255, 255, 1)'); }); it('50%', () => { - expect(gradientColorAtPercent('rgb(255, 255, 255)', 'rgb(0, 0, 0)', 0.5)).to.be.equal('rgba(128, 128, 128, 1)'); - expect(gradientColorAtPercent('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0)', 0.5)).to.be.equal('rgba(128, 128, 128, 0.5)'); - expect(gradientColorAtPercent('#ffffff', '#000000', 0.5)).to.be.equal('rgba(128, 128, 128, 1)'); - expect(gradientColorAtPercent('#fff', '#000', 0.5)).to.be.equal('rgba(128, 128, 128, 1)'); + expect(colorParser.gradientColorAtPercent('rgb(255, 255, 255)', 'rgb(0, 0, 0)', 0.5)).to.be.equal('rgba(128, 128, 128, 1)'); + expect(colorParser.gradientColorAtPercent('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0)', 0.5)).to.be.equal('rgba(128, 128, 128, 0.5)'); + expect(colorParser.gradientColorAtPercent('#ffffff', '#000000', 0.5)).to.be.equal('rgba(128, 128, 128, 1)'); + expect(colorParser.gradientColorAtPercent('#fff', '#000', 0.5)).to.be.equal('rgba(128, 128, 128, 1)'); }); it('100%', () => { - expect(gradientColorAtPercent('rgb(255, 255, 255)', 'rgb(0, 0, 0)', 1)).to.be.equal('rgba(0, 0, 0, 1)'); - expect(gradientColorAtPercent('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0)', 1)).to.be.equal('rgba(0, 0, 0, 0)'); - expect(gradientColorAtPercent('#ffffff', '#000000', 1)).to.be.equal('rgba(0, 0, 0, 1)'); - expect(gradientColorAtPercent('#fff', '#000', 1)).to.be.equal('rgba(0, 0, 0, 1)'); + expect(colorParser.gradientColorAtPercent('rgb(255, 255, 255)', 'rgb(0, 0, 0)', 1)).to.be.equal('rgba(0, 0, 0, 1)'); + expect(colorParser.gradientColorAtPercent('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0)', 1)).to.be.equal('rgba(0, 0, 0, 0)'); + expect(colorParser.gradientColorAtPercent('#ffffff', '#000000', 1)).to.be.equal('rgba(0, 0, 0, 1)'); + expect(colorParser.gradientColorAtPercent('#fff', '#000', 1)).to.be.equal('rgba(0, 0, 0, 1)'); }); });