generated at
ObsidianのLocal Graphのcode reading
D3.jsで描画している

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";

入力するデータの定義
SimulationNodeDatum SimulationLinkDatum の型定義はd3-forceのindex.d.tsを参照
ts
interface Node extends SimulationNodeDatum { /** ページタイトル */ id: string; } type Link = SimulationLinkDatum<Node>; declare const data: { nodes: Node[]; links: Link[]; };

SVG領域の作成
ts
declare 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, ], );

pathの色を決める
ts
declare 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)"; };


legend
blu3mo-quartzではOFFになっている
ts
declare 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"); }); }

Linkの作成
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);

Nodeの大きさを決める
半径=2+\sqrt{N_{in}+N_{out}}
意味がよく読み取れない式だtakker
まあリンク数が多いほど大きくなることはわかる
ts
declare 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); };

d3-forceの設定
ts
declare 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());

ts
declare 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");

Nodeの作成
hover時にd3-transitionを使っている
CSS Transitionで代用できそうだ
transition-timing-functionには liner を使う
関連ノードのみをtransitionする必要があるから無理
CSSを工夫すれば行けるかも?
:hover ~ [data-source="..."] で指定するとか
Nodeの数だけ動的にCSSを生成する必要があるな
d3-transitionの型定義の処理が困難
declare module {} Selection を無理矢理拡張している
これはDenoでは認識できない
使いたくなかったが、as unknown asでやり過ごした
https://wizardace.com/d3-forcesimulation-highlight/ のコードのほうがシンプル
ts
declare 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_());

Labelの作成
ts
declare 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_());

zooming
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); });

#2022-12-31 11:58:08
#2022-12-29 13:40:06