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:
force('center', forceCenter(innerWidth / 2, innerHeight / 2))
: This force attracts nodes to the center of the chart.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!