Published
Edited
Nov 17, 2021
2 stars
Insert cell
topmatter = md`# D3-Graphviz with MathJax

Here are a couple graphs (generated by [Graphviz](https://graphviz.org/)) with node and edge labels rendered with LaTeX. The technique is essentially identical to the one [described here](https://observablehq.com/@mcmcclur/svg-and-mathjax-3) but specialized to work well directly with [d3-graphviz](https://github.com/magjac/d3-graphviz).
`
Insert cell
viewof example = select({
title: "Example",
options: ["Digraph IFS", "Finite state machine"],
value: "dot"
})
Insert cell
viewof engine = select({
title: "Graphviz Engine",
options: ["dot", "circo"],
value: "dot"
})
Insert cell
viewof texit = checkbox({
options: [{ value: "texit", label: "TeX it" }],
value: "texit"
})
Insert cell
graph = {
let graph = d3.create("div").style("width", `${width}px`);

// A directed graph related to my research on digraph fractals
let digraph_code = `digraph "Digraph IFS" {
K_2 -> K_2 [label="f_{12}"]
K_1 -> K_2 [label="g_{12}"]
K_1 -> K_2 [label="h_{12}"]
K_2 -> K_1 [label="g_{21}"]
K_3 -> K_2
K_3 -> K_1
}`;

// Taken from the Graphviz gallery:
// https://graphviz.org/Gallery/directed/fsm.html
// I would probably want to adjust the edge lable positions
// more carefully for a genuine publication.
let finite_state_code = `digraph "Finite State Machine" {
size="8,5";
rankdir=LR;
node [shape = doublecircle]; LR_0 LR_3 LR_4 LR_8;
node [shape = circle];
LR_0 -> LR_2 [ label = "SS(B)" ];
LR_0 -> LR_1 [ label = "SS(S)" ];
LR_1 -> LR_3 [ label = "S($end)" ];
LR_2 -> LR_6 [ label = "SS(b)" ];
LR_2 -> LR_5 [ label = "SS(a)" ];
LR_2 -> LR_4 [ label = "S(A)" ];
LR_5 -> LR_7 [ label = "S(b)" ];
LR_5 -> LR_5 [ label = "S(a)" ];
LR_6 -> LR_6 [ label = "S(b)" ];
LR_6 -> LR_5 [ label = "S(a)" ];
LR_7 -> LR_8 [ label = "S(b)" ];
LR_7 -> LR_5 [ label = "S(a)" ];
LR_8 -> LR_6 [ label = "S(b)" ];
LR_8 -> LR_5 [ label = "S(a)" ];
}`;

let source_code;
if (example == "Digraph IFS") {
source_code = digraph_code;
} else if (example == "Finite state machine") {
source_code = finite_state_code;
}

d3.graphviz(graph.node()).engine(engine).renderDot(source_code);
if (texit) {
let main_group = graph.select("g");
main_group.selectAll(".node,.edge").each(function (e, i) {
let text = d3.select(this).select("text");
if (text.node() != null) {
let x = parseFloat(text.attr("x"));
let y = parseFloat(text.attr("y"));
let tex_group = main_group
.append("g")
.attr("transform", `translate(${x - 15} ${y - 10})`)
.append(() =>
MathJax.tex2svg(String.raw`${text.text()}`).querySelector("svg")
);
text.remove();
}
});
}

return graph.node();
}
Insert cell
comment = md`## A more involved SEIR example

My original motivation was to generate the image below that describes an SEIR model. In this example, I specify the node placement and need to adjust the edge label placement more precisely.
`
Insert cell
seir_graph = {
let graph = d3.create('div').style('width', `${width}px`);

// Here's the source code describing the graph to graphviz.
// Note that nodes and edge labels contain LaTeX code that
// will be passed to MathJax. I guess it gets piped through
// a couple of things; hence, the double escape leading to
// quadruple backslashes \\\\.
let source_code = `digraph {
S [pos="0,0!"]
E [pos="2.7,0!"]
I_1 [pos="4,0!"]
I_2 [pos="6,0.5!"]
I_3 [pos="8,0!"]
D [pos="10,0!"]
R [pos="6,-1.5!"]
S -> E [label="\\\\beta_1 I_1 S + \\\\beta_2 I_2 S + \\\\beta_3 I_3"]
E -> I_1 [label="\\\\alpha E"]
I_1 -> I_2 [label="p_1 I_1"]
I_2 -> I_3 [label="p_2 I_2"]
I_3-> D [label="\\\\mu I_3"]
I_1 -> R [label="\\\\gamma_1 I_1"]
I_2 -> R [label="\\\\gamma_2 I_2"]
I_3 -> R [label="\\\\gamma_3 I_3"]
}`;
d3.graphviz(graph.node())
.width(width)
.fit(true) // Doesn't quite work; see transform in penultimate line.
.zoom(false) // Re-transform for fit breaks the zoom.
.engine('neato')
.renderDot(source_code);

// The image is completely contained in a top level group,
// which we're going to manipulate
let main_group = graph.select('g');

// Don't really want the title
main_group.select('title').remove();

// Typeset the nodes
main_group.selectAll('.node').each(function(e, i) {
let text = d3.select(this).select('text');
if (text.node() != null) {
let x = parseFloat(text.attr('x'));
let y = parseFloat(text.attr('y'));
let tex_group = main_group
.append('g')
.attr('transform', `translate(${x - 8} ${y - 10})`)
.append(() =>
MathJax.tex2svg(String.raw`${text.text()}`).querySelector("svg")
);
text.remove();
}
});

// Placement of the typeset edge labels is a bit trickier. The following
// list of shifts adjusts the placement of the labels from the location
// specified by graphviz.
let shifts = [
[60, -30],
[-9, -27],
[-37, -15],
[10, 5],
[-20, -10],
[10, 0],
[-25, -12],
[44, 5]
];
main_group.selectAll('.edge').each(function(e, i) {
let text = d3.select(this).select('text');
if (text.node() != null) {
let x = parseFloat(text.attr('x'));
let y = parseFloat(text.attr('y'));
let tex_group = main_group
.append('g')
.attr(
'transform',
`translate(${x + shifts[i][0]} ${y + shifts[i][1]}) scale(0.75)`
)
.append(() =>
MathJax.tex2svg(String.raw`${text.text()}`).querySelector("svg")
);
text.remove();
}
});

// There's far more space to the left of the graph than I'd expect;
// I guess the reason is that the first, pre-shifted edge label extends
// quite far to the left. A hacky fix is to redefine the main transform
// to fit it a bit better. Unfortunately, this breaks zoom.
main_group.attr('transform', `translate(-130, 204) scale(1.15)`);
return graph.node();
}
Insert cell
tex`
\begin{aligned}
\dot{S} &= -\beta_1 I_1 S -\beta_2 I_2 S - \beta_3 I_3 S\\
\dot{E} &=\beta_1 I_1 S +\beta_2 I_2 S + \beta_3 I_3 S - a E \\
\dot{I_1} &= a E - \gamma_1 I_1 - p_1 I_1 \\
\dot{I_2} &= p_1 I_1 -\gamma_2 I_2 - p_2 I_2 \\
\dot{I_3} & = p_2 I_2 -\gamma_3 I_3 - \mu I_3 \\
\dot{R} & = \gamma_1 I_1 + \gamma_2 I_2 + \gamma_3 I_3 \\
\dot{D} & = \mu I_3
\end{aligned}`
Insert cell
import { select, checkbox } from "@jashkenas/inputs"
Insert cell
MathJax = require('https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js').catch(
() => window['MathJax']
)
Insert cell
d3 = require("d3@7", "d3-graphviz@2")
Insert cell
// Not sure why download svg or png don't work.
// You can grab the svg as follows:
// d3.select(graph).html()
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more