Skip to content

Commit 602a94a

Browse files
committed
feat: graph view
1 parent 3ee675c commit 602a94a

File tree

10 files changed

+290
-20
lines changed

10 files changed

+290
-20
lines changed

packages/devtools/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"ansis": "catalog:deps",
3838
"birpc": "catalog:deps",
3939
"cac": "catalog:deps",
40+
"d3-shape": "catalog:frontend",
4041
"diff": "catalog:deps",
4142
"get-port-please": "catalog:deps",
4243
"h3": "catalog:deps",
@@ -67,7 +68,6 @@
6768
"comlink": "catalog:frontend",
6869
"d3": "catalog:frontend",
6970
"d3-hierarchy": "catalog:frontend",
70-
"d3-shape": "catalog:frontend",
7171
"diff-match-patch-es": "catalog:frontend",
7272
"floating-vue": "catalog:frontend",
7373
"fuse.js": "catalog:frontend",

packages/devtools/src/app/components/display/HighlightedPath.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ export default defineComponent({
99
type: String,
1010
required: true,
1111
},
12+
minimal: {
13+
type: Boolean,
14+
default: false,
15+
},
1216
},
1317
setup(props) {
1418
return () => {
1519
const parts = props.path.split(/([?/&:=])/g)
20+
1621
let type: 'path' | 'query' = 'path'
1722

1823
const classes: string[][] = parts.map(() => [])
@@ -38,6 +43,15 @@ export default defineComponent({
3843
classes[index].push('op60')
3944
}
4045

46+
// If the path is minimal, remove all the parts before the node_modules
47+
if (part === 'node_modules') {
48+
if (props.minimal) {
49+
for (let i = 0; i < index + 2; i++) {
50+
removeIndexes.add(i)
51+
}
52+
}
53+
}
54+
4155
if (part === '.pnpm') {
4256
classes[index]?.push('op50')
4357
if (nodes[index])

packages/devtools/src/app/components/display/ModuleId.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const props = withDefaults(
1111
badges?: boolean
1212
icon?: boolean
1313
link?: boolean
14+
minimal?: boolean
1415
session: SessionContext
1516
}>(),
1617
{
@@ -55,7 +56,7 @@ const containerClass = computed(() => {
5556
>
5657
<DisplayFileIcon v-if="icon" :filename="id" mr1.5 />
5758
<span>
58-
<DisplayHighlightedPath :path="relativePath" />
59+
<DisplayHighlightedPath :path="relativePath" :minimal="minimal" />
5960
</span>
6061
<slot />
6162
<!-- <DisplayBadge

packages/devtools/src/app/components/modules/list.vue renamed to packages/devtools/src/app/components/modules/FlatList.vue

File renamed without changes.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
<script setup lang="ts">
2+
import type { HierarchyLink, HierarchyNode } from 'd3-hierarchy'
3+
import type { ModuleListItem, SessionContext } from '../../types/data'
4+
import { hierarchy, tree } from 'd3-hierarchy'
5+
import { linkHorizontal, linkVertical } from 'd3-shape'
6+
import { computed, nextTick, onMounted, reactive, ref, shallowReactive, shallowRef, useTemplateRef, watch } from 'vue'
7+
8+
const props = defineProps<{
9+
session: SessionContext
10+
modules: ModuleListItem[]
11+
}>()
12+
13+
type Link = HierarchyLink<ModuleListItem> & {
14+
id: string
15+
}
16+
17+
const graphRender = ref<'normal' | 'mini'>('normal')
18+
19+
const SPACING = reactive({
20+
width: computed(() => graphRender.value === 'normal' ? 400 : 10),
21+
height: computed(() => graphRender.value === 'normal' ? 55 : 20),
22+
linkOffset: computed(() => graphRender.value === 'normal' ? 20 : 0),
23+
margin: computed(() => 800),
24+
gap: computed(() => graphRender.value === 'normal' ? 150 : 100),
25+
})
26+
27+
const container = useTemplateRef<HTMLDivElement>('container')
28+
const isGrabbing = ref(false)
29+
const width = ref(window.innerWidth)
30+
const height = ref(window.innerHeight)
31+
const scale = ref(1)
32+
const nodesRefMap = shallowReactive(new Map<string, HTMLDivElement>())
33+
34+
const nodes = shallowRef<HierarchyNode<ModuleListItem>[]>([])
35+
const links = shallowRef<Link[]>([])
36+
const nodesMap = shallowReactive(new Map<string, HierarchyNode<ModuleListItem>>())
37+
const linksMap = shallowReactive(new Map<string, Link>())
38+
39+
const modulesMap = computed(() => {
40+
const map = new Map<string, ModuleListItem>()
41+
for (const module of props.modules) {
42+
map.set(module.id, module)
43+
}
44+
return map
45+
})
46+
47+
const rootModules = computed(() => {
48+
return props.modules.filter(x => x.importers.length === 0)
49+
})
50+
51+
const createLinkHorizontal = linkHorizontal()
52+
.x(d => d[0])
53+
.y(d => d[1])
54+
55+
const createLinkVertical = linkVertical()
56+
.x(d => d[0])
57+
.y(d => d[1])
58+
59+
function calculateGraph() {
60+
// Unset the canvas size, and recalculate again after nodes are rendered
61+
width.value = window.innerWidth
62+
height.value = window.innerHeight
63+
64+
const seen = new Set<ModuleListItem>()
65+
const root = hierarchy<ModuleListItem>(
66+
{ id: '~root' } as any,
67+
(node) => {
68+
if (node.id === '~root') {
69+
rootModules.value.forEach(x => seen.add(x))
70+
return rootModules.value
71+
}
72+
const modules = node.imports.map((x) => {
73+
const module = modulesMap.value.get(x)
74+
if (module) {
75+
if (seen.has(module)) {
76+
return undefined
77+
}
78+
seen.add(module)
79+
}
80+
return module
81+
}).filter(x => x !== undefined)
82+
return modules
83+
},
84+
)
85+
86+
// Calculate the layout
87+
const layout = tree<ModuleListItem>()
88+
.nodeSize([SPACING.height, SPACING.width + SPACING.gap])
89+
layout(root)
90+
91+
// Rotate the graph from top-down to left-right
92+
const _nodes = root.descendants()
93+
for (const node of _nodes) {
94+
[node.x, node.y] = [node.y! - SPACING.width, node.x!]
95+
}
96+
97+
// Offset the graph and adding margin
98+
const minX = Math.min(..._nodes.map(n => n.x!))
99+
const minY = Math.min(..._nodes.map(n => n.y!))
100+
if (minX < SPACING.margin) {
101+
for (const node of _nodes) {
102+
node.x! += Math.abs(minX) + SPACING.margin
103+
}
104+
}
105+
if (minY < SPACING.margin) {
106+
for (const node of _nodes) {
107+
node.y! += Math.abs(minY) + SPACING.margin
108+
}
109+
}
110+
111+
nodes.value = _nodes
112+
nodesMap.clear()
113+
for (const node of _nodes) {
114+
nodesMap.set(node.data.id, node)
115+
}
116+
const _links = root.links()
117+
.filter(x => x.source.data.id !== '~root')
118+
.map((x) => {
119+
return {
120+
...x,
121+
id: `${x.source.data.id}|${x.target.data.id}`,
122+
}
123+
})
124+
linksMap.clear()
125+
for (const link of _links) {
126+
linksMap.set(link.id, link)
127+
}
128+
links.value = _links
129+
130+
nextTick(() => {
131+
width.value = (container.value!.scrollWidth / scale.value + SPACING.margin)
132+
height.value = (container.value!.scrollHeight / scale.value + SPACING.margin)
133+
focusOn(rootModules.value[0].id, false)
134+
})
135+
}
136+
137+
function focusOn(id: string, animated = true) {
138+
const el = nodesRefMap.get(id)
139+
el?.scrollIntoView({
140+
block: 'center',
141+
inline: 'center',
142+
behavior: animated ? 'smooth' : 'instant',
143+
})
144+
}
145+
146+
function generateLink(link: Link) {
147+
if (link.target.x! <= link.source.x!) {
148+
return createLinkVertical({
149+
source: [link.source.x! + SPACING.width / 2 - SPACING.linkOffset, link.source.y!],
150+
target: [link.target.x! - SPACING.width / 2 + SPACING.linkOffset, link.target.y!],
151+
})
152+
}
153+
return createLinkHorizontal({
154+
source: [link.source.x! + SPACING.width / 2 - SPACING.linkOffset, link.source.y!],
155+
target: [link.target.x! - SPACING.width / 2 + SPACING.linkOffset, link.target.y!],
156+
})
157+
}
158+
159+
function getLinkColor(_link: Link) {
160+
return 'stroke-#8882'
161+
}
162+
163+
onMounted(() => {
164+
watch(
165+
() => [props.modules, graphRender.value],
166+
calculateGraph,
167+
{ immediate: true },
168+
)
169+
})
170+
</script>
171+
172+
<template>
173+
<div
174+
ref="container"
175+
w-screen h-screen of-scroll relative select-none
176+
:class="isGrabbing ? 'cursor-grabbing' : ''"
177+
>
178+
<svg pointer-events-none absolute left-0 top-0 z-graph-link :width="width" :height="height">
179+
<g>
180+
<path
181+
v-for="link of links"
182+
:key="link.id"
183+
:d="generateLink(link)!"
184+
:class="getLinkColor(link)"
185+
fill="none"
186+
/>
187+
</g>
188+
</svg>
189+
<!-- <svg pointer-events-none absolute left-0 top-0 z-graph-link-active :width="width" :height="height">
190+
<g>
191+
<path
192+
v-for="link of links"
193+
:key="link.id"
194+
:d="generateLink(link)!"
195+
fill="none"
196+
class="stroke-primary:75"
197+
/>
198+
</g>
199+
</svg> -->
200+
<template
201+
v-for="node of nodes"
202+
:key="node.data.id"
203+
>
204+
<template v-if="node.data.id !== '~root'">
205+
<DisplayModuleId
206+
:id="node.data.id"
207+
:ref="(el: any) => nodesRefMap.set(node.data.id, el?.$el)"
208+
absolute hover="bg-active" block px2 p1 bg-glass z-graph-node
209+
border="~ base rounded"
210+
:link="true"
211+
:session="session"
212+
:pkg="node.data"
213+
:minimal="true"
214+
:style="{
215+
left: `${node.x}px`,
216+
top: `${node.y}px`,
217+
minWidth: graphRender === 'normal' ? `${SPACING.width}px` : undefined,
218+
transform: 'translate(-50%, -50%)',
219+
maxWidth: '400px',
220+
maxHeight: '50px',
221+
overflow: 'hidden',
222+
}"
223+
/>
224+
</template>
225+
</template>
226+
</div>
227+
</template>

packages/devtools/src/app/pages/session/[session].vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ onMounted(async () => {
2323
session.modulesList = summary.modules.map(mod => ({
2424
id: mod.id,
2525
fileType: getFileTypeFromName(mod.id).name,
26+
imports: mod.imports ?? [],
27+
importers: mod.importers ?? [],
2628
}))
2729
})
2830
</script>

packages/devtools/src/app/pages/session/[session]/index.vue

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from '#app/composables/router'
44
import { clearUndefined } from '@antfu/utils'
55
import { computedWithControl, debouncedWatch } from '@vueuse/core'
66
import Fuse from 'fuse.js'
7-
import { computed, reactive } from 'vue'
7+
import { computed, reactive, ref } from 'vue'
88
import { parseReadablePath } from '../../../utils/filepath'
99
import { getFileTypeFromModuleId, getFileTypeFromName } from '../../../utils/icon'
1010
@@ -117,25 +117,39 @@ const searched = computed(() => {
117117
.search(filters.search)
118118
.map(r => r.item)
119119
})
120+
121+
const display = ref<'list' | 'graph'>('list')
120122
</script>
121123

122124
<template>
123125
<div flex="~ col gap-2" p4>
124-
<div flex="col gap-2">
125-
<div>
126+
<div h-20 />
127+
<div flex="col gap-2" right-4 top-4 border="~ base rounded-xl" p2 bg-glass fixed z-panel-nav>
128+
<button
129+
btn-action
130+
@click="display = display === 'list' ? 'graph' : 'list'"
131+
>
132+
<div v-if="display === 'graph'" i-ph-graph-duotone />
133+
<div v-else i-ph-list-duotone />
134+
{{ display === 'list' ? 'List' : 'Graph' }}
135+
</button>
136+
</div>
137+
<div flex="col gap-2" left-4 top-4 border="~ base rounded-xl" bg-glass fixed z-panel-nav>
138+
<div border="b base">
126139
<input
127140
v-model="filters.search"
128-
border="~ base rounded-full"
129-
p2 px4 w-full outline-none
141+
p2 px4 w-full
142+
style="outline: none"
130143
placeholder="Search"
131144
>
132145
</div>
133-
<div flex="~ gap-2" py2>
146+
<div flex="~ gap-2" p2>
134147
<label
135148
v-for="type of allFileTypes"
136149
:key="type"
137-
border="~ base rounded" px2 py1
150+
border="~ base rounded-md" px2 py1
138151
flex="~ items-center gap-1"
152+
select-none
139153
:title="type"
140154
:class="isFileTypeSelected(type) ? 'bg-active' : 'grayscale op50'"
141155
>
@@ -145,19 +159,28 @@ const searched = computed(() => {
145159
mr1
146160
@change="toggleFileType(type)"
147161
>
148-
<div :class="getFileTypeFromName(type).icon" />
162+
<div :class="getFileTypeFromName(type).icon" icon-catppuccin />
149163
<div text-sm>{{ getFileTypeFromName(type).description }}</div>
150164
</label>
151165
</div>
152166
<!-- TODO: should we add filters for node_modules? -->
153167
<!-- {{ allNodeModules }} -->
154168
</div>
155-
<ModulesList
156-
:session="session"
157-
:modules="searched"
158-
/>
159-
<div text-center text-xs op50 m4>
160-
{{ filtered.length }} of {{ session.modulesList.length }}
161-
</div>
169+
<template v-if="display === 'list'">
170+
<ModulesFlatList
171+
v-if="display === 'list'"
172+
:session="session"
173+
:modules="searched"
174+
/>
175+
<div text-center text-xs op50 m4>
176+
{{ filtered.length }} of {{ session.modulesList.length }}
177+
</div>
178+
</template>
179+
<template v-else>
180+
<ModulesGraph
181+
:session="session"
182+
:modules="searched"
183+
/>
184+
</template>
162185
</div>
163186
</template>

0 commit comments

Comments
 (0)