cosmograph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/World.js ADDED
@@ -0,0 +1,534 @@
1
+ import { Node } from './Node.js'
2
+ import { resolveShape } from './shapes.js'
3
+ import { QuadTree } from './QuadTree.js'
4
+
5
+ function rand(a, b) {
6
+ return a + Math.random() * (b - a)
7
+ }
8
+
9
+ function gaussianRand(minR, maxR) {
10
+ let u = 0, v = 0
11
+ while (u === 0) u = Math.random()
12
+ while (v === 0) v = Math.random()
13
+ const n = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v)
14
+ const normalized = Math.min(Math.max((n + 3) / 6, 0), 1)
15
+ return minR + (maxR - minR) * normalized
16
+ }
17
+
18
+ function hexToRgb(hex) {
19
+ const h = hex.replace('#', '')
20
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
21
+ }
22
+
23
+ function lerpColor(colors, t) {
24
+ if (colors.length === 1) return colors[0]
25
+ const scaled = Math.max(0, Math.min(1, t)) * (colors.length - 1)
26
+ const i = Math.min(Math.floor(scaled), colors.length - 2)
27
+ const f = scaled - i
28
+ const [r1, g1, b1] = hexToRgb(colors[i])
29
+ const [r2, g2, b2] = hexToRgb(colors[i + 1])
30
+ return `rgb(${Math.round(r1 + (r2 - r1) * f)},${Math.round(g1 + (g2 - g1) * f)},${Math.round(b1 + (b2 - b1) * f)})`
31
+ }
32
+
33
+ function spawnPosition(spawnRegion, width, height) {
34
+ if (typeof spawnRegion === 'function') {
35
+ return spawnRegion(width, height)
36
+ }
37
+ if (spawnRegion === 'center') {
38
+ return { x: width * 0.25 + Math.random() * width * 0.5, y: height * 0.25 + Math.random() * height * 0.5 }
39
+ }
40
+ if (spawnRegion === 'edges') {
41
+ const zone = Math.min(width, height) * 0.2
42
+ const edge = Math.floor(Math.random() * 4)
43
+ if (edge === 0) return { x: Math.random() * width, y: Math.random() * zone }
44
+ if (edge === 1) return { x: Math.random() * width, y: height - Math.random() * zone }
45
+ if (edge === 2) return { x: Math.random() * zone, y: Math.random() * height }
46
+ return { x: width - Math.random() * zone, y: Math.random() * height }
47
+ }
48
+ return { x: Math.random() * width, y: Math.random() * height }
49
+ }
50
+
51
+ const DEFAULTS = {
52
+ // Nodes
53
+ nodeCount: 60,
54
+ nodeSize: [0.8, 2.8],
55
+ colors: ['#ffffff'],
56
+ nodeSizeDistribution: 'uniform',
57
+ nodeColorMode: 'random',
58
+ nodeSpawnRegion: 'full',
59
+ nodeRotation: false,
60
+
61
+ // Edges
62
+ edgeMaxDist: 180,
63
+ edgeMaxOpacity: 0.18,
64
+ edgeWidth: 0.5,
65
+ edgeColors: null,
66
+ edgeStyle: 'solid',
67
+ edgeColorMode: 'alternate',
68
+ maxEdgesPerNode: null,
69
+ minEdgesPerNode: null,
70
+ edgeCurvature: 0,
71
+
72
+ // Node shape
73
+ nodeShape: 'circle',
74
+
75
+ // Glow
76
+ glowOnLargeNodes: true,
77
+ glowThreshold: 2,
78
+ glowScale: 4,
79
+ glowOpacity: 0.25,
80
+
81
+ // Rendering
82
+ pixelRatio: 'auto',
83
+ blendMode: 'source-over',
84
+ renderOrder: 'edges-first',
85
+ background: null,
86
+
87
+ // Callbacks
88
+ onFrame: null,
89
+ onNodeHover: null,
90
+ onNodeLeave: null,
91
+ onNodeClick: null,
92
+
93
+ // Performance
94
+ pauseWhenHidden: true,
95
+ maxEdgesPerFrame: null,
96
+
97
+ // Use QuadTree for edge distance queries — O(n log n) instead of O(n²), useful above ~200 nodes
98
+ spatialIndex: false,
99
+
100
+ // When false: World never sets canvas.width/height — caller is responsible for sizing
101
+ autoResize: true,
102
+
103
+ // Pause RAF when canvas is not visible in the viewport (uses IntersectionObserver)
104
+ pauseWhenOffscreen: false,
105
+ }
106
+
107
+ export class World {
108
+ constructor({ canvas, forces = [], ...options } = {}) {
109
+ this.canvas = canvas
110
+ this.ctx = canvas.getContext('2d')
111
+ this.forces = forces
112
+ this.options = { ...DEFAULTS, ...options }
113
+ if (!this.options.edgeColors) {
114
+ this.options.edgeColors = this.options.colors
115
+ }
116
+
117
+ this.nodes = []
118
+ this.raf = null
119
+ this.mouse = null
120
+ this.scrollY = 0
121
+ this._hoveredNode = null
122
+
123
+ this._onMouseMove = this._onMouseMove.bind(this)
124
+ this._onScroll = this._onScroll.bind(this)
125
+ this._onResize = this._onResize.bind(this)
126
+ this._onVisibilityChange = this._onVisibilityChange.bind(this)
127
+ this._onClick = this._onClick.bind(this)
128
+ this._loop = this._loop.bind(this)
129
+ }
130
+
131
+ _sampleNodeRadius(minR, maxR) {
132
+ const dist = this.options.nodeSizeDistribution
133
+ if (dist === 'gaussian') return gaussianRand(minR, maxR)
134
+ if (dist === 'weighted-small') return minR + (maxR - minR) * Math.pow(Math.random(), 2)
135
+ return rand(minR, maxR)
136
+ }
137
+
138
+ _createNodes() {
139
+ const { width, height } = this.canvas
140
+ const opts = this.options
141
+ const { nodeCount, nodeSize, colors, nodeColorMode, nodeSpawnRegion, nodeRotation } = opts
142
+ const [minR, maxR] = Array.isArray(nodeSize) ? nodeSize : [nodeSize, nodeSize]
143
+
144
+ const rawNodes = Array.from({ length: nodeCount }, (_, i) => {
145
+ const pos = spawnPosition(nodeSpawnRegion, width, height)
146
+ const r = this._sampleNodeRadius(minR, maxR)
147
+ const node = new Node({
148
+ x: pos.x,
149
+ y: pos.y,
150
+ r,
151
+ vx: rand(-0.08, 0.08),
152
+ vy: rand(-0.08, 0.08),
153
+ color: colors[Math.floor(Math.random() * colors.length)],
154
+ phase: Math.random() * Math.PI * 2,
155
+ twinkleSpeed: rand(0.001, 0.003),
156
+ })
157
+ node._index = i
158
+ if (nodeRotation) {
159
+ node.angle = 0
160
+ node.angularVelocity = rand(-0.02, 0.02)
161
+ }
162
+ return node
163
+ })
164
+
165
+ if (nodeColorMode === 'by-size') {
166
+ const sorted = [...rawNodes].sort((a, b) => a.r - b.r)
167
+ sorted.forEach((node, idx) => {
168
+ const t = sorted.length === 1 ? 0 : idx / (sorted.length - 1)
169
+ const colorIdx = Math.min(Math.floor(t * colors.length), colors.length - 1)
170
+ node.color = colors[colorIdx]
171
+ })
172
+ } else if (nodeColorMode === 'sequential') {
173
+ rawNodes.forEach((node, i) => {
174
+ node.color = colors[i % colors.length]
175
+ })
176
+ } else if (nodeColorMode === 'gradient') {
177
+ rawNodes.forEach(node => {
178
+ node.color = lerpColor(colors, node.x / width)
179
+ })
180
+ } else if (nodeColorMode === 'by-position') {
181
+ rawNodes.forEach(node => {
182
+ node.color = lerpColor(colors, (node.x / width + node.y / height) / 2)
183
+ })
184
+ }
185
+
186
+ this.nodes = rawNodes
187
+ }
188
+
189
+ _resize() {
190
+ if (!this.options.autoResize) {
191
+ this._logicalWidth = this.canvas.width
192
+ this._logicalHeight = this.canvas.height
193
+ this._createNodes()
194
+ return
195
+ }
196
+
197
+ const dpr = this.options.pixelRatio === 'auto' ? (window.devicePixelRatio ?? 1) : this.options.pixelRatio
198
+ const logicalWidth = window.innerWidth
199
+ const logicalHeight = document.documentElement.scrollHeight
200
+
201
+ this.canvas.width = logicalWidth * dpr
202
+ this.canvas.height = logicalHeight * dpr
203
+ this.canvas.style.width = logicalWidth + 'px'
204
+ this.canvas.style.height = logicalHeight + 'px'
205
+
206
+ this._dpr = dpr
207
+ this._logicalWidth = logicalWidth
208
+ this._logicalHeight = logicalHeight
209
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
210
+ this._createNodes()
211
+ }
212
+
213
+ _onResize() {
214
+ this._resize()
215
+ }
216
+
217
+ _onMouseMove(e) {
218
+ let mx, my
219
+ if (this.options.autoResize) {
220
+ mx = e.clientX
221
+ my = e.clientY + window.scrollY
222
+ } else {
223
+ const rect = this.canvas.getBoundingClientRect()
224
+ mx = e.clientX - rect.left
225
+ my = e.clientY - rect.top
226
+ }
227
+ this.mouse = { x: mx, y: my }
228
+
229
+ const { onNodeHover, onNodeLeave } = this.options
230
+ if (!onNodeHover && !onNodeLeave) return
231
+
232
+ let found = null
233
+ for (const node of this.nodes) {
234
+ if (Math.hypot(node.x - mx, node.y - my) < node.r * 3) {
235
+ found = node
236
+ break
237
+ }
238
+ }
239
+
240
+ if (found !== this._hoveredNode) {
241
+ if (this._hoveredNode && onNodeLeave) onNodeLeave(this._hoveredNode)
242
+ if (found && onNodeHover) onNodeHover(found)
243
+ this._hoveredNode = found
244
+ }
245
+ }
246
+
247
+ _onScroll() {
248
+ this.scrollY = window.scrollY
249
+ }
250
+
251
+ _onVisibilityChange() {
252
+ if (document.hidden) {
253
+ cancelAnimationFrame(this.raf)
254
+ this.raf = null
255
+ } else if (this.raf === null) {
256
+ this.raf = requestAnimationFrame(this._loop)
257
+ }
258
+ }
259
+
260
+ _onClick(e) {
261
+ const { onNodeClick } = this.options
262
+ if (!onNodeClick) return
263
+ let mx, my
264
+ if (this.options.autoResize) {
265
+ mx = e.clientX
266
+ my = e.clientY + window.scrollY
267
+ } else {
268
+ const rect = this.canvas.getBoundingClientRect()
269
+ mx = e.clientX - rect.left
270
+ my = e.clientY - rect.top
271
+ }
272
+ for (const node of this.nodes) {
273
+ if (Math.hypot(node.x - mx, node.y - my) < node.r * 3) {
274
+ onNodeClick(node)
275
+ break
276
+ }
277
+ }
278
+ }
279
+
280
+ resize() {
281
+ this._resize()
282
+ }
283
+
284
+ update(newOptions) {
285
+ Object.assign(this.options, newOptions)
286
+ if (newOptions.colors && !newOptions.edgeColors) {
287
+ this.options.edgeColors = newOptions.colors
288
+ }
289
+ if (newOptions.forces !== undefined) {
290
+ this.forces = newOptions.forces
291
+ }
292
+ }
293
+
294
+ _resolveEdgeColor(ctx, a, b, i, j, edgeColors) {
295
+ const mode = this.options.edgeColorMode
296
+ if (typeof mode === 'function') return mode(a, b, i, j)
297
+ if (mode === 'source') return a.color
298
+ if (mode === 'target') return b.color
299
+ return edgeColors[(i + j) % edgeColors.length]
300
+ }
301
+
302
+ _drawEdge(ctx, a, b, i, j, opts, edgeCounts) {
303
+ const { edgeMaxDist, edgeMaxOpacity, edgeWidth, edgeColors, edgeStyle, edgeCurvature } = opts
304
+ const dist = Math.hypot(a.x - b.x, a.y - b.y)
305
+ if (dist >= edgeMaxDist) return false
306
+
307
+ const opacity = (1 - dist / edgeMaxDist) * edgeMaxOpacity
308
+ const color = this._resolveEdgeColor(ctx, a, b, i, j, edgeColors)
309
+
310
+ ctx.beginPath()
311
+ ctx.moveTo(a.x, a.y)
312
+
313
+ if (edgeCurvature > 0) {
314
+ const mx = (a.x + b.x) / 2
315
+ const my = (a.y + b.y) / 2
316
+ const dx = b.x - a.x
317
+ const dy = b.y - a.y
318
+ const perpX = -dy
319
+ const perpY = dx
320
+ const len = Math.sqrt(perpX * perpX + perpY * perpY) || 1
321
+ const offset = dist * edgeCurvature * 0.5
322
+ ctx.quadraticCurveTo(mx + (perpX / len) * offset, my + (perpY / len) * offset, b.x, b.y)
323
+ } else {
324
+ ctx.lineTo(b.x, b.y)
325
+ }
326
+
327
+ if (edgeStyle === 'gradient') {
328
+ const grad = ctx.createLinearGradient(a.x, a.y, b.x, b.y)
329
+ grad.addColorStop(0, a.color)
330
+ grad.addColorStop(1, b.color)
331
+ ctx.strokeStyle = grad
332
+ } else {
333
+ ctx.strokeStyle = color
334
+ }
335
+
336
+ ctx.globalAlpha = opacity
337
+ ctx.lineWidth = edgeWidth
338
+ ctx.stroke()
339
+
340
+ if (edgeCounts) {
341
+ edgeCounts[i]++
342
+ edgeCounts[j]++
343
+ }
344
+ return true
345
+ }
346
+
347
+ _drawEdges(ctx, nodes, opts, width, height) {
348
+ const { edgeMaxDist, maxEdgesPerNode, minEdgesPerNode, maxEdgesPerFrame, spatialIndex } = opts
349
+ const needsCounts = maxEdgesPerNode !== null || minEdgesPerNode !== null
350
+ const edgeCounts = needsCounts ? new Int32Array(nodes.length) : null
351
+ let totalEdges = 0
352
+
353
+ if (opts.edgeStyle === 'dashed') ctx.setLineDash([4, 6])
354
+
355
+ if (spatialIndex) {
356
+ const qt = new QuadTree(-1, -1, width + 2, height + 2)
357
+ for (const node of nodes) qt.insert(node)
358
+
359
+ for (let i = 0; i < nodes.length; i++) {
360
+ if (maxEdgesPerFrame !== null && totalEdges >= maxEdgesPerFrame) return
361
+ if (edgeCounts && maxEdgesPerNode !== null && edgeCounts[i] >= maxEdgesPerNode) continue
362
+ const a = nodes[i]
363
+ const candidates = qt.queryRadius(a.x, a.y, edgeMaxDist)
364
+ for (const b of candidates) {
365
+ const j = b._index
366
+ if (j <= i) continue
367
+ if (maxEdgesPerFrame !== null && totalEdges >= maxEdgesPerFrame) return
368
+ if (edgeCounts && maxEdgesPerNode !== null && (edgeCounts[i] >= maxEdgesPerNode || edgeCounts[j] >= maxEdgesPerNode)) continue
369
+ if (this._drawEdge(ctx, a, b, i, j, opts, edgeCounts)) totalEdges++
370
+ }
371
+ }
372
+ } else {
373
+ for (let i = 0; i < nodes.length; i++) {
374
+ for (let j = i + 1; j < nodes.length; j++) {
375
+ if (maxEdgesPerFrame !== null && totalEdges >= maxEdgesPerFrame) return
376
+ if (edgeCounts && maxEdgesPerNode !== null && (edgeCounts[i] >= maxEdgesPerNode || edgeCounts[j] >= maxEdgesPerNode)) continue
377
+ if (this._drawEdge(ctx, nodes[i], nodes[j], i, j, opts, edgeCounts)) totalEdges++
378
+ }
379
+ }
380
+ }
381
+
382
+ // Minimum edges pass: for nodes with too few connections, draw to nearest neighbors
383
+ if (minEdgesPerNode !== null) {
384
+ const fallbackDist = edgeMaxDist * 3
385
+ for (let i = 0; i < nodes.length; i++) {
386
+ if (edgeCounts[i] >= minEdgesPerNode) continue
387
+ const a = nodes[i]
388
+ const byDist = []
389
+ for (let j = 0; j < nodes.length; j++) {
390
+ if (j === i) continue
391
+ byDist.push([j, Math.hypot(a.x - nodes[j].x, a.y - nodes[j].y)])
392
+ }
393
+ byDist.sort((x, y) => x[1] - y[1])
394
+ for (const [j, dist] of byDist) {
395
+ if (edgeCounts[i] >= minEdgesPerNode) break
396
+ if (dist < edgeMaxDist) continue // already handled in main pass
397
+ if (dist >= fallbackDist) break
398
+ if (maxEdgesPerNode !== null && edgeCounts[j] >= maxEdgesPerNode) continue
399
+ this._drawEdge(ctx, a, nodes[j], i, j, { ...opts, edgeMaxDist: fallbackDist, edgeMaxOpacity: opts.edgeMaxOpacity * 0.5 }, edgeCounts)
400
+ }
401
+ }
402
+ }
403
+
404
+ if (opts.edgeStyle === 'dashed') ctx.setLineDash([])
405
+ }
406
+
407
+ _drawNodes(ctx, nodes, opts, width, height) {
408
+ const drawShape = resolveShape(opts.nodeShape)
409
+ const fadeZone = 40
410
+
411
+ for (const node of nodes) {
412
+ const edgeFade = Math.min(
413
+ node.x / fadeZone,
414
+ (width - node.x) / fadeZone,
415
+ node.y / fadeZone,
416
+ (height - node.y) / fadeZone,
417
+ 1
418
+ )
419
+ const alpha = (node.brightness ?? 1) * edgeFade
420
+ const shape = node.shape ? (node._resolvedShape ??= resolveShape(node.shape)) : drawShape
421
+
422
+ if (opts.glowOnLargeNodes && node.r > opts.glowThreshold) {
423
+ const haloR = node.r * opts.glowScale
424
+ const grd = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, haloR)
425
+ grd.addColorStop(0, node.color)
426
+ grd.addColorStop(1, 'transparent')
427
+ ctx.beginPath()
428
+ ctx.arc(node.x, node.y, haloR, 0, Math.PI * 2)
429
+ ctx.fillStyle = grd
430
+ ctx.globalAlpha = alpha * opts.glowOpacity
431
+ ctx.fill()
432
+ }
433
+
434
+ ctx.globalAlpha = alpha
435
+ ctx.fillStyle = node.color
436
+
437
+ if (opts.nodeRotation && node.angle !== undefined) {
438
+ ctx.save()
439
+ ctx.translate(node.x, node.y)
440
+ ctx.rotate(node.angle)
441
+ shape(ctx, 0, 0, node.r)
442
+ ctx.restore()
443
+ } else {
444
+ shape(ctx, node.x, node.y, node.r)
445
+ }
446
+ }
447
+ }
448
+
449
+ _loop(time) {
450
+ const { canvas, ctx, nodes, forces } = this
451
+ const width = this._logicalWidth ?? canvas.width
452
+ const height = this._logicalHeight ?? canvas.height
453
+ const opts = this.options
454
+
455
+ const context = Object.freeze({ time, mouse: this.mouse, scrollY: this.scrollY, width, height })
456
+
457
+ for (const force of forces) force(nodes, context)
458
+
459
+ for (const node of nodes) {
460
+ node.x += node.vx
461
+ node.y += node.vy
462
+ if (node.x < 0) node.x += width
463
+ if (node.x > width) node.x -= width
464
+ if (node.y < 0) node.y += height
465
+ if (node.y > height) node.y -= height
466
+
467
+ if (opts.nodeRotation && node.angle !== undefined) {
468
+ node.angle += node.angularVelocity
469
+ }
470
+ }
471
+
472
+ ctx.globalCompositeOperation = opts.blendMode
473
+ ctx.clearRect(0, 0, width, height)
474
+
475
+ if (opts.background) {
476
+ ctx.globalAlpha = 1
477
+ ctx.fillStyle = opts.background
478
+ ctx.fillRect(0, 0, width, height)
479
+ }
480
+
481
+ if (opts.renderOrder === 'nodes-first') {
482
+ this._drawNodes(ctx, nodes, opts, width, height)
483
+ this._drawEdges(ctx, nodes, opts, width, height)
484
+ } else {
485
+ this._drawEdges(ctx, nodes, opts, width, height)
486
+ this._drawNodes(ctx, nodes, opts, width, height)
487
+ }
488
+
489
+ ctx.globalAlpha = 1
490
+
491
+ if (opts.onFrame) opts.onFrame(nodes, context)
492
+
493
+ this.raf = requestAnimationFrame(this._loop)
494
+ }
495
+
496
+ start() {
497
+ this._resize()
498
+ window.addEventListener('resize', this._onResize)
499
+ window.addEventListener('mousemove', this._onMouseMove)
500
+ window.addEventListener('scroll', this._onScroll, { passive: true })
501
+ if (this.options.pauseWhenHidden) {
502
+ document.addEventListener('visibilitychange', this._onVisibilityChange)
503
+ }
504
+ if (this.options.onNodeClick) {
505
+ window.addEventListener('click', this._onClick)
506
+ }
507
+ if (this.options.pauseWhenOffscreen) {
508
+ this._io = new IntersectionObserver(entries => {
509
+ for (const entry of entries) {
510
+ if (entry.isIntersecting) {
511
+ if (this.raf === null) this.raf = requestAnimationFrame(this._loop)
512
+ } else {
513
+ cancelAnimationFrame(this.raf)
514
+ this.raf = null
515
+ }
516
+ }
517
+ }, { threshold: 0 })
518
+ this._io.observe(this.canvas)
519
+ } else {
520
+ this.raf = requestAnimationFrame(this._loop)
521
+ }
522
+ }
523
+
524
+ stop() {
525
+ cancelAnimationFrame(this.raf)
526
+ this.raf = null
527
+ if (this._io) { this._io.disconnect(); this._io = null }
528
+ window.removeEventListener('resize', this._onResize)
529
+ window.removeEventListener('mousemove', this._onMouseMove)
530
+ window.removeEventListener('scroll', this._onScroll)
531
+ document.removeEventListener('visibilitychange', this._onVisibilityChange)
532
+ window.removeEventListener('click', this._onClick)
533
+ }
534
+ }
@@ -0,0 +1,37 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { World } from '../World.js'
3
+
4
+ /**
5
+ * useCosmograph — React hook for cosmograph
6
+ *
7
+ * Returns a canvasRef to attach to a <canvas> element.
8
+ * All World options are supported as props.
9
+ *
10
+ * Example:
11
+ * const ref = useCosmograph({ nodeCount: 60, colors: ['#fff'], forces: [drift()] })
12
+ * return <canvas ref={ref} style={{ width: '100%', height: '100%' }} />
13
+ */
14
+ export function useCosmograph(options = {}) {
15
+ const canvasRef = useRef(null)
16
+ const worldRef = useRef(null)
17
+
18
+ useEffect(() => {
19
+ const canvas = canvasRef.current
20
+ if (!canvas) return
21
+ const world = new World({ canvas, ...options })
22
+ worldRef.current = world
23
+ world.start()
24
+ return () => {
25
+ world.stop()
26
+ worldRef.current = null
27
+ }
28
+ }, [])
29
+
30
+ useEffect(() => {
31
+ if (worldRef.current) {
32
+ worldRef.current.update(options)
33
+ }
34
+ }) // no deps — runs after every render, world.update() is cheap for unchanged options
35
+
36
+ return canvasRef
37
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * attract — pulls nodes toward a point, but only within a radius (falloff to zero at edge)
3
+ *
4
+ * Unlike gravity (which always pulls), attract only affects nodes within radius.
5
+ *
6
+ * x, y: target position — values 0..1 are treated as relative (0.5 = center),
7
+ * values > 1 as absolute pixels, or a function (width, height) => value
8
+ * radius: influence zone in pixels
9
+ * strength: force magnitude
10
+ */
11
+ export function attract({ x = 0.5, y = 0.5, radius = 200, strength = 0.001 } = {}) {
12
+ return (nodes, context) => {
13
+ const { width, height } = context
14
+ const tx = typeof x === 'function' ? x(width, height) : (x <= 1 ? x * width : x)
15
+ const ty = typeof y === 'function' ? y(width, height) : (y <= 1 ? y * height : y)
16
+
17
+ for (const node of nodes) {
18
+ const dx = tx - node.x
19
+ const dy = ty - node.y
20
+ const dist = Math.hypot(dx, dy)
21
+ if (dist < 20 || dist > radius) continue
22
+ node.vx += (dx / dist) * strength
23
+ node.vy += (dy / dist) * strength
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,8 @@
1
+ export function dampen({ factor = 0.99 } = {}) {
2
+ return (nodes) => {
3
+ for (const node of nodes) {
4
+ node.vx *= factor
5
+ node.vy *= factor
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,11 @@
1
+ export function drift({ maxSpeed = 0.08 } = {}) {
2
+ return (nodes) => {
3
+ for (const node of nodes) {
4
+ const spd = Math.hypot(node.vx, node.vy)
5
+ if (spd > maxSpeed) {
6
+ node.vx = (node.vx / spd) * maxSpeed
7
+ node.vy = (node.vy / spd) * maxSpeed
8
+ }
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * gravity — pulls all nodes softly toward a fixed point
3
+ *
4
+ * x, y: target — fraction 0..1 (relative to canvas size), absolute px if outside that range,
5
+ * or a function (context) => number evaluated each frame
6
+ * strength: force multiplier (default 0.0003) — weakens with distance,
7
+ * never reaches zero (no singularity at the target point)
8
+ */
9
+ export function gravity({ x = 0.5, y = 0.5, strength = 0.0003 } = {}) {
10
+ return (nodes, context) => {
11
+ const { width, height } = context
12
+
13
+ const resolveCoord = (val, size) => {
14
+ if (typeof val === 'function') return val(context)
15
+ return (val >= 0 && val <= 1) ? val * size : val
16
+ }
17
+
18
+ const tx = resolveCoord(x, width)
19
+ const ty = resolveCoord(y, height)
20
+
21
+ for (const node of nodes) {
22
+ const dx = tx - node.x
23
+ const dy = ty - node.y
24
+ const distSq = dx * dx + dy * dy + 1 // +1 avoids division by zero
25
+ const f = strength / distSq * Math.sqrt(distSq)
26
+ node.vx += dx * f
27
+ node.vy += dy * f
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * mouseRepel — reacts to cursor position
3
+ *
4
+ * mode:
5
+ * 'repel' — nodes flee from cursor (default)
6
+ * 'attract' — nodes are pulled toward cursor
7
+ * 'orbit' — nodes orbit around cursor
8
+ * 'custom' — provide your own fn(node, mouse, context) => void
9
+ *
10
+ * radius: influence radius in px (default 120)
11
+ * strength: force multiplier (default 0.012)
12
+ */
13
+ export function mouseRepel({ mode = 'repel', radius = 120, strength = 0.012, fn } = {}) {
14
+ return (nodes, context) => {
15
+ const { mouse } = context
16
+ if (!mouse) return
17
+
18
+ if (mode === 'custom' && typeof fn === 'function') {
19
+ for (const node of nodes) fn(node, mouse, context)
20
+ return
21
+ }
22
+
23
+ for (const node of nodes) {
24
+ const dx = node.x - mouse.x
25
+ const dy = node.y - mouse.y
26
+ const dist = Math.hypot(dx, dy)
27
+ if (dist >= radius || dist === 0) continue
28
+
29
+ const t = (radius - dist) / radius // 0..1, stronger near center
30
+
31
+ if (mode === 'repel') {
32
+ const f = t * strength
33
+ node.vx += (dx / dist) * f
34
+ node.vy += (dy / dist) * f
35
+
36
+ } else if (mode === 'attract') {
37
+ const f = t * strength
38
+ node.vx -= (dx / dist) * f
39
+ node.vy -= (dy / dist) * f
40
+
41
+ } else if (mode === 'orbit') {
42
+ // perpendicular force creates circular motion
43
+ const f = t * strength
44
+ node.vx += (-dy / dist) * f
45
+ node.vy += (dx / dist) * f
46
+ }
47
+ }
48
+ }
49
+ }