diff --git a/.changeset/cruel-cameras-begin.md b/.changeset/cruel-cameras-begin.md new file mode 100644 index 000000000..5b14ebd06 --- /dev/null +++ b/.changeset/cruel-cameras-begin.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +breaking(Treemap): Remove `selected` prop diff --git a/.changeset/early-peaches-accept.md b/.changeset/early-peaches-accept.md new file mode 100644 index 000000000..08fd61a41 --- /dev/null +++ b/.changeset/early-peaches-accept.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Treemap): Add `maintainAspectRatio` (default: false) to opt into tiling function adjustment (primarily for zoom) diff --git a/.changeset/modern-nails-kiss.md b/.changeset/modern-nails-kiss.md new file mode 100644 index 000000000..e218d5071 --- /dev/null +++ b/.changeset/modern-nails-kiss.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Treemap): Fix reactivity of props (tile, padding, etc) diff --git a/.changeset/spotty-rules-taste.md b/.changeset/spotty-rules-taste.md new file mode 100644 index 000000000..50a84568d --- /dev/null +++ b/.changeset/spotty-rules-taste.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Treemap): Fix `padding*` prop types to support function or number constant diff --git a/packages/layerchart/src/lib/components/Treemap.svelte b/packages/layerchart/src/lib/components/Treemap.svelte index ee5d7e841..3d92d0b54 100644 --- a/packages/layerchart/src/lib/components/Treemap.svelte +++ b/packages/layerchart/src/lib/components/Treemap.svelte @@ -18,53 +18,53 @@ * * @default 0 */ - padding?: number; + padding?: number | ((node: HierarchyRectangularNode) => number); /** * The inner padding between nodes. * * @default 0 */ - paddingInner?: number; + paddingInner?: number | ((node: HierarchyRectangularNode) => number); /** * The outer padding between nodes. * * @default 0 */ - paddingOuter?: number; + paddingOuter?: number | ((node: HierarchyRectangularNode) => number); /** * The top padding between nodes. * * @default 0 */ - paddingTop?: number; + paddingTop?: number | ((node: HierarchyRectangularNode) => number); /** * The bottom padding between nodes. * * @default 0 */ - paddingBottom?: number; + paddingBottom?: number | ((node: HierarchyRectangularNode) => number); /** * The left padding between nodes. * */ - paddingLeft?: number; + paddingLeft?: number | ((node: HierarchyRectangularNode) => number); /** * The right padding between nodes. * */ - paddingRight?: number; + paddingRight?: number | ((node: HierarchyRectangularNode) => number); /** - * The selected node. + * Modify tiling function for approapriate aspect ratio when treemap is zoomed in * - * @default null + * @default false */ - selected?: HierarchyRectangularNode | null; + maintainAspectRatio?: boolean; hierarchy?: HierarchyNode; @@ -99,7 +99,7 @@ paddingBottom = 0, paddingLeft, paddingRight, - selected = $bindable(null), + maintainAspectRatio = false, children, }: TreemapProps = $props(); @@ -121,45 +121,82 @@ : tile ); - const treemap = $derived.by(() => { + const treemapData = $derived.by(() => { const _treemap = d3treemap() .size([ctx.width, ctx.height]) - .tile(aspectTile(tileFunc, ctx.width, ctx.height)); + .tile(maintainAspectRatio ? aspectTile(tileFunc, ctx.width, ctx.height) : tileFunc); if (padding) { - _treemap.padding(padding); + // Make Typescript happy to pick the correct overload + // TODO: Better way to do this? + if (typeof padding === 'number') { + _treemap.padding(padding); + } else { + _treemap.padding(padding); + } } if (paddingInner) { - _treemap.paddingInner(paddingInner); + if (typeof paddingInner === 'number') { + _treemap.paddingInner(typeof paddingInner === 'number' ? paddingInner : paddingInner); + } else { + _treemap.paddingInner(paddingInner); + } } if (paddingOuter) { - _treemap.paddingOuter(paddingOuter); + if (typeof paddingOuter === 'number') { + _treemap.paddingOuter(paddingOuter); + } else { + _treemap.paddingOuter(paddingOuter); + } } if (paddingTop) { - _treemap.paddingTop(paddingTop); + if (typeof paddingTop === 'number') { + _treemap.paddingTop(paddingTop); + } else { + _treemap.paddingTop(paddingTop); + } } if (paddingBottom) { - _treemap.paddingBottom(paddingBottom); + if (typeof paddingBottom === 'number') { + _treemap.paddingBottom(paddingBottom); + } else { + _treemap.paddingBottom(paddingBottom); + } } if (paddingLeft) { - _treemap.paddingLeft(paddingLeft); + if (typeof paddingLeft === 'number') { + _treemap.paddingLeft(paddingLeft); + } else { + _treemap.paddingLeft(paddingLeft); + } } if (paddingRight) { - _treemap.paddingRight(paddingRight); + if (typeof paddingRight === 'number') { + _treemap.paddingRight(paddingRight); + } else { + _treemap.paddingRight(paddingRight); + } } - return _treemap; - }); - const treemapData = $derived(hierarchy ? treemap(hierarchy) : null); + if (hierarchy) { + const h = hierarchy.copy(); + const treemapData = _treemap(h); + return { + links: treemapData.links(), + nodes: treemapData.descendants(), + }; + } - $effect.pre(() => { - selected = treemapData; + return { + links: [], + nodes: [], + }; }); -{@render children?.({ nodes: treemapData ? treemapData.descendants() : [] })} +{@render children?.({ nodes: treemapData.nodes })} diff --git a/packages/layerchart/src/lib/utils/treemap.ts b/packages/layerchart/src/lib/utils/treemap.ts index 5d91de23a..5bace5889 100644 --- a/packages/layerchart/src/lib/utils/treemap.ts +++ b/packages/layerchart/src/lib/utils/treemap.ts @@ -28,7 +28,7 @@ export function aspectTile(tile: TileFunc, width: number, height: number): TileF /** * Show if the node (a) is a child of the selected (b), or any parent above selected */ -export function isNodeVisible(a: HierarchyNode, b: HierarchyNode | null) { +export function isNodeVisible(a: HierarchyNode, b: HierarchyNode | null | undefined) { while (b) { if (a.parent === b) return true; b = b.parent; diff --git a/packages/layerchart/src/routes/docs/components/Treemap/+page.svelte b/packages/layerchart/src/routes/docs/components/Treemap/+page.svelte index 9d83a0259..f192a7ea0 100644 --- a/packages/layerchart/src/routes/docs/components/Treemap/+page.svelte +++ b/packages/layerchart/src/routes/docs/components/Treemap/+page.svelte @@ -29,7 +29,12 @@ .sum((d) => d.value) .sort(sortFunc('value', 'desc')); - const simpleRoot = hierarchy({ + const rootPopulation = hierarchy(data.population) + // @ts-expect-error + .sum((d) => d.value) + .sort(sortFunc('value', 'desc')); + + const rootFlat = hierarchy({ name: 'root', children: [ { name: 'A', value: 1000 }, @@ -51,6 +56,7 @@ let tile: ComponentProps['tile'] = $state('squarify'); let colorBy = $state('children'); + let maintainAspectRatio = $state(false); let paddingOuter = $state(4); let paddingInner = $state(4); @@ -91,7 +97,7 @@

Playground

-
+
Squarify @@ -102,6 +108,151 @@ Slice / Dice + + + No + Yes + + + + + Children + Depth + Parent + + +
+
+ + +
+
+ + + + +
+
+ + +
+ + {#snippet children({ context })} + + + {#snippet children({ nodes })} + {#each nodes as node} + context.tooltip.show(e, node)} + onpointerleave={context.tooltip.hide} + > + {@const nodeWidth = node.x1 - node.x0} + {@const nodeHeight = node.y1 - node.y0} + {@const nodeColor = getNodeColor(node, colorBy)} + + + + {node.data.name} + {#if node.children} + + {format(node.value ?? 0, 'integer')} + + {/if} + + + {#if !node.children} + + {/if} + + + {/each} + {/snippet} + + + + + {#snippet children({ data })} + {data.data.name} + + + + {/snippet} + + {/snippet} + +
+
+ +

Complex

+ +
+
+ + + Squarify + Resquarify + Binary + Slice + Dice + Slice / Dice + + + + + No + Yes + + Children @@ -122,13 +273,13 @@
- +
{#snippet children({ context })} {#snippet children({ nodes })} {#each nodes as node} @@ -210,11 +362,11 @@

Simple / flat

- +
- + {#snippet children({ nodes })} {#each nodes.filter((n) => n.depth > 0) as node} diff --git a/packages/layerchart/src/routes/docs/components/Treemap/+page.ts b/packages/layerchart/src/routes/docs/components/Treemap/+page.ts index d14b909a4..f35b4bea4 100644 --- a/packages/layerchart/src/routes/docs/components/Treemap/+page.ts +++ b/packages/layerchart/src/routes/docs/components/Treemap/+page.ts @@ -5,6 +5,56 @@ import pageSource from './+page.svelte?raw'; export async function load() { return { flare: await fetch('/data/examples/hierarchy/flare.json').then((r) => r.json()), + population: { + name: 'World', + children: [ + { + name: 'Europe', + children: [ + { name: 'Western Europe', value: 200 }, // ~200M based on UN data + { name: 'Southern Europe', value: 151 }, // ~151M based on UN data + { name: 'Eastern Europe', value: 284 }, // ~284M based on UN data + { name: 'Northern Europe', value: 109 }, // ~109M based on UN data + ], + }, + { + name: 'Asia', + children: [ + { name: 'East Asia', value: 1652 }, // 1,652M based on UN data + { name: 'South Asia', value: 2085 }, // 2,085M based on UN data + { name: 'Southeast Asia', value: 700 }, // 700M based on UN data + { name: 'Western Asia', value: 314 }, // 314M based on UN data + { name: 'Central Asia', value: 84 }, // 84M based on UN data + ], + }, + { + name: 'North America', + children: [ + { name: 'Northern America', value: 388 }, // ~388M based on UN data + { name: 'Central America', value: 184 }, // ~184M (estimated from total minus Northern America) + ], + }, + { + name: 'South America', + children: [{ name: 'South America', value: 434 }], // ~434M based on UN data + }, + { + name: 'Africa', + children: [ + { name: 'Western Africa', value: 467 }, // 467M based on UN data + { name: 'Southern Africa', value: 74 }, // 74M based on UN data + { name: 'Northern Africa', value: 276 }, // 276M based on UN data + { name: 'Eastern Africa', value: 513 }, // 513M based on UN data + { name: 'Middle Africa', value: 220 }, // 220M based on UN data + ], + }, + { + name: 'Oceania', + children: [{ name: 'Oceania', value: 47 }], // 47M based on UN data + }, + ], + }, + meta: { api, source, diff --git a/packages/layerchart/src/routes/docs/examples/Treemap/+page.svelte b/packages/layerchart/src/routes/docs/examples/Treemap/+page.svelte index 27872946b..1ea742b1d 100644 --- a/packages/layerchart/src/routes/docs/examples/Treemap/+page.svelte +++ b/packages/layerchart/src/routes/docs/examples/Treemap/+page.svelte @@ -32,6 +32,7 @@ Text, Tooltip, Treemap, + asAny, findAncestor, } from 'layerchart'; import { isNodeVisible } from '$lib/utils/treemap.js'; @@ -81,10 +82,11 @@ }); let tile: ComponentProps['tile'] = $state('squarify'); + let maintainAspectRatio = $state(false); let colorBy = $state('children'); - let selectedNested: HierarchyRectangularNode | null = $state(null); - let selectedZoomable: HierarchyRectangularNode | null = $state(null); + let selectedNested: HierarchyNode = $state(complexDataHierarchy.copy()); + let selectedZoomable: HierarchyNode = $state(complexDataHierarchy.copy()); let paddingOuter = $state(4); let paddingInner = $state(4); let paddingTop = $state(20); @@ -123,7 +125,7 @@

Zoomable

-
+
Squarify @@ -134,6 +136,18 @@ Slice / Dice + + + No + Yes + + Children @@ -174,15 +188,15 @@ {#snippet children({ context })} {#snippet children({ xScale, yScale })} Grouped and Filterable
-
+
Squarify @@ -282,6 +296,18 @@ Slice / Dice + + + No + Yes + + Children @@ -330,6 +356,7 @@ Zoomable
-
+
Squarify @@ -423,6 +450,18 @@ Slice / Dice + + + No + Yes + + Children @@ -452,16 +491,12 @@ {#snippet children({ xScale, yScale })} - + {#snippet children({ nodes })} {#each nodes as node} {@const nodeColor = getNodeColor(node, colorBy)} - {#if isNodeVisible(node, selectedZoomable)} + {#if isNodeVisible( node, nodes.find((n) => n.data.name === selectedZoomable.data.name && n.depth === selectedZoomable.depth) )}