layOutClouds
Lay n dots out as clouds, a step at a time (each yield a place to let other work run): the clouds sized by what
they hold and pulled together by the references between them (refs, as CodeMap has them), their puffs
packed inside them, and each dot near its puff's middle, drawn towards what it references.
import { arrangeCircles } from "#codebase/tcmts/share"; import type { Cloud, CloudLayout } from "#codebase/tcmts/share"; export function* layOutClouds( groups: { label: string; ns?: string; loose?: boolean; puffs: { label: string; ns: string; members: number[] }[]; }[], n: number, refs: number[], ): Generator<void, CloudLayout> { const cloud = Array.from({ length: n }, () => 0); const puff = Array.from({ length: n }, () => 0); groups.forEach((g, gi) => g.puffs.forEach((p, pi) => p.members.forEach((j) => ((cloud[j] = gi), (puff[j] = pi)))), ); // (room for each dot: a puff of k dots is about as big as k dots spread out) const radius = (k: number) => Math.sqrt((k * 150) / Math.PI) + 6; const clouds: Cloud[] = groups.map((g) => { const puffs = g.puffs.map((p) => ({ label: p.label, ns: p.ns, x: 0, y: 0, r: radius(p.members.length), n: p.members.length, })); const r = Math.sqrt((puffs.reduce((t, p) => t + Math.PI * (p.r + 2) ** 2, 0) * 1.1) / Math.PI) + 10; return { label: g.label, ...(g.ns !== undefined && { ns: g.ns }), ...(g.loose && { loose: true }), x: 0, y: 0, r, n: puffs.reduce((t, p) => t + p.n, 0), puffs, }; }); const tally = (pair: (a: number, b: number) => [number, number] | undefined) => { const counts = new Map<string, number>(); for (let k = 0; k < refs.length; k += 2) { const ab = pair(refs[k]!, refs[k + 1]!); if (!ab) continue; const key = `${Math.min(...ab)} ${Math.max(...ab)}`; counts.set(key, (counts.get(key) ?? 0) + 1); } return [...counts] .map(([key, w]): [number, number, number] => { const [a, b] = key.split(" ").map(Number); return [a!, b!, w]; }) .sort((x, y) => y[2] - x[2]); }; const links = tally((a, b) => (cloud[a] !== cloud[b] ? [cloud[a]!, cloud[b]!] : undefined)); arrangeCircles(clouds, links, { gap: 60, steps: 900, aspect: 1.6 }); yield; for (const [gi, c] of clouds.entries()) { const inside = tally((a, b) => cloud[a] === gi && cloud[b] === gi && puff[a] !== puff[b] ? [puff[a]!, puff[b]!] : undefined, ); arrangeCircles(c.puffs, inside, { within: c, gap: -2, steps: 500 }); yield; } // the dots: scattered in their puffs (by a seeded random, so a head's map is always the same), then settled let seed = 7; const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647; const x = new Float64Array(n); const y = new Float64Array(n); const puffOf = (j: number) => clouds[cloud[j]!]!.puffs[puff[j]!]!; for (let j = 0; j < n; j++) { const p = puffOf(j); const a = random() * Math.PI * 2; const d = Math.sqrt(random()) * p.r * 0.8; x[j] = p.x + Math.cos(a) * d; y[j] = p.y + Math.sin(a) * d; } const STEPS = 260; const CELL = 14; for (let step = 0; step < STEPS; step++) { const heat = 1 - step / STEPS; const fx = new Float64Array(n); const fy = new Float64Array(n); // each reference pulls its two ends together: hard in a puff, gently across puffs, barely across clouds for (let k = 0; k < refs.length; k += 2) { const a = refs[k]!; const b = refs[k + 1]!; const pull = cloud[a] !== cloud[b] ? 0.03 : puff[a] === puff[b] ? 1 : 0.25; const dx = x[b]! - x[a]!; const dy = y[b]! - y[a]!; const d = Math.hypot(dx, dy) || 1; const f = (d - 9) * 0.01 * pull; fx[a]! += (dx / d) * f; fy[a]! += (dy / d) * f; fx[b]! -= (dx / d) * f; fy[b]! -= (dy / d) * f; } // dots closer than a cell push apart (neighbours found by a grid, not every pair) const grid = new Map<number, number[]>(); const cellOf = (gx: number, gy: number) => gx * 100003 + gy; for (let j = 0; j < n; j++) { const key = cellOf(Math.floor(x[j]! / CELL), Math.floor(y[j]! / CELL)); const there = grid.get(key); if (there) there.push(j); else grid.set(key, [j]); } for (let a = 0; a < n; a++) { const gx = Math.floor(x[a]! / CELL); const gy = Math.floor(y[a]! / CELL); for (let i = -1; i <= 1; i++) { for (let k = -1; k <= 1; k++) { for (const b of grid.get(cellOf(gx + i, gy + k)) ?? []) { if (b <= a) continue; const dx = x[b]! - x[a]!; const dy = y[b]! - y[a]!; const d2 = dx * dx + dy * dy; if (d2 >= CELL * CELL) continue; const d = Math.sqrt(d2) || 0.01; const f = (CELL - d) * 0.25; fx[a]! -= (dx / d) * f; fy[a]! -= (dy / d) * f; fx[b]! += (dx / d) * f; fy[b]! += (dy / d) * f; } } } } // and each is held to its puff: loosely inside it, firmly once it strays out for (let j = 0; j < n; j++) { const p = puffOf(j); const dx = p.x - x[j]!; const dy = p.y - y[j]!; const hold = Math.hypot(dx, dy) > p.r ? 0.08 : 0.012; fx[j]! += dx * hold; fy[j]! += dy * hold; const most = 6 * heat + 0.5; const m = Math.hypot(fx[j]!, fy[j]!); const k = m > most ? most / m : 1; x[j]! += fx[j]! * k; y[j]! += fy[j]! * k; } yield; } const tenth = (v: number) => Math.round(v * 10) / 10; return { clouds: clouds.map((c) => ({ ...c, x: tenth(c.x), y: tenth(c.y), r: tenth(c.r), puffs: c.puffs.map((p) => ({ ...p, x: tenth(p.x), y: tenth(p.y), r: tenth(p.r) })), })), cloud, puff, x: [...x].map(Math.round), y: [...y].map(Math.round), links, }; }