Some checks failed
Test examples / Test Examples (20) (push) Has been cancelled
Test examples / Test Examples (22) (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Trigger Release / start (push) Has been cancelled
Stale issue handler / stale (push) Has been cancelled
Update Font Data / create-pull-request (push) Has been cancelled
build-and-deploy / deploy-target (push) Has been cancelled
build-and-deploy / build (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / build-wasm (nodejs) (push) Has been cancelled
build-and-deploy / build-wasm (web) (push) Has been cancelled
build-and-deploy / Deploy preview tarball (push) Has been cancelled
build-and-deploy / Potentially publish release (push) Has been cancelled
build-and-deploy / publish-turbopack-npm-packages (push) Has been cancelled
build-and-deploy / Deploy examples (push) Has been cancelled
build-and-deploy / thank you, build (push) Has been cancelled
build-and-deploy / Upload Turbopack Bytesize metrics to Datadog (push) Has been cancelled
Rspack Next.js development integration tests / Rspack integration tests (push) Has been cancelled
Rspack Next.js production integration tests / Rspack integration tests (push) Has been cancelled
Turbopack Next.js development integration tests / Next.js integration tests (push) Has been cancelled
Turbopack Next.js production integration tests / Next.js integration tests (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack development test manifest (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack production test manifest (push) Has been cancelled
Upload bundler test manifests to areweturboyet.com / Upload test results (push) Has been cancelled
Update React / create-pull-request (push) Has been cancelled
test-e2e-project-reset-cron / reset-test-project (push) Has been cancelled
Notify about the top 15 issues/PRs/feature requests (most reacted) in the last 90 days / run (push) Has been cancelled
1052 lines
29 KiB
TypeScript
1052 lines
29 KiB
TypeScript
'use client'
|
|
|
|
import { darken, lighten, readableColor } from 'polished'
|
|
import type React from 'react'
|
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
import type { AnalyzeData } from '@/lib/analyze-data'
|
|
import {
|
|
computeTreemapLayoutFromAnalyze,
|
|
type LayoutNode,
|
|
type LayoutNodeInfo,
|
|
SizeMode,
|
|
} from '@/lib/treemap-layout'
|
|
import { SpecialModule } from '@/lib/types'
|
|
import { formatBytes } from '@/lib/utils'
|
|
|
|
const UI_FONT = 'system-ui, sans-serif'
|
|
|
|
interface TreemapVisualizerProps {
|
|
analyzeData: AnalyzeData
|
|
sourceIndex: number
|
|
selectedSourceIndex?: number
|
|
onSelectSourceIndex?: (index: number) => void
|
|
focusedSourceIndex?: number
|
|
onFocusSourceIndex?: (index: number) => void
|
|
isMouseInTreemap?: boolean
|
|
onMouseInTreemapChange?: (isInside: boolean) => void
|
|
onHoveredNodeChange?: (nodeInfo: LayoutNodeInfo | null) => void
|
|
onHoveredNodeChangeDelayed?: (nodeInfo: LayoutNodeInfo | null) => void
|
|
searchQuery?: string
|
|
filterSource?: (sourceIndex: number) => boolean
|
|
isModulePolyfillChunk?: (sourceIndex: number) => boolean
|
|
isNoModulePolyfillChunk?: (sourceIndex: number) => boolean
|
|
sizeMode?: SizeMode
|
|
}
|
|
|
|
function getFileColor(node: {
|
|
js?: boolean
|
|
css?: boolean
|
|
json?: boolean
|
|
asset?: boolean
|
|
server?: boolean
|
|
client?: boolean
|
|
traced?: boolean
|
|
specialModuleType: SpecialModule | null
|
|
}): string {
|
|
const { js, css, json, asset, client, traced, specialModuleType } = node
|
|
|
|
if (isPolyfill(specialModuleType)) {
|
|
return '#5f707f'
|
|
}
|
|
|
|
let color = '#9ca3af' // gray-400 default
|
|
if (js) color = '#4682b4'
|
|
if (css) color = '#8b7d9e'
|
|
if (json) color = '#297a3a'
|
|
if (asset) color = '#da2f35'
|
|
|
|
if (!client) {
|
|
// Make it darker for server (30% darker)
|
|
color = darken(0.3, color)
|
|
|
|
if (traced) {
|
|
// Make it slightly lighter (15% lighter than darkened)
|
|
color = lighten(0.15, color)
|
|
}
|
|
}
|
|
return color
|
|
}
|
|
|
|
function isPolyfill(specialModuleType: SpecialModule | null): boolean {
|
|
return (
|
|
specialModuleType === SpecialModule.POLYFILL_MODULE ||
|
|
specialModuleType === SpecialModule.POLYFILL_NOMODULE
|
|
)
|
|
}
|
|
|
|
function calculateTitleFontSizes(titleBarHeight: number): {
|
|
titleFontSize: number
|
|
sizeFontSize: number
|
|
} {
|
|
const titleFontSize = Math.min(10, titleBarHeight * 0.5)
|
|
const sizeFontSize = Math.min(9, titleFontSize - 2)
|
|
return { titleFontSize, sizeFontSize }
|
|
}
|
|
|
|
const textWidthCache = new Map<string, number>()
|
|
const TEXT_WIDTH_CACHE_SIZE = 30_000 // Shouldn't be more than a few megabytes of memory
|
|
function measureTextCached(
|
|
ctx: CanvasRenderingContext2D,
|
|
text: string
|
|
): number {
|
|
const cacheKey = `${ctx.font}|${text}`
|
|
|
|
let width = textWidthCache.get(cacheKey)
|
|
if (width !== undefined) {
|
|
// Move to end -- update the insertion order for LRU
|
|
textWidthCache.delete(cacheKey)
|
|
textWidthCache.set(cacheKey, width)
|
|
return width
|
|
}
|
|
|
|
width = ctx.measureText(text).width
|
|
|
|
// LRU-style cache eviction
|
|
if (textWidthCache.size >= TEXT_WIDTH_CACHE_SIZE) {
|
|
const firstKey = textWidthCache.keys().next().value
|
|
if (firstKey !== undefined) {
|
|
textWidthCache.delete(firstKey)
|
|
}
|
|
}
|
|
|
|
textWidthCache.set(cacheKey, width)
|
|
return width
|
|
}
|
|
|
|
function truncateTextWithEllipsisIfNeeded(
|
|
ctx: CanvasRenderingContext2D,
|
|
text: string,
|
|
maxWidth: number
|
|
): string {
|
|
const ellipsisWidth = measureTextCached(ctx, '...')
|
|
|
|
if (measureTextCached(ctx, text) <= maxWidth) {
|
|
return text
|
|
}
|
|
|
|
let left = 0
|
|
let right = text.length
|
|
let bestLength = 0
|
|
|
|
while (left <= right) {
|
|
const mid = Math.floor((left + right) / 2)
|
|
const truncated = text.slice(0, mid)
|
|
// Don't use the cached version since we don't want repeated failures filling it up
|
|
const width = ctx.measureText(truncated).width
|
|
|
|
if (width + ellipsisWidth <= maxWidth) {
|
|
bestLength = mid
|
|
left = mid + 1
|
|
} else {
|
|
right = mid - 1
|
|
}
|
|
}
|
|
|
|
return bestLength > 0 ? `${text.slice(0, bestLength)}...` : '...'
|
|
}
|
|
|
|
function findNodeAtPosition(
|
|
node: LayoutNode,
|
|
x: number,
|
|
y: number
|
|
): LayoutNode | null {
|
|
const { rect } = node
|
|
|
|
// Check if point is within this node's bounds
|
|
if (
|
|
x < rect.x ||
|
|
x > rect.x + rect.width ||
|
|
y < rect.y ||
|
|
y > rect.y + rect.height
|
|
) {
|
|
return null
|
|
}
|
|
|
|
if (node.type === 'collapsed-directory') {
|
|
return node
|
|
}
|
|
|
|
// For regular directories, check if we're in the title bar first
|
|
if (node.type === 'directory') {
|
|
const titleBarHeight = node.titleBarHeight || 0
|
|
if (y >= rect.y && y <= rect.y + titleBarHeight) {
|
|
return node // Clicked on title bar
|
|
}
|
|
}
|
|
|
|
// Check children (if any)
|
|
if (node.children) {
|
|
for (const child of node.children) {
|
|
const found = findNodeAtPosition(child, x, y)
|
|
if (found) return found
|
|
}
|
|
}
|
|
|
|
return node
|
|
}
|
|
|
|
// Helper function to check if node or descendants match search
|
|
function searchOriginalTreeForMatch(
|
|
node: LayoutNode,
|
|
currentPath: string[],
|
|
searchQuery: string
|
|
): boolean {
|
|
const path = [...currentPath, node.name]
|
|
const fullPath = path.join('/').toLowerCase()
|
|
const query = searchQuery.toLowerCase()
|
|
|
|
// Check if current node matches
|
|
if (fullPath.includes(query)) {
|
|
return true
|
|
}
|
|
|
|
// Recursively check children
|
|
if (node.children) {
|
|
for (const child of node.children) {
|
|
if (searchOriginalTreeForMatch(child, path, searchQuery)) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function nodeOrDescendantsMatchSearch(
|
|
node: LayoutNode,
|
|
currentPath: string[],
|
|
searchQuery: string,
|
|
originalData: LayoutNode
|
|
): boolean {
|
|
const path = [...currentPath, node.name]
|
|
const fullPath = path.join('/').toLowerCase()
|
|
const query = searchQuery.toLowerCase()
|
|
|
|
// Check if current node matches
|
|
if (fullPath.includes(query)) {
|
|
return true
|
|
}
|
|
|
|
// For collapsed directories, search the original tree data
|
|
if (node.type === 'collapsed-directory') {
|
|
// Find the original node in the tree data
|
|
let originalNode = originalData
|
|
for (let i = 1; i < path.length; i++) {
|
|
if (!originalNode.children) return false
|
|
const found = originalNode.children.find(
|
|
(child) => child.name === path[i]
|
|
)
|
|
if (!found) return false
|
|
originalNode = found
|
|
}
|
|
|
|
// Search through the original node's children
|
|
if (originalNode.children) {
|
|
for (const child of originalNode.children) {
|
|
if (searchOriginalTreeForMatch(child, path, searchQuery)) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Check if any descendants match (for regular directories)
|
|
if (node.children) {
|
|
for (const child of node.children) {
|
|
if (
|
|
nodeOrDescendantsMatchSearch(child, path, searchQuery, originalData)
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function drawTreemap(
|
|
ctx: CanvasRenderingContext2D,
|
|
node: LayoutNode,
|
|
hoveredAncestorChain: number[] | null,
|
|
selectedAncestorChain: number[],
|
|
useSelectionFade: boolean,
|
|
focusedAncestorChain: number[],
|
|
searchQuery: string,
|
|
originalData: LayoutNode,
|
|
immediateHoveredSourceIndex: number | undefined,
|
|
currentPath: string[] = [],
|
|
parentFadedOut = false,
|
|
insideActiveSubtree = false
|
|
) {
|
|
const { rect, name, type, titleBarHeight, children, sourceIndex } = node
|
|
const path = [...currentPath, name]
|
|
|
|
// Check if this node is on the focused path
|
|
// When we wrap the layout with ancestors, nodes on the focus path should be drawn
|
|
// as title bars, and only the focused node's children should be drawn in full.
|
|
const focusedSourceIndex =
|
|
focusedAncestorChain[focusedAncestorChain.length - 1]
|
|
|
|
if (focusedAncestorChain.length > 1 && sourceIndex !== undefined) {
|
|
const isOnFocusPath = focusedAncestorChain.includes(sourceIndex)
|
|
const isFocusedNode = sourceIndex === focusedSourceIndex
|
|
|
|
// Draw ancestor title bars (nodes on path but before the focused node)
|
|
if (isOnFocusPath && !isFocusedNode && type === 'directory') {
|
|
const colors = getThemeColors()
|
|
|
|
if (titleBarHeight && rect.height > 20) {
|
|
ctx.fillStyle = colors.dirTitleBg
|
|
ctx.globalAlpha = 1.0
|
|
ctx.fillRect(rect.x, rect.y, rect.width, titleBarHeight)
|
|
|
|
ctx.strokeStyle = colors.dirTitleBorder
|
|
ctx.beginPath()
|
|
ctx.moveTo(rect.x, rect.y + titleBarHeight)
|
|
ctx.lineTo(rect.x + rect.width, rect.y + titleBarHeight)
|
|
ctx.stroke()
|
|
|
|
const { titleFontSize } = calculateTitleFontSizes(titleBarHeight)
|
|
ctx.fillStyle = colors.text
|
|
ctx.font = `600 ${titleFontSize}px ${UI_FONT}`
|
|
ctx.textAlign = 'left'
|
|
ctx.textBaseline = 'middle'
|
|
ctx.fillText(
|
|
name,
|
|
rect.x + 8,
|
|
rect.y + titleBarHeight / 2,
|
|
rect.width - 16
|
|
)
|
|
}
|
|
|
|
if (children) {
|
|
for (const child of children) {
|
|
drawTreemap(
|
|
ctx,
|
|
child,
|
|
hoveredAncestorChain,
|
|
selectedAncestorChain,
|
|
useSelectionFade,
|
|
focusedAncestorChain,
|
|
searchQuery,
|
|
originalData,
|
|
immediateHoveredSourceIndex,
|
|
path,
|
|
parentFadedOut,
|
|
insideActiveSubtree
|
|
)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Determine if this node should be faded out
|
|
let fadeOut = false
|
|
|
|
if (searchQuery && searchQuery.trim() !== '') {
|
|
// Search mode: fade out nodes that don't match
|
|
if (type === 'directory' || type === 'collapsed-directory') {
|
|
if (
|
|
!nodeOrDescendantsMatchSearch(
|
|
node,
|
|
currentPath,
|
|
searchQuery,
|
|
originalData
|
|
)
|
|
) {
|
|
fadeOut = true
|
|
}
|
|
} else {
|
|
const fullPath = path.join('/').toLowerCase()
|
|
const query = searchQuery.toLowerCase()
|
|
if (!fullPath.includes(query)) {
|
|
fadeOut = true
|
|
}
|
|
}
|
|
} else if (sourceIndex !== undefined) {
|
|
// Selection/hover mode: show active node + ancestors + descendants
|
|
const activeAncestorChain =
|
|
hoveredAncestorChain ?? (useSelectionFade ? selectedAncestorChain : [])
|
|
|
|
if (activeAncestorChain.length > 0) {
|
|
const activeSourceIndex =
|
|
activeAncestorChain[activeAncestorChain.length - 1]
|
|
const isActiveNode = sourceIndex === activeSourceIndex
|
|
const isAncestorOfActive = activeAncestorChain.includes(sourceIndex)
|
|
|
|
// Check if this node is a descendant of the active node
|
|
// This is tracked via the insideActiveSubtree parameter
|
|
const isDescendantOfActive = insideActiveSubtree
|
|
|
|
// Fade out if NOT related to active node
|
|
if (!isAncestorOfActive && !isActiveNode && !isDescendantOfActive) {
|
|
fadeOut = true
|
|
}
|
|
}
|
|
}
|
|
|
|
const opacity = fadeOut ? 0.3 : 1.0
|
|
const colors = getThemeColors()
|
|
|
|
// Check if this is the immediately hovered node for brightness boost
|
|
const isImmediateHovered =
|
|
sourceIndex !== undefined && sourceIndex === immediateHoveredSourceIndex
|
|
|
|
if (type === 'file') {
|
|
let color = getFileColor(node)
|
|
|
|
// Apply brightness boost to immediately hovered node
|
|
if (isImmediateHovered) {
|
|
color = lighten(0.15, color)
|
|
}
|
|
|
|
ctx.fillStyle = color
|
|
ctx.globalAlpha = opacity
|
|
ctx.fillRect(rect.x, rect.y, rect.width, rect.height)
|
|
|
|
ctx.strokeStyle = colors.border
|
|
ctx.lineWidth = 1
|
|
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height)
|
|
|
|
if (rect.width > 60 && rect.height > 30) {
|
|
const textColor = readableColor(color)
|
|
ctx.fillStyle = textColor
|
|
ctx.textAlign = 'center'
|
|
ctx.textBaseline = 'middle'
|
|
|
|
const maxWidth = rect.width - 8
|
|
|
|
const sizeText = formatBytes(node.size)
|
|
const fontSize = 12
|
|
const sizeFontSize = 10
|
|
const lineHeight = fontSize + 2
|
|
|
|
ctx.font = `${fontSize}px ${UI_FONT}`
|
|
const displayName = truncateTextWithEllipsisIfNeeded(ctx, name, maxWidth)
|
|
const hasSpaceForSize = rect.height > 50
|
|
if (hasSpaceForSize) {
|
|
ctx.fillText(
|
|
displayName,
|
|
rect.x + rect.width / 2,
|
|
rect.y + rect.height / 2 - lineHeight / 2
|
|
)
|
|
|
|
ctx.globalAlpha = opacity * 0.75
|
|
ctx.font = `${sizeFontSize}px ${UI_FONT}`
|
|
ctx.fillText(
|
|
sizeText,
|
|
rect.x + rect.width / 2,
|
|
rect.y + rect.height / 2 + lineHeight / 2
|
|
)
|
|
ctx.globalAlpha = opacity
|
|
} else {
|
|
// Only name fits, draw it centered
|
|
ctx.fillText(
|
|
displayName,
|
|
rect.x + rect.width / 2,
|
|
rect.y + rect.height / 2
|
|
)
|
|
}
|
|
}
|
|
|
|
ctx.globalAlpha = 1.0
|
|
} else if (type === 'collapsed-directory') {
|
|
let bgColor = colors.collapsedBg
|
|
|
|
// Apply brightness boost to immediately hovered node
|
|
if (isImmediateHovered) {
|
|
bgColor = lighten(0.15, bgColor)
|
|
}
|
|
|
|
ctx.fillStyle = bgColor
|
|
ctx.globalAlpha = opacity
|
|
ctx.fillRect(rect.x, rect.y, rect.width, rect.height)
|
|
|
|
ctx.strokeStyle = colors.dirBorder
|
|
ctx.lineWidth = 1
|
|
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height)
|
|
|
|
if (titleBarHeight) {
|
|
let titleBgColor = colors.dirTitleBg
|
|
|
|
// Apply brightness boost to title bar too
|
|
if (isImmediateHovered) {
|
|
titleBgColor = lighten(0.15, titleBgColor)
|
|
}
|
|
|
|
ctx.fillStyle = titleBgColor
|
|
ctx.fillRect(rect.x, rect.y, rect.width, titleBarHeight)
|
|
|
|
ctx.strokeStyle = colors.dirTitleBorder
|
|
ctx.beginPath()
|
|
ctx.moveTo(rect.x, rect.y + titleBarHeight)
|
|
ctx.lineTo(rect.x + rect.width, rect.y + titleBarHeight)
|
|
ctx.stroke()
|
|
|
|
const { titleFontSize, sizeFontSize } =
|
|
calculateTitleFontSizes(titleBarHeight)
|
|
const sizeText = formatBytes(node.size)
|
|
const centerY = rect.y + titleBarHeight / 2
|
|
const gap = 6
|
|
|
|
ctx.textBaseline = 'middle'
|
|
|
|
// Measure size text first to reserve space
|
|
ctx.font = `${sizeFontSize}px ${UI_FONT}`
|
|
const sizeWidth = measureTextCached(ctx, sizeText)
|
|
|
|
const nameX = rect.x + 8
|
|
const availableNameWidth = Math.max(0, rect.width - 16 - sizeWidth - gap)
|
|
ctx.font = `600 ${titleFontSize}px ${UI_FONT}`
|
|
const displayName = truncateTextWithEllipsisIfNeeded(
|
|
ctx,
|
|
name,
|
|
availableNameWidth
|
|
)
|
|
|
|
ctx.fillStyle = colors.text
|
|
ctx.textAlign = 'left'
|
|
ctx.fillText(displayName, nameX, centerY, availableNameWidth)
|
|
|
|
const nameWidth = measureTextCached(ctx, displayName)
|
|
const sizeX = nameX + nameWidth + gap
|
|
|
|
// Only draw size text if it fits within bounds
|
|
if (sizeX + sizeWidth <= rect.x + rect.width - 8) {
|
|
ctx.font = `${sizeFontSize}px ${UI_FONT}`
|
|
ctx.fillStyle = colors.textMuted
|
|
ctx.fillText(sizeText, sizeX, centerY)
|
|
}
|
|
}
|
|
|
|
ctx.globalAlpha = 1.0
|
|
} else {
|
|
let dirBgColor = colors.dirBg
|
|
|
|
// Apply brightness boost to immediately hovered node
|
|
if (isImmediateHovered) {
|
|
dirBgColor = lighten(0.15, dirBgColor)
|
|
}
|
|
|
|
ctx.fillStyle = dirBgColor
|
|
ctx.globalAlpha = opacity
|
|
ctx.fillRect(rect.x, rect.y, rect.width, rect.height)
|
|
|
|
ctx.strokeStyle = colors.dirBorder
|
|
ctx.lineWidth = 1
|
|
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height)
|
|
|
|
if (titleBarHeight && rect.height > 20) {
|
|
let dirTitleBgColor = colors.dirTitleBg
|
|
|
|
// Apply brightness boost to title bar too
|
|
if (isImmediateHovered) {
|
|
dirTitleBgColor = lighten(0.15, dirTitleBgColor)
|
|
}
|
|
|
|
ctx.fillStyle = dirTitleBgColor
|
|
ctx.fillRect(rect.x, rect.y, rect.width, titleBarHeight)
|
|
|
|
ctx.strokeStyle = colors.dirTitleBorder
|
|
ctx.beginPath()
|
|
ctx.moveTo(rect.x, rect.y + titleBarHeight)
|
|
ctx.lineTo(rect.x + rect.width, rect.y + titleBarHeight)
|
|
ctx.stroke()
|
|
|
|
const { titleFontSize, sizeFontSize } =
|
|
calculateTitleFontSizes(titleBarHeight)
|
|
const sizeText = formatBytes(node.size)
|
|
const centerY = rect.y + titleBarHeight / 2
|
|
const gap = 6
|
|
|
|
ctx.textBaseline = 'middle'
|
|
|
|
ctx.font = `${sizeFontSize}px ${UI_FONT}`
|
|
const sizeWidth = measureTextCached(ctx, sizeText)
|
|
|
|
const nameX = rect.x + 8
|
|
const availableNameWidth = Math.max(0, rect.width - 16 - sizeWidth - gap)
|
|
ctx.font = `600 ${titleFontSize}px ${UI_FONT}`
|
|
const displayName = truncateTextWithEllipsisIfNeeded(
|
|
ctx,
|
|
name,
|
|
availableNameWidth
|
|
)
|
|
|
|
ctx.fillStyle = colors.text
|
|
ctx.textAlign = 'left'
|
|
ctx.fillText(displayName, nameX, centerY, availableNameWidth)
|
|
|
|
const nameWidth = measureTextCached(ctx, displayName)
|
|
const sizeX = nameX + nameWidth + gap
|
|
|
|
// Only draw size text if it fits within bounds
|
|
if (sizeX + sizeWidth <= rect.x + rect.width - 8) {
|
|
ctx.font = `${sizeFontSize}px ${UI_FONT}`
|
|
ctx.fillStyle = colors.textMuted
|
|
ctx.fillText(sizeText, sizeX, centerY)
|
|
}
|
|
}
|
|
|
|
ctx.globalAlpha = 1.0
|
|
|
|
if (children) {
|
|
for (const child of children) {
|
|
const childFadeOut =
|
|
searchQuery && searchQuery.trim() !== '' ? false : fadeOut
|
|
|
|
// Determine if children are inside active subtree
|
|
// Children are inside if: this node is active OR we're already inside
|
|
const activeAncestorChain =
|
|
hoveredAncestorChain ??
|
|
(useSelectionFade ? selectedAncestorChain : [])
|
|
const activeSourceIndex =
|
|
activeAncestorChain[activeAncestorChain.length - 1]
|
|
const childInsideActiveSubtree =
|
|
insideActiveSubtree || sourceIndex === activeSourceIndex
|
|
|
|
drawTreemap(
|
|
ctx,
|
|
child,
|
|
hoveredAncestorChain,
|
|
selectedAncestorChain,
|
|
useSelectionFade,
|
|
focusedAncestorChain,
|
|
searchQuery,
|
|
originalData,
|
|
immediateHoveredSourceIndex,
|
|
path,
|
|
childFadeOut,
|
|
childInsideActiveSubtree
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function wrapLayoutWithAncestorsUsingIndices(
|
|
focusedLayout: LayoutNode,
|
|
focusedAncestorChain: number[],
|
|
analyzeData: AnalyzeData,
|
|
fullWidth: number,
|
|
fullHeight: number,
|
|
minTitleBarHeight = 12
|
|
): LayoutNode {
|
|
// If focusing on root, return as-is
|
|
if (focusedAncestorChain.length <= 1) {
|
|
return focusedLayout
|
|
}
|
|
|
|
let currentNode = focusedLayout
|
|
let cumulativeY = focusedLayout.rect.y // Start from where the focused node begins
|
|
|
|
// Work backwards from the parent of focused node to the child of root
|
|
for (let i = focusedAncestorChain.length - 2; i >= 1; i--) {
|
|
const ancestorIndex = focusedAncestorChain[i]
|
|
const ancestorSource = analyzeData.source(ancestorIndex)
|
|
if (!ancestorSource) continue
|
|
|
|
const titleBarHeight = minTitleBarHeight
|
|
|
|
// This ancestor starts at cumulativeY - titleBarHeight
|
|
cumulativeY -= titleBarHeight
|
|
|
|
const ancestorNode: LayoutNode = {
|
|
name: ancestorSource.path,
|
|
type: 'directory',
|
|
size: currentNode.size,
|
|
rect: {
|
|
x: 0,
|
|
y: cumulativeY,
|
|
width: fullWidth,
|
|
height: fullHeight - cumulativeY,
|
|
},
|
|
titleBarHeight: titleBarHeight,
|
|
children: [currentNode],
|
|
sourceIndex: ancestorIndex,
|
|
specialModuleType: null,
|
|
}
|
|
|
|
currentNode = ancestorNode
|
|
}
|
|
|
|
cumulativeY -= minTitleBarHeight
|
|
|
|
const rootIndex = focusedAncestorChain[0]
|
|
const rootSource = analyzeData.source(rootIndex)
|
|
|
|
const rootNode: LayoutNode = {
|
|
name: rootSource?.path || '',
|
|
type: 'directory',
|
|
size: currentNode.size,
|
|
rect: {
|
|
x: 0,
|
|
y: cumulativeY,
|
|
width: fullWidth,
|
|
height: fullHeight - cumulativeY,
|
|
},
|
|
titleBarHeight: minTitleBarHeight,
|
|
children: [currentNode],
|
|
sourceIndex: rootIndex,
|
|
specialModuleType: null,
|
|
}
|
|
|
|
return rootNode
|
|
}
|
|
|
|
export function TreemapVisualizer({
|
|
analyzeData,
|
|
sourceIndex,
|
|
selectedSourceIndex = sourceIndex,
|
|
onSelectSourceIndex = () => {},
|
|
focusedSourceIndex = sourceIndex,
|
|
onFocusSourceIndex = () => {},
|
|
isMouseInTreemap = false,
|
|
onHoveredNodeChange,
|
|
onHoveredNodeChangeDelayed,
|
|
searchQuery = '',
|
|
filterSource,
|
|
sizeMode = SizeMode.Compressed,
|
|
}: TreemapVisualizerProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const [hoveredNode, setHoveredNode] = useState<LayoutNode | null>(null)
|
|
const [shouldDimOthers, setShouldDimOthers] = useState(false)
|
|
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
const [dimensions, setDimensions] = useState<{
|
|
cssWidth: number
|
|
cssHeight: number
|
|
canvasWidth: number
|
|
canvasHeight: number
|
|
}>({
|
|
cssWidth: 1200,
|
|
cssHeight: 800,
|
|
canvasWidth: 1200,
|
|
canvasHeight: 800,
|
|
})
|
|
const [, _setTheme] = useState<'light' | 'dark'>('light')
|
|
|
|
// Build ancestor chain for focused source (list of source indices from root to focused)
|
|
const focusedAncestorChain = useMemo(() => {
|
|
const chain: number[] = []
|
|
let currentIndex = focusedSourceIndex
|
|
|
|
while (currentIndex !== undefined && currentIndex !== null) {
|
|
chain.unshift(currentIndex)
|
|
const source = analyzeData.source(currentIndex)
|
|
if (!source || source.parent_source_index === null) break
|
|
currentIndex = source.parent_source_index
|
|
}
|
|
|
|
return chain
|
|
}, [analyzeData, focusedSourceIndex])
|
|
|
|
// Build ancestor chain for selected source
|
|
const selectedAncestorChain = useMemo(() => {
|
|
const chain: number[] = []
|
|
let currentIndex = selectedSourceIndex
|
|
|
|
while (currentIndex !== undefined && currentIndex !== null) {
|
|
chain.unshift(currentIndex)
|
|
const source = analyzeData.source(currentIndex)
|
|
if (!source || source.parent_source_index === null) break
|
|
currentIndex = source.parent_source_index
|
|
}
|
|
|
|
return chain
|
|
}, [analyzeData, selectedSourceIndex])
|
|
|
|
// Build ancestor chain for hovered node (only used for dimming)
|
|
const hoveredAncestorChain = useMemo(() => {
|
|
if (
|
|
!shouldDimOthers ||
|
|
!hoveredNode ||
|
|
hoveredNode.sourceIndex === undefined
|
|
)
|
|
return null
|
|
|
|
const chain: number[] = []
|
|
let currentIndex = hoveredNode.sourceIndex
|
|
|
|
while (currentIndex !== undefined && currentIndex !== null) {
|
|
chain.unshift(currentIndex)
|
|
const source = analyzeData.source(currentIndex)
|
|
if (!source || source.parent_source_index === null) break
|
|
currentIndex = source.parent_source_index
|
|
}
|
|
|
|
return chain
|
|
}, [analyzeData, hoveredNode, shouldDimOthers])
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current
|
|
if (!container) return
|
|
|
|
const updateSize = () => {
|
|
const dpr = window.devicePixelRatio || 1
|
|
setDimensions((dimensions) => {
|
|
const rect = container.getBoundingClientRect()
|
|
if (
|
|
dimensions.cssWidth === Math.floor(rect.width) &&
|
|
dimensions.cssHeight === Math.floor(rect.height)
|
|
) {
|
|
return dimensions
|
|
}
|
|
|
|
return {
|
|
cssWidth: Math.floor(rect.width),
|
|
cssHeight: Math.floor(rect.height),
|
|
canvasWidth: Math.floor(rect.width * dpr),
|
|
canvasHeight: Math.floor(rect.height * dpr),
|
|
}
|
|
})
|
|
}
|
|
|
|
updateSize()
|
|
|
|
const resizeObserver = new ResizeObserver(updateSize)
|
|
resizeObserver.observe(container)
|
|
|
|
return () => resizeObserver.disconnect()
|
|
}, [])
|
|
|
|
const layout = useMemo(() => {
|
|
// Compute layout using the focused source index
|
|
const focusedLayout = computeTreemapLayoutFromAnalyze(
|
|
analyzeData,
|
|
focusedSourceIndex,
|
|
{
|
|
x: 0,
|
|
y: 12 * focusedAncestorChain.length,
|
|
width: dimensions.cssWidth,
|
|
height: dimensions.cssHeight,
|
|
},
|
|
filterSource,
|
|
sizeMode
|
|
)
|
|
|
|
// If we're not at the root, wrap with ancestor title bars
|
|
if (focusedAncestorChain.length > 1) {
|
|
return wrapLayoutWithAncestorsUsingIndices(
|
|
focusedLayout,
|
|
focusedAncestorChain,
|
|
analyzeData,
|
|
dimensions.cssWidth,
|
|
dimensions.cssHeight,
|
|
12
|
|
)
|
|
}
|
|
|
|
return focusedLayout
|
|
}, [
|
|
analyzeData,
|
|
focusedSourceIndex,
|
|
focusedAncestorChain,
|
|
dimensions.cssWidth,
|
|
dimensions.cssHeight,
|
|
filterSource,
|
|
sizeMode,
|
|
])
|
|
|
|
useLayoutEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
|
|
const dpr = window.devicePixelRatio || 1
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
|
ctx.scale(dpr, dpr)
|
|
|
|
ctx.clearRect(0, 0, dimensions.cssWidth, dimensions.cssHeight)
|
|
|
|
drawTreemap(
|
|
ctx,
|
|
layout,
|
|
hoveredAncestorChain,
|
|
selectedAncestorChain,
|
|
!isMouseInTreemap,
|
|
focusedAncestorChain,
|
|
searchQuery,
|
|
layout,
|
|
hoveredNode?.sourceIndex
|
|
)
|
|
}, [
|
|
layout,
|
|
hoveredAncestorChain,
|
|
selectedAncestorChain,
|
|
dimensions.cssWidth,
|
|
dimensions.cssHeight,
|
|
isMouseInTreemap,
|
|
focusedAncestorChain,
|
|
searchQuery,
|
|
hoveredNode,
|
|
])
|
|
|
|
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
const x = e.clientX - rect.left
|
|
const y = e.clientY - rect.top
|
|
|
|
const node = findNodeAtPosition(layout, x, y)
|
|
|
|
if (node && node.sourceIndex !== undefined) {
|
|
// If this node is already, refocus the root node to undim others
|
|
if (node.sourceIndex === selectedSourceIndex) {
|
|
onSelectSourceIndex(sourceIndex)
|
|
} else {
|
|
onSelectSourceIndex(node.sourceIndex)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleDoubleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
const x = e.clientX - rect.left
|
|
const y = e.clientY - rect.top
|
|
|
|
const node = findNodeAtPosition(layout, x, y)
|
|
|
|
if (node && node.sourceIndex !== undefined) {
|
|
// Navigate into directories on double-click
|
|
if (node.type === 'directory' || node.type === 'collapsed-directory') {
|
|
onFocusSourceIndex(node.sourceIndex)
|
|
onSelectSourceIndex(node.sourceIndex)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
const x = e.clientX - rect.left
|
|
const y = e.clientY - rect.top
|
|
|
|
const node = findNodeAtPosition(layout, x, y)
|
|
|
|
// Clear existing timeout
|
|
if (hoverTimeoutRef.current) {
|
|
clearTimeout(hoverTimeoutRef.current)
|
|
hoverTimeoutRef.current = null
|
|
}
|
|
|
|
if (node) {
|
|
const nodeInfo = {
|
|
name: node.name,
|
|
size: node.size,
|
|
server: node.server,
|
|
client: node.client,
|
|
}
|
|
|
|
if (node.type === 'directory') {
|
|
const titleBarHeight = node.titleBarHeight || 0
|
|
if (y >= node.rect.y && y <= node.rect.y + titleBarHeight) {
|
|
canvas.style.cursor = 'pointer'
|
|
// Immediately set for brightness increase and footer/tooltip updates
|
|
setHoveredNode(node)
|
|
setShouldDimOthers(false)
|
|
onHoveredNodeChange?.(nodeInfo)
|
|
// Delay dimming other nodes by 800ms
|
|
hoverTimeoutRef.current = setTimeout(() => {
|
|
setShouldDimOthers(true)
|
|
onHoveredNodeChangeDelayed?.(nodeInfo)
|
|
}, 1000)
|
|
return
|
|
}
|
|
} else {
|
|
canvas.style.cursor = 'pointer'
|
|
// Immediately set for brightness increase and footer/tooltip updates
|
|
setHoveredNode(node)
|
|
setShouldDimOthers(false)
|
|
onHoveredNodeChange?.(nodeInfo)
|
|
// Delay dimming other nodes by 800ms
|
|
hoverTimeoutRef.current = setTimeout(() => {
|
|
setShouldDimOthers(true)
|
|
onHoveredNodeChangeDelayed?.(nodeInfo)
|
|
}, 1000)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Immediately clear hover when mouse leaves a node
|
|
setHoveredNode(null)
|
|
setShouldDimOthers(false)
|
|
onHoveredNodeChange?.(null)
|
|
onHoveredNodeChangeDelayed?.(null)
|
|
if (canvasRef.current) {
|
|
canvasRef.current.style.cursor = 'default'
|
|
}
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
// Clear timeout when mouse leaves canvas
|
|
if (hoverTimeoutRef.current) {
|
|
clearTimeout(hoverTimeoutRef.current)
|
|
hoverTimeoutRef.current = null
|
|
}
|
|
|
|
setHoveredNode(null)
|
|
setShouldDimOthers(false)
|
|
onHoveredNodeChange?.(null)
|
|
onHoveredNodeChangeDelayed?.(null)
|
|
if (canvasRef.current) {
|
|
canvasRef.current.style.cursor = 'default'
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="w-full h-full bg-background border border-border rounded-lg overflow-hidden"
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={dimensions.canvasWidth}
|
|
height={dimensions.canvasHeight}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
onClick={handleClick}
|
|
onDoubleClick={handleDoubleClick}
|
|
className="block w-full h-full"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function isDarkMode(): boolean {
|
|
if (typeof window === 'undefined') return false
|
|
return document.documentElement.classList.contains('dark')
|
|
}
|
|
|
|
function getThemeColors() {
|
|
const dark = isDarkMode()
|
|
return {
|
|
text: dark ? '#ffffff' : '#000000',
|
|
textMuted: dark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)',
|
|
border: dark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(180, 180, 180, 0.5)',
|
|
dirBg: dark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(230, 230, 230, 0.1)',
|
|
dirBorder: dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(180, 180, 180, 0.6)',
|
|
dirTitleBg: dark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(230, 230, 230, 0.1)',
|
|
dirTitleBorder: dark
|
|
? 'rgba(255, 255, 255, 0.4)'
|
|
: 'rgba(180, 180, 180, 0.5)',
|
|
collapsedBg: dark
|
|
? 'rgba(128, 128, 128, 0.15)'
|
|
: 'rgba(230, 230, 230, 0.2)',
|
|
collapsedText: dark
|
|
? 'rgba(255, 255, 255, 0.5)'
|
|
: 'rgba(128, 128, 128, 0.6)',
|
|
}
|
|
}
|