File size: 1,544 Bytes
4319071
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import * as d3 from 'd3';

/**
 * Overlap removal via D3 force simulation — minimum displacement strategy.
 *
 * forceCollide only pushes pairs that actually overlap; points already well
 * separated don't move at all. A weak forceX/forceY pulls every point back
 * toward its original UMAP position, so the layout drifts as little as possible.
 *
 * @param {Array<{id: string, x: number, y: number}>} points  - initial screen-space positions
 * @param {Object} opts
 * @param {number} opts.collideRadius   - collision radius per glyph in pixels
 * @param {number} opts.ticks           - number of simulation steps (more = fuller resolution)
 * @param {number} opts.originStrength  - pull-back toward UMAP origin [0, 1] (keep small, e.g. 0.1)
 * @returns {Array<{id: string, x: number, y: number}>}
 */
export function applyOverlapRemoval(points, { collideRadius = 10, ticks = 140, originStrength = 0.03 }) {
  if (ticks === 0 || collideRadius === 0 || points.length < 2) return points;

  // d3 forceSimulation mutates node objects in-place
  const nodes = points.map(p => ({ id: p.id, x: p.x, y: p.y, ox: p.x, oy: p.y }));

  const sim = d3.forceSimulation(nodes)
    .force('collide', d3.forceCollide(collideRadius).strength(1).iterations(3))
    .force('x', d3.forceX(n => n.ox).strength(originStrength))
    .force('y', d3.forceY(n => n.oy).strength(originStrength))
    .stop();

  // Run synchronously (no animation loop needed)
  for (let i = 0; i < ticks; i++) sim.tick();

  return nodes.map(n => ({ id: n.id, x: n.x, y: n.y }));
}