diff --git a/webui/src/app/stats/[entry]/folders/[path].tsx b/webui/src/app/stats/[entry]/folders/[path].tsx index 0fe57bf..fedd353 100644 --- a/webui/src/app/stats/[entry]/folders/[path].tsx +++ b/webui/src/app/stats/[entry]/folders/[path].tsx @@ -2,6 +2,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useLocalSearchParams } from 'expo-router'; import type { ModuleGraphResponse } from '~/app/api/stats/[entry]/modules/graph+api'; +import { BundleGraph } from '~/components/BundleGraph'; import { Page, PageHeader, PageTitle } from '~/components/Page'; import { ModuleFilters, @@ -9,7 +10,6 @@ import { statsModuleFiltersToUrlParams, useStatsModuleFilters, } from '~/components/forms/StatsModuleFilter'; -import { BundleGraph } from '~/components/graphs/BundleGraph'; import { useStatsEntry } from '~/providers/stats'; import { Tag } from '~/ui/Tag'; import { fetchApi } from '~/utils/api'; diff --git a/webui/src/app/stats/[entry]/index.tsx b/webui/src/app/stats/[entry]/index.tsx index 7184066..841705d 100644 --- a/webui/src/app/stats/[entry]/index.tsx +++ b/webui/src/app/stats/[entry]/index.tsx @@ -1,6 +1,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'; import type { ModuleGraphResponse } from '~/app/api/stats/[entry]/modules/graph+api'; +import { BundleGraph } from '~/components/BundleGraph'; import { Page, PageHeader, PageTitle } from '~/components/Page'; import { type ModuleFilters, @@ -8,7 +9,6 @@ import { statsModuleFiltersToUrlParams, useStatsModuleFilters, } from '~/components/forms/StatsModuleFilter'; -import { BundleGraph } from '~/components/graphs/BundleGraph'; import { useStatsEntry } from '~/providers/stats'; import { Spinner } from '~/ui/Spinner'; import { Tag } from '~/ui/Tag'; diff --git a/webui/src/components/graphs/BundleGraph.tsx b/webui/src/components/BundleGraph.tsx similarity index 100% rename from webui/src/components/graphs/BundleGraph.tsx rename to webui/src/components/BundleGraph.tsx diff --git a/webui/src/components/graphs/Graph.tsx b/webui/src/components/graphs/Graph.tsx deleted file mode 100644 index 018f479..0000000 --- a/webui/src/components/graphs/Graph.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import ReactECharts from 'echarts-for-react'; -import { - type ComponentProps, - type RefObject, - forwardRef, - useEffect, - useRef, - useState, -} from 'react'; - -export const Graph = forwardRef>((props, ref) => { - const container = useRef(null); - const containerHeight = useDynamicHeight(container); - - const echartOptions = { - ...(props.opts ?? {}), - height: props.opts?.height ?? containerHeight, - }; - - return ( -
- -
- ); -}); - -let lastKnownHeight = 300; - -function useDynamicHeight( - ref: RefObject, - initialHeight = lastKnownHeight -) { - const [size, setSize] = useState({ height: initialHeight, width: 0 }); - - useEffect(() => { - lastKnownHeight = size.height; - }, [size.height]); - - useEffect(() => { - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - setSize(entry.contentRect); - } - }); - - if (ref.current) resizeObserver.observe(ref.current); - - return () => { - if (ref.current) resizeObserver.unobserve(ref.current); - }; - }, [ref]); - - return size.height; -} diff --git a/webui/src/components/graphs/TreemapGraph.tsx b/webui/src/components/graphs/TreemapGraph.tsx deleted file mode 100644 index 48ba088..0000000 --- a/webui/src/components/graphs/TreemapGraph.tsx +++ /dev/null @@ -1,488 +0,0 @@ -import * as echarts from 'echarts'; -import { useRouter } from 'expo-router'; -import { useCallback, useMemo } from 'react'; - -import { Graph } from './Graph'; - -import type { ModuleMetadata } from '~/app/api/stats/[entry]/modules/index+api'; -import { useStatsEntry } from '~/providers/stats'; -import { formatFileSize } from '~/utils/formatString'; - -type TreemapGraphProps = { - name?: string; - modules: ModuleMetadata[]; -}; - -const ICON_STRINGS = { - file: ``, - dir: ``, - pkg: ``, -}; - -function useInspectCallback() { - const router = useRouter(); - const { entry } = useStatsEntry(); - - return useCallback( - (type: 'folder' | 'module', path: string) => { - router.push({ - pathname: - type === 'module' ? '/stats/[entry]/modules/[path]' : '/stats/[entry]/folders/[path]', - params: { entry: entry.id, path }, - }); - }, - [entry.id] - ); -} - -export function TreemapGraph(props: TreemapGraphProps) { - const { data, maxDepth, maxNodeModules } = useMemo( - () => createModuleTree(props.modules.filter((module) => module.path.startsWith('/'))), - [props.modules] - ); - - const getLabelObj = ({ multiLevel }: any) => ({ - show: true, - overflow: 'truncate', - formatter(params: any) { - let ratioString = params.data.ratioString; - if (!ratioString) { - ratioString = formatFileSize(params.data.value); - } - return [params.name, `${ratioString}`].join(multiLevel ? '\n' : ' '); - }, - }); - - const labelObj = { - ...getLabelObj({ multiLevel: true }), - position: 'left', - align: 'left', - verticalAlign: 'middle', - }; - const upperLabelObj = { - ...getLabelObj({ multiLevel: false }), - }; - - const onInspectPath = useInspectCallback(); - - return ( - -
${sideIcon} - ${ - data.isNodeModuleRoot ? data.nodeModuleName : data.name - }
- ${data.ratioString} - ` - ); - const divider = ``; - components.push(divider); - - if (data.childCount) { - components.push( - `Files: ${data.childCount}` - ); - } - components.push( - `Size: ${data.sizeString}` - ); - components.push( - `Path: ${relativePath}` - ); - if (data.moduleHref) { - components.push(divider); - components.push( - `Open Module: ⌘ + Click` - ); - } else if (data.folderHref) { - components.push(divider); - components.push( - `Open Folder: ⌘ + Click` - ); - } - } else { - // Full bundle - const typeName = !props.name ? 'Bundle' : props.name; - components.push( - `
-
${ICON_STRINGS.pkg} - ${typeName}
- 100% -
` - ); - } - return `
${components.join( - '' - )}
`; - }, - }, - - series: [ - { - // roam: 'move', - name: !props.name ? 'Bundle' : props.name, - type: 'treemap', - height: '85%', - width: '95%', - // zoomToNodeRatio: 0.5, - - //# Colors - colorMappingBy: 'value', - visualDimension: 1, - color: new Array(maxNodeModules).fill(null).map((_, index) => { - const colors = ['#37434A', '#282A35', '#3C5056', '#263C5F', '#313158', '#4A325C']; - return colors[index % colors.length]; - }), - visualMin: 0, - visualMax: maxNodeModules, - - //# Breadcrumbs - breadcrumb: { - show: true, - height: 36, - left: 32, - top: 16, - emptyItemWidth: 25, - itemStyle: { - color: '#000', - borderColor: '#20293A', - borderWidth: 1, - gapWidth: 0, - shadowColor: 'transparent', - textStyle: { - color: '#96A2B5', - fontWeight: 'bold', - }, - }, - emphasis: { - itemStyle: { - borderWidth: 2, - textStyle: { - color: '#fff', - fontWeight: 'bold', - }, - }, - }, - }, - - upperLabel: { - // show: true, - height: 30, - // formatter: '{b}', - ...upperLabelObj, - emphasis: { - ...upperLabelObj, - }, - }, - - itemStyle: { - borderColor: '#fff', - shadowColor: 'rgba(0,0,0,0.5)', - shadowBlur: 0, - shadowOffsetX: -0.5, - shadowOffsetY: -0.5, - }, - - label: labelObj, - - levels: [ - { - itemStyle: { - borderColor: '#353745', - borderWidth: 6, - gapWidth: 4, - }, - upperLabel: { - // show: false, - }, - }, - { - itemStyle: { - borderColor: '#191A20', - borderWidth: 5, - gapWidth: 2, - }, - emphasis: { - itemStyle: { - borderColor: '#25262B', - }, - }, - }, - ...new Array(maxDepth).fill(null).map(() => ({ - itemStyle: { - borderWidth: 2, - borderColorSaturation: 0.4, - }, - })), - ], - data, - }, - ], - }} - /> - ); -} - -type TreemapItem = { - name: string; - path: string; - value: [number, number]; - moduleHref?: string; - folderHref?: string; - tip: string; - sizeString: string; - ratio: number; - childCount: number; - ratioString: string; - children: TreemapItem[]; - nodeModuleName: string; - isNodeModuleRoot?: boolean; -}; - -function createModuleTree(paths: ModuleMetadata[]): { - data: TreemapItem[]; - maxDepth: number; - maxNodeModules: number; -} { - const nodeModuleIndex: { [key: string]: number } = {}; - let lastIndex = 1; - function indexForNodeModule(moduleName: string) { - if (nodeModuleIndex[moduleName] == null) { - nodeModuleIndex[moduleName] = lastIndex++; - } - return nodeModuleIndex[moduleName]; - } - - const root: TreemapItem = { - folderHref: '/', - path: '/', - children: [], - name: '/', - value: [0, 0], - ratio: 0, - childCount: 0, - tip: '', - sizeString: '', - ratioString: '', - nodeModuleName: '', - }; - - let maxDepth = 0; - - paths.forEach((pathObj) => { - const parts = pathObj.path.split('/').filter(Boolean); - let current = root; - - maxDepth = Math.max(maxDepth, parts.length); - - parts.forEach((part, index) => { - const isLast = index === parts.length - 1; - const pathFull = '/' + parts.slice(0, index + 1).join('/'); - - let next = current.children.find((g) => g.path === part); - - if (!next) { - next = { - folderHref: pathFull, - path: part, - name: part, - children: [], - value: [0, 0], - ratio: 0, - childCount: 0, - tip: '', - sizeString: '', - ratioString: '', - nodeModuleName: '', - }; - current.children.push(next); - } - - if (isLast) { - next.path = pathObj.path; - next.moduleHref = pathObj.path; - next.value = [pathObj.size, indexForNodeModule(pathObj.package ?? '[unknown]')]; - next.nodeModuleName = pathObj.package ?? '[unknown]'; - } else { - next.value[0] += pathObj.size; - } - - current = next; - }); - }); - - const foldNodeModuleValue = (group: TreemapItem) => { - if (group.nodeModuleName) { - return group.nodeModuleName; - } - - const childNames = group.children - .map((nm) => { - const name = foldNodeModuleValue(nm); - return nm.name.startsWith('node_modules') ? null : name; - }) - .filter((name) => name != null) as string[]; - - const hasTopLevelChild = group.children.some((v) => !v.children.length); - - const hasAmbiguousName = !childNames.every((v) => v === childNames[0]); - - if (hasAmbiguousName) { - group.nodeModuleName = ''; - group.value[1] = 0; //indexForNodeModule(group.name); - } else { - if (hasTopLevelChild || !hasAmbiguousName) { - group.nodeModuleName = childNames[0]; - group.isNodeModuleRoot = group.nodeModuleName === group.name; - } else { - group.nodeModuleName = ''; - } - group.value[1] = indexForNodeModule(group.nodeModuleName); - } - - return group.nodeModuleName; - }; - foldNodeModuleValue(root); - - const foldSingleChildGroups = (group: TreemapItem) => { - if (group.children.length === 1 && group.name !== group.nodeModuleName) { - const child = group.children[0]; - group.value = child.value; - group.name = group.name + '/' + child.name; - group.path = child.path; - group.children = child.children; - group.moduleHref = child.moduleHref; - group.nodeModuleName = child.nodeModuleName; - - foldSingleChildGroups(group); // recursively fold single child children - } else { - group.children.forEach(foldSingleChildGroups); - } - }; - foldSingleChildGroups(root); - - const unfoldNodeModuleNames = (group: TreemapItem) => { - for (const child of group.children) { - // Has children and no nodeModuleName - if (child.children.length && !child.nodeModuleName && child.name.startsWith('@')) { - // Split this child into multiple sub children - for (const subChild of child.children) { - const name = child.name + '/' + subChild.name; - group.children.push({ - ...subChild, - isNodeModuleRoot: name === subChild.nodeModuleName, - name, - path: child.path + '/' + subChild.path, - }); - } - // Remove the original child - group.children.splice(group.children.indexOf(child), 1); - } - } - - group.children.forEach(unfoldNodeModuleNames); - }; - unfoldNodeModuleNames(root); - - // Recalculate the node modules value (#2) relative to the size of the node module overall. - // First we need to calculate the total size of each node module - const nodeModuleSizes: { [key: string]: number } = {}; - - const getNodeModuleSizesMap = (group: TreemapItem) => { - if (group.nodeModuleName && !nodeModuleSizes[group.nodeModuleName]) { - nodeModuleSizes[group.nodeModuleName] = group.value[0]; - } - - group.children.forEach(getNodeModuleSizesMap); - }; - getNodeModuleSizesMap(root); - - const sizes = Object.entries(nodeModuleSizes).sort((a, b) => b[1] - a[1]); - - const recalculateNodeModuleSizesValue = (group: TreemapItem) => { - const size = sizes.findIndex(([name]) => name === group.nodeModuleName); - group.value[1] = size + 1; - - group.children.forEach(recalculateNodeModuleSizesValue); - }; - recalculateNodeModuleSizesValue(root); - - root.value[0] = root.children.reduce((acc, g) => acc + g.value[0], 0); - - const calculateRatio = (group: TreemapItem) => { - group.ratio = group.value[0] / root.value[0]; - group.children.forEach(calculateRatio); - }; - calculateRatio(root); - - // Calculate the ratio of each group - const calculateTooltip = (group: TreemapItem) => { - const percentage = group.ratio * 100; - let percetageString = percentage.toFixed(2) + '%'; - if (percentage <= 0.01) { - percetageString = '< 0.01%'; - } - - const size = formatFileSize(group.value[0]); - group.ratioString = percetageString; - group.tip = percetageString + ' (' + size + ')'; - group.sizeString = size; - group.children.forEach(calculateTooltip); - }; - - calculateTooltip(root); - - const calculateChildCount = (group: TreemapItem): number => { - group.childCount = group.children.reduce((acc, v) => acc + calculateChildCount(v), 0); - return group.childCount + (group.children.length ? 0 : 1); - }; - calculateChildCount(root); - - return { data: root.children, maxDepth, maxNodeModules: lastIndex }; -}