@@ -11,10 +11,11 @@ interface ForceArrow {
1111 label : string ;
1212 color : string ;
1313 type : 'stretch' | 'compress' ;
14+ source : 'moon' | 'sun' ;
1415}
1516
1617export function ForceField ( ) {
17- const { moonRaw } = useCelestialPositions ( ) ;
18+ const { moonRaw, sunRaw } = useCelestialPositions ( ) ;
1819 const { scale } = useScene ( ) ;
1920 const tutorialActive = useTutorialStore ( ( s ) => s . isActive ) ;
2021 const getCurrentStep = useTutorialStore ( ( s ) => s . getCurrentStep ) ;
@@ -23,95 +24,142 @@ export function ForceField() {
2324 const currentStep = getCurrentStep ( ) ;
2425 const showExplanation = ! tutorialActive || currentStep ?. step . id === 'ch1-differential' ;
2526
26- const arrows = useMemo ( ( ) => {
27+ // Show Sun forces in chapter 2 (spring/neap) or outside tutorial
28+ const showSunForces = ! tutorialActive || currentStep ?. chapter . id === 'ch2-sun' ;
29+
30+ const { moonArrows, sunArrows, sunMoonAngle } = useMemo ( ( ) => {
2731 const earthR = scale . EARTH_RADIUS ;
28- const arrowLength = earthR * 1.2 ;
32+ const moonArrowLength = earthR * 1.2 ;
33+ const sunArrowLength = moonArrowLength * 0.46 ; // Sun's tidal force is 46% of Moon's
2934
3035 // Direction to Moon (normalized)
3136 const toMoon = new Vector3 ( moonRaw . x , moonRaw . y , moonRaw . z ) . normalize ( ) ;
37+ // Direction to Sun (normalized)
38+ const toSun = new Vector3 ( sunRaw . x , sunRaw . y , sunRaw . z ) . normalize ( ) ;
39+
40+ // Calculate angle between Sun and Moon directions
41+ const angle = Math . acos ( Math . abs ( toMoon . dot ( toSun ) ) ) * ( 180 / Math . PI ) ;
3242
33- // Perpendicular directions (for compression zones)
43+ // Perpendicular directions for Moon (for compression zones)
3444 const up = new Vector3 ( 0 , 1 , 0 ) ;
35- const perp1 = new Vector3 ( ) . crossVectors ( toMoon , up ) . normalize ( ) ;
36- if ( perp1 . length ( ) < 0.1 ) {
37- perp1 . crossVectors ( toMoon , new Vector3 ( 1 , 0 , 0 ) ) . normalize ( ) ;
45+ const moonPerp1 = new Vector3 ( ) . crossVectors ( toMoon , up ) . normalize ( ) ;
46+ if ( moonPerp1 . length ( ) < 0.1 ) {
47+ moonPerp1 . crossVectors ( toMoon , new Vector3 ( 1 , 0 , 0 ) ) . normalize ( ) ;
48+ }
49+ const moonPerp2 = new Vector3 ( ) . crossVectors ( toMoon , moonPerp1 ) . normalize ( ) ;
50+
51+ // Perpendicular directions for Sun
52+ const sunPerp1 = new Vector3 ( ) . crossVectors ( toSun , up ) . normalize ( ) ;
53+ if ( sunPerp1 . length ( ) < 0.1 ) {
54+ sunPerp1 . crossVectors ( toSun , new Vector3 ( 1 , 0 , 0 ) ) . normalize ( ) ;
3855 }
39- const perp2 = new Vector3 ( ) . crossVectors ( toMoon , perp1 ) . normalize ( ) ;
40-
41- const result : ForceArrow [ ] = [ ] ;
42-
43- // === NEAR SIDE (facing Moon) - STRETCH OUTWARD ===
44- const nearPos = toMoon . clone ( ) . multiplyScalar ( earthR ) ;
45- const nearEnd = toMoon . clone ( ) . multiplyScalar ( earthR + arrowLength ) ;
46- result . push ( {
47- start : [ nearPos . x , nearPos . y , nearPos . z ] ,
48- end : [ nearEnd . x , nearEnd . y , nearEnd . z ] ,
49- label : 'Pulled toward Moon' ,
50- color : '#ef4444' , // red
56+ const sunPerp2 = new Vector3 ( ) . crossVectors ( toSun , sunPerp1 ) . normalize ( ) ;
57+
58+ const moonResult : ForceArrow [ ] = [ ] ;
59+ const sunResult : ForceArrow [ ] = [ ] ;
60+
61+ // === MOON FORCES (red/blue) ===
62+ // Near side stretch
63+ const moonNearPos = toMoon . clone ( ) . multiplyScalar ( earthR ) ;
64+ const moonNearEnd = toMoon . clone ( ) . multiplyScalar ( earthR + moonArrowLength ) ;
65+ moonResult . push ( {
66+ start : [ moonNearPos . x , moonNearPos . y , moonNearPos . z ] ,
67+ end : [ moonNearEnd . x , moonNearEnd . y , moonNearEnd . z ] ,
68+ label : 'Moon pull' ,
69+ color : '#ef4444' ,
5170 type : 'stretch' ,
71+ source : 'moon' ,
5272 } ) ;
5373
54- // === FAR SIDE (opposite Moon) - STRETCH OUTWARD (away from Moon) ===
55- const farPos = toMoon . clone ( ) . multiplyScalar ( - earthR ) ;
56- const farEnd = toMoon . clone ( ) . multiplyScalar ( - earthR - arrowLength ) ;
57- result . push ( {
58- start : [ farPos . x , farPos . y , farPos . z ] ,
59- end : [ farEnd . x , farEnd . y , farEnd . z ] ,
60- label : 'Pulled less, bulges out ' ,
61- color : '#ef4444' , // red
74+ // Far side stretch
75+ const moonFarPos = toMoon . clone ( ) . multiplyScalar ( - earthR ) ;
76+ const moonFarEnd = toMoon . clone ( ) . multiplyScalar ( - earthR - moonArrowLength ) ;
77+ moonResult . push ( {
78+ start : [ moonFarPos . x , moonFarPos . y , moonFarPos . z ] ,
79+ end : [ moonFarEnd . x , moonFarEnd . y , moonFarEnd . z ] ,
80+ label : '' ,
81+ color : '#ef4444' ,
6282 type : 'stretch' ,
83+ source : 'moon' ,
6384 } ) ;
6485
65- // === PERPENDICULAR ZONES - COMPRESSION (arrows point toward center) ===
66- // Only label one arrow - the visual pattern is self-explanatory
67- const compressLength = earthR * 0.8 ;
68-
69- // Top (labeled)
70- const topPos = perp2 . clone ( ) . multiplyScalar ( earthR ) ;
71- const topEnd = perp2 . clone ( ) . multiplyScalar ( earthR - compressLength ) ;
72- result . push ( {
73- start : [ topPos . x , topPos . y , topPos . z ] ,
74- end : [ topEnd . x , topEnd . y , topEnd . z ] ,
75- label : 'Compressed' ,
76- color : '#3b82f6' , // blue
86+ // Moon compression (simplified - just top/bottom)
87+ const moonCompressLength = earthR * 0.8 ;
88+ const moonTopPos = moonPerp2 . clone ( ) . multiplyScalar ( earthR ) ;
89+ const moonTopEnd = moonPerp2 . clone ( ) . multiplyScalar ( earthR - moonCompressLength ) ;
90+ moonResult . push ( {
91+ start : [ moonTopPos . x , moonTopPos . y , moonTopPos . z ] ,
92+ end : [ moonTopEnd . x , moonTopEnd . y , moonTopEnd . z ] ,
93+ label : '' ,
94+ color : '#3b82f6' ,
7795 type : 'compress' ,
96+ source : 'moon' ,
7897 } ) ;
7998
80- // Bottom (no label - arrow speaks for itself)
81- const botPos = perp2 . clone ( ) . multiplyScalar ( - earthR ) ;
82- const botEnd = perp2 . clone ( ) . multiplyScalar ( - earthR + compressLength ) ;
83- result . push ( {
84- start : [ botPos . x , botPos . y , botPos . z ] ,
85- end : [ botEnd . x , botEnd . y , botEnd . z ] ,
99+ const moonBotPos = moonPerp2 . clone ( ) . multiplyScalar ( - earthR ) ;
100+ const moonBotEnd = moonPerp2 . clone ( ) . multiplyScalar ( - earthR + moonCompressLength ) ;
101+ moonResult . push ( {
102+ start : [ moonBotPos . x , moonBotPos . y , moonBotPos . z ] ,
103+ end : [ moonBotEnd . x , moonBotEnd . y , moonBotEnd . z ] ,
86104 label : '' ,
87- color : '#3b82f6' , // blue
105+ color : '#3b82f6' ,
88106 type : 'compress' ,
107+ source : 'moon' ,
89108 } ) ;
90109
91- // Side 1 (no label)
92- const side1Pos = perp1 . clone ( ) . multiplyScalar ( earthR ) ;
93- const side1End = perp1 . clone ( ) . multiplyScalar ( earthR - compressLength ) ;
94- result . push ( {
95- start : [ side1Pos . x , side1Pos . y , side1Pos . z ] ,
96- end : [ side1End . x , side1End . y , side1End . z ] ,
110+ // === SUN FORCES (orange/yellow, 46% size) ===
111+ // Near side stretch
112+ const sunNearPos = toSun . clone ( ) . multiplyScalar ( earthR ) ;
113+ const sunNearEnd = toSun . clone ( ) . multiplyScalar ( earthR + sunArrowLength ) ;
114+ sunResult . push ( {
115+ start : [ sunNearPos . x , sunNearPos . y , sunNearPos . z ] ,
116+ end : [ sunNearEnd . x , sunNearEnd . y , sunNearEnd . z ] ,
117+ label : 'Sun pull' ,
118+ color : '#f97316' ,
119+ type : 'stretch' ,
120+ source : 'sun' ,
121+ } ) ;
122+
123+ // Far side stretch
124+ const sunFarPos = toSun . clone ( ) . multiplyScalar ( - earthR ) ;
125+ const sunFarEnd = toSun . clone ( ) . multiplyScalar ( - earthR - sunArrowLength ) ;
126+ sunResult . push ( {
127+ start : [ sunFarPos . x , sunFarPos . y , sunFarPos . z ] ,
128+ end : [ sunFarEnd . x , sunFarEnd . y , sunFarEnd . z ] ,
129+ label : '' ,
130+ color : '#f97316' ,
131+ type : 'stretch' ,
132+ source : 'sun' ,
133+ } ) ;
134+
135+ // Sun compression
136+ const sunCompressLength = moonCompressLength * 0.46 ;
137+ const sunTopPos = sunPerp2 . clone ( ) . multiplyScalar ( earthR ) ;
138+ const sunTopEnd = sunPerp2 . clone ( ) . multiplyScalar ( earthR - sunCompressLength ) ;
139+ sunResult . push ( {
140+ start : [ sunTopPos . x , sunTopPos . y , sunTopPos . z ] ,
141+ end : [ sunTopEnd . x , sunTopEnd . y , sunTopEnd . z ] ,
97142 label : '' ,
98- color : '#3b82f6' , // blue
143+ color : '#fbbf24' ,
99144 type : 'compress' ,
145+ source : 'sun' ,
100146 } ) ;
101147
102- // Side 2 (no label)
103- const side2Pos = perp1 . clone ( ) . multiplyScalar ( - earthR ) ;
104- const side2End = perp1 . clone ( ) . multiplyScalar ( - earthR + compressLength ) ;
105- result . push ( {
106- start : [ side2Pos . x , side2Pos . y , side2Pos . z ] ,
107- end : [ side2End . x , side2End . y , side2End . z ] ,
148+ const sunBotPos = sunPerp2 . clone ( ) . multiplyScalar ( - earthR ) ;
149+ const sunBotEnd = sunPerp2 . clone ( ) . multiplyScalar ( - earthR + sunCompressLength ) ;
150+ sunResult . push ( {
151+ start : [ sunBotPos . x , sunBotPos . y , sunBotPos . z ] ,
152+ end : [ sunBotEnd . x , sunBotEnd . y , sunBotEnd . z ] ,
108153 label : '' ,
109- color : '#3b82f6' , // blue
154+ color : '#fbbf24' ,
110155 type : 'compress' ,
156+ source : 'sun' ,
111157 } ) ;
112158
113- return result ;
114- } , [ moonRaw , scale ] ) ;
159+ return { moonArrows : moonResult , sunArrows : sunResult , sunMoonAngle : angle } ;
160+ } , [ moonRaw , sunRaw , scale ] ) ;
161+
162+ const allArrows = showSunForces ? [ ...moonArrows , ...sunArrows ] : moonArrows ;
115163
116164 return (
117165 < group >
@@ -150,8 +198,90 @@ export function ForceField() {
150198 </ Html >
151199 ) }
152200
201+ { /* Alignment indicator - shows Sun-Earth-Moon angle (top-right corner) */ }
202+ { showSunForces && (
203+ < Html
204+ position = { [ 0 , 0 , 0 ] }
205+ style = { {
206+ position : 'fixed' ,
207+ top : '80px' ,
208+ right : '380px' ,
209+ pointerEvents : 'none' ,
210+ } }
211+ zIndexRange = { [ 1 , 10 ] }
212+ >
213+ < div
214+ style = { {
215+ background : 'rgba(15, 23, 42, 0.95)' ,
216+ padding : '12px' ,
217+ borderRadius : '8px' ,
218+ border : '1px solid rgba(100, 116, 139, 0.3)' ,
219+ width : '120px' ,
220+ } }
221+ >
222+ < div style = { { color : 'white' , fontSize : '10px' , fontWeight : 'bold' , marginBottom : '8px' , textAlign : 'center' } } >
223+ Top View
224+ </ div >
225+ < svg viewBox = "0 0 100 100" width = "96" height = "96" >
226+ { /* Earth at center */ }
227+ < circle cx = "50" cy = "50" r = "12" fill = "#3b82f6" />
228+ < text x = "50" y = "54" textAnchor = "middle" fill = "white" fontSize = "8" > E</ text >
229+
230+ { /* Moon direction (from moonRaw, projected to 2D) */ }
231+ < line
232+ x1 = "50"
233+ y1 = "50"
234+ x2 = { 50 + ( moonRaw . x / Math . sqrt ( moonRaw . x * moonRaw . x + moonRaw . z * moonRaw . z || 1 ) ) * 35 }
235+ y2 = { 50 - ( moonRaw . z / Math . sqrt ( moonRaw . x * moonRaw . x + moonRaw . z * moonRaw . z || 1 ) ) * 35 }
236+ stroke = "#94a3b8"
237+ strokeWidth = "2"
238+ />
239+ < circle
240+ cx = { 50 + ( moonRaw . x / Math . sqrt ( moonRaw . x * moonRaw . x + moonRaw . z * moonRaw . z || 1 ) ) * 35 }
241+ cy = { 50 - ( moonRaw . z / Math . sqrt ( moonRaw . x * moonRaw . x + moonRaw . z * moonRaw . z || 1 ) ) * 35 }
242+ r = "6"
243+ fill = "#94a3b8"
244+ />
245+ < text
246+ x = { 50 + ( moonRaw . x / Math . sqrt ( moonRaw . x * moonRaw . x + moonRaw . z * moonRaw . z || 1 ) ) * 35 }
247+ y = { 54 - ( moonRaw . z / Math . sqrt ( moonRaw . x * moonRaw . x + moonRaw . z * moonRaw . z || 1 ) ) * 35 }
248+ textAnchor = "middle"
249+ fill = "white"
250+ fontSize = "6"
251+ >
252+ M
253+ </ text >
254+
255+ { /* Sun direction */ }
256+ < line
257+ x1 = "50"
258+ y1 = "50"
259+ x2 = { 50 + ( sunRaw . x / Math . sqrt ( sunRaw . x * sunRaw . x + sunRaw . z * sunRaw . z || 1 ) ) * 40 }
260+ y2 = { 50 - ( sunRaw . z / Math . sqrt ( sunRaw . x * sunRaw . x + sunRaw . z * sunRaw . z || 1 ) ) * 40 }
261+ stroke = "#f97316"
262+ strokeWidth = "1.5"
263+ strokeDasharray = "3,2"
264+ />
265+ < circle
266+ cx = { 50 + ( sunRaw . x / Math . sqrt ( sunRaw . x * sunRaw . x + sunRaw . z * sunRaw . z || 1 ) ) * 40 }
267+ cy = { 50 - ( sunRaw . z / Math . sqrt ( sunRaw . x * sunRaw . x + sunRaw . z * sunRaw . z || 1 ) ) * 40 }
268+ r = "5"
269+ fill = "#f97316"
270+ />
271+ </ svg >
272+ < div style = { { color : '#94a3b8' , fontSize : '9px' , textAlign : 'center' , marginTop : '4px' } } >
273+ Angle: { sunMoonAngle . toFixed ( 0 ) } °
274+ < br />
275+ < span style = { { color : sunMoonAngle < 30 || sunMoonAngle > 150 ? '#22c55e' : sunMoonAngle > 60 && sunMoonAngle < 120 ? '#f97316' : '#94a3b8' } } >
276+ { sunMoonAngle < 30 || sunMoonAngle > 150 ? 'SPRING' : sunMoonAngle > 60 && sunMoonAngle < 120 ? 'NEAP' : '' }
277+ </ span >
278+ </ div >
279+ </ div >
280+ </ Html >
281+ ) }
282+
153283 { /* Force arrows */ }
154- { arrows . map ( ( arrow , i ) => {
284+ { allArrows . map ( ( arrow , i ) => {
155285 const dir = new Vector3 (
156286 arrow . end [ 0 ] - arrow . start [ 0 ] ,
157287 arrow . end [ 1 ] - arrow . start [ 1 ] ,
0 commit comments