Skip to content

Commit 267d6ee

Browse files
authored
Crosshairs for filemap (#7247)
This enables hovering and selecting segments from the file map, showing the selected segments as highlighted in the layout tree. It also has a tooltip showing the segment number, path, byte offset, and size. <img width="965" height="556" alt="Screenshot 2026-04-01 at 16 20 12" src="https://github.com/user-attachments/assets/1708ae56-b305-4ee5-8760-265762496eca" /> --------- Signed-off-by: Nicholas Gates <nick@nickgates.com>
1 parent 6cfb2b0 commit 267d6ee

4 files changed

Lines changed: 122 additions & 15 deletions

File tree

.github/workflows/web.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,14 @@ jobs:
100100
deployments: write
101101
environment:
102102
name: github-pages
103+
url: ${{ steps.deploy.outputs.deployment-url }}
103104
steps:
104105
- uses: actions/download-artifact@v4
105106
with:
106107
name: vortex-explorer
107108
path: dist
108109
- name: Deploy to Cloudflare Pages
110+
id: deploy
109111
uses: cloudflare/wrangler-action@v3
110112
with:
111113
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}

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

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

44
import { useRef, useEffect, useMemo, useState, useCallback } from 'react';
5-
import { collectSubtreeSegments } from '../swimlane/utils';
5+
import { collectSubtreeSegments, formatBytes } from '../swimlane/utils';
66
import { useVortexFile } from '../../contexts/VortexFileContext';
77
import { useSelection } from '../../contexts/SelectionContext';
88
import type { SegmentMapEntry } from '../swimlane/types';
@@ -17,10 +17,12 @@ import type { SegmentMapEntry } from '../swimlane/types';
1717
*/
1818
export function FileMap() {
1919
const file = useVortexFile();
20-
const { state: selection } = useSelection();
20+
const { state: selection, hoverNode, selectNode } = useSelection();
2121
const canvasRef = useRef<HTMLCanvasElement>(null);
2222
const containerRef = useRef<HTMLDivElement>(null);
2323
const [crosshair, setCrosshair] = useState<number | null>(null);
24+
const [byteOffset, setByteOffset] = useState<number | null>(null);
25+
const [hoveredSeg, setHoveredSeg] = useState<SegmentMapEntry | null>(null);
2426

2527
// Segments belonging to the selected subtree, sorted by byte offset
2628
const subtreeSegments = useMemo((): SegmentMapEntry[] => {
@@ -199,6 +201,33 @@ export function FileMap() {
199201
ctx.putImageData(imgData, 0, 0);
200202
}, [file, subtreeSegments, focusedSegment, hoverSegments]);
201203

204+
// All segments sorted by byte offset for hit-testing
205+
const sortedSegments = useMemo(
206+
() => [...file.segments].sort((a, b) => a.byteOffset - b.byteOffset),
207+
[file.segments],
208+
);
209+
210+
// Find the segment at a given byte offset using binary search
211+
const findSegmentAtByte = useCallback(
212+
(byte: number): SegmentMapEntry | null => {
213+
let lo = 0;
214+
let hi = sortedSegments.length - 1;
215+
while (lo <= hi) {
216+
const mid = (lo + hi) >>> 1;
217+
const seg = sortedSegments[mid];
218+
if (byte < seg.byteOffset) {
219+
hi = mid - 1;
220+
} else if (byte >= seg.byteOffset + seg.byteLength) {
221+
lo = mid + 1;
222+
} else {
223+
return seg;
224+
}
225+
}
226+
return null;
227+
},
228+
[sortedSegments],
229+
);
230+
202231
// Resize observer
203232
useEffect(() => {
204233
const container = containerRef.current;
@@ -211,28 +240,82 @@ export function FileMap() {
211240
return () => observer.disconnect();
212241
}, []);
213242

214-
const handleMouseMove = useCallback((e: React.MouseEvent) => {
215-
const container = containerRef.current;
216-
if (!container) return;
217-
const rect = container.getBoundingClientRect();
218-
setCrosshair(e.clientX - rect.left);
219-
}, []);
243+
const handleMouseMove = useCallback(
244+
(e: React.MouseEvent) => {
245+
const container = containerRef.current;
246+
if (!container) return;
247+
const rect = container.getBoundingClientRect();
248+
const x = e.clientX - rect.left;
249+
setCrosshair(x);
250+
251+
const fileSize = file.fileStructure.fileSize;
252+
const byte = (x / container.clientWidth) * fileSize;
253+
setByteOffset(Math.floor(byte));
254+
255+
const seg = findSegmentAtByte(byte);
256+
setHoveredSeg(seg);
257+
hoverNode(seg ? seg.layoutPath : null);
258+
},
259+
[file.fileStructure.fileSize, findSegmentAtByte, hoverNode],
260+
);
220261

221-
const handleMouseLeave = useCallback(() => setCrosshair(null), []);
262+
const handleMouseLeave = useCallback(() => {
263+
setCrosshair(null);
264+
setByteOffset(null);
265+
setHoveredSeg(null);
266+
hoverNode(null);
267+
}, [hoverNode]);
268+
269+
const handleClick = useCallback(
270+
(e: React.MouseEvent) => {
271+
const container = containerRef.current;
272+
if (!container) return;
273+
const rect = container.getBoundingClientRect();
274+
const x = e.clientX - rect.left;
275+
const fileSize = file.fileStructure.fileSize;
276+
const byte = (x / container.clientWidth) * fileSize;
277+
const seg = findSegmentAtByte(byte);
278+
if (seg) {
279+
selectNode(seg.layoutPath);
280+
}
281+
},
282+
[file.fileStructure.fileSize, findSegmentAtByte, selectNode],
283+
);
222284

223285
return (
224286
<div
225287
ref={containerRef}
226288
className="relative cursor-crosshair flex-shrink-0 h-[1lh] text-[10px] leading-none"
227289
onMouseMove={handleMouseMove}
228290
onMouseLeave={handleMouseLeave}
291+
onClick={handleClick}
229292
>
230293
<canvas ref={canvasRef} className="block" />
231294
{crosshair !== null && (
232-
<div
233-
className="absolute top-0 bottom-0 w-px bg-vortex-black dark:bg-vortex-white opacity-50 pointer-events-none"
234-
style={{ left: crosshair }}
235-
/>
295+
<>
296+
<div
297+
className="absolute top-0 bottom-0 w-px bg-vortex-black dark:bg-vortex-white opacity-50 pointer-events-none"
298+
style={{ left: crosshair }}
299+
/>
300+
{byteOffset !== null && (
301+
<div
302+
className="absolute bottom-full mb-1 -translate-x-1/2 px-1.5 py-1 rounded text-[9px] bg-vortex-black/90 dark:bg-white/90 text-white dark:text-vortex-black whitespace-nowrap pointer-events-none leading-normal"
303+
style={{ left: crosshair }}
304+
>
305+
{hoveredSeg ? (
306+
<div className="flex flex-col gap-0.5">
307+
<span className="font-medium">{hoveredSeg.layoutPath}</span>
308+
<span className="opacity-70">
309+
segment {hoveredSeg.index} &middot; {formatBytes(hoveredSeg.byteLength)} @{' '}
310+
{formatBytes(hoveredSeg.byteOffset)}
311+
</span>
312+
</div>
313+
) : (
314+
<span className="opacity-70">{formatBytes(byteOffset)}</span>
315+
)}
316+
</div>
317+
)}
318+
</>
236319
)}
237320
</div>
238321
);

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ export function TreePanel() {
8383
[allRows, searchQuery, file.layoutTree],
8484
);
8585

86+
// Compute the set of ancestor node IDs for the hovered node (for dim highlighting in tree)
87+
const hoveredAncestorIds = useMemo(() => {
88+
if (!selection.hoveredNodeId) return new Set<string>();
89+
const path = findPathToNode(file.layoutTree, selection.hoveredNodeId);
90+
// Exclude the hovered node itself — it gets full highlight
91+
const ids = new Set(path.map((n) => n.id));
92+
ids.delete(selection.hoveredNodeId);
93+
return ids;
94+
}, [selection.hoveredNodeId, file.layoutTree]);
95+
8696
// When a flat layout node is expanded, lazily attach array encoding children.
8797
// Track which nodes we've already requested to avoid re-triggering on tree updates.
8898
const expandedArrayRequests = useRef(new Set<string>());
@@ -131,6 +141,8 @@ export function TreePanel() {
131141
row={row}
132142
isExpanded={expanded.has(row.node.id)}
133143
isSelected={selection.selectedNodeId === row.node.id}
144+
isHovered={selection.hoveredNodeId === row.node.id}
145+
isHoveredAncestor={hoveredAncestorIds.has(row.node.id)}
134146
mode={mode}
135147
onToggle={() => toggleExpanded(row.node.id)}
136148
onSelect={() => handleNodeClick(row.node.id)}

vortex-web/src/components/swimlane/TreeRow.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ interface TreeRowProps {
1717
row: FlattenedRow;
1818
isExpanded: boolean;
1919
isSelected: boolean;
20+
isHovered?: boolean;
21+
isHoveredAncestor?: boolean;
2022
mode: 'schema' | 'layout';
2123
onToggle: () => void;
2224
onSelect: () => void;
@@ -27,6 +29,8 @@ export function TreeRow({
2729
row,
2830
isExpanded,
2931
isSelected,
32+
isHovered,
33+
isHoveredAncestor,
3034
mode,
3135
onToggle,
3236
onSelect,
@@ -82,12 +86,18 @@ export function TreeRow({
8286

8387
const opacity = isGroup ? 'opacity-50' : isLeaf ? 'opacity-70' : '';
8488
const fontStyle = isGroup ? 'italic' : '';
85-
const selectedBg = isSelected ? 'bg-vortex-light-blue/10' : '';
89+
const highlightBg = isSelected
90+
? 'bg-vortex-light-blue/10'
91+
: isHovered
92+
? 'bg-vortex-light-blue/15'
93+
: isHoveredAncestor
94+
? 'bg-vortex-light-blue/5'
95+
: '';
8696

8797
return (
8898
<div
8999
data-node-id={node.id}
90-
className={`flex items-center gap-1.5 text-[11px] whitespace-nowrap hover:bg-vortex-black/[0.03] dark:hover:bg-white/[0.04] cursor-default ${opacity} ${fontStyle} ${selectedBg}`}
100+
className={`flex items-center gap-1.5 text-[11px] whitespace-nowrap hover:bg-vortex-black/[0.03] dark:hover:bg-white/[0.04] cursor-default ${opacity} ${fontStyle} ${highlightBg}`}
91101
title={node.isArrayNode ? 'Array encoding node' : undefined}
92102
style={{
93103
height: ROW_HEIGHT,

0 commit comments

Comments
 (0)