@guinetik/gcanvas 1.0.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 (349) hide show
  1. package/.github/workflows/release.yaml +70 -0
  2. package/.jshintrc +4 -0
  3. package/.vscode/settings.json +22 -0
  4. package/CLAUDE.md +310 -0
  5. package/blackhole.jpg +0 -0
  6. package/demo.png +0 -0
  7. package/demos/CNAME +1 -0
  8. package/demos/animations.html +31 -0
  9. package/demos/basic.html +38 -0
  10. package/demos/baskara.html +31 -0
  11. package/demos/bezier.html +35 -0
  12. package/demos/beziersignature.html +29 -0
  13. package/demos/blackhole.html +28 -0
  14. package/demos/blob.html +35 -0
  15. package/demos/demos.css +289 -0
  16. package/demos/easing.html +28 -0
  17. package/demos/events.html +195 -0
  18. package/demos/fluent.html +647 -0
  19. package/demos/fractals.html +36 -0
  20. package/demos/genart.html +26 -0
  21. package/demos/gendream.html +26 -0
  22. package/demos/group.html +36 -0
  23. package/demos/home.html +587 -0
  24. package/demos/index.html +364 -0
  25. package/demos/isometric.html +34 -0
  26. package/demos/js/animations.js +452 -0
  27. package/demos/js/basic.js +204 -0
  28. package/demos/js/baskara.js +751 -0
  29. package/demos/js/bezier.js +692 -0
  30. package/demos/js/beziersignature.js +241 -0
  31. package/demos/js/blackhole/accretiondisk.obj.js +379 -0
  32. package/demos/js/blackhole/blackhole.obj.js +318 -0
  33. package/demos/js/blackhole/index.js +409 -0
  34. package/demos/js/blackhole/particle.js +56 -0
  35. package/demos/js/blackhole/starfield.obj.js +218 -0
  36. package/demos/js/blob.js +2263 -0
  37. package/demos/js/easing.js +477 -0
  38. package/demos/js/fluent.js +183 -0
  39. package/demos/js/fractals.js +931 -0
  40. package/demos/js/fractalworker.js +93 -0
  41. package/demos/js/genart.js +268 -0
  42. package/demos/js/gendream.js +209 -0
  43. package/demos/js/group.js +140 -0
  44. package/demos/js/info-toggle.js +25 -0
  45. package/demos/js/isometric.js +863 -0
  46. package/demos/js/kerr.js +1556 -0
  47. package/demos/js/lavalamp.js +590 -0
  48. package/demos/js/layout.js +354 -0
  49. package/demos/js/mondrian.js +285 -0
  50. package/demos/js/opacity.js +275 -0
  51. package/demos/js/painter.js +484 -0
  52. package/demos/js/particles-showcase.js +514 -0
  53. package/demos/js/particles.js +299 -0
  54. package/demos/js/patterns.js +397 -0
  55. package/demos/js/penrose/artifact.js +69 -0
  56. package/demos/js/penrose/blackhole.js +121 -0
  57. package/demos/js/penrose/constants.js +73 -0
  58. package/demos/js/penrose/game.js +943 -0
  59. package/demos/js/penrose/lore.js +278 -0
  60. package/demos/js/penrose/penrosescene.js +892 -0
  61. package/demos/js/penrose/ship.js +216 -0
  62. package/demos/js/penrose/sounds.js +211 -0
  63. package/demos/js/penrose/voidparticle.js +55 -0
  64. package/demos/js/penrose/voidscene.js +258 -0
  65. package/demos/js/penrose/voidship.js +144 -0
  66. package/demos/js/penrose/wormhole.js +46 -0
  67. package/demos/js/pipeline.js +555 -0
  68. package/demos/js/scene.js +304 -0
  69. package/demos/js/scenes.js +320 -0
  70. package/demos/js/schrodinger.js +410 -0
  71. package/demos/js/schwarzschild.js +1023 -0
  72. package/demos/js/shapes.js +628 -0
  73. package/demos/js/space/alien.js +171 -0
  74. package/demos/js/space/boom.js +98 -0
  75. package/demos/js/space/boss.js +353 -0
  76. package/demos/js/space/buff.js +73 -0
  77. package/demos/js/space/bullet.js +102 -0
  78. package/demos/js/space/constants.js +85 -0
  79. package/demos/js/space/game.js +1884 -0
  80. package/demos/js/space/hud.js +112 -0
  81. package/demos/js/space/laserbeam.js +179 -0
  82. package/demos/js/space/lightning.js +277 -0
  83. package/demos/js/space/minion.js +192 -0
  84. package/demos/js/space/missile.js +212 -0
  85. package/demos/js/space/player.js +430 -0
  86. package/demos/js/space/powerup.js +90 -0
  87. package/demos/js/space/starfield.js +58 -0
  88. package/demos/js/space/starpower.js +90 -0
  89. package/demos/js/spacetime.js +559 -0
  90. package/demos/js/svgtween.js +204 -0
  91. package/demos/js/tde/accretiondisk.js +418 -0
  92. package/demos/js/tde/blackhole.js +219 -0
  93. package/demos/js/tde/blackholescene.js +209 -0
  94. package/demos/js/tde/config.js +59 -0
  95. package/demos/js/tde/index.js +695 -0
  96. package/demos/js/tde/jets.js +290 -0
  97. package/demos/js/tde/lensedstarfield.js +147 -0
  98. package/demos/js/tde/tdestar.js +317 -0
  99. package/demos/js/tde/tidalstream.js +356 -0
  100. package/demos/js/tde_old/blackhole.obj.js +354 -0
  101. package/demos/js/tde_old/debris.obj.js +791 -0
  102. package/demos/js/tde_old/flare.obj.js +239 -0
  103. package/demos/js/tde_old/index.js +448 -0
  104. package/demos/js/tde_old/star.obj.js +812 -0
  105. package/demos/js/tiles.js +312 -0
  106. package/demos/js/tweendemo.js +79 -0
  107. package/demos/js/visibility.js +102 -0
  108. package/demos/kerr.html +28 -0
  109. package/demos/lavalamp.html +27 -0
  110. package/demos/layouts.html +37 -0
  111. package/demos/logo.svg +4 -0
  112. package/demos/loop.html +84 -0
  113. package/demos/mondrian.html +32 -0
  114. package/demos/og_image.png +0 -0
  115. package/demos/opacity.html +36 -0
  116. package/demos/painter.html +39 -0
  117. package/demos/particles-showcase.html +28 -0
  118. package/demos/particles.html +24 -0
  119. package/demos/patterns.html +33 -0
  120. package/demos/penrose-game.html +31 -0
  121. package/demos/pipeline.html +737 -0
  122. package/demos/scene.html +33 -0
  123. package/demos/scenes.html +96 -0
  124. package/demos/schrodinger.html +27 -0
  125. package/demos/schwarzschild.html +27 -0
  126. package/demos/shapes.html +16 -0
  127. package/demos/space.html +85 -0
  128. package/demos/spacetime.html +27 -0
  129. package/demos/svgtween.html +29 -0
  130. package/demos/tde.html +28 -0
  131. package/demos/tiles.html +28 -0
  132. package/demos/transforms.html +400 -0
  133. package/demos/tween.html +45 -0
  134. package/demos/visibility.html +33 -0
  135. package/disk_example.png +0 -0
  136. package/docs/README.md +222 -0
  137. package/docs/concepts/architecture-overview.md +204 -0
  138. package/docs/concepts/lifecycle.md +255 -0
  139. package/docs/concepts/rendering-pipeline.md +279 -0
  140. package/docs/concepts/tde-zorder.md +106 -0
  141. package/docs/concepts/two-layer-architecture.md +229 -0
  142. package/docs/getting-started/first-game.md +354 -0
  143. package/docs/getting-started/hello-world.md +269 -0
  144. package/docs/getting-started/installation.md +157 -0
  145. package/docs/modules/collision/README.md +453 -0
  146. package/docs/modules/fluent/README.md +1075 -0
  147. package/docs/modules/game/README.md +303 -0
  148. package/docs/modules/isometric-camera.md +210 -0
  149. package/docs/modules/isometric.md +275 -0
  150. package/docs/modules/painter/README.md +328 -0
  151. package/docs/modules/particle/README.md +559 -0
  152. package/docs/modules/shapes/README.md +221 -0
  153. package/docs/modules/shapes/base/euclidian.md +123 -0
  154. package/docs/modules/shapes/base/geometry2d.md +204 -0
  155. package/docs/modules/shapes/base/renderable.md +215 -0
  156. package/docs/modules/shapes/base/shape.md +262 -0
  157. package/docs/modules/shapes/base/transformable.md +243 -0
  158. package/docs/modules/shapes/hierarchy.md +218 -0
  159. package/docs/modules/state/README.md +577 -0
  160. package/docs/modules/util/README.md +99 -0
  161. package/docs/modules/util/camera3d.md +412 -0
  162. package/docs/modules/util/scene3d.md +395 -0
  163. package/index.html +17 -0
  164. package/jsdoc.json +50 -0
  165. package/package.json +55 -0
  166. package/readme.md +599 -0
  167. package/scripts/build-demo.js +69 -0
  168. package/scripts/bundle4llm.js +276 -0
  169. package/scripts/clearconsole.js +48 -0
  170. package/src/collision/collision-system.js +332 -0
  171. package/src/collision/collision.js +303 -0
  172. package/src/collision/index.js +10 -0
  173. package/src/fluent/fluent-game.js +430 -0
  174. package/src/fluent/fluent-go.js +1060 -0
  175. package/src/fluent/fluent-layer.js +152 -0
  176. package/src/fluent/fluent-scene.js +291 -0
  177. package/src/fluent/index.js +98 -0
  178. package/src/fluent/sketch.js +380 -0
  179. package/src/game/game.js +467 -0
  180. package/src/game/index.js +49 -0
  181. package/src/game/objects/go.js +220 -0
  182. package/src/game/objects/imagego.js +30 -0
  183. package/src/game/objects/index.js +54 -0
  184. package/src/game/objects/isometric-scene.js +260 -0
  185. package/src/game/objects/layoutscene.js +549 -0
  186. package/src/game/objects/scene.js +175 -0
  187. package/src/game/objects/scene3d.js +118 -0
  188. package/src/game/objects/text.js +221 -0
  189. package/src/game/objects/wrapper.js +232 -0
  190. package/src/game/pipeline.js +243 -0
  191. package/src/game/ui/button.js +396 -0
  192. package/src/game/ui/cursor.js +93 -0
  193. package/src/game/ui/fps.js +91 -0
  194. package/src/game/ui/index.js +5 -0
  195. package/src/game/ui/togglebutton.js +93 -0
  196. package/src/game/ui/tooltip.js +249 -0
  197. package/src/index.js +25 -0
  198. package/src/io/events.js +20 -0
  199. package/src/io/index.js +86 -0
  200. package/src/io/input.js +70 -0
  201. package/src/io/keys.js +152 -0
  202. package/src/io/mouse.js +61 -0
  203. package/src/io/touch.js +39 -0
  204. package/src/logger/debugtab.js +138 -0
  205. package/src/logger/index.js +3 -0
  206. package/src/logger/loggable.js +47 -0
  207. package/src/logger/logger.js +113 -0
  208. package/src/math/complex.js +37 -0
  209. package/src/math/constants.js +1 -0
  210. package/src/math/fractal.js +1271 -0
  211. package/src/math/gr.js +201 -0
  212. package/src/math/heat.js +202 -0
  213. package/src/math/index.js +12 -0
  214. package/src/math/noise.js +433 -0
  215. package/src/math/orbital.js +191 -0
  216. package/src/math/patterns.js +1339 -0
  217. package/src/math/penrose.js +259 -0
  218. package/src/math/quantum.js +115 -0
  219. package/src/math/random.js +195 -0
  220. package/src/math/tensor.js +1009 -0
  221. package/src/mixins/anchor.js +131 -0
  222. package/src/mixins/draggable.js +72 -0
  223. package/src/mixins/index.js +2 -0
  224. package/src/motion/bezier.js +132 -0
  225. package/src/motion/bounce.js +58 -0
  226. package/src/motion/easing.js +349 -0
  227. package/src/motion/float.js +130 -0
  228. package/src/motion/follow.js +125 -0
  229. package/src/motion/hop.js +52 -0
  230. package/src/motion/index.js +82 -0
  231. package/src/motion/motion.js +1124 -0
  232. package/src/motion/orbit.js +49 -0
  233. package/src/motion/oscillate.js +39 -0
  234. package/src/motion/parabolic.js +141 -0
  235. package/src/motion/patrol.js +147 -0
  236. package/src/motion/pendulum.js +48 -0
  237. package/src/motion/pulse.js +88 -0
  238. package/src/motion/shake.js +83 -0
  239. package/src/motion/spiral.js +144 -0
  240. package/src/motion/spring.js +150 -0
  241. package/src/motion/swing.js +47 -0
  242. package/src/motion/tween.js +92 -0
  243. package/src/motion/tweenetik.js +139 -0
  244. package/src/motion/waypoint.js +210 -0
  245. package/src/painter/index.js +8 -0
  246. package/src/painter/painter.colors.js +331 -0
  247. package/src/painter/painter.effects.js +230 -0
  248. package/src/painter/painter.img.js +229 -0
  249. package/src/painter/painter.js +295 -0
  250. package/src/painter/painter.lines.js +189 -0
  251. package/src/painter/painter.opacity.js +41 -0
  252. package/src/painter/painter.shapes.js +277 -0
  253. package/src/painter/painter.text.js +273 -0
  254. package/src/particle/emitter.js +124 -0
  255. package/src/particle/index.js +11 -0
  256. package/src/particle/particle-system.js +322 -0
  257. package/src/particle/particle.js +71 -0
  258. package/src/particle/updaters.js +170 -0
  259. package/src/shapes/arc.js +43 -0
  260. package/src/shapes/arrow.js +33 -0
  261. package/src/shapes/bezier.js +42 -0
  262. package/src/shapes/circle.js +62 -0
  263. package/src/shapes/clouds.js +56 -0
  264. package/src/shapes/cone.js +219 -0
  265. package/src/shapes/cross.js +70 -0
  266. package/src/shapes/cube.js +244 -0
  267. package/src/shapes/cylinder.js +254 -0
  268. package/src/shapes/diamond.js +48 -0
  269. package/src/shapes/euclidian.js +111 -0
  270. package/src/shapes/figure.js +115 -0
  271. package/src/shapes/geometry.js +220 -0
  272. package/src/shapes/group.js +375 -0
  273. package/src/shapes/heart.js +42 -0
  274. package/src/shapes/hexagon.js +26 -0
  275. package/src/shapes/image.js +192 -0
  276. package/src/shapes/index.js +111 -0
  277. package/src/shapes/line.js +29 -0
  278. package/src/shapes/pattern.js +90 -0
  279. package/src/shapes/pin.js +44 -0
  280. package/src/shapes/poly.js +31 -0
  281. package/src/shapes/prism.js +226 -0
  282. package/src/shapes/rect.js +35 -0
  283. package/src/shapes/renderable.js +333 -0
  284. package/src/shapes/ring.js +26 -0
  285. package/src/shapes/roundrect.js +95 -0
  286. package/src/shapes/shape.js +117 -0
  287. package/src/shapes/slice.js +26 -0
  288. package/src/shapes/sphere.js +314 -0
  289. package/src/shapes/sphere3d.js +537 -0
  290. package/src/shapes/square.js +15 -0
  291. package/src/shapes/star.js +99 -0
  292. package/src/shapes/svg.js +408 -0
  293. package/src/shapes/text.js +553 -0
  294. package/src/shapes/traceable.js +83 -0
  295. package/src/shapes/transform.js +357 -0
  296. package/src/shapes/transformable.js +172 -0
  297. package/src/shapes/triangle.js +26 -0
  298. package/src/sound/index.js +17 -0
  299. package/src/sound/sound.js +473 -0
  300. package/src/sound/synth.analyzer.js +149 -0
  301. package/src/sound/synth.effects.js +207 -0
  302. package/src/sound/synth.envelope.js +59 -0
  303. package/src/sound/synth.js +229 -0
  304. package/src/sound/synth.musical.js +160 -0
  305. package/src/sound/synth.noise.js +85 -0
  306. package/src/sound/synth.oscillators.js +293 -0
  307. package/src/state/index.js +10 -0
  308. package/src/state/state-machine.js +371 -0
  309. package/src/util/camera3d.js +438 -0
  310. package/src/util/index.js +6 -0
  311. package/src/util/isometric-camera.js +235 -0
  312. package/src/util/layout.js +317 -0
  313. package/src/util/position.js +147 -0
  314. package/src/util/tasks.js +47 -0
  315. package/src/util/zindex.js +287 -0
  316. package/src/webgl/index.js +9 -0
  317. package/src/webgl/shaders/sphere-shaders.js +994 -0
  318. package/src/webgl/webgl-renderer.js +388 -0
  319. package/tde.png +0 -0
  320. package/test/math/orbital.test.js +61 -0
  321. package/test/math/tensor.test.js +114 -0
  322. package/test/particle/emitter.test.js +204 -0
  323. package/test/particle/particle-system.test.js +310 -0
  324. package/test/particle/particle.test.js +116 -0
  325. package/test/particle/updaters.test.js +386 -0
  326. package/test/setup.js +120 -0
  327. package/test/shapes/euclidian.test.js +44 -0
  328. package/test/shapes/geometry.test.js +86 -0
  329. package/test/shapes/group.test.js +86 -0
  330. package/test/shapes/rectangle.test.js +64 -0
  331. package/test/shapes/transform.test.js +379 -0
  332. package/test/util/camera3d.test.js +428 -0
  333. package/test/util/scene3d.test.js +352 -0
  334. package/types/collision.d.ts +249 -0
  335. package/types/common.d.ts +155 -0
  336. package/types/game.d.ts +497 -0
  337. package/types/index.d.ts +309 -0
  338. package/types/io.d.ts +188 -0
  339. package/types/logger.d.ts +127 -0
  340. package/types/math.d.ts +268 -0
  341. package/types/mixins.d.ts +92 -0
  342. package/types/motion.d.ts +678 -0
  343. package/types/painter.d.ts +378 -0
  344. package/types/shapes.d.ts +864 -0
  345. package/types/sound.d.ts +672 -0
  346. package/types/state.d.ts +251 -0
  347. package/types/util.d.ts +253 -0
  348. package/vite.config.js +50 -0
  349. package/vitest.config.js +13 -0
