@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.
- package/.github/workflows/release.yaml +70 -0
- package/.jshintrc +4 -0
- package/.vscode/settings.json +22 -0
- package/CLAUDE.md +310 -0
- package/blackhole.jpg +0 -0
- package/demo.png +0 -0
- package/demos/CNAME +1 -0
- package/demos/animations.html +31 -0
- package/demos/basic.html +38 -0
- package/demos/baskara.html +31 -0
- package/demos/bezier.html +35 -0
- package/demos/beziersignature.html +29 -0
- package/demos/blackhole.html +28 -0
- package/demos/blob.html +35 -0
- package/demos/demos.css +289 -0
- package/demos/easing.html +28 -0
- package/demos/events.html +195 -0
- package/demos/fluent.html +647 -0
- package/demos/fractals.html +36 -0
- package/demos/genart.html +26 -0
- package/demos/gendream.html +26 -0
- package/demos/group.html +36 -0
- package/demos/home.html +587 -0
- package/demos/index.html +364 -0
- package/demos/isometric.html +34 -0
- package/demos/js/animations.js +452 -0
- package/demos/js/basic.js +204 -0
- package/demos/js/baskara.js +751 -0
- package/demos/js/bezier.js +692 -0
- package/demos/js/beziersignature.js +241 -0
- package/demos/js/blackhole/accretiondisk.obj.js +379 -0
- package/demos/js/blackhole/blackhole.obj.js +318 -0
- package/demos/js/blackhole/index.js +409 -0
- package/demos/js/blackhole/particle.js +56 -0
- package/demos/js/blackhole/starfield.obj.js +218 -0
- package/demos/js/blob.js +2263 -0
- package/demos/js/easing.js +477 -0
- package/demos/js/fluent.js +183 -0
- package/demos/js/fractals.js +931 -0
- package/demos/js/fractalworker.js +93 -0
- package/demos/js/genart.js +268 -0
- package/demos/js/gendream.js +209 -0
- package/demos/js/group.js +140 -0
- package/demos/js/info-toggle.js +25 -0
- package/demos/js/isometric.js +863 -0
- package/demos/js/kerr.js +1556 -0
- package/demos/js/lavalamp.js +590 -0
- package/demos/js/layout.js +354 -0
- package/demos/js/mondrian.js +285 -0
- package/demos/js/opacity.js +275 -0
- package/demos/js/painter.js +484 -0
- package/demos/js/particles-showcase.js +514 -0
- package/demos/js/particles.js +299 -0
- package/demos/js/patterns.js +397 -0
- package/demos/js/penrose/artifact.js +69 -0
- package/demos/js/penrose/blackhole.js +121 -0
- package/demos/js/penrose/constants.js +73 -0
- package/demos/js/penrose/game.js +943 -0
- package/demos/js/penrose/lore.js +278 -0
- package/demos/js/penrose/penrosescene.js +892 -0
- package/demos/js/penrose/ship.js +216 -0
- package/demos/js/penrose/sounds.js +211 -0
- package/demos/js/penrose/voidparticle.js +55 -0
- package/demos/js/penrose/voidscene.js +258 -0
- package/demos/js/penrose/voidship.js +144 -0
- package/demos/js/penrose/wormhole.js +46 -0
- package/demos/js/pipeline.js +555 -0
- package/demos/js/scene.js +304 -0
- package/demos/js/scenes.js +320 -0
- package/demos/js/schrodinger.js +410 -0
- package/demos/js/schwarzschild.js +1023 -0
- package/demos/js/shapes.js +628 -0
- package/demos/js/space/alien.js +171 -0
- package/demos/js/space/boom.js +98 -0
- package/demos/js/space/boss.js +353 -0
- package/demos/js/space/buff.js +73 -0
- package/demos/js/space/bullet.js +102 -0
- package/demos/js/space/constants.js +85 -0
- package/demos/js/space/game.js +1884 -0
- package/demos/js/space/hud.js +112 -0
- package/demos/js/space/laserbeam.js +179 -0
- package/demos/js/space/lightning.js +277 -0
- package/demos/js/space/minion.js +192 -0
- package/demos/js/space/missile.js +212 -0
- package/demos/js/space/player.js +430 -0
- package/demos/js/space/powerup.js +90 -0
- package/demos/js/space/starfield.js +58 -0
- package/demos/js/space/starpower.js +90 -0
- package/demos/js/spacetime.js +559 -0
- package/demos/js/svgtween.js +204 -0
- package/demos/js/tde/accretiondisk.js +418 -0
- package/demos/js/tde/blackhole.js +219 -0
- package/demos/js/tde/blackholescene.js +209 -0
- package/demos/js/tde/config.js +59 -0
- package/demos/js/tde/index.js +695 -0
- package/demos/js/tde/jets.js +290 -0
- package/demos/js/tde/lensedstarfield.js +147 -0
- package/demos/js/tde/tdestar.js +317 -0
- package/demos/js/tde/tidalstream.js +356 -0
- package/demos/js/tde_old/blackhole.obj.js +354 -0
- package/demos/js/tde_old/debris.obj.js +791 -0
- package/demos/js/tde_old/flare.obj.js +239 -0
- package/demos/js/tde_old/index.js +448 -0
- package/demos/js/tde_old/star.obj.js +812 -0
- package/demos/js/tiles.js +312 -0
- package/demos/js/tweendemo.js +79 -0
- package/demos/js/visibility.js +102 -0
- package/demos/kerr.html +28 -0
- package/demos/lavalamp.html +27 -0
- package/demos/layouts.html +37 -0
- package/demos/logo.svg +4 -0
- package/demos/loop.html +84 -0
- package/demos/mondrian.html +32 -0
- package/demos/og_image.png +0 -0
- package/demos/opacity.html +36 -0
- package/demos/painter.html +39 -0
- package/demos/particles-showcase.html +28 -0
- package/demos/particles.html +24 -0
- package/demos/patterns.html +33 -0
- package/demos/penrose-game.html +31 -0
- package/demos/pipeline.html +737 -0
- package/demos/scene.html +33 -0
- package/demos/scenes.html +96 -0
- package/demos/schrodinger.html +27 -0
- package/demos/schwarzschild.html +27 -0
- package/demos/shapes.html +16 -0
- package/demos/space.html +85 -0
- package/demos/spacetime.html +27 -0
- package/demos/svgtween.html +29 -0
- package/demos/tde.html +28 -0
- package/demos/tiles.html +28 -0
- package/demos/transforms.html +400 -0
- package/demos/tween.html +45 -0
- package/demos/visibility.html +33 -0
- package/disk_example.png +0 -0
- package/docs/README.md +222 -0
- package/docs/concepts/architecture-overview.md +204 -0
- package/docs/concepts/lifecycle.md +255 -0
- package/docs/concepts/rendering-pipeline.md +279 -0
- package/docs/concepts/tde-zorder.md +106 -0
- package/docs/concepts/two-layer-architecture.md +229 -0
- package/docs/getting-started/first-game.md +354 -0
- package/docs/getting-started/hello-world.md +269 -0
- package/docs/getting-started/installation.md +157 -0
- package/docs/modules/collision/README.md +453 -0
- package/docs/modules/fluent/README.md +1075 -0
- package/docs/modules/game/README.md +303 -0
- package/docs/modules/isometric-camera.md +210 -0
- package/docs/modules/isometric.md +275 -0
- package/docs/modules/painter/README.md +328 -0
- package/docs/modules/particle/README.md +559 -0
- package/docs/modules/shapes/README.md +221 -0
- package/docs/modules/shapes/base/euclidian.md +123 -0
- package/docs/modules/shapes/base/geometry2d.md +204 -0
- package/docs/modules/shapes/base/renderable.md +215 -0
- package/docs/modules/shapes/base/shape.md +262 -0
- package/docs/modules/shapes/base/transformable.md +243 -0
- package/docs/modules/shapes/hierarchy.md +218 -0
- package/docs/modules/state/README.md +577 -0
- package/docs/modules/util/README.md +99 -0
- package/docs/modules/util/camera3d.md +412 -0
- package/docs/modules/util/scene3d.md +395 -0
- package/index.html +17 -0
- package/jsdoc.json +50 -0
- package/package.json +55 -0
- package/readme.md +599 -0
- package/scripts/build-demo.js +69 -0
- package/scripts/bundle4llm.js +276 -0
- package/scripts/clearconsole.js +48 -0
- package/src/collision/collision-system.js +332 -0
- package/src/collision/collision.js +303 -0
- package/src/collision/index.js +10 -0
- package/src/fluent/fluent-game.js +430 -0
- package/src/fluent/fluent-go.js +1060 -0
- package/src/fluent/fluent-layer.js +152 -0
- package/src/fluent/fluent-scene.js +291 -0
- package/src/fluent/index.js +98 -0
- package/src/fluent/sketch.js +380 -0
- package/src/game/game.js +467 -0
- package/src/game/index.js +49 -0
- package/src/game/objects/go.js +220 -0
- package/src/game/objects/imagego.js +30 -0
- package/src/game/objects/index.js +54 -0
- package/src/game/objects/isometric-scene.js +260 -0
- package/src/game/objects/layoutscene.js +549 -0
- package/src/game/objects/scene.js +175 -0
- package/src/game/objects/scene3d.js +118 -0
- package/src/game/objects/text.js +221 -0
- package/src/game/objects/wrapper.js +232 -0
- package/src/game/pipeline.js +243 -0
- package/src/game/ui/button.js +396 -0
- package/src/game/ui/cursor.js +93 -0
- package/src/game/ui/fps.js +91 -0
- package/src/game/ui/index.js +5 -0
- package/src/game/ui/togglebutton.js +93 -0
- package/src/game/ui/tooltip.js +249 -0
- package/src/index.js +25 -0
- package/src/io/events.js +20 -0
- package/src/io/index.js +86 -0
- package/src/io/input.js +70 -0
- package/src/io/keys.js +152 -0
- package/src/io/mouse.js +61 -0
- package/src/io/touch.js +39 -0
- package/src/logger/debugtab.js +138 -0
- package/src/logger/index.js +3 -0
- package/src/logger/loggable.js +47 -0
- package/src/logger/logger.js +113 -0
- package/src/math/complex.js +37 -0
- package/src/math/constants.js +1 -0
- package/src/math/fractal.js +1271 -0
- package/src/math/gr.js +201 -0
- package/src/math/heat.js +202 -0
- package/src/math/index.js +12 -0
- package/src/math/noise.js +433 -0
- package/src/math/orbital.js +191 -0
- package/src/math/patterns.js +1339 -0
- package/src/math/penrose.js +259 -0
- package/src/math/quantum.js +115 -0
- package/src/math/random.js +195 -0
- package/src/math/tensor.js +1009 -0
- package/src/mixins/anchor.js +131 -0
- package/src/mixins/draggable.js +72 -0
- package/src/mixins/index.js +2 -0
- package/src/motion/bezier.js +132 -0
- package/src/motion/bounce.js +58 -0
- package/src/motion/easing.js +349 -0
- package/src/motion/float.js +130 -0
- package/src/motion/follow.js +125 -0
- package/src/motion/hop.js +52 -0
- package/src/motion/index.js +82 -0
- package/src/motion/motion.js +1124 -0
- package/src/motion/orbit.js +49 -0
- package/src/motion/oscillate.js +39 -0
- package/src/motion/parabolic.js +141 -0
- package/src/motion/patrol.js +147 -0
- package/src/motion/pendulum.js +48 -0
- package/src/motion/pulse.js +88 -0
- package/src/motion/shake.js +83 -0
- package/src/motion/spiral.js +144 -0
- package/src/motion/spring.js +150 -0
- package/src/motion/swing.js +47 -0
- package/src/motion/tween.js +92 -0
- package/src/motion/tweenetik.js +139 -0
- package/src/motion/waypoint.js +210 -0
- package/src/painter/index.js +8 -0
- package/src/painter/painter.colors.js +331 -0
- package/src/painter/painter.effects.js +230 -0
- package/src/painter/painter.img.js +229 -0
- package/src/painter/painter.js +295 -0
- package/src/painter/painter.lines.js +189 -0
- package/src/painter/painter.opacity.js +41 -0
- package/src/painter/painter.shapes.js +277 -0
- package/src/painter/painter.text.js +273 -0
- package/src/particle/emitter.js +124 -0
- package/src/particle/index.js +11 -0
- package/src/particle/particle-system.js +322 -0
- package/src/particle/particle.js +71 -0
- package/src/particle/updaters.js +170 -0
- package/src/shapes/arc.js +43 -0
- package/src/shapes/arrow.js +33 -0
- package/src/shapes/bezier.js +42 -0
- package/src/shapes/circle.js +62 -0
- package/src/shapes/clouds.js +56 -0
- package/src/shapes/cone.js +219 -0
- package/src/shapes/cross.js +70 -0
- package/src/shapes/cube.js +244 -0
- package/src/shapes/cylinder.js +254 -0
- package/src/shapes/diamond.js +48 -0
- package/src/shapes/euclidian.js +111 -0
- package/src/shapes/figure.js +115 -0
- package/src/shapes/geometry.js +220 -0
- package/src/shapes/group.js +375 -0
- package/src/shapes/heart.js +42 -0
- package/src/shapes/hexagon.js +26 -0
- package/src/shapes/image.js +192 -0
- package/src/shapes/index.js +111 -0
- package/src/shapes/line.js +29 -0
- package/src/shapes/pattern.js +90 -0
- package/src/shapes/pin.js +44 -0
- package/src/shapes/poly.js +31 -0
- package/src/shapes/prism.js +226 -0
- package/src/shapes/rect.js +35 -0
- package/src/shapes/renderable.js +333 -0
- package/src/shapes/ring.js +26 -0
- package/src/shapes/roundrect.js +95 -0
- package/src/shapes/shape.js +117 -0
- package/src/shapes/slice.js +26 -0
- package/src/shapes/sphere.js +314 -0
- package/src/shapes/sphere3d.js +537 -0
- package/src/shapes/square.js +15 -0
- package/src/shapes/star.js +99 -0
- package/src/shapes/svg.js +408 -0
- package/src/shapes/text.js +553 -0
- package/src/shapes/traceable.js +83 -0
- package/src/shapes/transform.js +357 -0
- package/src/shapes/transformable.js +172 -0
- package/src/shapes/triangle.js +26 -0
- package/src/sound/index.js +17 -0
- package/src/sound/sound.js +473 -0
- package/src/sound/synth.analyzer.js +149 -0
- package/src/sound/synth.effects.js +207 -0
- package/src/sound/synth.envelope.js +59 -0
- package/src/sound/synth.js +229 -0
- package/src/sound/synth.musical.js +160 -0
- package/src/sound/synth.noise.js +85 -0
- package/src/sound/synth.oscillators.js +293 -0
- package/src/state/index.js +10 -0
- package/src/state/state-machine.js +371 -0
- package/src/util/camera3d.js +438 -0
- package/src/util/index.js +6 -0
- package/src/util/isometric-camera.js +235 -0
- package/src/util/layout.js +317 -0
- package/src/util/position.js +147 -0
- package/src/util/tasks.js +47 -0
- package/src/util/zindex.js +287 -0
- package/src/webgl/index.js +9 -0
- package/src/webgl/shaders/sphere-shaders.js +994 -0
- package/src/webgl/webgl-renderer.js +388 -0
- package/tde.png +0 -0
- package/test/math/orbital.test.js +61 -0
- package/test/math/tensor.test.js +114 -0
- package/test/particle/emitter.test.js +204 -0
- package/test/particle/particle-system.test.js +310 -0
- package/test/particle/particle.test.js +116 -0
- package/test/particle/updaters.test.js +386 -0
- package/test/setup.js +120 -0
- package/test/shapes/euclidian.test.js +44 -0
- package/test/shapes/geometry.test.js +86 -0
- package/test/shapes/group.test.js +86 -0
- package/test/shapes/rectangle.test.js +64 -0
- package/test/shapes/transform.test.js +379 -0
- package/test/util/camera3d.test.js +428 -0
- package/test/util/scene3d.test.js +352 -0
- package/types/collision.d.ts +249 -0
- package/types/common.d.ts +155 -0
- package/types/game.d.ts +497 -0
- package/types/index.d.ts +309 -0
- package/types/io.d.ts +188 -0
- package/types/logger.d.ts +127 -0
- package/types/math.d.ts +268 -0
- package/types/mixins.d.ts +92 -0
- package/types/motion.d.ts +678 -0
- package/types/painter.d.ts +378 -0
- package/types/shapes.d.ts +864 -0
- package/types/sound.d.ts +672 -0
- package/types/state.d.ts +251 -0
- package/types/util.d.ts +253 -0
- package/vite.config.js +50 -0
- 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
|
+
});
|