____  ___    _  _     _   _ _____     _______
 / ___|/ _ \  | || |   | | | |_ _\ \   / / ____|
| |  _| | | | | || |_  | |_| || | \ \ / /|  _|
| |_| | |_| | |__   _| |  _  || |  \ V / | |___
 \____|\___/     |_|   |_| |_|___|  \_/  |_____|

 --- A GOPHER-LIKE INTERFACE FOR HIVE BLOCKCHAIN ---

Learn Creative Coding (#85) - Network and Graph Visualization

BY: @femdev | CREATED: June 5, 2026, 11:47 a.m. | VOTES: 10 | PAYOUT: $0.12 | [ VOTE ]

Learn Creative Coding (#85) - Network and Graph Visualization

[IMAGE: https://images.hive.blog/DQmZCVibociQsR6XsLDTrnzNBjz47h1z3ocJGFZgerPf7xM/cc-banner-red.png]

Last episode we visualized time -- timelines, spirals, calendar heatmaps, clock diagrams, streamgraphs, animated playback, and those dense hourly-by-daily fingerprint grids that cram 8,760 data points into a single image. Time is the most common data dimension and the way you lay it out on canvas determines which patterns appear. Straight lines ask "what happened?" Spirals ask "what repeats?" The representation IS the question.

But not all data is about when or where. Some data is about who connects to whom. Social networks, hyperlink graphs, citation networks, character co-occurrence in novels, function call trees in codebases, biological pathways, trade routes between countries. The underlying structure isn't a sequence or a grid -- it's a graph. Nodes and edges. Entities and relationships. And the visual language for rendering them is completely different from anything we've built so far.

This episode is about network visualization. We'll build force-directed layouts from scratch (springs and charges, simulated until equilibrium), encode node importance through size and color, detect visual communities, try alternative layouts like adjacency matrices and arc diagrams, and add the interactivity that makes dense networks actually explorable. We used physics simulation back in episode 18 for springs and flocking -- now those same forces arrange data instead of particles.

What's a graph, visually?

A graph has two things: nodes (the entities) and edges (the connections between them). A social network: nodes are people, edges are friendships. A citation network: nodes are papers, edges are references. A hyperlink graph: nodes are web pages, edges are links. The internet itself is a graph. Your brain is a graph. Most complex systems are.

Visually, the classic representation is a node-link diagram: circles for nodes, lines between them for edges. Simple in concept. Nightmarishly complex in practice when you have more then about 20 nodes, because the layout problem -- where do you put each node so the picture is readable? -- is genuinely hard. There's no single right answer. The same graph can look like a mess or a revelation depending on where you position the nodes.

const canvas = document.createElement('canvas');
canvas.width = 600;
canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// a small graph: 8 nodes, some edges
const nodes = [
  { id: 0, x: 150, y: 150 },
  { id: 1, x: 300, y: 100 },
  { id: 2, x: 450, y: 150 },
  { id: 3, x: 100, y: 350 },
  { id: 4, x: 300, y: 300 },
  { id: 5, x: 500, y: 350 },
  { id: 6, x: 200, y: 480 },
  { id: 7, x: 400, y: 480 }
];

const edges = [
  [0, 1], [1, 2], [0, 4], [1, 4],
  [2, 5], [3, 4], [3, 6], [4, 5],
  [4, 7], [5, 7], [6, 7]
];

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 600, 600);

// draw edges
for (const [a, b] of edges) {
  ctx.beginPath();
  ctx.moveTo(nodes[a].x, nodes[a].y);
  ctx.lineTo(nodes[b].x, nodes[b].y);
  ctx.strokeStyle = 'rgba(100, 140, 200, 0.3)';
  ctx.lineWidth = 1.5;
  ctx.stroke();
}

// draw nodes
for (const node of nodes) {
  ctx.beginPath();
  ctx.arc(node.x, node.y, 8, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(120, 180, 255, 0.7)';
  ctx.fill();
  ctx.strokeStyle = 'rgba(160, 200, 255, 0.4)';
  ctx.lineWidth = 1;
  ctx.stroke();
}

Eight nodes, eleven edges. I placed them by hand at positions that look reasonable. But that's the problem -- I chose those positions. They don't come from the data. If you add a 9th node, where does it go? If you have 200 nodes? Manual placement doesn't scale. We need an algorithm that figures out good positions automatically.

Force-directed layout: physics as design

This is the big one. The idea: treat each edge as a spring (it pulls connected nodes together) and treat each pair of nodes as repelling charges (they push apart, like magnets with the same polarity). Then simulate the physics. Connected nodes get pulled close. Unconnected nodes get pushed away. After enough iterations, the system reaches equilibrium -- a layout where the forces balance out. Clusters of densely connected nodes end up near each other, and loosely connected parts drift apart.

It's the same spring physics we built in episode 18, but applied to layout instead of animation.

const canvas = document.createElement('canvas');
canvas.width = 700;
canvas.height = 700;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// generate a random graph
const numNodes = 30;
const nodes = [];
for (let i = 0; i < numNodes; i++) {
  nodes.push({
    x: 200 + Math.random() * 300,
    y: 200 + Math.random() * 300,
    vx: 0,
    vy: 0
  });
}

// random edges -- each node connects to 2-4 others
const edges = [];
for (let i = 0; i < numNodes; i++) {
  const numEdges = 2 + Math.floor(Math.random() * 3);
  for (let e = 0; e < numEdges; e++) {
    const j = Math.floor(Math.random() * numNodes);
    if (j !== i) {
      edges.push([i, j]);
    }
  }
}

function simulate() {
  const repulsion = 5000;
  const springStrength = 0.01;
  const springLength = 80;
  const damping = 0.85;

  // repulsion between all node pairs
  for (let i = 0; i < numNodes; i++) {
    for (let j = i + 1; j < numNodes; j++) {
      const dx = nodes[j].x - nodes[i].x;
      const dy = nodes[j].y - nodes[i].y;
      const dist = Math.sqrt(dx * dx + dy * dy) + 0.1;
      const force = repulsion / (dist * dist);
      const fx = (dx / dist) * force;
      const fy = (dy / dist) * force;

      nodes[i].vx -= fx;
      nodes[i].vy -= fy;
      nodes[j].vx += fx;
      nodes[j].vy += fy;
    }
  }

  // spring attraction along edges
  for (const [a, b] of edges) {
    const dx = nodes[b].x - nodes[a].x;
    const dy = nodes[b].y - nodes[a].y;
    const dist = Math.sqrt(dx * dx + dy * dy) + 0.1;
    const displacement = dist - springLength;
    const fx = (dx / dist) * displacement * springStrength;
    const fy = (dy / dist) * displacement * springStrength;

    nodes[a].vx += fx;
    nodes[a].vy += fy;
    nodes[b].vx -= fx;
    nodes[b].vy -= fy;
  }

  // centering force (gentle pull toward canvas center)
  for (const node of nodes) {
    node.vx += (350 - node.x) * 0.001;
    node.vy += (350 - node.y) * 0.001;
  }

  // update positions with damping
  for (const node of nodes) {
    node.vx *= damping;
    node.vy *= damping;
    node.x += node.vx;
    node.y += node.vy;
  }
}

function draw() {
  ctx.fillStyle = '#0a0a1a';
  ctx.fillRect(0, 0, 700, 700);

  // edges
  for (const [a, b] of edges) {
    ctx.beginPath();
    ctx.moveTo(nodes[a].x, nodes[a].y);
    ctx.lineTo(nodes[b].x, nodes[b].y);
    ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)';
    ctx.lineWidth = 1;
    ctx.stroke();
  }

  // nodes
  for (const node of nodes) {
    ctx.beginPath();
    ctx.arc(node.x, node.y, 5, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(120, 180, 255, 0.7)';
    ctx.fill();
  }

  simulate();
  requestAnimationFrame(draw);
}

draw();

Thirty nodes, random edges. They start in a clump and then -- over a few seconds -- they sort themselves out. Connected groups drift together. Bridges between groups stretch into visible corridors. Isolated nodes float to the edges. The layout emerges from the physics. You didn't design it. The forces did.

The three forces at work:

  1. Repulsion (all pairs): Coulomb's law in reverse. Every node pushes every other node away. The force drops off with the square of the distance -- close nodes push hard, far nodes barely affect each other. This prevents everything from collapsing into a single point.

  2. Spring attraction (edges only): Hooke's law. Connected nodes are pulled toward a target distance (springLength). If they're farther apart, the spring pulls them together. If they're closer, it pushes them apart (yes, springs do that too). This keeps connected nodes near each other.

  3. Centering (all nodes): A weak pull toward the center of the canvas. Without this, the whole graph drifts off-screen because repulsion pushes everything outward with no counterweight.

The damping factor (0.85) acts like friction. Each frame, velocities are reduced by 15%. Without damping, the system oscillates forever -- nodes bounce back and forth and never settle. With damping, the kinetic energy drains away and the system converges to a stable layout.

Visual encoding: making nodes meaningful

A graph where every node looks the same is a graph that's hiding information. In most real networks, nodes have properties: importance, category, size of following, number of connections. The visual encoding of those properties turns a structural diagram into a data visualization.

The most important node property is degree -- how many edges connect to it. High-degree nodes are hubs. They hold the network together. Low-degree nodes are peripheral. You can compute degree just by counting edges per node.

// compute degree for each node
const degree = new Array(numNodes).fill(0);
for (const [a, b] of edges) {
  degree[a]++;
  degree[b]++;
}

const maxDeg = Math.max(...degree);

// draw nodes with size and color from degree
for (let i = 0; i < numNodes; i++) {
  const node = nodes[i];
  const d = degree[i];

  // area-proportional sizing (ep082 lesson)
  const area = (d / maxDeg) * 800 + 50;
  const r = Math.sqrt(area / Math.PI);

  // color: low degree = cool blue, high degree = warm amber
  const hue = 220 - (d / maxDeg) * 180;
  const lightness = 30 + (d / maxDeg) * 25;

  ctx.beginPath();
  ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
  ctx.fillStyle = `hsla(${hue}, 55%, ${lightness}%, 0.7)`;
  ctx.fill();
}

Now the hub nodes are large and warm-colored. Peripheral nodes are small and blue. You can immediately see which nodes hold the network together without counting edges manually. The area-proportional sizing we learned in episode 82 matters here too -- mapping degree to radius instead of area would exaggerate the visual importance of hubs. Math.sqrt(area / Math.PI) keeps it honest.

Edge thickness can carry meaning too. If your edges have weights (frequency of communication, number of shared connections, traffic volume), thicker edges mean stronger relationships:

// draw edges with thickness from weight
for (const edge of weightedEdges) {
  ctx.beginPath();
  ctx.moveTo(nodes[edge.source].x, nodes[edge.source].y);
  ctx.lineTo(nodes[edge.target].x, nodes[edge.target].y);

  const thickness = 0.5 + (edge.weight / maxWeight) * 4;
  ctx.strokeStyle = `rgba(80, 120, 180, ${0.1 + (edge.weight / maxWeight) * 0.4})`;
  ctx.lineWidth = thickness;
  ctx.stroke();
}

Thick bright edges are strong connections. Thin faint edges are weak ties. The network's backbone becomes visible -- the thick edges form the structural core, and the thin ones are the periphery.

Community detection by color

Real networks have communities -- groups of nodes that are more connected to each other than to the rest of the network. Social cliques, academic departments, music genre clusters. Visually, communities should have distinct colors so you can see the group structure at a glance.

A simple (not academically rigorous but visually effective) approach: assign each node to the community of its most-connected neighbor. Repeat a few times until it converges. It's a form of label propagation.

// simple community detection: label propagation
const community = [];
for (let i = 0; i < numNodes; i++) {
  community[i] = i; // each node starts as its own community
}

// build adjacency list
const neighbors = Array.from({ length: numNodes }, () => []);
for (const [a, b] of edges) {
  neighbors[a].push(b);
  neighbors[b].push(a);
}

// iterate: each node adopts the most common label among neighbors
for (let iter = 0; iter < 10; iter++) {
  for (let i = 0; i < numNodes; i++) {
    if (neighbors[i].length === 0) continue;

    const counts = {};
    for (const n of neighbors[i]) {
      const label = community[n];
      counts[label] = (counts[label] || 0) + 1;
    }

    // find the most frequent label
    let bestLabel = community[i];
    let bestCount = 0;
    for (const [label, count] of Object.entries(counts)) {
      if (count > bestCount) {
        bestCount = count;
        bestLabel = parseInt(label);
      }
    }
    community[i] = bestLabel;
  }
}

// map community labels to colors
const uniqueCommunities = [...new Set(community)];
const communityColors = {};
for (let i = 0; i < uniqueCommunities.length; i++) {
  communityColors[uniqueCommunities[i]] = (i * 137) % 360; // golden angle for good spread
}

// draw with community colors
for (let i = 0; i < numNodes; i++) {
  const node = nodes[i];
  const hue = communityColors[community[i]];

  ctx.beginPath();
  ctx.arc(node.x, node.y, 6, 0, Math.PI * 2);
  ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.7)`;
  ctx.fill();
}

After a few iterations, connected clusters converge on the same label. The golden angle spacing (137 degrees between successive hues) ensures that nearby community indices don't get similar colors -- same trick that sunflowers use to pack seeds efficiently. The result: clusters of same-colored nodes with differently-colored bridges between them. The community structure that's implicit in the edge list becomes explicit in the color map.

The adjacency matrix: when node-link fails

Node-link diagrams break down when graphs are dense. Above ~100 nodes with many edges, the drawing becomes a hairball -- a tangle of crossing lines where no structure is visible. The adjacency matrix is the alternative.

An adjacency matrix has one row and one column per node. The cell at row i, column j is filled if there's an edge between nodes i and j. It's a 2D grid representation of the same information as the node-link diagram, but it never has crossing edges because there are no edges -- just cells.

const canvas = document.createElement('canvas');
canvas.width = 500;
canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const n = 25;
// generate a clustered adjacency matrix
const matrix = Array.from({ length: n }, () => new Array(n).fill(0));

// three clusters: 0-8, 9-16, 17-24
function sameCluster(i, j) {
  return Math.floor(i / 9) === Math.floor(j / 9);
}

for (let i = 0; i < n; i++) {
  for (let j = i + 1; j < n; j++) {
    const prob = sameCluster(i, j) ? 0.6 : 0.05;
    if (Math.random() < prob) {
      matrix[i][j] = 1;
      matrix[j][i] = 1;
    }
  }
}

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 500, 500);

const cellSize = 16;
const offset = 40;

for (let i = 0; i < n; i++) {
  for (let j = 0; j < n; j++) {
    const x = offset + j * cellSize;
    const y = offset + i * cellSize;

    if (matrix[i][j]) {
      const cluster = Math.floor(i / 9);
      const hues = [200, 130, 350];
      ctx.fillStyle = `hsla(${hues[cluster]}, 50%, 45%, 0.7)`;
    } else {
      ctx.fillStyle = 'rgba(25, 30, 40, 0.5)';
    }

    ctx.fillRect(x, y, cellSize - 1, cellSize - 1);
  }
}

The three clusters show up as dense colored blocks along the diagonal. Connections between clusters are sparse dots scattered off-diagonal. The block structure is unmistakable -- you can see exactly how many inter-cluster connections exist by counting the off-diagonal dots. In a node-link diagram of the same graph, those inter-cluster edges would be tangled lines crossing through the clusters and the block structure would be invisible.

Adjacency matrices have a crucial property: the ordering of rows and columns matters enormously. If you shuffle the node order randomly, the blocks disappear -- the same connections become scattered cells with no visible pattern. A good adjacency matrix requires sorting nodes so that communities end up adjacent. That ordering IS the analysis -- getting it right reveals the structure, getting it wrong hides it.

Arc diagram: the literary layout

An arc diagram places all nodes along a horizontal line and draws edges as arcs above it. The arc height is proportional to the distance between connected nodes in the linear ordering. Nearby connections are short arcs. Long-range connections are tall arcs that reach across the diagram.

This layout works beautifully for sequential data. Characters in a book: who interacts with whom, and how far apart are they in the narrative? Function calls in a program: which functions call which other functions, and how far apart are they in the source file? Any data with a natural linear ordering benefits from the arc layout.

const canvas = document.createElement('canvas');
canvas.width = 900;
canvas.height = 350;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// 15 "characters" in a story, with interactions
const characters = [
  'Alice', 'Bob', 'Carol', 'Dan', 'Eve',
  'Frank', 'Grace', 'Hank', 'Iris', 'Jack',
  'Kate', 'Leo', 'Mia', 'Nate', 'Olive'
];

const interactions = [
  [0, 1, 8],  [0, 2, 3],  [1, 3, 5],  [2, 3, 6],
  [3, 5, 2],  [4, 6, 7],  [5, 7, 4],  [6, 8, 3],
  [7, 9, 5],  [8, 10, 2], [9, 11, 6], [10, 12, 4],
  [11, 13, 3], [12, 14, 5], [0, 14, 1], [3, 10, 2],
  [1, 7, 3],  [5, 12, 2]
  // [source, target, weight]
];

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 900, 350);

const y = 280;
const startX = 50;
const spacing = 55;

// draw arcs
for (const [a, b, w] of interactions) {
  const x1 = startX + a * spacing;
  const x2 = startX + b * spacing;
  const midX = (x1 + x2) / 2;
  const arcHeight = Math.abs(b - a) * 18;

  ctx.beginPath();
  ctx.moveTo(x1, y);
  ctx.quadraticCurveTo(midX, y - arcHeight, x2, y);
  ctx.strokeStyle = `rgba(140, 180, 255, ${0.15 + (w / 8) * 0.4})`;
  ctx.lineWidth = 0.5 + (w / 8) * 3;
  ctx.stroke();
}

// draw nodes
for (let i = 0; i < characters.length; i++) {
  const x = startX + i * spacing;

  ctx.beginPath();
  ctx.arc(x, y, 4, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(120, 180, 255, 0.8)';
  ctx.fill();

  ctx.save();
  ctx.translate(x, y + 12);
  ctx.rotate(-Math.PI / 4);
  ctx.fillStyle = 'rgba(160, 170, 190, 0.5)';
  ctx.font = '9px monospace';
  ctx.textAlign = 'right';
  ctx.fillText(characters[i], 0, 0);
  ctx.restore();
}

The long arc from Alice (position 0) to Olive (position 14) towers over the short arcs between adjacent characters. Edge thickness and opacity encode interaction frequency -- thicker arcs mean more interaction. You can see the narrative structure: most interactions happen between nearby characters (short arcs, dense connections), with a few long-range relationships bridging distant parts of the story.

Arc diagrams are one of my favourite graph layouts for creative coding because they have strong visual rhythm. The nested arcs create a kind of topographic contour that's inherently beautiful even before you think about what the data means. :-)

Interactive exploration: hover and highlight

Networks are too complex for purely static views. With 30+ nodes, the viewer needs to be able to focus on one node at a time and see its neighborhood highlighted while everything else fades. This is where mouse interaction becomes essential.

const canvas = document.createElement('canvas');
canvas.width = 700;
canvas.height = 700;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// reuse force-directed layout from earlier
// (assume nodes[] and edges[] are already computed and settled)

let hoveredNode = -1;

canvas.addEventListener('mousemove', function(e) {
  const rect = canvas.getBoundingClientRect();
  const mx = e.clientX - rect.left;
  const my = e.clientY - rect.top;

  hoveredNode = -1;
  for (let i = 0; i < nodes.length; i++) {
    const dx = mx - nodes[i].x;
    const dy = my - nodes[i].y;
    if (dx * dx + dy * dy < 15 * 15) {
      hoveredNode = i;
      break;
    }
  }
});

function drawInteractive() {
  ctx.fillStyle = '#0a0a1a';
  ctx.fillRect(0, 0, 700, 700);

  // find neighbors of hovered node
  const highlighted = new Set();
  const highlightedEdges = new Set();
  if (hoveredNode >= 0) {
    highlighted.add(hoveredNode);
    for (let e = 0; e < edges.length; e++) {
      const [a, b] = edges[e];
      if (a === hoveredNode || b === hoveredNode) {
        highlighted.add(a);
        highlighted.add(b);
        highlightedEdges.add(e);
      }
    }
  }

  // draw edges
  for (let e = 0; e < edges.length; e++) {
    const [a, b] = edges[e];
    ctx.beginPath();
    ctx.moveTo(nodes[a].x, nodes[a].y);
    ctx.lineTo(nodes[b].x, nodes[b].y);

    if (hoveredNode >= 0) {
      ctx.strokeStyle = highlightedEdges.has(e)
        ? 'rgba(120, 180, 255, 0.6)'
        : 'rgba(60, 70, 90, 0.1)';
      ctx.lineWidth = highlightedEdges.has(e) ? 2 : 0.5;
    } else {
      ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)';
      ctx.lineWidth = 1;
    }
    ctx.stroke();
  }

  // draw nodes
  for (let i = 0; i < nodes.length; i++) {
    const isHighlighted = hoveredNode < 0 || highlighted.has(i);

    ctx.beginPath();
    ctx.arc(nodes[i].x, nodes[i].y, isHighlighted ? 7 : 5, 0, Math.PI * 2);
    ctx.fillStyle = isHighlighted
      ? 'rgba(120, 180, 255, 0.8)'
      : 'rgba(50, 60, 80, 0.3)';
    ctx.fill();
  }

  requestAnimationFrame(drawInteractive);
}

drawInteractive();

When you hover over a node, its edges light up and its direct neighbors stay visible while everything else fades to near-invisibility. The ego-network (a node plus its connections) pops out of the complexity. Move to a different node and a completely different neighborhood emerges. The same graph looks different from every node's perspective -- which is actually a deep truth about networks. Your experience of a social network depends entirely on where you sit in it.

Edge bundling: taming the hairball

When a graph has hundreds of edges, even a good force-directed layout produces visual clutter. Edges cross each other in every direction and the structural patterns disappear into spaghetti. Edge bundling is a technique that routes nearby edges along shared paths, like cables bundled into conduits. Parallel edges merge into streams, then diverge to their individual endpoints.

The simplest approach: for each edge, instead of drawing a straight line from source to target, draw a bezier curve that bends toward the midpoint of the canvas (or toward the centroid of the graph). Edges with nearby endpoints share similar curves, creating the illusion of bundles.

// simple edge bundling: curve edges toward center
const cx = 350;
const cy = 350;
const bundleStrength = 0.4;

for (const [a, b] of edges) {
  const x1 = nodes[a].x;
  const y1 = nodes[a].y;
  const x2 = nodes[b].x;
  const y2 = nodes[b].y;

  // control point pulled toward center
  const midX = (x1 + x2) / 2;
  const midY = (y1 + y2) / 2;
  const ctrlX = midX + (cx - midX) * bundleStrength;
  const ctrlY = midY + (cy - midY) * bundleStrength;

  ctx.beginPath();
  ctx.moveTo(x1, y1);
  ctx.quadraticCurveTo(ctrlX, ctrlY, x2, y2);
  ctx.strokeStyle = 'rgba(80, 140, 220, 0.08)';
  ctx.lineWidth = 1;
  ctx.stroke();
}

The bundleStrength parameter controls how aggressively edges are pulled toward center. At 0 you get straight lines (no bundling). At 1 all edges pass through the center (maximally bundled, but you lose endpoint information). 0.3-0.5 is usually a good range -- enough bundling to reveal flow patterns, but the individual edge endpoints are still distinguisable.

With many edges at low opacity, the bundles emerge naturally from the overlapping curves. Dense bundles -- where many edges between two clusters merge into a thick stream -- glow brightly. Isolated edges stay faint. The major highways of the network become visible while the side streets fade into the background.

Using d3-force for production layouts

Building force simulation from scratch is educational (we just did it), but for production work you'd use d3-force. It's the gold standard force-directed layout engine, and you can import just the force module without the rest of D3. It handles all the physics, adaptive cooling, collision detection, and configurable force parameters.

// d3-force handles layout, we handle rendering with canvas
// import { forceSimulation, forceLink, forceManyBody, forceCenter } from 'd3-force';

const simulation = d3.forceSimulation(nodes)
  .force('charge', d3.forceManyBody().strength(-200))
  .force('link', d3.forceLink(edges).distance(60))
  .force('center', d3.forceCenter(350, 350))
  .on('tick', draw);

function draw() {
  ctx.fillStyle = '#0a0a1a';
  ctx.fillRect(0, 0, 700, 700);

  // d3 updates node.x and node.y automatically
  for (const edge of edges) {
    ctx.beginPath();
    ctx.moveTo(edge.source.x, edge.source.y);
    ctx.lineTo(edge.target.x, edge.target.y);
    ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)';
    ctx.lineWidth = 1;
    ctx.stroke();
  }

  for (const node of nodes) {
    ctx.beginPath();
    ctx.arc(node.x, node.y, 5, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(120, 180, 255, 0.7)';
    ctx.fill();
  }
}

The key difference from our DIY version: d3-force uses adaptive cooling. The simulation starts "hot" (large movements each tick) and gradually cools down (smaller and smaller movements) until it stops. Our manual version used constant damping, which works but takes longer to settle. D3's cooling schedule converges faster and produces smoother results.

You don't need to use D3 for rendering -- that's the whole point. D3 calculates the positions, and you render with canvas (or p5, or WebGL, or whatever). Separation of layout from rendering. The layout engine is a tool, the visual style is yours.

Creative exercise: character co-occurrence network

Allez, time to build something real. A character co-occurrence network from a novel. Two characters are connected if they appear in the same paragraph. The more paragraphs they share, the stronger the connection. Node size from total appearances, edge weight from co-occurrence count. Force-directed layout, colored by community.

We'll use a simplified dataset -- manually defining character appearances would take forever for a full novel, so we'll simulate the pattern with a fake "book" that has the right statistical structure.

const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 800;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// simulated book characters with appearance counts and co-occurrence
const characters = [
  { name: 'Protagonist', appearances: 180, group: 0 },
  { name: 'Mentor', appearances: 95, group: 0 },
  { name: 'Sidekick', appearances: 120, group: 0 },
  { name: 'Villain', appearances: 85, group: 1 },
  { name: 'Henchman1', appearances: 40, group: 1 },
  { name: 'Henchman2', appearances: 35, group: 1 },
  { name: 'Love Interest', appearances: 75, group: 0 },
  { name: 'Rival', appearances: 60, group: 2 },
  { name: 'Friend1', appearances: 45, group: 2 },
  { name: 'Friend2', appearances: 50, group: 2 },
  { name: 'Elder', appearances: 30, group: 0 },
  { name: 'Spy', appearances: 55, group: 1 },
  { name: 'Narrator', appearances: 70, group: 0 },
  { name: 'Merchant', appearances: 25, group: 2 },
  { name: 'Guard', appearances: 20, group: 1 }
];

// co-occurrence edges (source, target, shared paragraphs)
const cooccurrence = [
  [0, 1, 45], [0, 2, 70], [0, 3, 30], [0, 6, 40],
  [0, 7, 25], [0, 12, 35], [1, 2, 20], [1, 10, 15],
  [1, 12, 18], [2, 6, 15], [2, 7, 12], [3, 4, 30],
  [3, 5, 25], [3, 11, 22], [4, 5, 18], [4, 14, 12],
  [5, 14, 10], [6, 12, 8], [7, 8, 20], [7, 9, 18],
  [8, 9, 25], [8, 13, 10], [9, 13, 8], [10, 12, 6],
  [11, 3, 15], [11, 14, 8], [0, 11, 10], [2, 3, 8]
];

// initialize node positions
const nodes = characters.map((c, i) => ({
  x: 400 + (Math.random() - 0.5) * 200,
  y: 400 + (Math.random() - 0.5) * 200,
  vx: 0, vy: 0,
  ...c
}));

// run force simulation for 200 steps
for (let step = 0; step < 200; step++) {
  const cooling = 1.0 - step / 200;

  // repulsion
  for (let i = 0; i < nodes.length; i++) {
    for (let j = i + 1; j < nodes.length; j++) {
      const dx = nodes[j].x - nodes[i].x;
      const dy = nodes[j].y - nodes[i].y;
      const dist = Math.sqrt(dx * dx + dy * dy) + 1;
      const force = 4000 / (dist * dist);
      const fx = (dx / dist) * force * cooling;
      const fy = (dy / dist) * force * cooling;
      nodes[i].vx -= fx; nodes[i].vy -= fy;
      nodes[j].vx += fx; nodes[j].vy += fy;
    }
  }

  // spring attraction
  for (const [a, b, w] of cooccurrence) {
    const dx = nodes[b].x - nodes[a].x;
    const dy = nodes[b].y - nodes[a].y;
    const dist = Math.sqrt(dx * dx + dy * dy) + 1;
    const strength = 0.005 + (w / 70) * 0.01;
    const target = 70;
    const disp = dist - target;
    const fx = (dx / dist) * disp * strength * cooling;
    const fy = (dy / dist) * disp * strength * cooling;
    nodes[a].vx += fx; nodes[a].vy += fy;
    nodes[b].vx -= fx; nodes[b].vy -= fy;
  }

  // center + damping
  for (const node of nodes) {
    node.vx += (400 - node.x) * 0.002;
    node.vy += (400 - node.y) * 0.002;
    node.vx *= 0.8;
    node.vy *= 0.8;
    node.x += node.vx;
    node.y += node.vy;
  }
}

// draw
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 800);

const groupHues = [200, 350, 130];
const maxApp = Math.max(...characters.map(c => c.appearances));
const maxW = Math.max(...cooccurrence.map(e => e[2]));

// edges
for (const [a, b, w] of cooccurrence) {
  ctx.beginPath();
  ctx.moveTo(nodes[a].x, nodes[a].y);
  ctx.lineTo(nodes[b].x, nodes[b].y);
  ctx.strokeStyle = `rgba(100, 130, 180, ${0.05 + (w / maxW) * 0.3})`;
  ctx.lineWidth = 0.5 + (w / maxW) * 3;
  ctx.stroke();
}

// nodes
for (let i = 0; i < nodes.length; i++) {
  const n = nodes[i];
  const area = (n.appearances / maxApp) * 1200 + 80;
  const r = Math.sqrt(area / Math.PI);
  const hue = groupHues[n.group];

  // glow
  ctx.beginPath();
  ctx.arc(n.x, n.y, r + 4, 0, Math.PI * 2);
  ctx.fillStyle = `hsla(${hue}, 50%, 50%, 0.12)`;
  ctx.fill();

  // core
  ctx.beginPath();
  ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
  ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.7)`;
  ctx.fill();

  // label
  ctx.fillStyle = 'rgba(180, 190, 210, 0.5)';
  ctx.font = '9px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(n.name, n.x, n.y + r + 12);
}

The Protagonist dominates the center -- highest degree, most co-occurrences, pulled toward everyone. The Villain's group (red/pink) clusters separately but connects to the Protagonist through direct encounters and through the Spy who bridges both worlds. The Friend group (green) orbits the Rival, connected to the hero side but with their own internal density. The Elder and Narrator drift near the hero cluster but at the periphery -- they appear less frequently.

This is what network visualization does that no other technique can: it reveals relational structure. Not "how much" or "when" or "where" -- but "who connects to whom, and how tightly." The social architecture of a novel, visible in a single image. If you fed a real book through a paragraph co-occurrence counter (Project Gutenberg has plenty of free texts), the network would reveal the book's social structure in a way that reading the text cover-to-cover doesn't make explicit.

Radial layout: hierarchy as rings

Not all graphs are flat peer-to-peer networks. Some have hierarchy -- a root node with children, grandchildren, great-grandchildren. Organizational charts, file system trees, taxonomy classifications. A radial tree layout places the root at the center and arranges each level of the hierarchy as a concentric ring.

const canvas = document.createElement('canvas');
canvas.width = 700;
canvas.height = 700;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// build a tree: root has 4 children, each has 2-4 grandchildren
const tree = { id: 0, children: [] };
let nextId = 1;

for (let i = 0; i < 4; i++) {
  const child = { id: nextId++, children: [] };
  const numGrand = 2 + Math.floor(Math.random() * 3);
  for (let j = 0; j < numGrand; j++) {
    const grand = { id: nextId++, children: [] };
    // some grandchildren have leaves
    if (Math.random() < 0.5) {
      const numLeaves = 1 + Math.floor(Math.random() * 3);
      for (let k = 0; k < numLeaves; k++) {
        grand.children.push({ id: nextId++, children: [] });
      }
    }
    child.children.push(grand);
  }
  tree.children.push(child);
}

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 700, 700);

const cx = 350;
const cy = 350;

function drawRadialTree(node, startAngle, endAngle, depth) {
  const radius = depth * 80;
  const angle = (startAngle + endAngle) / 2;

  const x = cx + Math.cos(angle) * radius;
  const y = cy + Math.sin(angle) * radius;

  // draw edge to parent (except root)
  if (depth > 0) {
    const parentAngle = angle;
    const parentR = (depth - 1) * 80;
    const px = cx + Math.cos(parentAngle) * parentR;
    const py = cy + Math.sin(parentAngle) * parentR;

    ctx.beginPath();
    ctx.moveTo(px, py);
    ctx.lineTo(x, y);
    ctx.strokeStyle = `rgba(80, 120, 180, ${0.5 - depth * 0.1})`;
    ctx.lineWidth = 3 - depth * 0.5;
    ctx.stroke();
  }

  // draw node
  const r = depth === 0 ? 8 : 4;
  const hue = 200 + depth * 40;
  ctx.beginPath();
  ctx.arc(x, y, r, 0, Math.PI * 2);
  ctx.fillStyle = `hsla(${hue}, 50%, 55%, 0.7)`;
  ctx.fill();

  // recurse for children
  if (node.children.length > 0) {
    const sliceSize = (endAngle - startAngle) / node.children.length;
    for (let i = 0; i < node.children.length; i++) {
      const childStart = startAngle + i * sliceSize;
      const childEnd = childStart + sliceSize;
      drawRadialTree(node.children[i], childStart, childEnd, depth + 1);
    }
  }
}

drawRadialTree(tree, 0, Math.PI * 2, 0);

The root sits at the center. Four branches radiate outward, each claiming a quarter of the circle (roughly). Grandchildren subdivide their parent's angular range. Leaves sit at the outermost ring. The radial layout uses space efficiently -- linear tree layouts waste horizontal space because deep trees get very wide, but the radial tree wraps everything into a compact circle.

The color shifts from blue (root) to warmer tones (leaves), giving you a visual depth cue. Edge thickness decreases with depth too -- the trunk is thick, the branches thin, matching the visual metaphor of an actual tree.

What's coming

We've got geography (ep083), time (ep084), and now networks (this episode) -- three fundamentally different data structures, each with its own visual language. But most real-world data isn't neatly geographic, temporal, or relational. It's messy text -- articles, tweets, logs, books. Extracting visual patterns from text requires a different set of techniques: word frequency, sentiment analysis, linguistic structure. And text happens to be one of the richest sources of creative raw material when you know how to parse it.

't Komt erop neer...

Sallukes! Thanks for reading.

X

@femdev

TAGS: [ #stem ] [ #stemsocial ] [ #steemstem ] [ #programming ] [ #creativecoding ]

Replies

NO REPLIES FOUND.

[ BACK TO TRENDING ] [ BACK TO MENU ]
CMD>