@guinetik/gcanvas 1.0.4 → 1.0.5

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 (193) hide show
  1. package/dist/CNAME +1 -0
  2. package/dist/animations.html +31 -0
  3. package/dist/basic.html +38 -0
  4. package/dist/baskara.html +31 -0
  5. package/dist/bezier.html +35 -0
  6. package/dist/beziersignature.html +29 -0
  7. package/dist/blackhole.html +28 -0
  8. package/dist/blob.html +35 -0
  9. package/dist/coordinates.html +698 -0
  10. package/dist/cube3d.html +23 -0
  11. package/dist/demos.css +303 -0
  12. package/dist/dino.html +42 -0
  13. package/dist/easing.html +28 -0
  14. package/dist/events.html +195 -0
  15. package/dist/fluent.html +647 -0
  16. package/dist/fluid-simple.html +22 -0
  17. package/dist/fluid.html +37 -0
  18. package/dist/fractals.html +36 -0
  19. package/dist/gameobjects.html +626 -0
  20. package/dist/gcanvas.es.js +517 -0
  21. package/dist/gcanvas.es.min.js +1 -1
  22. package/dist/gcanvas.umd.js +1 -1
  23. package/dist/gcanvas.umd.min.js +1 -1
  24. package/dist/genart.html +26 -0
  25. package/dist/gendream.html +26 -0
  26. package/dist/group.html +36 -0
  27. package/dist/home.html +587 -0
  28. package/dist/hyperbolic001.html +23 -0
  29. package/dist/hyperbolic002.html +23 -0
  30. package/dist/hyperbolic003.html +23 -0
  31. package/dist/hyperbolic004.html +23 -0
  32. package/dist/hyperbolic005.html +22 -0
  33. package/dist/index.html +398 -0
  34. package/dist/isometric.html +34 -0
  35. package/dist/js/animations.js +452 -0
  36. package/dist/js/basic.js +204 -0
  37. package/dist/js/baskara.js +751 -0
  38. package/dist/js/bezier.js +692 -0
  39. package/dist/js/beziersignature.js +241 -0
  40. package/dist/js/blackhole/accretiondisk.obj.js +379 -0
  41. package/dist/js/blackhole/blackhole.obj.js +318 -0
  42. package/dist/js/blackhole/index.js +409 -0
  43. package/dist/js/blackhole/particle.js +56 -0
  44. package/dist/js/blackhole/starfield.obj.js +218 -0
  45. package/dist/js/blob.js +2276 -0
  46. package/dist/js/coordinates.js +840 -0
  47. package/dist/js/cube3d.js +789 -0
  48. package/dist/js/dino.js +1420 -0
  49. package/dist/js/easing.js +477 -0
  50. package/dist/js/fluent.js +183 -0
  51. package/dist/js/fluid-simple.js +253 -0
  52. package/dist/js/fluid.js +527 -0
  53. package/dist/js/fractals.js +932 -0
  54. package/dist/js/fractalworker.js +93 -0
  55. package/dist/js/gameobjects.js +176 -0
  56. package/dist/js/genart.js +268 -0
  57. package/dist/js/gendream.js +209 -0
  58. package/dist/js/group.js +140 -0
  59. package/dist/js/hyperbolic001.js +310 -0
  60. package/dist/js/hyperbolic002.js +388 -0
  61. package/dist/js/hyperbolic003.js +319 -0
  62. package/dist/js/hyperbolic004.js +345 -0
  63. package/dist/js/hyperbolic005.js +340 -0
  64. package/dist/js/info-toggle.js +25 -0
  65. package/dist/js/isometric.js +863 -0
  66. package/dist/js/kerr.js +1547 -0
  67. package/dist/js/lavalamp.js +590 -0
  68. package/dist/js/layout.js +354 -0
  69. package/dist/js/mondrian.js +285 -0
  70. package/dist/js/opacity.js +275 -0
  71. package/dist/js/painter.js +484 -0
  72. package/dist/js/particles-showcase.js +514 -0
  73. package/dist/js/particles.js +299 -0
  74. package/dist/js/patterns.js +397 -0
  75. package/dist/js/penrose/artifact.js +69 -0
  76. package/dist/js/penrose/blackhole.js +121 -0
  77. package/dist/js/penrose/constants.js +73 -0
  78. package/dist/js/penrose/game.js +943 -0
  79. package/dist/js/penrose/lore.js +278 -0
  80. package/dist/js/penrose/penrosescene.js +892 -0
  81. package/dist/js/penrose/ship.js +216 -0
  82. package/dist/js/penrose/sounds.js +211 -0
  83. package/dist/js/penrose/voidparticle.js +55 -0
  84. package/dist/js/penrose/voidscene.js +258 -0
  85. package/dist/js/penrose/voidship.js +144 -0
  86. package/dist/js/penrose/wormhole.js +46 -0
  87. package/dist/js/pipeline.js +555 -0
  88. package/dist/js/plane3d.js +256 -0
  89. package/dist/js/platformer.js +1579 -0
  90. package/dist/js/scene.js +304 -0
  91. package/dist/js/scenes.js +320 -0
  92. package/dist/js/schrodinger.js +410 -0
  93. package/dist/js/schwarzschild.js +1015 -0
  94. package/dist/js/shapes.js +628 -0
  95. package/dist/js/space/alien.js +171 -0
  96. package/dist/js/space/boom.js +98 -0
  97. package/dist/js/space/boss.js +353 -0
  98. package/dist/js/space/buff.js +73 -0
  99. package/dist/js/space/bullet.js +102 -0
  100. package/dist/js/space/constants.js +85 -0
  101. package/dist/js/space/game.js +1884 -0
  102. package/dist/js/space/hud.js +112 -0
  103. package/dist/js/space/laserbeam.js +179 -0
  104. package/dist/js/space/lightning.js +277 -0
  105. package/dist/js/space/minion.js +192 -0
  106. package/dist/js/space/missile.js +212 -0
  107. package/dist/js/space/player.js +430 -0
  108. package/dist/js/space/powerup.js +90 -0
  109. package/dist/js/space/starfield.js +58 -0
  110. package/dist/js/space/starpower.js +90 -0
  111. package/dist/js/spacetime.js +559 -0
  112. package/dist/js/sphere3d.js +229 -0
  113. package/dist/js/sprite.js +473 -0
  114. package/dist/js/starfaux/config.js +118 -0
  115. package/dist/js/starfaux/enemy.js +353 -0
  116. package/dist/js/starfaux/hud.js +78 -0
  117. package/dist/js/starfaux/index.js +482 -0
  118. package/dist/js/starfaux/laser.js +182 -0
  119. package/dist/js/starfaux/player.js +468 -0
  120. package/dist/js/starfaux/terrain.js +560 -0
  121. package/dist/js/study001.js +275 -0
  122. package/dist/js/study002.js +366 -0
  123. package/dist/js/study003.js +331 -0
  124. package/dist/js/study004.js +389 -0
  125. package/dist/js/study005.js +209 -0
  126. package/dist/js/study006.js +194 -0
  127. package/dist/js/study007.js +192 -0
  128. package/dist/js/study008.js +413 -0
  129. package/dist/js/svgtween.js +204 -0
  130. package/dist/js/tde/accretiondisk.js +471 -0
  131. package/dist/js/tde/blackhole.js +219 -0
  132. package/dist/js/tde/blackholescene.js +209 -0
  133. package/dist/js/tde/config.js +59 -0
  134. package/dist/js/tde/index.js +820 -0
  135. package/dist/js/tde/jets.js +290 -0
  136. package/dist/js/tde/lensedstarfield.js +154 -0
  137. package/dist/js/tde/tdestar.js +297 -0
  138. package/dist/js/tde/tidalstream.js +372 -0
  139. package/dist/js/tde_old/blackhole.obj.js +354 -0
  140. package/dist/js/tde_old/debris.obj.js +791 -0
  141. package/dist/js/tde_old/flare.obj.js +239 -0
  142. package/dist/js/tde_old/index.js +448 -0
  143. package/dist/js/tde_old/star.obj.js +812 -0
  144. package/dist/js/tetris/config.js +157 -0
  145. package/dist/js/tetris/grid.js +286 -0
  146. package/dist/js/tetris/index.js +1195 -0
  147. package/dist/js/tetris/renderer.js +634 -0
  148. package/dist/js/tetris/tetrominos.js +280 -0
  149. package/dist/js/tiles.js +312 -0
  150. package/dist/js/tweendemo.js +79 -0
  151. package/dist/js/visibility.js +102 -0
  152. package/dist/kerr.html +28 -0
  153. package/dist/lavalamp.html +27 -0
  154. package/dist/layouts.html +37 -0
  155. package/dist/logo.svg +4 -0
  156. package/dist/loop.html +84 -0
  157. package/dist/mondrian.html +32 -0
  158. package/dist/og_image.png +0 -0
  159. package/dist/opacity.html +36 -0
  160. package/dist/painter.html +39 -0
  161. package/dist/particles-showcase.html +28 -0
  162. package/dist/particles.html +24 -0
  163. package/dist/patterns.html +33 -0
  164. package/dist/penrose-game.html +31 -0
  165. package/dist/pipeline.html +737 -0
  166. package/dist/plane3d.html +24 -0
  167. package/dist/platformer.html +43 -0
  168. package/dist/scene.html +33 -0
  169. package/dist/scenes.html +96 -0
  170. package/dist/schrodinger.html +27 -0
  171. package/dist/schwarzschild.html +27 -0
  172. package/dist/shapes.html +16 -0
  173. package/dist/space.html +85 -0
  174. package/dist/spacetime.html +27 -0
  175. package/dist/sphere3d.html +24 -0
  176. package/dist/sprite.html +18 -0
  177. package/dist/starfaux.html +22 -0
  178. package/dist/study001.html +23 -0
  179. package/dist/study002.html +23 -0
  180. package/dist/study003.html +23 -0
  181. package/dist/study004.html +23 -0
  182. package/dist/study005.html +22 -0
  183. package/dist/study006.html +24 -0
  184. package/dist/study007.html +24 -0
  185. package/dist/study008.html +22 -0
  186. package/dist/svgtween.html +29 -0
  187. package/dist/tde.html +28 -0
  188. package/dist/tetris3d.html +25 -0
  189. package/dist/tiles.html +28 -0
  190. package/dist/transforms.html +400 -0
  191. package/dist/tween.html +45 -0
  192. package/dist/visibility.html +33 -0
  193. package/package.json +1 -1