@@ -0,0 +1,1556 @@
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 "../../src/index.js";
11
+ import { GameObject } from "../../src/game/objects/go.js";
12
+ import { Rectangle } from "../../src/shapes/rect.js";
13
+ import { TextShape } from "../../src/shapes/text.js";
14
+ import { Position } from "../../src/util/position.js";
15
+ import { Tensor } from "../../src/math/tensor.js";
16
+ import { flammEmbeddingHeight } from "../../src/math/gr.js";
17
+ import {
18
+ keplerianOmega,
19
+ kerrPrecessionRate,
20
+ orbitalRadiusSimple,
21
+ updateTrail,
22
+ } from "../../src/math/orbital.js";
23
+ import { verticalLayout, applyLayout } from "../../src/util/layout.js";
24
+ import { Tooltip } from "../../src/game/ui/tooltip.js";
25
+ import { Button } from "../../src/game/ui/button.js";
26
+
27
+ // Configuration
28
+ const CONFIG = {
29
+ // Grid parameters - FULLSCREEN
30
+ gridSize: 20,
31
+ gridResolution: 100, // Dense grid for full coverage
32
+ baseGridScale: 12,
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.updateGridScale();
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
+ updateGridScale() {
554
+ // Scale grid to show edges - user can see the fabric boundaries for frame dragging effect
555
+ const minDim = Math.min(this.width, this.height);
556
+ this.gridScale = (minDim / (CONFIG.gridSize * 2)) * 1.5;
557
+ }
558
+
559
+ shuffleParameters() {
560
+ // Randomize mass
561
+ this.mass =
562
+ CONFIG.massRange[0] +
563
+ Math.random() * (CONFIG.massRange[1] - CONFIG.massRange[0]);
564
+
565
+ // Randomize spin (as fraction of M)
566
+ const spinFraction =
567
+ CONFIG.spinRange[0] +
568
+ Math.random() * (CONFIG.spinRange[1] - CONFIG.spinRange[0]);
569
+ this.spin = spinFraction * this.mass;
570
+
571
+ // Reset orbit outside prograde ISCO
572
+ const iscoP = Tensor.kerrISCO(this.mass, this.spin, true);
573
+ this.orbitR = iscoP + 2 + Math.random() * 8;
574
+ this.orbitPhi = Math.random() * Math.PI * 2;
575
+ this.orbitL = 3.5 + Math.random() * 2;
576
+ this.precessionAngle = 0;
577
+ this.orbitTrail = [];
578
+
579
+ // Reset formation - grid goes back to flat, then forms into new Kerr
580
+ this.formationProgress = 0;
581
+ this.formationCompleteTime = null; // Reset for new orbiter fade-in
582
+
583
+ // Reset grid to original positions
584
+ const { gridResolution } = CONFIG;
585
+ for (let i = 0; i <= gridResolution; i++) {
586
+ for (let j = 0; j <= gridResolution; j++) {
587
+ const vertex = this.gridVertices[i][j];
588
+ vertex.x = vertex.baseX;
589
+ vertex.z = vertex.baseZ;
590
+ vertex.y = 0;
591
+ }
592
+ }
593
+
594
+ // Reset dragged particles
595
+ if (this.draggedParticles) {
596
+ for (const p of this.draggedParticles) {
597
+ p.angle = Math.random() * Math.PI * 2;
598
+ }
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Embedding height for Kerr using shared gr.js module.
604
+ * Uses r+ (outer horizon) instead of 2M for the Kerr case.
605
+ */
606
+ getEmbeddingHeight(r) {
607
+ const rPlus = Tensor.kerrHorizonRadius(this.mass, this.spin, false);
608
+ return flammEmbeddingHeight(
609
+ r,
610
+ rPlus,
611
+ this.mass,
612
+ CONFIG.gridSize,
613
+ CONFIG.embeddingScale,
614
+ );
615
+ }
616
+
617
+ /**
618
+ * Update geodesic motion with frame dragging using orbital.js utilities.
619
+ */
620
+ updateGeodesic(dt) {
621
+ const M = this.mass;
622
+ const a = this.spin;
623
+ const r = this.orbitR;
624
+
625
+ // Base Keplerian angular velocity
626
+ const baseOmega = keplerianOmega(r, M, CONFIG.orbitSpeed);
627
+
628
+ // Frame dragging contribution (Kerr-specific, stays in Tensor)
629
+ const omegaDrag = Tensor.kerrFrameDraggingOmega(r, Math.PI / 2, M, a);
630
+
631
+ // Total angular velocity (frame dragging adds to prograde motion)
632
+ const totalOmega =
633
+ baseOmega + omegaDrag * CONFIG.frameDraggingAmplification;
634
+
635
+ // Update orbital angle
636
+ this.orbitPhi += totalOmega * dt;
637
+
638
+ // Radial oscillation for eccentricity
639
+ this.orbitR = orbitalRadiusSimple(
640
+ CONFIG.orbitSemiMajor,
641
+ CONFIG.orbitEccentricity,
642
+ this.orbitPhi,
643
+ );
644
+
645
+ // Keep orbit outside prograde ISCO
646
+ const minR = Tensor.kerrISCO(M, a, true) + 1;
647
+ if (this.orbitR < minR) this.orbitR = minR;
648
+
649
+ // GR precession (enhanced by frame dragging)
650
+ const precessionRate = kerrPrecessionRate(r, M, a, CONFIG.precessionFactor);
651
+ this.precessionAngle += precessionRate * dt;
652
+
653
+ // Store in trail
654
+ const totalAngle = this.orbitPhi + this.precessionAngle;
655
+ updateTrail(
656
+ this.orbitTrail,
657
+ {
658
+ x: Math.cos(totalAngle) * this.orbitR,
659
+ z: Math.sin(totalAngle) * this.orbitR,
660
+ r: this.orbitR,
661
+ omega: omegaDrag,
662
+ },
663
+ 80,
664
+ );
665
+ }
666
+
667
+ update(dt) {
668
+ super.update(dt);
669
+ this.time += dt;
670
+
671
+ // Formation progress: λ goes from 0 to 1 over formationDuration
672
+ // Once at 1, stays there (Kerr is stationary - the final state)
673
+ const wasForming = this.formationProgress < 1;
674
+ if (this.formationProgress < 1) {
675
+ this.formationProgress += dt / CONFIG.formationDuration;
676
+ if (this.formationProgress >= 1) {
677
+ this.formationProgress = 1;
678
+ // Record when formation completed (for orbiter fade-in)
679
+ this.formationCompleteTime = this.time;
680
+ }
681
+ }
682
+
683
+ // Smooth easing for formation (ease-out cubic)
684
+ const lambda = 1 - Math.pow(1 - this.formationProgress, 3);
685
+
686
+ this.camera.update(dt);
687
+
688
+ // Only update geodesic motion after black hole has formed
689
+ // The orbiter appears after formation completes
690
+ if (this.formationProgress >= 1) {
691
+ this.updateGeodesic(dt);
692
+ }
693
+
694
+ this.updateGridScale();
695
+
696
+ // Update grid with Kerr geometry
697
+ // The twist is proportional to λ (formation progress), NOT accumulating over time
698
+ // This shows the FINAL Kerr geometry, not "evolving" spacetime
699
+ const { gridResolution } = CONFIG;
700
+ const M = this.mass;
701
+ const a = this.spin;
702
+ const rPlus = Tensor.kerrHorizonRadius(M, a, false);
703
+ const rErgo = Tensor.kerrErgosphereRadius(M, a, Math.PI / 2);
704
+
705
+ // Extended reach for frame dragging visualization (rubber sheet analogy)
706
+ // Real effect falls off as ~1/r³, but we extend it for visual clarity
707
+ const visualReach = rErgo * CONFIG.frameDraggingReach;
708
+
709
+ for (let i = 0; i <= gridResolution; i++) {
710
+ for (let j = 0; j <= gridResolution; j++) {
711
+ const vertex = this.gridVertices[i][j];
712
+ const baseR = Math.sqrt(
713
+ vertex.baseX * vertex.baseX + vertex.baseZ * vertex.baseZ,
714
+ );
715
+
716
+ // Frame dragging twist proportional to formation progress (λ)
717
+ // At λ=0: flat spacetime, no twist
718
+ // At λ=1: full Kerr geometry with frame dragging
719
+ // INTENTIONALLY EXAGGERATED: extends beyond physical ergosphere for visibility
720
+ if (baseR > rPlus * 0.5 && baseR < visualReach) {
721
+ const omega = Tensor.kerrFrameDraggingOmega(
722
+ Math.max(baseR, rPlus + 0.1),
723
+ Math.PI / 2,
724
+ M,
725
+ a,
726
+ );
727
+
728
+ // Visual falloff: smooth transition from max twist near horizon to zero at visualReach
729
+ // Uses quadratic falloff for smoother visual effect
730
+ const proximityFactor =
731
+ 1 - Math.pow((baseR - rPlus) / (visualReach - rPlus), 2);
732
+ const clampedProximity = Math.max(0, proximityFactor);
733
+
734
+ // Static twist angle - EXAGGERATED for visualization
735
+ const maxTwist = Math.PI / 4; // ~45 degrees max for dramatic effect
736
+ const twistAngle =
737
+ omega * CONFIG.frameDraggingStrength * lambda * clampedProximity;
738
+ const cappedTwist = Math.min(twistAngle, maxTwist);
739
+
740
+ // Apply rotation to grid point
741
+ const cosT = Math.cos(cappedTwist);
742
+ const sinT = Math.sin(cappedTwist);
743
+ vertex.x = vertex.baseX * cosT - vertex.baseZ * sinT;
744
+ vertex.z = vertex.baseX * sinT + vertex.baseZ * cosT;
745
+ }
746
+
747
+ // Embedding depth also scales with λ (flat → curved)
748
+ // Function already clamps at horizon, no need for extra clamp here
749
+ const r = Math.sqrt(vertex.x * vertex.x + vertex.z * vertex.z);
750
+ vertex.y = this.getEmbeddingHeight(r) * lambda;
751
+ }
752
+ }
753
+
754
+ // Update dragged particles in ergosphere
755
+ if (this.draggedParticles) {
756
+ for (const p of this.draggedParticles) {
757
+ // Particles get dragged by frame dragging
758
+ const omega = Tensor.kerrFrameDraggingOmega(p.baseR, Math.PI / 2, M, a);
759
+ p.angle += omega * dt * 50; // Strong visual drag
760
+ // Slight radial oscillation
761
+ p.r = p.baseR + Math.sin(this.time * 2 + p.phase) * 0.3;
762
+ }
763
+ }
764
+
765
+ // Update metric panel
766
+ if (this.metricPanel) {
767
+ // Use EFFECTIVE mass and spin based on formation progress (lambda)
768
+ // This allows the numbers to evolve from Flat Spacetime values to final Kerr values
769
+ // Note: We clamp at a small epsilon for M to avoid division by zero if lambda is 0
770
+ const effectiveM = Math.max(0.001, this.mass * lambda);
771
+ const effectiveA = this.spin * lambda;
772
+
773
+ this.metricPanel.setMetricValues(
774
+ this.orbitR,
775
+ Math.PI / 2,
776
+ effectiveM,
777
+ effectiveA,
778
+ );
779
+ this.metricPanel.setOrbiterPosition(
780
+ this.orbitR,
781
+ this.orbitPhi,
782
+ effectiveM,
783
+ effectiveA,
784
+ );
785
+ }
786
+ }
787
+
788
+ render() {
789
+ const w = this.width;
790
+ const h = this.height;
791
+ const cx = w / 2;
792
+ const cy = h / 2; // Centered to see fabric edges from outside
793
+
794
+ super.render();
795
+
796
+ // Draw key radii (ergosphere, horizons, ISCOs)
797
+ this.drawKeyRadii(cx, cy);
798
+
799
+ // Draw ergosphere fill with dragged particles
800
+ this.drawErgosphere(cx, cy);
801
+
802
+ // Draw grid (now with frame dragging twist!)
803
+ this.drawGrid(cx, cy);
804
+
805
+ // Draw rotating black hole with accretion disk
806
+ this.drawHorizon(cx, cy);
807
+
808
+ // Draw orbiter
809
+ this.drawOrbiter(cx, cy);
810
+
811
+ // Draw effective potential graph
812
+ this.drawEffectivePotential();
813
+
814
+ // Draw formation progress indicator
815
+ this.drawFormationProgress(w, h);
816
+
817
+ // Draw controls
818
+ this.renderControls();
819
+ }
820
+
821
+ renderControls() {
822
+ const w = this.width;
823
+ const h = this.height;
824
+ const isMobile = w < CONFIG.mobileWidth;
825
+ const margin = isMobile ? 10 : 15;
826
+ const lineSpacing = isMobile ? 12 : 15;
827
+
828
+ // On mobile, use shorter text
829
+ if (isMobile) {
830
+ this.controlsText.text = "tap to form | drag to rotate";
831
+ this.controlsText.font = "8px monospace";
832
+ }
833
+
834
+ // Position and render main controls text
835
+ this.controlsText.x = w - margin;
836
+ this.controlsText.y = h - 25 - (isMobile ? 1 : this.explanationShapes.length) * lineSpacing;
837
+ this.controlsText.render();
838
+
839
+ // Position and render explanation lines (hide most on mobile)
840
+ this.explanationShapes.forEach((shape, i) => {
841
+ if (isMobile && i < this.explanationShapes.length - 1) return; // Only show last line on mobile
842
+
843
+ shape.font = isMobile ? "8px monospace" : "10px monospace";
844
+ const lineIndexFromBottom = this.explanationShapes.length - 1 - i;
845
+ shape.x = w - margin;
846
+ shape.y = h - 10 - (lineIndexFromBottom * lineSpacing);
847
+ shape.render();
848
+ });
849
+ }
850
+
851
+ drawFormationProgress(w, h) {
852
+ const progress = this.formationProgress;
853
+ const lambda = 1 - Math.pow(1 - progress, 3); // Eased progress
854
+
855
+ // Position above the chart (same x alignment as chart)
856
+ const isMobile = w < CONFIG.mobileWidth;
857
+ const graphW = isMobile ? 120 : 160;
858
+ const graphX = w - graphW - (isMobile ? 15 : 20);
859
+ const graphY = isMobile ? 80 : 220; // Desktop moved down to avoid info div
860
+
861
+ const barWidth = graphW; // Same width as chart
862
+ const barHeight = 6;
863
+ const barX = graphX;
864
+ const barY = graphY - 35; // Above the chart
865
+
866
+ Painter.useCtx((ctx) => {
867
+ // Phase-aware label
868
+ ctx.font = "10px monospace";
869
+ ctx.textAlign = "left";
870
+ let label, color;
871
+
872
+ if (progress >= 1) {
873
+ label = "Kerr (stationary)";
874
+ color = "#8f8";
875
+ } else if (lambda < 0.2) {
876
+ label = "Collapse...";
877
+ color = "#fff";
878
+ } else if (lambda < 0.5) {
879
+ label = "Horizon forming...";
880
+ color = "#f88";
881
+ } else if (lambda < 0.8) {
882
+ label = "Ergosphere emerging...";
883
+ color = "#fa8";
884
+ } else {
885
+ label = "Frame dragging stabilizing...";
886
+ color = "#ff0";
887
+ }
888
+
889
+ ctx.fillStyle = color;
890
+ ctx.fillText(label, barX, barY - 8);
891
+
892
+ // Background bar
893
+ ctx.fillStyle = "rgba(255, 255, 255, 0.1)";
894
+ ctx.fillRect(barX, barY, barWidth, barHeight);
895
+
896
+ // Progress bar
897
+ const gradient = ctx.createLinearGradient(barX, 0, barX + barWidth, 0);
898
+ gradient.addColorStop(0, "rgba(100, 100, 255, 0.8)");
899
+ gradient.addColorStop(1, "rgba(255, 100, 100, 0.8)");
900
+ ctx.fillStyle = gradient;
901
+ ctx.fillRect(barX, barY, barWidth * progress, barHeight);
902
+
903
+ // λ indicator
904
+ ctx.fillStyle = "#888";
905
+ ctx.font = "9px monospace";
906
+ ctx.fillText(`λ = ${progress.toFixed(2)}`, barX, barY + 16);
907
+ });
908
+ }
909
+
910
+ drawKeyRadii(cx, cy) {
911
+ const M = this.mass;
912
+ const a = this.spin;
913
+
914
+ const radii = [
915
+ {
916
+ r: Tensor.kerrHorizonRadius(M, a, false),
917
+ color: CONFIG.outerHorizonColor,
918
+ label: "r+",
919
+ },
920
+ {
921
+ r: Tensor.kerrErgosphereRadius(M, a, Math.PI / 2),
922
+ color: CONFIG.ergosphereColor,
923
+ label: "ergo",
924
+ dashed: true,
925
+ },
926
+ {
927
+ r: Tensor.kerrISCO(M, a, true),
928
+ color: CONFIG.progradeISCOColor,
929
+ label: "ISCO_pro",
930
+ },
931
+ {
932
+ r: Tensor.kerrISCO(M, a, false),
933
+ color: CONFIG.retrogradeISCOColor,
934
+ label: "ISCO_retro",
935
+ },
936
+ ];
937
+
938
+ for (const { r, color, dashed } of radii) {
939
+ if (isNaN(r)) continue;
940
+ const segments = 48;
941
+
942
+ Painter.useCtx((ctx) => {
943
+ ctx.strokeStyle = color;
944
+ ctx.lineWidth = 1.5;
945
+ if (dashed) ctx.setLineDash([5, 5]);
946
+ ctx.beginPath();
947
+
948
+ for (let i = 0; i <= segments; i++) {
949
+ const angle = (i / segments) * Math.PI * 2;
950
+ const x = Math.cos(angle) * r;
951
+ const z = Math.sin(angle) * r;
952
+ const y = this.getEmbeddingHeight(r);
953
+
954
+ const p = this.camera.project(
955
+ x * this.gridScale,
956
+ y,
957
+ z * this.gridScale,
958
+ );
959
+
960
+ if (i === 0) ctx.moveTo(cx + p.x, cy + p.y);
961
+ else ctx.lineTo(cx + p.x, cy + p.y);
962
+ }
963
+ ctx.stroke();
964
+ ctx.setLineDash([]);
965
+ });
966
+ }
967
+ }
968
+
969
+ drawErgosphere(cx, cy) {
970
+ const M = this.mass;
971
+ const a = this.spin;
972
+ const rErgo = Tensor.kerrErgosphereRadius(M, a, Math.PI / 2);
973
+ const rPlus = Tensor.kerrHorizonRadius(M, a, false);
974
+
975
+ if (isNaN(rErgo) || isNaN(rPlus) || rErgo <= rPlus) return;
976
+
977
+ // Ergosphere only visible after significant formation (λ > 0.4)
978
+ // This is a property of the final Kerr geometry
979
+ const lambda = 1 - Math.pow(1 - this.formationProgress, 3);
980
+ const ergoVisibility = Math.max(0, (lambda - 0.4) / 0.6); // 0 at λ=0.4, 1 at λ=1
981
+ if (ergoVisibility <= 0) return;
982
+
983
+ const segments = 64;
984
+
985
+ Painter.useCtx((ctx) => {
986
+ // Semi-transparent orange fill for ergosphere - fades in with formation
987
+ ctx.fillStyle = `rgba(255, 100, 0, ${0.15 * ergoVisibility})`;
988
+ ctx.beginPath();
989
+
990
+ // Outer boundary (ergosphere)
991
+ for (let i = 0; i <= segments; i++) {
992
+ const angle = (i / segments) * Math.PI * 2;
993
+ const x = Math.cos(angle) * rErgo;
994
+ const z = Math.sin(angle) * rErgo;
995
+ const y = this.getEmbeddingHeight(rErgo);
996
+
997
+ const p = this.camera.project(
998
+ x * this.gridScale,
999
+ y,
1000
+ z * this.gridScale,
1001
+ );
1002
+
1003
+ if (i === 0) ctx.moveTo(cx + p.x, cy + p.y);
1004
+ else ctx.lineTo(cx + p.x, cy + p.y);
1005
+ }
1006
+
1007
+ // Inner boundary (horizon) - reverse to create ring
1008
+ for (let i = segments; i >= 0; i--) {
1009
+ const angle = (i / segments) * Math.PI * 2;
1010
+ const x = Math.cos(angle) * rPlus;
1011
+ const z = Math.sin(angle) * rPlus;
1012
+ const y = this.getEmbeddingHeight(rPlus + 0.1);
1013
+
1014
+ const p = this.camera.project(
1015
+ x * this.gridScale,
1016
+ y,
1017
+ z * this.gridScale,
1018
+ );
1019
+ ctx.lineTo(cx + p.x, cy + p.y);
1020
+ }
1021
+
1022
+ ctx.closePath();
1023
+ ctx.fill();
1024
+ });
1025
+
1026
+ // Draw dragged particles in ergosphere - shows frame dragging!
1027
+ // Particles also fade in with ergosphere visibility
1028
+ if (this.draggedParticles && ergoVisibility > 0) {
1029
+ Painter.useCtx((ctx) => {
1030
+ for (const p of this.draggedParticles) {
1031
+ // Only draw if within ergosphere
1032
+ if (p.r < rErgo && p.r > rPlus) {
1033
+ const x = Math.cos(p.angle) * p.r;
1034
+ const z = Math.sin(p.angle) * p.r;
1035
+ const y = this.getEmbeddingHeight(p.r);
1036
+
1037
+ const proj = this.camera.project(
1038
+ x * this.gridScale,
1039
+ y,
1040
+ z * this.gridScale,
1041
+ );
1042
+
1043
+ // Particles glow orange - they're being dragged!
1044
+ const size = 3 * proj.scale;
1045
+ ctx.fillStyle = `rgba(255, 180, 50, ${0.9 * ergoVisibility})`;
1046
+ ctx.beginPath();
1047
+ ctx.arc(cx + proj.x, cy + proj.y, size, 0, Math.PI * 2);
1048
+ ctx.fill();
1049
+
1050
+ // Trail showing direction of drag
1051
+ const trailAngle = p.angle - 0.3;
1052
+ const trailX = Math.cos(trailAngle) * p.r;
1053
+ const trailZ = Math.sin(trailAngle) * p.r;
1054
+ const trailProj = this.camera.project(
1055
+ trailX * this.gridScale,
1056
+ y,
1057
+ trailZ * this.gridScale,
1058
+ );
1059
+
1060
+ ctx.strokeStyle = `rgba(255, 150, 50, ${0.4 * ergoVisibility})`;
1061
+ ctx.lineWidth = 2 * proj.scale;
1062
+ ctx.beginPath();
1063
+ ctx.moveTo(cx + trailProj.x, cy + trailProj.y);
1064
+ ctx.lineTo(cx + proj.x, cy + proj.y);
1065
+ ctx.stroke();
1066
+ }
1067
+ }
1068
+ });
1069
+ }
1070
+ }
1071
+
1072
+ drawGrid(cx, cy) {
1073
+ const { gridResolution, gridColor, gridHighlight } = CONFIG;
1074
+ const gridScale = this.gridScale;
1075
+
1076
+ const projected = this.gridVertices.map((row) =>
1077
+ row.map((v) => {
1078
+ const p = this.camera.project(v.x * gridScale, v.y, v.z * gridScale);
1079
+ return { x: cx + p.x, y: cy + p.y, z: p.z };
1080
+ }),
1081
+ );
1082
+
1083
+ for (let i = 0; i <= gridResolution; i++) {
1084
+ const isMain = i % 5 === 0;
1085
+ Painter.useCtx((ctx) => {
1086
+ ctx.strokeStyle = isMain ? gridHighlight : gridColor;
1087
+ ctx.lineWidth = isMain ? 1.2 : 0.6;
1088
+ ctx.beginPath();
1089
+ for (let j = 0; j <= gridResolution; j++) {
1090
+ const p = projected[i][j];
1091
+ if (j === 0) ctx.moveTo(p.x, p.y);
1092
+ else ctx.lineTo(p.x, p.y);
1093
+ }
1094
+ ctx.stroke();
1095
+ });
1096
+ }
1097
+
1098
+ for (let j = 0; j <= gridResolution; j++) {
1099
+ const isMain = j % 5 === 0;
1100
+ Painter.useCtx((ctx) => {
1101
+ ctx.strokeStyle = isMain ? gridHighlight : gridColor;
1102
+ ctx.lineWidth = isMain ? 1.2 : 0.6;
1103
+ ctx.beginPath();
1104
+ for (let i = 0; i <= gridResolution; i++) {
1105
+ const p = projected[i][j];
1106
+ if (i === 0) ctx.moveTo(p.x, p.y);
1107
+ else ctx.lineTo(p.x, p.y);
1108
+ }
1109
+ ctx.stroke();
1110
+ });
1111
+ }
1112
+ }
1113
+
1114
+ drawHorizon(cx, cy) {
1115
+ const rPlus = Tensor.kerrHorizonRadius(this.mass, this.spin, false);
1116
+
1117
+ // Formation progress affects size, intensity, AND vertical position
1118
+ // Smooth easing for formation (ease-out cubic)
1119
+ const lambda = 1 - Math.pow(1 - this.formationProgress, 3);
1120
+
1121
+ // Black hole sinks down as space curves around it
1122
+ // At λ=0: sits at flat space level (y=0)
1123
+ // At λ=1: sits at bottom of the well
1124
+ const finalY = this.getEmbeddingHeight(rPlus + 0.1);
1125
+ const y = finalY * lambda; // Interpolate from 0 to final depth
1126
+
1127
+ const centerP = this.camera.project(0, y + 10, 0);
1128
+ const centerX = cx + centerP.x;
1129
+ const centerY = cy + centerP.y;
1130
+
1131
+ // Black hole size scales with mass AND formation progress
1132
+ // Starts as tiny seed (3px), grows to full size
1133
+ const fullSize =
1134
+ CONFIG.blackHoleSizeBase + this.mass * CONFIG.blackHoleSizeMassScale;
1135
+ const seedSize = 3; // Initial collapse seed
1136
+ const size = (seedSize + (fullSize - seedSize) * lambda) * centerP.scale;
1137
+
1138
+ // Spin direction indicator (which way the hole rotates)
1139
+ const spinDirection = this.spin > 0 ? 1 : -1;
1140
+ // Rotation speed increases as formation progresses
1141
+ const rotationAngle = this.time * 2 * spinDirection * lambda;
1142
+
1143
+ // During early formation, show bright collapse point
1144
+ if (lambda < 0.3) {
1145
+ const collapseIntensity = 1 - lambda / 0.3; // Fades out as formation progresses
1146
+ Painter.useCtx((ctx) => {
1147
+ // Bright white-blue collapse flash
1148
+ const flashSize = (10 + (1 - lambda) * 30) * centerP.scale;
1149
+ const gradient = ctx.createRadialGradient(
1150
+ centerX,
1151
+ centerY,
1152
+ 0,
1153
+ centerX,
1154
+ centerY,
1155
+ flashSize,
1156
+ );
1157
+ gradient.addColorStop(
1158
+ 0,
1159
+ `rgba(255, 255, 255, ${0.9 * collapseIntensity})`,
1160
+ );
1161
+ gradient.addColorStop(
1162
+ 0.3,
1163
+ `rgba(150, 200, 255, ${0.6 * collapseIntensity})`,
1164
+ );
1165
+ gradient.addColorStop(1, "transparent");
1166
+ ctx.fillStyle = gradient;
1167
+ ctx.beginPath();
1168
+ ctx.arc(centerX, centerY, flashSize, 0, Math.PI * 2);
1169
+ ctx.fill();
1170
+ });
1171
+ }
1172
+
1173
+ // Outer glow - intensity grows with formation
1174
+ const glowIntensity = 0.2 + lambda * 0.8; // 20% → 100%
1175
+ Painter.useCtx((ctx) => {
1176
+ const gradient = ctx.createRadialGradient(
1177
+ centerX,
1178
+ centerY,
1179
+ size,
1180
+ centerX,
1181
+ centerY,
1182
+ size * 4,
1183
+ );
1184
+ gradient.addColorStop(0, `rgba(100, 50, 150, ${0.5 * glowIntensity})`);
1185
+ gradient.addColorStop(0.5, `rgba(255, 100, 50, ${0.2 * glowIntensity})`);
1186
+ gradient.addColorStop(1, "transparent");
1187
+ ctx.fillStyle = gradient;
1188
+ ctx.beginPath();
1189
+ ctx.arc(centerX, centerY, size * 4, 0, Math.PI * 2);
1190
+ ctx.fill();
1191
+ });
1192
+
1193
+ // ROTATING ACCRETION DISK - fades in as black hole forms
1194
+ // Only visible after initial collapse phase (λ > 0.2)
1195
+ const diskVisibility = Math.max(0, (lambda - 0.2) / 0.8); // 0 at λ=0.2, 1 at λ=1
1196
+ if (diskVisibility > 0) {
1197
+ Painter.useCtx((ctx) => {
1198
+ ctx.save();
1199
+ ctx.translate(centerX, centerY);
1200
+
1201
+ // Draw spinning spiral arms
1202
+ const numArms = 3;
1203
+ for (let arm = 0; arm < numArms; arm++) {
1204
+ const armAngle = (arm / numArms) * Math.PI * 2 + rotationAngle;
1205
+
1206
+ // Spiral gradient for each arm
1207
+ ctx.beginPath();
1208
+ ctx.moveTo(0, 0);
1209
+
1210
+ // Draw spiral
1211
+ for (let t = 0; t <= 1; t += 0.02) {
1212
+ const r = size * 1.2 + t * size * 2.5;
1213
+ const angle = armAngle + t * Math.PI * 1.5 * spinDirection;
1214
+ const x = Math.cos(angle) * r;
1215
+ const y = Math.sin(angle) * r * 0.4; // Flatten for disk perspective
1216
+ ctx.lineTo(x, y);
1217
+ }
1218
+
1219
+ const baseAlpha = 0.6 - arm * 0.15;
1220
+ const alpha = baseAlpha * diskVisibility;
1221
+ ctx.strokeStyle = `rgba(255, ${150 + arm * 30}, ${50 + arm * 20}, ${alpha})`;
1222
+ ctx.lineWidth = 3 - arm * 0.5;
1223
+ ctx.stroke();
1224
+ }
1225
+
1226
+ // Inner bright ring (hot gas closest to horizon)
1227
+ ctx.strokeStyle = `rgba(255, 200, 100, ${0.8 * diskVisibility})`;
1228
+ ctx.lineWidth = 2;
1229
+ ctx.beginPath();
1230
+ ctx.ellipse(0, 0, size * 1.5, size * 0.6, 0, 0, Math.PI * 2);
1231
+ ctx.stroke();
1232
+
1233
+ // Spinning particles in the disk
1234
+ const numParticles = 12;
1235
+ for (let i = 0; i < numParticles; i++) {
1236
+ const baseAngle = (i / numParticles) * Math.PI * 2;
1237
+ const particleR = size * 1.8 + Math.sin(i * 2.7) * size * 0.5;
1238
+ const particleAngle =
1239
+ baseAngle + rotationAngle * (2 - particleR / (size * 3));
1240
+ const px = Math.cos(particleAngle) * particleR;
1241
+ const py = Math.sin(particleAngle) * particleR * 0.4;
1242
+
1243
+ const brightness = 150 + Math.sin(this.time * 3 + i) * 50;
1244
+ ctx.fillStyle = `rgba(255, ${brightness}, 50, ${0.8 * diskVisibility})`;
1245
+ ctx.beginPath();
1246
+ ctx.arc(px, py, 2, 0, Math.PI * 2);
1247
+ ctx.fill();
1248
+ }
1249
+
1250
+ ctx.restore();
1251
+ });
1252
+ }
1253
+
1254
+ // Black hole body (actual black center)
1255
+ Painter.useCtx((ctx) => {
1256
+ ctx.fillStyle = "#000";
1257
+ ctx.beginPath();
1258
+ ctx.arc(centerX, centerY, size, 0, Math.PI * 2);
1259
+ ctx.fill();
1260
+
1261
+ // Inner edge glow
1262
+ const innerGrad = ctx.createRadialGradient(
1263
+ centerX,
1264
+ centerY,
1265
+ size * 0.7,
1266
+ centerX,
1267
+ centerY,
1268
+ size,
1269
+ );
1270
+ innerGrad.addColorStop(0, "transparent");
1271
+ innerGrad.addColorStop(1, "rgba(255, 100, 0, 0.5)");
1272
+ ctx.fillStyle = innerGrad;
1273
+ ctx.beginPath();
1274
+ ctx.arc(centerX, centerY, size, 0, Math.PI * 2);
1275
+ ctx.fill();
1276
+ });
1277
+
1278
+ // Event horizon circle on grid
1279
+ const segments = 32;
1280
+ Painter.useCtx((ctx) => {
1281
+ ctx.strokeStyle = CONFIG.outerHorizonColor;
1282
+ ctx.lineWidth = 2;
1283
+ ctx.beginPath();
1284
+
1285
+ for (let i = 0; i <= segments; i++) {
1286
+ const angle = (i / segments) * Math.PI * 2;
1287
+ const x = Math.cos(angle) * rPlus;
1288
+ const z = Math.sin(angle) * rPlus;
1289
+
1290
+ const p = this.camera.project(
1291
+ x * this.gridScale,
1292
+ y,
1293
+ z * this.gridScale,
1294
+ );
1295
+
1296
+ if (i === 0) ctx.moveTo(cx + p.x, cy + p.y);
1297
+ else ctx.lineTo(cx + p.x, cy + p.y);
1298
+ }
1299
+ ctx.closePath();
1300
+ ctx.stroke();
1301
+ });
1302
+ }
1303
+
1304
+ drawOrbiter(cx, cy) {
1305
+ // Only show orbiter after black hole has fully formed
1306
+ // Geodesic motion is a property of the final Kerr spacetime
1307
+ if (this.formationProgress < 1) return;
1308
+
1309
+ // Fade in the orbiter over 0.5 seconds after formation completes
1310
+ const timeSinceFormation = this.formationProgress >= 1
1311
+ ? (this.time - this.formationCompleteTime || 0)
1312
+ : 0;
1313
+ const orbiterAlpha = Math.min(1, timeSinceFormation * 2); // 0.5s fade-in
1314
+
1315
+ const totalAngle = this.orbitPhi + this.precessionAngle;
1316
+ const orbiterX = Math.cos(totalAngle) * this.orbitR;
1317
+ const orbiterZ = Math.sin(totalAngle) * this.orbitR;
1318
+ const orbiterY = this.getEmbeddingHeight(this.orbitR);
1319
+
1320
+ const p = this.camera.project(
1321
+ orbiterX * this.gridScale,
1322
+ orbiterY,
1323
+ orbiterZ * this.gridScale,
1324
+ );
1325
+
1326
+ const screenX = cx + p.x;
1327
+ const screenY = cy + p.y;
1328
+ const size = 5 * p.scale;
1329
+
1330
+ // Glow
1331
+ Painter.useCtx((ctx) => {
1332
+ ctx.globalAlpha = orbiterAlpha;
1333
+ const gradient = ctx.createRadialGradient(
1334
+ screenX,
1335
+ screenY,
1336
+ 0,
1337
+ screenX,
1338
+ screenY,
1339
+ size * 4,
1340
+ );
1341
+ gradient.addColorStop(0, CONFIG.orbiterGlow);
1342
+ gradient.addColorStop(1, "transparent");
1343
+ ctx.fillStyle = gradient;
1344
+ ctx.beginPath();
1345
+ ctx.arc(screenX, screenY, size * 4, 0, Math.PI * 2);
1346
+ ctx.fill();
1347
+ ctx.globalAlpha = 1;
1348
+ });
1349
+
1350
+ // Body
1351
+ Painter.useCtx((ctx) => {
1352
+ ctx.globalAlpha = orbiterAlpha;
1353
+ const gradient = ctx.createRadialGradient(
1354
+ screenX - size * 0.3,
1355
+ screenY - size * 0.3,
1356
+ 0,
1357
+ screenX,
1358
+ screenY,
1359
+ size,
1360
+ );
1361
+ gradient.addColorStop(0, "#fff");
1362
+ gradient.addColorStop(0.5, CONFIG.orbiterColor);
1363
+ gradient.addColorStop(1, CONFIG.orbiterGlow);
1364
+ ctx.fillStyle = gradient;
1365
+ ctx.beginPath();
1366
+ ctx.arc(screenX, screenY, size, 0, Math.PI * 2);
1367
+ ctx.fill();
1368
+ ctx.globalAlpha = 1;
1369
+ });
1370
+
1371
+ this.drawOrbitPath(cx, cy, orbiterAlpha);
1372
+ this.drawOrbitalTrail(cx, cy, orbiterAlpha);
1373
+ }
1374
+
1375
+ drawOrbitPath(cx, cy, alpha = 1) {
1376
+ const segments = 64;
1377
+ const eccentricity = CONFIG.orbitEccentricity;
1378
+
1379
+ Painter.useCtx((ctx) => {
1380
+ ctx.globalAlpha = alpha;
1381
+ ctx.strokeStyle = "rgba(100, 180, 255, 0.25)";
1382
+ ctx.lineWidth = 1.5;
1383
+ ctx.beginPath();
1384
+
1385
+ for (let i = 0; i <= segments; i++) {
1386
+ const angle = (i / segments) * Math.PI * 2 + this.precessionAngle;
1387
+ const phi = (i / segments) * Math.PI * 2;
1388
+ const radialOscillation = eccentricity * Math.sin(phi * 2);
1389
+ const r = CONFIG.orbitSemiMajor + radialOscillation * 2;
1390
+
1391
+ const x = Math.cos(angle) * r;
1392
+ const z = Math.sin(angle) * r;
1393
+ const y = this.getEmbeddingHeight(r);
1394
+
1395
+ const p = this.camera.project(
1396
+ x * this.gridScale,
1397
+ y,
1398
+ z * this.gridScale,
1399
+ );
1400
+
1401
+ if (i === 0) ctx.moveTo(cx + p.x, cy + p.y);
1402
+ else ctx.lineTo(cx + p.x, cy + p.y);
1403
+ }
1404
+
1405
+ ctx.closePath();
1406
+ ctx.stroke();
1407
+ ctx.globalAlpha = 1;
1408
+ });
1409
+ }
1410
+
1411
+ drawOrbitalTrail(cx, cy, fadeAlpha = 1) {
1412
+ if (this.orbitTrail.length < 2) return;
1413
+
1414
+ Painter.useCtx((ctx) => {
1415
+ ctx.lineCap = "round";
1416
+
1417
+ for (let i = 1; i < this.orbitTrail.length; i++) {
1418
+ const t = i / this.orbitTrail.length;
1419
+ const point = this.orbitTrail[i];
1420
+ const prevPoint = this.orbitTrail[i - 1];
1421
+
1422
+ const trailY = this.getEmbeddingHeight(point.r);
1423
+ const prevY = this.getEmbeddingHeight(prevPoint.r);
1424
+
1425
+ const p = this.camera.project(
1426
+ point.x * this.gridScale,
1427
+ trailY,
1428
+ point.z * this.gridScale,
1429
+ );
1430
+
1431
+ const prevP = this.camera.project(
1432
+ prevPoint.x * this.gridScale,
1433
+ prevY,
1434
+ prevPoint.z * this.gridScale,
1435
+ );
1436
+
1437
+ const alpha = (1 - t) * 0.5 * fadeAlpha;
1438
+ ctx.strokeStyle = `rgba(100, 180, 255, ${alpha})`;
1439
+ ctx.lineWidth = (1 - t) * 2.5 * p.scale;
1440
+ ctx.beginPath();
1441
+ ctx.moveTo(cx + prevP.x, cy + prevP.y);
1442
+ ctx.lineTo(cx + p.x, cy + p.y);
1443
+ ctx.stroke();
1444
+ }
1445
+ });
1446
+ }
1447
+
1448
+ drawEffectivePotential() {
1449
+ // Responsive graph sizing
1450
+ const isMobile = this.width < CONFIG.mobileWidth;
1451
+ const graphW = isMobile ? 120 : 160;
1452
+ const graphH = isMobile ? 70 : 100;
1453
+ const graphX = this.width - graphW - (isMobile ? 15 : 20);
1454
+ const graphY = isMobile ? 80 : 220; // Desktop moved down to avoid info div
1455
+ const M = this.mass;
1456
+ const a = this.spin;
1457
+
1458
+ Painter.useCtx((ctx) => {
1459
+ // Background
1460
+ ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
1461
+ ctx.fillRect(graphX - 10, graphY - 10, graphW + 20, graphH + 40);
1462
+
1463
+ // Title
1464
+ ctx.fillStyle = "#ccc"; // Brightened from #888
1465
+ ctx.font = "10px monospace";
1466
+ ctx.textAlign = "center";
1467
+ ctx.fillText("Kerr Effective Potential", graphX + graphW / 2, graphY);
1468
+
1469
+ // Axes
1470
+ ctx.strokeStyle = "#444";
1471
+ ctx.lineWidth = 1;
1472
+ ctx.beginPath();
1473
+ ctx.moveTo(graphX, graphY + graphH);
1474
+ ctx.lineTo(graphX + graphW, graphY + graphH);
1475
+ ctx.moveTo(graphX, graphY + 10);
1476
+ ctx.lineTo(graphX, graphY + graphH);
1477
+ ctx.stroke();
1478
+
1479
+ // Labels
1480
+ ctx.fillStyle = "#aaa"; // Brightened from #666
1481
+ ctx.font = "10px monospace";
1482
+ ctx.textAlign = "left";
1483
+ ctx.fillText("r", graphX + graphW - 10, graphY + graphH + 12);
1484
+
1485
+ // Plot V_eff (using Schwarzschild approximation for display)
1486
+ const rPlus = Tensor.kerrHorizonRadius(M, a, false);
1487
+ const rMin = rPlus * 1.2;
1488
+ const rMax = 20;
1489
+
1490
+ ctx.strokeStyle = "#8f8";
1491
+ ctx.lineWidth = 1.5;
1492
+ ctx.beginPath();
1493
+
1494
+ let firstPoint = true;
1495
+ for (let i = 0; i <= 100; i++) {
1496
+ const r = rMin + (i / 100) * (rMax - rMin);
1497
+ const V = Tensor.effectivePotential(M, this.orbitL, r);
1498
+
1499
+ const px = graphX + ((r - rMin) / (rMax - rMin)) * graphW;
1500
+ const py = graphY + graphH - 20 - (V + 0.1) * 300;
1501
+
1502
+ if (py > graphY + 10 && py < graphY + graphH) {
1503
+ if (firstPoint) {
1504
+ ctx.moveTo(px, py);
1505
+ firstPoint = false;
1506
+ } else {
1507
+ ctx.lineTo(px, py);
1508
+ }
1509
+ }
1510
+ }
1511
+ ctx.stroke();
1512
+
1513
+ // Current position
1514
+ const currentPx =
1515
+ graphX + ((this.orbitR - rMin) / (rMax - rMin)) * graphW;
1516
+ const currentV = Tensor.effectivePotential(M, this.orbitL, this.orbitR);
1517
+ const currentPy = graphY + graphH - 20 - (currentV + 0.1) * 300;
1518
+
1519
+ if (currentPy > graphY + 10 && currentPy < graphY + graphH) {
1520
+ ctx.fillStyle = CONFIG.orbiterColor;
1521
+ ctx.beginPath();
1522
+ ctx.arc(currentPx, currentPy, 4, 0, Math.PI * 2);
1523
+ ctx.fill();
1524
+ }
1525
+
1526
+ // Mark prograde ISCO
1527
+ const iscoP = Tensor.kerrISCO(M, a, true);
1528
+ const iscoPx = graphX + ((iscoP - rMin) / (rMax - rMin)) * graphW;
1529
+ ctx.strokeStyle = CONFIG.progradeISCOColor;
1530
+ ctx.setLineDash([2, 2]);
1531
+ ctx.beginPath();
1532
+ ctx.moveTo(iscoPx, graphY + 10);
1533
+ ctx.lineTo(iscoPx, graphY + graphH);
1534
+ ctx.stroke();
1535
+
1536
+ // Mark retrograde ISCO
1537
+ const iscoR = Tensor.kerrISCO(M, a, false);
1538
+ const iscoRx = graphX + ((iscoR - rMin) / (rMax - rMin)) * graphW;
1539
+ if (iscoRx < graphX + graphW) {
1540
+ ctx.strokeStyle = CONFIG.retrogradeISCOColor;
1541
+ ctx.beginPath();
1542
+ ctx.moveTo(iscoRx, graphY + 10);
1543
+ ctx.lineTo(iscoRx, graphY + graphH);
1544
+ ctx.stroke();
1545
+ }
1546
+
1547
+ ctx.setLineDash([]);
1548
+ });
1549
+ }
1550
+ }
1551
+
1552
+ window.addEventListener("load", () => {
1553
+ const canvas = document.getElementById("game");
1554
+ const demo = new KerrDemo(canvas);
1555
+ demo.start();
1556
+ });