```javascript 'use client'; import React, { useState, useEffect, useRef, useMemo } from 'react'; // 常量定义 const VIEW_WIDTH = 1000; const VIEW_HEIGHT = 600; const MOON_RADIUS = 120; const CENTER_X = 350; // 月球中心X const CENTER_Y = 300; // 月球中心Y // 目标农历日期 const TARGET_DAYS = [1, 3, 5, 7, 10, 15, 18, 20, 23, 25, 27, 30]; // 辅助函数:生成星星 const generateStars = (count) => { return Array.from({ length: count }, () => ({ x: Math.random() * VIEW_WIDTH, y: Math.random() * VIEW_HEIGHT, r: Math.random() * 1.5 + 0.5, opacity: Math.random() * 0.5 + 0.3, blinkSpeed: Math.random() * 2 + 1, })); }; export const MoonPhaseAnimation = () => { // 状态管理 const [targetDay, setTargetDay] = useState(15); // 目标日期 const [displayDay, setDisplayDay] = useState(1); // 当前动画展示的日期(浮点数,用于动画插值) const [stars] = useState(() => generateStars(150)); // 动画循环引用 const requestRef = useRef(); const previousTimeRef = useRef(); // 动画核心逻辑 const animate = (time) => { if (previousTimeRef.current !== undefined) { const deltaTime = time - previousTimeRef.current; // 简单的缓动逻辑 (Lerp) setDisplayDay(prev => { const diff = targetDay - prev; // 如果差异很小,直接停止,节省计算 if (Math.abs(diff) < 0.05) return targetDay; // 速度系数,0.05 产生平滑的阻尼效果 return prev + diff * 0.08; }); } previousTimeRef.current = time; requestRef.current = requestAnimationFrame(animate); }; useEffect(() => { requestRef.current = requestAnimationFrame(animate); return () => cancelAnimationFrame(requestRef.current); }, [targetDay]); // 计算月相路径 // 核心算法:利用 SVG Path 绘制月球受光面 const getMoonPath = (day) => { // 将农历日期 1-30 映射到相位 0-1 (0=New, 0.5=Full, 1=New) // 农历约29.5天,这里简化为30天循环 const phase = (day - 1) / 29.5; const r = MOON_RADIUS; // 如果是朔月 (Day 1 or 30 roughly),几乎全黑 if (day <= 1 || day >= 29.5) { return ""; // 空路径,全黑 } // 上半月 (Waxing) 1 < day <= 15 if (day <= 15.5) { // 右半边圆弧固定 (从上顶点(0,-r)顺时针画到下顶点(0,r)) // 左半边是椭圆弧,根据日期从右凸变为左凸 // sweepX 计算:初一为r(右侧),初七八为0(中间),十五为-r(左侧) // 映射 day 1->15 到 1->-1 const progress = (day - 1) / 14; // 0 to 1 const sweepX = r * (1 - 2 * progress); // 路径构建: // M 0 -r (移动到顶点) // A r r 0 0 1 0 r (画右半圆弧到底部) // A |sweepX| r 0 0 (flag) 0 -r (画椭圆弧回顶部) // flag: 如果 sweepX > 0 (convex/gibbous) flag=1? No. // 实际上,上半月亮面在右。 // Day 1-7: Crescent (眉月). 亮部像眉毛。右边缘是圆,左边缘是凹椭圆。 // Day 7-15: Gibbous (盈凸). 右边缘是圆,左边缘是凸椭圆。 // 使用更通用的数学方法: // 亮面始终包含右半圆弧 (除了全黑)。 // 晨昏线(Terminator) x坐标 = -r * cos(phase * 2 * PI) // 简化逻辑: // 1. 画出右半圆 // 2. 加上/减去 左半部分的椭圆 const x = -r * Math.cos((day - 1) / 14.75 * Math.PI); // 构建路径字符串 // M 0 -r // A r r 0 0 1 0 r (右侧半圆) // A abs(x) r 0 0 (x<0 ? 0 : 1) 0 -r (晨昏线椭圆) const sweepFlag = x < 0 ? 0 : 1; return `M 0 -${r} A ${r} ${r} 0 0 1 0 ${r} A ${Math.abs(x)} ${r} 0 0 ${sweepFlag} 0 -${r}`; } // 下半月 (Waning) 15 < day <= 30 else { // 左半边圆弧固定 // 右半边是椭圆弧 const daysFromFull = day - 15.5; // x 从 -r (Full) 变到 r (New) const x = -r * Math.cos(daysFromFull / 14.75 * Math.PI); // M 0 -r // A r r 0 0 0 0 r (左侧半圆, sweep=0逆时针) // A abs(x) r 0 0 (x>0 ? 0 : 1) 0 -r const sweepFlag = x > 0 ? 0 : 1; return `M 0 -${r} A ${r} ${r} 0 0 0 0 ${r} A ${Math.abs(x)} ${r} 0 0 ${sweepFlag} 0 -${r}`; } }; // 获取文字描述 const getPhaseInfo = (d) => { const day = Math.round(d); let name = ""; let desc = ""; if (day === 1) { name = "新月 (朔)"; desc = "日月同经,无法看见"; } else if (day === 30) { name = "晦"; desc = "月亮不可见"; } else if (day >= 2 && day <= 6) { name = "蛾眉月"; desc = "亮面在右,形状如钩"; } else if (day >= 7 && day <= 9) { name = "上弦月"; desc = "右半圆亮,上半夜可见"; } else if (day >= 10 && day <= 14) { name = "盈凸月"; desc = "大部分亮,右侧饱满"; } else if (day >= 15 && day <= 16) { name = "满月 (望)"; desc = "日月相望,圆润明亮"; } else if (day >= 17 && day <= 21) { name = "亏凸月"; desc = "左侧饱满,开始亏缺"; } else if (day >= 22 && day <= 24) { name = "下弦月"; desc = "左半圆亮,下半夜可见"; } else if (day >= 25 && day <= 29) { name = "残月"; desc = "亮面在左,清晨可见"; } return { name, desc }; }; const { name, desc } = getPhaseInfo(displayDay); const isUpperMonth = displayDay <= 15.5; return ( {/* 1. 陨石坑纹理滤镜 - 增加月球真实感 */} {/* 2. 月球发光效果 */} {/* 3. 按钮渐变 */} {/* 背景:星空 */} {stars.map((star, i) => ( ))} {/* 装饰性网格线 */} SYSTEM: LUNAR_PHASE_VISUALIZER // V2.0 {/* 月球主体绘制区域 */} {/* 1. 阴影底色 (Dark Side) */} {/* 2. 纹理层 (Applied to the whole circle but masked later? No, draw light part with texture) */} {/* 实际上,为了效果好,我们先画一个暗的纹理球,再画亮的纹理球盖在上面 */} {/* 3. 亮面 (Light Side) - 使用计算出的 Path */} {/* 在亮面上叠加纹理,增加细节 */} {/* 4. 外部光晕环 */} {/* 文本信息展示区 - 左下侧 */} CURRENT_PHASE: {name} {desc} 农历 {Math.floor(displayDay) === 1 ? '初一' : Math.floor(displayDay) === 15 ? '十五' : Math.floor(displayDay) === 30 ? '三十' : `Day ${Math.floor(displayDay)}`} {/* 右侧控制面板 */} 月相演变控制 (Phase Control) {/* 规律总结面板 */} 月相变化规律 Summary {/* 上半月规律 */} 上半月 (初一 ~ 十五): ● 亮面在 右 (西) ● 由缺变圆 (Waxing) {/* 下半月规律 */} 下半月 (十六 ~ 三十): ● 亮面在 左 (东) ● 由圆变缺 (Waning) {/* 按钮网格 */} {TARGET_DAYS.map((day, i) => { const isActive = targetDay === day; const row = Math.floor(i / 6); // 2 rows const col = i % 6; const btnW = 35; const btnH = 35; const gap = 10; const x = col * (btnW + gap); const y = row * (btnH + gap); // 农历中文数字转换简化 const getDayName = (d) => { const map = {1:'初一', 3:'初三', 5:'初五', 7:'初七', 10:'初十', 15:'十五', 18:'十八', 20:'二十', 23:'廿三', 25:'廿五', 27:'廿七', 30:'三十'}; return map[d] || d; }; return ( setTargetDay(day)} style={{ cursor: 'pointer' }} > {getDayName(day)} {/* 选中时的光效 */} {isActive && } ); })} {/* 进度条指示器 */} Timeline {/* 动态数据面板 */} DATA LOG: Illumination: {Math.abs((15 - Math.abs(displayDay - 15)) / 15 * 100).toFixed(1)}% Age: {displayDay.toFixed(2)} days Visibility: {displayDay > 1 && displayDay < 29 ? 'Visible' : 'Invisible'} Direction: {isUpperMonth ? 'Waxing (+)' : 'Waning (-)'} ); }; ```