Skip to content

Commit

Permalink
fix: add contrast check for text over sparkline
Browse files Browse the repository at this point in the history
  • Loading branch information
nickofthyme committed Oct 6, 2023
1 parent ac205ac commit e254ac8
Show file tree
Hide file tree
Showing 13 changed files with 121 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export function shapeViewModel<D extends BaseDatum = Datum>(
visible: !isValueInRanges(d.value, bandsToHide),
formatted: formattedValue,
fontSize,
textColor: fillTextColor(background.fallbackColor, cellBackgroundColor, background.color),
textColor: fillTextColor(background.fallbackColor, cellBackgroundColor, background.color).color.keyword,
});
return acc;
}, new Map());
Expand Down
15 changes: 8 additions & 7 deletions packages/charts/src/chart_types/metric/renderer/dom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';

import { Metric as MetricComponent } from './metric';
import { highContrastColor } from '../../../../common/color_calcs';
import { ColorContrastOptions, highContrastColor } from '../../../../common/color_calcs';
import { colorToRgba } from '../../../../common/color_library_wrappers';
import { Colors } from '../../../../common/colors';
import { getResolvedBackgroundColor } from '../../../../common/fill_text_color';
import { BasicListener, ElementClickListener, ElementOverListener, settingsBuildProps } from '../../../../specs';
import { onChartRendered } from '../../../../state/actions/chart';
Expand Down Expand Up @@ -97,10 +96,11 @@ class Component extends React.Component<StateProps & DispatchProps> {

const panel = { width: width / maxColumns, height: height / totalRows };
const backgroundColor = getResolvedBackgroundColor(background.fallbackColor, background.color);
const emptyForegroundColor =
highContrastColor(colorToRgba(backgroundColor)) === Colors.White.rgba
? style.text.lightColor
: style.text.darkColor;
const contrastOptions: ColorContrastOptions = {
lightColor: colorToRgba(style.text.lightColor),
darkColor: colorToRgba(style.text.darkColor),
};
const { color: emptyForegroundColor } = highContrastColor(colorToRgba(backgroundColor), undefined, contrastOptions);

return (
// eslint-disable-next-line jsx-a11y/no-redundant-roles
Expand All @@ -126,7 +126,7 @@ class Component extends React.Component<StateProps & DispatchProps> {
return !datum ? (
<li key={`${columnIndex}-${rowIndex}`} role="presentation">
<div className={emptyMetricClassName} style={{ borderColor: style.border }}>
<div className="echMetricEmpty" style={{ borderColor: emptyForegroundColor }}></div>
<div className="echMetricEmpty" style={{ borderColor: emptyForegroundColor.keyword }}></div>
</div>
</li>
) : (
Expand All @@ -142,6 +142,7 @@ class Component extends React.Component<StateProps & DispatchProps> {
panel={panel}
style={style}
backgroundColor={backgroundColor}
contrastOptions={contrastOptions}
onElementClick={onElementClick}
onElementOut={onElementOut}
onElementOver={onElementOver}
Expand Down
32 changes: 24 additions & 8 deletions packages/charts/src/chart_types/metric/renderer/dom/metric.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import classNames from 'classnames';
import React, { CSSProperties, useState } from 'react';

import { ProgressBar } from './progress';
import { SparkLine } from './sparkline';
import { SparkLine, getSparkLineColor } from './sparkline';
import { MetricText } from './text';
import { changeColorLightness, colorToRgba } from '../../../../common/color_library_wrappers';
import { ColorContrastOptions } from '../../../../common/color_calcs';
import { changeColorLightness } from '../../../../common/color_library_wrappers';
import { Color } from '../../../../common/colors';
import { DEFAULT_CSS_CURSOR } from '../../../../common/constants';
import { fillTextColor } from '../../../../common/fill_text_color';
Expand Down Expand Up @@ -40,6 +41,7 @@ export const Metric: React.FunctionComponent<{
panel: Size;
style: MetricStyle;
backgroundColor: Color;
contrastOptions: ColorContrastOptions;
locale: string;
onElementClick?: ElementClickListener;
onElementOver?: ElementOverListener;
Expand All @@ -55,6 +57,7 @@ export const Metric: React.FunctionComponent<{
panel,
style,
backgroundColor,
contrastOptions,
locale,
onElementClick,
onElementOver,
Expand Down Expand Up @@ -98,11 +101,24 @@ export const Metric: React.FunctionComponent<{
backgroundColor,
isMetricWProgress(datum) ? backgroundColor : datum.color,
undefined,
{
lightColor: colorToRgba(style.text.lightColor),
darkColor: colorToRgba(style.text.darkColor),
},
contrastOptions,
);
let finalTextColor = highContrastTextColor.color;

if (isMetricWTrend(datum)) {
const { ratio, color, shade } = fillTextColor(
backgroundColor,
getSparkLineColor(datum.color),
undefined,
contrastOptions,
);

// TODO verify this check is applied correctly
if (shade !== highContrastTextColor.shade && ratio > highContrastTextColor.ratio) {
finalTextColor = color;
}
}

const onElementClickHandler = () => onElementClick && onElementClick([event]);

return (
Expand Down Expand Up @@ -146,14 +162,14 @@ export const Metric: React.FunctionComponent<{
panel={panel}
style={style}
onElementClick={onElementClick ? onElementClickHandler : undefined}
highContrastTextColor={highContrastTextColor}
highContrastTextColor={finalTextColor.keyword}
locale={locale}
/>
{isMetricWTrend(datumWithInteractionColor) && <SparkLine id={metricHTMLId} datum={datumWithInteractionColor} />}
{isMetricWProgress(datumWithInteractionColor) && (
<ProgressBar datum={datumWithInteractionColor} barBackground={style.barBackground} />
)}
<div className="echMetric--outline" style={{ color: highContrastTextColor }}></div>
<div className="echMetric--outline" style={{ color: finalTextColor.keyword }}></div>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import { isFiniteNumber } from '../../../../utils/common';
import { CurveType } from '../../../../utils/curves';
import { MetricTrendShape, MetricWTrend } from '../../specs';

/** @internal */
export const getSparkLineColor = (color: MetricWTrend['color']) => {
const [h, s, l, a] = colorToHsl(color);
return hslToColor(h, s, l >= 0.8 ? l - 0.1 : l + 0.1, a);
};

/** @internal */
export const SparkLine: FunctionComponent<{
id: string;
Expand All @@ -36,8 +42,6 @@ export const SparkLine: FunctionComponent<{
trendShape === MetricTrendShape.Bars ? CurveType.CURVE_STEP_AFTER : CurveType.LINEAR,
);

const [h, s, l, a] = colorToHsl(color);
const pathColor = hslToColor(h, s, l >= 0.8 ? l - 0.1 : l + 0.1, a);
const titleId = `${id}-trend-title`;
const descriptionId = `${id}-trend-description`;
return (
Expand Down Expand Up @@ -75,7 +79,7 @@ export const SparkLine: FunctionComponent<{
<path
d={path.area(trend)}
transform="translate(0, 0.5),scale(1,0.5)"
fill={pathColor}
fill={getSparkLineColor(color)}
stroke="none"
strokeWidth={0}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,6 @@ describe('Test fillTextColor function', () => {
const fillColor = 'rgba(55, 126, 184, 0.7)';
const containerBackgroundColor = 'white';
const expectedAdjustedTextColor = 'rgba(0, 0, 0, 1)'; // with WCAG 2 is black
expect(fillTextColor(fillColor, containerBackgroundColor)).toEqual(expectedAdjustedTextColor);
expect(fillTextColor(fillColor, containerBackgroundColor).color.keyword).toEqual(expectedAdjustedTextColor);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function linkTextLayout(
}
const textColor =
linkLabel.textColor === ColorVariant.Adaptive
? fillTextColor(fallbackBGColor, null, backgroundColor)
? fillTextColor(fallbackBGColor, null, backgroundColor).color.keyword
: linkLabel.textColor;
const labelFontSpec: Font = { ...linkLabel, textColor };
const valueFontSpec: Font = { ...linkLabel, ...linkLabel.valueFont, textColor };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export function makeQuadViewModel(
const textColor = textNegligible
? Colors.Transparent.keyword
: fillLabel.textColor === ColorVariant.Adaptive
? fillTextColor(fallbackBGColor, fillColor, backgroundColor)
? fillTextColor(fallbackBGColor, fillColor, backgroundColor).color.keyword
: fillLabel.textColor;

return { index, innerIndex, smAccessorValue, strokeWidth, strokeStyle, fillColor, textColor, ...node };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,8 @@ function getTextColors(
shadowColor: fillDefinition.borderColor || Colors.Transparent.keyword,
};
}
const fillColor = fillTextColor(fallbackBGColor, geometryColor, backgroundColor);
const shadowColor = fillTextColor(fallbackBGColor, fillColor, backgroundColor);
const fillColor = fillTextColor(fallbackBGColor, geometryColor, backgroundColor).color.keyword;
const shadowColor = fillTextColor(fallbackBGColor, fillColor, backgroundColor).color.keyword;

return {
fillColor,
Expand Down
60 changes: 50 additions & 10 deletions packages/charts/src/common/color_calcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Required } from 'utility-types';

import { APCAContrast } from './apca_color_contrast';
import { RgbaTuple, RGBATupleToString, RgbTuple } from './color_library_wrappers';
import { Colors } from './colors';
import { ColorDefinition, Colors } from './colors';
import { getWCAG2ContrastRatio } from './wcag2_color_contrast';

/** @internal */
Expand Down Expand Up @@ -59,25 +59,65 @@ const getOptionWithDefaults = (options: ColorContrastOptions = {}): Required<Col
...options,
});

function getHighContrastColorWCAG2(background: RgbTuple, options: ColorContrastOptions = {}): RgbaTuple {
function getHighContrastColorWCAG2(background: RgbTuple, options: ColorContrastOptions = {}): HighContrastResult {
const { lightColor, darkColor } = getOptionWithDefaults(options);
const wWhite = getWCAG2ContrastRatio(lightColor, background);
const wBlack = getWCAG2ContrastRatio(darkColor, background);
return wWhite >= wBlack ? lightColor : darkColor;
const wLight = getWCAG2ContrastRatio(lightColor, background);
const wDark = getWCAG2ContrastRatio(darkColor, background);
return wLight >= wDark
? {
color: {
rgba: lightColor,
keyword: RGBATupleToString(lightColor),
},
ratio: wLight,
shade: 'light',
}
: {
color: {
rgba: darkColor,
keyword: RGBATupleToString(darkColor),
},
ratio: wDark,
shade: 'dark',
};
}

function getHighContrastColorAPCA(background: RgbTuple, options: ColorContrastOptions = {}): RgbaTuple {
function getHighContrastColorAPCA(background: RgbTuple, options: ColorContrastOptions = {}): HighContrastResult {
const { lightColor, darkColor } = getOptionWithDefaults(options);
const wWhiteText = Math.abs(APCAContrast(background, lightColor));
const wBlackText = Math.abs(APCAContrast(background, darkColor));
return wWhiteText > wBlackText ? lightColor : darkColor;
const wLightText = Math.abs(APCAContrast(background, lightColor));
const wDarkText = Math.abs(APCAContrast(background, darkColor));

return wLightText > wDarkText
? {
color: {
rgba: lightColor,
keyword: RGBATupleToString(lightColor),
},
ratio: wLightText,
shade: 'light',
}
: {
color: {
rgba: darkColor,
keyword: RGBATupleToString(darkColor),
},
ratio: wDarkText,
shade: 'dark',
};
}

const HIGH_CONTRAST_FN = {
WCAG2: getHighContrastColorWCAG2,
WCAG3: getHighContrastColorAPCA,
};

/** @internal */
export interface HighContrastResult {
color: ColorDefinition;
ratio: number;
shade: 'light' | 'dark';
}

/**
* Use white or black text depending on the high contrast mode used
* @internal
Expand All @@ -86,6 +126,6 @@ export function highContrastColor(
background: RgbTuple,
mode: keyof typeof HIGH_CONTRAST_FN = 'WCAG2',
options?: ColorContrastOptions,
): RgbaTuple {
): HighContrastResult {
return HIGH_CONTRAST_FN[mode](background, options);
}
11 changes: 7 additions & 4 deletions packages/charts/src/common/colors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ import { RgbaTuple } from './color_library_wrappers';
export type Color = string; // todo static/runtime type it this for proper color string content; several places in the code, and ultimate use, dictate it not be an empty string

/** @internal */
export const Colors: Record<
'Red' | 'White' | 'Black' | 'Transparent' | 'DarkOpaqueRed',
{ keyword: Color; rgba: RgbaTuple }
> = {
export interface ColorDefinition {
keyword: Color;
rgba: RgbaTuple;
}

/** @internal */
export const Colors: Record<'Red' | 'White' | 'Black' | 'Transparent' | 'DarkOpaqueRed', ColorDefinition> = {
Red: {
keyword: 'red',
rgba: [255, 0, 0, 1],
Expand Down
23 changes: 13 additions & 10 deletions packages/charts/src/common/fill_text_color.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,19 @@ import { fillTextColor, TRANSPARENT_LIMIT } from './fill_text_color';
describe('Fill text color', () => {
describe('highContrastColor', () => {
it('should return black when background is white', () => {
expect(fillTextColor(Colors.White.keyword, null, Colors.White.keyword)).toEqual(
expect(fillTextColor(Colors.White.keyword, null, Colors.White.keyword).color.keyword).toEqual(
RGBATupleToString(Colors.Black.rgba),
);
});
// test contrast computation
it('should return black with yellow/semi-transparent background', () => {
expect(fillTextColor(Colors.White.keyword, null, 'rgba(255,255,51,0.3)')).toEqual(
expect(fillTextColor(Colors.White.keyword, null, 'rgba(255,255,51,0.3)').color.keyword).toEqual(
RGBATupleToString(Colors.Black.rgba),
);
});
it('should use white text for Thailand color', () => {
// black for WCAG2, white for WCAG3
expect(fillTextColor(Colors.White.keyword, null, 'rgba(120, 116, 178, 1)')).toEqual(
expect(fillTextColor(Colors.White.keyword, null, 'rgba(120, 116, 178, 1)').color.keyword).toEqual(
RGBATupleToString(Colors.Black.rgba),
);
});
Expand All @@ -35,29 +35,32 @@ describe('Fill text color', () => {
const fallbackBG = Colors.White.keyword;

it('should use fallbackBG if background is transparent and no foreground', () => {
expect(fillTextColor(fallbackBG, null, Colors.Transparent.keyword)).toEqual('rgba(0, 0, 0, 1)');
expect(fillTextColor(fallbackBG, null, Colors.Transparent.keyword).color.keyword).toEqual('rgba(0, 0, 0, 1)');
});

it('should use fallbackBG if background is lower tranparent limit and no foreground', () => {
expect(fillTextColor(fallbackBG, null, `rgba(120, 116, 178, ${TRANSPARENT_LIMIT - Number.EPSILON})`)).toEqual(
'rgba(0, 0, 0, 1)',
);
expect(
fillTextColor(fallbackBG, null, `rgba(120, 116, 178, ${TRANSPARENT_LIMIT - Number.EPSILON})`).color.keyword,
).toEqual('rgba(0, 0, 0, 1)');
});

it('should contrast fallbackBG with foreground if background is transparent', () => {
expect(fillTextColor(fallbackBG, 'rgba(0, 0, 255, 1)', Colors.Transparent.keyword)).toEqual(
expect(fillTextColor(fallbackBG, 'rgba(0, 0, 255, 1)', Colors.Transparent.keyword).color.keyword).toEqual(
'rgba(255, 255, 255, 1)',
);
});

it('should contrast fallbackBG with transparent foreground if background is transparent', () => {
expect(
fillTextColor(fallbackBG, `rgba(0, 0, 255, ${TRANSPARENT_LIMIT - Number.EPSILON})`, Colors.Transparent.keyword),
fillTextColor(fallbackBG, `rgba(0, 0, 255, ${TRANSPARENT_LIMIT - Number.EPSILON})`, Colors.Transparent.keyword)
.color.keyword,
).toEqual('rgba(0, 0, 0, 1)');
});

it('should return constrast with opac foreground and opac background', () => {
expect(fillTextColor(fallbackBG, 'rgba(0, 0, 255, 1)', 'rgba(255, 0, 0, 1)')).toEqual('rgba(255, 255, 255, 1)');
expect(fillTextColor(fallbackBG, 'rgba(0, 0, 255, 1)', 'rgba(255, 0, 0, 1)').color.keyword).toEqual(
'rgba(255, 255, 255, 1)',
);
});
});
});
8 changes: 4 additions & 4 deletions packages/charts/src/common/fill_text_color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Side Public License, v 1.
*/

import { ColorContrastOptions, combineColors, highContrastColor } from './color_calcs';
import { ColorContrastOptions, HighContrastResult, combineColors, highContrastColor } from './color_calcs';
import { colorToRgba, RGBATupleToString } from './color_library_wrappers';
import { Color, Colors } from './colors';

Expand All @@ -27,7 +27,7 @@ export function fillTextColor(
foreground: Color | null,
background: Color = Colors.Transparent.keyword,
options?: ColorContrastOptions,
): Color {
): HighContrastResult {
let backgroundRGBA = colorToRgba(background);

if (backgroundRGBA[3] < TRANSPARENT_LIMIT) {
Expand All @@ -37,10 +37,10 @@ export function fillTextColor(
if (foreground) {
const foregroundRGBA = colorToRgba(foreground);
const blendedFgBg = combineColors(foregroundRGBA, backgroundRGBA);
return RGBATupleToString(highContrastColor(blendedFgBg, 'WCAG2', options));
return highContrastColor(blendedFgBg, 'WCAG2', options);
}

return RGBATupleToString(highContrastColor(backgroundRGBA));
return highContrastColor(backgroundRGBA);
}

/** @internal */
Expand Down
Loading

0 comments on commit e254ac8

Please sign in to comment.