How to build an associative graph using React + p5

jeffml

Jeff Lowery

Posted on May 12, 2024

How to build an associative graph using React + p5

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.

Our cast of characters

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>;
};
Enter fullscreen mode Exit fullscreen mode

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;
   });
Enter fullscreen mode Exit fullscreen mode

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)) { 
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

The result:

lines of relationships

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:

further relationships

Not bad, but now let's color-code the relationships and add a legend:

relationships color-coded

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:

  1. the start point
  2. the first control point
  3. the second control point
  4. the end point

What are these control points? Here's an illustration:

bezier control points

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);
Enter fullscreen mode Exit fullscreen mode

The result is:

curves instead of lines

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];
Enter fullscreen mode Exit fullscreen mode

Here, the variable i is a counter that is increased as each character in the rhyme is rendered. The final output is:

further adjustment to curves

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.

💖 💪 🙅 🚩
jeffml
Jeff Lowery

Posted on May 12, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related