Skip to content

Commit 517d5ef

Browse files
committed
[TSVB] fix text color when using custom background color (#60261)
When the user apply a background color manually from the UI, this commit adapt the current colors to have a better contrast with the chosen background color irrespective of the used dark/light theme
1 parent cc47e4d commit 517d5ef

7 files changed

Lines changed: 220 additions & 8 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@
309309
"@types/cheerio": "^0.22.10",
310310
"@types/chromedriver": "^2.38.0",
311311
"@types/classnames": "^2.2.9",
312+
"@types/color": "^3.0.0",
312313
"@types/d3": "^3.5.43",
313314
"@types/dedent": "^0.7.0",
314315
"@types/deep-freeze-strict": "^1.1.0",

src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/_vis_types.scss

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,21 @@
77
.tvbVisTimeSeries {
88
overflow: hidden;
99
}
10+
.tvbVisTimeSeriesDark {
11+
.echReactiveChart_unavailable {
12+
color: #DFE5EF;
13+
}
14+
.echLegendItem {
15+
color: #DFE5EF;
16+
}
17+
}
18+
.tvbVisTimeSeriesLight {
19+
.echReactiveChart_unavailable {
20+
color: #343741;
21+
}
22+
.echLegendItem {
23+
color: #343741;
24+
}
25+
}
1026
}
27+

src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ import { getAxisLabelString } from '../../lib/get_axis_label_string';
3434
import { getInterval } from '../../lib/get_interval';
3535
import { areFieldsDifferent } from '../../lib/charts';
3636
import { createXaxisFormatter } from '../../lib/create_xaxis_formatter';
37-
import { isBackgroundDark } from '../../../../common/set_is_reversed';
3837
import { STACKED_OPTIONS } from '../../../visualizations/constants';
38+
import { getUISettings } from '../../../services';
3939

4040
export class TimeseriesVisualization extends Component {
4141
static propTypes = {
@@ -237,14 +237,16 @@ export class TimeseriesVisualization extends Component {
237237
}
238238
});
239239

