diff --git a/.changeset/treemap-layout-bounds.md b/.changeset/treemap-layout-bounds.md new file mode 100644 index 00000000..e73e27ae --- /dev/null +++ b/.changeset/treemap-layout-bounds.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': patch +--- + +Handle floating-point rounding at treemap plot boundaries and omit empty tiles collapsed outside the plot by inner padding. diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index bc22e4f0..a7de76c4 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -12,7 +12,7 @@ "tanstack": { "kind": "workspace", "revision": "69d70960dd6b29f3706a173d348f2fc8271b0dcc", - "inputDigest": "sha256:80f5c5447f39757715be5def33eed28b12869946936369671243436701fb7964" + "inputDigest": "sha256:8115665ae3c573649f47d236836e28c8cf14d513be1117a8e5c720e3a618680c" }, "chartjs": { "kind": "package", diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 2e356676..9b352e03 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "4c9a63949cbf9511cf8abd901c2bb8224ea959d284456a9b90e1b87745400ef9", + "sourceHash": "e9da35fe64694acbe80a296e2b848ce6904a0778cb545edb87a5e94cff8b9396", "assets": [ { "id": "01-line-gaps", diff --git a/packages/charts-core/src/hierarchy-treemap.test.ts b/packages/charts-core/src/hierarchy-treemap.test.ts index 8e226dac..594025cf 100644 --- a/packages/charts-core/src/hierarchy-treemap.test.ts +++ b/packages/charts-core/src/hierarchy-treemap.test.ts @@ -60,6 +60,63 @@ function allNodes(nodes: readonly SceneNode[]): SceneNode[] { } describe('treemap', () => { + it.each(['non-finite', 'out-of-bounds'])( + 'still rejects %s custom layouts', + (kind) => { + const method: TreemapTile = (node, x0, y0, x1, y1) => { + treemapDice(node, x0, y0, x1, y1) + const child = node.children?.[0] + if (!child) + throw new Error('Expected a child in the custom tile fixture') + if (kind === 'non-finite') child.x1 = Infinity + else child.x1 = x1 + 1 + } + expect(() => + scene( + treemap(rows, { + path: 'path', + delimiter: '.', + value: 'value', + method, + }), + ), + ).toThrow(`layout produced ${kind} coordinates`) + }, + ) + + it.each([ + [361, [50, 30, 20, 10, 5, 1], 0, 6], + [593, [7, 5, 3, 2, 1], 0, 5], + [804, [90, 7, 5, 4, 2, 1], 0, 6], + [600, [1000, 1], 3, 1], + ])( + 'keeps rounded and collapsed tiles in bounds at width %s', + (width, values, paddingInner, count) => { + const result = scene( + treemap( + values.map((value, i) => ({ + path: `root.${i}`, + value, + family: 'root', + })), + { path: 'path', delimiter: '.', value: 'value', paddingInner }, + ), + width, + 300, + ) + const rects = allNodes(result.nodes).filter( + (node) => node.kind === 'rect', + ) + expect(rects).toHaveLength(count) + for (const rect of rects) { + expect(rect.x).toBeGreaterThanOrEqual(0) + expect(rect.y).toBeGreaterThanOrEqual(0) + expect(rect.x + rect.width).toBeLessThanOrEqual(width) + expect(rect.y + rect.height).toBeLessThanOrEqual(300) + } + }, + ) + it('renders path leaves in final chart bounds with exact metadata and lineage', () => { const source = Object.freeze(rows.map((row) => Object.freeze({ ...row }))) const before = JSON.stringify(source) diff --git a/packages/charts-core/src/hierarchy-treemap.ts b/packages/charts-core/src/hierarchy-treemap.ts index f855f5d9..6b60ff8f 100644 --- a/packages/charts-core/src/hierarchy-treemap.ts +++ b/packages/charts-core/src/hierarchy-treemap.ts @@ -448,6 +448,7 @@ function assertLayoutCoordinates( width: number, height: number, ): void { + const tolerance = Math.max(width, height) * Number.EPSILON * 32 nodes.forEach((node) => { const coordinates = [node.x0, node.y0, node.x1, node.y1] if (!coordinates.every(Number.isFinite)) { @@ -460,11 +461,22 @@ function assertLayoutCoordinates( `treemap: layout produced reversed coordinates for node "${node.data.id}"`, ) } - if (node.x0 < 0 || node.y0 < 0 || node.x1 > width || node.y1 > height) { + // D3 can collapse a padded tile outside the plot. Empty cells are omitted. + if (node.x0 === node.x1 || node.y0 === node.y1) return + if ( + node.x0 < -tolerance || + node.y0 < -tolerance || + node.x1 > width + tolerance || + node.y1 > height + tolerance + ) { throw new TypeError( `treemap: layout produced out-of-bounds coordinates for node "${node.data.id}"`, ) } + node.x0 = Math.max(0, node.x0) + node.y0 = Math.max(0, node.y0) + node.x1 = Math.min(width, node.x1) + node.y1 = Math.min(height, node.y1) }) } diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 3026af4a..da3d5383 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -555,7 +555,7 @@ const entries = [ 'Hierarchy treemap mark', 'benchmarks/entries/charts-hierarchy-treemap.ts', 'D3 hierarchy treemap kernel', - 3.86, + 4022 / 1024, { inputBoundary: { require: ['hierarchyFlat', 'hierarchyTreemap', 'd3Hierarchy'],