diff --git a/src/platform/plugins/private/vis_types/xy/public/to_ast.ts b/src/platform/plugins/private/vis_types/xy/public/to_ast.ts index 27401a2e666c8..b57fb0e8295f9 100644 --- a/src/platform/plugins/private/vis_types/xy/public/to_ast.ts +++ b/src/platform/plugins/private/vis_types/xy/public/to_ast.ts @@ -18,6 +18,7 @@ import type { TimeRangeBounds } from '@kbn/data-plugin/common'; import type { PaletteOutput } from '@kbn/charts-plugin/common/expressions/palette/types'; import type { DateHistogramParams, HistogramParams } from '@kbn/chart-expressions-common'; import { LegendSize } from '@kbn/chart-expressions-common'; +import { charsToPixels } from './utils/truncate'; import type { Dimensions, Dimension, @@ -149,7 +150,8 @@ const prepareLayers = ( const getLabelArgs = (data: CategoryAxis, isTimeChart?: boolean) => { return { - truncate: data.labels.truncate, + // axis expressions expect pixels, we approximate pixels from character count + truncate: charsToPixels(data.labels.truncate), labelsOrientation: -(data.labels.rotate ?? (isTimeChart ? 0 : 90)), showOverlappingLabels: data.labels.filter === false, showDuplicates: data.labels.filter === false, diff --git a/src/platform/plugins/private/vis_types/xy/public/utils/truncate.test.ts b/src/platform/plugins/private/vis_types/xy/public/utils/truncate.test.ts new file mode 100644 index 0000000000000..7014475a3634a --- /dev/null +++ b/src/platform/plugins/private/vis_types/xy/public/utils/truncate.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { charsToPixels } from './truncate'; + +describe('charsToPixels', () => { + it('returns undefined when truncate is missing or non-positive', () => { + expect(charsToPixels(undefined)).toBeUndefined(); + expect(charsToPixels(null)).toBeUndefined(); + expect(charsToPixels(0)).toBeUndefined(); + expect(charsToPixels(-1)).toBeUndefined(); + }); + + it('converts character counts to an approximate pixel width', () => { + expect(charsToPixels(100)).toBe(660); + }); + + it('uses the provided font size', () => { + expect(charsToPixels(10, 12)).toBe(72); + }); +}); diff --git a/src/platform/plugins/private/vis_types/xy/public/utils/truncate.ts b/src/platform/plugins/private/vis_types/xy/public/utils/truncate.ts new file mode 100644 index 0000000000000..ce94dd16cd8d7 --- /dev/null +++ b/src/platform/plugins/private/vis_types/xy/public/utils/truncate.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * Average glyph width relative to tick label font size (Open Sans–like axis labels). + */ +const CHAR_WIDTH_RATIO = 0.6; + +/** Default Elastic Charts axis tick label font size used for conversion. */ +const DEFAULT_FONT_SIZE = 11; + +/** + * Converts character count approximate width in pixels. + */ +export const charsToPixels = ( + truncate: number | null | undefined, + fontSize: number = DEFAULT_FONT_SIZE +): number | undefined => { + if (truncate == null || truncate <= 0) { + return undefined; + } + + return Math.round(truncate * fontSize * CHAR_WIDTH_RATIO); +}; diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/common/i18n/index.tsx b/src/platform/plugins/shared/chart_expressions/expression_xy/common/i18n/index.tsx index e1f796729c9f5..545c2cd60e637 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/common/i18n/index.tsx +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/common/i18n/index.tsx @@ -332,7 +332,7 @@ export const strings = { }), getAxisTruncateHelp: () => i18n.translate('expressionXY.axisConfig.truncate.help', { - defaultMessage: 'The number of symbols before truncating', + defaultMessage: 'Maximum tick label width in pixels before truncating', }), getReferenceLineNameHelp: () => i18n.translate('expressionXY.referenceLine.name.help', { diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index 1ebe423526833..a6160566b7081 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -4179,6 +4179,8 @@ exports[`XYChart component it renders horizontal bar 1`] = ` } } tickFormat={[Function]} + tickLabelMaxLength="40%" + tickLabelTruncate="middle" title="c" /> l.isHistogram); const isEsqlMode = dataLayers.some((l) => l.table?.meta?.type === ESQL_TABLE_TYPE); const hasBars = dataLayers.some((l) => l.seriesType === SeriesTypes.BAR); + const isHorizontalBarChart = isHorizontalChart(dataLayers) && hasBars; const { baseDomain: rawXDomain, extendedDomain: xDomain } = getXDomain( data.datatableUtilities, @@ -484,7 +485,7 @@ export function XYChart({ ? getLinesCausedPaddings(visualConfigs, yAxesMap, shouldRotate) : {}; - const getYAxesStyle = (axis: AxisConfiguration) => { + const getYAxesStyle = (axis: AxisConfiguration): RecursivePartial => { const tickVisible = axis.showLabels; const position = getOriginalAxisPosition(axis.position, shouldRotate); @@ -702,6 +703,7 @@ export function XYChart({ visible: xAxisConfig?.showGridLines, strokeWidth: 1, }; + const xAxisStyle: RecursivePartial = isHorizontalTimeAxis ? { tickLabel: { @@ -947,17 +949,18 @@ export function XYChart({ title={xTitle} gridLine={gridLineStyle} hide={xAxisConfig?.hide || dataLayers[0]?.simpleView || !dataLayers[0]?.xAccessor} - tickFormat={(d) => { - let value = safeXAccessorLabelRenderer(d) || ''; - if (xAxisConfig?.truncate && value.length > xAxisConfig.truncate) { - value = `${value.slice(0, xAxisConfig.truncate)}...`; - } - return value; - }} + tickFormat={(d) => safeXAccessorLabelRenderer(d) || ''} maximumFractionDigits={xTickDecimals} style={xAxisStyle} showOverlappingLabels={xAxisConfig?.showOverlappingLabels} showDuplicatedTicks={xAxisConfig?.showDuplicates} + tickLabelMaxLength={ + xAxisConfig?.truncate ?? (isHorizontalBarChart ? '40%' : undefined) + } + tickLabelTruncate={ + // If legacy truncate is set, preserve end truncation behavior. + isHorizontalBarChart ? (xAxisConfig?.truncate ? 'end' : 'middle') : undefined + } {...getOverridesFor(overrides, 'axisX')} /> {isSplitChart && splitTable && ( @@ -983,18 +986,13 @@ export function XYChart({ visible: axis.showGridLines, }} hide={axis.hide || dataLayers[0]?.simpleView} - tickFormat={(d) => { - let value = axis.formatter?.convert(d) || ''; - if (axis.truncate && value.length > axis.truncate) { - value = `${value.slice(0, axis.truncate)}...`; - } - return value; - }} + tickFormat={(d) => axis.formatter?.convert(d) || ''} maximumFractionDigits={tickDecimals} style={getYAxesStyle(axis)} domain={getYAxisDomain(axis)} showOverlappingLabels={axis.showOverlappingLabels} showDuplicatedTicks={axis.showDuplicates} + tickLabelMaxLength={axis.truncate} {...getOverridesFor( overrides, /left/i.test(axis.groupId) ? 'axisLeft' : 'axisRight'