@cosmos.gl/graph 2.6.2 → 2.7.0-beta.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.
Files changed (186) hide show
  1. package/.eslintrc +147 -0
  2. package/.github/SECURITY.md +13 -0
  3. package/.github/dco.yml +4 -0
  4. package/.github/workflows/github_pages.yml +54 -0
  5. package/.storybook/main.ts +26 -0
  6. package/.storybook/manager-head.html +1 -0
  7. package/.storybook/manager.ts +14 -0
  8. package/.storybook/preview.ts +29 -0
  9. package/.storybook/style.css +3 -0
  10. package/CHARTER.md +69 -0
  11. package/CODE_OF_CONDUCT.md +178 -0
  12. package/CONTRIBUTING.md +22 -0
  13. package/GOVERNANCE.md +21 -0
  14. package/cosmos-2-0-migration-notes.md +98 -0
  15. package/cosmos_awesome.md +96 -0
  16. package/dist/config.d.ts +5 -18
  17. package/dist/graph/utils/error-message.d.ts +1 -1
  18. package/dist/helper.d.ts +39 -2
  19. package/dist/index-FUIgayhu.js +19827 -0
  20. package/dist/index-FUIgayhu.js.map +1 -0
  21. package/dist/index.d.ts +17 -64
  22. package/dist/index.js +14 -14658
  23. package/dist/index.js.map +1 -1
  24. package/dist/index.min.js +1062 -475
  25. package/dist/index.min.js.map +1 -1
  26. package/dist/modules/Clusters/index.d.ts +11 -3
  27. package/dist/modules/ForceCenter/index.d.ts +10 -3
  28. package/dist/modules/ForceGravity/index.d.ts +5 -1
  29. package/dist/modules/ForceLink/index.d.ts +8 -5
  30. package/dist/modules/ForceManyBody/index.d.ts +16 -7
  31. package/dist/modules/ForceMouse/index.d.ts +5 -1
  32. package/dist/modules/GraphData/index.d.ts +0 -1
  33. package/dist/modules/Lines/index.d.ts +11 -5
  34. package/dist/modules/Points/index.d.ts +31 -13
  35. package/dist/modules/Store/index.d.ts +93 -0
  36. package/dist/modules/core-module.d.ts +3 -3
  37. package/dist/stories/beginners/basic-set-up/data-gen.d.ts +4 -0
  38. package/dist/stories/beginners/basic-set-up/index.d.ts +6 -0
  39. package/dist/stories/beginners/link-hovering/data-generator.d.ts +19 -0
  40. package/dist/stories/beginners/link-hovering/index.d.ts +6 -0
  41. package/dist/stories/beginners/point-labels/data.d.ts +13 -0
  42. package/dist/stories/beginners/point-labels/index.d.ts +10 -0
  43. package/dist/stories/beginners/point-labels/labels.d.ts +8 -0
  44. package/dist/stories/beginners/quick-start.d.ts +6 -0
  45. package/dist/stories/beginners/remove-points/config.d.ts +2 -0
  46. package/dist/stories/beginners/remove-points/data-gen.d.ts +4 -0
  47. package/dist/stories/beginners/remove-points/index.d.ts +6 -0
  48. package/dist/stories/beginners.stories.d.ts +10 -0
  49. package/dist/stories/clusters/polygon-selection/index.d.ts +6 -0
  50. package/dist/stories/clusters/polygon-selection/polygon.d.ts +20 -0
  51. package/dist/stories/clusters/radial.d.ts +6 -0
  52. package/dist/stories/clusters/with-labels.d.ts +6 -0
  53. package/dist/stories/clusters/worm.d.ts +6 -0
  54. package/dist/stories/clusters.stories.d.ts +9 -0
  55. package/dist/stories/create-cluster-labels.d.ts +4 -0
  56. package/dist/stories/create-cosmos.d.ts +17 -0
  57. package/dist/stories/create-story.d.ts +16 -0
  58. package/dist/stories/experiments/full-mesh.d.ts +6 -0
  59. package/dist/stories/experiments/mesh-with-holes.d.ts +6 -0
  60. package/dist/stories/experiments.stories.d.ts +7 -0
  61. package/dist/stories/generate-mesh-data.d.ts +12 -0
  62. package/dist/stories/geospatial/moscow-metro-stations/index.d.ts +16 -0
  63. package/dist/stories/geospatial/moscow-metro-stations/moscow-metro-coords.d.ts +1 -0
  64. package/dist/stories/geospatial/moscow-metro-stations/point-colors.d.ts +1 -0
  65. package/dist/stories/geospatial.stories.d.ts +6 -0
  66. package/dist/stories/shapes/all-shapes/index.d.ts +6 -0
  67. package/dist/stories/shapes/image-example/index.d.ts +6 -0
  68. package/dist/stories/shapes.stories.d.ts +7 -0
  69. package/dist/stories/test-luma-migration.d.ts +6 -0
  70. package/dist/stories/test.stories.d.ts +6 -0
  71. package/dist/webgl-device-B9ewDj5L.js +3923 -0
  72. package/dist/webgl-device-B9ewDj5L.js.map +1 -0
  73. package/logo.svg +3 -0
  74. package/package.json +5 -7
  75. package/rollup.config.js +70 -0
  76. package/src/config.ts +728 -0
  77. package/src/declaration.d.ts +12 -0
  78. package/src/graph/utils/error-message.ts +23 -0
  79. package/src/helper.ts +113 -0
  80. package/src/index.ts +1769 -0
  81. package/src/modules/Clusters/calculate-centermass.frag +12 -0
  82. package/src/modules/Clusters/calculate-centermass.vert +38 -0
  83. package/src/modules/Clusters/force-cluster.frag +55 -0
  84. package/src/modules/Clusters/index.ts +578 -0
  85. package/src/modules/Drag/index.ts +33 -0
  86. package/src/modules/FPSMonitor/css.ts +53 -0
  87. package/src/modules/FPSMonitor/index.ts +28 -0
  88. package/src/modules/ForceCenter/calculate-centermass.frag +9 -0
  89. package/src/modules/ForceCenter/calculate-centermass.vert +26 -0
  90. package/src/modules/ForceCenter/force-center.frag +37 -0
  91. package/src/modules/ForceCenter/index.ts +284 -0
  92. package/src/modules/ForceGravity/force-gravity.frag +40 -0
  93. package/src/modules/ForceGravity/index.ts +107 -0
  94. package/src/modules/ForceLink/force-spring.ts +89 -0
  95. package/src/modules/ForceLink/index.ts +293 -0
  96. package/src/modules/ForceManyBody/calculate-level.frag +9 -0
  97. package/src/modules/ForceManyBody/calculate-level.vert +37 -0
  98. package/src/modules/ForceManyBody/force-centermass.frag +61 -0
  99. package/src/modules/ForceManyBody/force-level.frag +138 -0
  100. package/src/modules/ForceManyBody/index.ts +525 -0
  101. package/src/modules/ForceManyBody/quadtree-frag-shader.ts +89 -0
  102. package/src/modules/ForceManyBodyQuadtree/calculate-level.frag +9 -0
  103. package/src/modules/ForceManyBodyQuadtree/calculate-level.vert +25 -0
  104. package/src/modules/ForceManyBodyQuadtree/index.ts +157 -0
  105. package/src/modules/ForceManyBodyQuadtree/quadtree-frag-shader.ts +93 -0
  106. package/src/modules/ForceMouse/force-mouse.frag +35 -0
  107. package/src/modules/ForceMouse/index.ts +102 -0
  108. package/src/modules/GraphData/index.ts +383 -0
  109. package/src/modules/Lines/draw-curve-line.frag +59 -0
  110. package/src/modules/Lines/draw-curve-line.vert +248 -0
  111. package/src/modules/Lines/geometry.ts +18 -0
  112. package/src/modules/Lines/hovered-line-index.frag +43 -0
  113. package/src/modules/Lines/hovered-line-index.vert +13 -0
  114. package/src/modules/Lines/index.ts +661 -0
  115. package/src/modules/Points/atlas-utils.ts +137 -0
  116. package/src/modules/Points/drag-point.frag +34 -0
  117. package/src/modules/Points/draw-highlighted.frag +44 -0
  118. package/src/modules/Points/draw-highlighted.vert +145 -0
  119. package/src/modules/Points/draw-points.frag +259 -0
  120. package/src/modules/Points/draw-points.vert +203 -0
  121. package/src/modules/Points/fill-sampled-points.frag +12 -0
  122. package/src/modules/Points/fill-sampled-points.vert +51 -0
  123. package/src/modules/Points/find-hovered-point.frag +15 -0
  124. package/src/modules/Points/find-hovered-point.vert +90 -0
  125. package/src/modules/Points/find-points-on-area-selection.frag +88 -0
  126. package/src/modules/Points/find-points-on-polygon-selection.frag +89 -0
  127. package/src/modules/Points/index.ts +2292 -0
  128. package/src/modules/Points/track-positions.frag +30 -0
  129. package/src/modules/Points/update-position.frag +39 -0
  130. package/src/modules/Shared/buffer.ts +39 -0
  131. package/src/modules/Shared/clear.frag +10 -0
  132. package/src/modules/Shared/quad.vert +13 -0
  133. package/src/modules/Store/index.ts +283 -0
  134. package/src/modules/Zoom/index.ts +148 -0
  135. package/src/modules/core-module.ts +28 -0
  136. package/src/stories/1. welcome.mdx +75 -0
  137. package/src/stories/2. configuration.mdx +111 -0
  138. package/src/stories/3. api-reference.mdx +591 -0
  139. package/src/stories/beginners/basic-set-up/data-gen.ts +33 -0
  140. package/src/stories/beginners/basic-set-up/index.ts +167 -0
  141. package/src/stories/beginners/basic-set-up/style.css +35 -0
  142. package/src/stories/beginners/link-hovering/data-generator.ts +198 -0
  143. package/src/stories/beginners/link-hovering/index.ts +65 -0
  144. package/src/stories/beginners/link-hovering/style.css +73 -0
  145. package/src/stories/beginners/point-labels/data.ts +73 -0
  146. package/src/stories/beginners/point-labels/index.ts +69 -0
  147. package/src/stories/beginners/point-labels/labels.ts +46 -0
  148. package/src/stories/beginners/point-labels/style.css +16 -0
  149. package/src/stories/beginners/quick-start.ts +54 -0
  150. package/src/stories/beginners/remove-points/config.ts +25 -0
  151. package/src/stories/beginners/remove-points/data-gen.ts +30 -0
  152. package/src/stories/beginners/remove-points/index.ts +96 -0
  153. package/src/stories/beginners/remove-points/style.css +31 -0
  154. package/src/stories/beginners.stories.ts +130 -0
  155. package/src/stories/clusters/polygon-selection/index.ts +52 -0
  156. package/src/stories/clusters/polygon-selection/polygon.ts +143 -0
  157. package/src/stories/clusters/polygon-selection/style.css +8 -0
  158. package/src/stories/clusters/radial.ts +24 -0
  159. package/src/stories/clusters/with-labels.ts +54 -0
  160. package/src/stories/clusters/worm.ts +40 -0
  161. package/src/stories/clusters.stories.ts +77 -0
  162. package/src/stories/create-cluster-labels.ts +50 -0
  163. package/src/stories/create-cosmos.ts +72 -0
  164. package/src/stories/create-story.ts +51 -0
  165. package/src/stories/experiments/full-mesh.ts +13 -0
  166. package/src/stories/experiments/mesh-with-holes.ts +13 -0
  167. package/src/stories/experiments.stories.ts +43 -0
  168. package/src/stories/generate-mesh-data.ts +125 -0
  169. package/src/stories/geospatial/moscow-metro-stations/index.ts +66 -0
  170. package/src/stories/geospatial/moscow-metro-stations/moscow-metro-coords.ts +1 -0
  171. package/src/stories/geospatial/moscow-metro-stations/point-colors.ts +46 -0
  172. package/src/stories/geospatial/moscow-metro-stations/style.css +30 -0
  173. package/src/stories/geospatial.stories.ts +30 -0
  174. package/src/stories/shapes/all-shapes/index.ts +73 -0
  175. package/src/stories/shapes/image-example/icons/box.png +0 -0
  176. package/src/stories/shapes/image-example/icons/lego.png +0 -0
  177. package/src/stories/shapes/image-example/icons/s.png +0 -0
  178. package/src/stories/shapes/image-example/icons/swift.png +0 -0
  179. package/src/stories/shapes/image-example/icons/toolbox.png +0 -0
  180. package/src/stories/shapes/image-example/index.ts +246 -0
  181. package/src/stories/shapes.stories.ts +37 -0
  182. package/src/stories/test-luma-migration.ts +195 -0
  183. package/src/stories/test.stories.ts +25 -0
  184. package/src/variables.ts +68 -0
  185. package/tsconfig.json +41 -0
  186. package/vite.config.ts +52 -0