@@ -0,0 +1,1547 @@
1
+ /**
2
+ * Kerr Metric - Rotating Black Hole Demo
3
+ *
4
+ * Visualization of the Kerr solution to Einstein's field equations.
5
+ * Shows frame dragging, ergosphere, and the non-diagonal metric tensor.
6
+ *
7
+ * Key difference from Schwarzschild: g_tφ ≠ 0 (frame dragging term)
8
+ */
9
+
10
+ import { Game, Painter, Camera3D } from "/gcanvas.es.min.js";
11
+ import { GameObject } from "/gcanvas.es.min.js";
12
+ import { Rectangle } from "/gcanvas.es.min.js";
13
+ import { TextShape } from "/gcanvas.es.min.js";
14
+ import { Position } from "/gcanvas.es.min.js";
15
+ import { Tensor } from "/gcanvas.es.min.js";
16
+ import { flammEmbeddingHeight } from "/gcanvas.es.min.js";
17
+ import {
18
+ keplerianOmega,
19
+ kerrPrecessionRate,
20
+ orbitalRadiusSimple,
21
+ updateTrail,
22
+ } from "/gcanvas.es.min.js";
23
+ import { verticalLayout, applyLayout } from "/gcanvas.es.min.js";
24
+ import { Tooltip } from "/gcanvas.es.min.js";
25
+ import { Button } from "/gcanvas.es.min.js";
26
+
27
+ // Configuration
28
+ const CONFIG = {
29
+ // Grid parameters - match spacetime.js for clean visuals
30
+ gridSize: 20,
31
+ gridResolution: 40,
32
+ baseGridScale: 15,
33
+
34
+ // Mobile breakpoint
35
+ mobileWidth: 600,
36
+
37
+ // Physics (geometrized units: G = c = 1)
38
+ defaultMass: 1.0,
39
+ defaultSpin: 0.7, // 70% of extremal
40
+ massRange: [1.0, 3.0],
41
+ spinRange: [0.1, 0.95], // As fraction of M
42
+
43
+ // Embedding diagram - visible funnel depth (matches Schwarzschild)
44
+ embeddingScale: 180, // Consistent with Schwarzschild
45
+
46
+ // 3D view - tilted to see frame dragging twist
47
+ rotationX: 0.5, // Slightly less tilt to see more of the surface
48
+ rotationY: 0.4,
49
+ perspective: 900, // Bit more perspective for drama
50
+
51
+ // Orbit parameters
52
+ orbitSemiMajor: 10,
53
+ orbitEccentricity: 0.15,
54
+ angularMomentum: 4.0,
55
+
56
+ // Animation
57
+ autoRotateSpeed: 0.1,
58
+ orbitSpeed: 0.5,
59
+ precessionFactor: 0.15,
60
+ frameDraggingAmplification: 3.0, // Visual enhancement
61
+
62
+ // Formation animation (λ: 0→1 interpolation from flat to Kerr)
63
+ // Slow enough for users to notice the transformation
64
+ formationDuration: 6.0, // Seconds to form the black hole
65
+ formationEasing: 0.3, // Easing factor for smooth transition
66
+
67
+ // Visual exaggeration for user understanding (rubber sheet analogy)
68
+ // These values are NOT physically accurate - intentionally amplified
69
+ frameDraggingReach: 3.0, // How far frame dragging visually extends (multiplier)
70
+ frameDraggingStrength: 40, // INCREASED from 25 for stronger twist
71
+ blackHoleSizeBase: 12, // Base visual size of black hole
72
+ blackHoleSizeMassScale: 10, // How much mass affects visual size (more dramatic)
73
+
74
+ // Colors
75
+ gridColor: "rgba(0, 180, 255, 0.3)",
76
+ gridHighlight: "rgba(100, 220, 255, 0.5)",
77
+ outerHorizonColor: "rgba(255, 50, 50, 0.8)",
78
+ innerHorizonColor: "rgba(200, 50, 100, 0.6)",
79
+ ergosphereColor: "rgba(255, 150, 0, 0.7)",
80
+ progradeISCOColor: "rgba(50, 255, 150, 0.6)",
81
+ retrogradeISCOColor: "rgba(100, 150, 255, 0.6)",
82
+ frameDragColor: "rgba(255, 200, 100, 0.5)",
83
+ orbiterColor: "#4af",
84
+ orbiterGlow: "rgba(100, 180, 255, 0.6)",
85
+ };
86
+
87
+ /**
88
+ * KerrMetricPanelGO - Displays the Kerr metric tensor components
89
+ * Highlights the off-diagonal g_tφ frame dragging term
90
+ * Responsive for mobile screens
91
+ */
92
+ class KerrMetricPanelGO extends GameObject {
93
+ constructor(game, options = {}) {
94
+ // Responsive sizing
95
+ const isMobile = game.width < CONFIG.mobileWidth;
96
+ const panelWidth = isMobile ? 260 : 260;
97
+ const panelHeight = isMobile ? 300 : 280;
98
+ const lineHeight = isMobile ? 12 : 14;
99
+ const valueOffset = isMobile ? 140 : 180;
100
+
101
+ super(game, {
102
+ ...options,
103
+ width: panelWidth,
104
+ height: panelHeight,
105
+ anchor: Position.BOTTOM_LEFT,
106
+ });
107
+
108
+ this.bgRect = new Rectangle({
109
+ width: panelWidth,
110
+ height: panelHeight,
111
+ color: "rgba(0, 0, 0, 0.7)",
112
+ });
113
+
114
+ // Define features with descriptions for tooltips
115
+ this.features = {
116
+ title: {
117
+ text: "Kerr Metric (Rotating Black Hole)",
118
+ font: "bold 12px monospace",
119
+ color: "#f7a",
120
+ height: lineHeight + 4,
121
+ desc: "The Kerr metric describes spacetime around a rotating black hole.\n\nKerr is STATIONARY - it doesn't evolve over time. This animation shows geometric interpolation from flat to Kerr.\n\nNOTE: Visual effects are EXAGGERATED (like rubber sheet analogy) to make curvature and frame dragging easier to see.",
122
+ },
123
+ equation: {
124
+ text: "ds² = gμν dxμ dxν (Boyer-Lindquist)",
125
+ font: "12px monospace",
126
+ color: "#888",
127
+ height: lineHeight,
128
+ desc: "Boyer-Lindquist coordinates (t, r, θ, φ) generalize Schwarzschild coordinates for rotating spacetime.",
129
+ },
130
+ mass: {
131
+ text: "M = 1.00",
132
+ font: "11px monospace",
133
+ color: "#888",
134
+ height: lineHeight,
135
+ desc: "Mass of the black hole in geometrized units (G = c = 1).",
136
+ },
137
+ spin: {
138
+ text: "a = 0.70M (70%)",
139
+ font: "bold 11px monospace",
140
+ color: "#fa8",
141
+ height: lineHeight + 4,
142
+ desc: "Spin parameter a = J/Mc (angular momentum per unit mass).\n\n0 = Schwarzschild (no rotation)\nM = Extremal Kerr (maximum spin)\n\nClick to randomize!",
143
+ },
144
+ gtt: {
145
+ text: "g_tt = -(1 - 2Mr/Σ)",
146
+ font: "10px monospace",
147
+ color: "#f88",
148
+ height: lineHeight,
149
+ value: "= -0.800",
150
+ desc: "Time-time component. Modified by Σ = r² + a²cos²θ.\nDepends on BOTH r and θ (not spherically symmetric!).",
151
+ },
152
+ grr: {
153
+ text: "g_rr = Σ/Δ",
154
+ font: "10px monospace",
155
+ color: "#8f8",
156
+ height: lineHeight,
157
+ value: "= 1.250",
158
+ desc: "Radial component. Δ = r² - 2Mr + a².\nDiverges at horizons where Δ = 0.",
159
+ },
160
+ gthth: {
161
+ text: "g_θθ = Σ",
162
+ font: "10px monospace",
163
+ color: "#88f",
164
+ height: lineHeight,
165
+ value: "= 100.00",
166
+ desc: "Theta component. Σ = r² + a²cos²θ.\nNot just r² - rotation breaks spherical symmetry.",
167
+ },
168
+ gphph: {
169
+ text: "g_φφ = (r²+a²+...)sin²θ",
170
+ font: "10px monospace",
171
+ color: "#f8f",
172
+ height: lineHeight,
173
+ value: "= 100.00",
174
+ desc: "Phi component. More complex than Schwarzschild.\nIncludes 2Ma²r sin²θ/Σ rotation term.",
175
+ },
176
+ gtph: {
177
+ text: "g_tφ = -2Mar sin²θ/Σ",
178
+ font: "bold 11px monospace",
179
+ color: "#ff0",
180
+ height: lineHeight + 6,
181
+ value: "= -0.180",
182
+ desc: "FRAME DRAGGING TERM\n\nThis off-diagonal component is THE key difference!\n\nIt couples time and rotation: even light must rotate with the black hole.\n\nInside the ergosphere, NOTHING can stay still.",
183
+ },
184
+ rplus: {
185
+ text: "r+ = 1.44",
186
+ font: "10px monospace",
187
+ color: "#f55",
188
+ height: lineHeight - 2,
189
+ desc: "Outer Event Horizon: r+ = M + √(M² - a²)\nSmaller than Schwarzschild 2M when spinning.\nApproaches M as a → M (extremal).",
190
+ },
191
+ rminus: {
192
+ text: "r- = 0.56",
193
+ font: "10px monospace",
194
+ color: "#a55",
195
+ height: lineHeight - 2,
196
+ desc: "Inner (Cauchy) Horizon: r- = M - √(M² - a²)\nUnique to rotating black holes.\nHides a ring singularity, not a point.",
197
+ },
198
+ rergo: {
199
+ text: "r_ergo = 2.00",
200
+ font: "10px monospace",
201
+ color: "#f80",
202
+ height: lineHeight - 2,
203
+ desc: "Ergosphere boundary (at equator)\nBetween r+ and r_ergo: the ergosphere.\nObjects can escape, but CANNOT stay stationary!",
204
+ },
205
+ riscoP: {
206
+ text: "r_ISCO(pro) = 2.32",
207
+ font: "10px monospace",
208
+ color: "#5f8",
209
+ height: lineHeight - 2,
210
+ desc: "ISCO for prograde (co-rotating) orbits.\nCloser than Schwarzschild ISCO!\nFrame dragging helps co-rotating orbits.",
211
+ },
212
+ riscoR: {
213
+ text: "r_ISCO(retro) = 8.71",
214
+ font: "10px monospace",
215
+ color: "#58f",
216
+ height: lineHeight - 2,
217
+ desc: "ISCO for retrograde (counter-rotating) orbits.\nFarther than Schwarzschild ISCO!\nFrame dragging opposes counter-rotation.",
218
+ },
219
+ pos: {
220
+ text: "Orbiter: r=10, Ω_drag=0.02",
221
+ font: "10px monospace",
222
+ color: "#aaa",
223
+ height: lineHeight,
224
+ desc: "Orbiter position and local frame-dragging rate.\nΩ_drag shows how fast spacetime rotates here.",
225
+ },
226
+ };
227
+
228
+ this.panelWidth = panelWidth;
229
+ this.panelHeight = panelHeight;
230
+
231
+ // Create TextShapes
232
+ const rowItems = [];
233
+ for (const [key, config] of Object.entries(this.features)) {
234
+ config.shape = new TextShape(config.text, {
235
+ font: config.font,
236
+ color: config.color,
237
+ align: "left",
238
+ baseline: "top",
239
+ height: config.height,
240
+ });
241
+ rowItems.push(config.shape);
242
+
243
+ if (config.value) {
244
+ config.valueShape = new TextShape(config.value, {
245
+ font: config.font,
246
+ color: "#fff",
247
+ align: "left",
248
+ baseline: "top",
249
+ });
250
+ }
251
+ }
252
+
253
+ // Apply vertical layout
254
+ const layout = verticalLayout(rowItems, {
255
+ spacing: 10,
256
+ padding: 0,
257
+ align: "start",
258
+ centerItems: false,
259
+ });
260
+ applyLayout(rowItems, layout.positions, {
261
+ offsetX: -panelWidth / 2,
262
+ offsetY: -panelHeight / 2,
263
+ });
264
+
265
+ // Position value shapes
266
+ for (const config of Object.values(this.features)) {
267
+ if (config.valueShape) {
268
+ config.valueShape.x = config.shape.x + valueOffset;
269
+ config.valueShape.y = config.shape.y;
270
+ }
271
+ }
272
+ }
273
+
274
+ setMetricValues(r, theta, M, a) {
275
+ const metric = Tensor.kerr(r, theta, M, a);
276
+ const f = this.features;
277
+
278
+ // Diagonal components
279
+ f.gtt.valueShape.text = `= ${metric.get(0, 0).toFixed(4)}`;
280
+ f.grr.valueShape.text = `= ${metric.get(1, 1).toFixed(4)}`;
281
+ f.gthth.valueShape.text = `= ${metric.get(2, 2).toFixed(2)}`;
282
+ f.gphph.valueShape.text = `= ${metric.get(3, 3).toFixed(2)}`;
283
+
284
+ // OFF-DIAGONAL (the key term!)
285
+ f.gtph.valueShape.text = `= ${metric.get(0, 3).toFixed(4)}`;
286
+
287
+ // Parameters
288
+ const spinPercent = ((a / M) * 100).toFixed(0);
289
+ f.spin.shape.text = `a = ${a.toFixed(2)}M (${spinPercent}%)`;
290
+ f.mass.shape.text = `M = ${M.toFixed(2)}`;
291
+
292
+ // Key radii
293
+ const rPlus = Tensor.kerrHorizonRadius(M, a, false);
294
+ const rMinus = Tensor.kerrHorizonRadius(M, a, true);
295
+ const rErgo = Tensor.kerrErgosphereRadius(M, a, Math.PI / 2);
296
+ const iscoP = Tensor.kerrISCO(M, a, true);
297
+ const iscoR = Tensor.kerrISCO(M, a, false);
298
+
299
+ f.rplus.shape.text = `r+ = ${rPlus.toFixed(2)}`;
300
+ f.rminus.shape.text = `r- = ${rMinus.toFixed(2)}`;
301
+ f.rergo.shape.text = `r_ergo = ${rErgo.toFixed(2)}`;
302
+ f.riscoP.shape.text = `r_ISCO(pro) = ${iscoP.toFixed(2)}`;
303
+ f.riscoR.shape.text = `r_ISCO(retro) = ${iscoR.toFixed(2)}`;
304
+ }
305
+
306
+ setOrbiterPosition(r, phi, M, a) {
307
+ const omega = Tensor.kerrFrameDraggingOmega(r, Math.PI / 2, M, a);
308
+ this.features.pos.shape.text = `Orbiter: r=${r.toFixed(2)}, Ω_drag=${omega.toFixed(4)}`;
309
+ }
310
+
311
+ getFeatureAt(screenX, screenY) {
312
+ const localX = screenX - this.x;
313
+ const localY = screenY - this.y;
314
+
315
+ if (
316
+ localX < -this.panelWidth / 2 ||
317
+ localX > this.panelWidth / 2 ||
318
+ localY < -this.panelHeight / 2 ||
319
+ localY > this.panelHeight / 2
320
+ ) {
321
+ return null;
322
+ }
323
+
324
+ for (const config of Object.values(this.features)) {
325
+ const shape = config.shape;
326
+ const rowTop = shape.y;
327
+ const rowBottom = shape.y + (config.height || 14);
328
+
329
+ if (localY >= rowTop && localY <= rowBottom) {
330
+ return config;
331
+ }
332
+ }
333
+
334
+ return null;
335
+ }
336
+
337
+ draw() {
338
+ super.draw();
339
+ this.bgRect.render();
340
+
341
+ for (const config of Object.values(this.features)) {
342
+ config.shape.render();
343
+ if (config.valueShape) config.valueShape.render();
344
+ }
345
+ }
346
+ }
347
+
348
+ class KerrDemo extends Game {
349
+ constructor(canvas) {
350
+ super(canvas);
351
+ // Black background - it's space!
352
+ this.backgroundColor = "#000";
353
+ this.enableFluidSize();
354
+ }
355
+
356
+ init() {
357
+ super.init();
358
+ this.time = 0;
359
+
360
+ // Mass and spin
361
+ this.mass = CONFIG.defaultMass;
362
+ this.spin = CONFIG.defaultSpin * this.mass;
363
+
364
+ // Grid scale
365
+ this.gridScale = CONFIG.baseGridScale;
366
+
367
+ // Camera with inertia for smooth drag
368
+ this.camera = new Camera3D({
369
+ rotationX: CONFIG.rotationX,
370
+ rotationY: CONFIG.rotationY,
371
+ perspective: CONFIG.perspective,
372
+ minRotationX: -0.5,
373
+ maxRotationX: 1.5,
374
+ autoRotate: true,
375
+ autoRotateSpeed: CONFIG.autoRotateSpeed,
376
+ autoRotateAxis: "y",
377
+ inertia: true,
378
+ friction: 0.95,
379
+ velocityScale: 2.0,
380
+ });
381
+ this.camera.enableMouseControl(this.canvas);
382
+
383
+ // Orbital state
384
+ this.orbitR = CONFIG.orbitSemiMajor;
385
+ this.orbitPhi = 0;
386
+ this.orbitVr = 0;
387
+ this.orbitL = CONFIG.angularMomentum;
388
+ this.precessionAngle = 0;
389
+ this.orbitTrail = [];
390
+
391
+ // Formation parameter λ: interpolates from flat (0) to Kerr (1)
392
+ // This is NOT physical time - it's a geometric interpolation parameter
393
+ // representing the cumulative effects during black hole formation
394
+ this.formationProgress = 0; // λ ∈ [0, 1]
395
+
396
+ // Initialize grid
397
+ this.initGrid();
398
+ this.gridScale = CONFIG.baseGridScale;
399
+
400
+ // Create metric panel
401
+ this.metricPanel = new KerrMetricPanelGO(this, { name: "metricPanel" });
402
+ this.pipeline.add(this.metricPanel);
403
+
404
+ // Tooltip (responsive)
405
+ const isMobileTooltip = this.width < CONFIG.mobileWidth;
406
+ this.tooltip = new Tooltip(this, {
407
+ maxWidth: isMobileTooltip ? 200 : 300,
408
+ font: `${isMobileTooltip ? 9 : 11}px monospace`,
409
+ padding: isMobileTooltip ? 6 : 10,
410
+ bgColor: "rgba(20, 20, 30, 0.95)",
411
+ });
412
+ this.pipeline.add(this.tooltip);
413
+
414
+ this.hoveredFeature = null;
415
+
416
+ // Event listeners
417
+ this.canvas.addEventListener("mousemove", (e) => this.handleMouseMove(e));
418
+ this.canvas.addEventListener("mouseleave", () => this.tooltip.hide());
419
+ this.initControls();
420
+
421
+ // Button to form new black hole (positioned below the chart, same width)
422
+ const isMobile = this.width < CONFIG.mobileWidth;
423
+ const graphW = isMobile ? 120 : 160;
424
+ const graphH = isMobile ? 70 : 100;
425
+ const graphX = this.width - graphW - (isMobile ? 15 : 20);
426
+ const graphY = isMobile ? 80 : 220; // Desktop moved down to avoid info div
427
+
428
+ this.newBlackHoleBtn = new Button(this, {
429
+ anchor: Position.TOP_LEFT,
430
+ anchorRelative: this.metricPanel,
431
+ anchorOffsetX: -10,
432
+ anchorOffsetY: -60,
433
+ width: graphW,
434
+ height: isMobile ? 30 : 36,
435
+ text: "New Black Hole",
436
+ font: `${isMobile ? 10 : 12}px monospace`,
437
+ colorDefaultBg: "rgba(20, 20, 40, 0.8)",
438
+ colorDefaultStroke: "#f80",
439
+ colorDefaultText: "#fa8",
440
+ colorHoverBg: "rgba(40, 30, 60, 0.9)",
441
+ colorHoverStroke: "#ff0",
442
+ colorHoverText: "#ff0",
443
+ colorPressedBg: "rgba(60, 40, 80, 1)",
444
+ colorPressedStroke: "#fff",
445
+ colorPressedText: "#fff",
446
+ onClick: () => this.shuffleParameters(),
447
+ });
448
+ this.pipeline.add(this.newBlackHoleBtn);
449
+ }
450
+
451
+ initControls() {
452
+ // Instructions (drag to rotate)
453
+ this.controlsText = new TextShape(
454
+ "drag to rotate",
455
+ {
456
+ font: "10px monospace",
457
+ color: "#aaa",
458
+ align: "right",
459
+ baseline: "bottom",
460
+ }
461
+ );
462
+
463
+ // Explanatory text lines
464
+ const explanationLines = [
465
+ "Geometric Demonstration: Flat Spacetime \u2192 Kerr Metric", // Top line
466
+ "Visualizes the structural contrast, not physical time evolution.",
467
+ "Effects exaggerated for visibility.",
468
+ ];
469
+
470
+ this.explanationShapes = explanationLines.map((line) => {
471
+ return new TextShape(line, {
472
+ font: "10px monospace",
473
+ color: "#aaa",
474
+ align: "right",
475
+ baseline: "bottom",
476
+ });
477
+ });
478
+ }
479
+
480
+ handleMouseMove(e) {
481
+ const rect = this.canvas.getBoundingClientRect();
482
+ const mouseX = e.clientX - rect.left;
483
+ const mouseY = e.clientY - rect.top;
484
+
485
+ // Check metric panel
486
+ const feature = this.metricPanel.getFeatureAt(mouseX, mouseY);
487
+ if (feature && feature.desc) {
488
+ if (this.hoveredFeature !== feature) {
489
+ this.hoveredFeature = feature;
490
+ this.tooltip.show(feature.desc, mouseX, mouseY);
491
+ }
492
+ return;
493
+ }
494
+
495
+ // Check effective potential graph (responsive)
496
+ const isMobile = this.width < CONFIG.mobileWidth;
497
+ const graphW = isMobile ? 120 : 160;
498
+ const graphH = isMobile ? 70 : 100;
499
+ const graphX = this.width - graphW - (isMobile ? 15 : 20);
500
+ const graphY = isMobile ? 80 : 220; // Desktop moved down to avoid info div
501
+
502
+ if (
503
+ mouseX >= graphX - 10 &&
504
+ mouseX <= graphX + graphW + 10 &&
505
+ mouseY >= graphY - 10 &&
506
+ mouseY <= graphY + graphH + 30
507
+ ) {
508
+ if (this.hoveredFeature !== "graph") {
509
+ this.hoveredFeature = "graph";
510
+ this.tooltip.show(
511
+ "Kerr Effective Potential\n\nShows gravitational + centrifugal potential for the current spin.\n\nGreen = prograde ISCO (closer!)\nBlue = retrograde ISCO (farther!)\n\nFrame dragging makes co-rotating orbits more stable.",
512
+ mouseX,
513
+ mouseY,
514
+ );
515
+ }
516
+ return;
517
+ }
518
+
519
+ if (this.hoveredFeature) {
520
+ this.hoveredFeature = null;
521
+ this.tooltip.hide();
522
+ }
523
+ }
524
+
525
+ initGrid() {
526
+ const { gridSize, gridResolution } = CONFIG;
527
+ this.gridVertices = [];
528
+
529
+ for (let i = 0; i <= gridResolution; i++) {
530
+ const row = [];
531
+ for (let j = 0; j <= gridResolution; j++) {
532
+ const x = (i / gridResolution - 0.5) * 2 * gridSize;
533
+ const z = (j / gridResolution - 0.5) * 2 * gridSize;
534
+ row.push({ x, y: 0, z, baseX: x, baseZ: z }); // Store original positions
535
+ }
536
+ this.gridVertices.push(row);
537
+ }
538
+
539
+ // Initialize dragged particles in ergosphere
540
+ this.draggedParticles = [];
541
+ for (let i = 0; i < 20; i++) {
542
+ const angle = Math.random() * Math.PI * 2;
543
+ const r = 2 + Math.random() * 3; // Between horizon and ergosphere
544
+ this.draggedParticles.push({
545
+ angle,
546
+ r,
547
+ baseR: r,
548
+ phase: Math.random() * Math.PI * 2,
549
+ });
550
+ }
551
+ }
552
+
553
+ shuffleParameters() {
554
+ // Randomize mass
555
+ this.mass =
556
+ CONFIG.massRange[0] +
557
+ Math.random() * (CONFIG.massRange[1] - CONFIG.massRange[0]);
558
+
559
+ // Randomize spin (as fraction of M)
560
+ const spinFraction =
561
+ CONFIG.spinRange[0] +
562
+ Math.random() * (CONFIG.spinRange[1] - CONFIG.spinRange[0]);
563
+ this.spin = spinFraction * this.mass;
564
+
565
+ // Reset orbit outside prograde ISCO
566
+ const iscoP = Tensor.kerrISCO(this.mass, this.spin, true);
567
+ this.orbitR = iscoP + 2 + Math.random() * 8;
568
+ this.orbitPhi = Math.random() * Math.PI * 2;
569
+ this.orbitL = 3.5 + Math.random() * 2;
570
+ this.precessionAngle = 0;
571
+ this.orbitTrail = [];
572
+
573
+ // Reset formation - grid goes back to flat, then forms into new Kerr
574
+ this.formationProgress = 0;
575
+ this.formationCompleteTime = null; // Reset for new orbiter fade-in
576
+
577
+ // Reset grid to original positions
578
+ const { gridResolution } = CONFIG;
579
+ for (let i = 0; i <= gridResolution; i++) {
580
+ for (let j = 0; j <= gridResolution; j++) {
581
+ const vertex = this.gridVertices[i][j];
582
+ vertex.x = vertex.baseX;
583
+ vertex.z = vertex.baseZ;
584
+ vertex.y = 0;
585
+ }
586
+ }
587
+
588
+ // Reset dragged particles
589
+ if (this.draggedParticles) {
590
+ for (const p of this.draggedParticles) {
591
+ p.angle = Math.random() * Math.PI * 2;
592
+ }
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Embedding height for Kerr using shared gr.js module.
598
+ * Uses r+ (outer horizon) instead of 2M for the Kerr case.
599
+ */
600
+ getEmbeddingHeight(r) {
601
+ const rPlus = Tensor.kerrHorizonRadius(this.mass, this.spin, false);
602
+ const height = flammEmbeddingHeight(
603
+ r,
604
+ rPlus,
605
+ this.mass,
606
+ CONFIG.gridSize,
607
+ CONFIG.embeddingScale,
608
+ );
609
+ // Clamp to non-negative to prevent grid lines appearing above the flat plane
610
+ return Math.max(0, height);
611
+ }
612
+
613
+ /**
614
+ * Update geodesic motion with frame dragging using orbital.js utilities.
615
+ */
616
+ updateGeodesic(dt) {
617
+ const M = this.mass;
618
+ const a = this.spin;
619
+ const r = this.orbitR;
620
+
621
+ // Base Keplerian angular velocity
622
+ const baseOmega = keplerianOmega(r, M, CONFIG.orbitSpeed);
623
+
624
+ // Frame dragging contribution (Kerr-specific, stays in Tensor)
625
+ const omegaDrag = Tensor.kerrFrameDraggingOmega(r, Math.PI / 2, M, a);
626
+
627
+ // Total angular velocity (frame dragging adds to prograde motion)
628
+ const totalOmega =
629
+ baseOmega + omegaDrag * CONFIG.frameDraggingAmplification;
630
+
631
+ // Update orbital angle
632
+ this.orbitPhi += totalOmega * dt;
633
+
634
+ // Radial oscillation for eccentricity
635
+ this.orbitR = orbitalRadiusSimple(
636
+ CONFIG.orbitSemiMajor,
637
+ CONFIG.orbitEccentricity,
638
+ this.orbitPhi,
639
+ );
640
+
641
+ // Keep orbit outside prograde ISCO
642
+ const minR = Tensor.kerrISCO(M, a, true) + 1;
643
+ if (this.orbitR < minR) this.orbitR = minR;
644
+
645
+ // GR precession (enhanced by frame dragging)
646
+ const precessionRate = kerrPrecessionRate(r, M, a, CONFIG.precessionFactor);
647
+ this.precessionAngle += precessionRate * dt;
648
+
649
+ // Store in trail
650
+ const totalAngle = this.orbitPhi + this.precessionAngle;
651
+ updateTrail(
652
+ this.orbitTrail,
653
+ {
654
+ x: Math.cos(totalAngle) * this.orbitR,
655
+ z: Math.sin(totalAngle) * this.orbitR,
656
+ r: this.orbitR,
657
+ omega: omegaDrag,
658
+ },
659
+ 80,
660
+ );
661
+ }
662
+
663
+ update(dt) {
664
+ super.update(dt);
665
+ this.time += dt;
666
+
667
+ // Formation progress: λ goes from 0 to 1 over formationDuration
668
+ // Once at 1, stays there (Kerr is stationary - the final state)
669
+ const wasForming = this.formationProgress < 1;
670
+ if (this.formationProgress < 1) {
671
+ this.formationProgress += dt / CONFIG.formationDuration;
672
+ if (this.formationProgress >= 1) {
673
+ this.formationProgress = 1;
674
+ // Record when formation completed (for orbiter fade-in)
675
+ this.formationCompleteTime = this.time;
676
+ }
677
+ }
678
+
679
+ // Smooth easing for formation (ease-out cubic)
680
+ const lambda = 1 - Math.pow(1 - this.formationProgress, 3);
681
+
682
+ this.camera.update(dt);
683
+
684
+ // Only update geodesic motion after black hole has formed
685
+ // The orbiter appears after formation completes
686
+ if (this.formationProgress >= 1) {
687
+ this.updateGeodesic(dt);
688
+ }
689
+
690
+ // Update grid with Kerr geometry
691
+ // The twist is proportional to λ (formation progress), NOT accumulating over time
692
+ // This shows the FINAL Kerr geometry, not "evolving" spacetime
693
+ const { gridResolution } = CONFIG;
694
+ const M = this.mass;
695
+ const a = this.spin;
696
+ const rPlus = Tensor.kerrHorizonRadius(M, a, false);
697
+ const rErgo = Tensor.kerrErgosphereRadius(M, a, Math.PI / 2);
698
+
699
+ // Extended reach for frame dragging visualization (rubber sheet analogy)
700
+ // Real effect falls off as ~1/r³, but we extend it for visual clarity
701
+ const visualReach = rErgo * CONFIG.frameDraggingReach;
702
+
703
+ for (let i = 0; i <= gridResolution; i++) {
704
+ for (let j = 0; j <= gridResolution; j++) {
705
+ const vertex = this.gridVertices[i][j];
706
+ const baseR = Math.sqrt(
707
+ vertex.baseX * vertex.baseX + vertex.baseZ * vertex.baseZ,
708
+ );
709
+
710
+ // Frame dragging twist proportional to formation progress (λ)
711
+ // At λ=0: flat spacetime, no twist
712
+ // At λ=1: full Kerr geometry with frame dragging
713
+ // INTENTIONALLY EXAGGERATED: extends beyond physical ergosphere for visibility
714
+ if (baseR > rPlus * 0.5 && baseR < visualReach) {
715
+ const omega = Tensor.kerrFrameDraggingOmega(
716
+ Math.max(baseR, rPlus + 0.1),
717
+ Math.PI / 2,
718
+ M,
719
+ a,
720
+ );
721
+
722
+ // Visual falloff: smooth transition from max twist near horizon to zero at visualReach
723
+ // Uses quadratic falloff for smoother visual effect
724
+ const proximityFactor =
725
+ 1 - Math.pow((baseR - rPlus) / (visualReach - rPlus), 2);
726
+ const clampedProximity = Math.max(0, proximityFactor);
727
+
728
+ // Static twist angle - EXAGGERATED for visualization
729
+ const maxTwist = Math.PI / 4; // ~45 degrees max for dramatic effect
730
+ const twistAngle =
731
+ omega * CONFIG.frameDraggingStrength * lambda * clampedProximity;
732
+ const cappedTwist = Math.min(twistAngle, maxTwist);
733
+
734
+ // Apply rotation to grid point
735
+ const cosT = Math.cos(cappedTwist);
736
+ const sinT = Math.sin(cappedTwist);
737
+ vertex.x = vertex.baseX * cosT - vertex.baseZ * sinT;
738
+ vertex.z = vertex.baseX * sinT + vertex.baseZ * cosT;
739
+ }
740
+
741
+ // Embedding depth also scales with λ (flat → curved)
742
+ // Function already clamps at horizon, no need for extra clamp here
743
+ const r = Math.sqrt(vertex.x * vertex.x + vertex.z * vertex.z);
744
+ vertex.y = this.getEmbeddingHeight(r) * lambda;
745
+ }
746
+ }
747
+
748
+ // Update dragged particles in ergosphere
749
+ if (this.draggedParticles) {
750
+ for (const p of this.draggedParticles) {
751
+ // Particles get dragged by frame dragging
752
+ const omega = Tensor.kerrFrameDraggingOmega(p.baseR, Math.PI / 2, M, a);
753
+ p.angle += omega * dt * 50; // Strong visual drag
754
+ // Slight radial oscillation
755
+ p.r = p.baseR + Math.sin(this.time * 2 + p.phase) * 0.3;
756
+ }
757
+ }
758
+
759
+ // Update metric panel
760
+ if (this.metricPanel) {
761
+ // Use EFFECTIVE mass and spin based on formation progress (lambda)
762
+ // This allows the numbers to evolve from Flat Spacetime values to final Kerr values
763
+ // Note: We clamp at a small epsilon for M to avoid division by zero if lambda is 0
764
+ const effectiveM = Math.max(0.001, this.mass * lambda);
765
+ const effectiveA = this.spin * lambda;
766
+
767
+ this.metricPanel.setMetricValues(
768
+ this.orbitR,
769
+ Math.PI / 2,
770
+ effectiveM,
771
+ effectiveA,
772
+ );
773
+ this.metricPanel.setOrbiterPosition(
774
+ this.orbitR,
775
+ this.orbitPhi,
776
+ effectiveM,
777
+ effectiveA,
778
+ );
779
+ }
780
+ }
781
+
782
+ render() {
783
+ const w = this.width;
784
+ const h = this.height;
785
+ const cx = w / 2;
786
+ const cy = h / 2; // Centered to see fabric edges from outside
787
+
788
+ super.render();
789
+
790
+ // Draw ergosphere fill with dragged particles
791
+ this.drawErgosphere(cx, cy);
792
+
793
+ // Draw grid (now with frame dragging twist!)
794
+ this.drawGrid(cx, cy);
795
+
796
+ // Draw rotating black hole with accretion disk
797
+ this.drawHorizon(cx, cy);
798
+
799
+ // Draw orbiter
800
+ this.drawOrbiter(cx, cy);
801
+
802
+ // Draw effective potential graph
803
+ this.drawEffectivePotential();
804
+
805
+ // Draw formation progress indicator
806
+ this.drawFormationProgress(w, h);
807
+
808
+ // Draw controls
809
+ this.renderControls();
810
+ }
811
+
812
+ renderControls() {
813
+ const w = this.width;
814
+ const h = this.height;
815
+ const isMobile = w < CONFIG.mobileWidth;
816
+ const margin = isMobile ? 10 : 15;
817
+ const lineSpacing = isMobile ? 12 : 15;
818
+
819
+ // On mobile, use shorter text
820
+ if (isMobile) {
821
+ this.controlsText.text = "tap to form | drag to rotate";
822
+ this.controlsText.font = "8px monospace";
823
+ }
824
+
825
+ // Position and render main controls text
826
+ this.controlsText.x = w - margin;
827
+ this.controlsText.y = h - 25 - (isMobile ? 1 : this.explanationShapes.length) * lineSpacing;
828
+ this.controlsText.render();
829
+
830
+ // Position and render explanation lines (hide most on mobile)
831
+ this.explanationShapes.forEach((shape, i) => {
832
+ if (isMobile && i < this.explanationShapes.length - 1) return; // Only show last line on mobile
833
+
834
+ shape.font = isMobile ? "8px monospace" : "10px monospace";
835
+ const lineIndexFromBottom = this.explanationShapes.length - 1 - i;
836
+ shape.x = w - margin;
837
+ shape.y = h - 10 - (lineIndexFromBottom * lineSpacing);
838
+ shape.render();
839
+ });
840
+ }
841
+
842
+ drawFormationProgress(w, h) {
843
+ const progress = this.formationProgress;
844
+ const lambda = 1 - Math.pow(1 - progress, 3); // Eased progress
845
+
846
+ // Position above the chart (same x alignment as chart)
847
+ const isMobile = w < CONFIG.mobileWidth;
848
+ const graphW = isMobile ? 120 : 160;
849
+ const graphX = w - graphW - (isMobile ? 15 : 20);
850
+ const graphY = isMobile ? 80 : 220; // Desktop moved down to avoid info div
851
+
852
+ const barWidth = graphW; // Same width as chart
853
+ const barHeight = 6;
854
+ const barX = graphX;
855
+ const barY = graphY - 35; // Above the chart
856
+
857
+ Painter.useCtx((ctx) => {
858
+ // Phase-aware label
859
+ ctx.font = "10px monospace";
860
+ ctx.textAlign = "left";
861
+ let label, color;
862
+
863
+ if (progress >= 1) {
864
+ label = "Kerr (stationary)";
865
+ color = "#8f8";
866
+ } else if (lambda < 0.2) {
867
+ label = "Collapse...";
868
+ color = "#fff";
869
+ } else if (lambda < 0.5) {
870
+ label = "Horizon forming...";
871
+ color = "#f88";
872
+ } else if (lambda < 0.8) {
873
+ label = "Ergosphere emerging...";
874
+ color = "#fa8";
875
+ } else {
876
+ label = "Frame dragging stabilizing...";
877
+ color = "#ff0";
878
+ }
879
+
880
+ ctx.fillStyle = color;
881
+ ctx.fillText(label, barX, barY - 8);
882
+
883
+ // Background bar
884
+ ctx.fillStyle = "rgba(255, 255, 255, 0.1)";
885
+ ctx.fillRect(barX, barY, barWidth, barHeight);
886
+
887
+ // Progress bar
888
+ const gradient = ctx.createLinearGradient(barX, 0, barX + barWidth, 0);
889
+ gradient.addColorStop(0, "rgba(100, 100, 255, 0.8)");
890
+ gradient.addColorStop(1, "rgba(255, 100, 100, 0.8)");
891
+ ctx.fillStyle = gradient;
892
+ ctx.fillRect(barX, barY, barWidth * progress, barHeight);
893
+
894
+ // λ indicator
895
+ ctx.fillStyle = "#888";
896
+ ctx.font = "9px monospace";
897
+ ctx.fillText(`λ = ${progress.toFixed(2)}`, barX, barY + 16);
898
+ });
899
+ }
900
+
901
+ drawKeyRadii(cx, cy) {
902
+ const M = this.mass;
903
+ const a = this.spin;
904
+
905
+ const radii = [
906
+ {
907
+ r: Tensor.kerrHorizonRadius(M, a, false),
908
+ color: CONFIG.outerHorizonColor,
909
+ label: "r+",
910
+ },
911
+ {
912
+ r: Tensor.kerrErgosphereRadius(M, a, Math.PI / 2),
913
+ color: CONFIG.ergosphereColor,
914
+ label: "ergo",
915
+ dashed: true,
916
+ },
917
+ {
918
+ r: Tensor.kerrISCO(M, a, true),
919
+ color: CONFIG.progradeISCOColor,
920
+ label: "ISCO_pro",
921
+ },
922
+ {
923
+ r: Tensor.kerrISCO(M, a, false),
924
+ color: CONFIG.retrogradeISCOColor,
925
+ label: "ISCO_retro",
926
+ },
927
+ ];
928
+
929
+ for (const { r, color, dashed } of radii) {
930
+ if (isNaN(r)) continue;
931
+ const segments = 48;
932
+
933
+ Painter.useCtx((ctx) => {
934
+ ctx.strokeStyle = color;
935
+ ctx.lineWidth = 1.5;
936
+ if (dashed) ctx.setLineDash([5, 5]);
937
+ ctx.beginPath();
938
+
939
+ for (let i = 0; i <= segments; i++) {
940
+ const angle = (i / segments) * Math.PI * 2;
941
+ const x = Math.cos(angle) * r;
942
+ const z = Math.sin(angle) * r;
943
+ const y = this.getEmbeddingHeight(r);
944
+
945
+ const p = this.camera.project(
946
+ x * this.gridScale,
947
+ y,
948
+ z * this.gridScale,
949
+ );
950
+
951
+ if (i === 0) ctx.moveTo(cx + p.x, cy + p.y);
952
+ else ctx.lineTo(cx + p.x, cy + p.y);
953
+ }
954
+ ctx.stroke();
955
+ ctx.setLineDash([]);
956
+ });
957
+ }
958
+ }
959
+
960
+ drawErgosphere(cx, cy) {
961
+ const M = this.mass;
962
+ const a = this.spin;
963
+ const rErgo = Tensor.kerrErgosphereRadius(M, a, Math.PI / 2);
964
+ const rPlus = Tensor.kerrHorizonRadius(M, a, false);
965
+
966
+ if (isNaN(rErgo) || isNaN(rPlus) || rErgo <= rPlus) return;
967
+
968
+ // Ergosphere only visible after significant formation (λ > 0.4)
969
+ // This is a property of the final Kerr geometry
970
+ const lambda = 1 - Math.pow(1 - this.formationProgress, 3);
971
+ const ergoVisibility = Math.max(0, (lambda - 0.4) / 0.6); // 0 at λ=0.4, 1 at λ=1
972
+ if (ergoVisibility <= 0) return;
973
+
974
+ const segments = 64;
975
+
976
+ Painter.useCtx((ctx) => {
977
+ // Semi-transparent orange fill for ergosphere - fades in with formation
978
+ ctx.fillStyle = `rgba(255, 100, 0, ${0.15 * ergoVisibility})`;
979
+ ctx.beginPath();
980
+
981
+ // Outer boundary (ergosphere)
982
+ for (let i = 0; i <= segments; i++) {
983
+ const angle = (i / segments) * Math.PI * 2;
984
+ const x = Math.cos(angle) * rErgo;
985
+ const z = Math.sin(angle) * rErgo;
986
+ const y = this.getEmbeddingHeight(rErgo);
987
+
988
+ const p = this.camera.project(
989
+ x * this.gridScale,
990
+ y,
991
+ z * this.gridScale,
992
+ );
993
+
994
+ if (i === 0) ctx.moveTo(cx + p.x, cy + p.y);
995
+ else ctx.lineTo(cx + p.x, cy + p.y);
996
+ }
997
+
998
+ // Inner boundary (horizon) - reverse to create ring
999
+ for (let i = segments; i >= 0; i--) {
1000
+ const angle = (i / segments) * Math.PI * 2;
1001
+ const x = Math.cos(angle) * rPlus;
1002
+ const z = Math.sin(angle) * rPlus;
1003
+ const y = this.getEmbeddingHeight(rPlus + 0.1);
1004
+
1005
+ const p = this.camera.project(
1006
+ x * this.gridScale,
1007
+ y,
1008
+ z * this.gridScale,
1009
+ );
1010
+ ctx.lineTo(cx + p.x, cy + p.y);
1011
+ }
1012
+
1013
+ ctx.closePath();
1014
+ ctx.fill();
1015
+ });
1016
+
1017
+ // Draw dragged particles in ergosphere - shows frame dragging!
1018
+ // Particles also fade in with ergosphere visibility
1019
+ if (this.draggedParticles && ergoVisibility > 0) {
1020
+ Painter.useCtx((ctx) => {
1021
+ for (const p of this.draggedParticles) {
1022
+ // Only draw if within ergosphere
1023
+ if (p.r < rErgo && p.r > rPlus) {
1024
+ const x = Math.cos(p.angle) * p.r;
1025
+ const z = Math.sin(p.angle) * p.r;
1026
+ const y = this.getEmbeddingHeight(p.r);
1027
+
1028
+ const proj = this.camera.project(
1029
+ x * this.gridScale,
1030
+ y,
1031
+ z * this.gridScale,
1032
+ );
1033
+
1034
+ // Particles glow orange - they're being dragged!
1035
+ const size = 3 * proj.scale;
1036
+ ctx.fillStyle = `rgba(255, 180, 50, ${0.9 * ergoVisibility})`;
1037
+ ctx.beginPath();
1038
+ ctx.arc(cx + proj.x, cy + proj.y, size, 0, Math.PI * 2);
1039
+ ctx.fill();
1040
+
1041
+ // Trail showing direction of drag
1042
+ const trailAngle = p.angle - 0.3;
1043
+ const trailX = Math.cos(trailAngle) * p.r;
1044
+ const trailZ = Math.sin(trailAngle) * p.r;
1045
+ const trailProj = this.camera.project(
1046
+ trailX * this.gridScale,
1047
+ y,
1048
+ trailZ * this.gridScale,
1049
+ );
1050
+
1051
+ ctx.strokeStyle = `rgba(255, 150, 50, ${0.4 * ergoVisibility})`;
1052
+ ctx.lineWidth = 2 * proj.scale;
1053
+ ctx.beginPath();
1054
+ ctx.moveTo(cx + trailProj.x, cy + trailProj.y);
1055
+ ctx.lineTo(cx + proj.x, cy + proj.y);
1056
+ ctx.stroke();
1057
+ }
1058
+ }
1059
+ });
1060
+ }
1061
+ }
1062
+
1063
+ drawGrid(cx, cy) {
1064
+ const { gridResolution, gridColor, gridHighlight } = CONFIG;
1065
+ const gridScale = this.gridScale;
1066
+
1067
+ const projected = this.gridVertices.map((row) =>
1068
+ row.map((v) => {
1069
+ const p = this.camera.project(v.x * gridScale, v.y, v.z * gridScale);
1070
+ return { x: cx + p.x, y: cy + p.y, z: p.z };
1071
+ }),
1072
+ );
1073
+
1074
+ for (let i = 0; i <= gridResolution; i++) {
1075
+ const isMain = i % 5 === 0;
1076
+ Painter.useCtx((ctx) => {
1077
+ ctx.strokeStyle = isMain ? gridHighlight : gridColor;
1078
+ ctx.lineWidth = isMain ? 1.2 : 0.6;
1079
+ ctx.beginPath();
1080
+ for (let j = 0; j <= gridResolution; j++) {
1081
+ const p = projected[i][j];
1082
+ if (j === 0) ctx.moveTo(p.x, p.y);
1083
+ else ctx.lineTo(p.x, p.y);
1084
+ }
1085
+ ctx.stroke();
1086
+ });
1087
+ }
1088
+
1089
+ for (let j = 0; j <= gridResolution; j++) {
1090
+ const isMain = j % 5 === 0;
1091
+ Painter.useCtx((ctx) => {
1092
+ ctx.strokeStyle = isMain ? gridHighlight : gridColor;
1093
+ ctx.lineWidth = isMain ? 1.2 : 0.6;
1094
+ ctx.beginPath();
1095
+ for (let i = 0; i <= gridResolution; i++) {
1096
+ const p = projected[i][j];
1097
+ if (i === 0) ctx.moveTo(p.x, p.y);
1098
+ else ctx.lineTo(p.x, p.y);
1099
+ }
1100
+ ctx.stroke();
1101
+ });
1102
+ }
1103
+ }
1104
+
1105
+ drawHorizon(cx, cy) {
1106
+ const rPlus = Tensor.kerrHorizonRadius(this.mass, this.spin, false);
1107
+
1108
+ // Formation progress affects size, intensity, AND vertical position
1109
+ // Smooth easing for formation (ease-out cubic)
1110
+ const lambda = 1 - Math.pow(1 - this.formationProgress, 3);
1111
+
1112
+ // Black hole sinks down as space curves around it
1113
+ // At λ=0: sits at flat space level (y=0)
1114
+ // At λ=1: sits at bottom of the well
1115
+ const finalY = this.getEmbeddingHeight(rPlus + 0.1);
1116
+ const y = finalY * lambda; // Interpolate from 0 to final depth
1117
+
1118
+ const centerP = this.camera.project(0, y + 10, 0);
1119
+ const centerX = cx + centerP.x;
1120
+ const centerY = cy + centerP.y;
1121
+
1122
+ // Black hole size scales with mass AND formation progress
1123
+ // Starts as tiny seed (3px), grows to full size
1124
+ const fullSize =
1125
+ CONFIG.blackHoleSizeBase + this.mass * CONFIG.blackHoleSizeMassScale;
1126
+ const seedSize = 3; // Initial collapse seed
1127
+ const size = (seedSize + (fullSize - seedSize) * lambda) * centerP.scale;
1128
+
1129
+ // Spin direction indicator (which way the hole rotates)
1130
+ const spinDirection = this.spin > 0 ? 1 : -1;
1131
+ // Rotation speed increases as formation progresses
1132
+ const rotationAngle = this.time * 2 * spinDirection * lambda;
1133
+
1134
+ // During early formation, show bright collapse point
1135
+ if (lambda < 0.3) {
1136
+ const collapseIntensity = 1 - lambda / 0.3; // Fades out as formation progresses
1137
+ Painter.useCtx((ctx) => {
1138
+ // Bright white-blue collapse flash
1139
+ const flashSize = (10 + (1 - lambda) * 30) * centerP.scale;
1140
+ const gradient = ctx.createRadialGradient(
1141
+ centerX,
1142
+ centerY,
1143
+ 0,
1144
+ centerX,
1145
+ centerY,
1146
+ flashSize,
1147
+ );
1148
+ gradient.addColorStop(
1149
+ 0,
1150
+ `rgba(255, 255, 255, ${0.9 * collapseIntensity})`,
1151
+ );
1152
+ gradient.addColorStop(
1153
+ 0.3,
1154
+ `rgba(150, 200, 255, ${0.6 * collapseIntensity})`,
1155
+ );
1156
+ gradient.addColorStop(1, "transparent");
1157
+ ctx.fillStyle = gradient;
1158
+ ctx.beginPath();
1159
+ ctx.arc(centerX, centerY, flashSize, 0, Math.PI * 2);
1160
+ ctx.fill();
1161
+ });
1162
+ }
1163
+
1164
+ // Outer glow - intensity grows with formation
1165
+ const glowIntensity = 0.2 + lambda * 0.8; // 20% → 100%
1166
+ Painter.useCtx((ctx) => {
1167
+ const gradient = ctx.createRadialGradient(
1168
+ centerX,
1169
+ centerY,
1170
+ size,
1171
+ centerX,
1172
+ centerY,
1173
+ size * 4,
1174
+ );
1175
+ gradient.addColorStop(0, `rgba(100, 50, 150, ${0.5 * glowIntensity})`);
1176
+ gradient.addColorStop(0.5, `rgba(255, 100, 50, ${0.2 * glowIntensity})`);
1177
+ gradient.addColorStop(1, "transparent");
1178
+ ctx.fillStyle = gradient;
1179
+ ctx.beginPath();
1180
+ ctx.arc(centerX, centerY, size * 4, 0, Math.PI * 2);
1181
+ ctx.fill();
1182
+ });
1183
+
1184
+ // ROTATING ACCRETION DISK - fades in as black hole forms
1185
+ // Only visible after initial collapse phase (λ > 0.2)
1186
+ const diskVisibility = Math.max(0, (lambda - 0.2) / 0.8); // 0 at λ=0.2, 1 at λ=1
1187
+ if (diskVisibility > 0) {
1188
+ Painter.useCtx((ctx) => {
1189
+ ctx.save();
1190
+ ctx.translate(centerX, centerY);
1191
+
1192
+ // Draw spinning spiral arms
1193
+ const numArms = 3;
1194
+ for (let arm = 0; arm < numArms; arm++) {
1195
+ const armAngle = (arm / numArms) * Math.PI * 2 + rotationAngle;
1196
+
1197
+ // Spiral gradient for each arm
1198
+ ctx.beginPath();
1199
+ ctx.moveTo(0, 0);
1200
+
1201
+ // Draw spiral
1202
+ for (let t = 0; t <= 1; t += 0.02) {
1203
+ const r = size * 1.2 + t * size * 2.5;
1204
+ const angle = armAngle + t * Math.PI * 1.5 * spinDirection;
1205
+ const x = Math.cos(angle) * r;
1206
+ const y = Math.sin(angle) * r * 0.4; // Flatten for disk perspective
1207
+ ctx.lineTo(x, y);
1208
+ }
1209
+
1210
+ const baseAlpha = 0.6 - arm * 0.15;
1211
+ const alpha = baseAlpha * diskVisibility;
1212
+ ctx.strokeStyle = `rgba(255, ${150 + arm * 30}, ${50 + arm * 20}, ${alpha})`;
1213
+ ctx.lineWidth = 3 - arm * 0.5;
1214
+ ctx.stroke();
1215
+ }
1216
+
1217
+ // Inner bright ring (hot gas closest to horizon)
1218
+ ctx.strokeStyle = `rgba(255, 200, 100, ${0.8 * diskVisibility})`;
1219
+ ctx.lineWidth = 2;
1220
+ ctx.beginPath();
1221
+ ctx.ellipse(0, 0, size * 1.5, size * 0.6, 0, 0, Math.PI * 2);
1222
+ ctx.stroke();
1223
+
1224
+ // Spinning particles in the disk
1225
+ const numParticles = 12;
1226
+ for (let i = 0; i < numParticles; i++) {
1227
+ const baseAngle = (i / numParticles) * Math.PI * 2;
1228
+ const particleR = size * 1.8 + Math.sin(i * 2.7) * size * 0.5;
1229
+ const particleAngle =
1230
+ baseAngle + rotationAngle * (2 - particleR / (size * 3));
1231
+ const px = Math.cos(particleAngle) * particleR;
1232
+ const py = Math.sin(particleAngle) * particleR * 0.4;
1233
+
1234
+ const brightness = 150 + Math.sin(this.time * 3 + i) * 50;
1235
+ ctx.fillStyle = `rgba(255, ${brightness}, 50, ${0.8 * diskVisibility})`;
1236
+ ctx.beginPath();
1237
+ ctx.arc(px, py, 2, 0, Math.PI * 2);
1238
+ ctx.fill();
1239
+ }
1240
+
1241
+ ctx.restore();
1242
+ });
1243
+ }
1244
+
1245
+ // Black hole body (actual black center)
1246
+ Painter.useCtx((ctx) => {
1247
+ ctx.fillStyle = "#000";
1248
+ ctx.beginPath();
1249
+ ctx.arc(centerX, centerY, size, 0, Math.PI * 2);
1250
+ ctx.fill();
1251
+
1252
+ // Inner edge glow
1253
+ const innerGrad = ctx.createRadialGradient(
1254
+ centerX,
1255
+ centerY,
1256
+ size * 0.7,
1257
+ centerX,
1258
+ centerY,
1259
+ size,
1260
+ );
1261
+ innerGrad.addColorStop(0, "transparent");
1262
+ innerGrad.addColorStop(1, "rgba(255, 100, 0, 0.5)");
1263
+ ctx.fillStyle = innerGrad;
1264
+ ctx.beginPath();
1265
+ ctx.arc(centerX, centerY, size, 0, Math.PI * 2);
1266
+ ctx.fill();
1267
+ });
1268
+
1269
+ // Event horizon circle on grid
1270
+ const segments = 32;
1271
+ Painter.useCtx((ctx) => {
1272
+ ctx.strokeStyle = CONFIG.outerHorizonColor;
1273
+ ctx.lineWidth = 2;
1274
+ ctx.beginPath();
1275
+
1276
+ for (let i = 0; i <= segments; i++) {
1277
+ const angle = (i / segments) * Math.PI * 2;
1278
+ const x = Math.cos(angle) * rPlus;
1279
+ const z = Math.sin(angle) * rPlus;
1280
+
1281
+ const p = this.camera.project(
1282
+ x * this.gridScale,
1283
+ y,
1284
+ z * this.gridScale,
1285
+ );
1286
+
1287
+ if (i === 0) ctx.moveTo(cx + p.x, cy + p.y);
1288
+ else ctx.lineTo(cx + p.x, cy + p.y);
1289
+ }
1290
+ ctx.closePath();
1291
+ ctx.stroke();
1292
+ });
1293
+ }
1294
+
1295
+ drawOrbiter(cx, cy) {
1296
+ // Only show orbiter after black hole has fully formed
1297
+ // Geodesic motion is a property of the final Kerr spacetime
1298
+ if (this.formationProgress < 1) return;
1299
+
1300
+ // Fade in the orbiter over 0.5 seconds after formation completes
1301
+ const timeSinceFormation = this.formationProgress >= 1
1302
+ ? (this.time - this.formationCompleteTime || 0)
1303
+ : 0;
1304
+ const orbiterAlpha = Math.min(1, timeSinceFormation * 2); // 0.5s fade-in
1305
+
1306
+ const totalAngle = this.orbitPhi + this.precessionAngle;
1307
+ const orbiterX = Math.cos(totalAngle) * this.orbitR;
1308
+ const orbiterZ = Math.sin(totalAngle) * this.orbitR;
1309
+ const orbiterY = this.getEmbeddingHeight(this.orbitR);
1310
+
1311
+ const p = this.camera.project(
1312
+ orbiterX * this.gridScale,
1313
+ orbiterY,
1314
+ orbiterZ * this.gridScale,
1315
+ );
1316
+
1317
+ const screenX = cx + p.x;
1318
+ const screenY = cy + p.y;
1319
+ const size = 5 * p.scale;
1320
+
1321
+ // Glow
1322
+ Painter.useCtx((ctx) => {
1323
+ ctx.globalAlpha = orbiterAlpha;
1324
+ const gradient = ctx.createRadialGradient(
1325
+ screenX,
1326
+ screenY,
1327
+ 0,
1328
+ screenX,
1329
+ screenY,
1330
+ size * 4,
1331
+ );
1332
+ gradient.addColorStop(0, CONFIG.orbiterGlow);
1333
+ gradient.addColorStop(1, "transparent");
1334
+ ctx.fillStyle = gradient;
1335
+ ctx.beginPath();
1336
+ ctx.arc(screenX, screenY, size * 4, 0, Math.PI * 2);
1337
+ ctx.fill();
1338
+ ctx.globalAlpha = 1;
1339
+ });
1340
+
1341
+ // Body
1342
+ Painter.useCtx((ctx) => {
1343
+ ctx.globalAlpha = orbiterAlpha;
1344
+ const gradient = ctx.createRadialGradient(
1345
+ screenX - size * 0.3,
1346
+ screenY - size * 0.3,
1347
+ 0,
1348
+ screenX,
1349
+ screenY,
1350
+ size,
1351
+ );
1352
+ gradient.addColorStop(0, "#fff");
1353
+ gradient.addColorStop(0.5, CONFIG.orbiterColor);
1354
+ gradient.addColorStop(1, CONFIG.orbiterGlow);
1355
+ ctx.fillStyle = gradient;
1356
+ ctx.beginPath();
1357
+ ctx.arc(screenX, screenY, size, 0, Math.PI * 2);
1358
+ ctx.fill();
1359
+ ctx.globalAlpha = 1;
1360
+ });
1361
+
1362
+ this.drawOrbitPath(cx, cy, orbiterAlpha);
1363
+ this.drawOrbitalTrail(cx, cy, orbiterAlpha);
1364
+ }
1365
+
1366
+ drawOrbitPath(cx, cy, alpha = 1) {
1367
+ const segments = 64;
1368
+ const eccentricity = CONFIG.orbitEccentricity;
1369
+
1370
+ Painter.useCtx((ctx) => {
1371
+ ctx.globalAlpha = alpha;
1372
+ ctx.strokeStyle = "rgba(100, 180, 255, 0.25)";
1373
+ ctx.lineWidth = 1.5;
1374
+ ctx.beginPath();
1375
+
1376
+ for (let i = 0; i <= segments; i++) {
1377
+ const angle = (i / segments) * Math.PI * 2 + this.precessionAngle;
1378
+ const phi = (i / segments) * Math.PI * 2;
1379
+ const radialOscillation = eccentricity * Math.sin(phi * 2);
1380
+ const r = CONFIG.orbitSemiMajor + radialOscillation * 2;
1381
+
1382
+ const x = Math.cos(angle) * r;
1383
+ const z = Math.sin(angle) * r;
1384
+ const y = this.getEmbeddingHeight(r);
1385
+
1386
+ const p = this.camera.project(
1387
+ x * this.gridScale,
1388
+ y,
1389
+ z * this.gridScale,
1390
+ );
1391
+
1392
+ if (i === 0) ctx.moveTo(cx + p.x, cy + p.y);
1393
+ else ctx.lineTo(cx + p.x, cy + p.y);
1394
+ }
1395
+
1396
+ ctx.closePath();
1397
+ ctx.stroke();
1398
+ ctx.globalAlpha = 1;
1399
+ });
1400
+ }
1401
+
1402
+ drawOrbitalTrail(cx, cy, fadeAlpha = 1) {
1403
+ if (this.orbitTrail.length < 2) return;
1404
+
1405
+ Painter.useCtx((ctx) => {
1406
+ ctx.lineCap = "round";
1407
+
1408
+ for (let i = 1; i < this.orbitTrail.length; i++) {
1409
+ const t = i / this.orbitTrail.length;
1410
+ const point = this.orbitTrail[i];
1411
+ const prevPoint = this.orbitTrail[i - 1];
1412
+
1413
+ const trailY = this.getEmbeddingHeight(point.r);
1414
+ const prevY = this.getEmbeddingHeight(prevPoint.r);
1415
+
1416
+ const p = this.camera.project(
1417
+ point.x * this.gridScale,
1418
+ trailY,
1419
+ point.z * this.gridScale,
1420
+ );
1421
+
1422
+ const prevP = this.camera.project(
1423
+ prevPoint.x * this.gridScale,
1424
+ prevY,
1425
+ prevPoint.z * this.gridScale,
1426
+ );
1427
+
1428
+ const alpha = (1 - t) * 0.5 * fadeAlpha;
1429
+ ctx.strokeStyle = `rgba(100, 180, 255, ${alpha})`;
1430
+ ctx.lineWidth = (1 - t) * 2.5 * p.scale;
1431
+ ctx.beginPath();
1432
+ ctx.moveTo(cx + prevP.x, cy + prevP.y);
1433
+ ctx.lineTo(cx + p.x, cy + p.y);
1434
+ ctx.stroke();
1435
+ }
1436
+ });
1437
+ }
1438
+
1439
+ drawEffectivePotential() {
1440
+ // Responsive graph sizing
1441
+ const isMobile = this.width < CONFIG.mobileWidth;
1442
+ const graphW = isMobile ? 120 : 160;
1443
+ const graphH = isMobile ? 70 : 100;
1444
+ const graphX = this.width - graphW - (isMobile ? 15 : 20);
1445
+ const graphY = isMobile ? 80 : 220; // Desktop moved down to avoid info div
1446
+ const M = this.mass;
1447
+ const a = this.spin;
1448
+
1449
+ Painter.useCtx((ctx) => {
1450
+ // Background
1451
+ ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
1452
+ ctx.fillRect(graphX - 10, graphY - 10, graphW + 20, graphH + 40);
1453
+
1454
+ // Title
1455
+ ctx.fillStyle = "#ccc"; // Brightened from #888
1456
+ ctx.font = "10px monospace";
1457
+ ctx.textAlign = "center";
1458
+ ctx.fillText("Kerr Effective Potential", graphX + graphW / 2, graphY);
1459
+
1460
+ // Axes
1461
+ ctx.strokeStyle = "#444";
1462
+ ctx.lineWidth = 1;
1463
+ ctx.beginPath();
1464
+ ctx.moveTo(graphX, graphY + graphH);
1465
+ ctx.lineTo(graphX + graphW, graphY + graphH);
1466
+ ctx.moveTo(graphX, graphY + 10);
1467
+ ctx.lineTo(graphX, graphY + graphH);
1468
+ ctx.stroke();
1469
+
1470
+ // Labels
1471
+ ctx.fillStyle = "#aaa"; // Brightened from #666
1472
+ ctx.font = "10px monospace";
1473
+ ctx.textAlign = "left";
1474
+ ctx.fillText("r", graphX + graphW - 10, graphY + graphH + 12);
1475
+
1476
+ // Plot V_eff (using Schwarzschild approximation for display)
1477
+ const rPlus = Tensor.kerrHorizonRadius(M, a, false);
1478
+ const rMin = rPlus * 1.2;
1479
+ const rMax = 20;
1480
+
1481
+ ctx.strokeStyle = "#8f8";
1482
+ ctx.lineWidth = 1.5;
1483
+ ctx.beginPath();
1484
+
1485
+ let firstPoint = true;
1486
+ for (let i = 0; i <= 100; i++) {
1487
+ const r = rMin + (i / 100) * (rMax - rMin);
1488
+ const V = Tensor.effectivePotential(M, this.orbitL, r);
1489
+
1490
+ const px = graphX + ((r - rMin) / (rMax - rMin)) * graphW;
1491
+ const py = graphY + graphH - 20 - (V + 0.1) * 300;
1492
+
1493
+ if (py > graphY + 10 && py < graphY + graphH) {
1494
+ if (firstPoint) {
1495
+ ctx.moveTo(px, py);
1496
+ firstPoint = false;
1497
+ } else {
1498
+ ctx.lineTo(px, py);
1499
+ }
1500
+ }
1501
+ }
1502
+ ctx.stroke();
1503
+
1504
+ // Current position
1505
+ const currentPx =
1506
+ graphX + ((this.orbitR - rMin) / (rMax - rMin)) * graphW;
1507
+ const currentV = Tensor.effectivePotential(M, this.orbitL, this.orbitR);
1508
+ const currentPy = graphY + graphH - 20 - (currentV + 0.1) * 300;
1509
+
1510
+ if (currentPy > graphY + 10 && currentPy < graphY + graphH) {
1511
+ ctx.fillStyle = CONFIG.orbiterColor;
1512
+ ctx.beginPath();
1513
+ ctx.arc(currentPx, currentPy, 4, 0, Math.PI * 2);
1514
+ ctx.fill();
1515
+ }
1516
+
1517
+ // Mark prograde ISCO
1518
+ const iscoP = Tensor.kerrISCO(M, a, true);
1519
+ const iscoPx = graphX + ((iscoP - rMin) / (rMax - rMin)) * graphW;
1520
+ ctx.strokeStyle = CONFIG.progradeISCOColor;
1521
+ ctx.setLineDash([2, 2]);
1522
+ ctx.beginPath();
1523
+ ctx.moveTo(iscoPx, graphY + 10);
1524
+ ctx.lineTo(iscoPx, graphY + graphH);
1525
+ ctx.stroke();
1526
+
1527
+ // Mark retrograde ISCO
1528
+ const iscoR = Tensor.kerrISCO(M, a, false);
1529
+ const iscoRx = graphX + ((iscoR - rMin) / (rMax - rMin)) * graphW;
1530
+ if (iscoRx < graphX + graphW) {
1531
+ ctx.strokeStyle = CONFIG.retrogradeISCOColor;
1532
+ ctx.beginPath();
1533
+ ctx.moveTo(iscoRx, graphY + 10);
1534
+ ctx.lineTo(iscoRx, graphY + graphH);
1535
+ ctx.stroke();
1536
+ }
1537
+
1538
+ ctx.setLineDash([]);
1539
+ });
1540
+ }
1541
+ }
1542
+
1543
+ window.addEventListener("load", () => {
1544
+ const canvas = document.getElementById("game");
1545
+ const demo = new KerrDemo(canvas);
1546
+ demo.start();
1547
+ });