Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions packages/charts/src/components/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { v4 as uuidv4 } from 'uuid';

import { ChartBackground } from './chart_background';
import { ChartContainer } from './chart_container';
import { ChartMeasureCanvas } from './chart_measure_canvas';
import { ChartResizer } from './chart_resizer';
import { ChartStatus } from './chart_status';
import { Legend } from './legend/legend';
Expand Down Expand Up @@ -175,6 +176,7 @@ export class Chart extends React.Component<ChartProps, ChartState> {
return (
<Provider store={this.chartStore}>
<div className="echChart" style={containerSizeStyle} data-testid="echChart">
<ChartMeasureCanvas />
<Titles
displayTitles={this.state.displayTitles}
title={this.props.title}
Expand Down
39 changes: 39 additions & 0 deletions packages/charts/src/components/chart_measure_canvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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 React from 'react';

import { registerMeasureCanvas, unregisterMeasureCanvas } from '../utils/bbox/canvas_text_bbox_calculator';

const canvasStyle: React.CSSProperties = { position: 'absolute', width: 0, height: 0, overflow: 'hidden' };

/**
* Hidden canvas element that lives in the Chart DOM tree to inherit computed
* CSS font properties (e.g. font-feature-settings, font-variant-numeric)
* from ancestors. Used by {@link withTextMeasure} for accurate text measurement.
* @internal
*/
export class ChartMeasureCanvas extends React.Component {
private canvasRef = React.createRef<HTMLCanvasElement>();

componentDidMount() {
if (this.canvasRef.current) registerMeasureCanvas(this.canvasRef.current);
}

componentWillUnmount() {
if (this.canvasRef.current) unregisterMeasureCanvas(this.canvasRef.current);
}

shouldComponentUpdate() {
return false;
}

render() {
return <canvas ref={this.canvasRef} width={0} height={0} aria-hidden style={canvasStyle} />;
}
}
44 changes: 40 additions & 4 deletions packages/charts/src/utils/bbox/canvas_text_bbox_calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,60 @@ import { cssFontShorthand } from '../../common/text_utils';
import { withContext } from '../../renderers/canvas';
import type { Size } from '../dimensions';

let measureCanvas: HTMLCanvasElement | null = null;
let measureCtx: CanvasRenderingContext2D | null = null;

/**
* Registers a canvas element that lives in the Chart DOM tree.
* An in-DOM canvas inherits computed CSS properties (e.g. font-feature-settings,
* font-variant-numeric) from its ancestors, producing accurate text measurements
* that match the rendered output.
* @internal
*/
export function registerMeasureCanvas(canvas: HTMLCanvasElement) {
measureCanvas = canvas;
measureCtx = canvas.getContext('2d');
}

/** @internal */
export function unregisterMeasureCanvas(canvas: HTMLCanvasElement) {
if (measureCanvas === canvas) {
measureCanvas = null;
measureCtx = null;
}
}

/** @internal */
export const withTextMeasure = <T>(fun: (textMeasure: TextMeasure) => T) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
return fun(ctx ? measureText(ctx) : () => ({ width: 0, height: 0 }));
const ctx = measureCtx ?? document.createElement('canvas').getContext('2d');
const textMeasure = ctx ? measureText(ctx) : () => ({ width: 0, height: 0 });
return fun(textMeasure);
};

/** @internal */
export type TextMeasure = (text: string, font: Omit<Font, 'textColor'>, fontSize: number, lineHeight?: number) => Size;

/** @internal */
export function measureText(ctx: CanvasRenderingContext2D): TextMeasure {
const isMeasureCtx = ctx === measureCtx;
let lastFont = '';
return (text, font, fontSize, lineHeight = 1) => {
if (text.length === 0) {
return { width: 0, height: fontSize * lineHeight };
}
const fontString = cssFontShorthand(font, fontSize);
// measureCtx doesn't need withContext, so not using it for performance reasons
if (isMeasureCtx) {
// Avoid setting the font multiple times if it hasn't changed for performance
if (fontString !== lastFont) {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to save on performance cost as performance trail shows ctx.font assignment is the most expensive call when measuring text (it's even expensive than ctx.measureText()).

Image

ctx.font = fontString;
lastFont = fontString;
}
const { width } = ctx.measureText(text);
return { width, height: fontSize * lineHeight };
}
return withContext(ctx, (ctx): Size => {
ctx.font = cssFontShorthand(font, fontSize);
ctx.font = fontString;
const { width } = ctx.measureText(text);
return { width, height: fontSize * lineHeight };
});
Expand Down
169 changes: 169 additions & 0 deletions storybook/stories/test_cases/34_font_measurement.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* 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 { select, number, boolean } from '@storybook/addon-knobs';
import React from 'react';

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

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

const barData = [
{ x: '2021', y: 1234567.89 },
{ x: '2022', y: 2345678.9 },
{ x: '2023', y: 3456789.01 },
{ x: '2024', y: 4567890.12 },
{ x: '2025', y: 5678901.23 },
];

const metricData = [
[
{
color: '#3c3c3c',
title: 'Revenue 2025',
subtitle: 'Total Revenue',
value: 5678901.23,
valueFormatter: (v: number) => `$${v.toFixed(2)}`,
},
],
];

export const Example: ChartsStory = (_, { description }) => {
// ── Layout ───────────────────────────────────────────────────────────────
const chartWidth = number('Chart width (px)', 300, { min: 100, max: 1200, step: 1 });

// ── Font ─────────────────────────────────────────────────────────────────
const fontFamily = select(
'Font: family',
{ Inter: 'Inter', Arial: 'Arial', 'Times New Roman': 'Times New Roman', Courier: 'Courier' },
'Inter',
);
const fontSize = number('Font: size (px)', 20, { range: true, min: 8, max: 48, step: 1 });

// ── font-feature-settings ─────────────────────────────────────────────────
const tnum = boolean("font-feature-settings: 'tnum' — tabular digits", true);
const zero = boolean("font-feature-settings: 'zero' — slashed zero", true);
const ss01 = boolean("font-feature-settings: 'ss01' — open digits", true);
const ss07 = boolean("font-feature-settings: 'ss07' — squared punctuation", true);

// ── font-variant-numeric ──────────────────────────────────────────────────
const tabularNums = boolean('font-variant-numeric: tabular-nums', true);
const slashedZero = boolean('font-variant-numeric: slashed-zero', true);

const fontFeatureParts: string[] = [];
if (tnum) fontFeatureParts.push("'tnum'");
if (zero) fontFeatureParts.push("'zero'");
if (ss01) fontFeatureParts.push("'ss01'");
if (ss07) fontFeatureParts.push("'ss07'");

const fontVariantNumericParts: string[] = [];
if (tabularNums) fontVariantNumericParts.push('tabular-nums');
if (slashedZero) fontVariantNumericParts.push('slashed-zero');

const containerStyle: React.CSSProperties = {
...(fontFeatureParts.length > 0 ? { fontFeatureSettings: fontFeatureParts.join(', ') } : {}),
...(fontVariantNumericParts.length > 0 ? { fontVariantNumeric: fontVariantNumericParts.join(' ') } : {}),
};

const theme: PartialTheme = {
barSeriesStyle: {
displayValue: {
fontSize: fontSize + 2,
fontFamily,
fill: '#000',
},
},
axes: {
tickLabel: {
fontSize,
fontFamily,
fill: '#000',
},
},
};

const anyFeaturesActive = fontFeatureParts.length > 0 || fontVariantNumericParts.length > 0;

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', ...containerStyle }}>
<div style={{ height: '200px', width: `${chartWidth}px` }}>
<Chart title="Metric Chart" description={description}>
<Settings baseTheme={useBaseTheme()} />
<Metric id="metric1" data={metricData} />
</Chart>
</div>

<div style={{ height: '300px', width: `${chartWidth}px` }}>
<Chart>
<Settings theme={theme} baseTheme={useBaseTheme()} />
<Axis id="bottom" position={Position.Bottom} title="Year" showOverlappingTicks />
<Axis
id="left"
title="Revenue ($)"
position={Position.Left}
tickFormat={(d: number) => `$${(d / 1_000_000).toFixed(1)}M`}
/>
<BarSeries
id="bars"
displayValueSettings={{
showValueLabel: true,
overflowConstraints: [LabelOverflowConstraint.ChartEdges, LabelOverflowConstraint.BarGeometry],
}}
xScaleType={ScaleType.Ordinal}
yScaleType={ScaleType.Linear}
xAccessor="x"
yAccessors={['y']}
data={barData}
/>
</Chart>
</div>

{anyFeaturesActive && (
<div
style={{
padding: '12px 16px',
background: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
fontSize: '13px',
}}
>
<strong>Active features:</strong>{' '}
{[
...(fontFeatureParts.length > 0 ? [`font-feature-settings: ${fontFeatureParts.join(', ')}`] : []),
...(fontVariantNumericParts.length > 0
? [`font-variant-numeric: ${fontVariantNumericParts.join(' ')}`]
: []),
].join(' | ')}
<div style={{ marginTop: '8px', fontSize: '20px', fontFamily }}>0123456789 · $1,234,567.89 · 100.00%</div>
</div>
)}

<p style={{ margin: 0, fontSize: '12px', color: '#888' }}>
Tests whether canvas text measurement respects rendered text when font variants (e.g. OpenType features) are
applied via CSS inheritance. Text measurement should account for all computed font properties that affect
rendered dimensions, ensuring text is neither clipped nor overlapping.
</p>
</div>
);
};

Example.parameters = {
resize: false,
};
1 change: 1 addition & 0 deletions storybook/stories/test_cases/test_cases.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ export { Example as pointStyleOverrides } from './13_point_style_overrides.story
export { Example as errorBoundary } from './14_error_boundary.story';
export { Example as linearNicing } from './15_linear_nicing.story';
export { Example as lensStressTest } from './33_lens_stress.story';
export { Example as fontMeasurementTest } from './34_font_measurement.story';