package/src/index.ts ADDED
@@ -0,0 +1,1769 @@
1
+ import { select, Selection } from 'd3-selection'
2
+ import 'd3-transition'
3
+ import { easeQuadInOut, easeQuadIn, easeQuadOut } from 'd3-ease'
4
+ import { D3ZoomEvent } from 'd3-zoom'
5
+ import { D3DragEvent } from 'd3-drag'
6
+ import { Device, Framebuffer, luma } from '@luma.gl/core'
7
+ import { WebGLDevice, webgl2Adapter } from '@luma.gl/webgl'
8
+ import { GL } from '@luma.gl/constants'
9
+
10
+ import { GraphConfig, GraphConfigInterface } from '@/graph/config'
11
+ import { getRgbaColor, readPixels, sanitizeHtml } from '@/graph/helper'
12
+ import { ForceCenter } from '@/graph/modules/ForceCenter'
13
+ import { ForceGravity } from '@/graph/modules/ForceGravity'
14
+ import { ForceLink, LinkDirection } from '@/graph/modules/ForceLink'
15
+ import { ForceManyBody } from '@/graph/modules/ForceManyBody'
16
+ // import { ForceManyBodyQuadtree } from '@/graph/modules/ForceManyBodyQuadtree'
17
+ import { ForceMouse } from '@/graph/modules/ForceMouse'
18
+ import { Clusters } from '@/graph/modules/Clusters'
19
+ import { FPSMonitor } from '@/graph/modules/FPSMonitor'
20
+ import { GraphData } from '@/graph/modules/GraphData'
21
+ import { Lines } from '@/graph/modules/Lines'
22
+ import { Points } from '@/graph/modules/Points'
23
+ import { Store, ALPHA_MIN, MAX_POINT_SIZE, MAX_HOVER_DETECTION_DELAY, type Hovered } from '@/graph/modules/Store'
24
+ import { Zoom } from '@/graph/modules/Zoom'
25
+ import { Drag } from '@/graph/modules/Drag'
26
+ import { defaultConfigValues, defaultScaleToZoom, defaultGreyoutPointColor, defaultBackgroundColor } from '@/graph/variables'
27
+
28
+ export class Graph {
29
+ public config = new GraphConfig()
30
+ public graph = new GraphData(this.config)
31
+ private canvas: HTMLCanvasElement
32
+ private attributionDivElement: HTMLElement | undefined
33
+ private canvasD3Selection: Selection<HTMLCanvasElement, undefined, null, undefined> | undefined
34
+ private device: Device | undefined
35
+ private deviceInitPromise: Promise<Device>
36
+ private requestAnimationFrameId = 0
37
+ private isRightClickMouse = false
38
+
39
+ private store = new Store()
40
+ private points: Points | undefined
41
+ private lines: Lines | undefined
42
+ private forceGravity: ForceGravity | undefined
43
+ private forceCenter: ForceCenter | undefined
44
+ private forceManyBody: ForceManyBody | undefined
45
+ private forceLinkIncoming: ForceLink | undefined
46
+ private forceLinkOutgoing: ForceLink | undefined
47
+ private forceMouse: ForceMouse | undefined
48
+ private clusters: Clusters | undefined
49
+ private zoomInstance = new Zoom(this.store, this.config)
50
+ private dragInstance = new Drag(this.store, this.config)
51
+
52
+ private fpsMonitor: FPSMonitor | undefined
53
+
54
+ private currentEvent: D3ZoomEvent<HTMLCanvasElement, undefined> | D3DragEvent<HTMLCanvasElement, undefined, Hovered> | MouseEvent | undefined
55
+ /**
56
+ * The value of `_findHoveredItemExecutionCount` is incremented by 1 on each animation frame.
57
+ * When the counter reaches MAX_HOVER_DETECTION_DELAY (default 4), it is reset to 0 and the `findHoveredPoint` or `findHoveredLine` method is executed.
58
+ */
59
+ private _findHoveredItemExecutionCount = 0
60
+ /**
61
+ * If the mouse is not on the Canvas, the `findHoveredPoint` or `findHoveredLine` method will not be executed.
62
+ */
63
+ private _isMouseOnCanvas = false
64
+ /**
65
+ * After setting data and render graph at a first time, the fit logic will run
66
+ * */
67
+ private _isFirstRenderAfterInit = true
68
+ private _fitViewOnInitTimeoutID: number | undefined
69
+
70
+ private isPointPositionsUpdateNeeded = false
71
+ private isPointColorUpdateNeeded = false
72
+ private isPointSizeUpdateNeeded = false
73
+ private isPointShapeUpdateNeeded = false
74
+ private isPointImageIndicesUpdateNeeded = false
75
+ private isLinksUpdateNeeded = false
76
+ private isLinkColorUpdateNeeded = false
77
+ private isLinkWidthUpdateNeeded = false
78
+ private isLinkArrowUpdateNeeded = false
79
+ private isPointClusterUpdateNeeded = false
80
+ private isForceManyBodyUpdateNeeded = false
81
+ private isForceLinkUpdateNeeded = false
82
+ private isForceCenterUpdateNeeded = false
83
+ private isPointImageSizesUpdateNeeded = false
84
+
85
+ private _isDestroyed = false
86
+
87
+ public constructor (
88
+ div: HTMLDivElement,
89
+ config?: GraphConfigInterface
90
+ ) {
91
+ if (config) this.config.init(config)
92
+
93
+ this.store.div = div
94
+ const canvas = document.createElement('canvas')
95
+ canvas.style.width = '100%'
96
+ canvas.style.height = '100%'
97
+ this.store.div.appendChild(canvas)
98
+ this.addAttribution()
99
+ this.canvas = canvas
100
+
101
+ // Start device creation immediately (fire and forget)
102
+ this.deviceInitPromise = this.createDevice(canvas)
103
+ .then(device => {
104
+ this.device = device
105
+
106
+ const w = canvas.clientWidth
107
+ const h = canvas.clientHeight
108
+
109
+ canvas.width = w * this.config.pixelRatio
110
+ canvas.height = h * this.config.pixelRatio
111
+
112
+ this.store.adjustSpaceSize(this.config.spaceSize, this.device.limits.maxTextureDimension2D)
113
+ this.store.setWebGLMaxTextureSize(this.device.limits.maxTextureDimension2D)
114
+ this.store.updateScreenSize(w, h)
115
+
116
+ this.canvasD3Selection = select<HTMLCanvasElement, undefined>(this.canvas)
117
+ this.canvasD3Selection
118
+ .on('mouseenter.cosmos', () => { this._isMouseOnCanvas = true })
119
+ .on('mousemove.cosmos', () => { this._isMouseOnCanvas = true })
120
+ .on('mouseleave.cosmos', (event) => {
121
+ this._isMouseOnCanvas = false
122
+ this.currentEvent = event
123
+
124
+ // Clear point hover state and trigger callback if needed
125
+ if (this.store.hoveredPoint !== undefined && this.config.onPointMouseOut) {
126
+ this.config.onPointMouseOut(event)
127
+ }
128
+
129
+ // Clear link hover state and trigger callback if needed
130
+ if (this.store.hoveredLinkIndex !== undefined && this.config.onLinkMouseOut) {
131
+ this.config.onLinkMouseOut(event)
132
+ }
133
+
134
+ // Reset right-click flag
135
+ this.isRightClickMouse = false
136
+
137
+ // Clear hover states
138
+ this.store.hoveredPoint = undefined
139
+ this.store.hoveredLinkIndex = undefined
140
+
141
+ // Update cursor style after clearing hover states
142
+ this.updateCanvasCursor()
143
+ })
144
+ select(document)
145
+ .on('keydown.cosmos', (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = true })
146
+ .on('keyup.cosmos', (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = false })
147
+ this.zoomInstance.behavior
148
+ .on('start.detect', (e: D3ZoomEvent<HTMLCanvasElement, undefined>) => { this.currentEvent = e })
149
+ .on('zoom.detect', (e: D3ZoomEvent<HTMLCanvasElement, undefined>) => {
150
+ const userDriven = !!e.sourceEvent
151
+ if (userDriven) this.updateMousePosition(e.sourceEvent)
152
+ this.currentEvent = e
153
+ })
154
+ .on('end.detect', (e: D3ZoomEvent<HTMLCanvasElement, undefined>) => { this.currentEvent = e })
155
+ this.dragInstance.behavior
156
+ .on('start.detect', (e: D3DragEvent<HTMLCanvasElement, undefined, Hovered>) => {
157
+ this.currentEvent = e
158
+ this.updateCanvasCursor()
159
+ })
160
+ .on('drag.detect', (e: D3DragEvent<HTMLCanvasElement, undefined, Hovered>) => {
161
+ if (this.dragInstance.isActive) {
162
+ this.updateMousePosition(e)
163
+ }
164
+ this.currentEvent = e
165
+ })
166
+ .on('end.detect', (e: D3DragEvent<HTMLCanvasElement, undefined, Hovered>) => {
167
+ this.currentEvent = e
168
+ this.updateCanvasCursor()
169
+ })
170
+ this.canvasD3Selection
171
+ .call(this.dragInstance.behavior)
172
+ .call(this.zoomInstance.behavior)
173
+ .on('click', this.onClick.bind(this))
174
+ .on('mousemove', this.onMouseMove.bind(this))
175
+ .on('contextmenu', this.onRightClickMouse.bind(this))
176
+ if (!this.config.enableZoom || !this.config.enableDrag) this.updateZoomDragBehaviors()
177
+ this.setZoomLevel(this.config.initialZoomLevel ?? 1)
178
+
179
+ const pointSizeRange = (device as WebGLDevice).gl.getParameter(GL.ALIASED_POINT_SIZE_RANGE) as [number, number]
180
+ const pixelRatio = this.config.pixelRatio ?? (typeof window !== 'undefined' ? window.devicePixelRatio || 2 : 2)
181
+ this.store.maxPointSize = (pointSizeRange?.[1] ?? MAX_POINT_SIZE) / pixelRatio
182
+
183
+ this.points = new Points(device, this.config, this.store, this.graph)
184
+ this.lines = new Lines(device, this.config, this.store, this.graph, this.points)
185
+ if (this.config.enableSimulation) {
186
+ this.forceGravity = new ForceGravity(device, this.config, this.store, this.graph, this.points)
187
+ this.forceCenter = new ForceCenter(device, this.config, this.store, this.graph, this.points)
188
+ this.forceManyBody = new ForceManyBody(device, this.config, this.store, this.graph, this.points)
189
+ this.forceLinkIncoming = new ForceLink(device, this.config, this.store, this.graph, this.points)
190
+ this.forceLinkOutgoing = new ForceLink(device, this.config, this.store, this.graph, this.points)
191
+ this.forceMouse = new ForceMouse(device, this.config, this.store, this.graph, this.points)
192
+ }
193
+ this.clusters = new Clusters(device, this.config, this.store, this.graph, this.points)
194
+
195
+ this.store.backgroundColor = getRgbaColor(this.config.backgroundColor)
196
+ this.store.setHoveredPointRingColor(this.config.hoveredPointRingColor ?? defaultConfigValues.hoveredPointRingColor)
197
+ this.store.setFocusedPointRingColor(this.config.focusedPointRingColor ?? defaultConfigValues.focusedPointRingColor)
198
+ if (this.config.focusedPointIndex !== undefined) {
199
+ this.store.setFocusedPoint(this.config.focusedPointIndex)
200
+ }
201
+ this.store.setGreyoutPointColor(this.config.pointGreyoutColor ?? defaultGreyoutPointColor)
202
+ this.store.setHoveredLinkColor(this.config.hoveredLinkColor ?? defaultConfigValues.hoveredLinkColor)
203
+
204
+ this.store.updateLinkHoveringEnabled(this.config)
205
+
206
+ if (this.config.showFPSMonitor) this.fpsMonitor = new FPSMonitor(this.canvas)
207
+
208
+ if (this.config.randomSeed !== undefined) this.store.addRandomSeed(this.config.randomSeed)
209
+
210
+ return device
211
+ })
212
+ .catch(error => {
213
+ console.error('Device initialization failed:', error)
214
+ throw error
215
+ })
216
+ }
217
+
218
+ /**
219
+ * Returns the current simulation progress
220
+ */
221
+ public get progress (): number {
222
+ if (this._isDestroyed) return 0
223
+ return this.store.simulationProgress
224
+ }
225
+
226
+ /**
227
+ * A value that gives information about the running simulation status.
228
+ */
229
+ public get isSimulationRunning (): boolean {
230
+ if (this._isDestroyed) return false
231
+ return this.store.isSimulationRunning
232
+ }
233
+
234
+ /**
235
+ * The maximum point size.
236
+ * This value is the maximum size of the `gl.POINTS` primitive that WebGL can render on the user's hardware.
237
+ */
238
+ public get maxPointSize (): number {
239
+ if (this._isDestroyed) return 0
240
+ return this.store.maxPointSize
241
+ }
242
+
243
+ /**
244
+ * Set or update Cosmos configuration. The changes will be applied in real time.
245
+ * @param config Cosmos configuration object.
246
+ */
247
+ public setConfig (config: Partial<GraphConfigInterface>): void {
248
+ if (this._isDestroyed) return
249
+
250
+ if (this.ensureDevice(() => this.setConfig(config))) return
251
+ const prevConfig = { ...this.config }
252
+ this.config.init(config)
253
+ if (prevConfig.pointColor !== this.config.pointColor) {
254
+ this.graph.updatePointColor()
255
+ this.points?.updateColor()
256
+ }
257
+ if (prevConfig.pointSize !== this.config.pointSize) {
258
+ this.graph.updatePointSize()
259
+ this.points?.updateSize()
260
+ }
261
+ if (prevConfig.linkColor !== this.config.linkColor) {
262
+ this.graph.updateLinkColor()
263
+ this.lines?.updateColor()
264
+ }
265
+ if (prevConfig.linkWidth !== this.config.linkWidth) {
266
+ this.graph.updateLinkWidth()
267
+ this.lines?.updateWidth()
268
+ }
269
+ if (prevConfig.linkArrows !== this.config.linkArrows) {
270
+ this.graph.updateArrows()
271
+ this.lines?.updateArrow()
272
+ }
273
+ if (prevConfig.curvedLinkSegments !== this.config.curvedLinkSegments ||
274
+ prevConfig.curvedLinks !== this.config.curvedLinks) {
275
+ this.lines?.updateCurveLineGeometry()
276
+ }
277
+ if (prevConfig.backgroundColor !== this.config.backgroundColor) {
278
+ this.store.backgroundColor = getRgbaColor(this.config.backgroundColor ?? defaultBackgroundColor)
279
+ }
280
+ if (prevConfig.hoveredPointRingColor !== this.config.hoveredPointRingColor) {
281
+ this.store.setHoveredPointRingColor(this.config.hoveredPointRingColor ?? defaultConfigValues.hoveredPointRingColor)
282
+ }
283
+ if (prevConfig.focusedPointRingColor !== this.config.focusedPointRingColor) {
284
+ this.store.setFocusedPointRingColor(this.config.focusedPointRingColor ?? defaultConfigValues.focusedPointRingColor)
285
+ }
286
+ if (prevConfig.pointGreyoutColor !== this.config.pointGreyoutColor) {
287
+ this.store.setGreyoutPointColor(this.config.pointGreyoutColor ?? defaultGreyoutPointColor)
288
+ }
289
+ if (prevConfig.hoveredLinkColor !== this.config.hoveredLinkColor) {
290
+ this.store.setHoveredLinkColor(this.config.hoveredLinkColor ?? defaultConfigValues.hoveredLinkColor)
291
+ }
292
+ if (prevConfig.focusedPointIndex !== this.config.focusedPointIndex) {
293
+ this.store.setFocusedPoint(this.config.focusedPointIndex)
294
+ }
295
+ if (prevConfig.pixelRatio !== this.config.pixelRatio) {
296
+ // Update device's canvas context useDevicePixels
297
+ if (this.device?.canvasContext) {
298
+ const useDevicePixels = this.config.pixelRatio !== undefined
299
+ ? this.config.pixelRatio // Use config value as number
300
+ : true // Use window.devicePixelRatio
301
+ this.device.canvasContext.setProps({ useDevicePixels })
302
+
303
+ // Recalculate maxPointSize with new pixelRatio
304
+ const pointSizeRange = (this.device as WebGLDevice).gl.getParameter(GL.ALIASED_POINT_SIZE_RANGE) as [number, number]
305
+ const pixelRatio = this.config.pixelRatio ?? defaultConfigValues.pixelRatio
306
+ this.store.maxPointSize = (pointSizeRange?.[1] ?? MAX_POINT_SIZE) / pixelRatio
307
+ }
308
+ }
309
+ if (prevConfig.spaceSize !== this.config.spaceSize ||
310
+ prevConfig.simulationRepulsionQuadtreeLevels !== this.config.simulationRepulsionQuadtreeLevels) {
311
+ this.store.adjustSpaceSize(this.config.spaceSize, this.device?.limits.maxTextureDimension2D ?? 4096)
312
+ this.resizeCanvas(true)
313
+ this.update(this.store.isSimulationRunning ? this.store.alpha : 0)
314
+ }
315
+ if (prevConfig.showFPSMonitor !== this.config.showFPSMonitor) {
316
+ if (this.config.showFPSMonitor) {
317
+ this.fpsMonitor = new FPSMonitor(this.canvas)
318
+ } else {
319
+ this.fpsMonitor?.destroy()
320
+ this.fpsMonitor = undefined
321
+ }
322
+ }
323
+ if (prevConfig.enableZoom !== this.config.enableZoom || prevConfig.enableDrag !== this.config.enableDrag) {
324
+ this.updateZoomDragBehaviors()
325
+ }
326
+
327
+ if (prevConfig.onLinkClick !== this.config.onLinkClick ||
328
+ prevConfig.onLinkMouseOver !== this.config.onLinkMouseOver ||
329
+ prevConfig.onLinkMouseOut !== this.config.onLinkMouseOut) {
330
+ this.store.updateLinkHoveringEnabled(this.config)
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Sets the positions for the graph points.
336
+ *
337
+ * @param {Float32Array} pointPositions - A Float32Array representing the positions of points in the format [x1, y1, x2, y2, ..., xn, yn],
338
+ * where `n` is the index of the point.
339
+ * Example: `new Float32Array([1, 2, 3, 4, 5, 6])` sets the first point to (1, 2), the second point to (3, 4), and so on.
340
+ * @param {boolean | undefined} dontRescale - For this call only, don't rescale the points.
341
+ * - `true`: Don't rescale.
342
+ * - `false` or `undefined` (default): Use the behavior defined by `config.rescalePositions`.
343
+ */
344
+ public setPointPositions (pointPositions: Float32Array, dontRescale?: boolean | undefined): void {
345
+ if (this._isDestroyed) return
346
+
347
+ if (this.ensureDevice(() => this.setPointPositions(pointPositions, dontRescale))) return
348
+ this.graph.inputPointPositions = pointPositions
349
+ this.points!.shouldSkipRescale = dontRescale
350
+ this.isPointPositionsUpdateNeeded = true
351
+ // Links related texture depends on point positions, so we need to update it
352
+ this.isLinksUpdateNeeded = true
353
+ // Point related textures depend on point positions length, so we need to update them
354
+ this.isPointColorUpdateNeeded = true
355
+ this.isPointSizeUpdateNeeded = true
356
+ this.isPointShapeUpdateNeeded = true
357
+ this.isPointImageIndicesUpdateNeeded = true
358
+ this.isPointClusterUpdateNeeded = true
359
+ this.isForceManyBodyUpdateNeeded = true
360
+ this.isForceLinkUpdateNeeded = true
361
+ this.isForceCenterUpdateNeeded = true
362
+ }
363
+
364
+ /**
365
+ * Sets the colors for the graph points.
366
+ *
367
+ * @param {Float32Array} pointColors - A Float32Array representing the colors of points in the format [r1, g1, b1, a1, r2, g2, b2, a2, ..., rn, gn, bn, an],
368
+ * where each color is represented in RGBA format.
369
+ * Example: `new Float32Array([255, 0, 0, 1, 0, 255, 0, 1])` sets the first point to red and the second point to green.
370
+ */
371
+ public setPointColors (pointColors: Float32Array): void {
372
+ if (this._isDestroyed) return
373
+
374
+ if (this.ensureDevice(() => this.setPointColors(pointColors))) return
375
+ this.graph.inputPointColors = pointColors
376
+ this.isPointColorUpdateNeeded = true
377
+ }
378
+
379
+ /**
380
+ * Gets the current colors of the graph points.
381
+ *
382
+ * @returns {Float32Array} A Float32Array representing the colors of points in the format [r1, g1, b1, a1, r2, g2, b2, a2, ..., rn, gn, bn, an],
383
+ * where each color is in RGBA format. Returns an empty Float32Array if no point colors are set.
384
+ */
385
+ public getPointColors (): Float32Array {
386
+ if (this._isDestroyed) return new Float32Array()
387
+ return this.graph.pointColors ?? new Float32Array()
388
+ }
389
+
390
+ /**
391
+ * Sets the sizes for the graph points.
392
+ *
393
+ * @param {Float32Array} pointSizes - A Float32Array representing the sizes of points in the format [size1, size2, ..., sizen],
394
+ * where `n` is the index of the point.
395
+ * Example: `new Float32Array([10, 20, 30])` sets the first point to size 10, the second point to size 20, and the third point to size 30.
396
+ */
397
+ public setPointSizes (pointSizes: Float32Array): void {
398
+ if (this._isDestroyed) return
399
+ if (this.ensureDevice(() => this.setPointSizes(pointSizes))) return
400
+ this.graph.inputPointSizes = pointSizes
401
+ this.isPointSizeUpdateNeeded = true
402
+ }
403
+
404
+ /**
405
+ * Sets the shapes for the graph points.
406
+ *
407
+ * @param {Float32Array} pointShapes - A Float32Array representing the shapes of points in the format [shape1, shape2, ..., shapen],
408
+ * where `n` is the index of the point and each shape value corresponds to a PointShape enum:
409
+ * 0 = Circle, 1 = Square, 2 = Triangle, 3 = Diamond, 4 = Pentagon, 5 = Hexagon, 6 = Star, 7 = Cross, 8 = None.
410
+ * Example: `new Float32Array([0, 1, 2])` sets the first point to Circle, the second point to Square, and the third point to Triangle.
411
+ * Images are rendered above shapes.
412
+ */
413
+ public setPointShapes (pointShapes: Float32Array): void {
414
+ if (this._isDestroyed) return
415
+ if (this.ensureDevice(() => this.setPointShapes(pointShapes))) return
416
+ this.graph.inputPointShapes = pointShapes
417
+ this.isPointShapeUpdateNeeded = true
418
+ }
419
+
420
+ /**
421
+ * Sets the images for the graph points using ImageData objects.
422
+ * Images are rendered above shapes.
423
+ * To use images, provide image indices via setPointImageIndices().
424
+ *
425
+ * @param {ImageData[]} imageDataArray - Array of ImageData objects to use as point images.
426
+ * Example: `setImageData([imageData1, imageData2, imageData3])`
427
+ */
428
+ public setImageData (imageDataArray: ImageData[]): void {
429
+ if (this._isDestroyed) return
430
+ if (this.ensureDevice(() => this.setImageData(imageDataArray))) return
431
+ this.graph.inputImageData = imageDataArray
432
+ this.points?.createAtlas()
433
+ }
434
+
435
+ /**
436
+ * Sets which image each point should use from the images array.
437
+ * Images are rendered above shapes.
438
+ *
439
+ * @param {Float32Array} imageIndices - A Float32Array representing which image each point uses in the format [index1, index2, ..., indexn],
440
+ * where `n` is the index of the point and each value is an index into the images array provided to `setImageData`.
441
+ * Example: `new Float32Array([0, 1, 0])` sets the first point to use image 0, second point to use image 1, third point to use image 0.
442
+ */
443
+ public setPointImageIndices (imageIndices: Float32Array): void {
444
+ if (this._isDestroyed) return
445
+ if (this.ensureDevice(() => this.setPointImageIndices(imageIndices))) return
446
+ this.graph.inputPointImageIndices = imageIndices
447
+ this.isPointImageIndicesUpdateNeeded = true
448
+ }
449
+
450
+ /**
451
+ * Sets the sizes for the point images.
452
+ *
453
+ * @param {Float32Array} imageSizes - A Float32Array representing the sizes of point images in the format [size1, size2, ..., sizen],
454
+ * where `n` is the index of the point.
455
+ * Example: `new Float32Array([10, 20, 30])` sets the first image to size 10, the second image to size 20, and the third image to size 30.
456
+ */
457
+ public setPointImageSizes (imageSizes: Float32Array): void {
458
+ if (this._isDestroyed) return
459
+ if (this.ensureDevice(() => this.setPointImageSizes(imageSizes))) return
460
+ this.graph.inputPointImageSizes = imageSizes
461
+ this.isPointImageSizesUpdateNeeded = true
462
+ }
463
+
464
+ /**
465
+ * Gets the current sizes of the graph points.
466
+ *
467
+ * @returns {Float32Array} A Float32Array representing the sizes of points in the format [size1, size2, ..., sizen],
468
+ * where `n` is the index of the point. Returns an empty Float32Array if no point sizes are set.
469
+ */
470
+ public getPointSizes (): Float32Array {
471
+ if (this._isDestroyed) return new Float32Array()
472
+ return this.graph.pointSizes ?? new Float32Array()
473
+ }
474
+
475
+ /**
476
+ * Sets the links for the graph.
477
+ *
478
+ * @param {Float32Array} links - A Float32Array representing the links between points
479
+ * in the format [source1, target1, source2, target2, ..., sourcen, targetn],
480
+ * where `source` and `target` are the indices of the points being linked.
481
+ * Example: `new Float32Array([0, 1, 1, 2])` creates a link from point 0 to point 1 and another link from point 1 to point 2.
482
+ */
483
+ public setLinks (links: Float32Array): void {
484
+ if (this._isDestroyed) return
485
+ if (this.ensureDevice(() => this.setLinks(links))) return
486
+ this.graph.inputLinks = links
487
+ this.isLinksUpdateNeeded = true
488
+ // Links related texture depends on links length, so we need to update it
489
+ this.isLinkColorUpdateNeeded = true
490
+ this.isLinkWidthUpdateNeeded = true
491
+ this.isLinkArrowUpdateNeeded = true
492
+ this.isForceLinkUpdateNeeded = true
493
+ }
494
+
495
+ /**
496
+ * Sets the colors for the graph links.
497
+ *
498
+ * @param {Float32Array} linkColors - A Float32Array representing the colors of links in the format [r1, g1, b1, a1, r2, g2, b2, a2, ..., rn, gn, bn, an],
499
+ * where each color is in RGBA format.
500
+ * Example: `new Float32Array([255, 0, 0, 1, 0, 255, 0, 1])` sets the first link to red and the second link to green.
501
+ */
502
+ public setLinkColors (linkColors: Float32Array): void {
503
+ if (this._isDestroyed) return
504
+ if (this.ensureDevice(() => this.setLinkColors(linkColors))) return
505
+ this.graph.inputLinkColors = linkColors
506
+ this.isLinkColorUpdateNeeded = true
507
+ }
508
+
509
+ /**
510
+ * Gets the current colors of the graph links.
511
+ *
512
+ * @returns {Float32Array} A Float32Array representing the colors of links in the format [r1, g1, b1, a1, r2, g2, b2, a2, ..., rn, gn, bn, an],
513
+ * where each color is in RGBA format. Returns an empty Float32Array if no link colors are set.
514
+ */
515
+ public getLinkColors (): Float32Array {
516
+ if (this._isDestroyed) return new Float32Array()
517
+ return this.graph.linkColors ?? new Float32Array()
518
+ }
519
+
520
+ /**
521
+ * Sets the widths for the graph links.
522
+ *
523
+ * @param {Float32Array} linkWidths - A Float32Array representing the widths of links in the format [width1, width2, ..., widthn],
524
+ * where `n` is the index of the link.
525
+ * Example: `new Float32Array([1, 2, 3])` sets the first link to width 1, the second link to width 2, and the third link to width 3.
526
+ */
527
+ public setLinkWidths (linkWidths: Float32Array): void {
528
+ if (this._isDestroyed) return
529
+ if (this.ensureDevice(() => this.setLinkWidths(linkWidths))) return
530
+ this.graph.inputLinkWidths = linkWidths
531
+ this.isLinkWidthUpdateNeeded = true
532
+ }
533
+
534
+ /**
535
+ * Gets the current widths of the graph links.
536
+ *
537
+ * @returns {Float32Array} A Float32Array representing the widths of links in the format [width1, width2, ..., widthn],
538
+ * where `n` is the index of the link. Returns an empty Float32Array if no link widths are set.
539
+ */
540
+ public getLinkWidths (): Float32Array {
541
+ if (this._isDestroyed) return new Float32Array()
542
+ return this.graph.linkWidths ?? new Float32Array()
543
+ }
544
+
545
+ /**
546
+ * Sets the arrows for the graph links.
547
+ *
548
+ * @param {boolean[]} linkArrows - An array of booleans indicating whether each link should have an arrow,
549
+ * in the format [arrow1, arrow2, ..., arrown], where `n` is the index of the link.
550
+ * Example: `[true, false, true]` sets arrows on the first and third links, but not on the second link.
551
+ */
552
+ public setLinkArrows (linkArrows: boolean[]): void {
553
+ if (this._isDestroyed) return
554
+ if (this.ensureDevice(() => this.setLinkArrows(linkArrows))) return
555
+ this.graph.linkArrowsBoolean = linkArrows
556
+ this.isLinkArrowUpdateNeeded = true
557
+ }
558
+
559
+ /**
560
+ * Sets the strength for the graph links.
561
+ *
562
+ * @param {Float32Array} linkStrength - A Float32Array representing the strength of each link in the format [strength1, strength2, ..., strengthn],
563
+ * where `n` is the index of the link.
564
+ * Example: `new Float32Array([1, 2, 3])` sets the first link to strength 1, the second link to strength 2, and the third link to strength 3.
565
+ */
566
+ public setLinkStrength (linkStrength: Float32Array): void {
567
+ if (this._isDestroyed) return
568
+ if (this.ensureDevice(() => this.setLinkStrength(linkStrength))) return
569
+ this.graph.inputLinkStrength = linkStrength
570
+ this.isForceLinkUpdateNeeded = true
571
+ }
572
+
573
+ /**
574
+ * Sets the point clusters for the graph.
575
+ *
576
+ * @param {(number | undefined)[]} pointClusters - Array of cluster indices for each point in the graph.
577
+ * - Index: Each index corresponds to a point.
578
+ * - Values: Integers starting from 0; `undefined` indicates that a point does not belong to any cluster and will not be affected by cluster forces.
579
+ * @example
580
+ * `[0, 1, 0, 2, undefined, 1]` maps points to clusters: point 0 and 2 to cluster 0, point 1 to cluster 1, and point 3 to cluster 2.
581
+ * Points 4 is unclustered.
582
+ * @note Clusters without specified positions via `setClusterPositions` will be positioned at their centermass by default.
583
+ */
584
+ public setPointClusters (pointClusters: (number | undefined)[]): void {
585
+ if (this._isDestroyed) return
586
+ if (this.ensureDevice(() => this.setPointClusters(pointClusters))) return
587
+ this.graph.inputPointClusters = pointClusters
588
+ this.isPointClusterUpdateNeeded = true
589
+ }
590
+
591
+ /**
592
+ * Sets the positions of the point clusters for the graph.
593
+ *
594
+ * @param {(number | undefined)[]} clusterPositions - Array of cluster positions.
595
+ * - Every two elements represent the x and y coordinates for a cluster position.
596
+ * - `undefined` means the cluster's position is not defined and will use centermass positioning instead.
597
+ * @example
598
+ * `[10, 20, 30, 40, undefined, undefined]` places the first cluster at (10, 20) and the second at (30, 40);
599
+ * the third cluster will be positioned at its centermass automatically.
600
+ */
601
+ public setClusterPositions (clusterPositions: (number | undefined)[]): void {
602
+ if (this._isDestroyed) return
603
+ if (this.ensureDevice(() => this.setClusterPositions(clusterPositions))) return
604
+ this.graph.inputClusterPositions = clusterPositions
605
+ this.isPointClusterUpdateNeeded = true
606
+ }
607
+
608
+ /**
609
+ * Sets the force strength coefficients for clustering points in the graph.
610
+ *
611
+ * This method allows you to customize the forces acting on individual points during the clustering process.
612
+ * The force coefficients determine the strength of the forces applied to each point.
613
+ *
614
+ * @param {Float32Array} clusterStrength - A Float32Array of force strength coefficients for each point in the format [coeff1, coeff2, ..., coeffn],
615
+ * where `n` is the index of the point.
616
+ * Example: `new Float32Array([1, 0.4, 0.3])` sets the force coefficient for point 0 to 1, point 1 to 0.4, and point 2 to 0.3.
617
+ */
618
+ public setPointClusterStrength (clusterStrength: Float32Array): void {
619
+ if (this._isDestroyed) return
620
+ if (this.ensureDevice(() => this.setPointClusterStrength(clusterStrength))) return
621
+ this.graph.inputClusterStrength = clusterStrength
622
+ this.isPointClusterUpdateNeeded = true
623
+ }
624
+
625
+ /**
626
+ * Renders the graph.
627
+ *
628
+ * @param {number} [simulationAlpha] - Optional value between 0 and 1
629
+ * that controls the initial energy of the simulation.The higher the value,
630
+ * the more initial energy the simulation will get. Zero value stops the simulation.
631
+ */
632
+ public render (simulationAlpha?: number): void {
633
+ if (this._isDestroyed) return
634
+
635
+ if (this.ensureDevice(() => this.render(simulationAlpha))) return
636
+ this.graph.update()
637
+ const { fitViewOnInit, fitViewDelay, fitViewPadding, fitViewDuration, fitViewByPointsInRect, fitViewByPointIndices, initialZoomLevel } = this.config
638
+ if (!this.graph.pointsNumber && !this.graph.linksNumber) {
639
+ this.stopFrames()
640
+ select(this.canvas).style('cursor', null)
641
+ if (this.device) {
642
+ const clearPass = this.device.beginRenderPass({
643
+ clearColor: [0, 0, 1, 1], // this.store.backgroundColor,
644
+ clearDepth: 1,
645
+ clearStencil: 0,
646
+ })
647
+ clearPass.end()
648
+ }
649
+ return
650
+ }
651
+
652
+ // If `initialZoomLevel` is set, we don't need to fit the view
653
+ if (this._isFirstRenderAfterInit && fitViewOnInit && initialZoomLevel === undefined) {
654
+ this._fitViewOnInitTimeoutID = window.setTimeout(() => {
655
+ if (fitViewByPointIndices) this.fitViewByPointIndices(fitViewByPointIndices, fitViewDuration, fitViewPadding)
656
+ else if (fitViewByPointsInRect) this.setZoomTransformByPointPositions(fitViewByPointsInRect, fitViewDuration, undefined, fitViewPadding)
657
+ else this.fitView(fitViewDuration, fitViewPadding)
658
+ }, fitViewDelay)
659
+ }
660
+ this._isFirstRenderAfterInit = false
661
+
662
+ this.update(simulationAlpha)
663
+ }
664
+
665
+ /**
666
+ * Center the view on a point and zoom in, by point index.
667
+ * @param index The index of the point in the array of points.
668
+ * @param duration Duration of the animation transition in milliseconds (`700` by default).
669
+ * @param scale Scale value to zoom in or out (`3` by default).
670
+ * @param canZoomOut Set to `false` to prevent zooming out from the point (`true` by default).
671
+ */
672
+ public zoomToPointByIndex (index: number, duration = 700, scale = defaultScaleToZoom, canZoomOut = true): void {
673
+ if (this._isDestroyed) return
674
+
675
+ if (this.ensureDevice(() => this.zoomToPointByIndex(index, duration, scale, canZoomOut))) return
676
+ if (!this.device || !this.points || !this.canvasD3Selection) return
677
+ const { store: { screenSize } } = this
678
+ const positionPixels = readPixels(this.device, this.points.currentPositionFbo as Framebuffer)
679
+ if (index === undefined) return
680
+ const posX = positionPixels[index * 4 + 0]
681
+ const posY = positionPixels[index * 4 + 1]
682
+ if (posX === undefined || posY === undefined) return
683
+ const distance = this.zoomInstance.getDistanceToPoint([posX, posY])
684
+ const zoomLevel = canZoomOut ? scale : Math.max(this.getZoomLevel(), scale)
685
+ if (distance < Math.min(screenSize[0], screenSize[1])) {
686
+ this.setZoomTransformByPointPositions([posX, posY], duration, zoomLevel)
687
+ } else {
688
+ const transform = this.zoomInstance.getTransform([[posX, posY]], zoomLevel)
689
+ const middle = this.zoomInstance.getMiddlePointTransform([posX, posY])
690
+ this.canvasD3Selection
691
+ .transition()
692
+ .ease(easeQuadIn)
693
+ .duration(duration / 2)
694
+ .call(this.zoomInstance.behavior.transform, middle)
695
+ .transition()
696
+ .ease(easeQuadOut)
697
+ .duration(duration / 2)
698
+ .call(this.zoomInstance.behavior.transform, transform)
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Zoom the view in or out to the specified zoom level.
704
+ * @param value Zoom level
705
+ * @param duration Duration of the zoom in/out transition.
706
+ */
707
+
708
+ public zoom (value: number, duration = 0): void {
709
+ if (this._isDestroyed) return
710
+ this.setZoomLevel(value, duration)
711
+ }
712
+
713
+ /**
714
+ * Zoom the view in or out to the specified zoom level.
715
+ * @param value Zoom level
716
+ * @param duration Duration of the zoom in/out transition.
717
+ */
718
+ public setZoomLevel (value: number, duration = 0): void {
719
+ if (this._isDestroyed) return
720
+
721
+ if (this.ensureDevice(() => this.setZoomLevel(value, duration))) return
722
+
723
+ if (!this.canvasD3Selection) return
724
+
725
+ if (duration === 0) {
726
+ this.canvasD3Selection
727
+ .call(this.zoomInstance.behavior.scaleTo, value)
728
+ } else {
729
+ this.canvasD3Selection
730
+ .transition()
731
+ .duration(duration)
732
+ .call(this.zoomInstance.behavior.scaleTo, value)
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Get zoom level.
738
+ * @returns Zoom level value of the view.
739
+ */
740
+ public getZoomLevel (): number {
741
+ if (this._isDestroyed) return 0
742
+ return this.zoomInstance.eventTransform.k
743
+ }
744
+
745
+ /**
746
+ * Get current X and Y coordinates of the points.
747
+ * @returns Array of point positions.
748
+ */
749
+ public getPointPositions (): number[] {
750
+ if (this._isDestroyed || !this.device || !this.points) return []
751
+ if (this.graph.pointsNumber === undefined) return []
752
+ const positions: number[] = []
753
+ const pointPositionsPixels = readPixels(this.device, this.points.currentPositionFbo as Framebuffer)
754
+ positions.length = this.graph.pointsNumber * 2
755
+ for (let i = 0; i < this.graph.pointsNumber; i += 1) {
756
+ const posX = pointPositionsPixels[i * 4 + 0]
757
+ const posY = pointPositionsPixels[i * 4 + 1]
758
+ if (posX !== undefined && posY !== undefined) {
759
+ positions[i * 2] = posX
760
+ positions[i * 2 + 1] = posY
761
+ }
762
+ }
763
+ return positions
764
+ }
765
+
766
+ /**
767
+ * Get current X and Y coordinates of the clusters.
768
+ * @returns Array of point cluster.
769
+ */
770
+ public getClusterPositions (): number[] {
771
+ if (this._isDestroyed || !this.device || !this.clusters) return []
772
+ if (this.graph.pointClusters === undefined || this.clusters.clusterCount === undefined) return []
773
+ this.clusters.calculateCentermass()
774
+ const positions: number[] = []
775
+ const clusterPositionsPixels = readPixels(this.device, this.clusters.centermassFbo as Framebuffer)
776
+ positions.length = this.clusters.clusterCount * 2
777
+ for (let i = 0; i < positions.length / 2; i += 1) {
778
+ const sumX = clusterPositionsPixels[i * 4 + 0]
779
+ const sumY = clusterPositionsPixels[i * 4 + 1]
780
+ const sumN = clusterPositionsPixels[i * 4 + 2]
781
+ if (sumX !== undefined && sumY !== undefined && sumN !== undefined) {
782
+ positions[i * 2] = sumX / sumN
783
+ positions[i * 2 + 1] = sumY / sumN
784
+ }
785
+ }
786
+ return positions
787
+ }
788
+
789
+ /**
790
+ * Center and zoom in/out the view to fit all points in the scene.
791
+ * @param duration Duration of the center and zoom in/out animation in milliseconds (`250` by default).
792
+ * @param padding Padding around the viewport in percentage (`0.1` by default).
793
+ */
794
+ public fitView (duration = 250, padding = 0.1): void {
795
+ if (this._isDestroyed) return
796
+
797
+ if (this.ensureDevice(() => this.fitView(duration, padding))) return
798
+
799
+ this.setZoomTransformByPointPositions(this.getPointPositions(), duration, undefined, padding)
800
+ }
801
+
802
+ /**
803
+ * Center and zoom in/out the view to fit points by their indices in the scene.
804
+ * @param duration Duration of the center and zoom in/out animation in milliseconds (`250` by default).
805
+ * @param padding Padding around the viewport in percentage
806
+ */
807
+ public fitViewByPointIndices (indices: number[], duration = 250, padding = 0.1): void {
808
+ if (this._isDestroyed) return
809
+
810
+ if (this.ensureDevice(() => this.fitViewByPointIndices(indices, duration, padding))) return
811
+ const positionsArray = this.getPointPositions()
812
+ const positions = new Array(indices.length * 2)
813
+ for (const [i, index] of indices.entries()) {
814
+ positions[i * 2] = positionsArray[index * 2]
815
+ positions[i * 2 + 1] = positionsArray[index * 2 + 1]
816
+ }
817
+ this.setZoomTransformByPointPositions(positions, duration, undefined, padding)
818
+ }
819
+
820
+ /**
821
+ * Center and zoom in/out the view to fit points by their positions in the scene.
822
+ * @param duration Duration of the center and zoom in/out animation in milliseconds (`250` by default).
823
+ * @param padding Padding around the viewport in percentage
824
+ */
825
+ public fitViewByPointPositions (positions: number[], duration = 250, padding = 0.1): void {
826
+ if (this._isDestroyed) return
827
+
828
+ if (this.ensureDevice(() => this.fitViewByPointPositions(positions, duration, padding))) return
829
+
830
+ this.setZoomTransformByPointPositions(positions, duration, undefined, padding)
831
+ }
832
+
833
+ /**
834
+ * Get points indices inside a rectangular area.
835
+ * @param selection - Array of two corner points `[[left, top], [right, bottom]]`.
836
+ * The `left` and `right` coordinates should be from 0 to the width of the canvas.
837
+ * The `top` and `bottom` coordinates should be from 0 to the height of the canvas.
838
+ * @returns A Float32Array containing the indices of points inside a rectangular area.
839
+ */
840
+ public getPointsInRect (selection: [[number, number], [number, number]]): Float32Array {
841
+ if (this._isDestroyed || !this.device || !this.points) return new Float32Array()
842
+ const h = this.store.screenSize[1]
843
+ this.store.selectedArea = [[selection[0][0], (h - selection[1][1])], [selection[1][0], (h - selection[0][1])]]
844
+ this.points.findPointsOnAreaSelection()
845
+ const pixels = readPixels(this.device, this.points.selectedFbo as Framebuffer)
846
+
847
+ return pixels
848
+ .map((pixel, i) => {
849
+ if (i % 4 === 0 && pixel !== 0) return i / 4
850
+ else return -1
851
+ })
852
+ .filter(d => d !== -1)
853
+ }
854
+
855
+ /**
856
+ * Get points indices inside a rectangular area.
857
+ * @param selection - Array of two corner points `[[left, top], [right, bottom]]`.
858
+ * The `left` and `right` coordinates should be from 0 to the width of the canvas.
859
+ * The `top` and `bottom` coordinates should be from 0 to the height of the canvas.
860
+ * @returns A Float32Array containing the indices of points inside a rectangular area.
861
+ * @deprecated Use `getPointsInRect` instead. This method will be removed in a future version.
862
+ */
863
+ public getPointsInRange (selection: [[number, number], [number, number]]): Float32Array {
864
+ return this.getPointsInRect(selection)
865
+ }
866
+
867
+ /**
868
+ * Get points indices inside a polygon area.
869
+ * @param polygonPath - Array of points `[[x1, y1], [x2, y2], ..., [xn, yn]]` that defines the polygon.
870
+ * The coordinates should be from 0 to the width/height of the canvas.
871
+ * @returns A Float32Array containing the indices of points inside the polygon area.
872
+ */
873
+ public getPointsInPolygon (polygonPath: [number, number][]): Float32Array {
874
+ if (this._isDestroyed || !this.device || !this.points) return new Float32Array()
875
+ if (polygonPath.length < 3) return new Float32Array() // Need at least 3 points for a polygon
876
+
877
+ const h = this.store.screenSize[1]
878
+ // Convert coordinates to WebGL coordinate system (flip Y)
879
+ const convertedPath = polygonPath.map(([x, y]) => [x, h - y] as [number, number])
880
+ this.points.updatePolygonPath(convertedPath)
881
+ this.points.findPointsOnPolygonSelection()
882
+ const pixels = readPixels(this.device, this.points.selectedFbo as Framebuffer)
883
+
884
+ return pixels
885
+ .map((pixel, i) => {
886
+ if (i % 4 === 0 && pixel !== 0) return i / 4
887
+ else return -1
888
+ })
889
+ .filter(d => d !== -1)
890
+ }
891
+
892
+ /** Select points inside a rectangular area.
893
+ * @param selection - Array of two corner points `[[left, top], [right, bottom]]`.
894
+ * The `left` and `right` coordinates should be from 0 to the width of the canvas.
895
+ * The `top` and `bottom` coordinates should be from 0 to the height of the canvas. */
896
+ public selectPointsInRect (selection: [[number, number], [number, number]] | null): void {
897
+ if (this._isDestroyed) return
898
+
899
+ if (this.ensureDevice(() => this.selectPointsInRect(selection))) return
900
+ if (!this.device || !this.points) return
901
+ if (selection) {
902
+ const h = this.store.screenSize[1]
903
+ this.store.selectedArea = [[selection[0][0], (h - selection[1][1])], [selection[1][0], (h - selection[0][1])]]
904
+ this.points.findPointsOnAreaSelection()
905
+ const pixels = readPixels(this.device, this.points.selectedFbo as Framebuffer)
906
+ this.store.selectedIndices = pixels
907
+ .map((pixel, i) => {
908
+ if (i % 4 === 0 && pixel !== 0) return i / 4
909
+ else return -1
910
+ })
911
+ .filter(d => d !== -1)
912
+ } else {
913
+ this.store.selectedIndices = null
914
+ }
915
+ this.points.updateGreyoutStatus()
916
+ }
917
+
918
+ /** Select points inside a rectangular area.
919
+ * @param selection - Array of two corner points `[[left, top], [right, bottom]]`.
920
+ * The `left` and `right` coordinates should be from 0 to the width of the canvas.
921
+ * The `top` and `bottom` coordinates should be from 0 to the height of the canvas.
922
+ * @deprecated Use `selectPointsInRect` instead. This method will be removed in a future version.
923
+ */
924
+ public selectPointsInRange (selection: [[number, number], [number, number]] | null): void {
925
+ return this.selectPointsInRect(selection)
926
+ }
927
+
928
+ /** Select points inside a polygon area.
929
+ * @param polygonPath - Array of points `[[x1, y1], [x2, y2], ..., [xn, yn]]` that defines the polygon.
930
+ * The coordinates should be from 0 to the width/height of the canvas.
931
+ * Set to null to clear selection. */
932
+ public selectPointsInPolygon (polygonPath: [number, number][] | null): void {
933
+ if (this._isDestroyed) return
934
+
935
+ if (this.ensureDevice(() => this.selectPointsInPolygon(polygonPath))) return
936
+ if (!this.device || !this.points) return
937
+ if (polygonPath) {
938
+ if (polygonPath.length < 3) {
939
+ console.warn('Polygon path requires at least 3 points to form a polygon.')
940
+ return
941
+ }
942
+
943
+ const h = this.store.screenSize[1]
944
+ // Convert coordinates to WebGL coordinate system (flip Y)
945
+ const convertedPath = polygonPath.map(([x, y]) => [x, h - y] as [number, number])
946
+ this.points.updatePolygonPath(convertedPath)
947
+ this.points.findPointsOnPolygonSelection()
948
+ const pixels = readPixels(this.device, this.points.selectedFbo as Framebuffer)
949
+ this.store.selectedIndices = pixels
950
+ .map((pixel, i) => {
951
+ if (i % 4 === 0 && pixel !== 0) return i / 4
952
+ else return -1
953
+ })
954
+ .filter(d => d !== -1)
955
+ } else {
956
+ this.store.selectedIndices = null
957
+ }
958
+ this.points.updateGreyoutStatus()
959
+ }
960
+
961
+ /**
962
+ * Select a point by index. If you want the adjacent points to get selected too, provide `true` as the second argument.
963
+ * @param index The index of the point in the array of points.
964
+ * @param selectAdjacentPoints When set to `true`, selects adjacent points (`false` by default).
965
+ */
966
+ public selectPointByIndex (index: number, selectAdjacentPoints = false): void {
967
+ if (this._isDestroyed) return
968
+ if (this.ensureDevice(() => this.selectPointByIndex(index, selectAdjacentPoints))) return
969
+ if (selectAdjacentPoints) {
970
+ const adjacentIndices = this.graph.getAdjacentIndices(index) ?? []
971
+ this.selectPointsByIndices([index, ...adjacentIndices])
972
+ } else this.selectPointsByIndices([index])
973
+ }
974
+
975
+ /**
976
+ * Select multiples points by their indices.
977
+ * @param indices Array of points indices.
978
+ */
979
+ public selectPointsByIndices (indices?: (number | undefined)[] | null): void {
980
+ if (this._isDestroyed) return
981
+ if (this.ensureDevice(() => this.selectPointsByIndices(indices))) return
982
+ if (!this.points) return
983
+ if (!indices) {
984
+ this.store.selectedIndices = null
985
+ } else if (indices.length === 0) {
986
+ this.store.selectedIndices = new Float32Array()
987
+ } else {
988
+ this.store.selectedIndices = new Float32Array(indices.filter(d => d !== undefined))
989
+ }
990
+
991
+ this.points.updateGreyoutStatus()
992
+ }
993
+
994
+ /**
995
+ * Unselect all points.
996
+ */
997
+ public unselectPoints (): void {
998
+ if (this._isDestroyed) return
999
+ if (this.ensureDevice(() => this.unselectPoints())) return
1000
+ if (!this.points) return
1001
+ this.store.selectedIndices = null
1002
+ this.points.updateGreyoutStatus()
1003
+ }
1004
+
1005
+ /**
1006
+ * Get indices of points that are currently selected.
1007
+ * @returns Array of selected indices of points.
1008
+ */
1009
+ public getSelectedIndices (): number[] | null {
1010
+ if (this._isDestroyed) return null
1011
+ const { selectedIndices } = this.store
1012
+ if (!selectedIndices) return null
1013
+ return Array.from(selectedIndices)
1014
+ }
1015
+
1016
+ /**
1017
+ * Get indices that are adjacent to a specific point by its index.
1018
+ * @param index Index of the point.
1019
+ * @returns Array of adjacent indices.
1020
+ */
1021
+
1022
+ public getAdjacentIndices (index: number): number[] | undefined {
1023
+ if (this._isDestroyed) return undefined
1024
+ return this.graph.getAdjacentIndices(index)
1025
+ }
1026
+
1027
+ /**
1028
+ * Converts the X and Y point coordinates from the space coordinate system to the screen coordinate system.
1029
+ * @param spacePosition Array of x and y coordinates in the space coordinate system.
1030
+ * @returns Array of x and y coordinates in the screen coordinate system.
1031
+ */
1032
+ public spaceToScreenPosition (spacePosition: [number, number]): [number, number] {
1033
+ if (this._isDestroyed) return [0, 0]
1034
+ return this.zoomInstance.convertSpaceToScreenPosition(spacePosition)
1035
+ }
1036
+
1037
+ /**
1038
+ * Converts the X and Y point coordinates from the screen coordinate system to the space coordinate system.
1039
+ * @param screenPosition Array of x and y coordinates in the screen coordinate system.
1040
+ * @returns Array of x and y coordinates in the space coordinate system.
1041
+ */
1042
+ public screenToSpacePosition (screenPosition: [number, number]): [number, number] {
1043
+ if (this._isDestroyed) return [0, 0]
1044
+ return this.zoomInstance.convertScreenToSpacePosition(screenPosition)
1045
+ }
1046
+
1047
+ /**
1048
+ * Converts the point radius value from the space coordinate system to the screen coordinate system.
1049
+ * @param spaceRadius Radius of point in the space coordinate system.
1050
+ * @returns Radius of point in the screen coordinate system.
1051
+ */
1052
+ public spaceToScreenRadius (spaceRadius: number): number {
1053
+ if (this._isDestroyed) return 0
1054
+ return this.zoomInstance.convertSpaceToScreenRadius(spaceRadius)
1055
+ }
1056
+
1057
+ /**
1058
+ * Get point radius by its index.
1059
+ * @param index Index of the point.
1060
+ * @returns Radius of the point.
1061
+ */
1062
+ public getPointRadiusByIndex (index: number): number | undefined {
1063
+ if (this._isDestroyed) return undefined
1064
+ return this.graph.pointSizes?.[index]
1065
+ }
1066
+
1067
+ /**
1068
+ * Track multiple point positions by their indices on each Cosmos tick.
1069
+ * @param indices Array of points indices.
1070
+ */
1071
+ public trackPointPositionsByIndices (indices: number[]): void {
1072
+ if (this._isDestroyed) return
1073
+
1074
+ if (this.ensureDevice(() => this.trackPointPositionsByIndices(indices))) return
1075
+ if (!this.points) return
1076
+ this.points.trackPointsByIndices(indices)
1077
+ }
1078
+
1079
+ /**
1080
+ * Get current X and Y coordinates of the tracked points.
1081
+ * Do not mutate the returned map - it may affect future calls.
1082
+ * @returns A ReadonlyMap where keys are point indices and values are their corresponding X and Y coordinates in the [number, number] format.
1083
+ * @see trackPointPositionsByIndices To set which points should be tracked
1084
+ */
1085
+ public getTrackedPointPositionsMap (): ReadonlyMap<number, [number, number]> {
1086
+ if (this._isDestroyed || !this.points) return new Map()
1087
+ return this.points.getTrackedPositionsMap()
1088
+ }
1089
+
1090
+ /**
1091
+ * Get current X and Y coordinates of the tracked points as an array.
1092
+ * @returns Array of point positions in the format [x1, y1, x2, y2, ..., xn, yn] for tracked points only.
1093
+ * The positions are ordered by the tracking indices (same order as provided to trackPointPositionsByIndices).
1094
+ * Returns an empty array if no points are being tracked.
1095
+ */
1096
+ public getTrackedPointPositionsArray (): number[] {
1097
+ if (this._isDestroyed || !this.points) return []
1098
+ return this.points.getTrackedPositionsArray()
1099
+ }
1100
+
1101
+ /**
1102
+ * For the points that are currently visible on the screen, get a sample of point indices with their coordinates.
1103
+ * The resulting number of points will depend on the `pointSamplingDistance` configuration property,
1104
+ * and the sampled points will be evenly distributed.
1105
+ * @returns A Map object where keys are the index of the points and values are their corresponding X and Y coordinates in the [number, number] format.
1106
+ */
1107
+ public getSampledPointPositionsMap (): Map<number, [number, number]> {
1108
+ if (this._isDestroyed || !this.points) return new Map()
1109
+ return this.points.getSampledPointPositionsMap()
1110
+ }
1111
+
1112
+ /**
1113
+ * For the points that are currently visible on the screen, get a sample of point indices and positions.
1114
+ * The resulting number of points will depend on the `pointSamplingDistance` configuration property,
1115
+ * and the sampled points will be evenly distributed.
1116
+ * @returns An object containing arrays of point indices and positions.
1117
+ */
1118
+ public getSampledPoints (): { indices: number[]; positions: number[] } {
1119
+ if (this._isDestroyed || !this.points) return { indices: [], positions: [] }
1120
+ return this.points.getSampledPoints()
1121
+ }
1122
+
1123
+ /**
1124
+ * Gets the X-axis of rescaling function.
1125
+ *
1126
+ * This scale is automatically created when position rescaling is enabled.
1127
+ */
1128
+ public getScaleX (): ((x: number) => number) | undefined {
1129
+ if (this._isDestroyed || !this.points) return undefined
1130
+ return this.points.scaleX
1131
+ }
1132
+
1133
+ /**
1134
+ * Gets the Y-axis of rescaling function.
1135
+ *
1136
+ * This scale is automatically created when position rescaling is enabled.
1137
+ */
1138
+ public getScaleY (): ((y: number) => number) | undefined {
1139
+ if (this._isDestroyed || !this.points) return undefined
1140
+ return this.points.scaleY
1141
+ }
1142
+
1143
+ /**
1144
+ * Start the simulation.
1145
+ * @param alpha Value from 0 to 1. The higher the value, the more initial energy the simulation will get.
1146
+ */
1147
+ public start (alpha = 1): void {
1148
+ if (this._isDestroyed) return
1149
+
1150
+ if (this.ensureDevice(() => this.start(alpha))) return
1151
+
1152
+ if (!this.graph.pointsNumber) return
1153
+
1154
+ // Only start the simulation if alpha > 0
1155
+ if (alpha > 0) {
1156
+ this.store.isSimulationRunning = true
1157
+ this.store.simulationProgress = 0
1158
+ this.config.onSimulationStart?.()
1159
+ }
1160
+
1161
+ this.store.alpha = alpha
1162
+ this.stopFrames()
1163
+ this.frame()
1164
+ }
1165
+
1166
+ /**
1167
+ * Pause the simulation. When paused, the simulation stops running
1168
+ * and can be resumed using the unpause method.
1169
+ */
1170
+ public pause (): void {
1171
+ if (this._isDestroyed) return
1172
+ if (this.ensureDevice(() => this.pause())) return
1173
+ this.store.isSimulationRunning = false
1174
+ this.config.onSimulationPause?.()
1175
+ }
1176
+
1177
+ /**
1178
+ * Unpause the simulation. This method resumes a paused
1179
+ * simulation and continues its execution.
1180
+ */
1181
+ public unpause (): void {
1182
+ if (this._isDestroyed) return
1183
+ if (this.ensureDevice(() => this.unpause())) return
1184
+ this.store.isSimulationRunning = true
1185
+ this.config.onSimulationUnpause?.()
1186
+ }
1187
+
1188
+ /**
1189
+ * Restart/Resume the simulation. This method unpauses a paused
1190
+ * simulation and resumes its execution.
1191
+ * @deprecated Use `unpause()` instead. This method will be removed in a future version.
1192
+ */
1193
+ public restart (): void {
1194
+ if (this._isDestroyed) return
1195
+ if (this.ensureDevice(() => this.restart())) return
1196
+ this.store.isSimulationRunning = true
1197
+ this.config.onSimulationRestart?.()
1198
+ }
1199
+
1200
+ /**
1201
+ * Render only one frame of the simulation (stops the simulation if it was running).
1202
+ */
1203
+ public step (): void {
1204
+ if (this._isDestroyed) return
1205
+
1206
+ if (this.ensureDevice(() => this.step())) return
1207
+
1208
+ this.store.isSimulationRunning = false
1209
+ this.stopFrames()
1210
+ this.frame()
1211
+ }
1212
+
1213
+ /**
1214
+ * Destroy this Cosmos instance.
1215
+ */
1216
+ public destroy (): void {
1217
+ if (this._isDestroyed) return
1218
+ window.clearTimeout(this._fitViewOnInitTimeoutID)
1219
+ this.stopFrames()
1220
+
1221
+ // Remove all event listeners
1222
+ if (this.canvasD3Selection) {
1223
+ this.canvasD3Selection
1224
+ .on('mouseenter.cosmos', null)
1225
+ .on('mousemove.cosmos', null)
1226
+ .on('mouseleave.cosmos', null)
1227
+ .on('click', null)
1228
+ .on('mousemove', null)
1229
+ .on('contextmenu', null)
1230
+ .on('.drag', null)
1231
+ .on('.zoom', null)
1232
+ }
1233
+
1234
+ select(document)
1235
+ .on('keydown.cosmos', null)
1236
+ .on('keyup.cosmos', null)
1237
+
1238
+ if (this.zoomInstance?.behavior) {
1239
+ this.zoomInstance.behavior
1240
+ .on('start.detect', null)
1241
+ .on('zoom.detect', null)
1242
+ .on('end.detect', null)
1243
+ }
1244
+
1245
+ if (this.dragInstance?.behavior) {
1246
+ this.dragInstance.behavior
1247
+ .on('start.detect', null)
1248
+ .on('drag.detect', null)
1249
+ .on('end.detect', null)
1250
+ }
1251
+
1252
+ this.fpsMonitor?.destroy()
1253
+ if (this.device) {
1254
+ // Clears the canvas after particle system is destroyed
1255
+ const clearPass = this.device.beginRenderPass({
1256
+ clearColor: this.store.backgroundColor,
1257
+ clearDepth: 1,
1258
+ clearStencil: 0,
1259
+ })
1260
+ clearPass.end()
1261
+ this.device.destroy()
1262
+ }
1263
+
1264
+ if (this.canvas && this.canvas.parentNode) {
1265
+ this.canvas.parentNode.removeChild(this.canvas)
1266
+ }
1267
+
1268
+ if (this.attributionDivElement && this.attributionDivElement.parentNode) {
1269
+ this.attributionDivElement.parentNode.removeChild(this.attributionDivElement)
1270
+ }
1271
+
1272
+ document.getElementById('gl-bench-style')?.remove()
1273
+
1274
+ this.canvasD3Selection = undefined
1275
+ this.attributionDivElement = undefined
1276
+
1277
+ this._isDestroyed = true
1278
+ }
1279
+
1280
+ /**
1281
+ * Updates and recreates the graph visualization based on pending changes.
1282
+ */
1283
+ public create (): void {
1284
+ if (this._isDestroyed) return
1285
+ if (this.ensureDevice(() => this.create())) return
1286
+ if (!this.points) return
1287
+ if (!this.lines) return
1288
+ if (this.isPointPositionsUpdateNeeded) this.points.updatePositions()
1289
+ if (this.isPointColorUpdateNeeded) this.points.updateColor()
1290
+ if (this.isPointSizeUpdateNeeded) this.points.updateSize()
1291
+ if (this.isPointShapeUpdateNeeded) this.points.updateShape()
1292
+ if (this.isPointImageIndicesUpdateNeeded) this.points.updateImageIndices()
1293
+ if (this.isPointImageSizesUpdateNeeded) this.points.updateImageSizes()
1294
+
1295
+ if (this.isLinksUpdateNeeded) this.lines.updatePointsBuffer()
1296
+ if (this.isLinkColorUpdateNeeded) this.lines.updateColor()
1297
+ if (this.isLinkWidthUpdateNeeded) this.lines.updateWidth()
1298
+ if (this.isLinkArrowUpdateNeeded) this.lines.updateArrow()
1299
+
1300
+ if (this.isForceManyBodyUpdateNeeded) this.forceManyBody?.create()
1301
+ if (this.isForceLinkUpdateNeeded) {
1302
+ this.forceLinkIncoming?.create(LinkDirection.INCOMING)
1303
+ this.forceLinkOutgoing?.create(LinkDirection.OUTGOING)
1304
+ }
1305
+ if (this.isForceCenterUpdateNeeded) this.forceCenter?.create()
1306
+ if (this.isPointClusterUpdateNeeded) this.clusters?.create()
1307
+
1308
+ this.isPointPositionsUpdateNeeded = false
1309
+ this.isPointColorUpdateNeeded = false
1310
+ this.isPointSizeUpdateNeeded = false
1311
+ this.isPointShapeUpdateNeeded = false
1312
+ this.isPointImageIndicesUpdateNeeded = false
1313
+ this.isPointImageSizesUpdateNeeded = false
1314
+ this.isLinksUpdateNeeded = false
1315
+ this.isLinkColorUpdateNeeded = false
1316
+ this.isLinkWidthUpdateNeeded = false
1317
+ this.isLinkArrowUpdateNeeded = false
1318
+ this.isPointClusterUpdateNeeded = false
1319
+ this.isForceManyBodyUpdateNeeded = false
1320
+ this.isForceLinkUpdateNeeded = false
1321
+ this.isForceCenterUpdateNeeded = false
1322
+ }
1323
+
1324
+ /**
1325
+ * Converts an array of tuple positions to a single array containing all coordinates sequentially
1326
+ * @param pointPositions An array of tuple positions
1327
+ * @returns A flatten array of coordinates
1328
+ */
1329
+ public flatten (pointPositions: [number, number][]): number[] {
1330
+ return pointPositions.flat()
1331
+ }
1332
+
1333
+ /**
1334
+ * Converts a flat array of point positions to a tuple pairs representing coordinates
1335
+ * @param pointPositions A flattened array of coordinates
1336
+ * @returns An array of tuple positions
1337
+ */
1338
+ public pair (pointPositions: number[]): [number, number][] {
1339
+ const arr = new Array(pointPositions.length / 2) as [number, number][]
1340
+ for (let i = 0; i < pointPositions.length / 2; i++) {
1341
+ arr[i] = [pointPositions[i * 2] as number, pointPositions[i * 2 + 1] as number]
1342
+ }
1343
+
1344
+ return arr
1345
+ }
1346
+
1347
+ /**
1348
+ * Ensures device is initialized before executing a method.
1349
+ * If device is not ready, queues the method to run after initialization.
1350
+ * @param callback - Function to execute once device is ready
1351
+ * @returns true if device was not ready and operation was queued, false if device is ready
1352
+ */
1353
+ private ensureDevice (callback: () => void): boolean {
1354
+ if (!this.device) {
1355
+ this.deviceInitPromise
1356
+ .then(() => {
1357
+ callback()
1358
+ })
1359
+ .catch(error => {
1360
+ console.error('Device initialization failed', error)
1361
+ })
1362
+ return true
1363
+ }
1364
+ return false
1365
+ }
1366
+
1367
+ /**
1368
+ * Internal device creation method
1369
+ * Graph class decides what device to create with sensible defaults
1370
+ */
1371
+ private async createDevice (
1372
+ canvas: HTMLCanvasElement
1373
+ ): Promise<Device> {
1374
+ // Use config.pixelRatio if provided, otherwise use true (window.devicePixelRatio)
1375
+ const useDevicePixels = this.config.pixelRatio !== undefined
1376
+ ? this.config.pixelRatio // Use config value as number multiplier
1377
+ : true // Use window.devicePixelRatio automatically
1378
+
1379
+ return await luma.createDevice({
1380
+ type: 'webgl',
1381
+ adapters: [webgl2Adapter],
1382
+ createCanvasContext: {
1383
+ canvas, // Provide existing canvas
1384
+ useDevicePixels, // Use computed value
1385
+ autoResize: true,
1386
+ width: undefined,
1387
+ height: undefined,
1388
+ },
1389
+ })
1390
+ }
1391
+
1392
+ private update (simulationAlpha = this.store.alpha): void {
1393
+ const { graph } = this
1394
+ this.store.pointsTextureSize = Math.ceil(Math.sqrt(graph.pointsNumber ?? 0))
1395
+ this.store.linksTextureSize = Math.ceil(Math.sqrt((graph.linksNumber ?? 0) * 2))
1396
+ this.create()
1397
+ this.initPrograms()
1398
+ this.store.hoveredPoint = undefined
1399
+ this.start(simulationAlpha)
1400
+ }
1401
+
1402
+ private initPrograms (): void {
1403
+ if (this._isDestroyed || !this.points || !this.lines || !this.clusters) return
1404
+ this.points.initPrograms()
1405
+ this.lines.initPrograms()
1406
+ this.forceGravity?.initPrograms()
1407
+ this.forceManyBody?.initPrograms()
1408
+ this.forceCenter?.initPrograms()
1409
+ this.forceLinkIncoming?.initPrograms()
1410
+ this.forceLinkOutgoing?.initPrograms()
1411
+ this.forceMouse?.initPrograms()
1412
+ this.clusters.initPrograms()
1413
+ }
1414
+
1415
+ private frame (): void {
1416
+ if (this._isDestroyed) return
1417
+ const { config: { simulationGravity, simulationCenter, renderLinks, enableSimulation }, store: { alpha, isSimulationRunning } } = this
1418
+ if (alpha < ALPHA_MIN && isSimulationRunning) this.end()
1419
+ if (!this.store.pointsTextureSize) return
1420
+
1421
+ this.requestAnimationFrameId = window.requestAnimationFrame((now) => {
1422
+ this.fpsMonitor?.begin()
1423
+ this.resizeCanvas()
1424
+ if (!this.dragInstance.isActive) {
1425
+ this.findHoveredItem()
1426
+ }
1427
+
1428
+ if (enableSimulation) {
1429
+ if (this.isRightClickMouse && this.config.enableRightClickRepulsion) {
1430
+ this.forceMouse?.run()
1431
+ this.points?.updatePosition()
1432
+ }
1433
+
1434
+ const shouldRunSimulation =
1435
+ isSimulationRunning &&
1436
+ !(this.zoomInstance.isRunning && !this.config.enableSimulationDuringZoom)
1437
+
1438
+ if (shouldRunSimulation) {
1439
+ // Clear velocity buffer once per frame before applying forces
1440
+ if (this.points?.velocityFbo && !this.points.velocityFbo.destroyed && this.device) {
1441
+ const velocityClearPass = this.device.beginRenderPass({
1442
+ framebuffer: this.points.velocityFbo,
1443
+ clearColor: [0, 0, 0, 0],
1444
+ })
1445
+ velocityClearPass.end()
1446
+ }
1447
+
1448
+ if (simulationGravity) {
1449
+ this.forceGravity?.run()
1450
+ this.points?.updatePosition()
1451
+ }
1452
+
1453
+ if (this.isRightClickMouse && this.config.enableRightClickRepulsion) {
1454
+ this.forceMouse?.run()
1455
+ this.points?.updatePosition()
1456
+ }
1457
+
1458
+ if (simulationCenter) {
1459
+ this.forceCenter?.run()
1460
+ this.points?.updatePosition()
1461
+ }
1462
+
1463
+ this.forceManyBody?.run()
1464
+ this.points?.updatePosition()
1465
+
1466
+ if (this.store.linksTextureSize) {
1467
+ this.forceLinkIncoming?.run()
1468
+ this.points?.updatePosition()
1469
+ this.forceLinkOutgoing?.run()
1470
+ this.points?.updatePosition()
1471
+ }
1472
+
1473
+ if (this.graph.pointClusters || this.graph.clusterPositions) {
1474
+ this.clusters?.run()
1475
+ this.points?.updatePosition()
1476
+ }
1477
+
1478
+ this.store.alpha += this.store.addAlpha(this.config.simulationDecay ?? defaultConfigValues.simulation.decay)
1479
+ if (this.isRightClickMouse && this.config.enableRightClickRepulsion) this.store.alpha = Math.max(this.store.alpha, 0.1)
1480
+ this.store.simulationProgress = Math.sqrt(Math.min(1, ALPHA_MIN / this.store.alpha))
1481
+ this.config.onSimulationTick?.(
1482
+ this.store.alpha,
1483
+ this.store.hoveredPoint?.index,
1484
+ this.store.hoveredPoint?.position
1485
+ )
1486
+ }
1487
+
1488
+ this.points?.trackPoints()
1489
+ }
1490
+
1491
+ // Create a single render pass for drawing (points, lines, etc.)
1492
+ // Simulation will use separate render passes later
1493
+ if (this.device) {
1494
+ const backgroundColor = this.store.backgroundColor ?? [0, 0, 0, 1]
1495
+ const drawRenderPass = this.device.beginRenderPass({
1496
+ clearColor: backgroundColor,
1497
+ clearDepth: 1,
1498
+ clearStencil: 0,
1499
+ })
1500
+
1501
+ const shouldDrawLinks =
1502
+ renderLinks !== false &&
1503
+ !!this.store.linksTextureSize &&
1504
+ !!this.graph.linksNumber &&
1505
+ this.graph.linksNumber > 0
1506
+
1507
+ if (shouldDrawLinks) {
1508
+ this.lines?.draw(drawRenderPass)
1509
+ }
1510
+
1511
+ this.points?.draw(drawRenderPass)
1512
+
1513
+ if (this.dragInstance.isActive) {
1514
+ // To prevent the dragged point from suddenly jumping, run the drag function twice
1515
+ this.points?.drag()
1516
+ this.points?.drag()
1517
+ }
1518
+
1519
+ drawRenderPass.end()
1520
+ this.device.submit()
1521
+ }
1522
+
1523
+ this.fpsMonitor?.end(now)
1524
+
1525
+ this.currentEvent = undefined
1526
+ if (!this._isDestroyed) {
1527
+ this.frame()
1528
+ }
1529
+ })
1530
+ }
1531
+
1532
+ private stopFrames (): void {
1533
+ if (this.requestAnimationFrameId) window.cancelAnimationFrame(this.requestAnimationFrameId)
1534
+ }
1535
+
1536
+ private end (): void {
1537
+ this.store.isSimulationRunning = false
1538
+ this.store.simulationProgress = 1
1539
+ this.config.onSimulationEnd?.()
1540
+ }
1541
+
1542
+ private onClick (event: MouseEvent): void {
1543
+ this.config.onClick?.(
1544
+ this.store.hoveredPoint?.index,
1545
+ this.store.hoveredPoint?.position,
1546
+ event
1547
+ )
1548
+
1549
+ if (this.store.hoveredPoint) {
1550
+ this.config.onPointClick?.(
1551
+ this.store.hoveredPoint.index,
1552
+ this.store.hoveredPoint.position,
1553
+ event
1554
+ )
1555
+ } else if (this.store.hoveredLinkIndex !== undefined) {
1556
+ this.config.onLinkClick?.(
1557
+ this.store.hoveredLinkIndex,
1558
+ event
1559
+ )
1560
+ } else {
1561
+ this.config.onBackgroundClick?.(
1562
+ event
1563
+ )
1564
+ }
1565
+ }
1566
+
1567
+ private updateMousePosition (event: MouseEvent | D3DragEvent<HTMLCanvasElement, undefined, Hovered>): void {
1568
+ if (!event) return
1569
+ const mouseX = (event as MouseEvent).offsetX ?? (event as D3DragEvent<HTMLCanvasElement, undefined, Hovered>).x
1570
+ const mouseY = (event as MouseEvent).offsetY ?? (event as D3DragEvent<HTMLCanvasElement, undefined, Hovered>).y
1571
+ if (mouseX === undefined || mouseY === undefined) return
1572
+ this.store.mousePosition = this.zoomInstance.convertScreenToSpacePosition([mouseX, mouseY])
1573
+ this.store.screenMousePosition = [mouseX, (this.store.screenSize[1] - mouseY)]
1574
+ }
1575
+
1576
+ private onMouseMove (event: MouseEvent): void {
1577
+ this.currentEvent = event
1578
+ this.updateMousePosition(event)
1579
+ this.isRightClickMouse = event.which === 3
1580
+ this.config.onMouseMove?.(
1581
+ this.store.hoveredPoint?.index,
1582
+ this.store.hoveredPoint?.position,
1583
+ this.currentEvent
1584
+ )
1585
+ }
1586
+
1587
+ private onRightClickMouse (event: MouseEvent): void {
1588
+ event.preventDefault()
1589
+ }
1590
+
1591
+ private resizeCanvas (forceResize = false): void {
1592
+ if (this._isDestroyed) return
1593
+ const w = this.canvas.clientWidth
1594
+ const h = this.canvas.clientHeight
1595
+ const [prevW, prevH] = this.store.screenSize
1596
+
1597
+ // Check if CSS size changed (luma.gl's autoResize handles canvas.width/height automatically)
1598
+ if (forceResize || prevW !== w || prevH !== h) {
1599
+ const { k } = this.zoomInstance.eventTransform
1600
+ const centerPosition = this.zoomInstance.convertScreenToSpacePosition([prevW / 2, prevH / 2])
1601
+
1602
+ this.store.updateScreenSize(w, h)
1603
+ // Note: canvas.width and canvas.height are managed by luma.gl's autoResize
1604
+ // We only update our internal state and dependent components
1605
+ this.canvasD3Selection
1606
+ ?.call(this.zoomInstance.behavior.transform, this.zoomInstance.getTransform([centerPosition], k))
1607
+ this.points?.updateSampledPointsGrid()
1608
+ // Only update link index FBO if link hovering is enabled
1609
+ if (this.store.isLinkHoveringEnabled) {
1610
+ this.lines?.updateLinkIndexFbo()
1611
+ }
1612
+ }
1613
+ }
1614
+
1615
+ private setZoomTransformByPointPositions (positions: number[], duration = 250, scale?: number, padding?: number): void {
1616
+ this.resizeCanvas()
1617
+ const transform = this.zoomInstance.getTransform(this.pair(positions), scale, padding)
1618
+ this.canvasD3Selection
1619
+ ?.transition()
1620
+ .ease(easeQuadInOut)
1621
+ .duration(duration)
1622
+ .call(this.zoomInstance.behavior.transform, transform)
1623
+ }
1624
+
1625
+ private updateZoomDragBehaviors (): void {
1626
+ if (this.config.enableDrag) {
1627
+ this.canvasD3Selection?.call(this.dragInstance.behavior)
1628
+ } else {
1629
+ this.canvasD3Selection
1630
+ ?.call(this.dragInstance.behavior)
1631
+ .on('.drag', null)
1632
+ }
1633
+
1634
+ if (this.config.enableZoom) {
1635
+ this.canvasD3Selection?.call(this.zoomInstance.behavior)
1636
+ } else {
1637
+ this.canvasD3Selection
1638
+ ?.call(this.zoomInstance.behavior)
1639
+ .on('wheel.zoom', null)
1640
+ }
1641
+ }
1642
+
1643
+ private findHoveredItem (): void {
1644
+ if (this._isDestroyed || !this._isMouseOnCanvas) return
1645
+ if (this._findHoveredItemExecutionCount < MAX_HOVER_DETECTION_DELAY) {
1646
+ this._findHoveredItemExecutionCount += 1
1647
+ return
1648
+ }
1649
+ this._findHoveredItemExecutionCount = 0
1650
+ this.findHoveredPoint()
1651
+
1652
+ if (this.graph.linksNumber && this.store.isLinkHoveringEnabled) {
1653
+ this.findHoveredLine()
1654
+ } else if (this.store.hoveredLinkIndex !== undefined) {
1655
+ // Clear stale hoveredLinkIndex when there are no links
1656
+ const wasHovered = this.store.hoveredLinkIndex !== undefined
1657
+ this.store.hoveredLinkIndex = undefined
1658
+ if (wasHovered && this.config.onLinkMouseOut) {
1659
+ this.config.onLinkMouseOut(this.currentEvent)
1660
+ }
1661
+ }
1662
+
1663
+ this.updateCanvasCursor()
1664
+ }
1665
+
1666
+ private findHoveredPoint (): void {
1667
+ if (this._isDestroyed || !this.device || !this.points) return
1668
+ this.points.findHoveredPoint()
1669
+ let isMouseover = false
1670
+ let isMouseout = false
1671
+ const pixels = readPixels(this.device, this.points.hoveredFbo as Framebuffer, 0, 0, 2, 2)
1672
+ // Shader writes: rgba = vec4(index, size, pointPosition.xy)
1673
+ const hoveredIndex = pixels[0] as number
1674
+ const pointSize = pixels[1] as number
1675
+ const pointX = pixels[2] as number
1676
+ const pointY = pixels[3] as number
1677
+
1678
+ if (pointSize > 0) {
1679
+ if (this.store.hoveredPoint === undefined || this.store.hoveredPoint.index !== hoveredIndex) {
1680
+ isMouseover = true
1681
+ }
1682
+ this.store.hoveredPoint = {
1683
+ index: hoveredIndex,
1684
+ position: [pointX, pointY],
1685
+ }
1686
+ } else {
1687
+ if (this.store.hoveredPoint) isMouseout = true
1688
+ this.store.hoveredPoint = undefined
1689
+ }
1690
+
1691
+ if (isMouseover && this.store.hoveredPoint) {
1692
+ this.config.onPointMouseOver?.(
1693
+ this.store.hoveredPoint.index,
1694
+ this.store.hoveredPoint.position,
1695
+ this.currentEvent
1696
+ )
1697
+ }
1698
+ if (isMouseout) this.config.onPointMouseOut?.(this.currentEvent)
1699
+ }
1700
+
1701
+ private findHoveredLine (): void {
1702
+ if (this._isDestroyed || !this.lines) return
1703
+ if (this.store.hoveredPoint) {
1704
+ if (this.store.hoveredLinkIndex !== undefined) {
1705
+ this.store.hoveredLinkIndex = undefined
1706
+ this.config.onLinkMouseOut?.(this.currentEvent)
1707
+ }
1708
+ return
1709
+ }
1710
+ this.lines.findHoveredLine()
1711
+ let isMouseover = false
1712
+ let isMouseout = false
1713
+
1714
+ if (!this.device) return
1715
+ const pixels = readPixels(this.device, this.lines.hoveredLineIndexFbo!)
1716
+ const hoveredLineIndex = pixels[0] as number
1717
+
1718
+ if (hoveredLineIndex >= 0) {
1719
+ if (this.store.hoveredLinkIndex !== hoveredLineIndex) isMouseover = true
1720
+ this.store.hoveredLinkIndex = hoveredLineIndex
1721
+ } else {
1722
+ if (this.store.hoveredLinkIndex !== undefined) isMouseout = true
1723
+ this.store.hoveredLinkIndex = undefined
1724
+ }
1725
+
1726
+ if (isMouseover && this.store.hoveredLinkIndex !== undefined) {
1727
+ this.config.onLinkMouseOver?.(this.store.hoveredLinkIndex)
1728
+ }
1729
+ if (isMouseout) this.config.onLinkMouseOut?.(this.currentEvent)
1730
+ }
1731
+
1732
+ private updateCanvasCursor (): void {
1733
+ const { hoveredPointCursor, hoveredLinkCursor } = this.config
1734
+ if (this.dragInstance.isActive) select(this.canvas).style('cursor', 'grabbing')
1735
+ else if (this.store.hoveredPoint) {
1736
+ if (!this.config.enableDrag || this.store.isSpaceKeyPressed) select(this.canvas).style('cursor', hoveredPointCursor)
1737
+ else select(this.canvas).style('cursor', 'grab')
1738
+ } else if (this.store.isLinkHoveringEnabled && this.store.hoveredLinkIndex !== undefined) {
1739
+ select(this.canvas).style('cursor', hoveredLinkCursor)
1740
+ } else select(this.canvas).style('cursor', null)
1741
+ }
1742
+
1743
+ private addAttribution (): void {
1744
+ if (!this.config.attribution) return
1745
+ this.attributionDivElement = document.createElement('div')
1746
+ this.attributionDivElement.style.cssText = `
1747
+ user-select: none;
1748
+ position: absolute;
1749
+ bottom: 0;
1750
+ right: 0;
1751
+ color: var(--cosmosgl-attribution-color);
1752
+ margin: 0 0.6rem 0.6rem 0;
1753
+ font-size: 0.7rem;
1754
+ font-family: inherit;
1755
+ `
1756
+ // Sanitize the attribution HTML content to prevent XSS attacks
1757
+ // Use more permissive settings for attribution since it's controlled by the library user
1758
+ this.attributionDivElement.innerHTML = sanitizeHtml(this.config.attribution, {
1759
+ ALLOWED_TAGS: ['a', 'b', 'i', 'em', 'strong', 'span', 'div', 'p', 'br', 'img'],
1760
+ ALLOWED_ATTR: ['href', 'target', 'class', 'id', 'style', 'src', 'alt', 'title'],
1761
+ })
1762
+ this.store.div?.appendChild(this.attributionDivElement)
1763
+ }
1764
+ }
1765
+
1766
+ export type { GraphConfigInterface } from './config'
1767
+ export { PointShape } from './modules/GraphData'
1768
+
1769
+ export * from './helper'