Skip to content

Commit ff72825

Browse files
cjw6kclaude
andcommitted
feat: Add Sun force vectors and alignment indicator for spring/neap
- Sun force vectors shown in orange (46% size of Moon's) during chapter 2 (spring/neap tides) and outside tutorial - Top-down alignment indicator inset shows Sun-Earth-Moon angle - Displays "SPRING" (aligned) or "NEAP" (90°) based on angle - Helps visualize why forces add (spring) or partially cancel (neap) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d089a93 commit ff72825

File tree

1 file changed

+193
-63
lines changed

1 file changed

+193
-63
lines changed

tidal-harmonics/src/components/canvas/ForceField.tsx

Lines changed: 193 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ interface ForceArrow {
1111
label: string;
1212
color: string;
1313
type: 'stretch' | 'compress';
14+
source: 'moon' | 'sun';
1415
}
1516

1617
export 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

Comments
 (0)