Skip to content

Commit 6f08e27

Browse files
committed
refactor(vortex-web): label treemap layouts locally instead of a path header
Move each layout's name back onto its own block: container layouts label their header band (which their children never occupy, so labels cannot collide) and leaves label their body with name + bytes. Drop the top path header. The full nested tree still renders down to the leaves so every physical block is visible, coloured by dtype; hover drives the tooltip/sidebar and click selects (flat blocks expand in place). Relies on the corrected header band (paddingTop set after paddingOuter) so nested names stack cleanly without overlap. Signed-off-by: Robert <robert@spiraldb.com>
1 parent 85bc085 commit 6f08e27

6 files changed

Lines changed: 95 additions & 140 deletions

File tree

vortex-web/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ The **Treemap** view (toggle in the header, next to the theme picker) renders a
88
file's physical blocks all the way down to the leaves: layouts nest, flat
99
layouts subdivide into their array-encoding buffers, and every block is sized by
1010
its byte footprint and coloured by dtype to match the swimlane and detail
11-
treemap. Only leaf blocks are labelled inline, so labels never collide; a header
12-
bar at the top is the path to the block under the cursor. Click a path segment
13-
to focus that subtree, and click a flat block to expand its buffers in place. It
14-
follows the app theme — dark by default, matching explore.vortex.dev — and the
15-
light theme via the theme picker.
11+
treemap. Each layout's name sits locally on the block — in a container's header
12+
band (which its children never occupy, so labels never collide) or in a leaf's
13+
body. Click a flat block to expand its buffers in place. It follows the app
14+
theme — dark by default, matching explore.vortex.dev — and the light theme via
15+
the theme picker.
1616

1717
![Treemap explorer overview](docs/img/treemap-explorer-overview.png)
1818

4.29 KB
Loading
-780 Bytes
Loading
1.63 KB
Loading

vortex-web/src/components/explorer/BlockTreemap.tsx

