Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions packages/charts/api/charts.api.md
Comment thread
nickofthyme marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ export interface AxisStyle {
rotation: number;
offset: TextOffset;
alignment: TextAlignment;
truncation?: Truncate;
};
// (undocumented)
tickLine: TickStyle;
Expand Down Expand Up @@ -3539,6 +3540,14 @@ export interface TreeNode extends AngleFromTo {
y1: TreeLevel;
}

// @public (undocumented)
export interface Truncate {
// (undocumented)
position: 'end' | 'start' | 'middle';
// (undocumented)
width: Pixels;
}

// @public
export interface UnaryAccessorFn<D extends BaseDatum = any, Return = any> {
// (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@

import { getScaleConfigsFromSpecsSelector } from './get_api_scale_configs';
import { getAxisSpecsSelector, getSeriesSpecsSelector } from './get_specs';
import type { Font } from '../../../../common/text_utils';
import { fitText } from '../../../../common/text_utils';
import { createCustomCachedSelector } from '../../../../state/create_selector';
import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_spec';
import type { TextMeasure } from '../../../../utils/bbox/canvas_text_bbox_calculator';
import type { Rotation } from '../../../../utils/common';
import type { SpecId } from '../../../../utils/ids';
import type { AxisStyle } from '../../../../utils/themes/theme';
import { defaultTickFormatter, isXDomain } from '../../utils/axis_utils';
import { groupBy } from '../../utils/group_data_series';
import type { AxisSpec } from '../../utils/specs';
Expand All @@ -22,6 +26,27 @@ export type AxisLabelFormatter<V = unknown> = (value: V) => string;
/** @internal */
export type AxisLabelFormatters = { x: Map<SpecId, AxisLabelFormatter>; y: Map<SpecId, AxisLabelFormatter> };

/** @internal */
export function withTickLabelTruncation(
measure: TextMeasure,
tickLabel: AxisStyle['tickLabel'],
): <V>(formatter: AxisLabelFormatter<V>) => AxisLabelFormatter<V> {
const { truncation, fontSize, fontStyle, fontFamily, fill } = tickLabel;
const maxWidth = truncation?.width;
if (!maxWidth || maxWidth <= 0) return (formatter) => formatter;
const position = truncation?.position ?? 'end';

const font: Font = {
fontStyle: fontStyle ?? 'normal',
fontFamily,
fontWeight: 'normal',
fontVariant: 'normal',
textColor: fill,
};

return (formatter) => (value) => fitText(measure, formatter(value), maxWidth, fontSize, font, position).text;
}

/** @internal */
export const getAxisTickLabelFormatter = createCustomCachedSelector(
[getSeriesSpecsSelector, getAxisSpecsSelector, getSettingsSpecSelector, getScaleConfigsFromSpecsSelector],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import type { AxisLabelFormatter } from './axis_tick_formatter';
import { getAxisTickLabelFormatter } from './axis_tick_formatter';
import { getAxisTickLabelFormatter, withTickLabelTruncation } from './axis_tick_formatter';
import { computeSeriesDomainsSelector } from './compute_series_domains';
import { countBarsInClusterSelector } from './count_bars_in_cluster';
import { getAxesStylesSelector } from './get_axis_styles';
Expand Down Expand Up @@ -147,11 +147,13 @@ export const computeAxisTicksDimensionsSelector = createCustomCachedSelector(
withTextMeasure(
(textMeasure): AxesTicksDimensions =>
[...joinedAxesData].reduce<AxesTicksDimensions>(
(axesTicksDimensions, [id, { axisSpec, scale, axesStyle, gridLine, labelFormatter }]) =>
axesTicksDimensions.set(
(axesTicksDimensions, [id, { axisSpec, scale, axesStyle, gridLine, labelFormatter }]) => {
const truncatedLabelFormatter = withTickLabelTruncation(textMeasure, axesStyle.tickLabel)(labelFormatter);
return axesTicksDimensions.set(
id,
getLabelBox(axesStyle, scale.ticks(), labelFormatter, textMeasure, axisSpec, gridLine),
),
getLabelBox(axesStyle, scale.ticks(), truncatedLabelFormatter, textMeasure, axisSpec, gridLine),
);
},
new Map(),
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type { AxisLabelFormatter } from './axis_tick_formatter';
import { withTickLabelTruncation } from './axis_tick_formatter';
import type { JoinedAxisData } from './compute_axis_ticks_dimensions';
import { getJoinedVisibleAxesData, getLabelBox } from './compute_axis_ticks_dimensions';
import { computeSeriesDomainsSelector } from './compute_series_domains';
Expand Down Expand Up @@ -255,6 +256,10 @@ function getVisibleTickSets(
const panel = getPanelSize(smScales);
return [...joinedAxesData].reduce(
(acc, [axisId, { axisSpec, axesStyle, gridLine, isXAxis, labelFormatter: userProvidedLabelFormatter }]) => {
const tickLabelFormatter = withTickLabelTruncation(
textMeasure,
axesStyle.tickLabel,
)(userProvidedLabelFormatter);
const { groupId, integersOnly, maximumFractionDigits: mfd, timeAxisLayerCount } = axisSpec;
const yDomain = yDomains.find((yd) => yd.groupId === groupId);
const domain = isXAxis ? xDomain : yDomain;
Expand Down Expand Up @@ -318,7 +323,7 @@ function getVisibleTickSets(
const scale = getScale(triedTickCount);
const actualTickCount = scale?.ticks().length ?? 0;
if (!scale || actualTickCount === previousActualTickCount || actualTickCount < 2) continue;
const raster = getMeasuredTicks(scale, scale.ticks(), undefined, 0, userProvidedLabelFormatter);
const raster = getMeasuredTicks(scale, scale.ticks(), undefined, 0, tickLabelFormatter);
const nonZeroLengthTicks = raster.ticks.filter((tick) => tick.label.length > 0);
const uniqueLabels = new Set(raster.ticks.map((tick) => tick.label));
const areLabelsUnique = raster.ticks.length === uniqueLabels.size;
Expand Down Expand Up @@ -378,8 +383,7 @@ function getVisibleTickSets(

// todo dry it up
const scale = getScale(adaptiveTickCount ? fallbackAskedTickCount : maxTickCount);
const lastResortCandidate =
scale && getMeasuredTicks(scale, scale.ticks(), undefined, 0, userProvidedLabelFormatter);
const lastResortCandidate = scale && getMeasuredTicks(scale, scale.ticks(), undefined, 0, tickLabelFormatter);
return lastResortCandidate ? acc.set(axisId, lastResortCandidate) : acc;
},
new Map(),
Expand Down
56 changes: 56 additions & 0 deletions packages/charts/src/common/text_utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/

import type { Font } from './text_utils';
import { fitText } from './text_utils';
import type { TextMeasure } from '../utils/bbox/canvas_text_bbox_calculator';

const monospaceMeasure: TextMeasure = (text) => ({
width: text.length,
height: 12,
});

const font: Font = {
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: 400,
fontFamily: 'sans-serif',
textColor: '#000',
};

const fontSize = 12;

describe('fitText', () => {
it('returns the full string when it already fits (end)', () => {
expect(fitText(monospaceMeasure, 'abc', 10, fontSize, font, 'end')).toEqual({
width: 3,
text: 'abc',
});
});

it('truncates at the end with an ellipsis when width is constrained', () => {
expect(fitText(monospaceMeasure, 'abcdef', 4, fontSize, font, 'end')).toEqual({
width: 4,
text: 'abc…',
});
});

it('truncates at the start when position is start', () => {
expect(fitText(monospaceMeasure, 'abcdef', 4, fontSize, font, 'start')).toEqual({
width: 4,
text: '…def',
});
});

it('truncates in the middle when position is middle', () => {
expect(fitText(monospaceMeasure, 'abcdef', 4, fontSize, font, 'middle')).toEqual({
width: 4,
text: 'ab…f',
});
});
});
50 changes: 44 additions & 6 deletions packages/charts/src/common/text_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { ArrayEntry } from '../chart_types/partition_chart/layout/utils/gro
import { integerSnap, monotonicHillClimb } from '../solvers/monotonic_hill_climb';
import type { TextMeasure } from '../utils/bbox/canvas_text_bbox_calculator';
import type { Datum } from '../utils/common';
import type { Truncate } from '../utils/themes/theme';

const FONT_WEIGHTS_NUMERIC = [100, 200, 300, 400, 450, 500, 600, 700, 800, 900] as const;
const FONT_WEIGHTS_ALPHA = ['normal', 'bold', 'lighter', 'bolder', 'inherit', 'initial', 'unset'] as const;
Expand Down Expand Up @@ -120,22 +121,59 @@ export function cutToLength(s: string, maxLength: number) {
return s.length <= maxLength ? s : `${s.slice(0, Math.max(0, maxLength - 1))}…`; // ellipsis is one char
}

/** @internal */
export function fitText(
function truncate(
measure: TextMeasure,
desiredText: string,
allottedWidth: number,
fontSize: number,
font: Font,
build: (k: number) => string,
min: number,
) {
const desiredLength = desiredText.length;
const response = (v: number) => measure(desiredText.slice(0, Math.max(0, v)), font, fontSize).width;
const visibleLength = monotonicHillClimb(response, desiredLength, allottedWidth, integerSnap);
const text = visibleLength < 2 && desiredLength >= 2 ? '' : cutToLength(desiredText, visibleLength);
if (desiredText.length === 0) return { width: measure('', font, fontSize).width, text: '' };

const fullWidth = measure(desiredText, font, fontSize).width;
if (fullWidth <= allottedWidth) return { width: fullWidth, text: desiredText };

const response = (k: number) => measure(build(k), font, fontSize).width;
const visible = monotonicHillClimb(response, desiredText.length, allottedWidth, integerSnap, min);

if (!Number.isFinite(visible) || visible < min) return { width: measure('', font, fontSize).width, text: '' };

const text = build(visible);
const { width } = measure(text, font, fontSize);

return { width, text };
}

/** @internal */
export function fitText(
measure: TextMeasure,
desiredText: string,
allottedWidth: number,
fontSize: number,
font: Font,
position: Truncate['position'] = 'end',
) {
const ELLIPSIS = '…';
Comment thread
biamalveiro marked this conversation as resolved.
Outdated

const truncateText = (build: (k: number) => string, min: number) => {
return truncate(measure, desiredText, allottedWidth, fontSize, font, build, min);
};

if (position === 'start') {
return truncateText((k) => `${ELLIPSIS}${desiredText.slice(desiredText.length - k)}`, 1);
}
if (position === 'middle') {
return truncateText((k) => {
const left = desiredText.slice(0, Math.ceil(k / 2));
const right = desiredText.slice(desiredText.length - Math.floor(k / 2));
return `${left}${ELLIPSIS}${right}`;
}, 2);
}
return truncateText((v) => cutToLength(desiredText, v), desiredText.length < 2 ? 1 : 2);
}

/** @internal */
export function maximiseFontSize(
measure: TextMeasure,
Expand Down
11 changes: 11 additions & 0 deletions packages/charts/src/utils/themes/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ export interface Opacity {
opacity: number;
}

/** @public */
export interface Truncate {
width: Pixels;
Comment thread
biamalveiro marked this conversation as resolved.
Outdated
position: 'end' | 'start' | 'middle';
}

/** @public */
export interface AxisStyle {
axisTitle: TextStyle & Visible;
Expand All @@ -175,6 +181,11 @@ export interface AxisStyle {
*/
offset: TextOffset;
alignment: TextAlignment;
/**
* When set, tick labels are truncated with an ellipsis so their measured width
* does not exceed `width` (unrotated text-direction pixels).
*/
truncation?: Truncate;
};
tickLine: TickStyle;
gridLine: {
Expand Down
60 changes: 60 additions & 0 deletions storybook/stories/axes/16_tick_label_truncation.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/

import { number, select } from '@storybook/addon-knobs';
import React from 'react';

import type { Truncate } from '@elastic/charts';
import { Axis, BarSeries, Chart, Position, ScaleType, Settings } from '@elastic/charts';

import type { ChartsStory } from '../../types';
import { useBaseTheme } from '../../use_base_theme';

const data = [
{ x: 'com.example.something.host.23', y: 12 },
{ x: 'com.example.something.host.11', y: 8 },
{ x: 'com.example.something.host.07', y: 17 },
{ x: 'com.example.something.host.02', y: 5 },
{ x: 'com.example.something.worker.04', y: 9 },
{ x: 'com.example.something.worker.01', y: 4 },
];

export const Example: ChartsStory = (_, { title, description }) => {
const widthPx = number('Truncation width', 120, { min: 0, max: 400, step: 10 });
const position = select<Truncate['position']>(
'Truncation position',
{ end: 'end', start: 'start', middle: 'middle' },
'middle',
);

return (
<Chart title={title} description={description}>
<Settings baseTheme={useBaseTheme()} rotation={90} />
<Axis id="bottom" position={Position.Bottom} title="Count" />
<Axis
id="left"
position={Position.Left}
title="Team"
style={{
tickLabel: {
truncation: widthPx > 0 ? { width: widthPx, position } : undefined,
},
}}
/>

<BarSeries
id="bars"
xScaleType={ScaleType.Ordinal}
yScaleType={ScaleType.Linear}
xAccessor="x"
yAccessors={['y']}
data={data}
/>
</Chart>
);
};
1 change: 1 addition & 0 deletions storybook/stories/axes/axes.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ export { Example as fitDomain } from './11_fit_domain_extent.story';
export { Example as duplicateTicks } from './12_duplicate_ticks.story';
export { Example as labelFormatting } from './13_label_formatting.story';
export { Example as duplicateTicks2 } from './14_duplicate_ticks_2.story';
export { Example as tickLabelTruncation } from './16_tick_label_truncation.story';