|
22 | 22 | const MAX_CENTROID_GROUPS = 512; |
23 | 23 |
|
24 | 24 | let model = null; |
25 | | - let settings = { repel: 48, link: 16, gravity: 48 }; |
| 25 | + let settings = { |
| 26 | + repel: 48, link: 16, gravity: 48, |
| 27 | + gravitationalConstant: 1, blackHoleMass: 1, localGravitationalConstant: 1, |
| 28 | + damping: 1, springStiffness: 1, |
| 29 | + }; |
26 | 30 | let generation = 0; |
27 | 31 |
|
28 | 32 | function post(message) { self.postMessage(message); } |
|
217 | 221 | springs run weak — they are visual routes between districts, not licence to drag |
218 | 222 | the districts into one another over the settle passes. */ |
219 | 223 | const scaledSpacing = SPACING * MAP_SCALE; |
| 224 | + const spring = Number.isFinite(Number(settings.springStiffness)) |
| 225 | + ? Math.max(0, Math.min(100 / 32, Number(settings.springStiffness))) : 1; |
| 226 | + // springStiffness is already a normalized multiplier from the dashboard. Preserve its |
| 227 | + // zero endpoint so the Link spring control can actually disable pair attraction. |
| 228 | + const springScale = spring; |
220 | 229 | const rest = Math.max(scaledSpacing * 1.9, Number(settings.link) * 1.6 * (MAP_SCALE * 0.55)); |
221 | 230 | for (let edge = 0; edge < model.totalLinks; edge += 1) { |
222 | 231 | const a = model.sources[edge], b = model.targets[edge]; |
223 | 232 | const ddx = pos[b * 2] - pos[a * 2], ddy = pos[b * 2 + 1] - pos[a * 2 + 1]; |
224 | 233 | const dist = Math.sqrt(ddx * ddx + ddy * ddy) || 0.0001; |
225 | 234 | const crossCommunity = |
226 | 235 | model.communities[a] !== model.communities[b] ? 0.02 : 0.07; |
227 | | - const force = (dist - rest) / dist * crossCommunity; |
228 | | - dx[a] -= ddx * force; dy[a] -= ddy * force; |
229 | | - dx[b] += ddx * force; dy[b] += ddy * force; |
| 236 | + const force = (dist - rest) / dist * crossCommunity * springScale; |
| 237 | + /* `force` is positive when the pair is beyond rest and negative when it overlaps. |
| 238 | + Apply equal-and-opposite corrections along a→b so positive force attracts the pair |
| 239 | + and negative force separates it. */ |
| 240 | + dx[a] += ddx * force; dy[a] += ddy * force; |
| 241 | + dx[b] -= ddx * force; dy[b] -= ddy * force; |
230 | 242 | } |
231 | 243 |
|
232 | 244 | /* Local repulsion through a spatial hash with a per-node visit cap keeps each pass O(n) |
|
241 | 253 | } |
242 | 254 | const minDist = SPACING * MAP_SCALE * 1.55; |
243 | 255 | const minDist2 = minDist * minDist; |
244 | | - const push = Number(settings.repel) / 48; |
| 256 | + /* The dashboard maps the 0..200 Cluster cohesion slider to localGravitationalConstant |
| 257 | + 0..4, so clamping at 2 left the entire upper half of the control inert: it is this |
| 258 | + worker's only consumer of the setting (PR #177 review thread at this site). Accept |
| 259 | + the full emitted range and floor the inverted push coefficient at zero so a high |
| 260 | + cohesion cannot turn the collision-style push into an attraction. */ |
| 261 | + const cohesion = Number.isFinite(Number(settings.localGravitationalConstant)) |
| 262 | + ? Math.max(0, Math.min(4, Number(settings.localGravitationalConstant))) : 1; |
| 263 | + /* Cluster cohesion strengthens the attractive spring network above. Invert its influence |
| 264 | + on the collision-style push so a higher cohesion setting does not spread clusters apart. */ |
| 265 | + const push = Number(settings.repel) / 48 * Math.max(0, 1.5 - 0.5 * cohesion); |
245 | 266 | for (let index = 0; index < count; index += 1) { |
246 | 267 | const gx = Math.floor(pos[index * 2] / cell), gy = Math.floor(pos[index * 2 + 1] / cell); |
247 | 268 | let checked = 0; |
|
314 | 335 | } |
315 | 336 | } |
316 | 337 |
|
317 | | - const gravity = Number(settings.gravity) / 48 * 0.0015; |
| 338 | + /* The dashboard emits Core attraction/local cohesion over 0..4 (raw/50) and Core mass |
| 339 | + up to 4.4; clamping at 2 left the upper half of those controls inert (PR #177 review |
| 340 | + thread at this site). Accept the full emitted ranges. */ |
| 341 | + const coreAttraction = Number.isFinite(Number(settings.gravitationalConstant)) |
| 342 | + ? Math.max(0, Math.min(4, Number(settings.gravitationalConstant))) : 1; |
| 343 | + const coreMass = Number.isFinite(Number(settings.blackHoleMass)) |
| 344 | + ? Math.max(0, Math.min(4.4, Number(settings.blackHoleMass))) : 1; |
| 345 | + const gravity = Number(settings.gravity) / 48 * 0.0015 * coreAttraction * coreMass; |
318 | 346 | for (let index = 0; index < count; index += 1) { |
319 | 347 | dx[index] += (cx - pos[index * 2]) * gravity; |
320 | 348 | dy[index] += (cy - pos[index * 2 + 1]) * gravity; |
321 | 349 | } |
322 | 350 |
|
323 | 351 | /* A tight per-pass step cap keeps the settle from smearing district boundaries. */ |
324 | | - const damp = 0.8, maxStep = SPACING * MAP_SCALE * 0.7; |
| 352 | + const resistance = Number.isFinite(Number(settings.damping)) |
| 353 | + ? Math.max(0, Math.min(15, Number(settings.damping))) : 1; |
| 354 | + /* Keep the full 0..15 control range responsive: the reciprocal curve reaches 0.2 |
| 355 | + at resistance 13, so a 0.2 floor would make the final two slider units inert. */ |
| 356 | + const damp = Math.max(0.15, Math.min(0.95, 0.8 / (0.75 + 0.25 * resistance))); |
| 357 | + const maxStep = SPACING * MAP_SCALE * 0.7; |
325 | 358 | for (let index = 0; index < count; index += 1) { |
326 | 359 | let vx = dx[index] * damp, vy = dy[index] * damp; |
327 | 360 | const speed = Math.sqrt(vx * vx + vy * vy); |
|
409 | 442 | repel: Number.isFinite(Number(next.repel)) ? Number(next.repel) : settings.repel, |
410 | 443 | link: Number.isFinite(Number(next.link)) ? Number(next.link) : settings.link, |
411 | 444 | gravity: Number.isFinite(Number(next.gravity)) ? Number(next.gravity) : settings.gravity, |
| 445 | + gravitationalConstant: Number.isFinite(Number(next.gravitationalConstant)) |
| 446 | + ? Number(next.gravitationalConstant) : settings.gravitationalConstant, |
| 447 | + blackHoleMass: Number.isFinite(Number(next.blackHoleMass)) |
| 448 | + ? Number(next.blackHoleMass) : settings.blackHoleMass, |
| 449 | + localGravitationalConstant: Number.isFinite(Number(next.localGravitationalConstant)) |
| 450 | + ? Number(next.localGravitationalConstant) : settings.localGravitationalConstant, |
| 451 | + damping: Number.isFinite(Number(next.damping)) ? Number(next.damping) : settings.damping, |
| 452 | + springStiffness: Number.isFinite(Number(next.springStiffness)) |
| 453 | + ? Number(next.springStiffness) : settings.springStiffness, |
412 | 454 | }; |
413 | 455 | if (data.relayout && model) { |
414 | 456 | generation += 1; |
|
0 commit comments