Skip to content

Commit 61ed00c

Browse files
authored
feat: module imports relationships (#11)
1 parent e0838a0 commit 61ed00c

File tree

1 file changed

+283
-4
lines changed

1 file changed

+283
-4
lines changed
Lines changed: 283 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,293 @@
11
<script setup lang="ts">
2-
import type { ModuleInfo, SessionContext } from '~~/shared/types'
2+
import type { HierarchyLink, HierarchyNode } from 'd3-hierarchy'
3+
import type { ModuleImport, ModuleInfo, ModuleListItem, SessionContext } from '~~/shared/types'
4+
import { hierarchy, tree } from 'd3-hierarchy'
5+
import { linkHorizontal, linkVertical } from 'd3-shape'
6+
import { computed, nextTick, onMounted, ref, shallowReactive, shallowRef, useTemplateRef, watch } from 'vue'
37
4-
defineProps<{
8+
const props = defineProps<{
59
module: ModuleInfo
610
session: SessionContext
711
}>()
12+
13+
interface Node {
14+
module: ModuleListItem
15+
import?: ModuleImport
16+
}
17+
18+
type Link = HierarchyLink<Node> & {
19+
id: string
20+
import?: ModuleImport
21+
}
22+
23+
const SPACING = {
24+
width: 400,
25+
height: 55,
26+
linkOffset: 20,
27+
margin: 300,
28+
gap: 60,
29+
}
30+
31+
const container = useTemplateRef<HTMLDivElement>('container')
32+
const width = ref(window.innerWidth)
33+
const height = ref(window.innerHeight)
34+
const nodes = shallowRef<HierarchyNode<Node>[]>([])
35+
const links = shallowRef<Link[]>([])
36+
const nodesRefMap = shallowReactive(new Map<string, HTMLDivElement>())
37+
38+
const modulesMap = computed(() => {
39+
const map = new Map<string, ModuleListItem>()
40+
for (const module of props.session.modulesList) {
41+
map.set(module.id, module)
42+
}
43+
return map
44+
})
45+
46+
const createLinkHorizontal = linkHorizontal()
47+
.x(d => d[0])
48+
.y(d => d[1])
49+
50+
const createLinkVertical = linkVertical()
51+
.x(d => d[0])
52+
.y(d => d[1])
53+
54+
function generateLink(link: Link) {
55+
if (link.target.x! <= link.source.x!) {
56+
return createLinkVertical({
57+
source: [link.source.x! + SPACING.width / 2 - SPACING.linkOffset, link.source.y!],
58+
target: [link.target.x! - SPACING.width / 2 + SPACING.linkOffset, link.target.y!],
59+
})
60+
}
61+
return createLinkHorizontal({
62+
source: [link.source.x! + SPACING.width / 2 - SPACING.linkOffset, link.source.y!],
63+
target: [link.target.x! - SPACING.width / 2 + SPACING.linkOffset, link.target.y!],
64+
})
65+
}
66+
67+
function getLinkColor(_link: Link) {
68+
return 'stroke-#8882'
69+
}
70+
71+
function calculateGraph() {
72+
// Unset the canvas size, and recalculate again after nodes are rendered
73+
width.value = window.innerWidth
74+
height.value = window.innerHeight
75+
const seen = new Set<ModuleListItem>()
76+
77+
// build imports graph
78+
const importsRoot = hierarchy<Node>(
79+
{ module: { id: '~root' } } as any,
80+
(parent) => {
81+
if (parent.module.id === '~root') {
82+
const module = modulesMap.value.get(props.module.id)!
83+
return [{ module }]
84+
}
85+
else if (parent.module.id === props.module.id) {
86+
const modules = parent.module.imports
87+
.map((x): Node | undefined => {
88+
const module = modulesMap.value.get(x.module_id)!
89+
if (!module)
90+
return undefined
91+
if (seen.has(module))
92+
return undefined
93+
94+
seen.add(module)
95+
return {
96+
module,
97+
}
98+
})
99+
.filter(x => x !== undefined)
100+
return [...modules]
101+
}
102+
},
103+
)
104+
105+
const layout = tree<Node>()
106+
.nodeSize([SPACING.height, SPACING.width + SPACING.gap])
107+
layout(importsRoot)
108+
109+
// Rotate the graph from top-down to left-right
110+
const _importsNodes = importsRoot.descendants()
111+
for (const node of _importsNodes) {
112+
[node.x, node.y] = [node.y! - SPACING.width, node.x!]
113+
}
114+
115+
// Offset the graph and adding margin
116+
const minX = Math.min(..._importsNodes.map(n => n.x!))
117+
const minY = Math.min(..._importsNodes.map(n => n.y!))
118+
if (minX < SPACING.margin) {
119+
for (const node of _importsNodes) {
120+
node.x! += Math.abs(minX) + SPACING.margin
121+
}
122+
}
123+
if (minY < SPACING.margin) {
124+
for (const node of _importsNodes) {
125+
node.y! += Math.abs(minY) + SPACING.margin
126+
}
127+
}
128+
129+
const _importsLinks = importsRoot.links()
130+
.filter(x => x.source.data.module.id !== '~root')
131+
.map((x): Link => {
132+
return {
133+
...x,
134+
import: x.source.data.import,
135+
id: `${x.source.data.module.id}|${x.target.data.module.id}`,
136+
}
137+
})
138+
139+
// build importers graph
140+
const importersRoot = hierarchy<Node>(
141+
{ module: { id: '~root' } } as any,
142+
(parent) => {
143+
if (parent.module.id === '~root') {
144+
const module = modulesMap.value.get(props.module.id)!
145+
if (seen.has(module))
146+
return undefined
147+
seen.add(module)
148+
return [{ module }]
149+
}
150+
else if (parent.module.id === props.module.id) {
151+
const modules = parent.module.importers
152+
.map((x): Node | undefined => {
153+
const module = modulesMap.value.get(x)!
154+
if (!module)
155+
return undefined
156+
if (seen.has(module))
157+
return undefined
158+
159+
seen.add(module)
160+
return {
161+
module,
162+
}
163+
})
164+
.filter(x => x !== undefined)
165+
return modules
166+
}
167+
},
168+
)
169+
170+
layout(importersRoot)
171+
172+
const _importersNodes = importersRoot.descendants()
173+
for (const node of _importersNodes) {
174+
if (props.module.importers?.includes(node.data.module.id)) {
175+
[node.x, node.y] = [-(SPACING.width + SPACING.gap), node.x!]
176+
}
177+
else {
178+
[node.x, node.y] = [node.y! - SPACING.width, node.x!]
179+
}
180+
}
181+
182+
const rootNode = _importsNodes.find(n => n.data.module.id === props.module.id)!
183+
_importersNodes.forEach((n) => {
184+
if (n.data.module.id === props.module.id) {
185+
n.x = rootNode!.x
186+
n.y = rootNode!.y
187+
}
188+
else {
189+
n.x = rootNode.x! + n.x!
190+
n.y = rootNode.y! + n.y!
191+
}
192+
})
193+
194+
const _importersLinks = importersRoot.links()
195+
.filter(x => x.source.data.module.id !== '~root')
196+
.map((x): Link => {
197+
return {
198+
...x,
199+
source: {
200+
...x.source,
201+
x: x.source.x! - (SPACING.width) + SPACING.linkOffset,
202+
y: x.source.y!,
203+
} as HierarchyNode<Node>,
204+
target: {
205+
...x.target,
206+
x: x.target.x! + SPACING.width - SPACING.linkOffset,
207+
} as HierarchyNode<Node>,
208+
import: x.source.data.import,
209+
id: `${x.source.data.module.id}|${x.target.data.module.id}`,
210+
}
211+
})
212+
213+
// deduplicate modules
214+
nodes.value = [..._importsNodes, ..._importersNodes].filter((n, i, s) =>
215+
i === s.findIndex(t => t.data.module.id === n.data.module.id),
216+
)
217+
links.value = [..._importsLinks, ..._importersLinks]
218+
219+
nextTick(() => {
220+
width.value = (container.value!.scrollWidth + SPACING.margin)
221+
height.value = (container.value!.scrollHeight + SPACING.margin)
222+
focusOn(props.module.id, false)
223+
})
224+
}
225+
226+
function focusOn(id: string, animated = true) {
227+
const el = nodesRefMap.get(id)
228+
el?.scrollIntoView({
229+
block: 'center',
230+
inline: 'center',
231+
behavior: animated ? 'smooth' : 'instant',
232+
})
233+
}
234+
235+
onMounted(() => {
236+
watch(
237+
() => [props.module],
238+
calculateGraph,
239+
{ immediate: true },
240+
)
241+
})
8242
</script>
9243

10244
<template>
11-
<div>
12-
Module Imports Relationships (TODO)
245+
<div
246+
ref="container"
247+
w-full min-h-full relative select-none of-auto
248+
>
249+
<div
250+
flex="~ items-center justify-center"
251+
>
252+
<svg pointer-events-none absolute left-0 top-0 z-graph-link :width="width" :height="height">
253+
<g>
254+
<path
255+
v-for="link of links"
256+
:key="link.id"
257+
:d="generateLink(link)!"
258+
:class="getLinkColor(link)"
259+
:stroke-dasharray="link.import?.kind === 'dynamic-import' ? '3 6' : undefined"
260+
fill="none"
261+
/>
262+
</g>
263+
</svg>
264+
<template
265+
v-for="node of nodes"
266+
:key="node.data.module.id"
267+
>
268+
<template v-if="node.data.module.id !== '~root'">
269+
<DisplayModuleId
270+
:id="node.data.module.id"
271+
:ref="(el: any) => nodesRefMap.set(node.data.module.id, el?.$el)"
272+
absolute hover="bg-active" block px2 p1 bg-glass
273+
z-graph-node
274+
border="~ base rounded"
275+
:link="true"
276+
:session="session"
277+
:pkg="node.data.module"
278+
:minimal="true"
279+
:style="{
280+
left: `${node.x}px`,
281+
top: `${node.y}px`,
282+
minWidth: `${SPACING.width}px`,
283+
transform: 'translate(-50%, -50%)',
284+
maxWidth: '400px',
285+
maxHeight: '50px',
286+
overflow: 'hidden',
287+
}"
288+
/>
289+
</template>
290+
</template>
291+
</div>
13292
</div>
14293
</template>

0 commit comments

Comments
 (0)