22// SPDX-FileCopyrightText: Copyright the Vortex contributors
33
44import { useRef , useEffect , useMemo , useState , useCallback } from 'react' ;
5- import { collectSubtreeSegments } from '../swimlane/utils' ;
5+ import { collectSubtreeSegments , formatBytes } from '../swimlane/utils' ;
66import { useVortexFile } from '../../contexts/VortexFileContext' ;
77import { useSelection } from '../../contexts/SelectionContext' ;
88import type { SegmentMapEntry } from '../swimlane/types' ;
@@ -17,10 +17,12 @@ import type { SegmentMapEntry } from '../swimlane/types';
1717 */
1818export 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 } · { 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 ) ;
0 commit comments