@guinetik/gcanvas 1.0.4 → 2.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 (261) hide show
  1. package/dist/CNAME +1 -0
  2. package/dist/aizawa.html +27 -0
  3. package/dist/animations.html +31 -0
  4. package/dist/basic.html +38 -0
  5. package/dist/baskara.html +31 -0
  6. package/dist/bezier.html +35 -0
  7. package/dist/beziersignature.html +29 -0
  8. package/dist/blackhole.html +28 -0
  9. package/dist/blob.html +35 -0
  10. package/dist/clifford.html +25 -0
  11. package/dist/cmb.html +24 -0
  12. package/dist/coordinates.html +698 -0
  13. package/dist/cube3d.html +23 -0
  14. package/dist/dadras.html +26 -0
  15. package/dist/dejong.html +25 -0
  16. package/dist/demos.css +303 -0
  17. package/dist/dino.html +42 -0
  18. package/dist/easing.html +28 -0
  19. package/dist/events.html +195 -0
  20. package/dist/fluent.html +647 -0
  21. package/dist/fluid-simple.html +22 -0
  22. package/dist/fluid.html +37 -0
  23. package/dist/fractals.html +36 -0
  24. package/dist/gameobjects.html +626 -0
  25. package/dist/gcanvas.es.js +14368 -9093
  26. package/dist/gcanvas.es.min.js +1 -1
  27. package/dist/gcanvas.umd.js +1 -1
  28. package/dist/gcanvas.umd.min.js +1 -1
  29. package/dist/genart.html +26 -0
  30. package/dist/gendream.html +26 -0
  31. package/dist/group.html +36 -0
  32. package/dist/halvorsen.html +27 -0
  33. package/dist/home.html +587 -0
  34. package/dist/hyperbolic001.html +23 -0
  35. package/dist/hyperbolic002.html +23 -0
  36. package/dist/hyperbolic003.html +23 -0
  37. package/dist/hyperbolic004.html +23 -0
  38. package/dist/hyperbolic005.html +22 -0
  39. package/dist/index.html +446 -0
  40. package/dist/isometric.html +34 -0
  41. package/dist/js/aizawa.js +425 -0
  42. package/dist/js/animations.js +452 -0
  43. package/dist/js/basic.js +204 -0
  44. package/dist/js/baskara.js +751 -0
  45. package/dist/js/bezier.js +692 -0
  46. package/dist/js/beziersignature.js +241 -0
  47. package/dist/js/blackhole/accretiondisk.obj.js +379 -0
  48. package/dist/js/blackhole/blackhole.obj.js +318 -0
  49. package/dist/js/blackhole/index.js +409 -0
  50. package/dist/js/blackhole/particle.js +56 -0
  51. package/dist/js/blackhole/starfield.obj.js +218 -0
  52. package/dist/js/blob.js +2276 -0
  53. package/dist/js/clifford.js +236 -0
  54. package/dist/js/cmb.js +594 -0
  55. package/dist/js/coordinates.js +840 -0
  56. package/dist/js/cube3d.js +789 -0
  57. package/dist/js/dadras.js +405 -0
  58. package/dist/js/dejong.js +257 -0
  59. package/dist/js/dino.js +1420 -0
  60. package/dist/js/easing.js +477 -0
  61. package/dist/js/fluent.js +183 -0
  62. package/dist/js/fluid-simple.js +253 -0
  63. package/dist/js/fluid.js +527 -0
  64. package/dist/js/fractals.js +932 -0
  65. package/dist/js/fractalworker.js +93 -0
  66. package/dist/js/gameobjects.js +176 -0
  67. package/dist/js/genart.js +268 -0
  68. package/dist/js/gendream.js +209 -0
  69. package/dist/js/group.js +140 -0
  70. package/dist/js/halvorsen.js +405 -0
  71. package/dist/js/hyperbolic001.js +310 -0
  72. package/dist/js/hyperbolic002.js +388 -0
  73. package/dist/js/hyperbolic003.js +319 -0
  74. package/dist/js/hyperbolic004.js +345 -0
  75. package/dist/js/hyperbolic005.js +340 -0
  76. package/dist/js/info-toggle.js +25 -0
  77. package/dist/js/isometric.js +851 -0
  78. package/dist/js/kerr.js +1547 -0
  79. package/dist/js/lavalamp.js +590 -0
  80. package/dist/js/layout.js +354 -0
  81. package/dist/js/lorenz.js +425 -0
  82. package/dist/js/mondrian.js +285 -0
  83. package/dist/js/opacity.js +275 -0
  84. package/dist/js/painter.js +484 -0
  85. package/dist/js/particles-showcase.js +514 -0
  86. package/dist/js/particles.js +299 -0
  87. package/dist/js/patterns.js +397 -0
  88. package/dist/js/penrose/artifact.js +69 -0
  89. package/dist/js/penrose/blackhole.js +121 -0
  90. package/dist/js/penrose/constants.js +73 -0
  91. package/dist/js/penrose/game.js +943 -0
  92. package/dist/js/penrose/lore.js +278 -0
  93. package/dist/js/penrose/penrosescene.js +892 -0
  94. package/dist/js/penrose/ship.js +216 -0
  95. package/dist/js/penrose/sounds.js +211 -0
  96. package/dist/js/penrose/voidparticle.js +55 -0
  97. package/dist/js/penrose/voidscene.js +258 -0
  98. package/dist/js/penrose/voidship.js +144 -0
  99. package/dist/js/penrose/wormhole.js +46 -0
  100. package/dist/js/pipeline.js +555 -0
  101. package/dist/js/plane3d.js +256 -0
  102. package/dist/js/platformer.js +1579 -0
  103. package/dist/js/rossler.js +480 -0
  104. package/dist/js/scene.js +304 -0
  105. package/dist/js/scenes.js +320 -0
  106. package/dist/js/schrodinger.js +706 -0
  107. package/dist/js/schwarzschild.js +1015 -0
  108. package/dist/js/shapes.js +628 -0
  109. package/dist/js/space/alien.js +171 -0
  110. package/dist/js/space/boom.js +98 -0
  111. package/dist/js/space/boss.js +353 -0
  112. package/dist/js/space/buff.js +73 -0
  113. package/dist/js/space/bullet.js +102 -0
  114. package/dist/js/space/constants.js +85 -0
  115. package/dist/js/space/game.js +1884 -0
  116. package/dist/js/space/hud.js +112 -0
  117. package/dist/js/space/laserbeam.js +179 -0
  118. package/dist/js/space/lightning.js +277 -0
  119. package/dist/js/space/minion.js +192 -0
  120. package/dist/js/space/missile.js +212 -0
  121. package/dist/js/space/player.js +430 -0
  122. package/dist/js/space/powerup.js +90 -0
  123. package/dist/js/space/starfield.js +58 -0
  124. package/dist/js/space/starpower.js +90 -0
  125. package/dist/js/spacetime.js +559 -0
  126. package/dist/js/sphere3d.js +229 -0
  127. package/dist/js/sprite.js +473 -0
  128. package/dist/js/starfaux/config.js +118 -0
  129. package/dist/js/starfaux/enemy.js +353 -0
  130. package/dist/js/starfaux/hud.js +78 -0
  131. package/dist/js/starfaux/index.js +482 -0
  132. package/dist/js/starfaux/laser.js +182 -0
  133. package/dist/js/starfaux/player.js +468 -0
  134. package/dist/js/starfaux/terrain.js +560 -0
  135. package/dist/js/study001.js +275 -0
  136. package/dist/js/study002.js +366 -0
  137. package/dist/js/study003.js +331 -0
  138. package/dist/js/study004.js +389 -0
  139. package/dist/js/study005.js +209 -0
  140. package/dist/js/study006.js +194 -0
  141. package/dist/js/study007.js +192 -0
  142. package/dist/js/study008.js +413 -0
  143. package/dist/js/svgtween.js +204 -0
  144. package/dist/js/tde/accretiondisk.js +471 -0
  145. package/dist/js/tde/blackhole.js +219 -0
  146. package/dist/js/tde/blackholescene.js +209 -0
  147. package/dist/js/tde/config.js +59 -0
  148. package/dist/js/tde/index.js +820 -0
  149. package/dist/js/tde/jets.js +290 -0
  150. package/dist/js/tde/lensedstarfield.js +154 -0
  151. package/dist/js/tde/tdestar.js +297 -0
  152. package/dist/js/tde/tidalstream.js +372 -0
  153. package/dist/js/tde_old/blackhole.obj.js +354 -0
  154. package/dist/js/tde_old/debris.obj.js +791 -0
  155. package/dist/js/tde_old/flare.obj.js +239 -0
  156. package/dist/js/tde_old/index.js +448 -0
  157. package/dist/js/tde_old/star.obj.js +812 -0
  158. package/dist/js/tetris/config.js +157 -0
  159. package/dist/js/tetris/grid.js +286 -0
  160. package/dist/js/tetris/index.js +1195 -0
  161. package/dist/js/tetris/renderer.js +634 -0
  162. package/dist/js/tetris/tetrominos.js +280 -0
  163. package/dist/js/thomas.js +394 -0
  164. package/dist/js/tiles.js +312 -0
  165. package/dist/js/tweendemo.js +79 -0
  166. package/dist/js/visibility.js +102 -0
  167. package/dist/kerr.html +28 -0
  168. package/dist/lavalamp.html +27 -0
  169. package/dist/layouts.html +37 -0
  170. package/dist/logo.svg +4 -0
  171. package/dist/loop.html +84 -0
  172. package/dist/lorenz.html +27 -0
  173. package/dist/mondrian.html +32 -0
  174. package/dist/og_image.png +0 -0
  175. package/dist/opacity.html +36 -0
  176. package/dist/painter.html +39 -0
  177. package/dist/particles-showcase.html +28 -0
  178. package/dist/particles.html +24 -0
  179. package/dist/patterns.html +33 -0
  180. package/dist/penrose-game.html +31 -0
  181. package/dist/pipeline.html +737 -0
  182. package/dist/plane3d.html +24 -0
  183. package/dist/platformer.html +43 -0
  184. package/dist/rossler.html +27 -0
  185. package/dist/scene-interactivity-test.html +220 -0
  186. package/dist/scene.html +33 -0
  187. package/dist/scenes.html +96 -0
  188. package/dist/schrodinger.html +27 -0
  189. package/dist/schwarzschild.html +27 -0
  190. package/dist/shapes.html +16 -0
  191. package/dist/space.html +85 -0
  192. package/dist/spacetime.html +27 -0
  193. package/dist/sphere3d.html +24 -0
  194. package/dist/sprite.html +18 -0
  195. package/dist/starfaux.html +22 -0
  196. package/dist/study001.html +23 -0
  197. package/dist/study002.html +23 -0
  198. package/dist/study003.html +23 -0
  199. package/dist/study004.html +23 -0
  200. package/dist/study005.html +22 -0
  201. package/dist/study006.html +24 -0
  202. package/dist/study007.html +24 -0
  203. package/dist/study008.html +22 -0
  204. package/dist/svgtween.html +29 -0
  205. package/dist/tde.html +28 -0
  206. package/dist/tetris3d.html +25 -0
  207. package/dist/thomas.html +27 -0
  208. package/dist/tiles.html +28 -0
  209. package/dist/transforms.html +400 -0
  210. package/dist/tween.html +45 -0
  211. package/dist/visibility.html +33 -0
  212. package/package.json +1 -1
  213. package/readme.md +30 -22
  214. package/src/game/objects/go.js +7 -0
  215. package/src/game/objects/index.js +2 -0
  216. package/src/game/objects/isometric-scene.js +53 -3
  217. package/src/game/objects/layoutscene.js +57 -0
  218. package/src/game/objects/mask.js +241 -0
  219. package/src/game/objects/scene.js +19 -0
  220. package/src/game/objects/wrapper.js +14 -2
  221. package/src/game/pipeline.js +17 -0
  222. package/src/game/ui/button.js +101 -16
  223. package/src/game/ui/theme.js +0 -6
  224. package/src/game/ui/togglebutton.js +25 -14
  225. package/src/game/ui/tooltip.js +12 -4
  226. package/src/index.js +3 -0
  227. package/src/io/gesture.js +409 -0
  228. package/src/io/index.js +4 -1
  229. package/src/io/keys.js +9 -1
  230. package/src/io/screen.js +476 -0
  231. package/src/math/attractors.js +664 -0
  232. package/src/math/heat.js +106 -0
  233. package/src/math/index.js +1 -0
  234. package/src/mixins/draggable.js +15 -19
  235. package/src/painter/painter.shapes.js +11 -5
  236. package/src/particle/particle-system.js +165 -1
  237. package/src/physics/index.js +26 -0
  238. package/src/physics/physics-updaters.js +333 -0
  239. package/src/physics/physics.js +375 -0
  240. package/src/shapes/image.js +5 -5
  241. package/src/shapes/index.js +2 -0
  242. package/src/shapes/parallelogram.js +147 -0
  243. package/src/shapes/righttriangle.js +115 -0
  244. package/src/shapes/svg.js +281 -100
  245. package/src/shapes/text.js +22 -6
  246. package/src/shapes/transformable.js +5 -0
  247. package/src/sound/effects.js +807 -0
  248. package/src/sound/index.js +13 -0
  249. package/src/webgl/index.js +7 -0
  250. package/src/webgl/shaders/clifford-point-shaders.js +131 -0
  251. package/src/webgl/shaders/dejong-point-shaders.js +131 -0
  252. package/src/webgl/shaders/point-sprite-shaders.js +152 -0
  253. package/src/webgl/webgl-clifford-renderer.js +477 -0
  254. package/src/webgl/webgl-dejong-renderer.js +472 -0
  255. package/src/webgl/webgl-line-renderer.js +391 -0
  256. package/src/webgl/webgl-particle-renderer.js +410 -0
  257. package/types/index.d.ts +30 -2
  258. package/types/io.d.ts +217 -0
  259. package/types/physics.d.ts +299 -0
  260. package/types/shapes.d.ts +8 -0
  261. package/types/webgl.d.ts +188 -109
