From 2dbaaca9e21d5ac567ef6806d9ee046834bed030 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 20 Oct 2026 08:03:02 +0800 Subject: [PATCH] perf: stop redrawing the sunburst on hover Moving across a wedge rebuilt every arc and restarted the opacity transition on all of them. Arcs are built once, and hover only updates the active wedge. --- src/app/analyze/page.tsx | 496 +++++++++++++++++++++++---------------- 1 file changed, 292 insertions(+), 204 deletions(-) diff --git a/src/app/analyze/page.tsx b/src/app/analyze/page.tsx index 1520395..3ab5483 100644 --- a/src/app/analyze/page.tsx +++ b/src/app/analyze/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useRef, useMemo, useLayoutEffect, Suspense } from 'react' +import { useEffect, useState, useRef, useMemo, useLayoutEffect, Suspense, memo, useImperativeHandle } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import { ArrowLeft, Loader2, X, ArrowUpDown } from 'lucide-react' import { invoke, Channel } from '@tauri-apps/api/core' @@ -31,6 +31,13 @@ interface PackedForest { r: number[] } +interface SectorDraw { + kind: 'circle' | 'path' + d: string + r: number + strokeWidth: number +} + interface ChartData { name: string value: number @@ -40,6 +47,8 @@ interface ChartData { startAngle?: number endAngle?: number isTinyNode?: boolean // True if this node is merged into "其他" in the chart + sectorId?: string + draw?: SectorDraw } interface LayerData { @@ -61,6 +70,48 @@ function generateSectorId(path: string, depth: number): string { return Math.abs(hash).toString(16) } +function sectorDraw(startAngleDeg: number, endAngleDeg: number, innerRadius: number, outerRadius: number): SectorDraw { + const centerX = 200 + const centerY = 200 + const angleRange = endAngleDeg - startAngleDeg + if (Math.abs(angleRange - 360) < 0.01) { + return { + kind: 'circle', + d: '', + r: (innerRadius + outerRadius) / 2, + strokeWidth: outerRadius - innerRadius, + } + } + + const startAngle = startAngleDeg - 90 + const endAngle = endAngleDeg - 90 + const startRad = (startAngle * Math.PI) / 180 + const endRad = (endAngle * Math.PI) / 180 + const x1 = centerX + innerRadius * Math.cos(startRad) + const y1 = centerY + innerRadius * Math.sin(startRad) + const x2 = centerX + outerRadius * Math.cos(startRad) + const y2 = centerY + outerRadius * Math.sin(startRad) + const x3 = centerX + outerRadius * Math.cos(endRad) + const y3 = centerY + outerRadius * Math.sin(endRad) + const x4 = centerX + innerRadius * Math.cos(endRad) + const y4 = centerY + innerRadius * Math.sin(endRad) + const largeArc = endAngle - startAngle > 180 ? 1 : 0 + + return { + kind: 'path', + r: 0, + strokeWidth: 0.5, + d: [ + `M ${x1} ${y1}`, + `L ${x2} ${y2}`, + `A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${x3} ${y3}`, + `L ${x4} ${y4}`, + `A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${x1} ${y1}`, + 'Z', + ].join(' '), + } +} + // Hash function for consistent color assignment based on name function hashName(name: string): number { let hash = 0 @@ -192,6 +243,182 @@ function formatBytesCompact(bytes: number): string { } } +type SunburstHandle = { setHighlight: (sectorId: string | null) => void } + +type ChartHandlers = { + hover: (sectorId: string, event: React.MouseEvent, label: string, name: string, size: string, icon: any, color: string) => void + move: (event: React.MouseEvent) => void + leave: () => void + activate: (item: ChartData) => void +} + +const SunburstChart = memo(function SunburstChart({ + layers, + centerPrimary, + centerSecondary, + chartRef, + handlersRef, +}: { + layers: LayerData[] + centerPrimary: string + centerSecondary: string + chartRef: React.RefObject + handlersRef: React.RefObject +}) { + const svgRef = useRef(null) + const highlightedRef = useRef(null) + const layersRef = useRef(layers) + layersRef.current = layers + + useImperativeHandle(chartRef, () => ({ + setHighlight(sectorId: string | null) { + const svg = svgRef.current + if (!svg || highlightedRef.current === sectorId) return + if (highlightedRef.current) { + const prev = CSS.escape(highlightedRef.current) + svg.querySelectorAll(`[data-sector-id="${prev}"]`).forEach(el => el.classList.remove('hovered')) + } + svg.classList.toggle('chart-dimming', sectorId !== null) + if (sectorId) { + const next = CSS.escape(sectorId) + svg.querySelectorAll(`[data-sector-id="${next}"]`).forEach(el => el.classList.add('hovered')) + } + highlightedRef.current = sectorId + }, + }), [chartRef]) + + const hitTest = (target: EventTarget | null) => { + if (!(target instanceof Element)) return null + const el = target.closest('.chart-sector') + if (!el || !svgRef.current?.contains(el)) return null + const layer = Number(el.getAttribute('data-layer')) + const index = Number(el.getAttribute('data-index')) + const item = layersRef.current[layer]?.data[index] + if (!item?.sectorId) return null + return item + } + + return ( +
+ + { + const item = hitTest(event.target) + if (!item?.sectorId) return + const fileTypeInfo = getFileTypeInfo(item.name, item.node.isDirectory) + handlersRef.current?.hover( + item.sectorId, + event, + fileTypeInfo.label, + item.name, + formatBytes(item.value), + fileTypeInfo.icon, + fileTypeInfo.color, + ) + }} + onMouseMove={(event) => { + if (!hitTest(event.target)) return + handlersRef.current?.move(event) + }} + onMouseLeave={() => handlersRef.current?.leave()} + onClick={(event) => { + const item = hitTest(event.target) + if (!item) return + handlersRef.current?.activate(item) + }} + > + + {centerPrimary} + + + {centerSecondary} + + {layers.map((layer, layerIndex) => ( + + {layer.data.map((item, index) => { + const draw = item.draw + if (!draw) return null + const sectorId = item.sectorId + const style = { + cursor: 'pointer', + animation: `layerFadeIn 0.5s ease-out ${layerIndex * 0.1}s both`, + } + if (draw.kind === 'circle') { + return ( + + ) + } + return ( + + ) + })} + + ))} + +
+ ) +}) + function rebuildTreeFromCompactNodes(rootNode: FileNode, forests: PackedForest[]): FileNode { const rootCopy: FileNode = { ...rootNode, children: [] } for (const forest of forests) { @@ -281,12 +508,15 @@ function AnalyzeContent() { // Cache compact nodes from batches const compactNodesCache = useRef([]) - const svgRef = useRef(null) + const chartRef = useRef(null) + const chartHandlersRef = useRef(null) + const hoverIdRef = useRef(null) const [hoveredSectorId, setHoveredSectorId] = useState(null) const [tooltip, setTooltip] = useState<{ content: string; label: string; size: string; icon: any; color: string } | null>(null) const tooltipRef = useRef(null) const tooltipMouseRef = useRef({ x: 0, y: 0 }) + const tooltipSizeRef = useRef({ w: 200, h: 70 }) const listContainerRef = useRef(null) const positionTooltip = (mouseX: number, mouseY: number) => { @@ -295,8 +525,8 @@ function AnalyzeContent() { const offsetX = 8 const offsetY = 8 - const tooltipWidth = el.offsetWidth || 200 - const tooltipHeight = el.offsetHeight || 70 + const tooltipWidth = tooltipSizeRef.current.w + const tooltipHeight = tooltipSizeRef.current.h const isLeftHalf = mouseX < window.innerWidth / 2 const isTopHalf = mouseY < window.innerHeight / 2 const x = isLeftHalf ? mouseX + offsetX : mouseX - tooltipWidth - offsetX @@ -306,12 +536,29 @@ function AnalyzeContent() { } useLayoutEffect(() => { - if (!tooltip) return + const el = tooltipRef.current + if (!tooltip || !el) return + tooltipSizeRef.current = { + w: el.offsetWidth || 200, + h: el.offsetHeight || 70, + } positionTooltip(tooltipMouseRef.current.x, tooltipMouseRef.current.y) }, [tooltip]) + const clearHover = () => { + chartRef.current?.setHighlight(null) + if (hoverIdRef.current === null) return + hoverIdRef.current = null + setHoveredSectorId(null) + setTooltip(null) + } + const handleHover = (sectorId: string, event: React.MouseEvent, label: string, name: string, size: string, icon: any, color: string) => { tooltipMouseRef.current = { x: event.clientX, y: event.clientY } + chartRef.current?.setHighlight(sectorId) + positionTooltip(event.clientX, event.clientY) + if (hoverIdRef.current === sectorId) return + hoverIdRef.current = sectorId setHoveredSectorId(sectorId) setTooltip(prev => { if (prev && prev.content === name && prev.label === label && prev.size === size && prev.color === color && prev.icon === icon) { @@ -319,15 +566,13 @@ function AnalyzeContent() { } return { content: name, label, size, icon, color } }) - positionTooltip(event.clientX, event.clientY) } const handleMouseMove = (event: React.MouseEvent) => { tooltipMouseRef.current = { x: event.clientX, y: event.clientY } positionTooltip(event.clientX, event.clientY) } const handleLeave = () => { - setHoveredSectorId(null) - setTooltip(null) + clearHover() } const handleCancelScan = async () => { @@ -571,33 +816,6 @@ function AnalyzeContent() { return filterNode(root) || root } - // Sync hover states between chart and file list - useEffect(() => { - if (!svgRef.current) return - - const updateElements = (elements: NodeListOf, className: string) => { - elements.forEach(element => { - const sectorId = element.getAttribute('data-sector-id') - element.classList.remove('hovered', 'dimmed') - - if (hoveredSectorId && sectorId === hoveredSectorId) { - element.classList.add('hovered') - } else if (hoveredSectorId) { - element.classList.add('dimmed') - } - }) - } - - // Update chart sectors - updateElements(svgRef.current.querySelectorAll('.chart-sector'), 'chart-sector') - - // Update file list items - const fileListContainer = document.querySelector('.file-list') - if (fileListContainer) { - updateElements(fileListContainer.querySelectorAll('.file-item'), 'file-item') - } - }, [hoveredSectorId]) - useEffect(() => { if (!path) return @@ -751,7 +969,7 @@ function AnalyzeContent() { } // Helper functions must be defined before use - const prepareMultiLayerData = (rootNode: FileNode | null, maxDepth: number = 8): LayerData[] => { + const prepareMultiLayerData = (rootNode: FileNode | null, availableLabel: string, maxDepth: number = 8): LayerData[] => { if (!rootNode || !rootNode.children) return [] const layers: Map = new Map() @@ -944,12 +1162,12 @@ function AnalyzeContent() { const availableEndAngle = currentAngle + availableAngleRange layers.get(0)!.push({ - name: t('analyze.chart.availableSpace'), + name: availableLabel, value: logicalAvailableSpace, color: 'rgba(128, 128, 128, 0.4)', path: '__available__', node: { - name: t('analyze.chart.availableSpace'), + name: availableLabel, size: logicalAvailableSpace, path: '__available__', isDirectory: false @@ -965,6 +1183,10 @@ function AnalyzeContent() { layers.forEach((data, depth) => { const innerRadius = 50 + (depth * layerThickness) const outerRadius = innerRadius + layerThickness - 2 + for (const item of data) { + item.sectorId = generateSectorId(item.path, depth) + item.draw = sectorDraw(item.startAngle || 0, item.endAngle || 0, innerRadius, outerRadius) + } result.push({ data, @@ -1027,14 +1249,16 @@ function AnalyzeContent() { path: child.path, node: child, isTinyNode, // Mark if this should be grouped in "其他" + sectorId: isTinyNode ? generateSectorId('__others__', 0) : generateSectorId(child.path, 0), } }) } // Prepare data (must be before conditional returns) + const availableLabel = t('analyze.chart.availableSpace') const layers = useMemo( - () => prepareMultiLayerData(currentLevel), - [currentLevel, diskInfo, data, t] + () => prepareMultiLayerData(currentLevel, availableLabel), + [currentLevel, diskInfo, data, availableLabel] ) const listData = useMemo( () => prepareListData(currentLevel), @@ -1078,6 +1302,7 @@ function AnalyzeContent() { // Helper function to navigate to a node (used by both chart and file list) const navigateToNode = (targetNode: FileNode) => { if (!targetNode.isDirectory || !targetNode.children) return + clearHover() // Check if node is a direct child of current level (most common case) if (currentLevel?.children?.some(child => child.path === targetNode.path)) { @@ -1388,6 +1613,16 @@ function AnalyzeContent() { } } + const centerParts = formatBytesCompact(currentLevel?.size || 0).split(' ') + chartHandlersRef.current = { + hover: handleHover, + move: handleMouseMove, + leave: handleLeave, + activate: (item) => { + if (item.name !== '可用空間') navigateToNode(item.node) + }, + } + return (
@@ -1400,42 +1635,20 @@ function AnalyzeContent() { {/* Header */}
@@ -1495,141 +1708,14 @@ function AnalyzeContent() {
{layers.length > 0 ? (
- - {/* Center size display */} - - {formatBytesCompact(currentLevel?.size || 0).split(' ')[0]} - - - {formatBytesCompact(currentLevel?.size || 0).split(' ')[1]} - - {layers.map((layer, layerIndex) => { - const centerX = 200 - const centerY = 200 - - return ( - - {layer.data.map((item, index) => { - const angleRange = (item.endAngle || 0) - (item.startAngle || 0) - const sectorId = generateSectorId(item.path, layer.depth) - - // Special case: full circle (360°) - if (Math.abs(angleRange - 360) < 0.01) { - const radius = (layer.innerRadius + layer.outerRadius) / 2 - - return ( - - { - const fileTypeInfo = getFileTypeInfo(item.name, item.node.isDirectory) - handleHover(sectorId, e, fileTypeInfo.label, item.name, formatBytes(item.value), fileTypeInfo.icon, fileTypeInfo.color) - }} - onMouseMove={handleMouseMove} - style={{ - cursor: 'pointer', - animation: `layerFadeIn 0.5s ease-out ${layerIndex * 0.1}s both` - }} - onClick={() => { - if (item.name !== '可用空間') { - navigateToNode(item.node) - } - }} - > - - - ) - } - - const startAngle = (item.startAngle || 0) - 90 - const endAngle = (item.endAngle || 0) - 90 - const startRad = (startAngle * Math.PI) / 180 - const endRad = (endAngle * Math.PI) / 180 - - const x1 = centerX + layer.innerRadius * Math.cos(startRad) - const y1 = centerY + layer.innerRadius * Math.sin(startRad) - const x2 = centerX + layer.outerRadius * Math.cos(startRad) - const y2 = centerY + layer.outerRadius * Math.sin(startRad) - const x3 = centerX + layer.outerRadius * Math.cos(endRad) - const y3 = centerY + layer.outerRadius * Math.sin(endRad) - const x4 = centerX + layer.innerRadius * Math.cos(endRad) - const y4 = centerY + layer.innerRadius * Math.sin(endRad) - - const largeArc = endAngle - startAngle > 180 ? 1 : 0 - - const pathData = [ - `M ${x1} ${y1}`, - `L ${x2} ${y2}`, - `A ${layer.outerRadius} ${layer.outerRadius} 0 ${largeArc} 1 ${x3} ${y3}`, - `L ${x4} ${y4}`, - `A ${layer.innerRadius} ${layer.innerRadius} 0 ${largeArc} 0 ${x1} ${y1}`, - 'Z' - ].join(' ') - - return ( - { - const fileTypeInfo = getFileTypeInfo(item.name, item.node.isDirectory) - handleHover(sectorId, e, fileTypeInfo.label, item.name, formatBytes(item.value), fileTypeInfo.icon, fileTypeInfo.color) - }} - onMouseMove={handleMouseMove} - style={{ - cursor: 'pointer', - animation: `layerFadeIn 0.5s ease-out ${layerIndex * 0.1}s both` - }} - onClick={() => { - if (item.name !== '可用空間') { - navigateToNode(item.node) - } - }} - > - - ) - })} - - ) - })} - + layers={layers} + centerPrimary={centerParts[0]} + centerSecondary={centerParts[1] || ''} + chartRef={chartRef} + handlersRef={chartHandlersRef} + />
) : (
@@ -1677,7 +1763,7 @@ function AnalyzeContent() {
{rowVirtualizer.getVirtualItems().map((virtualRow) => { const item = listData[virtualRow.index] - const sectorId = item.isTinyNode ? generateSectorId('__others__', 0) : generateSectorId(item.path, 0) + const sectorId = item.sectorId || (item.isTinyNode ? generateSectorId('__others__', 0) : generateSectorId(item.path, 0)) const fileTypeInfo = getFileTypeInfo(item.name, item.node.isDirectory) const IconComponent = fileTypeInfo.icon const isSelected = selectedFiles.has(item.path) @@ -1712,6 +1798,8 @@ function AnalyzeContent() { >