Skip to content

Commit a707485

Browse files
[charts] Batch string size measurement
1 parent ecc9177 commit a707485

File tree

3 files changed

+107
-39
lines changed

3 files changed

+107
-39
lines changed

packages/x-charts/src/ChartsXAxis/ChartsXAxis.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useRtl } from '@mui/system/RtlProvider';
88
import { clampAngle } from '../internals/clampAngle';
99
import { useIsHydrated } from '../hooks/useIsHydrated';
1010
import { doesTextFitInRect, ellipsize } from '../internals/ellipsize';
11-
import { getStringSize } from '../internals/domUtils';
11+
import { getStringSize, warmUpStringCache } from '../internals/domUtils';
1212
import { useTicks, TickItemType } from '../hooks/useTicks';
1313
import { AxisConfig, ChartsXAxisProps, ComputedXAxis } from '../models/axis';
1414
import { getAxisUtilityClass } from '../ChartsAxis/axisClasses';
@@ -82,6 +82,14 @@ function getVisibleLabels(
8282
let previousTextLimit = 0;
8383
const direction = reverse ? -1 : 1;
8484

85+
/* Avoid warming up the cache for too many values because we know not all of them will fit, so we'd be doing useless work */
86+
if (isMounted && xTicks.length < 100) {
87+
warmUpStringCache(
88+
xTicks.flatMap((t) => t.formattedValue?.split('\n')).filter((t) => t != null),
89+
style,
90+
);
91+
}
92+
8593
return new Set(
8694
xTicks.filter((item, labelIndex) => {
8795
const { offset, labelOffset } = item;
@@ -153,6 +161,14 @@ function shortenLabels(
153161
[leftBoundFactor, rightBoundFactor] = [rightBoundFactor, leftBoundFactor];
154162
}
155163

164+
// Measure strings so it's cached
165+
warmUpStringCache(
166+
Array.from(visibleLabels)
167+
.map((item) => item.formattedValue)
168+
.filter((item) => item != null),
169+
tickLabelStyle,
170+
);
171+
156172
for (const item of visibleLabels) {
157173
if (item.formattedValue) {
158174
// That maximum width of the tick depends on its proximity to the axis bounds.

packages/x-charts/src/ChartsYAxis/ChartsYAxis.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useRtl } from '@mui/system/RtlProvider';
88
import { useIsHydrated } from '../hooks/useIsHydrated';
99
import { getDefaultBaseline, getDefaultTextAnchor } from '../ChartsText/defaultTextPlacement';
1010
import { doesTextFitInRect, ellipsize } from '../internals/ellipsize';
11-
import { getStringSize } from '../internals/domUtils';
11+
import { getStringSize, warmUpStringCache } from '../internals/domUtils';
1212
import { TickItemType, useTicks } from '../hooks/useTicks';
1313
import { ChartDrawingArea, useDrawingArea } from '../hooks/useDrawingArea';
1414
import { AxisConfig, ChartsYAxisProps } from '../models/axis';
@@ -73,6 +73,14 @@ function shortenLabels(
7373
[topBoundFactor, bottomBoundFactor] = [bottomBoundFactor, topBoundFactor];
7474
}
7575

76+
/* Avoid warming up the cache for too many values because we know not all of them will fit, so we'd be doing useless work */
77+
if (visibleLabels.length < 100) {
78+
warmUpStringCache(
79+
visibleLabels.flatMap((t) => t.formattedValue).filter((t) => t != null),
80+
tickLabelStyle,
81+
);
82+
}
83+
7684
for (const item of visibleLabels) {
7785
if (item.formattedValue) {
7886
// That maximum height of the tick depends on its proximity to the axis bounds.

packages/x-charts/src/internals/domUtils.ts

Lines changed: 81 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
// DOM utils taken from
22
// https://github.com/recharts/recharts/blob/master/src/util/DOMUtils.ts
33

4+
import * as React from 'react';
5+
46
function isSsr(): boolean {
57
return typeof window === 'undefined';
68
}
79

8-
interface StringCache {
9-
widthCache: Record<string, { width: number; height: number }>;
10-
cacheCount: number;
11-
}
10+
type Size = { width: number; height: number };
11+
12+
const cache = new Map<string, Size>();
13+
14+
// @ts-ignore
15+
window.xChartsClearStringCache = () => cache.clear();
1216

13-
const stringCache: StringCache = {
14-
widthCache: {},
15-
cacheCount: 0,
16-
};
1717
const MAX_CACHE_NUM = 2000;
1818
const SPAN_STYLE = {
1919
position: 'absolute',
@@ -45,7 +45,8 @@ const STYLE_LIST = [
4545
'marginTop',
4646
'marginBottom',
4747
];
48-
export const MEASUREMENT_SPAN_ID = 'mui_measurement_span';
48+
49+
const MEASUREMENT_DIV_ID = 'mui_measurement_div';
4950

5051
/**
5152
*
@@ -114,58 +115,101 @@ export const getStringSize = (text: string | number, style: React.CSSProperties
114115
const styleString = getStyleString(style);
115116
const cacheKey = `${str}-${styleString}`;
116117

117-
if (stringCache.widthCache[cacheKey]) {
118-
return stringCache.widthCache[cacheKey];
118+
const result = cache.get(cacheKey);
119+
120+
if (result) {
121+
return result;
119122
}
120123

124+
warmUpStringCache([text], style);
125+
126+
return cache.get(cacheKey) ?? { width: 0, height: 0 };
127+
};
128+
129+
export function warmUpStringCache(
130+
texts: Iterable<string | number>,
131+
style: React.CSSProperties = {},
132+
) {
133+
if (isSsr()) {
134+
return;
135+
}
136+
137+
const styleString = getStyleString(style);
138+
121139
try {
122-
let measurementSpan = document.getElementById(MEASUREMENT_SPAN_ID);
123-
if (measurementSpan === null) {
124-
measurementSpan = document.createElement('span');
125-
measurementSpan.setAttribute('id', MEASUREMENT_SPAN_ID);
126-
measurementSpan.setAttribute('aria-hidden', 'true');
127-
document.body.appendChild(measurementSpan);
140+
let measurementDiv = document.getElementById(MEASUREMENT_DIV_ID);
141+
if (measurementDiv === null) {
142+
measurementDiv = document.createElement('div');
143+
measurementDiv.setAttribute('id', MEASUREMENT_DIV_ID);
144+
measurementDiv.setAttribute('aria-hidden', 'true');
145+
document.body.appendChild(measurementDiv);
128146
}
147+
129148
// Need to use CSS Object Model (CSSOM) to be able to comply with Content Security Policy (CSP)
130149
// https://en.wikipedia.org/wiki/Content_Security_Policy
131-
const measurementSpanStyle: Record<string, any> = { ...SPAN_STYLE, ...style };
150+
const measurementDivStyle: Record<string, any> = { ...SPAN_STYLE, ...style };
132151

133-
Object.keys(measurementSpanStyle).map((styleKey) => {
134-
(measurementSpan!.style as Record<string, any>)[camelToMiddleLine(styleKey)] =
135-
autoCompleteStyle(styleKey, measurementSpanStyle[styleKey]);
152+
Object.keys(style).map((styleKey) => {
153+
(measurementDiv!.style as Record<string, any>)[camelToMiddleLine(styleKey)] =
154+
autoCompleteStyle(styleKey, measurementDivStyle[styleKey]);
136155
return styleKey;
137156
});
138-
measurementSpan.textContent = str;
139-
const rect = measurementSpan.getBoundingClientRect();
140-
const result = { width: rect.width, height: rect.height };
141157

142-
stringCache.widthCache[cacheKey] = result;
158+
const spans = [];
159+
for (const text of texts) {
160+
const str = `${text}`;
161+
const cacheKey = `${str}-${styleString}`;
143162

144-
if (stringCache.cacheCount + 1 > MAX_CACHE_NUM) {
145-
stringCache.cacheCount = 0;
146-
stringCache.widthCache = {};
147-
} else {
148-
stringCache.cacheCount += 1;
163+
if (cache.has(cacheKey)) {
164+
// If already cached, skip the measurement
165+
continue;
166+
}
167+
168+
const span = document.createElement('span');
169+
span.textContent = str;
170+
spans.push(span);
171+
}
172+
173+
measurementDiv.replaceChildren(...spans);
174+
175+
let i = 0;
176+
for (const text of texts) {
177+
const str = `${text}`;
178+
const cacheKey = `${str}-${styleString}`;
179+
180+
if (cache.has(cacheKey)) {
181+
// The string is already cached. Do not increment the index because a span wasn't created for cached strings
182+
continue;
183+
}
184+
185+
const span = spans[i];
186+
const rect = span.getBoundingClientRect();
187+
188+
cache.set(cacheKey, { width: rect.width, height: rect.height });
189+
190+
if (cache.size + 1 > MAX_CACHE_NUM) {
191+
// This is very inefficient, we should be smarter about which entries to purge
192+
cache.clear();
193+
}
194+
i += 1;
149195
}
150196

151197
if (process.env.NODE_ENV === 'test') {
152-
// In test environment, we clean the measurement span immediately
153-
measurementSpan.textContent = '';
198+
// In test environment, we clean the measurement div immediately
199+
measurementDiv.replaceChildren();
154200
} else {
155201
if (domCleanTimeout) {
156202
clearTimeout(domCleanTimeout);
157203
}
158204
domCleanTimeout = setTimeout(() => {
159205
// Limit node cleaning to once per render cycle
160-
measurementSpan.textContent = '';
206+
measurementDiv.replaceChildren();
161207
}, 0);
162208
}
163-
164-
return result;
165209
} catch {
166-
return { width: 0, height: 0 };
210+
/* Intentionally do nothing */
167211
}
168-
};
212+
}
169213

170214
// eslint-disable-next-line @typescript-eslint/naming-convention
171215
export function unstable_cleanupDOM() {

0 commit comments

Comments
 (0)