How to build an associative graph using React + p5
Jeff Lowery
Posted on May 12, 2024
The idea
I've been working on a chess opening database in my copious spare time, and have begun experimenting with visualizations of said openings. One of those is a relationship graph (see above) that displays origin square to destination square of each opening move.
The Farmer in the Dell
Relationships (a type of association) can be found everywhere. In this post, I'll graph the relationships as described in a nursery rhyme called "The Farmer in the Dell".
The farmer in the dell
The farmer in the dell
Hi-ho, the derry-o
The farmer in the dell
After establishing that there's a farmer that lives in a dell, a series of new characters are introduced via the verb "takes".
- The farmer takes a wife
- The wife takes the child
- The child takes the nurse
- The nurse takes the cow
- The cow takes the dog
- The dog takes the cat
- The cat takes the mouse
- The mouse takes the cheese
- The cheese stands alone
The word "takes" is way overloaded here, but that can be addressed later. From this list of relationships we can see that the farmer and the cheese have a single relation (terminal nodes in the graph to come), but all other characters have two.
Graphing the characters
To display the relationships among the characters in the rhyme, we can start by arranging each in a circle.
I'm using the JavaScript graphics library p5 inside a react component, like so:
/* eslint-disable react/prop-types */
import { useRef, useEffect } from "react";
import p5 from "p5";
const characters = {
farmer: {},
wife: {},
child: {},
nurse: {},
cow: {},
dog: {},
cat: {},
mouse: {},
cheese: {},
};
const Version1 = () => {
const renderRef = useRef();
useEffect(() => {
let remove;
new p5((p) => {
remove = p.remove;
p.setup = () => {
const r = 250;
const cast = Object.entries(characters);
for (const [index, [key]] of Object.entries(cast)) {
const character = characters[key];
character.angle =
(p.TWO_PI / Object.keys(characters).length) * index;
character.location = [
r * p.sin(character.angle),
r * p.cos(character.angle),
];
}
p.createCanvas(600, 600).parent(renderRef.current);
p.background(150)
//move 0,0 to the center of the canvas
p.translate(p.width / 2, p.height / 2);
p.ellipseMode(p.CENTER);
p.textAlign(p.CENTER, p.CENTER);
p.textFont("Georgia");
for (const c in characters) {
const [x, y] = characters[c].location;
p.ellipse(x, y, 40);
p.text(c, x, y);
}
};
});
return remove;
});
return <div id="Version1" ref={renderRef}></div>;
};
Without delving too deep into p5, it's worth pointing out some features of this code.
How to render graphics inside a React component
const renderRef = useRef();
p.createCanvas(600, 600).parent(renderRef.current);
return <div id="Version1" ref={renderRef}></div>;
useEffect() cleanup function
useEffect(() => {
let remove;
new p5((p) => {
remove = p.remove;
/* ... */
});
return remove;
});
the p5 remove function 'cleans up' the previous render before useEffect is called again.
Setting up the character locations
p.setup = () => {
const r = 250;
const cast = Object.entries(characters);
for (const [index, [key]] of Object.entries(cast)) {
Note the two Object.entries() calls. This is done to get an index to calculate each character's angle from the center:
character.angle =
(p.TWO_PI / Object.keys(characters).length) * index;
Drawing the relationships
Now to draw a line from each taker to each taken character. First, I add a "takes" relation from farmer all the way down to cheese, then draw a line from the taker location to the taken location.
const doRelations = () => {
// for this version, all relations are the same.
const keys = Object.keys(characters);
keys.forEach((key, i) => {
if (i + 1 < keys.length) {
characters[key].takes = keys[i + 1];
}
});
};
/* ... */
for (const c in characters) {
const character = characters[c];
const [x, y] = character.location;
if (character.takes) {
const taken = characters[character.takes];
const [x2, y2] = taken.location;
p.line(x, y, x2, y2);
}
p.ellipse(x, y, 40);
p.text(c, x, y);
}
The result:
Not terribly interesting, but it's a start.
Requirements analysis
Let's reexamine the word "takes". It's usage is very ambiguous and often nonsensical. Here's one attempt to disambiguate the rhyme:
- The farmer married a wife
- The wife married a farmer
- The wife adopts the child
- The wife employs the nurse
- The child needs the nurse
- The nurse cares for the child
- The farmer owns the cow
- The nurse milks the cow
- The farmer owns the dog
- The dog guards the cow
- The dog befriends the cat
- The cat befriends the dog
- The cat adopts the farmer
- The cat hunts the mouse
- The mouse eats the cheese
Let's see what the relationship graph looks like now:
Not bad, but now let's color-code the relationships and add a legend:
Drawing curves instead of lines
One problem with using lines is that in reciprocal relationships, such as "married", one line overwrites another, obscuring the former relationship. Bezier curves can be used instead of lines, and look better. Each bezier curve takes eight arguments, which are the (x,y) coordinates of:
- the start point
- the first control point
- the second control point
- the end point
What are these control points? Here's an illustration:
The shape of the curve can be adjusted by moving the control points. In this case, I'll set the first control point to be halfway between the start location and the center of the graph, and similarly for the end point.
const cp1 = [x / 2, y / 2];
const cp2 = [x2 / 2, y2 / 2];
p.noFill();
p.bezier(x, y, ...cp1, ...cp2, x2, y2);
The result is:
Though aesthetically better, the change didn't fix the overwrite problem. The control points can be adjusted for each character by adding a "fudge" factor to the control point equation:
const fudge = (v) => ((v*i*3)/40)
const nx = x + fudge(x)
const ny = y + fudge(y)
const nx2 = x2 + fudge(x2)
const ny2 = y2 + fudge(y2)
i++;
const cp1 = [nx / 2, ny / 2];
const cp2 = [nx2 / 2, ny2 / 2];
Here, the variable i is a counter that is increased as each character in the rhyme is rendered. The final output is:
Arrows? What about arrows?
One thing missing from the diagram is an indication of the direction of the relationship, e.g. "farmer owns dog" and not "dog owns farmer". However, there is no native support for arrowheads in p5, so it takes quite a bit of trigonometry to draw them...another post in itself. See links below for more info on the subject.
References
A complete program can be found here.
Useful links:
This example gets halfway to an arrow feature.
One thing is to have the tip of the arrow touch the edge of each character's circle, using the formula in the answer found here.
Posted on May 12, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.