Skip to content

Commit ca125f4

Browse files
⚡ Bolt: Cache font size calculation in Sunburst
1 parent d9f4cde commit ca125f4

File tree

3 files changed

+80
-1
lines changed

3 files changed

+80
-1
lines changed

.jules/bolt.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
## 2024-05-23 - DOM Layout Thrashing in Sunburst
2+
**Learning:** `getComputedTextLength` causes synchronous layout thrashing and is a major bottleneck when called in loops (e.g. `renderText`).
3+
**Action:** Leverage the `DataNode.extra` property to memoize expensive calculations like text width. This avoids repeated layout thrashing on re-renders when data hasn't changed.

src/visualizations/sunburst/Sunburst.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,8 +417,15 @@ export default class Sunburst {
417417
.attr("dy", ".2em")
418418
.text((d: HRN<DataNode>) => this.settings.getLabel(d.data))
419419
.style("font-size", function(this: SVGTextContentElement, d: HRN<DataNode>) {
420+
const label = that.settings.getLabel(d.data);
421+
if (d.data.extra.fontSizeCache && d.data.extra.fontSizeCache.label === label) {
422+
return d.data.extra.fontSizeCache.size;
423+
}
424+
420425
const txtLength = offscreenCanvasSupported ? ctx.measureText(this.textContent!).width : this.getComputedTextLength();
421-
return Math.floor(Math.min(((that.settings.radius / that.settings.levels) / txtLength * 10) + 1, 12)) + "px";
426+
const fontSize = Math.floor(Math.min(((that.settings.radius / that.settings.levels) / txtLength * 10) + 1, 12)) + "px";
427+
d.data.extra.fontSizeCache = { label, size: fontSize };
428+
return fontSize;
422429
});
423430

424431
// Somewhat of a hack as we rely on arcTween updating the scales.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import Sunburst from "./../Sunburst";
2+
import { waitForCondition } from "./../../../test/TestUtils";
3+
import SunburstSettings from "./../SunburstSettings";
4+
import { JSDOM } from "jsdom";
5+
import { describe, it, expect } from "vitest";
6+
import taxonomyObject from "./resources/taxonomy.json";
7+
8+
describe("Sunburst Performance", () => {
9+
function createJSDom() {
10+
const dom = new JSDOM("<!DOCTYPE html><div id=\"visualization\"></div>");
11+
return dom;
12+
}
13+
14+
it("should reduce getComputedTextLength calls with caching", async () => {
15+
let callCount = 0;
16+
17+
const dom = createJSDom();
18+
// Mock getComputedTextLength on the prototype inside the JSDOM window
19+
// @ts-ignore
20+
dom.window.Element.prototype.getComputedTextLength = function() {
21+
callCount++;
22+
return 20;
23+
};
24+
25+
const element = dom.window.document.getElementById("visualization")!;
26+
const settings = new SunburstSettings();
27+
settings.animationDuration = 0; // Disable animation for instant updates
28+
29+
// @ts-ignore
30+
const sunburst = new Sunburst(element, taxonomyObject, settings);
31+
32+
// Wait for initial render
33+
await waitForCondition(() => element.getElementsByTagName("text").length > 0, 2000, 100);
34+
35+
const initialCount = callCount;
36+
console.log("Initial calls:", initialCount);
37+
38+
// Trigger click on a child (path-1)
39+
const paths = element.getElementsByTagName("path");
40+
const event = dom.window.document.createEvent("CustomEvent");
41+
event.initEvent("click", true, true);
42+
paths.item(1)!.dispatchEvent(event);
43+
44+
// Wait for re-render (allow some time for async renderText)
45+
await new Promise(r => setTimeout(r, 200));
46+
47+
const afterDrillDownCount = callCount;
48+
console.log("Calls after drill down:", afterDrillDownCount - initialCount);
49+
50+
// Reset to root (drill up)
51+
sunburst.reset();
52+
53+
// Wait for re-render
54+
await new Promise(r => setTimeout(r, 200));
55+
56+
const afterResetCount = callCount;
57+
const resetCalls = afterResetCount - afterDrillDownCount;
58+
console.log("Calls after reset:", resetCalls);
59+
60+
// We export the count so we can verify it in the prompt/journal.
61+
// For the baseline, we expect resetCalls to be roughly equal to initialCount (re-measuring everything).
62+
// We will fail this test purposely if we want to enforce "improvement",
63+
// but for now let's just log it and maybe add an assertion that it IS high,
64+
// then later update it to expect LOW.
65+
66+
// With optimization, resetCalls should be 0 because all nodes in the initial view have been cached.
67+
expect(resetCalls).toBe(0);
68+
});
69+
});

0 commit comments

Comments
 (0)