@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,1023 @@
1
+ /**
2
+ * Schwarzschild Metric - General Relativity Demo
3
+ *
4
+ * Visualization of the Schwarzschild solution to Einstein's field equations.
5
+ * Shows the metric tensor components and geodesic motion with orbital precession.
6
+ *
7
+ * Metric: ds² = -(1-rs/r)c²dt² + (1-rs/r)⁻¹dr² + r²dΩ²
8
+ * where rs = 2GM/c² is the Schwarzschild radius
9
+ */
10
+
11
+ import { Game, Painter, Camera3D } from "../../src/index.js";
12
+ import { GameObject } from "../../src/game/objects/go.js";
13
+ import { Rectangle } from "../../src/shapes/rect.js";
14
+ import { TextShape } from "../../src/shapes/text.js";
15
+ import { Position } from "../../src/util/position.js";
16
+ import { Tensor } from "../../src/math/tensor.js";
17
+ import { flammEmbeddingHeight } from "../../src/math/gr.js";
18
+ import {
19
+ keplerianOmega,
20
+ schwarzschildPrecessionRate,
21
+ orbitalRadiusSimple,
22
+ updateTrail,
23
+ createTrailPoint,
24
+ } from "../../src/math/orbital.js";
25
+ import { verticalLayout, applyLayout } from "../../src/util/layout.js";
26
+ import { Tooltip } from "../../src/game/ui/tooltip.js";
27
+ import { Button } from "../../src/game/ui/button.js";
28
+
29
+ // Configuration
30
+ const CONFIG = {
31
+ // Grid parameters - FULLSCREEN
32
+ gridSize: 20,
33
+ gridResolution: 100, // Denser grid for better coverage
34
+ baseGridScale: 12, // Base scale, will be multiplied to fill screen
35
+
36
+ // Mobile breakpoint
37
+ mobileWidth: 600,
38
+
39
+ // Physics (geometrized units: G = c = 1)
40
+ schwarzschildRadius: 2.0, // rs = 2M in geometrized units
41
+ massRange: [1.0, 4.0], // Mass range for shuffling
42
+
43
+ // Embedding diagram - visible funnel depth
44
+ embeddingScale: 180, // Deeper funnel like Kerr
45
+
46
+ // 3D view
47
+ rotationX: 0.5,
48
+ rotationY: 0.3,
49
+ perspective: 900, // Match Kerr for similar depth perception
50
+
51
+ // Orbit parameters
52
+ orbitSemiMajor: 10, // Semi-major axis (in units of M)
53
+ orbitEccentricity: 0.3, // Orbital eccentricity
54
+ angularMomentum: 4.0, // Specific angular momentum L/m
55
+
56
+ // Animation
57
+ autoRotateSpeed: 0.1,
58
+ orbitSpeed: 0.5, // Base orbital angular velocity
59
+ precessionFactor: 0.15, // GR precession rate
60
+
61
+ // Black hole visualization - mass-proportional sizing (rubber sheet analogy)
62
+ // "Heavier objects dent the fabric more" - intuitive for users
63
+ blackHoleSizeBase: 8, // Base size of black hole sphere
64
+ blackHoleSizeMassScale: 6, // Additional size per unit mass
65
+
66
+ // Visual
67
+ gridColor: "rgba(0, 180, 255, 0.3)",
68
+ gridHighlight: "rgba(100, 220, 255, 0.5)",
69
+ horizonColor: "rgba(255, 50, 50, 0.8)",
70
+ photonSphereColor: "rgba(255, 200, 50, 0.6)",
71
+ iscoColor: "rgba(50, 255, 150, 0.6)",
72
+ orbiterColor: "#4af",
73
+ orbiterGlow: "rgba(100, 180, 255, 0.6)",
74
+ };
75
+
76
+ /**
77
+ * MetricPanelGO - Displays the Schwarzschild metric tensor components
78
+ * Uses verticalLayout for automatic positioning
79
+ * Responsive for mobile screens
80
+ */
81
+ class MetricPanelGO extends GameObject {
82
+ constructor(game, options = {}) {
83
+ // Responsive sizing
84
+ const isMobile = game.width < CONFIG.mobileWidth;
85
+ const panelWidth = isMobile ? 240 : 320;
86
+ const panelHeight = isMobile ? 130 : 150;
87
+ const lineHeight = isMobile ? 14 : 16;
88
+ const valueOffset = isMobile ? 125 : 160;
89
+
90
+ super(game, {
91
+ ...options,
92
+ width: panelWidth,
93
+ height: panelHeight,
94
+ anchor: Position.BOTTOM_LEFT,
95
+ });
96
+
97
+ // Background
98
+ this.bgRect = new Rectangle({
99
+ width: panelWidth,
100
+ height: panelHeight,
101
+ color: "rgba(0, 0, 0, 0.7)",
102
+ });
103
+
104
+ // Define all features as data with descriptions for tooltips
105
+ this.features = {
106
+ title: {
107
+ text: "Schwarzschild Metric Tensor",
108
+ font: "bold 13px monospace",
109
+ color: "#7af",
110
+ height: lineHeight + 4,
111
+ desc: "The Schwarzschild metric describes spacetime geometry around a non-rotating, spherically symmetric mass. It was the first exact solution to Einstein's field equations (1916).",
112
+ },
113
+ equation: {
114
+ text: "ds² = gμν dxμ dxν",
115
+ font: "12px monospace",
116
+ color: "#888",
117
+ height: lineHeight,
118
+ desc: "The line element ds² measures spacetime intervals. It uses the metric tensor gμν to convert coordinate differences into proper distances/times.",
119
+ },
120
+ mass: {
121
+ text: "M = 1.00",
122
+ font: "12px monospace",
123
+ color: "#888",
124
+ height: lineHeight + 8,
125
+ desc: "Mass of the black hole (in geometrized units where G = c = 1).\nClick anywhere to randomize between 1.0 and 4.0.",
126
+ },
127
+ gtt: {
128
+ text: "g_tt = -(1 - rs/r)",
129
+ font: "11px monospace",
130
+ color: "#f88",
131
+ height: lineHeight,
132
+ value: "= -0.800",
133
+ desc: "Time-time component: Controls how time flows.\nNegative sign indicates timelike direction.\nApproaches 0 at the event horizon (time freezes for distant observers).",
134
+ },
135
+ grr: {
136
+ text: "g_rr = (1 - rs/r)⁻¹",
137
+ font: "11px monospace",
138
+ color: "#8f8",
139
+ height: lineHeight,
140
+ value: "= 1.250",
141
+ desc: "Radial-radial component: Controls radial distances.\nDiverges at rs (coordinate singularity).\nRadial distances stretch near the black hole.",
142
+ },
143
+ gthth: {
144
+ text: "g_θθ = r²",
145
+ font: "11px monospace",
146
+ color: "#88f",
147
+ height: lineHeight,
148
+ value: "= 100.00",
149
+ desc: "Theta-theta component: Angular metric in the polar direction.\nSame as flat space - angles are unaffected by the mass.",
150
+ },
151
+ gphph: {
152
+ text: "g_φφ = r²sin²θ",
153
+ font: "11px monospace",
154
+ color: "#f8f",
155
+ height: lineHeight + 8,
156
+ value: "= 100.00",
157
+ desc: "Phi-phi component: Angular metric in azimuthal direction.\nAt equator (θ=π/2), sin²θ = 1.\nSpherical symmetry preserved.",
158
+ },
159
+ rs: {
160
+ text: "rs = 2M = 2.00",
161
+ font: "10px monospace",
162
+ color: "#f55",
163
+ height: lineHeight - 2,
164
+ desc: "Schwarzschild Radius (Event Horizon)\nThe point of no return - even light cannot escape from within.\nFor the Sun: rs ≈ 3 km. For Earth: rs ≈ 9 mm.",
165
+ },
166
+ rph: {
167
+ text: "r_photon = 1.5rs = 3.00",
168
+ font: "10px monospace",
169
+ color: "#fa5",
170
+ height: lineHeight - 2,
171
+ desc: "Photon Sphere\nUnstable circular orbit for light.\nPhotons can orbit here, but any perturbation sends them spiraling in or out.",
172
+ },
173
+ risco: {
174
+ text: "r_ISCO = 3rs = 6.00",
175
+ font: "10px monospace",
176
+ color: "#5f8",
177
+ height: lineHeight + 8,
178
+ desc: "Innermost Stable Circular Orbit (ISCO)\nThe closest stable orbit for massive particles.\nWithin this radius, orbits require constant thrust to maintain.",
179
+ },
180
+ pos: {
181
+ text: "Orbiter: r = 10.00, φ = 0.00",
182
+ font: "10px monospace",
183
+ color: "#aaa",
184
+ height: lineHeight,
185
+ desc: "Current position of the test particle in Schwarzschild coordinates.\nr = radial distance, φ = orbital angle.",
186
+ },
187
+ };
188
+
189
+ // Store panel dimensions for hit testing
190
+ this.panelWidth = panelWidth;
191
+ this.panelHeight = panelHeight;
192
+
193
+ // Create TextShapes from features
194
+ const rowItems = [];
195
+ for (const [key, config] of Object.entries(this.features)) {
196
+ config.shape = new TextShape(config.text, {
197
+ font: config.font,
198
+ color: config.color,
199
+ align: "left",
200
+ baseline: "top",
201
+ height: config.height,
202
+ });
203
+ rowItems.push(config.shape);
204
+
205
+ if (config.value) {
206
+ config.valueShape = new TextShape(config.value, {
207
+ font: config.font,
208
+ color: "#fff",
209
+ align: "left",
210
+ baseline: "top",
211
+ });
212
+ }
213
+ }
214
+
215
+ // Apply vertical layout
216
+ const layout = verticalLayout(rowItems, {
217
+ spacing: 5,
218
+ padding: 0,
219
+ align: "start",
220
+ centerItems: false,
221
+ });
222
+ applyLayout(rowItems, layout.positions, {
223
+ offsetX: -panelWidth / 2,
224
+ offsetY: -panelHeight / 2,
225
+ });
226
+
227
+ // Position value shapes next to their labels
228
+ for (const config of Object.values(this.features)) {
229
+ if (config.valueShape) {
230
+ config.valueShape.x = config.shape.x + valueOffset;
231
+ config.valueShape.y = config.shape.y;
232
+ }
233
+ }
234
+ }
235
+
236
+ setMetricValues(r, rs, mass, theta = Math.PI / 2) {
237
+ const metric = Tensor.schwarzschild(r, rs, theta);
238
+ const f = this.features;
239
+
240
+ f.gtt.valueShape.text = `= ${metric.get(0, 0).toFixed(4)}`;
241
+ f.grr.valueShape.text = `= ${metric.get(1, 1).toFixed(4)}`;
242
+ f.gthth.valueShape.text = `= ${metric.get(2, 2).toFixed(2)}`;
243
+ f.gphph.valueShape.text = `= ${metric.get(3, 3).toFixed(2)}`;
244
+
245
+ f.mass.shape.text = `M = ${mass.toFixed(2)}`;
246
+ f.rs.shape.text = `rs = 2M = ${rs.toFixed(2)}`;
247
+ f.rph.shape.text = `r_photon = 1.5rs = ${Tensor.photonSphereRadius(rs).toFixed(2)}`;
248
+ f.risco.shape.text = `r_ISCO = 3rs = ${Tensor.iscoRadius(rs).toFixed(2)}`;
249
+ }
250
+
251
+ setOrbiterPosition(r, phi) {
252
+ this.features.pos.shape.text = `Orbiter: r = ${r.toFixed(2)}, φ = ${(phi % (2 * Math.PI)).toFixed(2)}`;
253
+ }
254
+
255
+ /**
256
+ * Get the feature at a given screen position (for tooltip hit testing).
257
+ * @param {number} screenX - Screen X coordinate
258
+ * @param {number} screenY - Screen Y coordinate
259
+ * @returns {object|null} Feature config with desc, or null if not over panel
260
+ */
261
+ getFeatureAt(screenX, screenY) {
262
+ // Convert screen coords to local panel coords
263
+ const localX = screenX - this.x;
264
+ const localY = screenY - this.y;
265
+
266
+ // Check if within panel bounds
267
+ if (
268
+ localX < -this.panelWidth / 2 ||
269
+ localX > this.panelWidth / 2 ||
270
+ localY < -this.panelHeight / 2 ||
271
+ localY > this.panelHeight / 2
272
+ ) {
273
+ return null;
274
+ }
275
+
276
+ // Find which feature row we're over
277
+ for (const config of Object.values(this.features)) {
278
+ const shape = config.shape;
279
+ const rowTop = shape.y;
280
+ const rowBottom = shape.y + (config.height || 16);
281
+
282
+ if (localY >= rowTop && localY <= rowBottom) {
283
+ return config;
284
+ }
285
+ }
286
+
287
+ return null;
288
+ }
289
+
290
+ draw() {
291
+ super.draw();
292
+ this.bgRect.render();
293
+
294
+ for (const config of Object.values(this.features)) {
295
+ config.shape.render();
296
+ if (config.valueShape) config.valueShape.render();
297
+ }
298
+ }
299
+ }
300
+
301
+ class SchwarzschildDemo extends Game {
302
+ constructor(canvas) {
303
+ super(canvas);
304
+ // Black background - it's space!
305
+ this.backgroundColor = "#000";
306
+ this.enableFluidSize();
307
+ }
308
+
309
+ init() {
310
+ super.init();
311
+ this.time = 0;
312
+
313
+ // Mass (in geometrized units where G = c = 1)
314
+ this.mass = 1.0;
315
+ this.rs = 2 * this.mass; // Schwarzschild radius
316
+
317
+ // Initialize grid scale (will be updated for screen size)
318
+ this.gridScale = CONFIG.baseGridScale;
319
+
320
+ // Camera with inertia for smooth drag
321
+ this.camera = new Camera3D({
322
+ rotationX: CONFIG.rotationX,
323
+ rotationY: CONFIG.rotationY,
324
+ perspective: CONFIG.perspective,
325
+ minRotationX: -0.5,
326
+ maxRotationX: 1.5,
327
+ autoRotate: true,
328
+ autoRotateSpeed: CONFIG.autoRotateSpeed,
329
+ autoRotateAxis: "y",
330
+ inertia: true,
331
+ friction: 0.95,
332
+ velocityScale: 2.0,
333
+ });
334
+ this.camera.enableMouseControl(this.canvas);
335
+
336
+ // Orbital state (using r, phi in equatorial plane)
337
+ this.orbitR = CONFIG.orbitSemiMajor;
338
+ this.orbitPhi = 0;
339
+ this.orbitVr = 0; // Radial velocity
340
+ this.orbitL = CONFIG.angularMomentum; // Angular momentum per unit mass
341
+ this.precessionAngle = 0;
342
+
343
+ // Trail stores actual positions
344
+ this.orbitTrail = [];
345
+
346
+ // Initialize grid vertices
347
+ this.initGrid();
348
+
349
+ // Grid scale responsive to screen size
350
+ this.updateGridScale();
351
+
352
+ // Create metric panel
353
+ this.metricPanel = new MetricPanelGO(this, { name: "metricPanel" });
354
+ this.pipeline.add(this.metricPanel);
355
+
356
+ // Create tooltip for explanations (responsive)
357
+ const isMobileTooltip = this.width < CONFIG.mobileWidth;
358
+ this.tooltip = new Tooltip(this, {
359
+ maxWidth: isMobileTooltip ? 200 : 280,
360
+ font: `${isMobileTooltip ? 9 : 11}px monospace`,
361
+ padding: isMobileTooltip ? 6 : 10,
362
+ bgColor: "rgba(20, 20, 30, 0.95)",
363
+ });
364
+ this.pipeline.add(this.tooltip);
365
+
366
+ // Track what's being hovered for tooltip
367
+ this.hoveredFeature = null;
368
+
369
+ // Mouse move for tooltip
370
+ this.canvas.addEventListener("mousemove", (e) => this.handleMouseMove(e));
371
+ this.canvas.addEventListener("mouseleave", () => this.tooltip.hide());
372
+
373
+ // Button to shuffle parameters (positioned below the chart, same width)
374
+ const isMobile = this.width < CONFIG.mobileWidth;
375
+ const graphW = isMobile ? 120 : 160;
376
+ const graphH = isMobile ? 70 : 100;
377
+ const graphX = this.width - graphW - (isMobile ? 15 : 20);
378
+ const graphY = isMobile ? 80 : 220; // Desktop moved down to avoid info div
379
+
380
+ this.shuffleBtn = new Button(this, {
381
+ width: graphW,
382
+ height: isMobile ? 30 : 36,
383
+ anchor: Position.TOP_LEFT,
384
+ anchorRelative: this.metricPanel,
385
+ anchorOffsetX: -10,
386
+ anchorOffsetY: -60,
387
+ text: "Shuffle Mass",
388
+ font: `${isMobile ? 10 : 12}px monospace`,
389
+ colorDefaultBg: "rgba(20, 20, 40, 0.8)",
390
+ colorDefaultStroke: "#7af",
391
+ colorDefaultText: "#8af",
392
+ colorHoverBg: "rgba(40, 30, 60, 0.9)",
393
+ colorHoverStroke: "#aff",
394
+ colorHoverText: "#aff",
395
+ colorPressedBg: "rgba(60, 40, 80, 1)",
396
+ colorPressedStroke: "#fff",
397
+ colorPressedText: "#fff",
398
+ onClick: () => this.shuffleParameters(),
399
+ });
400
+ this.pipeline.add(this.shuffleBtn);
401
+ }
402
+
403
+ handleMouseMove(e) {
404
+ const rect = this.canvas.getBoundingClientRect();
405
+ const mouseX = e.clientX - rect.left;
406
+ const mouseY = e.clientY - rect.top;
407
+
408
+ // Check if over metric panel
409
+ const feature = this.metricPanel.getFeatureAt(mouseX, mouseY);
410
+ if (feature && feature.desc) {
411
+ if (this.hoveredFeature !== feature) {
412
+ this.hoveredFeature = feature;
413
+ this.tooltip.show(feature.desc, mouseX, mouseY);
414
+ }
415
+ return;
416
+ }
417
+
418
+ // Check if over effective potential graph (responsive)
419
+ const isMobile = this.width < CONFIG.mobileWidth;
420
+ const graphW = isMobile ? 120 : 160;
421
+ const graphH = isMobile ? 70 : 100;
422
+ const graphX = this.width - graphW - (isMobile ? 15 : 20);
423
+ const graphY = isMobile ? 80 : 220; // Desktop moved down to avoid info div
424
+
425
+ if (
426
+ mouseX >= graphX - 10 &&
427
+ mouseX <= graphX + graphW + 10 &&
428
+ mouseY >= graphY - 10 &&
429
+ mouseY <= graphY + graphH + 30
430
+ ) {
431
+ if (this.hoveredFeature !== "graph") {
432
+ this.hoveredFeature = "graph";
433
+ this.tooltip.show(
434
+ "Effective Potential V_eff(r)\n\nShows the combined gravitational and centrifugal potential.\n\nThe blue dot marks the orbiter's current position.\n\nLocal minima = stable orbits\nLocal maxima = unstable orbits\n\nThe GR term (-ML²/r³) creates the inner peak that doesn't exist in Newtonian gravity.",
435
+ mouseX,
436
+ mouseY,
437
+ );
438
+ }
439
+ return;
440
+ }
441
+
442
+ // Not over anything - hide tooltip
443
+ if (this.hoveredFeature) {
444
+ this.hoveredFeature = null;
445
+ this.tooltip.hide();
446
+ }
447
+ }
448
+
449
+ initGrid() {
450
+ const { gridSize, gridResolution } = CONFIG;
451
+ this.gridVertices = [];
452
+
453
+ for (let i = 0; i <= gridResolution; i++) {
454
+ const row = [];
455
+ for (let j = 0; j <= gridResolution; j++) {
456
+ const x = (i / gridResolution - 0.5) * 2 * gridSize;
457
+ const z = (j / gridResolution - 0.5) * 2 * gridSize;
458
+ row.push({ x, y: 0, z });
459
+ }
460
+ this.gridVertices.push(row);
461
+ }
462
+ }
463
+
464
+ updateGridScale() {
465
+ // Scale grid to show edges - same behavior as kerr.js
466
+ const minDim = Math.min(this.width, this.height);
467
+ this.gridScale = (minDim / (CONFIG.gridSize * 2)) * 1.5;
468
+ }
469
+
470
+ shuffleParameters() {
471
+ // Randomize mass
472
+ this.mass =
473
+ CONFIG.massRange[0] +
474
+ Math.random() * (CONFIG.massRange[1] - CONFIG.massRange[0]);
475
+ this.rs = 2 * this.mass;
476
+
477
+ // Randomize orbit (keep it outside ISCO using Tensor utility)
478
+ const isco = Tensor.iscoRadius(this.rs);
479
+ this.orbitR = isco + 2 + Math.random() * 8;
480
+ this.orbitPhi = Math.random() * Math.PI * 2;
481
+ this.orbitL = 3.5 + Math.random() * 2;
482
+ this.precessionAngle = 0;
483
+
484
+ // Clear trail for fresh start
485
+ this.orbitTrail = [];
486
+ }
487
+
488
+ /**
489
+ * Flamm's paraboloid embedding using shared gr.js module.
490
+ * Inverted so it looks like a gravity well going DOWN.
491
+ */
492
+ getEmbeddingHeight(r) {
493
+ return flammEmbeddingHeight(
494
+ r,
495
+ this.rs,
496
+ this.mass,
497
+ CONFIG.gridSize,
498
+ CONFIG.embeddingScale,
499
+ );
500
+ }
501
+
502
+ /**
503
+ * Effective potential for geodesic motion
504
+ * V_eff = -M/r + L²/(2r²) - ML²/r³
505
+ * Uses Tensor.effectivePotential static utility
506
+ */
507
+ effectivePotential(r) {
508
+ return Tensor.effectivePotential(this.mass, this.orbitL, r);
509
+ }
510
+
511
+ /**
512
+ * Update geodesic motion using orbital.js utilities.
513
+ * Simplified for visualization while maintaining GR character.
514
+ */
515
+ updateGeodesic(dt) {
516
+ const r = this.orbitR;
517
+
518
+ // Kepler's 3rd law angular velocity
519
+ const baseOmega = keplerianOmega(r, this.mass, CONFIG.orbitSpeed);
520
+
521
+ // Update orbital angle
522
+ this.orbitPhi += baseOmega * dt;
523
+
524
+ // Radial oscillation for eccentricity effect
525
+ this.orbitR = orbitalRadiusSimple(
526
+ CONFIG.orbitSemiMajor,
527
+ CONFIG.orbitEccentricity,
528
+ this.orbitPhi,
529
+ );
530
+
531
+ // Keep orbit bounded outside ISCO
532
+ const minR = Tensor.iscoRadius(this.rs) + 1;
533
+ if (this.orbitR < minR) this.orbitR = minR;
534
+
535
+ // GR precession: orbit doesn't close, rotates over time
536
+ const precessionRate = schwarzschildPrecessionRate(
537
+ r,
538
+ this.rs,
539
+ CONFIG.precessionFactor,
540
+ );
541
+ this.precessionAngle += precessionRate * dt;
542
+
543
+ // Store current position in trail
544
+ const totalAngle = this.orbitPhi + this.precessionAngle;
545
+ updateTrail(this.orbitTrail, createTrailPoint(this.orbitR, totalAngle), 80);
546
+ }
547
+
548
+ update(dt) {
549
+ super.update(dt);
550
+ this.time += dt;
551
+
552
+ this.camera.update(dt);
553
+ this.updateGeodesic(dt);
554
+ this.updateGridScale(); // Keep grid responsive
555
+
556
+ // Update grid with Flamm's paraboloid embedding
557
+ const { gridResolution } = CONFIG;
558
+ for (let i = 0; i <= gridResolution; i++) {
559
+ for (let j = 0; j <= gridResolution; j++) {
560
+ const vertex = this.gridVertices[i][j];
561
+ // Function already clamps at horizon, no need for extra clamp here
562
+ const r = Math.sqrt(vertex.x * vertex.x + vertex.z * vertex.z);
563
+ vertex.y = this.getEmbeddingHeight(r);
564
+ }
565
+ }
566
+
567
+ // Update metric panel
568
+ if (this.metricPanel) {
569
+ this.metricPanel.setMetricValues(this.orbitR, this.rs, this.mass);
570
+ this.metricPanel.setOrbiterPosition(this.orbitR, this.orbitPhi);
571
+ }
572
+ }
573
+
574
+ render() {
575
+ const w = this.width;
576
+ const h = this.height;
577
+ const cx = w / 2;
578
+ const cy = h / 2; // Centered to see full well depth
579
+
580
+ super.render();
581
+
582
+ // Draw key radii circles
583
+ this.drawKeyRadii(cx, cy);
584
+
585
+ // Draw grid
586
+ this.drawGrid(cx, cy);
587
+
588
+ // Draw event horizon
589
+ this.drawHorizon(cx, cy);
590
+
591
+ // Draw orbiter
592
+ this.drawOrbiter(cx, cy);
593
+
594
+ // Draw effective potential graph
595
+ this.drawEffectivePotential();
596
+
597
+ // Draw controls
598
+ this.drawControls(w, h);
599
+ }
600
+
601
+ drawKeyRadii(cx, cy) {
602
+ const radii = [
603
+ { r: this.rs, color: CONFIG.horizonColor, label: "rs" },
604
+ { r: this.rs * 1.5, color: CONFIG.photonSphereColor, label: "r_ph" },
605
+ { r: this.rs * 3, color: CONFIG.iscoColor, label: "ISCO" },
606
+ ];
607
+
608
+ for (const { r, color, label } of radii) {
609
+ const segments = 48;
610
+ Painter.useCtx((ctx) => {
611
+ ctx.strokeStyle = color;
612
+ ctx.lineWidth = 1.5;
613
+ ctx.setLineDash([5, 5]);
614
+ ctx.beginPath();
615
+
616
+ for (let i = 0; i <= segments; i++) {
617
+ const angle = (i / segments) * Math.PI * 2;
618
+ const x = Math.cos(angle) * r;
619
+ const z = Math.sin(angle) * r;
620
+ const y = this.getEmbeddingHeight(r);
621
+
622
+ const p = this.camera.project(
623
+ x * this.gridScale,
624
+ y,
625
+ z * this.gridScale,
626
+ );
627
+
628
+ if (i === 0) {
629
+ ctx.moveTo(cx + p.x, cy + p.y);
630
+ } else {
631
+ ctx.lineTo(cx + p.x, cy + p.y);
632
+ }
633
+ }
634
+ ctx.stroke();
635
+ ctx.setLineDash([]);
636
+ });
637
+ }
638
+ }
639
+
640
+ drawGrid(cx, cy) {
641
+ const { gridResolution, gridColor, gridHighlight } = CONFIG;
642
+ const gridScale = this.gridScale;
643
+
644
+ const projected = this.gridVertices.map((row) =>
645
+ row.map((v) => {
646
+ const p = this.camera.project(v.x * gridScale, v.y, v.z * gridScale);
647
+ return { x: cx + p.x, y: cy + p.y, z: p.z };
648
+ }),
649
+ );
650
+
651
+ // Draw grid lines
652
+ for (let i = 0; i <= gridResolution; i++) {
653
+ const isMain = i % 5 === 0;
654
+ Painter.useCtx((ctx) => {
655
+ ctx.strokeStyle = isMain ? gridHighlight : gridColor;
656
+ ctx.lineWidth = isMain ? 1.2 : 0.6;
657
+ ctx.beginPath();
658
+ for (let j = 0; j <= gridResolution; j++) {
659
+ const p = projected[i][j];
660
+ if (j === 0) ctx.moveTo(p.x, p.y);
661
+ else ctx.lineTo(p.x, p.y);
662
+ }
663
+ ctx.stroke();
664
+ });
665
+ }
666
+
667
+ for (let j = 0; j <= gridResolution; j++) {
668
+ const isMain = j % 5 === 0;
669
+ Painter.useCtx((ctx) => {
670
+ ctx.strokeStyle = isMain ? gridHighlight : gridColor;
671
+ ctx.lineWidth = isMain ? 1.2 : 0.6;
672
+ ctx.beginPath();
673
+ for (let i = 0; i <= gridResolution; i++) {
674
+ const p = projected[i][j];
675
+ if (i === 0) ctx.moveTo(p.x, p.y);
676
+ else ctx.lineTo(p.x, p.y);
677
+ }
678
+ ctx.stroke();
679
+ });
680
+ }
681
+ }
682
+
683
+ drawHorizon(cx, cy) {
684
+ // Draw filled event horizon - the BLACK hole
685
+ const segments = 32;
686
+ const r = this.rs;
687
+ const y = this.getEmbeddingHeight(r + 0.1);
688
+
689
+ // Project center for black hole body
690
+ const centerP = this.camera.project(0, y + 10, 0);
691
+ const centerX = cx + centerP.x;
692
+ const centerY = cy + centerP.y;
693
+
694
+ // Mass-proportional sizing: heavier = bigger (rubber sheet intuition)
695
+ const baseSize =
696
+ CONFIG.blackHoleSizeBase + this.mass * CONFIG.blackHoleSizeMassScale;
697
+ const size = baseSize * centerP.scale;
698
+
699
+ // Draw dark glow around black hole
700
+ Painter.useCtx((ctx) => {
701
+ const gradient = ctx.createRadialGradient(
702
+ centerX,
703
+ centerY,
704
+ size,
705
+ centerX,
706
+ centerY,
707
+ size * 3,
708
+ );
709
+ gradient.addColorStop(0, "rgba(80, 40, 120, 0.6)");
710
+ gradient.addColorStop(1, "transparent");
711
+ ctx.fillStyle = gradient;
712
+ ctx.beginPath();
713
+ ctx.arc(centerX, centerY, size * 3, 0, Math.PI * 2);
714
+ ctx.fill();
715
+ });
716
+
717
+ // Draw the black hole (actually black!)
718
+ Painter.useCtx((ctx) => {
719
+ ctx.fillStyle = "#000";
720
+ ctx.beginPath();
721
+ ctx.arc(centerX, centerY, size, 0, Math.PI * 2);
722
+ ctx.fill();
723
+
724
+ // Event horizon ring (accretion disk hint)
725
+ ctx.strokeStyle = "rgba(150, 100, 200, 0.8)";
726
+ ctx.lineWidth = 2;
727
+ ctx.beginPath();
728
+ ctx.arc(centerX, centerY, size * 1.3, 0, Math.PI * 2);
729
+ ctx.stroke();
730
+ });
731
+
732
+ // Draw event horizon circle on the grid
733
+ Painter.useCtx((ctx) => {
734
+ ctx.strokeStyle = CONFIG.horizonColor;
735
+ ctx.lineWidth = 2;
736
+ ctx.beginPath();
737
+
738
+ for (let i = 0; i <= segments; i++) {
739
+ const angle = (i / segments) * Math.PI * 2;
740
+ const x = Math.cos(angle) * r;
741
+ const z = Math.sin(angle) * r;
742
+
743
+ const p = this.camera.project(
744
+ x * this.gridScale,
745
+ y,
746
+ z * this.gridScale,
747
+ );
748
+
749
+ if (i === 0) ctx.moveTo(cx + p.x, cy + p.y);
750
+ else ctx.lineTo(cx + p.x, cy + p.y);
751
+ }
752
+ ctx.closePath();
753
+ ctx.stroke();
754
+ });
755
+ }
756
+
757
+ drawOrbiter(cx, cy) {
758
+ // Apply precession to orbit
759
+ const totalAngle = this.orbitPhi + this.precessionAngle;
760
+
761
+ // Position in orbital plane
762
+ const orbiterX = Math.cos(totalAngle) * this.orbitR;
763
+ const orbiterZ = Math.sin(totalAngle) * this.orbitR;
764
+ const orbiterY = this.getEmbeddingHeight(this.orbitR);
765
+
766
+ const p = this.camera.project(
767
+ orbiterX * this.gridScale,
768
+ orbiterY,
769
+ orbiterZ * this.gridScale,
770
+ );
771
+
772
+ const screenX = cx + p.x;
773
+ const screenY = cy + p.y;
774
+ const size = 5 * p.scale;
775
+
776
+ // Glow
777
+ Painter.useCtx((ctx) => {
778
+ const gradient = ctx.createRadialGradient(
779
+ screenX,
780
+ screenY,
781
+ 0,
782
+ screenX,
783
+ screenY,
784
+ size * 4,
785
+ );
786
+ gradient.addColorStop(0, CONFIG.orbiterGlow);
787
+ gradient.addColorStop(1, "transparent");
788
+ ctx.fillStyle = gradient;
789
+ ctx.beginPath();
790
+ ctx.arc(screenX, screenY, size * 4, 0, Math.PI * 2);
791
+ ctx.fill();
792
+ });
793
+
794
+ // Body
795
+ Painter.useCtx((ctx) => {
796
+ const gradient = ctx.createRadialGradient(
797
+ screenX - size * 0.3,
798
+ screenY - size * 0.3,
799
+ 0,
800
+ screenX,
801
+ screenY,
802
+ size,
803
+ );
804
+ gradient.addColorStop(0, "#fff");
805
+ gradient.addColorStop(0.5, CONFIG.orbiterColor);
806
+ gradient.addColorStop(1, CONFIG.orbiterGlow);
807
+ ctx.fillStyle = gradient;
808
+ ctx.beginPath();
809
+ ctx.arc(screenX, screenY, size, 0, Math.PI * 2);
810
+ ctx.fill();
811
+ });
812
+
813
+ // Draw full orbital path
814
+ this.drawOrbitPath(cx, cy);
815
+
816
+ // Draw trailing tail
817
+ this.drawOrbitalTrail(cx, cy);
818
+ }
819
+
820
+ drawOrbitPath(cx, cy) {
821
+ const segments = 64;
822
+
823
+ Painter.useCtx((ctx) => {
824
+ ctx.strokeStyle = "rgba(100, 180, 255, 0.25)";
825
+ ctx.lineWidth = 1.5;
826
+ ctx.beginPath();
827
+
828
+ for (let i = 0; i <= segments; i++) {
829
+ // Full circle with precession applied
830
+ const angle = (i / segments) * Math.PI * 2 + this.precessionAngle;
831
+ const phi = (i / segments) * Math.PI * 2;
832
+
833
+ // Same radius formula as the orbiter
834
+ const r = orbitalRadiusSimple(
835
+ CONFIG.orbitSemiMajor,
836
+ CONFIG.orbitEccentricity,
837
+ phi,
838
+ );
839
+
840
+ const x = Math.cos(angle) * r;
841
+ const z = Math.sin(angle) * r;
842
+ const y = this.getEmbeddingHeight(r);
843
+
844
+ const p = this.camera.project(
845
+ x * this.gridScale,
846
+ y,
847
+ z * this.gridScale,
848
+ );
849
+
850
+ if (i === 0) {
851
+ ctx.moveTo(cx + p.x, cy + p.y);
852
+ } else {
853
+ ctx.lineTo(cx + p.x, cy + p.y);
854
+ }
855
+ }
856
+
857
+ ctx.closePath();
858
+ ctx.stroke();
859
+ });
860
+ }
861
+
862
+ drawOrbitalTrail(cx, cy) {
863
+ if (this.orbitTrail.length < 2) return;
864
+
865
+ Painter.useCtx((ctx) => {
866
+ ctx.lineCap = "round";
867
+
868
+ for (let i = 1; i < this.orbitTrail.length; i++) {
869
+ const t = i / this.orbitTrail.length;
870
+ const point = this.orbitTrail[i];
871
+ const prevPoint = this.orbitTrail[i - 1];
872
+
873
+ const trailY = this.getEmbeddingHeight(point.r);
874
+ const prevY = this.getEmbeddingHeight(prevPoint.r);
875
+
876
+ const p = this.camera.project(
877
+ point.x * this.gridScale,
878
+ trailY,
879
+ point.z * this.gridScale,
880
+ );
881
+
882
+ const prevP = this.camera.project(
883
+ prevPoint.x * this.gridScale,
884
+ prevY,
885
+ prevPoint.z * this.gridScale,
886
+ );
887
+
888
+ const alpha = (1 - t) * 0.5;
889
+ ctx.strokeStyle = `rgba(100, 180, 255, ${alpha})`;
890
+ ctx.lineWidth = (1 - t) * 2.5 * p.scale;
891
+ ctx.beginPath();
892
+ ctx.moveTo(cx + prevP.x, cy + prevP.y);
893
+ ctx.lineTo(cx + p.x, cy + p.y);
894
+ ctx.stroke();
895
+ }
896
+ });
897
+ }
898
+
899
+ drawEffectivePotential() {
900
+ // Responsive graph sizing
901
+ const isMobile = this.width < CONFIG.mobileWidth;
902
+ const graphW = isMobile ? 120 : 160;
903
+ const graphH = isMobile ? 70 : 100;
904
+ const graphX = this.width - graphW - (isMobile ? 15 : 20);
905
+ const graphY = isMobile ? 80 : 220; // Desktop moved down to avoid info div
906
+
907
+ Painter.useCtx((ctx) => {
908
+ // Background
909
+ ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
910
+ ctx.fillRect(graphX - 10, graphY - 10, graphW + 20, graphH + 40);
911
+
912
+ // Title
913
+ ctx.fillStyle = "#888";
914
+ ctx.font = "10px monospace";
915
+ ctx.textAlign = "center";
916
+ ctx.fillText("Effective Potential V_eff(r)", graphX + graphW / 2, graphY);
917
+
918
+ // Axes
919
+ ctx.strokeStyle = "#444";
920
+ ctx.lineWidth = 1;
921
+ ctx.beginPath();
922
+ ctx.moveTo(graphX, graphY + graphH);
923
+ ctx.lineTo(graphX + graphW, graphY + graphH);
924
+ ctx.moveTo(graphX, graphY + 10);
925
+ ctx.lineTo(graphX, graphY + graphH);
926
+ ctx.stroke();
927
+
928
+ // Labels
929
+ ctx.fillStyle = "#666";
930
+ ctx.font = "8px monospace";
931
+ ctx.textAlign = "left";
932
+ ctx.fillText("r", graphX + graphW - 10, graphY + graphH + 12);
933
+ ctx.fillText("V", graphX - 8, graphY + 15);
934
+
935
+ // Plot V_eff
936
+ ctx.strokeStyle = "#8f8";
937
+ ctx.lineWidth = 1.5;
938
+ ctx.beginPath();
939
+
940
+ const rMin = this.rs * 1.2;
941
+ const rMax = 20;
942
+ let firstPoint = true;
943
+
944
+ for (let i = 0; i <= 100; i++) {
945
+ const r = rMin + (i / 100) * (rMax - rMin);
946
+ const V = this.effectivePotential(r);
947
+
948
+ const px = graphX + ((r - rMin) / (rMax - rMin)) * graphW;
949
+ const py = graphY + graphH - 20 - (V + 0.1) * 300;
950
+
951
+ if (py > graphY + 10 && py < graphY + graphH) {
952
+ if (firstPoint) {
953
+ ctx.moveTo(px, py);
954
+ firstPoint = false;
955
+ } else {
956
+ ctx.lineTo(px, py);
957
+ }
958
+ }
959
+ }
960
+ ctx.stroke();
961
+
962
+ // Current position marker
963
+ const currentPx =
964
+ graphX + ((this.orbitR - rMin) / (rMax - rMin)) * graphW;
965
+ const currentV = this.effectivePotential(this.orbitR);
966
+ const currentPy = graphY + graphH - 20 - (currentV + 0.1) * 300;
967
+
968
+ if (currentPy > graphY + 10 && currentPy < graphY + graphH) {
969
+ ctx.fillStyle = CONFIG.orbiterColor;
970
+ ctx.beginPath();
971
+ ctx.arc(currentPx, currentPy, 4, 0, Math.PI * 2);
972
+ ctx.fill();
973
+ }
974
+
975
+ // Mark ISCO
976
+ const iscoPx = graphX + ((3 * this.rs - rMin) / (rMax - rMin)) * graphW;
977
+ ctx.strokeStyle = CONFIG.iscoColor;
978
+ ctx.setLineDash([2, 2]);
979
+ ctx.beginPath();
980
+ ctx.moveTo(iscoPx, graphY + 10);
981
+ ctx.lineTo(iscoPx, graphY + graphH);
982
+ ctx.stroke();
983
+ ctx.setLineDash([]);
984
+ });
985
+ }
986
+
987
+ drawControls(w, h) {
988
+ const isMobile = w < CONFIG.mobileWidth;
989
+ const fontSize = isMobile ? 8 : 10;
990
+ const margin = isMobile ? 10 : 15;
991
+
992
+ Painter.useCtx((ctx) => {
993
+ ctx.fillStyle = "#445";
994
+ ctx.font = `${fontSize}px monospace`;
995
+ ctx.textAlign = "right";
996
+
997
+ if (isMobile) {
998
+ ctx.fillText("drag to rotate", w - margin, h - 25);
999
+ ctx.fillStyle = "#553";
1000
+ ctx.fillText("Curvature exaggerated", w - margin, h - 10);
1001
+ } else {
1002
+ ctx.fillText("drag to rotate", w - margin, h - 45);
1003
+ ctx.fillText(
1004
+ "Flamm's paraboloid embedding | Geodesic precession",
1005
+ w - margin,
1006
+ h - 30,
1007
+ );
1008
+ ctx.fillStyle = "#553";
1009
+ ctx.fillText(
1010
+ "Curvature exaggerated for visibility (rubber sheet analogy)",
1011
+ w - margin,
1012
+ h - 15,
1013
+ );
1014
+ }
1015
+ });
1016
+ }
1017
+ }
1018
+
1019
+ window.addEventListener("load", () => {
1020
+ const canvas = document.getElementById("game");
1021
+ const demo = new SchwarzschildDemo(canvas);
1022
+ demo.start();
1023
+ });