next.js/apps/bundle-analyzer/components/treemap-visualizer.tsx
treemap-visualizer.tsx1052 lines29.2 KB
'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)',
  }
}
Quest for Codev2.0.0
/
SIGN IN