mirror of
https://github.com/arthur-pbty/moon.git
synced 2026-06-03 23:36:19 +02:00
204 lines
7.1 KiB
TypeScript
204 lines
7.1 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback } from 'react';
|
|
import { useLocale } from './LocaleProvider';
|
|
import { ts } from '@/lib/i18n';
|
|
import { getMoonPhaseInfo, getMoonZodiacSign } from '@/lib/lunar';
|
|
|
|
type PhaseDescKey =
|
|
| 'phase_desc_new'
|
|
| 'phase_desc_waxing_crescent'
|
|
| 'phase_desc_first_quarter'
|
|
| 'phase_desc_waxing_gibbous'
|
|
| 'phase_desc_full'
|
|
| 'phase_desc_waning_gibbous'
|
|
| 'phase_desc_last_quarter'
|
|
| 'phase_desc_waning_crescent';
|
|
|
|
const PHASE_DESC_MAP: Record<string, PhaseDescKey> = {
|
|
new_moon: 'phase_desc_new',
|
|
waxing_crescent: 'phase_desc_waxing_crescent',
|
|
first_quarter: 'phase_desc_first_quarter',
|
|
waxing_gibbous: 'phase_desc_waxing_gibbous',
|
|
full_moon: 'phase_desc_full',
|
|
waning_gibbous: 'phase_desc_waning_gibbous',
|
|
last_quarter: 'phase_desc_last_quarter',
|
|
waning_crescent: 'phase_desc_waning_crescent',
|
|
};
|
|
|
|
const ZODIAC_EMOJI: Record<string, string> = {
|
|
Aries: '♈', Taurus: '♉', Gemini: '♊', Cancer: '♋',
|
|
Leo: '♌', Virgo: '♍', Libra: '♎', Scorpio: '♏',
|
|
Sagittarius: '♐', Capricorn: '♑', Aquarius: '♒', Pisces: '♓',
|
|
};
|
|
|
|
function MoonVisual({ phase, size = 160 }: { phase: number; size?: number }) {
|
|
const drawMoon = useCallback((ctx: CanvasRenderingContext2D) => {
|
|
const w = size;
|
|
const h = size;
|
|
const margin = 14;
|
|
const r = w / 2 - margin;
|
|
const cx = w / 2;
|
|
const cy = h / 2;
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
// Moon shadow (dark side)
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#1a1a2e';
|
|
ctx.fill();
|
|
|
|
// Lit surface
|
|
ctx.beginPath();
|
|
const angleRad = phase * 2 * Math.PI;
|
|
|
|
if (phase <= 0.5) {
|
|
// Waxing: right side lit
|
|
ctx.arc(cx, cy, r, -Math.PI / 2, Math.PI / 2, false);
|
|
const sweep = Math.cos(angleRad);
|
|
ctx.ellipse(cx, cy, Math.abs(sweep) * r, r, 0, Math.PI / 2, -Math.PI / 2, sweep < 0);
|
|
} else {
|
|
// Waning: left side lit
|
|
ctx.arc(cx, cy, r, Math.PI / 2, -Math.PI / 2, false);
|
|
const sweep = Math.cos(angleRad);
|
|
ctx.ellipse(cx, cy, Math.abs(sweep) * r, r, 0, -Math.PI / 2, Math.PI / 2, sweep > 0);
|
|
}
|
|
|
|
ctx.closePath();
|
|
|
|
// Gradient for realistic look
|
|
const grad = ctx.createRadialGradient(cx - r * 0.3, cy - r * 0.3, 0, cx, cy, r);
|
|
grad.addColorStop(0, '#fef9c3');
|
|
grad.addColorStop(0.5, '#fbbf24');
|
|
grad.addColorStop(1, '#b45309');
|
|
ctx.fillStyle = grad;
|
|
ctx.fill();
|
|
|
|
// Surface texture (craters)
|
|
const craters = [
|
|
{ x: 0.3, y: 0.35, r: 0.08 },
|
|
{ x: 0.6, y: 0.25, r: 0.06 },
|
|
{ x: 0.45, y: 0.6, r: 0.1 },
|
|
{ x: 0.7, y: 0.55, r: 0.05 },
|
|
{ x: 0.35, y: 0.75, r: 0.07 },
|
|
{ x: 0.55, y: 0.45, r: 0.04 },
|
|
];
|
|
|
|
craters.forEach(crater => {
|
|
ctx.beginPath();
|
|
ctx.arc(cx + (crater.x - 0.5) * 2 * r, cy + (crater.y - 0.5) * 2 * r, crater.r * r, 0, Math.PI * 2);
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
|
|
ctx.fill();
|
|
});
|
|
|
|
// Glow
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, r + 8, 0, Math.PI * 2);
|
|
const glowGrad = ctx.createRadialGradient(cx, cy, r - 2, cx, cy, r + 15);
|
|
glowGrad.addColorStop(0, 'rgba(251, 191, 36, 0.15)');
|
|
glowGrad.addColorStop(1, 'rgba(251, 191, 36, 0)');
|
|
ctx.fillStyle = glowGrad;
|
|
ctx.fill();
|
|
}, [phase, size]);
|
|
|
|
const canvasRef = useCallback((node: HTMLCanvasElement | null) => {
|
|
if (node) {
|
|
const ctx = node.getContext('2d');
|
|
if (ctx) drawMoon(ctx);
|
|
}
|
|
}, [drawMoon]);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={size}
|
|
height={size}
|
|
className="mx-auto"
|
|
style={{ width: size, height: size }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default function PhaseSimulator() {
|
|
const { locale } = useLocale();
|
|
const [date, setDate] = useState(() => {
|
|
const d = new Date();
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
});
|
|
|
|
const selectedDate = new Date(date + 'T12:00:00');
|
|
const phaseInfo = getMoonPhaseInfo(selectedDate);
|
|
const zodiac = getMoonZodiacSign(selectedDate);
|
|
const phaseDescKey: PhaseDescKey = PHASE_DESC_MAP[phaseInfo.phaseName] || 'phase_desc_full';
|
|
|
|
return (
|
|
<section id="simulator" aria-label="Interactive Moon Phase Simulator" className="section-container">
|
|
<div className="text-center mb-10">
|
|
<h2 className="section-title">{ts('simulator_title', locale)}</h2>
|
|
<p className="section-subtitle mx-auto">{ts('simulator_subtitle', locale)}</p>
|
|
</div>
|
|
|
|
<div className="glass-card max-w-2xl mx-auto p-4 sm:p-6 md:p-8">
|
|
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-8">
|
|
{/* Moon visual */}
|
|
<div className="shrink-0">
|
|
<MoonVisual phase={phaseInfo.phase} size={160} />
|
|
</div>
|
|
|
|
{/* Controls & info */}
|
|
<div className="flex-1 w-full space-y-6">
|
|
<div>
|
|
<label className="block text-sm text-white/50 mb-2">{ts('simulator_date', locale)}</label>
|
|
<input
|
|
type="date"
|
|
value={date}
|
|
onChange={(e) => setDate(e.target.value)}
|
|
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white focus:border-indigo-400/50 focus:outline-none transition-all"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="p-3 rounded-xl bg-white/5">
|
|
<p className="text-xs text-white/40 mb-1">{ts('current_phase', locale)}</p>
|
|
<p className="font-semibold flex items-center gap-2">
|
|
<span className="text-xl">{phaseInfo.emoji}</span>
|
|
{ts(phaseInfo.phaseName, locale)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-3 rounded-xl bg-white/5">
|
|
<p className="text-xs text-white/40 mb-1">{ts('simulator_illumination', locale)}</p>
|
|
<p className="font-semibold text-yellow-200">{phaseInfo.illumination}%</p>
|
|
<div className="w-full h-1.5 bg-white/10 rounded-full mt-2">
|
|
<div
|
|
className="h-full gradient-bar-illumination rounded-full transition-all duration-500"
|
|
style={{ width: `${phaseInfo.illumination}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-3 rounded-xl bg-white/5">
|
|
<p className="text-xs text-white/40 mb-1">{ts('simulator_age', locale)}</p>
|
|
<p className="font-semibold">{phaseInfo.age.toFixed(1)} {ts('simulator_days', locale)}</p>
|
|
</div>
|
|
|
|
<div className="p-3 rounded-xl bg-white/5">
|
|
<p className="text-xs text-white/40 mb-1">{ts('simulator_zodiac', locale)}</p>
|
|
<p className="font-semibold text-purple-300">{ZODIAC_EMOJI[zodiac] || '♈'} {zodiac}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Phase description */}
|
|
<div className="p-4 rounded-xl bg-indigo-500/10 border border-indigo-500/20">
|
|
<p className="text-sm text-white/70 leading-relaxed">
|
|
{ts(phaseDescKey, locale)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|