diff --git a/vortex-web/.storybook/preview.ts b/vortex-web/.storybook/preview.ts index fd8d1ba8064..dbb0795b0fc 100644 --- a/vortex-web/.storybook/preview.ts +++ b/vortex-web/.storybook/preview.ts @@ -3,7 +3,7 @@ import { createElement } from 'react'; import type { Preview } from '@storybook/react-vite'; -import { ThemeContext } from '../src/contexts/ThemeContextCore'; +import { ThemeProvider } from '../src/contexts/ThemeContext'; import '../src/index.css'; const preview: Preview = { @@ -26,16 +26,13 @@ const preview: Preview = { }, decorators: [ (Story, context) => { - const theme = context.globals.theme || 'light'; - document.documentElement.classList.toggle('dark', theme === 'dark'); - document.documentElement.classList.toggle('light', theme === 'light'); - // 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(), - ); + // Wrap every story in the real ThemeProvider so theme-aware components + // (e.g. FileHeader/ThemePicker, which call useTheme) have their context. + // Seed it from the toolbar's theme global; `key` remounts the provider + // when the toolbar theme changes so the switch takes effect. + const theme = (context.globals.theme as string) || 'light'; + localStorage.setItem('vortex-theme', theme); + return createElement(ThemeProvider, { key: theme }, Story()); }, ], parameters: { diff --git a/vortex-web/src/App.tsx b/vortex-web/src/App.tsx index 07b52b120fe..8fd119fa8e0 100644 --- a/vortex-web/src/App.tsx +++ b/vortex-web/src/App.tsx @@ -9,7 +9,7 @@ import type { LayoutTreeNode } from './components/swimlane/types'; import { arrayTreeToLayoutChildren, findNodeById } from './components/swimlane/utils'; import { FileDropScreen } from './components/explorer/FileDropScreen'; import { FileHeader } from './components/explorer/FileHeader'; -import { MainArea } from './components/explorer/MainArea'; +import { MainArea, type MainView } from './components/explorer/MainArea'; import { StatusBar } from './components/explorer/StatusBar'; import { VortexWorker } from './workers/VortexWorker'; @@ -18,6 +18,7 @@ function App() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [isDragging, setIsDragging] = useState(false); + const [view, setView] = useState('details'); const dragCounter = useRef(0); const workerRef = useRef(null); @@ -177,8 +178,8 @@ function App() { onDragLeave={handleDragLeave} onDrop={handleDrop} > - - + + {isDragging && (
diff --git a/vortex-web/src/components/detail/DetailPanel.stories.tsx b/vortex-web/src/components/detail/DetailPanel.stories.tsx index ce8a05c9c0c..053eac2d1d8 100644 --- a/vortex-web/src/components/detail/DetailPanel.stories.tsx +++ b/vortex-web/src/components/detail/DetailPanel.stories.tsx @@ -26,7 +26,16 @@ const mockFileState: VortexFileState = { const meta: Meta = { component: DetailPanel, - decorators: [withMockFileContext(mockFileState), withMockSelection(layout)], + decorators: [ + withMockFileContext(mockFileState), + withMockSelection(layout), + // DetailPanel fills its parent's height; give it a bounded flex container. + (Story) => ( +
+ +
+ ), + ], parameters: { layout: 'padded', }, @@ -35,4 +44,5 @@ export default meta; type Story = StoryObj; +/** No node selected — the panel shows its empty state. */ export const NoSelection: Story = {}; diff --git a/vortex-web/src/components/detail/DetailPanel.tsx b/vortex-web/src/components/detail/DetailPanel.tsx index 76fd3dad29c..b4cd1ec36e7 100644 --- a/vortex-web/src/components/detail/DetailPanel.tsx +++ b/vortex-web/src/components/detail/DetailPanel.tsx @@ -11,8 +11,7 @@ import { shortEncoding, DTYPE_COLORS, } from '../swimlane/utils'; -import { SummaryPane } from './SummaryPane'; -import { ArraySummaryPane } from './ArraySummaryPane'; +import { SummarySidebar } from './SummarySidebar'; import { EncodingPane } from './EncodingPane'; import { SegmentsPane } from './SegmentsPane'; import { BlockTreemap } from '../explorer/BlockTreemap'; @@ -66,7 +65,8 @@ export function DetailPanel() { const selectedIdSet = useMemo(() => new Set(selectedPath.map((n) => n.id)), [selectedPath]); const isHoverBreadcrumb = hoveredPath.length > 0; - const currentTab = tabs.find((t) => t.id === activeTab) ? activeTab : tabs[0]?.id; + // Prefer the active tab; otherwise fall back to the first available tab. + const currentTab = tabs.find((t) => t.id === activeTab)?.id ?? tabs[0]?.id; return (
@@ -144,7 +144,6 @@ export function DetailPanel() { {/* Main content: tab content (left) + summary sidebar (right) */}
- {/* Tab content */}
{currentTab === 'encoding' && selection.selectedNode && (
@@ -169,7 +168,7 @@ export function DetailPanel() { {currentTab === 'buffers' && selection.selectedNode && ( )} - {!currentTab && !selection.selectedNode && ( + {!selection.selectedNode && (
Select a node to view details.
@@ -177,13 +176,7 @@ export function DetailPanel() {
{/* Summary sidebar — always visible */} -
- {isArrayNode && selection.selectedNode ? ( - - ) : ( - - )} -
+
); diff --git a/vortex-web/src/components/detail/SummarySidebar.tsx b/vortex-web/src/components/detail/SummarySidebar.tsx new file mode 100644 index 00000000000..b07cbedbf91 --- /dev/null +++ b/vortex-web/src/components/detail/SummarySidebar.tsx @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { useVortexFile } from '../../contexts/VortexFileContextCore'; +import { useSelection } from '../../contexts/SelectionContextCore'; +import { SummaryPane } from './SummaryPane'; +import { ArraySummaryPane } from './ArraySummaryPane'; + +/** + * Right-hand summary sidebar shared by the details and swimlane views. Shows the + * selected node's summary (array-aware), falling back to the whole-file summary + * when nothing is selected. + */ +export function SummarySidebar() { + const file = useVortexFile(); + const { state: selection } = useSelection(); + const isArrayNode = selection.selectedNode?.isArrayNode ?? false; + + return ( +
+ {isArrayNode && selection.selectedNode ? ( + + ) : ( + + )} +
+ ); +} diff --git a/vortex-web/src/components/explorer/ExplorerShell.stories.tsx b/vortex-web/src/components/explorer/ExplorerShell.stories.tsx index aa8cec1a8bd..993d1722074 100644 --- a/vortex-web/src/components/explorer/ExplorerShell.stories.tsx +++ b/vortex-web/src/components/explorer/ExplorerShell.stories.tsx @@ -1,9 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { FileHeader } from './FileHeader'; -import { MainArea } from './MainArea'; +import { MainArea, type MainView } from './MainArea'; import { StatusBar } from './StatusBar'; import { withMockFileContext, withMockSelection } from '../../storybook/decorators'; import { ordersMock } from '../../mocks/layouts'; @@ -29,10 +30,11 @@ const mockFileState: VortexFileState = { /** Full page layout — mirrors App.tsx when a file is loaded */ function ExplorerPage() { + const [view, setView] = useState('details'); return (
- {}} /> - + {}} view={view} onViewChange={setView} /> +
); diff --git a/vortex-web/src/components/explorer/FileHeader.stories.tsx b/vortex-web/src/components/explorer/FileHeader.stories.tsx index f01be01db41..c785d834d00 100644 --- a/vortex-web/src/components/explorer/FileHeader.stories.tsx +++ b/vortex-web/src/components/explorer/FileHeader.stories.tsx @@ -32,4 +32,6 @@ export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + args: { view: 'details', onViewChange: () => {} }, +}; diff --git a/vortex-web/src/components/explorer/FileHeader.tsx b/vortex-web/src/components/explorer/FileHeader.tsx index 18a351602e6..2a523dee315 100644 --- a/vortex-web/src/components/explorer/FileHeader.tsx +++ b/vortex-web/src/components/explorer/FileHeader.tsx @@ -3,12 +3,15 @@ import { useVortexFile } from '../../contexts/VortexFileContextCore'; import { ThemePicker } from '../ThemePicker'; +import type { MainView } from './MainArea'; interface FileHeaderProps { onClose: () => void; + view: MainView; + onViewChange: (view: MainView) => void; } -export function FileHeader({ onClose }: FileHeaderProps) { +export function FileHeader({ onClose, view, onViewChange }: FileHeaderProps) { const file = useVortexFile(); return ( @@ -22,28 +25,47 @@ export function FileHeader({ onClose }: FileHeaderProps) { > v{file.version} -
- - + ))} +
+ +
+ + + + + + + +
); diff --git a/vortex-web/src/components/explorer/MainArea.tsx b/vortex-web/src/components/explorer/MainArea.tsx index a8029e1c0e8..8685d68aad2 100644 --- a/vortex-web/src/components/explorer/MainArea.tsx +++ b/vortex-web/src/components/explorer/MainArea.tsx @@ -3,19 +3,24 @@ import { useCallback, useRef, useState } from 'react'; import { TreePanel } from './TreePanel'; -import { FileMap } from './FileMap'; +import { SwimlaneOverview } from '../swimlane/SwimlaneOverview'; import { DataPreview } from './DataPreview'; import { DetailPanel } from '../detail/DetailPanel'; +import { SummarySidebar } from '../detail/SummarySidebar'; const MIN_PANEL_HEIGHT = 120; const DEFAULT_PREVIEW_HEIGHT = 200; +export type MainView = 'details' | 'swimlane'; + /** - * Main explorer area: tree panel (left) | detail + filemap + preview (right). + * Main explorer area: tree panel (left) | the active main view (details or + * swimlane, chosen in the top header) over a resizable data preview (right). The + * tree stays put as column navigation while the header tabs switch the view. * * The preview panel at the bottom is vertically resizable via a drag handle. */ -export function MainArea() { +export function MainArea({ view }: { view: MainView }) { const [previewHeight, setPreviewHeight] = useState(DEFAULT_PREVIEW_HEIGHT); const dragging = useRef(false); const startY = useRef(0); @@ -48,19 +53,29 @@ export function MainArea() { return (
- {/* Left: tree panel — full height, fixed width */} -
- -
+ {/* Left: tree panel — column navigation paired with the details view. The + swimlane view hides it so the byte-bars get the full width. */} + {view === 'details' && ( +
+ +
+ )} - {/* Right: detail pane, file map, data preview — stacked vertically */} + {/* Right: active main view over the resizable data preview */}
- {/* Detail pane — fills available vertical space, scrolls internally */} - - - {/* File map strip */} -
- + {/* Active view — fills available vertical space, scrolls internally. The + swimlane keeps the summary sidebar alongside it, like the details view. */} +
+ {view === 'details' ? ( + + ) : ( + <> +
+ +
+ + + )}
{/* Resize handle */} diff --git a/vortex-web/src/components/explorer/TimelineTab.tsx b/vortex-web/src/components/explorer/TimelineTab.tsx deleted file mode 100644 index ff7f859f9a4..00000000000 --- a/vortex-web/src/components/explorer/TimelineTab.tsx +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -import { useState } from 'react'; -import { useVortexFile } from '../../contexts/VortexFileContextCore'; -import { useSelection } from '../../contexts/SelectionContextCore'; -import { LayoutSwimlane } from '../swimlane/LayoutSwimlane'; - -export function TimelineTab() { - const file = useVortexFile(); - const { state: selection, selectNode } = useSelection(); - const [mode, setMode] = useState<'schema' | 'layout'>('schema'); - - return ( -
- {/* Mode toggle */} -
- - -
- - -
- ); -} diff --git a/vortex-web/src/components/swimlane/AxisBar.tsx b/vortex-web/src/components/swimlane/AxisBar.tsx index 27ad96081ff..3167a185593 100644 --- a/vortex-web/src/components/swimlane/AxisBar.tsx +++ b/vortex-web/src/components/swimlane/AxisBar.tsx @@ -2,19 +2,19 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors import { useMemo } from 'react'; -import { formatRowCount } from './utils'; +import { formatBytes } from './utils'; interface AxisBarProps { - totalRows: number; + totalBytes: number; swimlaneMinWidth: number; - rulerPosition: { x: number; row: number } | null; + rulerPosition: { x: number; byte: number } | null; scrollLeft: number; containerWidth: number; axisRef: React.RefObject; } export function AxisBar({ - totalRows, + totalBytes, swimlaneMinWidth, rulerPosition, scrollLeft, @@ -23,26 +23,28 @@ export function AxisBar({ }: AxisBarProps) { const axisTicks = useMemo(() => { const ticks = []; - const step = totalRows / 5; + const step = totalBytes / 5; for (let i = 0; i <= 5; i++) { ticks.push(Math.round(i * step)); } return ticks; - }, [totalRows]); + }, [totalBytes]); return ( -
+ // Reserve the same scrollbar gutter as the swimlane panel so the ticks stay + // aligned with the bars whether or not the vertical scrollbar is present. +
{axisTicks.map((tick) => (
- {formatRowCount(tick)} + {formatBytes(tick)}
))}
@@ -54,7 +56,7 @@ export function AxisBar({ left: Math.max(0, Math.min(rulerPosition.x - scrollLeft - 20, containerWidth - 50)), }} > - {formatRowCount(rulerPosition.row)} + {formatBytes(rulerPosition.byte)}
)}
diff --git a/vortex-web/src/components/swimlane/LayoutSwimlane.stories.tsx b/vortex-web/src/components/swimlane/LayoutSwimlane.stories.tsx index deacacab73d..1df8c893ea7 100644 --- a/vortex-web/src/components/swimlane/LayoutSwimlane.stories.tsx +++ b/vortex-web/src/components/swimlane/LayoutSwimlane.stories.tsx @@ -4,7 +4,21 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { LayoutSwimlane } from './LayoutSwimlane'; -import { ordersMock, simpleMock, wideMock, deepMock, heavyChunksMock } from '../../mocks'; +import type { LayoutTreeNode } from './types'; +import { + ordersMock, + simpleMock, + wideMock, + deepMock, + heavyChunksMock, + gappedMock, + generateSegments, +} from '../../mocks'; + +/** Bundle a layout with a generated physical segment map for the byte-based axis. */ +function physical(layout: LayoutTreeNode, fileSize: number) { + return { layout, segments: generateSegments(layout, fileSize), totalBytes: fileSize }; +} const meta: Meta = { component: LayoutSwimlane, @@ -27,8 +41,7 @@ const ordersLayout = ordersMock(); export const Orders: Story = { args: { - layout: ordersLayout, - totalRows: 100000, + ...physical(ordersLayout, 12_400_000), defaultExpanded: ['root', 'root.customer', 'root.customer.id', 'root.status'], height: 400, }, @@ -36,8 +49,7 @@ export const Orders: Story = { export const SchemaMode: Story = { args: { - layout: ordersLayout, - totalRows: 100000, + ...physical(ordersLayout, 12_400_000), mode: 'schema', defaultExpanded: ['root', 'root.customer'], height: 400, @@ -46,8 +58,7 @@ export const SchemaMode: Story = { export const LayoutMode: Story = { args: { - layout: ordersLayout, - totalRows: 100000, + ...physical(ordersLayout, 12_400_000), mode: 'layout', defaultExpanded: ['root', 'root.customer', 'root.customer.id', 'root.status'], height: 400, @@ -56,16 +67,14 @@ export const LayoutMode: Story = { export const SingleFlat: Story = { args: { - layout: simpleMock(), - totalRows: 10000, + ...physical(simpleMock(), 800_000), height: 100, }, }; export const WideSchema: Story = { args: { - layout: wideMock(), - totalRows: 50000, + ...physical(wideMock(), 6_000_000), mode: 'schema', defaultExpanded: ['root'], height: 400, @@ -74,8 +83,7 @@ export const WideSchema: Story = { export const DeepNesting: Story = { args: { - layout: deepMock(), - totalRows: 25000, + ...physical(deepMock(), 2_500_000), mode: 'schema', defaultExpanded: ['root', 'root.user', 'root.user.profile', 'root.user.profile.address'], height: 400, @@ -84,14 +92,24 @@ export const DeepNesting: Story = { export const HeavyChunks: Story = { args: { - layout: heavyChunksMock(), - totalRows: 1000000, + ...physical(heavyChunksMock(), 60_000_000), mode: 'layout', defaultExpanded: ['root', 'root.values'], height: 400, }, }; +/** Two columns interleaved on disk: each column's chunks are split by the other's, + so plotting by physical offset reveals the gaps in each column's storage. */ +export const Gapped: Story = { + args: { + ...gappedMock(), + mode: 'schema', + defaultExpanded: ['root', 'root.temp', 'root.count'], + height: 240, + }, +}; + /** Interactive story demonstrating controlled selection */ export const WithSelection: StoryObj = { render: () => { @@ -100,8 +118,7 @@ export const WithSelection: StoryObj = {
Selected: {selectedId ?? 'none'}
(null); - const [rulerPosition, setRulerPosition] = useState<{ x: number; row: number } | null>(null); + const [rulerPosition, setRulerPosition] = useState<{ x: number; byte: number } | null>(null); const treeScrollRef = useRef(null); const swimlaneScrollRef = useRef(null); @@ -64,6 +67,10 @@ export function LayoutSwimlane({ [allRows, searchQuery, layout], ); + // Resolve each segment to its physical byte placement so bars can be plotted + // by file offset rather than by row. + const segmentIndex = useMemo(() => buildSegmentIndex(layout, segments), [layout, segments]); + const toggleExpanded = useCallback((id: string) => { setExpanded((prev) => { const next = new Set(prev); @@ -133,11 +140,11 @@ export function LayoutSwimlane({ const x = e.clientX - rect.left; const panelWidth = panel.offsetWidth; if (x >= 0 && x <= panelWidth) { - const rowNum = (x / panelWidth) * totalRows; - setRulerPosition({ x, row: Math.max(0, Math.min(totalRows, rowNum)) }); + const byte = (x / panelWidth) * totalBytes; + setRulerPosition({ x, byte: Math.max(0, Math.min(totalBytes, byte)) }); } }, - [totalRows], + [totalBytes], ); const handleSwimlaneMouseLeave = useCallback(() => setRulerPosition(null), []); @@ -146,11 +153,19 @@ export function LayoutSwimlane({ return (
+ {/* Filter header — sits above the tree column only, so the tree rows and + swimlane bars below it start at the same vertical offset and stay aligned. */} +
+
+ +
+
+
+ {/* Tree + Swimlane */}
{/* Tree panel */}
-
{visibleRows.map((row) => ( @@ -168,20 +183,31 @@ export function LayoutSwimlane({
- {/* Swimlane panel */} + {/* Swimlane panel. scrollbar-gutter reserves space for the vertical + scrollbar so bars never overflow underneath it, and the inner panel is + the positioning context so bar widths track the content (not the + scrollbar) and stay aligned with the axis. */}
-
+
{visibleRows.map((row) => ( handleNodeClick(row.node.id)} + isSelected={selectedNodeId === row.node.id} /> ))} @@ -199,7 +225,7 @@ export function LayoutSwimlane({
; onHover: (node: LayoutTreeNode | null, position: { x: number; y: number }) => void; + onSelect: () => void; + isSelected: boolean; }) { return (
{row.displayKind !== 'hiddenIndicator' && ( - + )}
); diff --git a/vortex-web/src/components/swimlane/SwimlaneBar.tsx b/vortex-web/src/components/swimlane/SwimlaneBar.tsx index 6c0beef5eb2..4422527cd1f 100644 --- a/vortex-web/src/components/swimlane/SwimlaneBar.tsx +++ b/vortex-web/src/components/swimlane/SwimlaneBar.tsx @@ -2,96 +2,99 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors import React from 'react'; -import type { LayoutTreeNode, FlattenedRow } from './types'; -import { getDtypeCategory, getNodeRowRange } from './utils'; +import type { LayoutTreeNode, FlattenedRow, PhysicalSegment } from './types'; +import { collectSubtreeSegments, getDtypeCategory, MIN_CELL_WIDTH } from './utils'; import { DTYPE_COLORS, getEncodingStyle } from './styles'; interface SwimlaneBarProps { row: FlattenedRow; - totalRows: number; + /** Total byte span of the file — the x-axis maximum. */ + totalBytes: number; + /** Lookup from segment index to its physical byte placement. */ + segmentIndex: Map; onHover: (node: LayoutTreeNode | null, position: { x: number; y: number }) => void; + /** Focus this node's column when its bar is clicked. */ + onSelect: () => void; + /** Whether this node's column is the current focused selection. */ + isSelected: boolean; } -export function SwimlaneBar({ row, totalRows, onHover }: SwimlaneBarProps) { - const { node, displayKind, groupedChildren } = row; - const isLeaf = node.children.length === 0; - const isGroup = displayKind === 'group'; +/** + * A swimlane bar plotted by physical byte offset. Every segment reachable from + * the node is drawn at its real position in the file, so a column whose storage + * is split across the file (interleaved with other columns) shows visible gaps. + */ +export function SwimlaneBar({ + row, + totalBytes, + segmentIndex, + onHover, + onSelect, + isSelected, +}: SwimlaneBarProps) { + const { node } = row; const style = getEncodingStyle(node.encoding); - // Group nodes: render rolled-up bars from each grouped child - if (isGroup && groupedChildren) { - return ( - <> - {groupedChildren.map((child) => { - const range = getNodeRowRange(child); - const left = (range[0] / totalRows) * 100; - const width = ((range[1] - range[0]) / totalRows) * 100; - const dtypeCat = getDtypeCategory(child.dtype); - const dtypeColor = DTYPE_COLORS[dtypeCat]; - return ( -
- ); - })} - - ); - } + if (totalBytes <= 0) return null; - const rowRange = getNodeRowRange(node); - const left = (rowRange[0] / totalRows) * 100; - const width = ((rowRange[1] - rowRange[0]) / totalRows) * 100; + // Physical segments reachable from this node, in file order. + const segments = collectSubtreeSegments(node) + .map((id) => segmentIndex.get(id)) + .filter((s): s is PhysicalSegment => s != null) + .sort((a, b) => a.byteOffset - b.byteOffset); - let barClasses = 'absolute top-[3px] bottom-[3px] rounded transition-[filter] duration-100'; - const barStyle: React.CSSProperties = { - left: `calc(${left}% + 1px)`, - width: `calc(${width}% - 3px)`, - }; + if (segments.length === 0) return null; - if (isLeaf) { - const dtypeCat = getDtypeCategory(node.dtype); - const dtypeColor = DTYPE_COLORS[dtypeCat]; - barStyle.backgroundColor = `${dtypeColor}40`; - barStyle.border = `1.5px solid ${dtypeColor}`; - barClasses += ' cursor-pointer'; - } else { - barStyle.border = `1.5px solid ${style.color}40`; + // Merge into contiguous runs for the encoding frame. Gaps between runs are + // physical gaps in the column's representation and are left empty. + const runs: Array<[number, number]> = []; + for (const seg of segments) { + const start = seg.byteOffset; + const end = seg.byteOffset + seg.byteLength; + const last = runs[runs.length - 1]; + if (last && start <= last[1]) last[1] = Math.max(last[1], end); + else runs.push([start, end]); } - const handleMouseEnter = (e: React.MouseEvent) => { - if (isLeaf) { - (e.currentTarget as HTMLDivElement).style.filter = 'brightness(1.15)'; - onHover(node, { x: e.clientX, y: e.clientY }); - } - }; - - const handleMouseMove = (e: React.MouseEvent) => { - if (isLeaf) { - onHover(node, { x: e.clientX, y: e.clientY }); - } - }; - - const handleMouseLeave = (e: React.MouseEvent) => { - if (isLeaf) { - (e.currentTarget as HTMLDivElement).style.filter = ''; - onHover(null, { x: 0, y: 0 }); - } - }; + const handleEnter = (e: React.MouseEvent) => onHover(node, { x: e.clientX, y: e.clientY }); + const handleMove = (e: React.MouseEvent) => onHover(node, { x: e.clientX, y: e.clientY }); + const handleLeave = () => onHover(null, { x: 0, y: 0 }); return ( -
+ <> + {segments.map((seg) => { + const color = DTYPE_COLORS[getDtypeCategory(seg.dtype)]; + return ( +
+ ); + })} + {runs.map(([start, end]) => ( +
+ ))} + ); } diff --git a/vortex-web/src/components/swimlane/SwimlaneOverview.stories.tsx b/vortex-web/src/components/swimlane/SwimlaneOverview.stories.tsx new file mode 100644 index 00000000000..1dffe7777c9 --- /dev/null +++ b/vortex-web/src/components/swimlane/SwimlaneOverview.stories.tsx @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SwimlaneOverview } from './SwimlaneOverview'; +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, is_active=bool, customer={id=i64, name=utf8}, items=list, amount=f64, metadata=struct, status=utf8}', + layoutTree: layout, + segments, + fileStructure, +}; + +const meta: Meta = { + component: SwimlaneOverview, + parameters: { layout: 'padded' }, + decorators: [ + withMockFileContext(mockFileState), + withMockSelection(layout), + // The overview fills its parent's height; give it a bounded box like the panel + // it occupies in the app. + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/vortex-web/src/components/swimlane/SwimlaneOverview.tsx b/vortex-web/src/components/swimlane/SwimlaneOverview.tsx new file mode 100644 index 00000000000..03519dd206e --- /dev/null +++ b/vortex-web/src/components/swimlane/SwimlaneOverview.tsx @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useVortexFile } from '../../contexts/VortexFileContextCore'; +import { useSelection } from '../../contexts/SelectionContextCore'; +import type { LayoutTreeNode } from './types'; +import { + buildSegmentIndex, + flattenTree, + findNodeById, + getNodeDisplayName, + getDtypeCategory, + hasExpandableChildren, + isFlatLayout, + formatBytes, + DTYPE_COLORS, +} from './utils'; +import { ROW_HEIGHT } from './styles'; +import { SwimlaneBar } from './SwimlaneBar'; +import { Tooltip } from './Tooltip'; + +const LABEL_WIDTH = 150; + +/** + * Standalone whole-file column byte-map. Each schema column is a row whose bars + * show where that column's bytes physically live in the file; clicking a bar (or + * its label) focuses the column in the shared selection, which drives the detail + * view. Rows expand/collapse via a chevron in the label gutter — the same model + * as the tree — lazily attaching a flat column's array-encoding children the + * first time it is expanded. + */ +export function SwimlaneOverview() { + const file = useVortexFile(); + const { state: selection, selectNode, hoverNode } = useSelection(); + const [expanded, setExpanded] = useState>(() => new Set(['root'])); + const [tooltip, setTooltip] = useState<{ + node: LayoutTreeNode; + position: { x: number; y: number }; + } | null>(null); + + const totalBytes = file.fileStructure.fileSize; + + const rows = useMemo( + () => + flattenTree(file.layoutTree, expanded, null, 'schema').filter( + (r) => r.displayKind !== 'hiddenIndicator', + ), + [file.layoutTree, expanded], + ); + + const segmentIndex = useMemo( + () => buildSegmentIndex(file.layoutTree, file.segments), + [file.layoutTree, file.segments], + ); + + const toggleExpanded = useCallback((id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + // Expanding a flat layout node lazily attaches its array-encoding children. + // The tree is hidden in this view, so the swimlane drives the load itself. + const arrayRequests = useRef(new Set()); + useEffect(() => { + for (const id of expanded) { + if (arrayRequests.current.has(id)) continue; + const node = findNodeById(file.layoutTree, id); + if (node && isFlatLayout(node) && !node.children.some((c) => c.isArrayNode)) { + arrayRequests.current.add(id); + file.expandArrayTree(id); + } + } + }, [expanded, file]); + + const toggleSelect = useCallback( + (nodeId: string) => selectNode(selection.selectedNodeId === nodeId ? null : nodeId), + [selection.selectedNodeId, selectNode], + ); + + const handleHover = useCallback( + (node: LayoutTreeNode | null, position: { x: number; y: number }) => { + setTooltip(node ? { node, position } : null); + hoverNode(node ? node.id : null); + }, + [hoverNode], + ); + + const axisTicks = useMemo(() => { + if (totalBytes <= 0) return []; + return Array.from({ length: 6 }, (_, i) => Math.round((i * totalBytes) / 5)); + }, [totalBytes]); + + return ( +
+ {/* Column rows. scrollbar-gutter reserves space for the vertical scrollbar so + the bars stay aligned with the axis below whether or not it's present. */} +
+ {rows.map((row) => { + const { node, depth } = row; + const expandable = hasExpandableChildren(node); + const isExpanded = expanded.has(node.id); + const isSelected = selection.selectedNodeId === node.id; + const isHovered = selection.hoveredNodeId === node.id; + const rowBg = isSelected + ? 'bg-vortex-light-blue/10' + : isHovered + ? 'bg-vortex-light-blue/[0.06]' + : ''; + return ( +
hoverNode(node.id)} + onMouseLeave={() => hoverNode(null)} + > +
toggleSelect(node.id)} + title={node.dtype} + > + { + e.stopPropagation(); + if (expandable) toggleExpanded(node.id); + }} + > + {isExpanded ? '▼' : '▶'} + + + {getNodeDisplayName(node)} +
+ +
+ toggleSelect(node.id)} + isSelected={isSelected} + /> +
+
+ ); + })} +
+ + {/* Byte axis — aligned to the bars column via the same label-width offset and + scrollbar gutter. */} +
+
+
+ {axisTicks.map((tick) => ( +
+ {formatBytes(tick)} +
+ ))} +
+
+ + {tooltip && } +
+ ); +} + +export default SwimlaneOverview; diff --git a/vortex-web/src/components/swimlane/types.ts b/vortex-web/src/components/swimlane/types.ts index d2c0a318524..28e2a8f5ff3 100644 --- a/vortex-web/src/components/swimlane/types.ts +++ b/vortex-web/src/components/swimlane/types.ts @@ -61,6 +61,13 @@ export interface SegmentMapEntry { layoutPath: string; } +/** A segment resolved to its physical byte placement and the dtype that owns it. */ +export interface PhysicalSegment { + byteOffset: number; + byteLength: number; + dtype: string; +} + export interface FileStructureInfo { fileSize: number; version: number; diff --git a/vortex-web/src/components/swimlane/utils.ts b/vortex-web/src/components/swimlane/utils.ts index 605ebbfd30d..c640bc27491 100644 --- a/vortex-web/src/components/swimlane/utils.ts +++ b/vortex-web/src/components/swimlane/utils.ts @@ -8,6 +8,8 @@ import type { DtypeCategory, FlattenedRow, DisplayKind, + SegmentMapEntry, + PhysicalSegment, } from './types'; // Encoding styles — keyed by encoding string, with fallback for unknown encodings @@ -68,6 +70,8 @@ export const DTYPE_CATEGORIES: DtypeCategory[] = [ export const ROW_HEIGHT = 26; export const MIN_LABEL_WIDTH = 36; export const GROUP_SIZE = 10; +/** Minimum rendered width (px) of a swimlane segment so tiny segments stay visible. */ +export const MIN_CELL_WIDTH = 3; /** * Determine dtype category from dtype string @@ -417,6 +421,54 @@ export function collectSubtreeIds(node: LayoutTreeNode): Set { return ids; } +/** + * Collect the leaf descendants of a node (nodes with no children), in row order. + * Used to roll up a parent's data into a single swimlane bar so that container + * layouts (structs, chunked, zone maps) are not rendered as empty outlines. + */ +export function collectLeafDescendants(node: LayoutTreeNode): LayoutTreeNode[] { + const leaves: LayoutTreeNode[] = []; + function walk(n: LayoutTreeNode) { + if (n.children.length === 0) { + leaves.push(n); + return; + } + for (const child of n.children) walk(child); + } + for (const child of node.children) walk(child); + return leaves; +} + +/** + * Build a lookup from segment index to its physical byte placement and the dtype + * of the layout node that owns it. Lets the swimlane plot bars by physical file + * offset (revealing gaps in a column's storage) rather than by row. + */ +export function buildSegmentIndex( + root: LayoutTreeNode, + segments: SegmentMapEntry[], +): Map { + const placement = new Map(); + for (const s of segments) placement.set(s.index, s); + + const index = new Map(); + function walk(node: LayoutTreeNode) { + for (const id of node.segmentIds) { + const seg = placement.get(id); + if (seg) { + index.set(id, { + byteOffset: seg.byteOffset, + byteLength: seg.byteLength, + dtype: node.dtype, + }); + } + } + for (const child of node.children) walk(child); + } + walk(root); + return index; +} + /** * Collect all segment IDs reachable from a subtree */ diff --git a/vortex-web/src/mocks/index.ts b/vortex-web/src/mocks/index.ts index 681e38aace8..428816605c7 100644 --- a/vortex-web/src/mocks/index.ts +++ b/vortex-web/src/mocks/index.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -export { simpleMock, ordersMock, wideMock, deepMock, heavyChunksMock } from './layouts'; +export { simpleMock, ordersMock, wideMock, deepMock, heavyChunksMock, gappedMock } from './layouts'; export { generateSegments } from './segments'; export { generateFileStructure } from './fileStructure'; export { diff --git a/vortex-web/src/mocks/layouts.ts b/vortex-web/src/mocks/layouts.ts index e18c469c7ab..c181de20275 100644 --- a/vortex-web/src/mocks/layouts.ts +++ b/vortex-web/src/mocks/layouts.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -import type { LayoutTreeNode } from '../components/swimlane/types'; +import type { LayoutTreeNode, SegmentMapEntry } from '../components/swimlane/types'; import { resetSegmentIds, makeFlat, @@ -260,3 +260,76 @@ export function heavyChunksMock(): LayoutTreeNode { ], }); } + +/** + * Gapped: two columns whose chunks are interleaved on disk (temp[0], count[0], + * temp[1], count[1], …). Plotting by physical byte offset shows each column's + * storage split by the other's — a gap in the column representation. Returns a + * hand-built segment map so the interleaving is explicit. + */ +export function gappedMock(): { + layout: LayoutTreeNode; + segments: SegmentMapEntry[]; + totalBytes: number; +} { + const rowsPerChunk = 30_000; + const totalRows = rowsPerChunk * 3; + const chunkBytes = 100_000; + + const chunk = (columnId: string, dtype: string, i: number, segId: number): LayoutTreeNode => ({ + id: `${columnId}.[${i}]`, + encoding: 'vortex.flat', + dtype, + rowCount: rowsPerChunk, + rowOffset: i * rowsPerChunk, + metadataBytes: 32, + segmentIds: [segId], + childType: { kind: 'chunk', chunkIndex: i, rowOffset: i * rowsPerChunk }, + children: [], + }); + + const column = ( + columnId: string, + fieldName: string, + dtype: string, + segIds: number[], + ): LayoutTreeNode => ({ + id: columnId, + encoding: 'vortex.chunked', + dtype, + rowCount: totalRows, + rowOffset: 0, + metadataBytes: 64, + segmentIds: [], + childType: { kind: 'field', fieldName }, + children: segIds.map((segId, i) => chunk(columnId, dtype, i, segId)), + }); + + const layout: LayoutTreeNode = { + id: 'root', + encoding: 'vortex.struct', + dtype: '{temp=f64, count=i64}', + rowCount: totalRows, + rowOffset: 0, + metadataBytes: 96, + segmentIds: [], + childType: { kind: 'root' }, + children: [ + column('root.temp', 'temp', 'f64', [0, 2, 4]), + column('root.count', 'count', 'i64', [1, 3, 5]), + ], + }; + + // Segment i sits at byte offset i * chunkBytes, so even segments (temp) and odd + // segments (count) alternate on disk — each column is physically fragmented. + const segments: SegmentMapEntry[] = [0, 1, 2, 3, 4, 5].map((index) => ({ + index, + byteOffset: index * chunkBytes, + byteLength: chunkBytes, + alignment: 64, + column: index % 2 === 0 ? 'temp' : 'count', + layoutPath: index % 2 === 0 ? `root.temp.[${index / 2}]` : `root.count.[${(index - 1) / 2}]`, + })); + + return { layout, segments, totalBytes: 6 * chunkBytes }; +}