| 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 })); | |
| } | |