diff --git a/e2e/screenshots/metric_stories.test.ts-snapshots/metric/should-render-metric-value-fit-font-size-chrome-linux.png b/e2e/screenshots/metric_stories.test.ts-snapshots/metric/should-render-metric-value-fit-font-size-chrome-linux.png new file mode 100644 index 0000000000..3790598590 Binary files /dev/null and b/e2e/screenshots/metric_stories.test.ts-snapshots/metric/should-render-metric-value-fit-font-size-chrome-linux.png differ diff --git a/e2e/tests/metric_stories.test.ts b/e2e/tests/metric_stories.test.ts index a6dca322d0..dec9587eee 100644 --- a/e2e/tests/metric_stories.test.ts +++ b/e2e/tests/metric_stories.test.ts @@ -62,6 +62,12 @@ test.describe('Metric', () => { ); }); + test('should render metric value fit font size', async ({ page }) => { + await common.expectChartAtUrlToMatchScreenshot(page)( + 'http://localhost:9001/?path=/story/metric-alpha--array-of-values&globals=toggles.showHeader:true;toggles.showChartTitle:false;toggles.showChartDescription:false;toggles.showChartBoundary:false;theme:light&knob-Blue groupId_Annotations=primary&knob-Dataset_Legend=shortCopyDataset&knob-Domain axis_Annotations=y&knob-EUI icon glyph name=warning&knob-EUI value icon glyph name=sortUp&knob-Enable debug state=true&knob-FadeOnFocusingOthers_Animations=true&knob-Hide color picker_Legend=true&knob-Legend Value_Legend=median,min,max&knob-Legend position_Legend=right&knob-Number formatting precision_Legend=2&knob-Outside dimension_Annotations=4&knob-Popover position_Legend=leftCenter&knob-Red groupId_Annotations=primary&knob-Render outside chart_Annotations=true&knob-Scale type=linear&knob-Series type=area&knob-SeriesType=bar&knob-Tick size=10&knob-annotation count_Styles=6&knob-annotation opacity_Styles=0.5&knob-annotation zIndex_Styles=0&knob-attach click handler=true&knob-blending background=rgba(255,255,255,1)&knob-chartRotation=180&knob-empty background=transparent&knob-max trend data points=30&knob-number of columns=3&knob-value font mode=fit&knob-value font size (px)=40&knob-show grid border=&knob-debug randomized data=', + ); + }); + pwEach.describe(['trend', 'bar', 'none'])( (v) => `Metric - ${v} type`, (type) => { diff --git a/packages/charts/src/chart_types/bullet_graph/renderer/canvas/index.tsx b/packages/charts/src/chart_types/bullet_graph/renderer/canvas/index.tsx index 6f9207aba5..26bfc92896 100644 --- a/packages/charts/src/chart_types/bullet_graph/renderer/canvas/index.tsx +++ b/packages/charts/src/chart_types/bullet_graph/renderer/canvas/index.tsx @@ -207,11 +207,13 @@ class Component extends React.Component { textLightColor: 'white', textDarkColor: 'black', nonFiniteText: 'N/A', + valueFontSize: 'default', // bullet does not support fit mode })} locale={locale} backgroundColor={backgroundColor} contrastOptions={contrastOptions} panel={{ width: size.width / stats.columns, height: size.height / stats.rows }} + fittedValueFontSize={NaN} /> ); }} diff --git a/packages/charts/src/chart_types/metric/renderer/dom/index.tsx b/packages/charts/src/chart_types/metric/renderer/dom/index.tsx index 5e4daf3dea..4d3f66aacb 100644 --- a/packages/charts/src/chart_types/metric/renderer/dom/index.tsx +++ b/packages/charts/src/chart_types/metric/renderer/dom/index.tsx @@ -10,16 +10,17 @@ /* eslint-disable react/no-array-index-key */ import classNames from 'classnames'; -import React from 'react'; +import React, { useEffect } from 'react'; import { connect } from 'react-redux'; import { bindActionCreators, Dispatch } from 'redux'; import { Metric as MetricComponent } from './metric'; +import { getFitValueFontSize, getMetricTextPartDimensions } from './text'; import { ColorContrastOptions, combineColors, highContrastColor } from '../../../../common/color_calcs'; import { colorToRgba, RGBATupleToString } from '../../../../common/color_library_wrappers'; import { Color } from '../../../../common/colors'; import { BasicListener, ElementClickListener, ElementOverListener, settingsBuildProps } from '../../../../specs'; -import { onChartRendered } from '../../../../state/actions/chart'; +import { onChartRendered as onChartRenderedAction } from '../../../../state/actions/chart'; import { GlobalChartState } from '../../../../state/chart_state'; import { A11ySettings, @@ -56,134 +57,154 @@ interface StateProps { } interface DispatchProps { - onChartRendered: typeof onChartRendered; + onChartRendered: typeof onChartRenderedAction; } -class Component extends React.Component { - static displayName = 'Metric'; - componentDidMount() { - this.props.onChartRendered(); +function Component({ + chartId, + hasTitles, + initialized, + size: { width, height }, + a11y, + specs: [spec], + style, + backgroundColor, + onElementClick, + onElementOut, + onElementOver, + locale, + onChartRendered, +}: StateProps & DispatchProps) { + useEffect(() => { + onChartRendered(); + }); + + if (!initialized || !spec || width === 0 || height === 0) { + return null; } - componentDidUpdate() { - this.props.onChartRendered(); - } + const { data } = spec; - render() { - const { - chartId, - hasTitles, - initialized, - size: { width, height }, - a11y, - specs: [spec], // ignoring other specs - style, - backgroundColor, - onElementClick, - onElementOut, - onElementOver, - locale, - } = this.props; - if (!initialized || !spec || width === 0 || height === 0) { - return null; - } - - const { data } = spec; - - const totalRows = data.length; - const maxColumns = data.reduce((acc, row) => { - return Math.max(acc, row.length); - }, 0); - - const panel = { width: width / maxColumns, height: height / totalRows }; - const contrastOptions: ColorContrastOptions = { - lightColor: colorToRgba(style.textLightColor), - darkColor: colorToRgba(style.textDarkColor), - }; - - const emptyBackgroundRGBA = combineColors(colorToRgba(style.emptyBackground), colorToRgba(backgroundColor)); - const emptyBackground = RGBATupleToString(emptyBackgroundRGBA); - const { color: emptyForegroundColor } = highContrastColor(emptyBackgroundRGBA, undefined, contrastOptions); - - return ( - // eslint-disable-next-line jsx-a11y/no-redundant-roles - - ); - } + const totalRows = data.length; + const maxColumns = data.reduce((acc, row) => { + return Math.max(acc, row.length); + }, 0); + + const panel = { width: width / maxColumns, height: height / totalRows }; + const contrastOptions: ColorContrastOptions = { + lightColor: colorToRgba(style.textLightColor), + darkColor: colorToRgba(style.textDarkColor), + }; + + const emptyBackgroundRGBA = combineColors(colorToRgba(style.emptyBackground), colorToRgba(backgroundColor)); + const emptyBackground = RGBATupleToString(emptyBackgroundRGBA); + const { color: emptyForegroundColor } = highContrastColor(emptyBackgroundRGBA, undefined, contrastOptions); + + const fittedValueFontSize = + style.valueFontSize !== 'fit' + ? NaN + : data + .flat() + .filter((d) => d !== undefined) + .reduce((acc, datum) => { + const { sizes, progressBarWidth, visibility, textParts } = getMetricTextPartDimensions( + datum, + panel, + style, + locale, + ); + const fontSize = getFitValueFontSize( + sizes.valueFontSize, + panel.width - progressBarWidth, + visibility.gapHeight, + textParts, + style.minValueFontSize, + datum.valueIcon !== undefined, + ); + return Math.min(acc, fontSize); + }, Number.MAX_SAFE_INTEGER); + + return ( + // eslint-disable-next-line jsx-a11y/no-redundant-roles + + ); } +Component.displayName = 'Metric'; + const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => bindActionCreators( { - onChartRendered, + onChartRendered: onChartRenderedAction, }, dispatch, ); diff --git a/packages/charts/src/chart_types/metric/renderer/dom/metric.tsx b/packages/charts/src/chart_types/metric/renderer/dom/metric.tsx index 4fae2aed92..d461ca1d2c 100644 --- a/packages/charts/src/chart_types/metric/renderer/dom/metric.tsx +++ b/packages/charts/src/chart_types/metric/renderer/dom/metric.tsx @@ -43,6 +43,7 @@ export const Metric: React.FunctionComponent<{ backgroundColor: Color; contrastOptions: ColorContrastOptions; locale: string; + fittedValueFontSize: number; onElementClick?: ElementClickListener; onElementOver?: ElementOverListener; onElementOut?: BasicListener; @@ -62,6 +63,7 @@ export const Metric: React.FunctionComponent<{ onElementClick, onElementOver, onElementOut, + fittedValueFontSize, }) => { const progressBarSize = 'small'; // currently we provide only the small progress bar; const [mouseState, setMouseState] = useState<'leave' | 'enter' | 'down'>('leave'); @@ -175,6 +177,7 @@ export const Metric: React.FunctionComponent<{ progressBarSize={progressBarSize} highContrastTextColor={finalTextColor.keyword} locale={locale} + fittedValueFontSize={fittedValueFontSize} /> {isMetricWTrend(datumWithInteractionColor) && } {isMetricWProgress(datumWithInteractionColor) && ( diff --git a/packages/charts/src/chart_types/metric/renderer/dom/text.tsx b/packages/charts/src/chart_types/metric/renderer/dom/text.tsx index f1ac73f239..208df06d9c 100644 --- a/packages/charts/src/chart_types/metric/renderer/dom/text.tsx +++ b/packages/charts/src/chart_types/metric/renderer/dom/text.tsx @@ -191,35 +191,6 @@ function elementVisibility( }); } -/** - * Approximate font size to fit given available space - */ -function getFitValueFontSize( - valueFontSize: number, - maxWidth: number, - gapHeight: number, - textParts: TextParts[], - minValueFontSize: number, - hasIcon: boolean, -) { - const widthConstrainedSize = withTextMeasure((textMeasure) => { - const iconMultiplier = hasIcon ? 1 : 0; - const textWidth = textParts.reduce((sum, { text, emphasis }) => { - const fontSize = emphasis === 'small' ? valueFontSize / VALUE_PART_FONT_RATIO : valueFontSize; - return sum + textMeasure(text, VALUE_FONT, fontSize).width; - }, 0); - const ratio = textWidth / valueFontSize; - return (maxWidth - iconMultiplier * PADDING) / (ratio + iconMultiplier / VALUE_PART_FONT_RATIO); - }); - const heightConstrainedSize = valueFontSize + gapHeight; - const fitValueFontSize = Math.max(Math.min(heightConstrainedSize, widthConstrainedSize), minValueFontSize); - - return { - valueFontSize: fitValueFontSize, - valuePartFontSize: fitValueFontSize / VALUE_PART_FONT_RATIO, - }; -} - function lineClamp(maxLines: number): CSSProperties { return { textOverflow: 'ellipsis', @@ -241,36 +212,39 @@ export const MetricText: React.FunctionComponent<{ onElementClick?: () => void; highContrastTextColor: Color; progressBarSize: 'small'; + fittedValueFontSize: number; locale: string; -}> = ({ id, datum, panel, style, onElementClick, highContrastTextColor, progressBarSize, locale }) => { +}> = ({ + id, + datum, + panel, + style, + onElementClick, + highContrastTextColor, + progressBarSize, + locale, + fittedValueFontSize, +}) => { + const { sizes, hasProgressBar, progressBarDirection, visibility, textParts } = getMetricTextPartDimensions( + datum, + panel, + style, + locale, + ); const { extra, body } = datum; - const sizes = getFontSizes(HEIGHT_BP, panel.height, style); - const hasProgressBar = isMetricWProgress(datum); - const hasTarget = !isNil((datum as MetricWNumber)?.target); - const progressBarDirection = isMetricWProgress(datum) ? datum.progressBarDirection : undefined; - const progressBarWidth = - hasProgressBar && progressBarDirection === LayoutDirection.Vertical - ? PROGRESS_BAR_WIDTH + (hasTarget ? PROGRESS_BAR_TARGET_WIDTH : 0) - : 0; const containerClassName = classNames('echMetricText', { [`echMetricText--${progressBarSize}`]: hasProgressBar, 'echMetricText--vertical': progressBarDirection === LayoutDirection.Vertical, 'echMetricText--horizontal': progressBarDirection === LayoutDirection.Horizontal, }); - const visibility = elementVisibility(datum, panel, sizes, locale, style.valueFontSize === 'fit'); - const textParts = getTextParts(datum, style); const { valueFontSize, valuePartFontSize } = - style.valueFontSize !== 'fit' - ? sizes - : getFitValueFontSize( - sizes.valueFontSize, - (panel.width - progressBarWidth - 2 * PADDING) * 0.98, // small buffer to prevent clipping - visibility.gapHeight, - textParts, - style.minValueFontSize, - datum.valueIcon !== undefined, - ); + style.valueFontSize === 'fit' + ? { + valueFontSize: fittedValueFontSize, + valuePartFontSize: fittedValueFontSize / VALUE_PART_FONT_RATIO, + } + : sizes; const TitleElement = () => ( { + const iconMultiplier = hasIcon ? 1 : 0; + const textWidth = textParts.reduce((sum, { text, emphasis }) => { + const fontSize = emphasis === 'small' ? valueFontSize / VALUE_PART_FONT_RATIO : valueFontSize; + return sum + textMeasure(text, VALUE_FONT, fontSize).width; + }, 0); + const ratio = textWidth / valueFontSize; + return (maxWidth - iconMultiplier * PADDING) / (ratio + iconMultiplier / VALUE_PART_FONT_RATIO); + }); + const heightConstrainedSize = valueFontSize + gapHeight; + + return Math.max(Math.min(heightConstrainedSize, widthConstrainedSize), minValueFontSize); +} + +/** @internal */ +export function getMetricTextPartDimensions(datum: MetricDatum, panel: Size, style: MetricStyle, locale: string) { + const sizes = getFontSizes(HEIGHT_BP, panel.height, style); + const hasProgressBar = isMetricWProgress(datum); + const hasTarget = !isNil((datum as MetricWNumber)?.target); + const progressBarDirection = isMetricWProgress(datum) ? datum.progressBarDirection : undefined; + + return { + sizes, + hasProgressBar, + progressBarDirection, + progressBarWidth: + hasProgressBar && progressBarDirection === LayoutDirection.Vertical + ? PROGRESS_BAR_WIDTH + (hasTarget ? PROGRESS_BAR_TARGET_WIDTH : 0) + : 0, + visibility: elementVisibility(datum, panel, sizes, locale, style.valueFontSize === 'fit'), + textParts: getTextParts(datum, style), + }; +} diff --git a/storybook/stories/metric/2_grid.story.tsx b/storybook/stories/metric/2_grid.story.tsx index 2ab58e9c4f..e5cba7b3f9 100644 --- a/storybook/stories/metric/2_grid.story.tsx +++ b/storybook/stories/metric/2_grid.story.tsx @@ -52,6 +52,16 @@ export const Example: ChartsStory = (_, { title, description }) => { const progressBarDirection = select('progress bar direction', ['horizontal', 'vertical'], 'vertical'); const maxDataPoints = number('max trend data points', 30, { min: 0, max: 50, step: 1 }); const emptyBackground = color('empty background', 'transparent'); + const valueFontSizeMode = select( + 'value font mode', + { + Default: 'default', + Fit: 'fit', + Custom: 'custom', + }, + 'default', + ); + const valueFontSize = number('value font size (px)', 40, { min: 0, step: 10 }); const data: (MetricDatum | undefined)[] = useMemo( () => [ @@ -215,6 +225,7 @@ export const Example: ChartsStory = (_, { title, description }) => { theme={{ metric: { emptyBackground, + valueFontSize: valueFontSizeMode === 'custom' ? valueFontSize : valueFontSizeMode, }, }} baseTheme={useBaseTheme()}