@@ -0,0 +1,1015 @@
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 "/gcanvas.es.min.js";
12
+ import { GameObject } from "/gcanvas.es.min.js";
13
+ import { Rectangle } from "/gcanvas.es.min.js";
14
+ import { TextShape } from "/gcanvas.es.min.js";
15
+ import { Position } from "/gcanvas.es.min.js";
16
+ import { Tensor } from "/gcanvas.es.min.js";
17
+ import { flammEmbeddingHeight } from "/gcanvas.es.min.js";
18
+ import {
19
+ keplerianOmega,
20
+ schwarzschildPrecessionRate,
21
+ orbitalRadiusSimple,
22
+ updateTrail,
23
+ createTrailPoint,
24
+ } from "/gcanvas.es.min.js";
25
+ import { verticalLayout, applyLayout } from "/gcanvas.es.min.js";
26
+ import { Tooltip } from "/gcanvas.es.min.js";
27
+ import { Button } from "/gcanvas.es.min.js";
28
+
29
+ // Configuration
30
+ const CONFIG = {
31
+ // Grid parameters - match spacetime.js for clean visuals
32
+ gridSize: 20,
33
+ gridResolution: 40,
34
+ baseGridScale: 15,
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
+ // Fixed grid scale (like spacetime.js)
350
+ this.gridScale = CONFIG.baseGridScale;
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
+ shuffleParameters() {
465
+ // Randomize mass
466
+ this.mass =
467
+ CONFIG.massRange[0] +
468
+ Math.random() * (CONFIG.massRange[1] - CONFIG.massRange[0]);
469
+ this.rs = 2 * this.mass;
470
+
471
+ // Randomize orbit (keep it outside ISCO using Tensor utility)
472
+ const isco = Tensor.iscoRadius(this.rs);
473
+ this.orbitR = isco + 2 + Math.random() * 8;
474
+ this.orbitPhi = Math.random() * Math.PI * 2;
475
+ this.orbitL = 3.5 + Math.random() * 2;
476
+ this.precessionAngle = 0;
477
+
478
+ // Clear trail for fresh start
479
+ this.orbitTrail = [];
480
+ }
481
+
482
+ /**
483
+ * Flamm's paraboloid embedding using shared gr.js module.
484
+ * Inverted so it looks like a gravity well going DOWN.
485
+ */
486
+ getEmbeddingHeight(r) {
487
+ const height = flammEmbeddingHeight(
488
+ r,
489
+ this.rs,
490
+ this.mass,
491
+ CONFIG.gridSize,
492
+ CONFIG.embeddingScale,
493
+ );
494
+ // Clamp to non-negative to prevent grid lines appearing above the flat plane
495
+ return Math.max(0, height);
496
+ }
497
+
498
+ /**
499
+ * Effective potential for geodesic motion
500
+ * V_eff = -M/r + L²/(2r²) - ML²/r³
501
+ * Uses Tensor.effectivePotential static utility
502
+ */
503
+ effectivePotential(r) {
504
+ return Tensor.effectivePotential(this.mass, this.orbitL, r);
505
+ }
506
+
507
+ /**
508
+ * Update geodesic motion using orbital.js utilities.
509
+ * Simplified for visualization while maintaining GR character.
510
+ */
511
+ updateGeodesic(dt) {
512
+ const r = this.orbitR;
513
+
514
+ // Kepler's 3rd law angular velocity
515
+ const baseOmega = keplerianOmega(r, this.mass, CONFIG.orbitSpeed);
516
+
517
+ // Update orbital angle
518
+ this.orbitPhi += baseOmega * dt;
519
+
520
+ // Radial oscillation for eccentricity effect
521
+ this.orbitR = orbitalRadiusSimple(
522
+ CONFIG.orbitSemiMajor,
523
+ CONFIG.orbitEccentricity,
524
+ this.orbitPhi,
525
+ );
526
+
527
+ // Keep orbit bounded outside ISCO
528
+ const minR = Tensor.iscoRadius(this.rs) + 1;
529
+ if (this.orbitR < minR) this.orbitR = minR;
530
+
531
+ // GR precession: orbit doesn't close, rotates over time
532
+ const precessionRate = schwarzschildPrecessionRate(
533
+ r,
534
+ this.rs,
535
+ CONFIG.precessionFactor,
536
+ );
537
+ this.precessionAngle += precessionRate * dt;
538
+
539
+ // Store current position in trail
540
+ const totalAngle = this.orbitPhi + this.precessionAngle;
541
+ updateTrail(this.orbitTrail, createTrailPoint(this.orbitR, totalAngle), 80);
542
+ }
543
+
544
+ update(dt) {
545
+ super.update(dt);
546
+ this.time += dt;
547
+
548
+ this.camera.update(dt);
549
+ this.updateGeodesic(dt);
550
+
551
+ // Update grid with Flamm's paraboloid embedding
552
+ const { gridResolution } = CONFIG;
553
+ for (let i = 0; i <= gridResolution; i++) {
554
+ for (let j = 0; j <= gridResolution; j++) {
555
+ const vertex = this.gridVertices[i][j];
556
+ // Function already clamps at horizon, no need for extra clamp here
557
+ const r = Math.sqrt(vertex.x * vertex.x + vertex.z * vertex.z);
558
+ vertex.y = this.getEmbeddingHeight(r);
559
+ }
560
+ }
561
+
562
+ // Update metric panel
563
+ if (this.metricPanel) {
564
+ this.metricPanel.setMetricValues(this.orbitR, this.rs, this.mass);
565
+ this.metricPanel.setOrbiterPosition(this.orbitR, this.orbitPhi);
566
+ }
567
+ }
568
+
569
+ render() {
570
+ const w = this.width;
571
+ const h = this.height;
572
+ const cx = w / 2;
573
+ const cy = h / 2; // Centered to see full well depth
574
+
575
+ super.render();
576
+
577
+ // Draw grid
578
+ this.drawGrid(cx, cy);
579
+
580
+ // Draw event horizon
581
+ this.drawHorizon(cx, cy);
582
+
583
+ // Draw orbiter
584
+ this.drawOrbiter(cx, cy);
585
+
586
+ // Draw effective potential graph
587
+ this.drawEffectivePotential();
588
+
589
+ // Draw controls
590
+ this.drawControls(w, h);
591
+ }
592
+
593
+ drawKeyRadii(cx, cy) {
594
+ const radii = [
595
+ { r: this.rs, color: CONFIG.horizonColor, label: "rs" },
596
+ { r: this.rs * 1.5, color: CONFIG.photonSphereColor, label: "r_ph" },
597
+ { r: this.rs * 3, color: CONFIG.iscoColor, label: "ISCO" },
598
+ ];
599
+
600
+ for (const { r, color, label } of radii) {
601
+ const segments = 48;
602
+ Painter.useCtx((ctx) => {
603
+ ctx.strokeStyle = color;
604
+ ctx.lineWidth = 1.5;
605
+ ctx.setLineDash([5, 5]);
606
+ ctx.beginPath();
607
+
608
+ for (let i = 0; i <= segments; i++) {
609
+ const angle = (i / segments) * Math.PI * 2;
610
+ const x = Math.cos(angle) * r;
611
+ const z = Math.sin(angle) * r;
612
+ const y = this.getEmbeddingHeight(r);
613
+
614
+ const p = this.camera.project(
615
+ x * this.gridScale,
616
+ y,
617
+ z * this.gridScale,
618
+ );
619
+
620
+ if (i === 0) {
621
+ ctx.moveTo(cx + p.x, cy + p.y);
622
+ } else {
623
+ ctx.lineTo(cx + p.x, cy + p.y);
624
+ }
625
+ }
626
+ ctx.stroke();
627
+ ctx.setLineDash([]);
628
+ });
629
+ }
630
+ }
631
+
632
+ drawGrid(cx, cy) {
633
+ const { gridResolution, gridColor, gridHighlight } = CONFIG;
634
+ const gridScale = this.gridScale;
635
+
636
+ const projected = this.gridVertices.map((row) =>
637
+ row.map((v) => {
638
+ const p = this.camera.project(v.x * gridScale, v.y, v.z * gridScale);
639
+ return { x: cx + p.x, y: cy + p.y, z: p.z };
640
+ }),
641
+ );
642
+
643
+ // Draw grid lines
644
+ for (let i = 0; i <= gridResolution; i++) {
645
+ const isMain = i % 5 === 0;
646
+ Painter.useCtx((ctx) => {
647
+ ctx.strokeStyle = isMain ? gridHighlight : gridColor;
648
+ ctx.lineWidth = isMain ? 1.2 : 0.6;
649
+ ctx.beginPath();
650
+ for (let j = 0; j <= gridResolution; j++) {
651
+ const p = projected[i][j];
652
+ if (j === 0) ctx.moveTo(p.x, p.y);
653
+ else ctx.lineTo(p.x, p.y);
654
+ }
655
+ ctx.stroke();
656
+ });
657
+ }
658
+
659
+ for (let j = 0; j <= gridResolution; j++) {
660
+ const isMain = j % 5 === 0;
661
+ Painter.useCtx((ctx) => {
662
+ ctx.strokeStyle = isMain ? gridHighlight : gridColor;
663
+ ctx.lineWidth = isMain ? 1.2 : 0.6;
664
+ ctx.beginPath();
665
+ for (let i = 0; i <= gridResolution; i++) {
666
+ const p = projected[i][j];
667
+ if (i === 0) ctx.moveTo(p.x, p.y);
668
+ else ctx.lineTo(p.x, p.y);
669
+ }
670
+ ctx.stroke();
671
+ });
672
+ }
673
+ }
674
+
675
+ drawHorizon(cx, cy) {
676
+ // Draw filled event horizon - the BLACK hole
677
+ const segments = 32;
678
+ const r = this.rs;
679
+ const y = this.getEmbeddingHeight(r + 0.1);
680
+
681
+ // Project center for black hole body
682
+ const centerP = this.camera.project(0, y + 10, 0);
683
+ const centerX = cx + centerP.x;
684
+ const centerY = cy + centerP.y;
685
+
686
+ // Mass-proportional sizing: heavier = bigger (rubber sheet intuition)
687
+ const baseSize =
688
+ CONFIG.blackHoleSizeBase + this.mass * CONFIG.blackHoleSizeMassScale;
689
+ const size = baseSize * centerP.scale;
690
+
691
+ // Draw dark glow around black hole
692
+ Painter.useCtx((ctx) => {
693
+ const gradient = ctx.createRadialGradient(
694
+ centerX,
695
+ centerY,
696
+ size,
697
+ centerX,
698
+ centerY,
699
+ size * 3,
700
+ );
701
+ gradient.addColorStop(0, "rgba(80, 40, 120, 0.6)");
702
+ gradient.addColorStop(1, "transparent");
703
+ ctx.fillStyle = gradient;
704
+ ctx.beginPath();
705
+ ctx.arc(centerX, centerY, size * 3, 0, Math.PI * 2);
706
+ ctx.fill();
707
+ });
708
+
709
+ // Draw the black hole (actually black!)
710
+ Painter.useCtx((ctx) => {
711
+ ctx.fillStyle = "#000";
712
+ ctx.beginPath();
713
+ ctx.arc(centerX, centerY, size, 0, Math.PI * 2);
714
+ ctx.fill();
715
+
716
+ // Event horizon ring (accretion disk hint)
717
+ ctx.strokeStyle = "rgba(150, 100, 200, 0.8)";
718
+ ctx.lineWidth = 2;
719
+ ctx.beginPath();
720
+ ctx.arc(centerX, centerY, size * 1.3, 0, Math.PI * 2);
721
+ ctx.stroke();
722
+ });
723
+
724
+ // Draw event horizon circle on the grid
725
+ Painter.useCtx((ctx) => {
726
+ ctx.strokeStyle = CONFIG.horizonColor;
727
+ ctx.lineWidth = 2;
728
+ ctx.beginPath();
729
+
730
+ for (let i = 0; i <= segments; i++) {
731
+ const angle = (i / segments) * Math.PI * 2;
732
+ const x = Math.cos(angle) * r;
733
+ const z = Math.sin(angle) * r;
734
+
735
+ const p = this.camera.project(
736
+ x * this.gridScale,
737
+ y,
738
+ z * this.gridScale,
739
+ );
740
+
741
+ if (i === 0) ctx.moveTo(cx + p.x, cy + p.y);
742
+ else ctx.lineTo(cx + p.x, cy + p.y);
743
+ }
744
+ ctx.closePath();
745
+ ctx.stroke();
746
+ });
747
+ }
748
+
749
+ drawOrbiter(cx, cy) {
750
+ // Apply precession to orbit
751
+ const totalAngle = this.orbitPhi + this.precessionAngle;
752
+
753
+ // Position in orbital plane
754
+ const orbiterX = Math.cos(totalAngle) * this.orbitR;
755
+ const orbiterZ = Math.sin(totalAngle) * this.orbitR;
756
+ const orbiterY = this.getEmbeddingHeight(this.orbitR);
757
+
758
+ const p = this.camera.project(
759
+ orbiterX * this.gridScale,
760
+ orbiterY,
761
+ orbiterZ * this.gridScale,
762
+ );
763
+
764
+ const screenX = cx + p.x;
765
+ const screenY = cy + p.y;
766
+ const size = 5 * p.scale;
767
+
768
+ // Glow
769
+ Painter.useCtx((ctx) => {
770
+ const gradient = ctx.createRadialGradient(
771
+ screenX,
772
+ screenY,
773
+ 0,
774
+ screenX,
775
+ screenY,
776
+ size * 4,
777
+ );
778
+ gradient.addColorStop(0, CONFIG.orbiterGlow);
779
+ gradient.addColorStop(1, "transparent");
780
+ ctx.fillStyle = gradient;
781
+ ctx.beginPath();
782
+ ctx.arc(screenX, screenY, size * 4, 0, Math.PI * 2);
783
+ ctx.fill();
784
+ });
785
+
786
+ // Body
787
+ Painter.useCtx((ctx) => {
788
+ const gradient = ctx.createRadialGradient(
789
+ screenX - size * 0.3,
790
+ screenY - size * 0.3,
791
+ 0,
792
+ screenX,
793
+ screenY,
794
+ size,
795
+ );
796
+ gradient.addColorStop(0, "#fff");
797
+ gradient.addColorStop(0.5, CONFIG.orbiterColor);
798
+ gradient.addColorStop(1, CONFIG.orbiterGlow);
799
+ ctx.fillStyle = gradient;
800
+ ctx.beginPath();
801
+ ctx.arc(screenX, screenY, size, 0, Math.PI * 2);
802
+ ctx.fill();
803
+ });
804
+
805
+ // Draw full orbital path
806
+ this.drawOrbitPath(cx, cy);
807
+
808
+ // Draw trailing tail
809
+ this.drawOrbitalTrail(cx, cy);
810
+ }
811
+
812
+ drawOrbitPath(cx, cy) {
813
+ const segments = 64;
814
+
815
+ Painter.useCtx((ctx) => {
816
+ ctx.strokeStyle = "rgba(100, 180, 255, 0.25)";
817
+ ctx.lineWidth = 1.5;
818
+ ctx.beginPath();
819
+
820
+ for (let i = 0; i <= segments; i++) {
821
+ // Full circle with precession applied
822
+ const angle = (i / segments) * Math.PI * 2 + this.precessionAngle;
823
+ const phi = (i / segments) * Math.PI * 2;
824
+
825
+ // Same radius formula as the orbiter
826
+ const r = orbitalRadiusSimple(
827
+ CONFIG.orbitSemiMajor,
828
+ CONFIG.orbitEccentricity,
829
+ phi,
830
+ );
831
+
832
+ const x = Math.cos(angle) * r;
833
+ const z = Math.sin(angle) * r;
834
+ const y = this.getEmbeddingHeight(r);
835
+
836
+ const p = this.camera.project(
837
+ x * this.gridScale,
838
+ y,
839
+ z * this.gridScale,
840
+ );
841
+
842
+ if (i === 0) {
843
+ ctx.moveTo(cx + p.x, cy + p.y);
844
+ } else {
845
+ ctx.lineTo(cx + p.x, cy + p.y);
846
+ }
847
+ }
848
+
849
+ ctx.closePath();
850
+ ctx.stroke();
851
+ });
852
+ }
853
+
854
+ drawOrbitalTrail(cx, cy) {
855
+ if (this.orbitTrail.length < 2) return;
856
+
857
+ Painter.useCtx((ctx) => {
858
+ ctx.lineCap = "round";
859
+
860
+ for (let i = 1; i < this.orbitTrail.length; i++) {
861
+ const t = i / this.orbitTrail.length;
862
+ const point = this.orbitTrail[i];
863
+ const prevPoint = this.orbitTrail[i - 1];
864
+
865
+ const trailY = this.getEmbeddingHeight(point.r);
866
+ const prevY = this.getEmbeddingHeight(prevPoint.r);
867
+
868
+ const p = this.camera.project(
869
+ point.x * this.gridScale,
870
+ trailY,
871
+ point.z * this.gridScale,
872
+ );
873
+
874
+ const prevP = this.camera.project(
875
+ prevPoint.x * this.gridScale,
876
+ prevY,
877
+ prevPoint.z * this.gridScale,
878
+ );
879
+
880
+ const alpha = (1 - t) * 0.5;
881
+ ctx.strokeStyle = `rgba(100, 180, 255, ${alpha})`;
882
+ ctx.lineWidth = (1 - t) * 2.5 * p.scale;
883
+ ctx.beginPath();
884
+ ctx.moveTo(cx + prevP.x, cy + prevP.y);
885
+ ctx.lineTo(cx + p.x, cy + p.y);
886
+ ctx.stroke();
887
+ }
888
+ });
889
+ }
890
+
891
+ drawEffectivePotential() {
892
+ // Responsive graph sizing
893
+ const isMobile = this.width < CONFIG.mobileWidth;
894
+ const graphW = isMobile ? 120 : 160;
895
+ const graphH = isMobile ? 70 : 100;
896
+ const graphX = this.width - graphW - (isMobile ? 15 : 20);
897
+ const graphY = isMobile ? 80 : 220; // Desktop moved down to avoid info div
898
+
899
+ Painter.useCtx((ctx) => {
900
+ // Background
901
+ ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
902
+ ctx.fillRect(graphX - 10, graphY - 10, graphW + 20, graphH + 40);
903
+
904
+ // Title
905
+ ctx.fillStyle = "#888";
906
+ ctx.font = "10px monospace";
907
+ ctx.textAlign = "center";
908
+ ctx.fillText("Effective Potential V_eff(r)", graphX + graphW / 2, graphY);
909
+
910
+ // Axes
911
+ ctx.strokeStyle = "#444";
912
+ ctx.lineWidth = 1;
913
+ ctx.beginPath();
914
+ ctx.moveTo(graphX, graphY + graphH);
915
+ ctx.lineTo(graphX + graphW, graphY + graphH);
916
+ ctx.moveTo(graphX, graphY + 10);
917
+ ctx.lineTo(graphX, graphY + graphH);
918
+ ctx.stroke();
919
+
920
+ // Labels
921
+ ctx.fillStyle = "#666";
922
+ ctx.font = "8px monospace";
923
+ ctx.textAlign = "left";
924
+ ctx.fillText("r", graphX + graphW - 10, graphY + graphH + 12);
925
+ ctx.fillText("V", graphX - 8, graphY + 15);
926
+
927
+ // Plot V_eff
928
+ ctx.strokeStyle = "#8f8";
929
+ ctx.lineWidth = 1.5;
930
+ ctx.beginPath();
931
+
932
+ const rMin = this.rs * 1.2;
933
+ const rMax = 20;
934
+ let firstPoint = true;
935
+
936
+ for (let i = 0; i <= 100; i++) {
937
+ const r = rMin + (i / 100) * (rMax - rMin);
938
+ const V = this.effectivePotential(r);
939
+
940
+ const px = graphX + ((r - rMin) / (rMax - rMin)) * graphW;
941
+ const py = graphY + graphH - 20 - (V + 0.1) * 300;
942
+
943
+ if (py > graphY + 10 && py < graphY + graphH) {
944
+ if (firstPoint) {
945
+ ctx.moveTo(px, py);
946
+ firstPoint = false;
947
+ } else {
948
+ ctx.lineTo(px, py);
949
+ }
950
+ }
951
+ }
952
+ ctx.stroke();
953
+
954
+ // Current position marker
955
+ const currentPx =
956
+ graphX + ((this.orbitR - rMin) / (rMax - rMin)) * graphW;
957
+ const currentV = this.effectivePotential(this.orbitR);
958
+ const currentPy = graphY + graphH - 20 - (currentV + 0.1) * 300;
959
+
960
+ if (currentPy > graphY + 10 && currentPy < graphY + graphH) {
961
+ ctx.fillStyle = CONFIG.orbiterColor;
962
+ ctx.beginPath();
963
+ ctx.arc(currentPx, currentPy, 4, 0, Math.PI * 2);
964
+ ctx.fill();
965
+ }
966
+
967
+ // Mark ISCO
968
+ const iscoPx = graphX + ((3 * this.rs - rMin) / (rMax - rMin)) * graphW;
969
+ ctx.strokeStyle = CONFIG.iscoColor;
970
+ ctx.setLineDash([2, 2]);
971
+ ctx.beginPath();
972
+ ctx.moveTo(iscoPx, graphY + 10);
973
+ ctx.lineTo(iscoPx, graphY + graphH);
974
+ ctx.stroke();
975
+ ctx.setLineDash([]);
976
+ });
977
+ }
978
+
979
+ drawControls(w, h) {
980
+ const isMobile = w < CONFIG.mobileWidth;
981
+ const fontSize = isMobile ? 8 : 10;
982
+ const margin = isMobile ? 10 : 15;
983
+
984
+ Painter.useCtx((ctx) => {
985
+ ctx.fillStyle = "#445";
986
+ ctx.font = `${fontSize}px monospace`;
987
+ ctx.textAlign = "right";
988
+
989
+ if (isMobile) {
990
+ ctx.fillText("drag to rotate", w - margin, h - 25);
991
+ ctx.fillStyle = "#553";
992
+ ctx.fillText("Curvature exaggerated", w - margin, h - 10);
993
+ } else {
994
+ ctx.fillText("drag to rotate", w - margin, h - 45);
995
+ ctx.fillText(
996
+ "Flamm's paraboloid embedding | Geodesic precession",
997
+ w - margin,
998
+ h - 30,
999
+ );
1000
+ ctx.fillStyle = "#553";
1001
+ ctx.fillText(
1002
+ "Curvature exaggerated for visibility (rubber sheet analogy)",
1003
+ w - margin,
1004
+ h - 15,
1005
+ );
1006
+ }
1007
+ });
1008
+ }
1009
+ }
1010
+
1011
+ window.addEventListener("load", () => {
1012
+ const canvas = document.getElementById("game");
1013
+ const demo = new SchwarzschildDemo(canvas);
1014
+ demo.start();
1015
+ });