Creating a Force Simulation Chart with Svelte 5 and D3

Introduction

Let’s build a dynamic force-directed layout using D3’s physics simulation in Svelte 5. This tutorial will cover how to effectively arrange one-dimensional ranking data into a two-dimensional space while preventing overlaps. By leveraging force-based positioning, we can create a structured and visually organized chart that clearly represents our data.

Top Runner Influencers - by follower count

What is a Force Simulation?

D3’s force simulation helps arrange elements dynamically using physics-based rules. Each node (circle) is treated as a particle in a system, and forces like attraction, repulsion, and collision detection determine its position.

We have a list of influencers, each represented as a circle, where the size of the circle corresponds to the rank by their follower count on X —the more followers, the bigger the circle. Since this is one-dimensional data (just a ranking), we need to arrange it in a two-dimensional space in a way that prevents circles from overlapping. To achieve this, we use a force simulation that distributes them evenly around a central point, forming a visually balanced layout.

Data

You can download the data here

Setting Up Svelte 5 and D3

To get started, ensure you have Svelte 5 installed and import necessary D3 modules. We’ll focus on forceSimulation, forceCenter, and forceCollide to create our chart.

Code Implementation

Import Dependencies and Setup Data

import { onMount } from 'svelte';
import {
  forceSimulation,
  forceCenter,
  forceCollide,
  type Simulation,
  type SimulationNodeDatum
} from 'd3-force';
import data from './influencers.js';

interface DataPoint extends SimulationNodeDatum {
  username: string;
  img: string;
  ranking: number;
}

const baseWidth = 800;
const baseHeight = 500;
const scalingFactor = 1.6; // Add padding around edges

const innerWidth = baseWidth * scalingFactor;
const innerHeight = baseHeight * scalingFactor;

const maxRanking = Math.max(...data.map((d) => d.ranking));
let processedData: DataPoint[] = [];

Applying Force Simulation

const runSimulation = () => {
  const simulation = forceSimulation<DataPoint>(data.map((d) => ({ ...d })))
    .force('center', forceCenter(innerWidth / 2, innerHeight / 2))
    .force(
      'collide',
      forceCollide<DataPoint>().radius((d) => (d.ranking / maxRanking) * 50)
    );

  simulation.tick(300);

  processedData = simulation.nodes().map((d) => ({
    ...d,
    x: Math.min(
      Math.max(d.x, (d.ranking / maxRanking) * 50),
      innerWidth - (d.ranking / maxRanking) * 50
    ),
    y: Math.min(
      Math.max(d.y, (d.ranking / maxRanking) * 50),
      innerHeight - (d.ranking / maxRanking) * 50
    )
  }));
};

onMount(runSimulation);

Simulation

In our simulation, we use the forceSimulation function from D3 to create a force-directed layout. We apply two forces:

  1. force('center', forceCenter(innerWidth / 2, innerHeight / 2)): This force attracts nodes to the center of the chart.
  2. force('collide', forceCollide<DataPoint>().radius((d) => (d.ranking / maxRanking) * 50)): This force prevents nodes from overlapping by repelling them from each other.

Since the radius of each circle is equal to d.ranking / maxRanking * 50, we use this value to set the collide radius.

const simulation = forceSimulation<DataPoint>(data.map((d) => ({ ...d })))
      .force('center', forceCenter(innerWidth / 2, innerHeight / 2))
      .force(
        'collide',
        forceCollide<DataPoint>().radius((d) => (d.ranking / maxRanking) * 50)
      );

Rendering the SVG Chart

<h2 class="text-2xl">Top Runner Influencers - by follower count</h2>

<div class="w-full h-auto">
  {#if processedData.length}
    <svg
      width="100%"
      height="100%"
      viewBox="0 0 {innerWidth} {innerHeight}"
      preserveAspectRatio="xMidYMid meet">
      {#each processedData as d, i}
        <circle
          title={d.username}
          class="cursor-pointer"
          r={(d.ranking / maxRanking) * 50}
          cx={d.x}
          cy={d.y}
          fill="url(#image{i})" />
      {/each}
      <defs>
        {#each processedData as d, i}
          <pattern
            id={'image' + i}
            patternUnits="objectBoundingBox"
            width="100%"
            height="100%">
            <image
              x="0"
              y="0"
              width={(d.ranking / maxRanking) * 100}
              height={(d.ranking / maxRanking) * 100}
              xlink:href={d.img} />
          </pattern>
        {/each}
      </defs>
    </svg>
  {/if}
</div>

Full Code preview

<script lang="ts">
  import { onMount } from 'svelte';
  import {
    forceSimulation,
    forceCenter,
    forceCollide,
    type Simulation,
    type SimulationNodeDatum
  } from 'd3-force';
  import data from './influencers.js';

  interface DataPoint extends SimulationNodeDatum {
    username: string;
    img: string;
    ranking: number;
  }

  const baseWidth = 800;
  const baseHeight = 500;
  const scalingFactor = 1.6; // Add padding around edges

  const innerWidth = baseWidth * scalingFactor;
  const innerHeight = baseHeight * scalingFactor;

  const maxRanking = Math.max(...data.map((d) => d.ranking));
  let processedData: DataPoint[] = [];

  const runSimulation = () => {
    const simulation = forceSimulation<DataPoint>(data.map((d) => ({ ...d })))
      .force('center', forceCenter(innerWidth / 2, innerHeight / 2))
      .force(
        'collide',
        forceCollide<DataPoint>().radius((d) => (d.ranking / maxRanking) * 50)
      );

    simulation.tick(300);

    processedData = simulation.nodes().map((d) => ({
      ...d,
      x: Math.min(
        Math.max(d.x, (d.ranking / maxRanking) * 50),
        innerWidth - (d.ranking / maxRanking) * 50
      ),
      y: Math.min(
        Math.max(d.y, (d.ranking / maxRanking) * 50),
        innerHeight - (d.ranking / maxRanking) * 50
      )
    }));
  };

  onMount(runSimulation);
</script>

<h2 class="text-2xl">Top Runner Influencers - by follower count</h2>

<div class="w-full h-auto">
  {#if processedData.length}
    <svg
      width="100%"
      height="100%"
      viewBox="0 0 {innerWidth} {innerHeight}"
      preserveAspectRatio="xMidYMid meet">
      {#each processedData as d, i}
        <circle
          title={d.username}
          class="cursor-pointer"
          r={(d.ranking / maxRanking) * 50}
          cx={d.x}
          cy={d.y}
          fill="url(#image{i})" />
      {/each}
      <defs>
        {#each processedData as d, i}
          <pattern
            id={'image' + i}
            patternUnits="objectBoundingBox"
            width="100%"
            height="100%">
            <image
              x="0"
              y="0"
              width={(d.ranking / maxRanking) * 100}
              height={(d.ranking / maxRanking) * 100}
              xlink:href={d.img} />
          </pattern>
        {/each}
      </defs>
    </svg>
  {/if}
</div>

Conclusion

By using forceSimulation, we dynamically position nodes in a way that prevents overlap and ensures a structured layout. You can enhance this further by adding interactions like tooltips, drag behavior, or animations.

Join our Discord to share your charts, ask questions, or collaborate with fellow developers!