diff --git a/src/VisModal.ts b/src/VisModal.ts index 62e0a429..d7dd9d73 100644 --- a/src/VisModal.ts +++ b/src/VisModal.ts @@ -17,6 +17,7 @@ import { circlePacking } from "src/Visualisations/CirclePacking"; import { edgeBundling } from "src/Visualisations/EdgeBundling"; import { forceDirectedG } from "src/Visualisations/ForceDirectedG"; import { tidyTree } from "src/Visualisations/TidyTree"; +import { arcDiagram } from "src/Visualisations/ArcDiagram"; export function graphlibToD3(g: Graph): d3Graph { const d3Graph: d3Graph = { nodes: [], links: [] }; @@ -406,6 +407,10 @@ export class VisModal extends Modal { fun: edgeBundling, argArr: [graph, contentEl, currFile, width, height], }, + "Arc Diagram": { + fun: arcDiagram, + argArr: [graph, this.app, currFile, this.modal, width, height], + }, }; types[type].fun(...types[type].argArr); diff --git a/src/Visualisations/ArcDiagram.ts b/src/Visualisations/ArcDiagram.ts new file mode 100644 index 00000000..c363463c --- /dev/null +++ b/src/Visualisations/ArcDiagram.ts @@ -0,0 +1,197 @@ +import * as d3 from "d3"; +import type { Graph } from "graphlib"; +import type { App, TFile } from "obsidian"; +import type { d3Node } from "src/interfaces"; +import { openOrSwitch } from "src/sharedFunctions"; +import { graphlibToD3, VisModal } from "src/VisModal"; + +export const arcDiagram = ( + graph: Graph, + app: App, + currFile: TFile, + modal: VisModal, + width: number, + height: number +) => { + const data = graphlibToD3(graph); + + const margin = { top: 20, right: 20, bottom: 20, left: 150 }; + const svg = d3 + .select(".d3-graph") + .append("svg") + .attr("height", height) + .attr("width", width); + + const nodes = data.nodes.map(({ id, name }) => ({ + id, + name, + sourceLinks: [], + targetLinks: [], + })); + + const nodeById = new Map(nodes.map((d) => [d.id, d])); + + const links = data.links.map(({ source, target }) => ({ + source: nodeById.get(source as number), + target: nodeById.get(target as number), + })); + + for (const link of links) { + const { source, target } = link; + source.sourceLinks.push(link); + target.targetLinks.push(link); + } + + svg.append("style").text(` + +path { + stroke: #808080; + opacity: 0.8; +} + +text { + stroke: var(--text-a); + opacity: 0.8; +} + + +.hover g.primary text { + fill: black; +} + +.hover g.secondary text { + fill: #333; +} + +.hover .secondary { + color: red; +} + +.hover path.primary { + stroke: #333; + stroke-opacity: 1; +} + +.hover rect { + opacity: 1; + cursor: pointer; +} + +`); + + const y = d3.scalePoint(nodes.map((d) => d.name).sort(d3.ascending), [ + margin.top, + height - margin.bottom, + ]); + + const label = svg + .append("g") + .attr("font-family", "sans-serif") + .attr("font-size", 10) + .attr("text-anchor", "end") + .selectAll("g") + .data(nodes) + .join("g") + .attr("transform", (d) => `translate(${margin.left},${(d.y = y(d.name))})`) + .call((g) => + g + .append("text") + .attr("x", -6) + .attr("dy", "0.35em") + // .attr("fill", (d) => d3.lab(color(d.group)).darker(2)) + .text((d) => d.name) + ) + .call( + (g) => g.append("circle").attr("r", 3) + // .attr("fill", (d) => color(d.group)) + ); + + const path = svg + .insert("g", "*") + .attr("fill", "none") + .attr("stroke-opacity", 0.6) + .attr("stroke-width", 1.5) + .selectAll("path") + .data(links) + .join("path") + // .attr("stroke", (d) => + // d.source.group === d.target.group ? color(d.source.group) : "#aaa" + // ) + .attr("d", arc); + + const step = 14; + + const nodeClick = (event: MouseEvent, dest: string) => { + const currFile = app.workspace.getActiveFile(); + openOrSwitch(app, dest, currFile, event); + modal.close(); + }; + + const overlay = svg + .append("g") + .attr("fill", "none") + .attr("pointer-events", "all") + .selectAll("rect") + .data(nodes) + .join("rect") + .attr("width", margin.left + 40) + .attr("height", step) + .attr("y", (d) => y(d.name) - step / 2) + .on("mouseover", (d) => { + svg.classed("hover", true); + label.classed("primary", (n) => n === d); + label.classed( + "secondary", + (n) => + n.sourceLinks.some((l) => l.target === d) || + n.targetLinks.some((l) => l.source === d) + ); + path + .classed("primary", (l) => l.source === d || l.target === d) + .filter(".primary") + .raise(); + }) + .on("mouseout", (d) => { + svg.classed("hover", false); + label.classed("primary", false); + label.classed("secondary", false); + path.classed("primary", false).order(); + }) + .on("click", (event: MouseEvent, d: d3Node) => { + nodeClick(event, d.name); + }); + + // function update() { + // y.domain(nodes.sort(viewof order.value).map(d => d.id)); + + // const t = svg.transition() + // .duration(750); + + // label.transition(t) + // .delay((d, i) => i * 20) + // .attrTween("transform", d => { + // const i = d3.interpolateNumber(d.y, y(d.id)); + // return t => `translate(${margin.left},${d.y = i(t)})`; + // }); + + // path.transition(t) + // .duration(750 + nodes.length * 20) + // .attrTween("d", d => () => arc(d)); + + // overlay.transition(t) + // .delay((d, i) => i * 20) + // .attr("y", d => y(d.id) - step / 2); + // } + + // viewof order.addEventListener("input", update); + // invalidation.then(() => viewof order.removeEventListener("input", update)); + + function arc(d) { + const y1 = d.source.y; + const y2 = d.target.y; + const r = Math.abs(y2 - y1) / 2; + return `M${margin.left},${y1}A${r},${r} 0,0,${y1 < y2 ? 1 : 0} ${ + margin.left + },${y2}`; + } +}; diff --git a/src/constants.ts b/src/constants.ts index 14f9b23b..6f9baa17 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -18,6 +18,7 @@ export const VISTYPES: visTypes[] = [ "Tidy Tree", "Circle Packing", "Edge Bundling", + "Arc Diagram", ]; export const RELATIONS: Relations[] = ["Parent", "Sibling", "Child"]; export const REAlCLOSED = ["Real", "Closed"]; diff --git a/src/interfaces.ts b/src/interfaces.ts index 89204f0c..bfc14a63 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -135,4 +135,5 @@ export type visTypes = | "Force Directed Tree" | "Tidy Tree" | "Circle Packing" - | "Edge Bundling"; + | "Edge Bundling" + | "Arc Diagram";