240+
const darkMode = getUISettings().get('theme:darkMode');
240241
return (
241242
<div className="tvbVis" style={styles.tvbVis}>
242243
<TimeSeries
243244
series={series}
244245
yAxis={yAxis}
245246
onBrush={onBrush}
246247
enableHistogramMode={enableHistogramMode}
247-
isDarkMode={isBackgroundDark(model.background_color)}
248+
backgroundColor={model.background_color}
249+
darkMode={darkMode}
248250
showGrid={Boolean(model.show_grid)}
249251
legend={Boolean(model.show_legend)}
250252
legendPosition={model.legend_position}

src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/__mocks__/@elastic/charts.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,5 @@ export const ScaleType = {
4040

4141
export const BarSeries = () => null;
4242
export const AreaSeries = () => null;
43+
44+
export { LIGHT_THEME, DARK_THEME } from '@elastic/charts';

src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,13 @@
1919

2020
import React, { useEffect, useRef } from 'react';
2121
import PropTypes from 'prop-types';
22+
import classNames from 'classnames';
2223

2324
import {
2425
Axis,
2526
Chart,
2627
Position,
2728
Settings,
28-
DARK_THEME,
29-
LIGHT_THEME,
3029
AnnotationDomainTypes,
3130
LineAnnotation,
3231
TooltipType,
@@ -40,6 +39,7 @@ import { GRID_LINE_CONFIG, ICON_TYPES_MAP, STACKED_OPTIONS } from '../../constan
4039
import { AreaSeriesDecorator } from './decorators/area_decorator';
4140
import { BarSeriesDecorator } from './decorators/bar_decorator';
4241
import { getStackAccessors } from './utils/stack_format';
42+
import { getTheme, getChartClasses } from './utils/theme';
4343

4444
const generateAnnotationData = (values, formatter) =>
4545
values.map(({ key, docs }) => ({
@@ -57,7 +57,8 @@ const handleCursorUpdate = cursor => {
5757
};
5858

5959
export const TimeSeries = ({
60-
isDarkMode,
60+
darkMode,
61+
backgroundColor,
6162
showGrid,
6263
legend,
6364
legendPosition,
@@ -89,8 +90,13 @@ export const TimeSeries = ({
8990
const timeZone = timezoneProvider(uiSettings)();
9091
const hasBarChart = series.some(({ bars }) => bars.show);
9192

93+
// compute the theme based on the bg color
94+
const theme = getTheme(darkMode, backgroundColor);
95+
// apply legend style change if bgColor is configured
96+
const classes = classNames('tvbVisTimeSeries', getChartClasses(backgroundColor));
97+
9298
return (
93-
<Chart ref={chartRef} renderer="canvas" className="tvbVisTimeSeries">
99+
<Chart ref={chartRef} renderer="canvas" className={classes}>
94100
<Settings
95101
showLegend={legend}
96102
legendPosition={legendPosition}
@@ -108,7 +114,7 @@ export const TimeSeries = ({
108114
},
109115
}
110116
}
111-
baseTheme={isDarkMode ? DARK_THEME : LIGHT_THEME}
117+
baseTheme={theme}
112118
tooltip={{
113119
snap: true,
114120
type: TooltipType.VerticalCursor,
@@ -240,7 +246,8 @@ TimeSeries.defaultProps = {
240246
};
241247

242248
TimeSeries.propTypes = {
243-
isDarkMode: PropTypes.bool,
249+
darkMode: PropTypes.bool,
250+
backgroundColor: PropTypes.string,
244251
showGrid: PropTypes.bool,
245252
legend: PropTypes.bool,
246253
legendPosition: PropTypes.string,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { getTheme } from './theme';
21+
import { LIGHT_THEME, DARK_THEME } from '@elastic/charts';
22+
23+
describe('TSVB theme', () => {
24+
it('should return the basic themes if no bg color is specified', () => {
25+
// use original dark/light theme
26+
expect(getTheme(false)).toEqual(LIGHT_THEME);
27+
expect(getTheme(true)).toEqual(DARK_THEME);
28+
29+
// discard any wrong/missing bg color
30+
expect(getTheme(true, null)).toEqual(DARK_THEME);
31+
expect(getTheme(true, '')).toEqual(DARK_THEME);
32+
expect(getTheme(true, undefined)).toEqual(DARK_THEME);
33+
});
34+
it('should return a highcontrast color theme for a different background', () => {
35+
// red use a near full-black color
36+
expect(getTheme(false, 'red').axes.axisTitleStyle.fill).toEqual('rgb(23,23,23)');
37+
38+
// violet increased the text color to full white for higer contrast
39+
expect(getTheme(false, '#ba26ff').axes.axisTitleStyle.fill).toEqual('rgb(255,255,255)');
40+
41+
// light yellow, prefer the LIGHT_THEME fill color because already with a good contrast
42+
expect(getTheme(false, '#fff49f').axes.axisTitleStyle.fill).toEqual('#333');
43+
});
44+
});
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import colorJS from 'color';
20+
import { Theme, LIGHT_THEME, DARK_THEME } from '@elastic/charts';
21+
22+
function computeRelativeLuminosity(rgb: string) {
23+
return colorJS(rgb).luminosity();
24+
}
25+
26+
function computeContrast(rgb1: string, rgb2: string) {
27+
return colorJS(rgb1).contrast(colorJS(rgb2));
28+
}
29+
30+
function getAAARelativeLum(bgColor: string, fgColor: string, ratio = 7) {
31+
const relLum1 = computeRelativeLuminosity(bgColor);
32+
const relLum2 = computeRelativeLuminosity(fgColor);
33+
if (relLum1 > relLum2) {
34+
// relLum1 is brighter, relLum2 is darker
35+
return (relLum1 + 0.05 - ratio * 0.05) / ratio;
36+
} else {
37+
// relLum1 is darker, relLum2 is brighter
38+
return Math.min(ratio * (relLum1 + 0.05) - 0.05, 1);
39+
}
40+
}
41+
42+
function getGrayFromRelLum(relLum: number) {
43+
if (relLum <= 0.0031308) {
44+
return relLum * 12.92;
45+
} else {
46+
return (1.0 + 0.055) * Math.pow(relLum, 1.0 / 2.4) - 0.055;
47+
}
48+
}
49+
50+
function getGrayRGBfromGray(gray: number) {
51+
const g = Math.round(gray * 255);
52+
return `rgb(${g},${g},${g})`;
53+
}
54+
55+
function getAAAGray(bgColor: string, fgColor: string, ratio = 7) {
56+
const relLum = getAAARelativeLum(bgColor, fgColor, ratio);
57+
const gray = getGrayFromRelLum(relLum);
58+
return getGrayRGBfromGray(gray);
59+
}
60+
61+
function findBestContrastColor(
62+
bgColor: string,
63+
lightFgColor: string,
64+
darkFgColor: string,
65+
ratio = 4.5
66+
) {
67+
const lc = computeContrast(bgColor, lightFgColor);
68+
const dc = computeContrast(bgColor, darkFgColor);
69+
if (lc >= dc) {
70+
if (lc >= ratio) {
71+
return lightFgColor;
72+
}
73+
return getAAAGray(bgColor, lightFgColor, ratio);
74+
}
75+
if (dc >= ratio) {
76+
return darkFgColor;
77+
}
78+
return getAAAGray(bgColor, darkFgColor, ratio);
79+
}
80+
81+
function isValidColor(color: string | null | undefined): color is string {
82+
if (typeof color !== 'string') {
83+
return false;
84+
}
85+
if (color.length === 0) {
86+
return false;
87+
}
88+
try {
89+
colorJS(color);
90+
return true;
91+
} catch {
92+
return false;
93+
}
94+
}
95+
96+
export function getTheme(darkMode: boolean, bgColor?: string | null): Theme {
97+
if (!isValidColor(bgColor)) {
98+
return darkMode ? DARK_THEME : LIGHT_THEME;
99+
}
100+
101+
const bgLuminosity = computeRelativeLuminosity(bgColor);
102+
const mainTheme = bgLuminosity <= 0.179 ? DARK_THEME : LIGHT_THEME;
103+
const color = findBestContrastColor(
104+
bgColor,
105+
LIGHT_THEME.axes.axisTitleStyle.fill,
106+
DARK_THEME.axes.axisTitleStyle.fill
107+
);
108+
return {
109+
...mainTheme,
110+
axes: {
111+
...mainTheme.axes,
112+
axisTitleStyle: {
113+
...mainTheme.axes.axisTitleStyle,
114+
fill: color,
115+
},
116+
tickLabelStyle: {
117+
...mainTheme.axes.tickLabelStyle,
118+
fill: color,
119+
},
120+
axisLineStyle: {
121+
...mainTheme.axes.axisLineStyle,
122+
stroke: color,
123+
},
124+
tickLineStyle: {
125+
...mainTheme.axes.tickLineStyle,
126+
stroke: color,
127+
},
128+
},
129+
};
130+
}
131+
132+
export function getChartClasses(bgColor?: string) {
133+
// keep the original theme color if no bg color is specified
134+
if (typeof bgColor !== 'string') {
135+
return;
136+
}
137+
const bgLuminosity = computeRelativeLuminosity(bgColor);
138+
return bgLuminosity <= 0.179 ? 'tvbVisTimeSeriesDark' : 'tvbVisTimeSeriesLight';
139+
}

0 commit comments

Comments
 (0)