ts/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
import {
select,
selectAll,
} from "https://cdn.skypack.dev/d3-selection@v3.0.0?dts";
import { Transition } from "https://cdn.skypack.dev/d3-transition@v3.0.1?dts";
import { D3ZoomEvent, zoom } from "https://cdn.skypack.dev/d3-zoom@v3.0.0?dts";
import { D3DragEvent, drag } from "https://cdn.skypack.dev/d3-drag@v3.0.0?dts";
import {
forceCenter,
forceLink,
forceManyBody,
forceSimulation,
Simulation,
SimulationLinkDatum,
SimulationNodeDatum,
} from "https://cdn.skypack.dev/d3-force@v3.0.0?dts";
data.links
にはd3.forceLinkによって破壊的変更が加わるhttps://github.com/DefinitelyTyped/DefinitelyTyped/blob/0e5df2fa47f66db88084a3cd1642a33ecdb2dd3e/types/d3-force/index.d.ts#L65-L68 ので注意tsinterface Node extends SimulationNodeDatum {
/** ページタイトル */
id: string;
}
type Link = SimulationLinkDatum<Node>;
declare const data: {
nodes: Node[];
links: Link[];
};
tsdeclare const scale: number;
declare const isHome: boolean;
const container = document.getElementById("graph-container")!;
const height = Math.max(container.offsetHeight, isHome ? 500 : 250);
const width = container.offsetWidth;
const svg = select(container)
.append("svg")
.attr("width", width)
.attr("height", height)
.attr(
"viewBox",
//@ts-ignore githubで閲覧すると、配列も指定できるようになっている
[
-width / 2 * 1 / scale,
-height / 2 * 1 / scale,
width * 1 / scale,
height * 1 / scale,
],
);
tsdeclare const pathColors: Record<string, string>[];
declare const curPage: string;
const color = (d: Node) => {
if (d.id === curPage || (d.id === "/" && curPage === "")) {
return "var(--g-node-active)";
}
for (const pathColor of pathColors) {
const path = Object.keys(pathColor)[0];
const colour = pathColor[path];
if (d.id.startsWith(path)) {
return colour;
}
}
return "var(--g-node)";
};
tsdeclare const enableLegend: boolean;
if (enableLegend) {
const legend = [{ Current: "var(--g-node-active)" }, {
Note: "var(--g-node)",
}, ...pathColors];
legend.forEach((legendEntry, i) => {
const key = Object.keys(legendEntry)[0];
const colour = legendEntry[key];
svg
.append("circle")
.attr("cx", -width / 2 + 20)
.attr("cy", height / 2 - 30 * (i + 1))
.attr("r", 6)
.style("fill", colour);
svg
.append("text")
.attr("x", -width / 2 + 40)
.attr("y", height / 2 - 30 * (i + 1))
.text(key)
.style("font-size", "15px")
.attr("alignment-baseline", "middle");
});
}
ts// draw links between nodes
const link = svg
.append("g")
.selectAll("line")
.data(data.links)
.join("line")
.attr("class", "link")
.attr("stroke", "var(--g-link)")
.attr("stroke-width", 2)
// After initialization, d.source and d.target are converted to Node
.attr("data-source", (d) => (d.source as Node).id)
.attr("data-target", (d) => (d.target as Node).id);
tsdeclare const index: {
links: Record<string, Node[]>;
backlinks: Record<string, Node[]>;
};
// calculate radius
const nodeRadius = (d: Node) => {
const numOut = index.links[d.id]?.length ?? 0;
const numIn = index.backlinks[d.id]?.length ?? 0;
return 2 + Math.sqrt(numOut + numIn);
};
tsdeclare const repelForce: number;
const simulation = forceSimulation<Node, Link>(data.nodes)
.force(
"link",
forceLink<Node, Link>(data.links)
.id((d) => d.id)
.distance(50),
)
.force("charge", forceManyBody().strength(-100 * repelForce))
.force("center", forceCenter());
tsdeclare const enableDrag: boolean;
const drag_ = <T extends Element>() => {
const noop = () => {};
type DEvent = D3DragEvent<T, Node, SVGGElement>;
return enableDrag
? drag<T, Node>()
.on(
"start",
(event: DEvent, d) => {
if (!event.active) simulation.alphaTarget(1).restart();
d.fx = d.x;
d.fy = d.y;
},
)
.on("drag", (event: DEvent, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event: DEvent, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
})
: drag<T, Node>().on("start", noop).on("drag", noop).on(
"end",
noop,
);
};
ts// svg groups
const graphNode = svg.append("g").selectAll("g").data(data.nodes).enter()
.append("g");
:hover ~ [data-source="..."]
で指定するとか Selection
を無理矢理拡張しているtsdeclare const content: Record<
string,
{
content: string;
title: string;
lastModified: string;
tags: string[] | null;
}
>;
declare const fontSize: number;
// draw individual nodes
const node = graphNode
.append("circle")
.attr("class", "node")
.attr("id", (d) => d.id)
.attr("r", nodeRadius)
.attr("fill", color)
.style("cursor", "pointer")
.on("click", (_, d) => {
// SPA navigation
// Only navigate when the page exists
if (content[d.id]) {
window.open(
new URL(`${location.hostname}${decodeURI(d.id).replace(/\s+/g, "-")}/`),
".singlePage",
);
}
})
.on("mouseover", (e: MouseEvent, d) => {
const nodes = selectAll<SVGCircleElement, Node>(
".node",
) as unknown as Transition<
SVGCircleElement,
Node,
SVGGElement,
unknown
>;
nodes.transition().duration(100).attr(
"fill",
"var(--g-node-inactive)",
);
const neighbours: string[] = [];
const neighbourNodes = selectAll<SVGCircleElement, Node>(".node").filter((
d,
) => neighbours.includes(d.id)) as unknown as Transition<
SVGCircleElement,
Node,
SVGGElement,
unknown
>;
const currentId = d.id;
window.open(
new URL(`${location.hostname}${decodeURI(d.id).replace(/\s+/g, "-")}/`),
);
const linkNodes = selectAll<SVGLineElement, Link>(".link")
.filter((d) =>
(d.source as Node).id === currentId ||
(d.target as Node).id === currentId
) as unknown as Transition<
SVGLineElement,
Link,
SVGGElement,
unknown
>;
// highlight neighbour nodes
neighbourNodes.transition().duration(200).attr("fill", color);
// highlight links
linkNodes.transition().duration(200).attr(
"stroke",
"var(--g-link-active)",
);
const bigFont = fontSize * 1.5;
// show text for self
const self = e.target as SVGCircleElement;
const parent = self.parentNode as SVGGElement;
const label = select(parent)
.raise()
.select("text") as unknown as Transition<
SVGTextElement,
Node,
SVGGElement,
unknown
>;
label.transition()
.duration(200)
.attr(
"opacityOld",
select(parent).select("text").style("opacity"),
)
.style("opacity", 1)
.style("font-size", `${bigFont}em`)
.attr("dy", (d) => `${nodeRadius(d) + 20}px`); // radius is in px
})
.on("mouseleave", (e: MouseEvent, d) => {
const nodes = selectAll<SVGCircleElement, Node>(
".node",
) as unknown as Transition<
SVGCircleElement,
Node,
SVGGElement,
unknown
>;
nodes.transition().duration(200).attr("fill", color);
const currentId = d.id;
const linkNodes = selectAll<SVGLineElement, Link>(".link")
.filter((d) =>
(d.source as Node).id === currentId ||
(d.target as Node).id === currentId
) as unknown as Transition<
SVGLineElement,
Link,
SVGGElement,
unknown
>;
linkNodes.transition().duration(200).attr("stroke", "var(--g-link)");
const self = e.target as SVGCircleElement;
const parent = self.parentNode as SVGGElement;
const label = select(parent)
.raise()
.select("text") as unknown as Transition<
SVGTextElement,
Node,
SVGGElement,
unknown
>;
label.transition()
.duration(200)
.style(
"opacity",
select(parent).select("text").attr("opacityOld"),
)
.style("font-size", `${fontSize}em`)
.attr("dy", (d) => `${nodeRadius(d) + 8}px`); // radius is in px
})
.call(drag_());
tsdeclare const opacityScale: number;
// draw labels
const labels = graphNode
.append("text")
.attr("dx", 0)
.attr("dy", (d) => `${nodeRadius(d) + 8}px`)
.attr("text-anchor", "middle")
.text((d) =>
content[d.id]?.title || decodeURIComponent(d.id.replace("-", " "))
)
.style("opacity", (opacityScale - 1) / 3.75)
.style("pointer-events", "none")
.style("font-size", `${fontSize}em`)
.raise()
.call(drag_());
ts// set panning
declare const enableZoom: boolean;
if (enableZoom) {
svg.call(
zoom<SVGSVGElement, unknown>()
.extent([
[0, 0],
[width, height],
])
.scaleExtent([0.25, 4])
.on("zoom", ({ transform }: D3ZoomEvent<SVGSVGElement, unknown>) => {
link.attr("transform", transform.toString());
node.attr("transform", transform.toString());
const scale = transform.k * opacityScale;
// 縮尺が小さいほど薄くなる
const scaledOpacity = Math.max((scale - 1) / 3.75, 0);
labels.attr("transform", transform.toString()).style(
"opacity",
scaledOpacity,
);
}),
);
}
ts// progress the simulation
simulation.on("tick", () => {
link
.attr("x1", (d) => (d.source as Node).x ?? null)
.attr("y1", (d) => (d.source as Node).y ?? null)
.attr("x2", (d) => (d.target as Node).x ?? null)
.attr("y2", (d) => (d.target as Node).y ?? null);
node.attr("cx", (d) => d.x ?? null).attr("cy", (d) => d.y ?? null);
labels.attr("x", (d) => d.x ?? null).attr("y", (d) => d.y ?? null);
});