@@ -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
4644type RectNode = HierarchyRectangularNode < TreeNode > ;
4745
46+ // Header band reserved at the top of each container layout for its own name.
47+ const HEADER = 16 ;
4848const PAD = 2 ;
4949
5050function 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. */
7070function 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}
0 commit comments