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/LICENSE +21 -0
- package/README.md +282 -0
- package/index.d.ts +176 -0
- package/package.json +30 -0
- package/src/Node.js +13 -0
- package/src/QuadTree.js +65 -0
- package/src/World.js +534 -0
- package/src/adapters/react.js +37 -0
- package/src/forces/attract.js +26 -0
- package/src/forces/dampen.js +8 -0
- package/src/forces/drift.js +11 -0
- package/src/forces/gravity.js +30 -0
- package/src/forces/mouseRepel.js +49 -0
- package/src/forces/nodeRepel.js +33 -0
- package/src/forces/noise.js +64 -0
- package/src/forces/scrollDrift.js +58 -0
- package/src/forces/twinkle.js +7 -0
- package/src/forces/wind.js +27 -0
- package/src/index.js +14 -0
- package/src/shapes.js +60 -0
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,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
|
+
}
|