Lines changed: 89 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ import {
1010
getDtypeCategory,
1111
shortEncoding,
1212
collectSubtreeSegments,
13-
findNodeById,
14-
findPathToNode,
1513
isFlatLayout,
1614
formatBytes,
1715
DTYPE_COLORS,
@@ -45,6 +43,8 @@ interface TreeNode {
4543

4644
type RectNode = HierarchyRectangularNode<TreeNode>;
4745

46+
// Header band reserved at the top of each container layout for its own name.
47+
const HEADER = 16;
4848
const PAD = 2;
4949

5050
function arraySubtreeBytes(node: LayoutTreeNode): number {
@@ -64,9 +64,9 @@ function subtreeBytes(node: LayoutTreeNode, segmentMap: Map<number, SegmentMapEn
6464
);
6565
}
6666

67-
/** Build the full nested treemap for a subtree so every physical block is
68-
* visible. Flat / array layouts expose their array-encoding children; other
69-
* layouts hide array children until expanded. Tiles are coloured by dtype. */
67+
/** Build the full nested treemap for the file so every physical block is
68+
* visible down to the leaves. Flat / array layouts expose their array-encoding
69+
* children; other layouts hide array children until expanded. */
7070
function buildTree(node: LayoutTreeNode, segmentMap: Map<number, SegmentMapEntry>): TreeNode {
7171
const isArray = node.isArrayNode ?? false;
7272
const isFlatOrArray = isArray || node.encoding === 'vortex.flat';
@@ -118,20 +118,14 @@ export function BlockTreemap({
118118
const boxRef = useRef<HTMLDivElement>(null);
119119
const svgRef = useRef<SVGSVGElement>(null);
120120
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
121-
const [drillId, setDrillId] = useState(root.id);
122121
const [tooltip, setTooltip] = useState<Tooltip | null>(null);
123122
const [localHover, setLocalHover] = useState<string | null>(null);
124123

125124
const { theme: themeChoice } = useTheme();
126125
const theme = useMemo(() => resolveThemeColors(themeChoice), [themeChoice]);
127126
const segmentMap = useMemo(() => new Map(segments.map((s) => [s.index, s])), [segments]);
127+
const tree = useMemo(() => buildTree(root, segmentMap), [root, segmentMap]);
128128

129-
// Current drill root (the box shows this node's blocks). Falls back to the
130-
// file root if the drilled node disappears.
131-
const drillNode = useMemo(() => findNodeById(root, drillId) ?? root, [root, drillId]);
132-
const tree = useMemo(() => buildTree(drillNode, segmentMap), [drillNode, segmentMap]);
133-
134-
// Track the box size (area below the path header).
135129
useEffect(() => {
136130
const el = boxRef.current;
137131
if (!el) return;
@@ -149,12 +143,22 @@ export function BlockTreemap({
149143
.sum((d) => (d.children ? 0 : d.bytes))
150144
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
151145
treemap<TreeNode>()
152-
.size([size.w, size.h])
146+
// Lay out HEADER taller and shift up so the root's own header band sits
147+
// off-screen — top-level fields start flush with the top.
148+
.size([size.w, size.h + HEADER])
153149
.tile(treemapSquarify)
154150
.paddingInner(PAD)
155151
.paddingOuter(PAD)
152+
// paddingTop MUST be set after paddingOuter (which also sets the top pad),
153+
// or the header band collapses and container names overlap their children.
154+
.paddingTop(HEADER)
156155
.round(true)(r);
157-
return r.descendants() as RectNode[];
156+
const desc = r.descendants() as RectNode[];
157+
for (const n of desc) {
158+
n.y0 -= HEADER;
159+
n.y1 -= HEADER;
160+
}
161+
return desc;
158162
}, [tree, size]);
159163

160164
const byId = useMemo(() => {
@@ -176,14 +180,9 @@ export function BlockTreemap({
176180
}, [selectedNodeId, byId]);
177181

178182
const activeHover = localHover ?? hoveredNodeId;
179-
const hoveredNode = activeHover ? findNodeById(root, activeHover) : null;
180-
181-
// Path shown in the header: to the hovered block while hovering (so any block
182-
// can be identified), otherwise to the focused drill node.
183-
const headerNode = hoveredNode ?? drillNode;
184-
const headerPath = useMemo(() => findPathToNode(root, headerNode.id), [root, headerNode.id]);
185183

186-
/** Deepest tile (excluding the drill root itself) containing a point. */
184+
/** Deepest tile (below the root) containing a point. A container's header band
185+
* is not covered by children, so clicking there selects the container. */
187186
const hitTest = useCallback(
188187
(px: number, py: number): RectNode | null => {
189188
let best: RectNode | null = null;
@@ -223,17 +222,6 @@ export function BlockTreemap({
223222
onHoverNode(null);
224223
}, [onHoverNode]);
225224

226-
/** Drill into a node: re-root the box at it and select it. */
227-
const drillTo = useCallback(
228-
(node: LayoutTreeNode) => {
229-
if (isFlatLayout(node) && !node.children.some((c) => c.isArrayNode)) onExpand?.(node.id);
230-
setDrillId(node.id);
231-
onSelectNode(node.id);
232-
setTooltip(null);
233-
},
234-
[onExpand, onSelectNode],
235-
);
236-
237225
const handleClick = useCallback(
238226
(e: React.MouseEvent<SVGSVGElement>) => {
239227
const p = localPoint(e.clientX, e.clientY);
@@ -249,106 +237,75 @@ export function BlockTreemap({
249237
);
250238

251239
return (
252-
<div className="flex flex-col w-full h-full">
253-
{/* Path header — the box's title bar showing the current location. */}
254-
<div className="flex items-center gap-1 flex-shrink-0 h-6 px-2 border-b border-vortex-grey-light/40 dark:border-white/[0.06] bg-vortex-grey-lightest dark:bg-white/[0.03] text-[11px] font-mono overflow-hidden">
255-
{headerPath.map((node, i) => {
256-
const isLast = i === headerPath.length - 1;
257-
return (
258-
<span key={node.id} className="flex items-center gap-1 min-w-0 flex-shrink-0">
259-
{i > 0 && <span className="text-vortex-grey-dark opacity-40">/</span>}
260-
{isLast ? (
261-
<span className="text-vortex-fg-light dark:text-vortex-fg truncate">
262-
{getNodeDisplayName(node)}
263-
</span>
264-
) : (
265-
<button
266-
className="text-vortex-grey-dark hover:text-vortex-light-blue truncate"
267-
onClick={() => drillTo(node)}
268-
title={`Focus ${getNodeDisplayName(node)}`}
269-
>
270-
{getNodeDisplayName(node)}
271-
</button>
272-
)}
273-
</span>
274-
);
275-
})}
276-
{hoveredNode && (
277-
<span className="ml-auto flex-shrink-0 text-vortex-grey-dark truncate">
278-
{shortEncoding(hoveredNode.encoding)} · {hoveredNode.dtype}
279-
</span>
280-
)}
281-
</div>
282-
283-
{/* Box — the current node's blocks. */}
284-
<div ref={boxRef} className="relative flex-1 min-h-0 overflow-hidden">
285-
{size && (
286-
<svg
287-
ref={svgRef}
288-
width={size.w}
289-
height={size.h}
290-
className="block select-none"
291-
onMouseMove={handleMouseMove}
292-
onMouseLeave={handleMouseLeave}
293-
onClick={handleClick}
294-
>
295-
{nodes.map((n) => {
296-
if (n.depth === 0) return null; // the drill root fills the box; shown in the header
297-
const w = n.x1 - n.x0;
298-
const h = n.y1 - n.y0;
299-
if (w < 1 || h < 1) return null;
300-
301-
const d = n.data;
302-
const isLeaf = !n.children || n.children.length === 0;
303-
const isHovered = d.nodeId === activeHover;
304-
const isSelected = selectedSubtreeIds.has(d.nodeId);
305-
const maxChars = Math.floor((w - 6) / 6);
306-
const label = maxChars < 2 ? '' : truncate(d.name, maxChars);
307-
308-
return (
309-
<g key={d.nodeId} pointerEvents="none">
310-
<rect
311-
x={n.x0}
312-
y={n.y0}
313-
width={w}
314-
height={h}
315-
fill={d.color}
316-
fillOpacity={isSelected ? 0.4 : isLeaf ? 0.18 : 0.06}
317-
stroke={isHovered ? theme.highlight : theme.border}
318-
strokeWidth={isHovered ? 2 : isLeaf ? 0.5 : 1}
319-
/>
320-
{/* Only leaf blocks are labelled, so labels never collide with
321-
nested content. Parent names come from the path header. */}
322-
{isLeaf && label && h > 14 && (
323-
<text
324-
x={n.x0 + 4}
325-
y={n.y0 + 12}
326-
fill={theme.fg}
327-
fontSize={10}
328-
fontFamily="'Geist Mono', monospace"
329-
>
330-
{label}
331-
</text>
332-
)}
333-
{isLeaf && w > 50 && h > 28 && (
334-
<text
335-
x={n.x0 + 4}
336-
y={n.y0 + 23}
337-
fill={theme.dim}
338-
fontSize={9}
339-
fontFamily="'Geist Mono', monospace"
340-
>
341-
{formatBytes(d.bytes)}
342-
</text>
343-
)}
344-
</g>
345-
);
346-
})}
347-
</svg>
348-
)}
349-
350-
{tooltip && <TileTooltip tooltip={tooltip} segments={segments} fileSize={fileSize} />}
351-
</div>
240+
<div ref={boxRef} className="relative w-full h-full overflow-hidden">
241+
{size && (
242+
<svg
243+
ref={svgRef}
244+
width={size.w}
245+
height={size.h}
246+
className="block select-none cursor-pointer"
247+
onMouseMove={handleMouseMove}
248+
onMouseLeave={handleMouseLeave}
249+
onClick={handleClick}
250+
>
251+
{nodes.map((n) => {
252+
if (n.depth === 0) return null; // root fills the box; its band is shifted off-screen
253+
const w = n.x1 - n.x0;
254+
const h = n.y1 - n.y0;
255+
if (w < 1 || h < 1) return null;
256+
257+
const d = n.data;
258+
const isLeaf = !n.children || n.children.length === 0;
259+
const isHovered = d.nodeId === activeHover;
260+
const isSelected = selectedSubtreeIds.has(d.nodeId);
261+
const maxChars = Math.floor((w - 6) / 6);
262+
const label = maxChars < 2 ? '' : truncate(d.name, maxChars);
263+
264+
return (
265+
<g key={d.nodeId} pointerEvents="none">
266+
<rect
267+
x={n.x0}
268+
y={n.y0}
269+
width={w}
270+
height={h}
271+
fill={d.color}
272+
fillOpacity={isSelected ? 0.4 : isLeaf ? 0.18 : 0.06}
273+
stroke={isHovered ? theme.highlight : theme.border}
274+
strokeWidth={isHovered ? 2 : isLeaf ? 0.5 : 1}
275+
/>
276+
{/* Name sits locally on the block: in a leaf's body, or in a
277+
container's header band (which children never occupy, so it
278+
cannot collide with them). */}
279+
{label && h > 11 && (
280+
<text
281+
x={n.x0 + 4}
282+
y={n.y0 + 11}
283+
fill={theme.fg}
284+
fontSize={10}
285+
fontWeight={isLeaf ? 400 : 600}
286+
fontFamily="'Geist Mono', monospace"
287+
>
288+
{label}
289+
</text>
290+
)}
291+
{isLeaf && w > 50 && h > 26 && (
292+
<text
293+
x={n.x0 + 4}
294+
y={n.y0 + 22}
295+
fill={theme.dim}
296+
fontSize={9}
297+
fontFamily="'Geist Mono', monospace"
298+
>
299+
{formatBytes(d.bytes)}
300+
</text>
301+
)}
302+
</g>
303+
);
304+
})}
305+
</svg>
306+
)}
307+
308+
{tooltip && <TileTooltip tooltip={tooltip} segments={segments} fileSize={fileSize} />}
352309
</div>
353310
);
354311
}

vortex-web/src/components/explorer/TreemapExplorer.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,7 @@ export function TreemapExplorer() {
5757
{cat}
5858
</div>
5959
))}
60-
<span className="ml-auto flex-shrink-0">
61-
click a block to select · click the path to focus a subtree
62-
</span>
60+
<span className="ml-auto flex-shrink-0">click a block to inspect it</span>
6361
</div>
6462

6563
{/* Map + sidebar */}

0 commit comments

Comments
 (0)