Skip to content

Commit 8618932

Browse files
authored
feat: add sunburst view for assets (#57)
1 parent bb20405 commit 8618932

5 files changed

Lines changed: 347 additions & 271 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<script setup lang="ts">
2+
import type { GraphBase, GraphBaseOptions } from 'nanovis'
3+
import type { AssetChartInfo, AssetChartNode } from '~/types/assets'
4+
import { colorToCssBackground } from 'nanovis'
5+
import { useTemplateRef, watchEffect } from 'vue'
6+
7+
const props = defineProps<{
8+
graph: GraphBase<AssetChartInfo | undefined, GraphBaseOptions<AssetChartInfo | undefined>>
9+
selected?: AssetChartNode | undefined
10+
}>()
11+
12+
const emit = defineEmits<{
13+
(e: 'select', node: AssetChartNode | null): void
14+
}>()
15+
16+
const el = useTemplateRef<HTMLDivElement>('el')
17+
watchEffect(() => el.value?.append(props.graph.el))
18+
</script>
19+
20+
<template>
21+
<div grid="~ cols-[max-content_1fr] gap-2" p4>
22+
<div ref="el" w-500px />
23+
<div flex="~ col gap-4">
24+
<ChartAssetNavBreadcrumb
25+
border="b base" py2
26+
:selected="selected"
27+
:options="graph.options"
28+
@select="emit('select', $event)"
29+
/>
30+
<div v-if="selected" grid="~ cols-[250px_1fr] gap-1">
31+
<template v-for="child of selected.children" :key="child.id">
32+
<button
33+
ws-nowrap text-nowrap text-left overflow-hidden text-ellipsis text-sm
34+
hover="bg-active" rounded px2
35+
@click="emit('select', child)"
36+
>
37+
<span v-if="child.meta && child.meta === selected?.meta" text-primary>(self)</span>
38+
<span v-else>{{ child.id }}</span>
39+
</button>
40+
41+
<button
42+
relative flex="~ gap-1 items-center"
43+
hover="bg-active" rounded
44+
@click="emit('select', child)"
45+
>
46+
<div
47+
h-5 rounded shadow border="~ base"
48+
:style="{
49+
background: colorToCssBackground(graph.options.getColor?.(child) || '#000'),
50+
width: `${child.size / selected.size * 100}%`,
51+
}"
52+
/>
53+
<DisplayFileSizeBadge text-xs :bytes="child.size" :total="selected.size" :percent-ratio="3" />
54+
<div
55+
v-if="child.children.length > 0"
56+
v-tooltip="`${child.children.length} dependencies`"
57+
:title="`${child.children.length} dependencies`"
58+
text-xs op-fade
59+
>
60+
({{ child.children.length }})
61+
</div>
62+
</button>
63+
</template>
64+
</div>
65+
</div>
66+
</div>
67+
</template>
Lines changed: 15 additions & 239 deletions
Original file line numberDiff line numberDiff line change
@@ -1,251 +1,27 @@
11
<script setup lang="ts">
2-
import type { Asset as AssetInfo } from '@rolldown/debug'
32
import type { GraphBase, GraphBaseOptions } from 'nanovis'
4-
import type { SessionContext } from '~~/shared/types'
53
import type { AssetChartInfo, AssetChartNode } from '~/types/assets'
6-
import { useRouter } from '#app/composables/router'
7-
import { useMouse } from '@vueuse/core'
8-
import { createColorGetterSpectrum, Treemap } from 'nanovis'
9-
import { computed, nextTick, onUnmounted, reactive, ref, shallowRef, watch } from 'vue'
10-
import { isDark } from '~/composables/dark'
11-
import { settings } from '~/state/settings'
12-
import { bytesToHumanSize } from '~/utils/format'
4+
import { useTemplateRef, watchEffect } from 'vue'
135
146
const props = defineProps<{
15-
assets: AssetInfo[]
16-
session: SessionContext
7+
graph: GraphBase<AssetChartInfo | undefined, GraphBaseOptions<AssetChartInfo | undefined>>
8+
selected?: AssetChartNode | undefined
179
}>()
1810
19-
const router = useRouter()
20-
const mouse = reactive(useMouse())
21-
const graph = shallowRef<GraphBase<AssetChartInfo | undefined, GraphBaseOptions<AssetChartInfo | undefined>> | undefined>(undefined)
22-
const nodeHover = shallowRef<AssetChartNode | undefined>(undefined)
23-
const nodeSelected = shallowRef<AssetChartNode | undefined>(undefined)
24-
const selectedNode = ref<AssetChartInfo | undefined>(undefined)
25-
let dispose: () => void | undefined
26-
27-
const tree = computed(() => {
28-
const assets = props.assets
29-
const map = new Map<string, AssetChartNode>()
30-
let maxDepth = 0
31-
32-
const root: AssetChartNode = {
33-
id: '~root',
34-
text: 'Project',
35-
size: 0,
36-
sizeSelf: 0,
37-
children: [],
38-
}
39-
40-
const macrosTasks: (() => void)[] = []
41-
42-
macrosTasks.unshift(() => {
43-
root.size += root.children.reduce((acc, i) => acc + i.size, 0)
44-
root.subtext = bytesToHumanSize(root.size).join(' ')
45-
root.children.sort((a, b) => b.size - a.size || a.id.localeCompare(b.id))
46-
})
47-
48-
function assetToNode(asset: AssetInfo, path: string, name: string, parent: AssetChartNode, depth: number): AssetChartNode {
49-
if (map.has(path)) {
50-
return map.get(path)!
51-
}
52-
53-
if (depth > maxDepth) {
54-
maxDepth = depth
55-
}
56-
57-
const node: AssetChartNode = {
58-
id: path,
59-
text: name,
60-
size: 0,
61-
sizeSelf: 0,
62-
children: [],
63-
meta: {
64-
chunk_id: 0,
65-
content: '',
66-
filename: '',
67-
size: 0,
68-
path: name,
69-
type: 'folder',
70-
},
71-
parent,
72-
}
73-
74-
map.set(path, node)
75-
parent.children.push(node)
76-
77-
macrosTasks.unshift(() => {
78-
const selfSize = node.sizeSelf
79-
node.size += node.children.reduce((acc, i) => acc + i.size, 0)
80-
node.subtext = bytesToHumanSize(node.size).join(' ')
81-
82-
if (node.children.length && selfSize / node.size > 0.1) {
83-
node.children.push({
84-
id: `${node.id}-self`,
85-
text: '',
86-
size: selfSize,
87-
sizeSelf: selfSize,
88-
subtext: bytesToHumanSize(selfSize).join(' '),
89-
children: [],
90-
meta: {
91-
...asset,
92-
path: '',
93-
type: 'file',
94-
},
95-
parent: node,
96-
})
97-
}
98-
99-
node.children.sort((a, b) => b.size - a.size || a.id.localeCompare(b.id))
100-
})
101-
102-
return node
103-
}
104-
105-
function processAsset(asset: AssetInfo) {
106-
const parts = asset.filename.split('/').filter(Boolean)
107-
let current = root
108-
let currentPath = ''
109-
let depth = 0
110-
111-
parts.forEach((part, index) => {
112-
currentPath += (currentPath ? '/' : '') + part
113-
depth++
114-
115-
if (index === parts.length - 1) {
116-
const fileNode: AssetChartNode = {
117-
id: asset.filename,
118-
text: part,
119-
size: asset.size,
120-
sizeSelf: asset.size,
121-
subtext: bytesToHumanSize(asset.size).join(' '),
122-
children: [],
123-
meta: {
124-
...asset,
125-
path: part,
126-
type: 'file',
127-
},
128-
}
129-
130-
current.children.push(fileNode)
131-
map.set(asset.filename, fileNode)
132-
}
133-
else {
134-
current = assetToNode(asset, currentPath, part, current, depth)
135-
}
136-
})
137-
}
138-
139-
assets.forEach(processAsset)
140-
141-
macrosTasks.forEach(fn => fn())
142-
143-
return {
144-
map,
145-
root,
146-
maxDepth,
147-
}
148-
})
149-
150-
const options = computed<GraphBaseOptions<AssetChartInfo | undefined>>(() => {
151-
return {
152-
onClick(node) {
153-
if (node)
154-
nodeHover.value = node
155-
if (node.meta?.type === 'file') {
156-
selectedNode.value = node.meta
157-
router.replace({ query: { asset: node.meta.filename } })
158-
}
159-
},
160-
onHover(node) {
161-
if (node)
162-
nodeHover.value = node
163-
},
164-
onLeave() {
165-
nodeHover.value = undefined
166-
},
167-
onSelect(node) {
168-
nodeSelected.value = node || tree.value.root
169-
selectedNode.value = node?.meta
170-
},
171-
animate: settings.value.chartAnimation,
172-
palette: {
173-
stroke: isDark.value ? '#222' : '#555',
174-
fg: isDark.value ? '#fff' : '#000',
175-
bg: isDark.value ? '#111' : '#fff',
176-
},
177-
getColor: createColorGetterSpectrum(
178-
tree.value.root,
179-
isDark.value ? 0.8 : 0.9,
180-
isDark.value ? 1 : 1.1,
181-
),
182-
getSubtext: (node) => {
183-
return node.subtext
184-
},
185-
}
186-
})
187-
188-
function selectNode(node: AssetChartNode | null, animate?: boolean) {
189-
selectedNode.value = node?.meta
190-
if (!node?.children.length)
191-
node = node?.parent || null
192-
graph.value?.select(node, animate)
193-
}
194-
195-
watch(() => [tree.value, options.value], () => {
196-
dispose?.()
197-
198-
nodeSelected.value = tree.value.root
199-
200-
if (tree.value?.root) {
201-
graph.value = new Treemap(tree.value.root, {
202-
...options.value,
203-
selectedPaddingRatio: 0,
204-
})
205-
}
206-
nextTick(() => {
207-
const selected = selectedNode.value ? tree.value.map.get(selectedNode.value.filename) || null : null
208-
if (selected)
209-
selectNode(selected, false)
210-
})
211-
212-
dispose = () => {
213-
graph.value?.dispose()
214-
graph.value = undefined
215-
}
216-
}, {
217-
deep: true,
218-
immediate: true,
219-
})
11+
const emit = defineEmits<{
12+
(e: 'select', node: AssetChartNode | null): void
13+
}>()
22014
221-
onUnmounted(() => {
222-
dispose?.()
223-
})
15+
const el = useTemplateRef<HTMLDivElement>('el')
16+
watchEffect(() => el.value?.append(props.graph.el))
22417
</script>
22518

22619
<template>
227-
<div p4>
228-
<ChartAssetTreemap
229-
v-if="graph"
230-
:graph="graph"
231-
:selected="nodeSelected"
232-
@select="x => selectNode(x)"
233-
/>
234-
</div>
235-
<div
236-
v-if="nodeHover?.meta"
237-
bg-glass fixed z-panel-nav border="~ base rounded" p2 text-sm
238-
flex="~ col gap-2"
239-
:style="{
240-
left: `${mouse.x + 10}px`,
241-
top: `${mouse.y + 10}px`,
242-
}"
243-
>
244-
<div flex="~ gap-1 items-center">
245-
{{ nodeHover.text }}
246-
</div>
247-
<div flex="~ gap-1 items-center">
248-
<DisplayFileSizeBadge :bytes="nodeHover.size" :percent="false" />
249-
</div>
250-
</div>
20+
<ChartAssetNavBreadcrumb
21+
border="b base" py2 min-h-10
22+
:selected="selected"
23+
:options="graph.options"
24+
@select="emit('select', $event)"
25+
/>
26+
<div ref="el" />
25127
</template>

packages/devtools/src/app/components/chart/AssetTreemap.vue

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)