import React, { useCallback, useEffect, useRef, useState } from 'react' import { useTheme } from '@mui/material' import Flatbush from 'flatbush' import { autorun } from 'mobx' import { observer } from 'mobx-react' import { makeStyles } from 'tss-react/mui' import TreeBranchMenu from './TreeBranchMenu.tsx' import TreeNodeMenu from './TreeNodeMenu.tsx' import { padding, renderTreeCanvas } from './renderTreeCanvas.ts' import type { MsaViewModel } from '../../model.ts' const useStyles = makeStyles()(theme => ({ tooltip: { position: 'fixed', pointerEvents: 'none', zIndex: 10000, backgroundColor: theme.palette.grey[700], color: theme.palette.common.white, padding: '4px 8px', borderRadius: 4, fontSize: 12, whiteSpace: 'nowrap', maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', }, })) interface TooltipData { name: string id: string x: number y: number } interface ClickEntry { name: string id: string branch?: boolean minX: number maxX: number minY: number maxY: number } class ClickMapIndex { private flatbush: Flatbush | null = null private entries: ClickEntry[] = [] clear() { this.flatbush = null this.entries = [] } insert(entry: ClickEntry) { this.entries.push(entry) } finish() { if (this.entries.length === 0) { this.flatbush = null return } else { this.flatbush = new Flatbush(this.entries.length) for (const entry of this.entries) { this.flatbush.add(entry.minX, entry.minY, entry.maxX, entry.maxY) } this.flatbush.finish() } } search(box: { minX: number maxX: number minY: number maxY: number }): ClickEntry[] { return ( this.flatbush ?.search(box.minX, box.minY, box.maxX, box.maxY) .map(i => this.entries[i]!) ?? [] ) } } const TreeCanvasBlock = observer(function ({ model, offsetY, }: { model: MsaViewModel offsetY: number }) { const { classes } = useStyles() const theme = useTheme() const ref = useRef(null) const clickMap = useRef(new ClickMapIndex()) const mouseoverRef = useRef(null) const [branchMenu, setBranchMenu] = useState() const [toggleNodeMenu, setToggleNodeMenu] = useState() const [hoverElt, setHoverElt] = useState() const [tooltipInfo, setTooltipInfo] = useState<{ name: string x: number y: number }>() const { scrollY, treeAreaWidth, blockSize, highResScaleFactor } = model const width = treeAreaWidth + padding const height = blockSize const w2 = width * highResScaleFactor const h2 = height * highResScaleFactor // biome-ignore lint/correctness/useExhaustiveDependencies: const vref = useCallback( (arg: HTMLCanvasElement) => { model.incrementRef() ref.current = arg }, // eslint-disable-next-line react-hooks/exhaustive-deps [model, height, width], ) useEffect(() => { const ctx = ref.current?.getContext('2d') if (!ctx) { return } return autorun(() => { ctx.resetTransform() ctx.clearRect( 0, 0, (treeAreaWidth + padding) * highResScaleFactor, blockSize * highResScaleFactor, ) renderTreeCanvas({ ctx, model, offsetY, clickMap: clickMap.current, theme, }) }) }, [model, blockSize, highResScaleFactor, treeAreaWidth, offsetY, theme]) useEffect(() => { const ctx = mouseoverRef.current?.getContext('2d') if (!ctx) { return } ctx.resetTransform() ctx.clearRect(0, 0, treeAreaWidth + padding, blockSize) ctx.translate(0, -offsetY) // Highlight tree element being directly hovered if (hoverElt) { const { minX, maxX, minY, maxY } = hoverElt ctx.fillStyle = 'rgba(0,0,0,0.1)' ctx.fillRect(minX, minY, maxX - minX, maxY - minY) } }, [hoverElt, offsetY, blockSize, treeAreaWidth]) function hoverBranchClickMap(event: React.MouseEvent) { const x = event.nativeEvent.offsetX const y = event.nativeEvent.offsetY const [entry] = clickMap.current.search({ minX: x, maxX: x + 1, minY: y + offsetY, maxY: y + 1 + offsetY, }) return entry?.branch ? { ...entry, x: event.clientX, y: event.clientY } : undefined } function hoverNameClickMap(event: React.MouseEvent) { const x = event.nativeEvent.offsetX const y = event.nativeEvent.offsetY const [entry] = clickMap.current.search({ minX: x, maxX: x + 1, minY: y + offsetY, maxY: y + 1 + offsetY, }) return entry && !entry.branch ? { ...entry, x: event.clientX, y: event.clientY } : undefined } const style = { width, height, top: scrollY + offsetY, left: 0, position: 'absolute', } as const return ( <> {branchMenu?.id ? ( { setBranchMenu(undefined) }} /> ) : null} {toggleNodeMenu?.id ? ( { setToggleNodeMenu(undefined) }} /> ) : null} { if (!ref.current) { return } const hoveredLeaf = hoverNameClickMap(event) const hoveredBranch = hoverBranchClickMap(event) const hoveredAny = hoveredLeaf || hoveredBranch ref.current.style.cursor = hoveredAny ? 'pointer' : 'default' setHoverElt(hoveredLeaf) // Only show direct hover highlight for leaf nodes // Set tooltip info if (hoveredAny) { setTooltipInfo({ name: hoveredAny.name, x: event.clientX, y: event.clientY, }) } else { setTooltipInfo(undefined) } // Handle tree node hover for multi-row highlighting if (hoveredAny) { model.setHoveredTreeNode(hoveredAny.id) // For leaf nodes, also set single row highlight for backward compatibility if (hoveredLeaf?.name) { const rowIndex = model.rowNamesSet.get(hoveredLeaf.name) if (rowIndex !== undefined) { model.setMousePos(undefined, rowIndex) } } } else { // Clear all highlighting when not hovering over any tree node model.setHoveredTreeNode(undefined) model.setMousePos(undefined, undefined) } }} onClick={event => { const { clientX: x, clientY: y } = event const data = hoverBranchClickMap(event) if (data?.id) { setBranchMenu({ x, y, id: data.id, name: data.name }) } const data2 = hoverNameClickMap(event) if (data2?.id) { setToggleNodeMenu({ ...data2, x, y }) } }} onMouseLeave={() => { setHoverElt(undefined) setTooltipInfo(undefined) // Clear all highlighting when leaving tree area model.setHoveredTreeNode(undefined) model.setMousePos(undefined, undefined) }} ref={vref} /> {tooltipInfo ? (
{tooltipInfo.name}
) : null} ) }) export default TreeCanvasBlock