-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* docs: migrate to v2 api * feat: add start and end line * feat: separate dependency arrow drawing
- Loading branch information
Showing
4 changed files
with
168 additions
and
37 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,7 +4,6 @@ | |
/// <reference lib="dom" /> | ||
|
||
import ForceGraph from "https://esm.sh/[email protected]" | ||
import { forceCluster } from "https://esm.sh/d3-force-cluster" | ||
|
||
// import ForceGraph2D from "https://esm.sh/[email protected]" | ||
// import ReactDom from "https://esm.sh/react-dom@17" | ||
|
@@ -18,8 +17,19 @@ const graphDom = document.querySelector("div#graph") | |
if (!graphDom) throw new Error("graph dom not found") | ||
|
||
const data = await fetch("./assets/data.json").then((res) => res.json()) | ||
const imports = Object.fromEntries( | ||
Object.entries(Object.groupBy(data.imports, (x) => x.source)) | ||
.map(( | ||
[k, v], | ||
) => [k, v.map((x) => data.nodes.find((node) => node.id === x.target))]), | ||
) | ||
|
||
let hoveredNode | ||
|
||
// console.log(imports) | ||
// ReactDOM.render() | ||
const ARROW_WH_RATIO = 1.6 | ||
const ARROW_VLEN_RATIO = 0.2 | ||
|
||
const graph = ForceGraph()(graphDom) | ||
.width(graphDom.clientWidth) | ||
|
@@ -29,10 +39,16 @@ const graph = ForceGraph()(graphDom) | |
.nodeAutoColorBy("path") | ||
.linkAutoColorBy("color") | ||
.nodeLabel("url") | ||
.linkDirectionalArrowLength(6) | ||
.linkWidth((link) => link.type === "import" ? 3 : 0.5) | ||
.linkDirectionalArrowLength(3) | ||
.linkWidth(0.5) | ||
.nodeCanvasObject((node, ctx, globalScale) => { | ||
const label = /**@type{string}*/ (node.id) | ||
ctx.globalAlpha = hoveredNode | ||
? ((hoveredNode === node || imports[hoveredNode.id]?.includes(node)) | ||
? 1 | ||
: 0.1) | ||
: 1 | ||
|
||
const label = /**@type{string}*/ (node.name) | ||
const fontSize = (node.type === "import" ? 20 : 16) / globalScale | ||
ctx.font = `${fontSize}px Sans-Serif` | ||
const textWidth = ctx.measureText(label).width | ||
|
@@ -80,6 +96,97 @@ const graph = ForceGraph()(graphDom) | |
node.bgWidth = bgWidth | ||
// @ts-ignore: to re-use in nodePointerAreaPaint | ||
node.bgHeight = bgHeight | ||
|
||
// if (hoveredNode === node) { | ||
{ | ||
ctx.save() | ||
ctx.globalAlpha = (hoveredNode === node) ? 1 : 0.25 | ||
ctx.lineWidth = (hoveredNode === node) ? 3 : 1 | ||
const arrowLength = (hoveredNode === node) ? 24 : 6 | ||
const arrowRelPos = 0.5 | ||
const arrowColor = node.color // "rgba(241, 21, 21, 0.521)" | ||
const arrowHalfWidth = arrowLength / ARROW_WH_RATIO / 2 | ||
|
||
imports[node.id]?.forEach((target) => { | ||
// draws line | ||
ctx.beginPath() | ||
ctx.moveTo( | ||
// @ts-ignore: node do has x and y but force-graph marks it optional | ||
node.x, | ||
// @ts-ignore: node do has x and y but force-graph marks it optional | ||
node.y, | ||
) | ||
ctx.lineTo( | ||
// @ts-ignore: node do has x and y but force-graph marks it optional | ||
target.x, | ||
// @ts-ignore: node do has x and y but force-graph marks it optional | ||
target.y, | ||
) | ||
ctx.strokeStyle = arrowColor | ||
ctx.stroke() | ||
|
||
// draws arrow | ||
|
||
const start = node | ||
const end = target | ||
|
||
if ( | ||
!start || !end || !start.hasOwnProperty("x") || | ||
!end.hasOwnProperty("x") | ||
) return // skip invalid link | ||
|
||
// Construct bezier for curved lines | ||
// const bzLine = link.__controlPoints && | ||
// new Bezier(start.x, start.y, ...link.__controlPoints, end.x, end.y) | ||
|
||
const getCoordsAlongLine = | ||
// bzLine | ||
// ? (t) => bzLine.get(t) // get position along bezier line | ||
// : | ||
(t) => ({ // straight line: interpolate linearly | ||
x: start.x + (end.x - start.x) * t || 0, | ||
y: start.y + (end.y - start.y) * t || 0, | ||
}) | ||
|
||
const lineLen = | ||
// bzLine | ||
// ? bzLine.length() | ||
// : | ||
Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)) | ||
|
||
const posAlongLine = 1 + arrowLength + | ||
(lineLen - 1 - 1 - arrowLength) * arrowRelPos | ||
|
||
const arrowHead = getCoordsAlongLine(posAlongLine / lineLen) | ||
const arrowTail = getCoordsAlongLine( | ||
(posAlongLine - arrowLength) / lineLen, | ||
) | ||
const arrowTailVertex = getCoordsAlongLine( | ||
(posAlongLine - arrowLength * (1 - ARROW_VLEN_RATIO)) / lineLen, | ||
) | ||
|
||
const arrowTailAngle = | ||
Math.atan2(arrowHead.y - arrowTail.y, arrowHead.x - arrowTail.x) - | ||
Math.PI / 2 | ||
|
||
ctx.beginPath() | ||
|
||
ctx.moveTo(arrowHead.x, arrowHead.y) | ||
ctx.lineTo( | ||
arrowTail.x + arrowHalfWidth * Math.cos(arrowTailAngle), | ||
arrowTail.y + arrowHalfWidth * Math.sin(arrowTailAngle), | ||
) | ||
ctx.lineTo(arrowTailVertex.x, arrowTailVertex.y) | ||
ctx.lineTo( | ||
arrowTail.x - arrowHalfWidth * Math.cos(arrowTailAngle), | ||
arrowTail.y - arrowHalfWidth * Math.sin(arrowTailAngle), | ||
) | ||
|
||
ctx.fillStyle = arrowColor | ||
ctx.fill() | ||
}) | ||
ctx.restore() | ||
} | ||
}) | ||
.nodePointerAreaPaint((node, color, ctx) => { | ||
ctx.fillStyle = color | ||
|
@@ -96,8 +203,16 @@ const graph = ForceGraph()(graphDom) | |
}) | ||
// @ts-ignore: node has url but force-graph lacks generics to know it | ||
.onNodeClick((node) => globalThis.open(node.url)) | ||
.onNodeHover((node) => { | ||
hoveredNode = node | ||
}) | ||
.autoPauseRedraw(false) | ||
|
||
// graph.d3Force("link")?.distance(60) | ||
graph.d3Force("link")?.distance(90) | ||
// graph.d3Force("link")?.strength(link => { | ||
// console.log(link) | ||
// return link.type === "import" ? 1 : 0 | ||
// }) | ||
// const clusters = Object.groupBy(data.nodes, (n) => n.dir) | ||
// console.log(clusters) | ||
// graph.d3Force('center', ) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,15 @@ | ||
import { relative } from "https://deno.land/[email protected]/path/relative.ts" | ||
import { dirname } from "https://deno.land/[email protected]/path/dirname.ts" | ||
|
||
import { Stream } from "https://deno.land/x/[email protected]/stream/mod.ts" | ||
import { HashSet } from "https://deno.land/x/[email protected]/hashed/mod.ts" | ||
|
||
import { Project } from "../deps/ts_morph.ts" | ||
import { StackGraph } from "../graph/fluent.ts" | ||
|
||
import { colors, hashRGB } from "../render/colors.ts" | ||
import { denoProjectOption } from "../utils/project.ts" | ||
import { ancestors } from "./ancestors.ts" | ||
import { HashSet } from "https://deno.land/x/[email protected]/hashed/mod.ts" | ||
import { declDepsToGraph, getAllDecls, getDeclDeps } from "../graph/mod.ts" | ||
import { encodeVSCodeURI } from "../graph/vscode_uri.ts" | ||
|
||
/** | ||
* Generate dependency map of **StackGraph** itself | ||
|
@@ -22,30 +23,35 @@ export type Link = { | |
color: string | ||
type: Type | ||
} | ||
type RawNode = Pick<Node, "path" | "id" | "type"> | ||
type RawNode = Pick<Node, "line" | "name" | "path" | "id" | "type"> | ||
export type Node = { | ||
id: string | ||
name: string | ||
color: string | ||
textColor: string | ||
path: string | ||
dir: string | ||
url: string | ||
line: string | ||
type: Type | ||
} | ||
|
||
const linkNode = <const T extends { id: string; path: string }>(node: T) => { | ||
const linkNode = <const T extends { id: string; path: string; line: string }>( | ||
node: T, | ||
) => { | ||
const dir = dirname(node.path) | ||
|
||
return { | ||
...node, | ||
dir, | ||
url: `https://github.com/daangn/stackgraph/tree/main/${node.path}`, | ||
url: | ||
`https://github.com/daangn/stackgraph/tree/main/${node.path}${node.line}`, | ||
} | ||
} | ||
|
||
const colorNode = <const T extends { dir: string }>(node: T) => ({ | ||
const colorNode = <const T extends { path: string }>(node: T) => ({ | ||
...node, | ||
...colors(hashRGB(node.dir)), | ||
...colors(hashRGB(node.path)), | ||
}) | ||
|
||
const distinctById = HashSet.createContext<RawNode>({ | ||
|
@@ -54,13 +60,17 @@ const distinctById = HashSet.createContext<RawNode>({ | |
|
||
if (import.meta.main) { | ||
const project = new Project(denoProjectOption) | ||
|
||
const root = import.meta.dirname + "/../" | ||
const files = project.addSourceFilesAtPaths( | ||
import.meta.dirname + "/../**/*.ts", | ||
import.meta.dirname + "/../graph/**/*_test.ts", | ||
) | ||
|
||
const stackgraph = StackGraph.searchAll(files) | ||
const links: Link[] = stackgraph.graph.streamConnections() | ||
const decls = Stream.fromObjectValues(files).flatMap(getAllDecls).toArray() | ||
const declDeps = getDeclDeps(decls) | ||
const graph = declDepsToGraph(declDeps) | ||
|
||
const links: Link[] = graph.streamConnections() | ||
.map(([source, target]) => ({ | ||
source, | ||
target, | ||
|
@@ -69,30 +79,31 @@ if (import.meta.main) { | |
} as const)) | ||
.toArray() | ||
|
||
const nodes: RawNode[] = Array.from(stackgraph.depsMap.keys()) | ||
.map((node) => ({ | ||
id: node.getName()!, | ||
path: relative(root, node.getSourceFile().getFilePath()), | ||
type: "import" as const, | ||
})) | ||
const nodes: RawNode[] = Array.from(declDeps.keys()) | ||
.map((node) => { | ||
const srcfile = node.getSourceFile() | ||
const begin = node.getStartLineNumber() | ||
const end = node.getEndLineNumber() | ||
|
||
return { | ||
id: encodeVSCodeURI(node), | ||
name: node.getName()!, | ||
path: relative(root, srcfile.getFilePath()), | ||
line: `#L${begin}-L${end}`, | ||
type: "import" as const, | ||
} | ||
}) | ||
|
||
const dirLinks = Stream.from(nodes) | ||
.stream() | ||
.flatMap((node) => { | ||
// console.log( | ||
// Stream.from(ancestors(node.path)).append(node.id).window( | ||
// 2, | ||
// { skipAmount: 1 }, | ||
// ).toArray(), | ||
// ) | ||
const newLocal = Stream.from(ancestors(node.path)).append(node.id).window( | ||
2, | ||
{ skipAmount: 1 }, | ||
) | ||
const links = Stream.from(ancestors(node.path)).append(node.id) | ||
.window(2, { skipAmount: 1 }) | ||
.map(([target, source]) => | ||
({ source, target, color: "#dacaca", type: "path" }) as const | ||
) | ||
return newLocal | ||
|
||
return links | ||
}) | ||
.reduce(HashSet.reducer()) | ||
.toArray() | ||
|
@@ -101,7 +112,9 @@ if (import.meta.main) { | |
.flatMap((node) => | ||
ancestors(node.path).map((path) => ({ | ||
id: path, | ||
name: path, | ||
path, | ||
line: "", | ||
type: "path" as const, | ||
color: "#bdbbbb48", | ||
textColor: "#00000080", | ||
|
@@ -114,12 +127,15 @@ if (import.meta.main) { | |
import.meta.dirname + "/assets/data.json", | ||
JSON.stringify( | ||
{ | ||
links: Stream.from(links, dirLinks).toArray(), | ||
links: dirLinks, | ||
imports: links, | ||
// links: Stream.from(links, dirLinks).toArray(), | ||
nodes: [ | ||
...nodes.map(linkNode).map(colorNode), | ||
...dirNodes.map(linkNode), | ||
], | ||
} satisfies { links: Link[]; nodes: Node[] }, | ||
}, | ||
// satisfies { links: Link[]; nodes: Node[] }, | ||
null, | ||
2, | ||
), | ||
|