Created
May 24, 2026 21:35
-
-
Save robert-moore/8487fd71407c45c9700f0268ecd77364 to your computer and use it in GitHub Desktop.
Label layout helper using D3 force simulation — featured in The Nine Thirty-Six, Issue 06
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import * as d3 from 'd3'; | |
| export interface IDecoratedForceNode extends d3.SimulationNodeDatum { | |
| width: number; | |
| targetX: number; | |
| } | |
| function boundedTick(simulation: d3.Simulation<any, any>, boundMin = 0, boundMax = 1) { | |
| simulation.tick(); | |
| simulation.nodes().forEach((d: any) => { | |
| const x = d.x; | |
| if (x < boundMin + d.width / 2) { | |
| d.x = d.width; | |
| } else if (x > boundMax - d.width / 2) { | |
| d.x = boundMax; | |
| } | |
| d.y = 0; | |
| }); | |
| } | |
| export function layoutUniformWidth(anchors: number[], itemWidth: number, boundMin = 0, boundMax = 1) { | |
| const nodes = anchors.map((d) => ({ | |
| width: itemWidth, | |
| targetX: d, | |
| x: d + (Math.random() - 0.5) * 0.01 * itemWidth, | |
| y: 0, | |
| vx: 0, | |
| vy: 0, | |
| })); | |
| const simulation = d3 | |
| .forceSimulation(nodes) | |
| .force('x', d3.forceX((d: IDecoratedForceNode) => d.targetX).strength(0.01)) | |
| // .force('charge', d3.forceManyBody().strength(-itemWidth)) | |
| .force('y', d3.forceY(0).strength(1)) | |
| .force( | |
| 'collide', | |
| d3 | |
| .forceCollide() | |
| .radius((d: any) => d.width / 2) | |
| .strength(1) | |
| .iterations(5) | |
| ) | |
| .stop(); | |
| for (let i = 0; i < 100; ++i) { | |
| boundedTick(simulation, boundMin, boundMax); | |
| } | |
| return nodes.map((d) => d.x); | |
| } | |
| export function layoutVariableWidth(items: { x: number; width: number }[], boundMin = 0, boundMax = 1) { | |
| const nodes = items.map((d) => { | |
| let x = d.x; | |
| if (x < boundMin + d.width / 2) { | |
| x = d.width / 2; | |
| } else if (x > boundMax - d.width / 2) { | |
| x = boundMax - d.width / 2; | |
| } | |
| return { | |
| width: d.width, | |
| targetX: x, | |
| x, | |
| y: 0, | |
| vx: 0, | |
| vy: 0, | |
| }; | |
| }); | |
| const simulation = d3 | |
| .forceSimulation(nodes) | |
| .force('x', d3.forceX((d: IDecoratedForceNode) => d.targetX).strength(0.1)) | |
| .force('y', d3.forceY(0).strength(1)) | |
| .force( | |
| 'collide', | |
| d3 | |
| .forceCollide() | |
| .radius((d: any) => d.width / 2) | |
| .strength(1) | |
| .iterations(3) | |
| ) | |
| .stop(); | |
| for (let i = 0; i < 100; ++i) { | |
| boundedTick(simulation, boundMin, boundMax); | |
| } | |
| return nodes.map((d) => d.x); | |
| } | |
| // todo - left align, right align | |
| // todo - automatic shrink | |
| // including giving each node several different widths, | |
| // and shrinking automatically if squeeze pressure exceeds threshold |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment