diff --git a/vortex-web/.storybook/preview.ts b/vortex-web/.storybook/preview.ts index e120ec5e169..479f3129943 100644 --- a/vortex-web/.storybook/preview.ts +++ b/vortex-web/.storybook/preview.ts @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +import { createElement } from 'react'; import type { Preview } from '@storybook/react-vite'; +import { ThemeContext } from '../src/contexts/ThemeContext'; import '../src/index.css'; const preview: Preview = { @@ -27,7 +29,13 @@ const preview: Preview = { const theme = context.globals.theme || 'light'; document.documentElement.classList.toggle('dark', theme === 'dark'); document.documentElement.classList.toggle('light', theme === 'light'); - return Story(); + // Supply the theme context so components using useTheme render in stories, + // following the Storybook theme toolbar rather than persisted preferences. + return createElement( + ThemeContext.Provider, + { value: { theme, setTheme: () => {} } }, + Story(), + ); }, ], parameters: { diff --git a/vortex-web/README.md b/vortex-web/README.md index ac1a1a53201..3bdea08fcf0 100644 --- a/vortex-web/README.md +++ b/vortex-web/README.md @@ -2,6 +2,32 @@ A web UI for exploring Vortex data files, built with React, TypeScript, Tailwind CSS, and Rust/WASM. +## Treemap Explorer + +The detail panel's **Treemap** tab renders the file's physical blocks all the +way down to the leaves: layouts nest, flat layouts subdivide into their +array-encoding buffers, and every block is sized by its byte footprint and +coloured by dtype to match the swimlane. Each layout's name sits locally on the +block — in a container's header band (which its children never occupy, so labels +never collide) or in a leaf's body. Single-click a block to select it — it is +highlighted and the tree panel scrolls to it; double-click to zoom in (re-root +the map there), with an "↑" control to zoom back out. Selecting a node in the +tree panel drives the zoom too — the map re-roots at it. + +![Treemap explorer overview](docs/img/treemap-explorer-overview.png) + +Hovering a block shows its physical statistics — data and metadata bytes, +percentage of the file, encoded bytes-per-row density, rows, and segment/buffer +counts — in a floating tooltip; the sidebar and data sample track the selected +block, and selection stays in sync with the tree panel. + +![Hovering a tile shows physical statistics](docs/img/treemap-explorer-hover.png) + +Files with many chunks expose every chunk as its own byte-sized tile, making +skew and per-chunk size distribution obvious at a glance: + +![Many chunks rendered as byte-sized tiles](docs/img/treemap-explorer-chunks-dark.png) + ## Prerequisites - Node.js 22+ @@ -34,7 +60,7 @@ This starts a dev server at http://localhost:6006. ## Scripts | Command | Description | -|---------------------------|--------------------------------------------| +| ------------------------- | ------------------------------------------ | | `npm run dev` | Build WASM (debug) + start Vite dev server | | `npm run build` | Production build (WASM release + Vite) | | `npm run storybook` | Start Storybook dev server on port 6006 | @@ -49,18 +75,18 @@ This starts a dev server at http://localhost:6006. Add story files alongside your components as `*.stories.tsx`: ```tsx -import type {Meta, StoryObj} from '@storybook/react-vite'; -import {MyComponent} from './MyComponent'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { MyComponent } from './MyComponent'; const meta: Meta = { - component: MyComponent, + component: MyComponent, }; export default meta; type Story = StoryObj; export const Default: Story = { - args: {}, + args: {}, }; ``` diff --git a/vortex-web/docs/img/treemap-explorer-chunks-dark.png b/vortex-web/docs/img/treemap-explorer-chunks-dark.png new file mode 100644 index 00000000000..99307f75726 Binary files /dev/null and b/vortex-web/docs/img/treemap-explorer-chunks-dark.png differ diff --git a/vortex-web/docs/img/treemap-explorer-hover.png b/vortex-web/docs/img/treemap-explorer-hover.png new file mode 100644 index 00000000000..994ea2794da Binary files /dev/null and b/vortex-web/docs/img/treemap-explorer-hover.png differ diff --git a/vortex-web/docs/img/treemap-explorer-overview.png b/vortex-web/docs/img/treemap-explorer-overview.png new file mode 100644 index 00000000000..132497eaae5 Binary files /dev/null and b/vortex-web/docs/img/treemap-explorer-overview.png differ diff --git a/vortex-web/src/components/detail/DetailPanel.tsx b/vortex-web/src/components/detail/DetailPanel.tsx index a37ef08ac8d..1dde7abca47 100644 --- a/vortex-web/src/components/detail/DetailPanel.tsx +++ b/vortex-web/src/components/detail/DetailPanel.tsx @@ -15,7 +15,7 @@ import { SummaryPane } from './SummaryPane'; import { ArraySummaryPane } from './ArraySummaryPane'; import { EncodingPane } from './EncodingPane'; import { SegmentsPane } from './SegmentsPane'; -import { TreemapPane } from './TreemapPane'; +import { BlockTreemap } from '../explorer/BlockTreemap'; import { BuffersPane } from './BuffersPane'; type TabId = 'encoding' | 'segments' | 'treemap' | 'buffers'; @@ -155,11 +155,15 @@ export function DetailPanel() { )} {currentTab === 'treemap' && selection.selectedNode && ( - )} {currentTab === 'buffers' && selection.selectedNode && ( diff --git a/vortex-web/src/components/detail/TreemapPane.stories.tsx b/vortex-web/src/components/detail/TreemapPane.stories.tsx deleted file mode 100644 index 9a97e9c2f52..00000000000 --- a/vortex-web/src/components/detail/TreemapPane.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { TreemapPane } from './TreemapPane'; -import { withMockFileContext, withMockSelection } from '../../storybook/decorators'; -import { ordersMock } from '../../mocks/layouts'; -import { generateSegments } from '../../mocks/segments'; -import { generateFileStructure } from '../../mocks/fileStructure'; -import type { VortexFileState } from '../../contexts/VortexFileContext'; - -const layout = ordersMock(); -const segments = generateSegments(layout, 12_400_000); -const fileStructure = generateFileStructure(segments, 12_400_000); - -const mockFileState: VortexFileState = { - fileName: 'orders.vortex', - fileSize: 12_400_000, - rowCount: 100_000, - version: 1, - dtype: '{order_id=i64, ...}', - layoutTree: layout, - segments, - fileStructure, -}; - -const meta: Meta = { - component: TreemapPane, - decorators: [withMockFileContext(mockFileState), withMockSelection(layout)], - parameters: { - layout: 'fullscreen', - }, -}; -export default meta; - -type Story = StoryObj; - -export const RootNode: Story = { - args: { - node: layout, - segments, - onSelectNode: (id: string) => console.log('select', id), - onHoverNode: (id: string | null) => console.log('hover', id), - }, - decorators: [ - (Story) => ( -
- -
- ), - ], -}; diff --git a/vortex-web/src/components/detail/TreemapPane.tsx b/vortex-web/src/components/detail/TreemapPane.tsx deleted file mode 100644 index 55418a972c9..00000000000 --- a/vortex-web/src/components/detail/TreemapPane.tsx +++ /dev/null @@ -1,289 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -import { useMemo, useRef, useEffect, useCallback, useState } from 'react'; -import { hierarchy, treemap, treemapSquarify } from 'd3-hierarchy'; -import type { HierarchyRectangularNode } from 'd3-hierarchy'; -import type { LayoutTreeNode, SegmentMapEntry } from '../swimlane/types'; -import { - getNodeDisplayName, - getDtypeCategory, - collectSubtreeSegments, - DTYPE_COLORS, - formatBytes, -} from '../swimlane/utils'; -import { useTheme } from '../../contexts/ThemeContext'; - -interface TreemapPaneProps { - node: LayoutTreeNode; - segments: SegmentMapEntry[]; - onSelectNode: (nodeId: string) => void; - onHoverNode: (nodeId: string | null) => void; -} - -interface TreeNode { - name: string; - nodeId: string; - color: string; - bytes: number; - children?: TreeNode[]; -} - -type RectNode = HierarchyRectangularNode; - -/** Total buffer bytes for an array node subtree. */ -function arraySubtreeBytes(node: LayoutTreeNode): number { - const own = (node.bufferLengths ?? []).reduce((s, b) => s + b, 0); - const childBytes = node.children - .filter((c) => c.isArrayNode) - .reduce((s, c) => s + arraySubtreeBytes(c), 0); - return own + childBytes; -} - -function buildTree(node: LayoutTreeNode, segmentMap: Map): TreeNode { - const color = DTYPE_COLORS[getDtypeCategory(node.dtype)]; - const name = getNodeDisplayName(node); - - // For array nodes, size by buffer bytes; for layout nodes, by segment bytes. - // Layout-level treemaps skip array children to avoid eager expansion. - const isArray = node.isArrayNode ?? false; - const bytes = isArray - ? arraySubtreeBytes(node) - : collectSubtreeSegments(node).reduce( - (sum, id) => sum + (segmentMap.get(id)?.byteLength ?? 0), - 0, - ); - - // For layout nodes, skip array children of NON-flat layouts to avoid eager expansion. - // Flat layouts and array nodes show all their children (array tree is already fetched). - const isFlatOrArray = isArray || node.encoding === 'vortex.flat'; - const childrenToShow = isFlatOrArray - ? node.children - : node.children.filter((c) => !c.isArrayNode); - - if (childrenToShow.length === 0) { - return { name, nodeId: node.id, color, bytes: Math.max(bytes, 1) }; - } - - return { - name, - nodeId: node.id, - color, - bytes: Math.max(bytes, 1), - children: childrenToShow.map((c) => buildTree(c, segmentMap)), - }; -} - -function resolveThemeColors(choice: 'light' | 'dark' | 'system') { - let isDark: boolean; - if (choice === 'system') { - isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - } else { - isDark = choice === 'dark'; - } - return isDark - ? { fg: '#e4e4e8', dim: '#71717a', border: 'rgba(255,255,255,0.12)', highlight: '#2CB9D1' } - : { fg: '#18181b', dim: '#71717a', border: 'rgba(0,0,0,0.1)', highlight: '#2CB9D1' }; -} - -/** Find the deepest node containing point (px, py), preferring depth >= 1. */ -function hitTest(nodes: RectNode[], px: number, py: number): RectNode | null { - let best: RectNode | null = null; - for (const n of nodes) { - if (n.depth >= 1 && px >= n.x0 && px < n.x1 && py >= n.y0 && py < n.y1) { - if (!best || n.depth > best.depth) best = n; - } - } - return best; -} - -/** Collect all nodeIds in a RectNode subtree. */ -function collectRectIds(n: RectNode): Set { - const ids = new Set(); - function walk(node: RectNode) { - ids.add(node.data.nodeId); - if (node.children) { - for (const c of node.children) walk(c); - } - } - walk(n); - return ids; -} - -export function TreemapPane({ node, segments, onSelectNode, onHoverNode }: TreemapPaneProps) { - const containerRef = useRef(null); - const svgRef = useRef(null); - const [size, setSize] = useState<{ w: number; h: number } | null>(null); - const [hoveredNodeId, setHoveredNodeId] = useState(null); - const [selectedNodeId, setSelectedNodeId] = useState(null); - - const segmentMap = useMemo(() => new Map(segments.map((s) => [s.index, s])), [segments]); - - const tree = useMemo(() => buildTree(node, segmentMap), [node, segmentMap]); - const { theme: themeChoice } = useTheme(); - const theme = useMemo(() => resolveThemeColors(themeChoice), [themeChoice]); - - useEffect(() => { - const el = containerRef.current; - if (!el) return; - const ro = new ResizeObserver(([entry]) => { - const { width, height } = entry.contentRect; - if (width > 0 && height > 0) setSize({ w: width, h: height }); - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - // Reset local selection when the treemap root node changes. - useEffect(() => { - setSelectedNodeId(null); - }, [node.id]); - - const nodes = useMemo(() => { - if (!size) return []; - const root = hierarchy(tree) - .sum((d) => (d.children ? 0 : d.bytes)) - .sort((a, b) => (b.value ?? 0) - (a.value ?? 0)); - treemap() - .size([size.w, size.h]) - .tile(treemapSquarify) - .paddingTop(18) - .paddingInner(2) - .paddingOuter(1) - .round(true)(root); - return root.descendants() as RectNode[]; - }, [tree, size]); - - // Set of nodeIds in the selected depth-1 subtree (for solid highlight). - const selectedSubtreeIds = useMemo>(() => { - if (!selectedNodeId) return new Set(); - const selected = nodes.find((n) => n.data.nodeId === selectedNodeId); - return selected ? collectRectIds(selected) : new Set(); - }, [selectedNodeId, nodes]); - - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - const svg = svgRef.current; - if (!svg) return; - const rect = svg.getBoundingClientRect(); - const px = e.clientX - rect.left; - const py = e.clientY - rect.top; - const hit = hitTest(nodes, px, py); - const nodeId = hit ? hit.data.nodeId : null; - setHoveredNodeId(nodeId); - onHoverNode(nodeId); - }, - [nodes, onHoverNode], - ); - - const handleMouseLeave = useCallback(() => { - setHoveredNodeId(null); - onHoverNode(null); - }, [onHoverNode]); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - const svg = svgRef.current; - if (!svg) return; - const rect = svg.getBoundingClientRect(); - const px = e.clientX - rect.left; - const py = e.clientY - rect.top; - const hit = hitTest(nodes, px, py); - if (hit) { - e.stopPropagation(); - setSelectedNodeId(hit.data.nodeId); - } else { - setSelectedNodeId(null); - } - }, - [nodes], - ); - - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - const svg = svgRef.current; - if (!svg) return; - const rect = svg.getBoundingClientRect(); - const px = e.clientX - rect.left; - const py = e.clientY - rect.top; - const hit = hitTest(nodes, px, py); - if (hit) { - e.stopPropagation(); - onSelectNode(hit.data.nodeId); - } - }, - [nodes, onSelectNode], - ); - - return ( -
- {size && ( - - {nodes.map((n) => { - const w = n.x1 - n.x0; - const h = n.y1 - n.y0; - if (w < 1 || h < 1) return null; - - const isLeaf = !n.children || n.children.length === 0; - const d = n.data; - const isHovered = d.nodeId === hoveredNodeId; - const isSelected = selectedSubtreeIds.has(d.nodeId); - const maxChars = Math.floor(w / 6); - const label = - maxChars < 2 - ? '' - : d.name.length > maxChars - ? d.name.slice(0, maxChars - 1) + '\u2026' - : d.name; - - return ( - - - {n.depth === 1 && label && h > 14 && ( - - {label} - - )} - {n.depth === 1 && w > 50 && h > 28 && ( - - {formatBytes(d.bytes)} - - )} - - ); - })} - - )} -
- ); -} diff --git a/vortex-web/src/components/explorer/BlockTreemap.stories.tsx b/vortex-web/src/components/explorer/BlockTreemap.stories.tsx new file mode 100644 index 00000000000..021394ab29d --- /dev/null +++ b/vortex-web/src/components/explorer/BlockTreemap.stories.tsx @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { BlockTreemap } from './BlockTreemap'; +import { withMockFileContext, withMockSelection } from '../../storybook/decorators'; +import { ordersMock, heavyChunksMock } from '../../mocks/layouts'; +import { generateSegments } from '../../mocks/segments'; +import { generateFileStructure } from '../../mocks/fileStructure'; +import type { LayoutTreeNode } from '../swimlane/types'; +import type { VortexFileState } from '../../contexts/VortexFileContext'; +import { findNodeById } from '../swimlane/utils'; + +function mockState( + fileName: string, + layout: LayoutTreeNode, + fileSize: number, + rowCount: number, +): VortexFileState { + const segments = generateSegments(layout, fileSize); + return { + fileName, + fileSize, + rowCount, + version: 1, + dtype: layout.dtype, + layoutTree: layout, + segments, + fileStructure: generateFileStructure(segments, fileSize), + }; +} + +const orders = ordersMock(); +const ordersState = mockState('orders.vortex', orders, 12_400_000, 100_000); + +const meta: Meta = { + component: BlockTreemap, + parameters: { layout: 'fullscreen' }, + globals: { theme: 'dark' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; + +type Story = StoryObj; + +const noop = { + onSelectNode: (id: string | null) => console.log('select (zoom in)', id), + onHoverNode: (id: string | null) => console.log('hover', id), +}; + +/** The whole file — every physical block down to the leaves, with one block + * selected (highlighted). Single-click selects, double-click zooms in. */ +export const Root: Story = { + decorators: [withMockFileContext(ordersState), withMockSelection(orders)], + args: { + root: orders, + segments: ordersState.segments, + fileSize: 12_400_000, + selectedNodeId: 'root.amount', + hoveredNodeId: null, + ...noop, + }, +}; + +/** Zoomed in: rooted at a single field (what a double-click produces). */ +export const Field: Story = { + decorators: [withMockFileContext(ordersState), withMockSelection(orders)], + args: { + root: findNodeById(orders, 'root.customer')!, + segments: ordersState.segments, + fileSize: 12_400_000, + selectedNodeId: null, + hoveredNodeId: null, + ...noop, + }, +}; + +const heavy = heavyChunksMock(); +const heavyState = mockState('heavy.vortex', heavy, 80_000_000, 1_000_000); + +/** A column with 500 chunks, all rendered as their own blocks. */ +export const HeavyChunks: Story = { + decorators: [withMockFileContext(heavyState), withMockSelection(heavy)], + args: { + root: heavy, + segments: heavyState.segments, + fileSize: 80_000_000, + selectedNodeId: null, + hoveredNodeId: null, + ...noop, + }, +}; diff --git a/vortex-web/src/components/explorer/BlockTreemap.tsx b/vortex-web/src/components/explorer/BlockTreemap.tsx new file mode 100644 index 00000000000..532aff2a624 --- /dev/null +++ b/vortex-web/src/components/explorer/BlockTreemap.tsx @@ -0,0 +1,440 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { hierarchy, treemap, treemapSquarify } from 'd3-hierarchy'; +import type { HierarchyRectangularNode } from 'd3-hierarchy'; +import type { LayoutTreeNode, SegmentMapEntry } from '../swimlane/types'; +import { + getNodeDisplayName, + getDtypeCategory, + shortEncoding, + collectSubtreeSegments, + findNodeById, + findPathToNode, + isFlatLayout, + formatBytes, + DTYPE_COLORS, +} from '../swimlane/utils'; +import { useTheme } from '../../contexts/ThemeContext'; +import { nodePhysicalStats, formatPercent, formatBytesPerRow } from './physicalStats'; + +interface BlockTreemapProps { + /** The full layout tree the map can explore. */ + root: LayoutTreeNode; + segments: SegmentMapEntry[]; + fileSize: number; + /** Currently selected node id — highlighted in the map. */ + selectedNodeId: string | null; + /** Currently hovered node id (from selection context). */ + hoveredNodeId: string | null; + /** Single-click selects a block (highlights it and scrolls the tree); does not + * re-root. Double-click zooms in. */ + onSelectNode: (id: string | null) => void; + onHoverNode: (id: string | null) => void; + /** Called when a flat layout should reveal its array-encoding children. */ + onExpand?: (id: string) => void; +} + +interface TreeNode { + name: string; + nodeId: string; + color: string; + bytes: number; + layoutNode: LayoutTreeNode; + children?: TreeNode[]; +} + +type RectNode = HierarchyRectangularNode; + +// Header band reserved at the top of each container layout for its own name. +const HEADER = 16; +const PAD = 2; + +function arraySubtreeBytes(node: LayoutTreeNode): number { + const own = (node.bufferLengths ?? []).reduce((s, b) => s + b, 0); + const childBytes = node.children + .filter((c) => c.isArrayNode) + .reduce((s, c) => s + arraySubtreeBytes(c), 0); + return own + childBytes; +} + +/** Total encoded bytes for a node's whole subtree. */ +function subtreeBytes(node: LayoutTreeNode, segmentMap: Map): number { + if (node.isArrayNode) return arraySubtreeBytes(node); + return collectSubtreeSegments(node).reduce( + (sum, id) => sum + (segmentMap.get(id)?.byteLength ?? 0), + 0, + ); +} + +/** Build the full nested treemap for the file so every physical block is + * visible down to the leaves. Flat / array layouts expose their array-encoding + * children; other layouts hide array children until expanded. */ +function buildTree(node: LayoutTreeNode, segmentMap: Map): TreeNode { + const isArray = node.isArrayNode ?? false; + const isFlatOrArray = isArray || node.encoding === 'vortex.flat'; + const children = isFlatOrArray ? node.children : node.children.filter((c) => !c.isArrayNode); + const base = { + name: getNodeDisplayName(node), + nodeId: node.id, + color: DTYPE_COLORS[getDtypeCategory(node.dtype)], + bytes: Math.max(subtreeBytes(node, segmentMap), 1), + layoutNode: node, + }; + if (children.length === 0) return base; + return { ...base, children: children.map((c) => buildTree(c, segmentMap)) }; +} + +interface ThemeColors { + fg: string; + dim: string; + border: string; + highlight: string; +} + +function resolveThemeColors(choice: 'light' | 'dark' | 'system'): ThemeColors { + const isDark = + choice === 'system' + ? window.matchMedia('(prefers-color-scheme: dark)').matches + : choice === 'dark'; + return isDark + ? { fg: '#e4e4e8', dim: '#71717a', border: 'rgba(255,255,255,0.12)', highlight: '#2CB9D1' } + : { fg: '#18181b', dim: '#71717a', border: 'rgba(0,0,0,0.1)', highlight: '#2CB9D1' }; +} + +interface Tooltip { + node: LayoutTreeNode; + x: number; + y: number; +} + +export function BlockTreemap({ + root, + segments, + fileSize, + selectedNodeId, + hoveredNodeId, + onSelectNode, + onHoverNode, + onExpand, +}: BlockTreemapProps) { + const boxRef = useRef(null); + const svgRef = useRef(null); + const [size, setSize] = useState<{ w: number; h: number } | null>(null); + const [tooltip, setTooltip] = useState(null); + const [localHover, setLocalHover] = useState(null); + // The layout the map is zoomed into (double-click drills in). Independent of + // selection, so single-click can select a block without re-rooting. + const [drillId, setDrillId] = useState(root.id); + // Tracks selections the map itself made, so they don't trigger a re-zoom — only + // external selections (e.g. clicking the tree panel) drive the zoom. + const selfSelected = useRef(null); + + const { theme: themeChoice } = useTheme(); + const theme = useMemo(() => resolveThemeColors(themeChoice), [themeChoice]); + const segmentMap = useMemo(() => new Map(segments.map((s) => [s.index, s])), [segments]); + const drillNode = useMemo(() => findNodeById(root, drillId) ?? root, [root, drillId]); + const tree = useMemo(() => buildTree(drillNode, segmentMap), [drillNode, segmentMap]); + const drillPath = useMemo(() => findPathToNode(root, drillNode.id), [root, drillNode.id]); + const drillParent = drillPath.length >= 2 ? drillPath[drillPath.length - 2] : null; + + useEffect(() => { + const el = boxRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) setSize({ w: width, h: height }); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Reset the zoom when the file (root) changes. + useEffect(() => setDrillId(root.id), [root.id]); + + // An external selection (e.g. clicking the tree panel) drives the zoom: re-root + // the map at the selected node. Selections the map made itself are ignored. + useEffect(() => { + if (!selectedNodeId || selectedNodeId === selfSelected.current) return; + setDrillId(selectedNodeId); + const node = findNodeById(root, selectedNodeId); + if (node && isFlatLayout(node) && !node.children.some((c) => c.isArrayNode)) { + onExpand?.(node.id); + } + }, [selectedNodeId, root, onExpand]); + + const nodes = useMemo(() => { + if (!size) return []; + const r = hierarchy(tree) + .sum((d) => (d.children ? 0 : d.bytes)) + .sort((a, b) => (b.value ?? 0) - (a.value ?? 0)); + treemap() + // Lay out HEADER taller and shift up so the root's own header band sits + // off-screen — top-level fields start flush with the top. + .size([size.w, size.h + HEADER]) + .tile(treemapSquarify) + .paddingInner(PAD) + .paddingOuter(PAD) + // paddingTop MUST be set after paddingOuter (which also sets the top pad), + // or the header band collapses and container names overlap their children. + .paddingTop(HEADER) + .round(true)(r); + const desc = r.descendants() as RectNode[]; + for (const n of desc) { + n.y0 -= HEADER; + n.y1 -= HEADER; + } + return desc; + }, [tree, size]); + + const byId = useMemo(() => { + const m = new Map(); + for (const n of nodes) m.set(n.data.nodeId, n); + return m; + }, [nodes]); + + // Node ids in the selected subtree — rendered with a solid tint. + const selectedSubtreeIds = useMemo>(() => { + const ids = new Set(); + const sel = selectedNodeId ? byId.get(selectedNodeId) : undefined; + if (!sel) return ids; + (function walk(n: RectNode) { + ids.add(n.data.nodeId); + if (n.children) for (const c of n.children) walk(c); + })(sel); + return ids; + }, [selectedNodeId, byId]); + + const activeHover = localHover ?? hoveredNodeId; + + /** Deepest tile (below the root) containing a point. A container's header band + * is not covered by children, so clicking there selects the container. */ + const hitTest = useCallback( + (px: number, py: number): RectNode | null => { + let best: RectNode | null = null; + for (const n of nodes) { + if (n.depth >= 1 && px >= n.x0 && px < n.x1 && py >= n.y0 && py < n.y1) { + if (!best || n.depth > best.depth) best = n; + } + } + return best; + }, + [nodes], + ); + + const localPoint = useCallback((clientX: number, clientY: number) => { + const svg = svgRef.current; + if (!svg) return null; + const rect = svg.getBoundingClientRect(); + return { px: clientX - rect.left, py: clientY - rect.top }; + }, []); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const p = localPoint(e.clientX, e.clientY); + if (!p) return; + const hit = hitTest(p.px, p.py); + const id = hit ? hit.data.nodeId : null; + setLocalHover(id); + onHoverNode(id); + setTooltip(hit ? { node: hit.data.layoutNode, x: e.clientX, y: e.clientY } : null); + }, + [hitTest, localPoint, onHoverNode], + ); + + const handleMouseLeave = useCallback(() => { + setLocalHover(null); + setTooltip(null); + onHoverNode(null); + }, [onHoverNode]); + + /** Reveal a flat layout's array buffers in place so they render to leaves. */ + const expandIfFlat = useCallback( + (node: LayoutTreeNode) => { + if (isFlatLayout(node) && !node.children.some((c) => c.isArrayNode)) onExpand?.(node.id); + }, + [onExpand], + ); + + // Single click selects the block under the cursor — highlighting it and + // scrolling the tree panel to it — without changing the zoom. + const handleClick = useCallback( + (e: React.MouseEvent) => { + const p = localPoint(e.clientX, e.clientY); + if (!p) return; + const hit = hitTest(p.px, p.py); + if (!hit) return; + selfSelected.current = hit.data.nodeId; + onSelectNode(hit.data.nodeId); + expandIfFlat(hit.data.layoutNode); + }, + [hitTest, localPoint, onSelectNode, expandIfFlat], + ); + + // Double click zooms in: re-root the map at the block (and select it). + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + const p = localPoint(e.clientX, e.clientY); + if (!p) return; + const hit = hitTest(p.px, p.py); + if (!hit) return; + e.stopPropagation(); + selfSelected.current = hit.data.nodeId; + setDrillId(hit.data.nodeId); + onSelectNode(hit.data.nodeId); + expandIfFlat(hit.data.layoutNode); + }, + [hitTest, localPoint, onSelectNode, expandIfFlat], + ); + + return ( +
+ {/* Zoom-out control, shown when drilled into a child layout. */} + {drillParent && ( + + )} + {size && ( + + {nodes.map((n) => { + // The drill root's frame is shifted off-screen — skip it, unless the + // root is itself a leaf (then render it as the single block). + if (n.depth === 0 && n.children && n.children.length > 0) return null; + const w = n.x1 - n.x0; + const h = n.y1 - n.y0; + if (w < 1 || h < 1) return null; + + const d = n.data; + const isLeaf = !n.children || n.children.length === 0; + const isHovered = d.nodeId === activeHover; + const isSelected = selectedSubtreeIds.has(d.nodeId); + const maxChars = Math.floor((w - 6) / 6); + const label = maxChars < 2 ? '' : truncate(d.name, maxChars); + + return ( + + + {/* Name sits locally on the block: in a leaf's body, or in a + container's header band (which children never occupy, so it + cannot collide with them). */} + {label && h > 11 && ( + + {label} + + )} + {isLeaf && w > 50 && h > 26 && ( + + {formatBytes(d.bytes)} + + )} + + ); + })} + + )} + + {tooltip && } +
+ ); +} + +function truncate(s: string, maxChars: number): string { + if (maxChars < 2) return ''; + return s.length > maxChars ? s.slice(0, maxChars - 1) + '…' : s; +} + +function TileTooltip({ + tooltip, + segments, + fileSize, +}: { + tooltip: Tooltip; + segments: SegmentMapEntry[]; + fileSize: number; +}) { + const { node, x, y } = tooltip; + const stats = nodePhysicalStats(node, segments, fileSize); + const dtypeCat = getDtypeCategory(node.dtype); + const dtypeColor = DTYPE_COLORS[dtypeCat]; + return ( +
+
+ + {getNodeDisplayName(node)} + + + {dtypeCat} + +
+
+ rows + + {stats.rowCount.toLocaleString()} + + encoding + + {shortEncoding(node.encoding)} + + data + + {formatBytes(stats.dataBytes)} + + % of file + + {formatPercent(stats.fractionOfFile)} + + density + + {formatBytesPerRow(stats.bytesPerRow)} + +
+
+ ); +} diff --git a/vortex-web/src/components/explorer/physicalStats.ts b/vortex-web/src/components/explorer/physicalStats.ts new file mode 100644 index 00000000000..8b87b0e0ce6 --- /dev/null +++ b/vortex-web/src/components/explorer/physicalStats.ts @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import type { LayoutTreeNode, SegmentMapEntry } from '../swimlane/types'; +import { collectSubtreeSegments } from '../swimlane/utils'; + +/** + * Physical (on-disk) properties of a layout or array-encoding node, aggregated + * over its subtree. These describe how the logical data is laid out as bytes in + * the file — the focus of the treemap explorer. + */ +export interface PhysicalStats { + /** Bytes of actual encoded data (segment bytes for layouts, buffer bytes for array nodes). */ + dataBytes: number; + /** Bytes of metadata summed over the subtree. */ + metadataBytes: number; + /** dataBytes + metadataBytes. */ + totalBytes: number; + /** Fraction of the whole file occupied by this subtree (0..1). */ + fractionOfFile: number; + /** Average encoded bytes per logical row, or null when the node spans no rows. */ + bytesPerRow: number | null; + /** Logical rows spanned by this node. */ + rowCount: number; + /** Number of file segments reachable from this subtree (layout nodes). */ + segmentCount: number; + /** Number of buffers in this array node (array nodes only). */ + bufferCount: number; +} + +/** Total buffer bytes for an array-encoding node subtree. */ +function arraySubtreeBytes(node: LayoutTreeNode): number { + const own = (node.bufferLengths ?? []).reduce((sum, b) => sum + b, 0); + const childBytes = node.children + .filter((c) => c.isArrayNode) + .reduce((sum, c) => sum + arraySubtreeBytes(c), 0); + return own + childBytes; +} + +/** Sum of metadata bytes across an entire subtree. */ +function subtreeMetadataBytes(node: LayoutTreeNode): number { + return node.children.reduce((sum, c) => sum + subtreeMetadataBytes(c), node.metadataBytes); +} + +/** Count buffers across an array-node subtree. */ +function arraySubtreeBufferCount(node: LayoutTreeNode): number { + const own = (node.bufferLengths ?? []).length; + return node.children + .filter((c) => c.isArrayNode) + .reduce((sum, c) => sum + arraySubtreeBufferCount(c), own); +} + +/** + * Compute the physical statistics for a node, aggregated over its subtree. + * + * @param node the layout or array node to describe + * @param segments the file's segment map (used to resolve layout byte sizes) + * @param fileSize total file size in bytes, used for the percentage-of-file metric + */ +export function nodePhysicalStats( + node: LayoutTreeNode, + segments: SegmentMapEntry[], + fileSize: number, +): PhysicalStats { + const isArray = node.isArrayNode ?? false; + + let dataBytes: number; + let segmentCount: number; + let bufferCount: number; + + if (isArray) { + dataBytes = arraySubtreeBytes(node); + bufferCount = arraySubtreeBufferCount(node); + segmentCount = 0; + } else { + const segmentMap = new Map(segments.map((s) => [s.index, s])); + const ids = new Set(collectSubtreeSegments(node)); + dataBytes = 0; + for (const id of ids) { + dataBytes += segmentMap.get(id)?.byteLength ?? 0; + } + segmentCount = ids.size; + bufferCount = 0; + } + + const metadataBytes = subtreeMetadataBytes(node); + const totalBytes = dataBytes + metadataBytes; + + return { + dataBytes, + metadataBytes, + totalBytes, + fractionOfFile: fileSize > 0 ? totalBytes / fileSize : 0, + bytesPerRow: node.rowCount > 0 ? dataBytes / node.rowCount : null, + rowCount: node.rowCount, + segmentCount, + bufferCount, + }; +} + +/** Format a fraction (0..1) as a percentage string, e.g. "12.3%". */ +export function formatPercent(fraction: number): string { + return `${(fraction * 100).toFixed(1)}%`; +} + +/** Format bytes-per-row density, e.g. "4.0 B/row" or "—" when unknown. */ +export function formatBytesPerRow(bytesPerRow: number | null): string { + if (bytesPerRow === null) return '—'; + if (bytesPerRow >= 1024) return `${(bytesPerRow / 1024).toFixed(1)} KB/row`; + if (bytesPerRow >= 10) return `${bytesPerRow.toFixed(0)} B/row`; + return `${bytesPerRow.toFixed(2)} B/row`; +} diff --git a/vortex-web/src/contexts/ThemeContext.tsx b/vortex-web/src/contexts/ThemeContext.tsx index 4230ebef84d..ff9aeb672b7 100644 --- a/vortex-web/src/contexts/ThemeContext.tsx +++ b/vortex-web/src/contexts/ThemeContext.tsx @@ -61,3 +61,5 @@ export function useTheme(): ThemeContextValue { if (!ctx) throw new Error('useTheme must be used within a ThemeProvider'); return ctx; } + +export { ThemeContext };