mirror of
https://github.com/arthur-pbty/moon.git
synced 2026-06-03 23:36:19 +02:00
first commit
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useLocale } from './LocaleProvider';
|
||||
import { ts } from '@/lib/i18n';
|
||||
|
||||
interface Article {
|
||||
id: string;
|
||||
emoji: string;
|
||||
title: Record<string, string>;
|
||||
summary: Record<string, string>;
|
||||
content: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const ARTICLES: Article[] = [
|
||||
{
|
||||
id: 'mythology',
|
||||
emoji: '🏛️',
|
||||
title: {
|
||||
en: 'Moon in World Mythology',
|
||||
fr: 'La Lune dans les mythologies du monde',
|
||||
},
|
||||
summary: {
|
||||
en: 'From Selene to Chang\'e, explore how cultures worshipped the Moon.',
|
||||
fr: 'De Séléné à Chang\'e, découvrez comment les cultures vénéraient la Lune.',
|
||||
},
|
||||
content: {
|
||||
en: [
|
||||
'The Moon has been a central figure in human mythology for millennia. Nearly every civilization developed rich stories to explain its cycles, its glow, and its influence on life.',
|
||||
'In Greek mythology, Selene was the Titan goddess of the Moon, driving her silver chariot across the night sky. She fell in love with the mortal Endymion, whom Zeus cast into eternal sleep so she could visit him each night.',
|
||||
'In Roman tradition, Luna and Diana (goddess of the hunt) were both associated with the Moon. Diana\'s triple aspect — maiden, mother, crone — reflected the waxing, full, and waning moon.',
|
||||
'Chinese mythology tells of Chang\'e, who swallowed an elixir of immortality and floated to the Moon, where she lives in the Moon Palace with a jade rabbit. The Mid-Autumn Festival celebrates her story.',
|
||||
'In Hindu mythology, Chandra (the Moon god) rides a chariot pulled by ten white horses across the sky. The waxing and waning moon represent Chandra\'s blessings and his curse from Ganesha.',
|
||||
'Norse mythology describes Máni, brother of Sól (Sun), who guides the Moon across the sky while being chased by the great wolf Hati. At Ragnarök, the wolf will finally catch him.',
|
||||
'In Japanese Shinto tradition, Tsukuyomi is the Moon god, sibling of Amaterasu (Sun). After killing the food goddess Uke Mochi, he was banished to the night sky, forever separated from the Sun.',
|
||||
'African traditions offer diverse Moon stories. In Bushmen mythology, the Moon is a man who angered the Sun, who sliced away pieces of him, explaining the phases. He slowly grows back each month.',
|
||||
],
|
||||
fr: [
|
||||
'La Lune a été une figure centrale dans la mythologie humaine pendant des millénaires. Presque chaque civilisation a développé des histoires riches pour expliquer ses cycles et son influence.',
|
||||
'Dans la mythologie grecque, Séléné était la déesse titanide de la Lune, conduisant son char d\'argent à travers le ciel nocturne. Elle tomba amoureuse du mortel Endymion, que Zeus plongea dans un sommeil éternel.',
|
||||
'Dans la tradition romaine, Luna et Diane (déesse de la chasse) étaient associées à la Lune. Le triple aspect de Diane — jeune fille, mère, vieille femme — reflétait la lune croissante, pleine et décroissante.',
|
||||
'La mythologie chinoise raconte l\'histoire de Chang\'e, qui avala un élixir d\'immortalité et s\'envola vers la Lune, où elle vit dans le Palais Lunaire avec un lapin de jade. La Fête de la Mi-Automne célèbre son histoire.',
|
||||
'Dans la mythologie hindoue, Chandra (le dieu de la Lune) chevauche un char tiré par dix chevaux blancs. Les phases croissantes et décroissantes représentent ses bénédictions et sa malédiction par Ganesha.',
|
||||
'La mythologie nordique décrit Máni, frère de Sól (Soleil), qui guide la Lune à travers le ciel tout en étant pourchassé par le grand loup Hati. Au Ragnarök, le loup l\'attrapera finalement.',
|
||||
'Dans la tradition shintoïste japonaise, Tsukuyomi est le dieu de la Lune, frère d\'Amaterasu (Soleil). Après avoir tué la déesse Uke Mochi, il fut banni dans le ciel nocturne, séparé du Soleil pour toujours.',
|
||||
'Les traditions africaines offrent des histoires lunaires variées. Dans la mythologie bochimane, la Lune est un homme qui a irrité le Soleil, lequel l\'a découpé en morceaux, expliquant les phases.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'photography',
|
||||
emoji: '📸',
|
||||
title: {
|
||||
en: 'Moon Photography Guide',
|
||||
fr: 'Guide de photographie lunaire',
|
||||
},
|
||||
summary: {
|
||||
en: 'Essential tips and settings for capturing stunning moon photos.',
|
||||
fr: 'Conseils essentiels et réglages pour capturer de superbes photos de lune.',
|
||||
},
|
||||
content: {
|
||||
en: [
|
||||
'Photographing the Moon is one of the most rewarding challenges in astrophotography. With the right technique and equipment, even beginners can capture stunning lunar images.',
|
||||
'📷 Equipment: A DSLR or mirrorless camera with manual controls is ideal. A telephoto lens (200mm+) or telescope with adapter will reveal surface details. A sturdy tripod is essential to eliminate camera shake.',
|
||||
'⚙️ Camera Settings: Start with ISO 100-200, aperture f/8-f/11, and shutter speed 1/125 to 1/250 second. The "Looney 11 Rule" suggests using f/11 at 1/ISO speed (e.g., at ISO 100, use 1/100s at f/11).',
|
||||
'🎯 Focus: Use manual focus set to infinity, then fine-tune using Live View at maximum zoom on the Moon\'s edge. Look for sharp crater detail. Auto-focus often fails on the Moon.',
|
||||
'🌗 Best Phases: While the full moon is iconic, the best detail shows during quarter phases. The terminator line (shadow boundary) reveals dramatic crater shadows and mountain textures.',
|
||||
'🕐 Timing: Shoot the Moon when it\'s high in the sky for least atmospheric distortion. The "golden hour" Moon near the horizon appears large but is actually distorted and less sharp.',
|
||||
'📐 The Moon Illusion: The Moon near the horizon looks huge but is actually the same angular size (~0.5°). To capture this dramatic effect, use a long telephoto (600mm+) with foreground elements like buildings or trees.',
|
||||
'💻 Post-Processing: Stack multiple exposures using software like RegiStax or AutoStakkert for incredible detail. Apply gentle sharpening and adjust levels to bring out mare and crater details.',
|
||||
],
|
||||
fr: [
|
||||
'Photographier la Lune est l\'un des défis les plus gratifiants en astrophotographie. Avec la bonne technique, même les débutants peuvent capturer de superbes images lunaires.',
|
||||
'📷 Équipement : Un appareil DSLR ou hybride avec contrôles manuels est idéal. Un téléobjectif (200mm+) ou télescope avec adaptateur révélera les détails de surface. Un trépied solide est essentiel.',
|
||||
'⚙️ Réglages : Commencez avec ISO 100-200, ouverture f/8-f/11, et vitesse 1/125 à 1/250s. La "règle Looney 11" suggère f/11 à 1/ISO (ex. ISO 100, vitesse 1/100s à f/11).',
|
||||
'🎯 Mise au point : Utilisez la mise au point manuelle sur l\'infini, puis affinez avec le Live View au zoom maximum sur le bord de la Lune. Cherchez des détails nets de cratères.',
|
||||
'🌗 Meilleures phases : Le premier ou dernier quartier révèle les meilleurs détails. La ligne du terminateur (limite d\'ombre) montre des ombres dramatiques de cratères et textures de montagnes.',
|
||||
'🕐 Timing : Photographiez la Lune quand elle est haute dans le ciel pour moins de distorsion atmosphérique. La Lune à l\'horizon paraît grande mais est déformée et moins nette.',
|
||||
'📐 L\'illusion lunaire : La Lune près de l\'horizon semble énorme mais a la même taille angulaire (~0,5°). Pour capturer cet effet, utilisez un long téléobjectif (600mm+) avec des éléments de premier plan.',
|
||||
'💻 Post-traitement : Empilez plusieurs expositions avec RegiStax ou AutoStakkert pour des détails incroyables. Appliquez un affûtage modéré et ajustez les niveaux pour révéler les mers et cratères.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gardening',
|
||||
emoji: '🌿',
|
||||
title: {
|
||||
en: 'Moon Gardening: Planting by Lunar Cycles',
|
||||
fr: 'Jardinage lunaire : planter selon les cycles',
|
||||
},
|
||||
summary: {
|
||||
en: 'How farmers and gardeners have used the Moon to guide planting for centuries.',
|
||||
fr: 'Comment les jardiniers utilisent la Lune pour guider les plantations depuis des siècles.',
|
||||
},
|
||||
content: {
|
||||
en: [
|
||||
'Lunar gardening is an ancient practice based on the idea that the Moon\'s gravitational pull affects moisture in soil, just as it affects ocean tides. Many farmers and gardeners worldwide still follow lunar cycles for planting.',
|
||||
'🌑 New Moon (Days 1-7): Rising moisture. Best time to plant above-ground crops with external seeds: lettuce, spinach, celery, broccoli, cabbage, cauliflower, and grain crops.',
|
||||
'🌓 First Quarter (Days 8-14): Strong leaf growth. Plant above-ground crops with internal seeds: beans, peas, peppers, squash, tomatoes, melons. The gravitational pull creates more moisture.',
|
||||
'🌕 Full Moon (Days 15-21): Peak moisture, then decreasing. Ideal for planting root crops: beets, carrots, onions, potatoes, radishes. Also good for transplanting and propagation. Moonlight peaks, boosting growth.',
|
||||
'🌗 Last Quarter (Days 22-28): Resting period. Focus on maintenance: weeding, pruning, harvesting, composting. Sap flow decreases, making it perfect for cutting back and clearing.',
|
||||
'🌲 Moon Signs in the Zodiac: Traditional lunar gardeners also consider which zodiac sign the Moon occupies. Water signs (Cancer, Scorpio, Pisces) are considered most fertile. Earth signs (Taurus, Virgo, Capricorn) are productive. Fire and air signs are best for cultivation and harvesting.',
|
||||
'🔬 Scientific Perspective: While controlled studies have shown mixed results, some research suggests that lunar gravity does affect water table levels and plant germination. The practice connects us to natural rhythms regardless of the science.',
|
||||
'📋 Quick Guide: Plant leafy crops during waxing moon, root crops during waning moon, and rest during the fourth quarter. Keep a garden journal to track your results across lunar cycles!',
|
||||
],
|
||||
fr: [
|
||||
'Le jardinage lunaire est une pratique ancestrale basée sur l\'idée que l\'attraction gravitationnelle de la Lune affecte l\'humidité du sol, comme elle affecte les marées. Beaucoup de jardiniers suivent encore les cycles lunaires.',
|
||||
'🌑 Nouvelle Lune (Jours 1-7) : Humidité montante. Meilleur moment pour planter les cultures aériennes à graines externes : laitue, épinard, céleri, brocoli, chou, chou-fleur et céréales.',
|
||||
'🌓 Premier Quartier (Jours 8-14) : Forte croissance foliaire. Planter les cultures aériennes à graines internes : haricots, pois, poivrons, courges, tomates, melons.',
|
||||
'🌕 Pleine Lune (Jours 15-21) : Pic d\'humidité puis décroissance. Idéal pour les légumes-racines : betteraves, carottes, oignons, pommes de terre, radis. Bon aussi pour transplanter.',
|
||||
'🌗 Dernier Quartier (Jours 22-28) : Période de repos. Se concentrer sur l\'entretien : désherbage, taille, récolte, compostage. Le flux de sève diminue.',
|
||||
'🌲 Signes lunaires du Zodiaque : Les jardiniers traditionnels considèrent aussi le signe zodiacal de la Lune. Les signes d\'eau (Cancer, Scorpion, Poissons) sont les plus fertiles.',
|
||||
'🔬 Perspective scientifique : Les études contrôlées montrent des résultats mitigés, mais certaines recherches suggèrent que la gravité lunaire affecte le niveau des nappes phréatiques.',
|
||||
'📋 Guide rapide : Plantez les cultures foliaires en lune croissante, les racines en lune décroissante, et reposez-vous au dernier quartier.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'eclipses',
|
||||
emoji: '🌑',
|
||||
title: {
|
||||
en: 'Lunar Eclipses: Blood Moons Explained',
|
||||
fr: 'Éclipses lunaires : les lunes de sang expliquées',
|
||||
},
|
||||
summary: {
|
||||
en: 'The science behind lunar eclipses and why the Moon turns red.',
|
||||
fr: 'La science derrière les éclipses lunaires et pourquoi la Lune rougit.',
|
||||
},
|
||||
content: {
|
||||
en: [
|
||||
'A lunar eclipse occurs when Earth passes between the Sun and Moon, casting its shadow on the lunar surface. Unlike solar eclipses, lunar eclipses are visible from anywhere on Earth\'s night side and are safe to watch with naked eyes.',
|
||||
'🔴 Why "Blood Moon"? During a total lunar eclipse, the Moon doesn\'t disappear — it turns a deep red. This happens because Earth\'s atmosphere bends (refracts) sunlight around the planet. Blue light is scattered away while red wavelengths pass through and illuminate the Moon.',
|
||||
'🌍 Types: Penumbral eclipses are subtle darkening. Partial eclipses show Earth\'s curved shadow. Total eclipses are the spectacular "blood moons" lasting up to 1 hour 42 minutes.',
|
||||
'📊 The Danjon Scale rates the darkness of lunar eclipses from L0 (very dark, nearly invisible) to L4 (bright copper-red with bluish edges). Atmospheric conditions like volcanic ash affect the rating.',
|
||||
'🔄 Saros Cycle: Lunar eclipses repeat in an 18-year, 11-day cycle called a Saros. Each Saros series produces 70-85 eclipses over about 1,300 years. Knowing the Saros helps predict future eclipses.',
|
||||
'📅 Frequency: On average, there are 2-3 lunar eclipses per year, but total lunar eclipses are rarer — about one every 2.5 years on average. A "tetrad" is four consecutive total eclipses without partial eclipses between them.',
|
||||
'🏛️ Historical Significance: Christopher Columbus used his knowledge of a 1504 lunar eclipse to convince Jamaican natives to provide supplies. Chinese and Babylonian astronomers tracked eclipses for predictive astronomy.',
|
||||
'🔭 Observing Tips: No special equipment needed! Binoculars enhance the red color beautifully. Photograph with settings similar to full moon photography but with longer exposures (1-4 seconds) during totality.',
|
||||
],
|
||||
fr: [
|
||||
'Une éclipse lunaire se produit quand la Terre passe entre le Soleil et la Lune, projetant son ombre sur la surface lunaire. Contrairement aux éclipses solaires, elles sont visibles partout côté nuit et sans danger pour les yeux.',
|
||||
'🔴 Pourquoi "Lune de sang" ? Pendant une éclipse totale, la Lune ne disparaît pas — elle devient rouge profond. L\'atmosphère terrestre dévie la lumière, diffusant le bleu et laissant passer le rouge qui illumine la Lune.',
|
||||
'🌍 Types : Les éclipses pénombrales sont subtiles. Les partielles montrent l\'ombre courbe de la Terre. Les totales sont les spectaculaires "lunes de sang" durant jusqu\'à 1h42.',
|
||||
'📊 L\'échelle de Danjon évalue l\'obscurité des éclipses de L0 (très sombre) à L4 (cuivre brillant avec bords bleutés). Les conditions atmosphériques comme les cendres volcaniques affectent la notation.',
|
||||
'🔄 Cycle de Saros : Les éclipses se répètent dans un cycle de 18 ans et 11 jours appelé Saros. Chaque série produit 70-85 éclipses sur environ 1 300 ans.',
|
||||
'📅 Fréquence : En moyenne 2-3 éclipses lunaires par an, mais les totales sont plus rares — environ une tous les 2,5 ans. Une "tétrade" est quatre éclipses totales consécutives.',
|
||||
'🏛️ Importance historique : Christophe Colomb utilisa sa connaissance d\'une éclipse de 1504 pour convaincre les Jamaïcains de fournir des provisions. Les astronomes chinois et babyloniens suivaient les éclipses.',
|
||||
'🔭 Conseils d\'observation : Aucun équipement spécial nécessaire ! Les jumelles magnifient la couleur rouge. Photographiez avec des expositions plus longues (1-4s) pendant la totalité.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'seo2026',
|
||||
emoji: '📅',
|
||||
title: {
|
||||
en: 'Moon Searches 2026: Dates, Times and Eclipse Queries',
|
||||
fr: 'Recherches lune 2026 : dates, heures et éclipse lunaire',
|
||||
},
|
||||
summary: {
|
||||
en: 'Quick answers to common searches: full moon March 2026, blood moon time, next full moon, and weekly lunar planning.',
|
||||
fr: 'Réponses rapides aux recherches fréquentes : pleine lune mars 2026, heure de la lune rouge, prochaine pleine lune et planning hebdo.',
|
||||
},
|
||||
content: {
|
||||
en: [
|
||||
'People often search for: full moon eclipse, full moon March 2026, blood moon 2026, lunar eclipse 2026, next full moon, and what time the red moon appears. This page groups all these intents in one place with updated lunar calculations.',
|
||||
'Use the lunar calendar to check exact full moon dates and local time. Use the simulator for moon appearance on a specific day, and the visibility map to know where an eclipse can be observed.',
|
||||
],
|
||||
fr: [
|
||||
'Requêtes fréquentes traitées sur cette page : pleine lune eclipse lunaire, pleine lune mars 2026, lune rouge 3 mars, lune rouge, pleine lune, eclipse lunaire 2026, lune de sang, lune rouge 3 mars heure, pleine lune 3 mars 2026, lune de sang 2026, eclipse lunaire 3 mars 2026, lune, pleine lune mars, pleine lune 2026, lune de sang 3 mars, a quelle heure la lune de sang, date pleine lune mars 2026, lune 3 mars 2026, lune rouge 3 mars 2026 heure, la lune rouge, lune rouge heure, prochaine pleine lune, horoscope, semaine 2026.',
|
||||
'Important : les heures d\'éclipse et de pleine lune dépendent de la ville et du fuseau horaire. Le simulateur et le calendrier donnent la conversion locale pour vérifier rapidement la bonne heure.',
|
||||
'Pour les recherches type horoscope et semaine 2026, nous proposons une lecture lunaire hebdomadaire basée sur les phases (nouvelle lune, premier quartier, pleine lune, dernier quartier).',
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function Articles() {
|
||||
const { locale } = useLocale();
|
||||
const lang = (locale === 'fr') ? 'fr' : 'en';
|
||||
const [openArticle, setOpenArticle] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<section id="articles" aria-label="Articles about the Moon" className="section-container">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="section-title">{ts('articles_title', locale)}</h2>
|
||||
<p className="section-subtitle mx-auto">{ts('articles_subtitle', locale)}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 max-w-5xl mx-auto">
|
||||
{ARTICLES.map((article) => (
|
||||
<div key={article.id} className="glass-card p-4 sm:p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<span className="text-4xl">{article.emoji}</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold mb-2">{article.title[lang] || article.title.en}</h3>
|
||||
<p className="text-white/50 text-sm">{article.summary[lang] || article.summary.en}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{openArticle === article.id ? (
|
||||
<>
|
||||
<div className="space-y-4 mt-6 pt-6 border-t border-white/10">
|
||||
{(article.content[lang] || article.content.en).map((para, i) => (
|
||||
<p key={i} className="text-white/70 text-sm leading-relaxed">{para}</p>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpenArticle(null)}
|
||||
className="mt-6 text-indigo-300 hover:text-indigo-200 text-sm font-medium"
|
||||
>
|
||||
← {locale === 'fr' ? 'Réduire' : 'Collapse'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setOpenArticle(article.id)}
|
||||
className="glow-btn mt-4 text-sm px-6 py-2"
|
||||
>
|
||||
{ts('articles_read', locale)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLocale } from './LocaleProvider';
|
||||
import { ts } from '@/lib/i18n';
|
||||
import { getFullMoonsForYear, FULL_MOON_NAMES } from '@/lib/lunar';
|
||||
|
||||
const MOON_DESCRIPTIONS: Record<number, Record<string, { cultural: string; effects: string; ritual: string }>> = {
|
||||
1: {
|
||||
en: {
|
||||
cultural: "Named by Native Americans for the howling wolves of winter. In Celtic tradition, it's the Quiet Moon. Hindu cultures associate it with Paush Purnima, a time of spiritual reflection.",
|
||||
effects: "Winter full moons appear higher and brighter in the sky. Tidal ranges increase, and nocturnal animals become more active during cold clear nights.",
|
||||
ritual: "A time for setting intentions for the new year, meditation by moonlight, and journaling about personal goals."
|
||||
},
|
||||
fr: {
|
||||
cultural: "Nommée par les Amérindiens pour les loups hurlants de l'hiver. Chez les Celtes, c'est la Lune Tranquille. Les cultures hindoues l'associent à Paush Purnima.",
|
||||
effects: "Les pleines lunes d'hiver apparaissent plus hautes et brillantes. Les marées augmentent et les animaux nocturnes sont plus actifs.",
|
||||
ritual: "Moment idéal pour fixer ses intentions, méditer au clair de lune et réfléchir à ses objectifs personnels."
|
||||
}
|
||||
},
|
||||
2: {
|
||||
en: {
|
||||
cultural: "Named for heavy February snowfall. Also called the Hunger Moon by some tribes, as food was scarce. In Chinese tradition, it's associated with the Lantern Festival.",
|
||||
effects: "Snow reflects moonlight, creating exceptionally bright nights. Ocean tides remain strong, and migration patterns begin shifting.",
|
||||
ritual: "Time for purification rituals, releasing old habits, and preparing for the coming spring renewal."
|
||||
},
|
||||
fr: {
|
||||
cultural: "Nommée pour les chutes de neige de février. Appelée aussi Lune de la Faim. En Chine, elle est associée à la Fête des Lanternes.",
|
||||
effects: "La neige reflète la lumière lunaire, créant des nuits exceptionnellement brillantes. Les marées restent fortes.",
|
||||
ritual: "Temps de purification, abandon des vieilles habitudes et préparation au renouveau printanier."
|
||||
}
|
||||
},
|
||||
3: {
|
||||
en: {
|
||||
cultural: "Named after earthworms emerging as soil thaws. Also known as the Sap Moon (maple sap flows) and the Crow Moon (crows signal spring).",
|
||||
effects: "Spring equinox tides combine with the full moon for dramatic coastal changes. Birds begin migration, influenced by moonlit navigation.",
|
||||
ritual: "A time for spring cleaning, both physical and spiritual. Plant seeds of intention and embrace new beginnings."
|
||||
},
|
||||
fr: {
|
||||
cultural: "Nommée d'après les vers de terre qui émergent au dégel. Aussi appelée Lune de la Sève et Lune du Corbeau.",
|
||||
effects: "Les marées d'équinoxe combinées à la pleine lune créent des changements côtiers spectaculaires.",
|
||||
ritual: "Temps du nettoyage de printemps, physique et spirituel. Plantez des graines d'intentions."
|
||||
}
|
||||
},
|
||||
4: {
|
||||
en: {
|
||||
cultural: "Named for pink wildflowers (phlox) blooming in spring. In Japan, it coincides with hanami (cherry blossom viewing) celebrations.",
|
||||
effects: "Warming waters respond to spring tides. Coral spawning events worldwide are synchronized with this full moon.",
|
||||
ritual: "Celebrate growth and beauty. Create art by moonlight, spend time in nature, and practice gratitude."
|
||||
},
|
||||
fr: {
|
||||
cultural: "Nommée pour les fleurs sauvages roses (phlox). Au Japon, elle coïncide avec le hanami (observation des cerisiers en fleurs).",
|
||||
effects: "Les eaux se réchauffent et les marées de printemps stimulent la ponte des coraux dans le monde entier.",
|
||||
ritual: "Célébrez la croissance et la beauté. Créez de l'art au clair de lune et pratiquez la gratitude."
|
||||
}
|
||||
},
|
||||
5: {
|
||||
en: {
|
||||
cultural: "Named for abundant spring flowers. Vesak, the most sacred Buddhist festival celebrating Buddha's birth, falls on this full moon.",
|
||||
effects: "Maximum biodiversity activity. Nocturnal pollination peaks, fireflies appear, and many marine species spawn.",
|
||||
ritual: "Honor abundance and connection to nature. Create flower offerings, dance under the moon, and express creativity."
|
||||
},
|
||||
fr: {
|
||||
cultural: "Nommée pour les fleurs printanières abondantes. Vesak, fête bouddhiste de la naissance de Bouddha, tombe durant cette pleine lune.",
|
||||
effects: "Activité maximale de biodiversité. Pollinisation nocturne à son pic, apparition des lucioles.",
|
||||
ritual: "Honorez l'abondance. Créez des offrandes florales, dansez sous la lune et exprimez votre créativité."
|
||||
}
|
||||
},
|
||||
6: {
|
||||
en: {
|
||||
cultural: "Named for strawberry harvest season. In Europe, it's the Rose Moon or Mead Moon. Hindu Vat Purnima celebrates marital devotion.",
|
||||
effects: "Summer solstice proximity creates unique low-hanging amber moons. Turtles use moonlight for beach nesting navigation.",
|
||||
ritual: "Celebrate love and relationships. Make strawberry mead, gather with loved ones, and honor summer's arrival."
|
||||
},
|
||||
fr: {
|
||||
cultural: "Nommée pour la saison des fraises. En Europe, c'est la Lune Rose. Le Vat Purnima hindou célèbre la dévotion conjugale.",
|
||||
effects: "Proximité du solstice d'été créant des lunes ambrées basses. Les tortues utilisent la lumière lunaire pour nicher.",
|
||||
ritual: "Célébrez l'amour. Préparez de l'hydromel aux fraises et honorez l'arrivée de l'été."
|
||||
}
|
||||
},
|
||||
7: {
|
||||
en: {
|
||||
cultural: "Named for bucks growing new antlers. Also the Thunder Moon for summer storms. In Hindu tradition, Guru Purnima honors spiritual teachers.",
|
||||
effects: "Warm summer waters amplify tidal effects. Dolphins and whales show increased activity during full moon nights.",
|
||||
ritual: "Express gratitude to mentors and teachers. Reflect on personal growth and strength gained during the year."
|
||||
},
|
||||
fr: {
|
||||
cultural: "Nommée pour les cerfs qui développent de nouveaux bois. Aussi la Lune du Tonnerre. Guru Purnima honore les maîtres spirituels.",
|
||||
effects: "Les eaux chaudes d'été amplifient les marées. Dauphins et baleines sont plus actifs les nuits de pleine lune.",
|
||||
ritual: "Exprimez votre gratitude envers vos mentors. Réfléchissez à votre croissance personnelle."
|
||||
}
|
||||
},
|
||||
8: {
|
||||
en: {
|
||||
cultural: "Named for Great Lakes sturgeon fishing season. Also the Green Corn Moon. In Sri Lanka, Nikini Poya commemorates the first Buddhist council.",
|
||||
effects: "Late summer full moons create spectacular reflections on calm waters. Fish feeding patterns peak at night.",
|
||||
ritual: "Celebrate harvest abundance. Prepare seasonal foods, give thanks, and share your bounty with others."
|
||||
},
|
||||
fr: {
|
||||
cultural: "Nommée pour la saison de pêche à l'esturgeon. Aussi la Lune du Maïs Vert. Au Sri Lanka, Nikini Poya commémore le premier concile bouddhiste.",
|
||||
effects: "Les pleines lunes de fin d'été créent des reflets spectaculaires sur les eaux calmes.",
|
||||
ritual: "Célébrez l'abondance de la récolte. Préparez des repas de saison et partagez avec les autres."
|
||||
}
|
||||
},
|
||||
9: {
|
||||
en: {
|
||||
cultural: "Named because it rises near sunset during harvest, providing extra light for farmers. The most famous full moon in many cultures worldwide.",
|
||||
effects: "Appears unusually large and orange near the horizon. Strongest combined gravitational pull with autumn equinox tides.",
|
||||
ritual: "The quintessential moon for harvest celebrations, Thanksgiving traditions, and honoring the Earth's abundance."
|
||||
},
|
||||
fr: {
|
||||
cultural: "Nommée car elle se lève près du coucher du soleil pendant la moisson, offrant de la lumière aux agriculteurs. La pleine lune la plus célèbre.",
|
||||
effects: "Paraît exceptionnellement grande et orange près de l'horizon. Force gravitationnelle maximale avec les marées d'équinoxe.",
|
||||
ritual: "La lune par excellence pour les célébrations de récolte et pour honorer l'abondance de la Terre."
|
||||
}
|
||||
},
|
||||
10: {
|
||||
en: {
|
||||
cultural: "Named because hunters tracked prey by autumn moonlight. In Chinese culture, this full moon period includes the Double Ninth Festival.",
|
||||
effects: "Crisp autumn air provides exceptional clarity for moongazing. Migratory birds use the full moon for nocturnal navigation.",
|
||||
ritual: "Plan for the winter ahead. Focus on determination, strategy, and gathering resources — physical and spiritual."
|
||||
},
|
||||
fr: {
|
||||
cultural: "Nommée car les chasseurs traquaient leur proie au clair de lune. En Chine, cette période inclut la Fête du Double Neuf.",
|
||||
effects: "L'air frais d'automne offre une clarté exceptionnelle. Les oiseaux migrateurs utilisent la pleine lune pour naviguer.",
|
||||
ritual: "Planifiez pour l'hiver. Concentrez-vous sur la détermination et la collecte de ressources."
|
||||
}
|
||||
},
|
||||
11: {
|
||||
en: {
|
||||
cultural: "Named for beaver trapping season before winter. Also the Frost Moon. In Hindu tradition, Kartik Purnima involves ceremonial lamp floating.",
|
||||
effects: "Longer nights make this moon visible for up to 15 hours. Cold waters produce stronger tidal forces.",
|
||||
ritual: "Time for building foundations, creating warmth and community, and preparing for the introspective winter months."
|
||||
},
|
||||
fr: {
|
||||
cultural: "Nommée pour la saison de piégeage des castors. Aussi la Lune du Gel. Kartik Purnima hindou implique des lampes flottantes.",
|
||||
effects: "Les longues nuits rendent cette lune visible jusqu'à 15 heures. Les eaux froides créent des marées plus fortes.",
|
||||
ritual: "Temps de construire des fondations, créer de la chaleur et se préparer à l'hiver introspectif."
|
||||
}
|
||||
},
|
||||
12: {
|
||||
en: {
|
||||
cultural: "Named for cold December nights. Also the Long Night Moon (longest nights of the year). In Buddhism, Bodhi Day (enlightenment) often falls near this moon.",
|
||||
effects: "Highest position in the sky of any full moon. Winter ice reflects moonlight, creating shimmering landscapes.",
|
||||
ritual: "Time for deep reflection, letting go of the past year, and meditating on inner light during the longest nights."
|
||||
},
|
||||
fr: {
|
||||
cultural: "Nommée pour les nuits froides de décembre. Aussi la Lune de la Longue Nuit. Le Jour de Bodhi bouddhiste tombe souvent près de cette lune.",
|
||||
effects: "Position la plus haute dans le ciel. La glace hivernale reflète la lumière, créant des paysages scintillants.",
|
||||
ritual: "Temps de réflexion profonde, lâcher prise sur l'année passée et méditer sur la lumière intérieure."
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default function FullMoonDescriptions() {
|
||||
const { locale } = useLocale();
|
||||
const year = new Date().getFullYear();
|
||||
const fullMoons = useMemo(() => getFullMoonsForYear(year), [year]);
|
||||
|
||||
const lang = (locale === 'fr') ? 'fr' : 'en';
|
||||
|
||||
return (
|
||||
<section id="fullmoons" aria-label="Full Moon Names and Traditions" className="section-container">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="section-title">{ts('fullmoons_title', locale)}</h2>
|
||||
<p className="section-subtitle mx-auto">{ts('fullmoons_subtitle', locale)}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{fullMoons.map((moon, i) => {
|
||||
const name = FULL_MOON_NAMES[moon.month]?.[locale as keyof typeof FULL_MOON_NAMES[1]] || FULL_MOON_NAMES[moon.month]?.en;
|
||||
const desc = MOON_DESCRIPTIONS[moon.month]?.[lang] || MOON_DESCRIPTIONS[moon.month]?.['en'];
|
||||
const monthName = moon.date.toLocaleDateString(locale, { month: 'long' });
|
||||
|
||||
return (
|
||||
<article key={i} className="glass-card p-6 flex flex-col">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="text-4xl">🌕</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-yellow-200">{name}</h3>
|
||||
<p className="text-sm text-white/50 capitalize">{monthName} {year}</p>
|
||||
<p className="text-xs text-indigo-300 mt-1">
|
||||
{moon.date.toLocaleDateString(locale, { day: 'numeric', month: 'short' })} —{' '}
|
||||
{moon.date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{desc && (
|
||||
<div className="space-y-3 text-sm flex-1">
|
||||
<div>
|
||||
<h4 className="text-indigo-300 font-semibold mb-1">🌍 {ts('culture_native', locale)}</h4>
|
||||
<p className="text-white/60 leading-relaxed">{desc.cultural}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-indigo-300 font-semibold mb-1">🌊 {ts('effects_title', locale)}</h4>
|
||||
<p className="text-white/60 leading-relaxed">{desc.effects}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-indigo-300 font-semibold mb-1">🧘 Ritual</h4>
|
||||
<p className="text-white/60 leading-relaxed">{desc.ritual}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useLocale } from './LocaleProvider';
|
||||
import { ts } from '@/lib/i18n';
|
||||
import { getMoonPhaseInfo, getNextFullMoon, FULL_MOON_NAMES } from '@/lib/lunar';
|
||||
|
||||
function computeCountdown(target: Date) {
|
||||
const now = new Date();
|
||||
const totalMs = target.getTime() - now.getTime();
|
||||
if (totalMs <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||
|
||||
return {
|
||||
days: Math.floor(totalMs / (1000 * 60 * 60 * 24)),
|
||||
hours: Math.floor((totalMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)),
|
||||
minutes: Math.floor((totalMs % (1000 * 60 * 60)) / (1000 * 60)),
|
||||
seconds: Math.floor((totalMs % (1000 * 60)) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export default function HeroSection() {
|
||||
const { locale } = useLocale();
|
||||
const nextFull = useMemo(() => getNextFullMoon(), []);
|
||||
const currentPhase = useMemo(() => getMoonPhaseInfo(new Date()), []);
|
||||
const [countdown, setCountdown] = useState(() => computeCountdown(nextFull));
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mountTimer = setTimeout(() => setMounted(true), 0);
|
||||
|
||||
const updateCountdown = () => {
|
||||
setCountdown(computeCountdown(nextFull));
|
||||
};
|
||||
|
||||
const timer = setInterval(updateCountdown, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(mountTimer);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [nextFull]);
|
||||
|
||||
const nextFullName =
|
||||
FULL_MOON_NAMES[nextFull.getUTCMonth() + 1]?.[locale as keyof typeof FULL_MOON_NAMES[1]] ||
|
||||
FULL_MOON_NAMES[nextFull.getUTCMonth() + 1]?.en;
|
||||
|
||||
const formatDate = (d: Date) => {
|
||||
try {
|
||||
return d.toLocaleDateString(locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
} catch {
|
||||
return d.toLocaleDateString('en', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="hero" aria-label="Moon Phases Hero" className="relative min-h-screen flex items-center justify-center overflow-hidden pt-16">
|
||||
{/* Moon glow */}
|
||||
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-75 h-75 md:w-125 md:h-125 rounded-full gradient-radial-glow blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="section-container px-3 sm:px-6 text-center relative z-10">
|
||||
{/* Current phase display */}
|
||||
<div className="mb-4 sm:mb-6 inline-flex max-w-full flex-wrap items-center justify-center gap-2 sm:gap-3 px-3 sm:px-5 py-2 sm:py-2.5 rounded-full bg-white/5 border border-white/10">
|
||||
<span className="text-2xl sm:text-3xl">{currentPhase.emoji}</span>
|
||||
<span className="text-xs sm:text-sm text-white/60 leading-snug wrap-break-word">
|
||||
{ts('current_phase', locale)}: {ts(currentPhase.phaseName, locale)} — {currentPhase.illumination}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl sm:text-5xl md:text-7xl font-bold mb-3 sm:mb-6 leading-tight">
|
||||
<span className="gradient-text-hero">
|
||||
{ts('hero_title', locale)}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-base md:text-xl text-white/50 max-w-2xl mx-auto mb-8 sm:mb-12 leading-relaxed">
|
||||
{ts('hero_subtitle', locale)}
|
||||
</p>
|
||||
|
||||
{/* Next Full Moon Card */}
|
||||
<div className="glass-card w-full max-w-lg mx-auto p-4 sm:p-6 md:p-8 mb-8">
|
||||
<h2 className="text-sm uppercase tracking-widest text-indigo-300 mb-3">{ts('next_full_moon', locale)}</h2>
|
||||
<div className="text-3xl mb-1">🌕</div>
|
||||
<>
|
||||
<p className="text-lg sm:text-xl font-semibold text-yellow-200 mb-1">{nextFullName}</p>
|
||||
<p className="text-white/60 text-xs sm:text-sm mb-4 sm:mb-6 wrap-break-word">{mounted ? formatDate(nextFull) : ''}</p>
|
||||
</>
|
||||
|
||||
{/* Countdown */}
|
||||
<div className="flex flex-wrap justify-center gap-1.5 sm:gap-3 md:gap-4">
|
||||
{[
|
||||
{ value: countdown.days, label: ts('countdown_days', locale) },
|
||||
{ value: countdown.hours, label: ts('countdown_hours', locale) },
|
||||
{ value: countdown.minutes, label: ts('countdown_minutes', locale) },
|
||||
{ value: countdown.seconds, label: ts('countdown_seconds', locale) },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="countdown-box">
|
||||
<span className="countdown-number">{mounted ? String(item.value).padStart(2, '0') : '--'}</span>
|
||||
<span className="countdown-label">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<a href="#calendar" className="glow-btn inline-block text-base">
|
||||
{ts('explore', locale)} ↓
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { useLocale } from './LocaleProvider';
|
||||
import { ts } from '@/lib/i18n';
|
||||
|
||||
export default function Infographics() {
|
||||
const { locale } = useLocale();
|
||||
const phasesCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const tidesCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const drawPhasesCycle = useCallback(() => {
|
||||
const canvas = phasesCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const radius = Math.min(cx, cy) - 60;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0a0a1a';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Orbit circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = 'rgba(99, 102, 241, 0.2)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Earth at center
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 25, 0, Math.PI * 2);
|
||||
const earthGrad = ctx.createRadialGradient(cx - 5, cy - 5, 0, cx, cy, 25);
|
||||
earthGrad.addColorStop(0, '#4da6ff');
|
||||
earthGrad.addColorStop(1, '#1a5276');
|
||||
ctx.fillStyle = earthGrad;
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 10px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('🌍', cx, cy + 4);
|
||||
|
||||
// Sun direction arrow
|
||||
ctx.fillStyle = 'rgba(251, 191, 36, 0.6)';
|
||||
ctx.font = '12px system-ui';
|
||||
ctx.fillText('☀️ Sun →', w - 60, 30);
|
||||
|
||||
// Sun light gradient from right
|
||||
const sunGrad = ctx.createLinearGradient(w, 0, 0, 0);
|
||||
sunGrad.addColorStop(0, 'rgba(251, 191, 36, 0.03)');
|
||||
sunGrad.addColorStop(1, 'rgba(251, 191, 36, 0)');
|
||||
ctx.fillStyle = sunGrad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Moon phases around orbit
|
||||
const phases = [
|
||||
{ angle: 0, emoji: '🌑', label: ts('new_moon', locale), illum: '0%' },
|
||||
{ angle: Math.PI / 4, emoji: '🌒', label: ts('waxing_crescent', locale), illum: '25%' },
|
||||
{ angle: Math.PI / 2, emoji: '🌓', label: ts('first_quarter', locale), illum: '50%' },
|
||||
{ angle: 3 * Math.PI / 4, emoji: '🌔', label: ts('waxing_gibbous', locale), illum: '75%' },
|
||||
{ angle: Math.PI, emoji: '🌕', label: ts('full_moon', locale), illum: '100%' },
|
||||
{ angle: 5 * Math.PI / 4, emoji: '🌖', label: ts('waning_gibbous', locale), illum: '75%' },
|
||||
{ angle: 3 * Math.PI / 2, emoji: '🌗', label: ts('last_quarter', locale), illum: '50%' },
|
||||
{ angle: 7 * Math.PI / 4, emoji: '🌘', label: ts('waning_crescent', locale), illum: '25%' },
|
||||
];
|
||||
|
||||
phases.forEach((p) => {
|
||||
const x = cx + Math.sin(p.angle) * radius;
|
||||
const y = cy - Math.cos(p.angle) * radius;
|
||||
|
||||
// Moon circle background
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 22, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(15, 15, 35, 0.9)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(99, 102, 241, 0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Emoji
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '22px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(p.emoji, x, y + 7);
|
||||
|
||||
// Label
|
||||
const labelX = cx + Math.sin(p.angle) * (radius + 42);
|
||||
const labelY = cy - Math.cos(p.angle) * (radius + 42);
|
||||
ctx.fillStyle = 'rgba(232, 232, 240, 0.7)';
|
||||
ctx.font = '11px system-ui';
|
||||
ctx.fillText(String(p.label), labelX, labelY);
|
||||
|
||||
ctx.fillStyle = 'rgba(129, 140, 248, 0.7)';
|
||||
ctx.font = '9px system-ui';
|
||||
ctx.fillText(p.illum, labelX, labelY + 14);
|
||||
});
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = '#818cf8';
|
||||
ctx.font = 'bold 14px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('29.53 days', cx, cy + 45);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '10px system-ui';
|
||||
ctx.fillText('Synodic Month', cx, cy + 58);
|
||||
}, [locale]);
|
||||
|
||||
const drawTidesChart = useCallback(() => {
|
||||
const canvas = tidesCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const pad = { top: 40, right: 20, bottom: 50, left: 50 };
|
||||
const chartW = w - pad.left - pad.right;
|
||||
const chartH = h - pad.top - pad.bottom;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = '#0a0a1a';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = '#818cf8';
|
||||
ctx.font = 'bold 14px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(locale === 'fr' ? 'Influence lunaire sur les marées' : 'Lunar Influence on Tides', w / 2, 25);
|
||||
|
||||
// Axes
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, pad.top);
|
||||
ctx.lineTo(pad.left, h - pad.bottom);
|
||||
ctx.lineTo(w - pad.right, h - pad.bottom);
|
||||
ctx.stroke();
|
||||
|
||||
// Y-axis label
|
||||
ctx.save();
|
||||
ctx.translate(15, h / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '11px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(locale === 'fr' ? 'Hauteur des marées (m)' : 'Tide Height (m)', 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
// Generate tide data (29.53 days cycle)
|
||||
const days = 30;
|
||||
const points: { x: number; y: number }[] = [];
|
||||
|
||||
for (let d = 0; d <= days; d += 0.5) {
|
||||
const phaseAngle = (d / 29.53) * Math.PI * 2;
|
||||
// Spring tides at new/full moon (0 and π), neap tides at quarters
|
||||
const springNeap = Math.cos(2 * phaseAngle);
|
||||
// Semi-diurnal tide variation
|
||||
const semiDiurnal = Math.sin(d * Math.PI * 2 * 2);
|
||||
const tideHeight = 1.0 + springNeap * 0.8 + semiDiurnal * 0.3;
|
||||
|
||||
const x = pad.left + (d / days) * chartW;
|
||||
const y = pad.top + chartH - (tideHeight / 2.5) * chartH;
|
||||
points.push({ x, y });
|
||||
}
|
||||
|
||||
// Fill area under curve
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, h - pad.bottom);
|
||||
points.forEach(p => ctx.lineTo(p.x, p.y));
|
||||
ctx.lineTo(points[points.length - 1].x, h - pad.bottom);
|
||||
ctx.closePath();
|
||||
const fillGrad = ctx.createLinearGradient(0, pad.top, 0, h - pad.bottom);
|
||||
fillGrad.addColorStop(0, 'rgba(99, 102, 241, 0.3)');
|
||||
fillGrad.addColorStop(1, 'rgba(99, 102, 241, 0.02)');
|
||||
ctx.fillStyle = fillGrad;
|
||||
ctx.fill();
|
||||
|
||||
// Line
|
||||
ctx.beginPath();
|
||||
points.forEach((p, i) => {
|
||||
if (i === 0) ctx.moveTo(p.x, p.y);
|
||||
else ctx.lineTo(p.x, p.y);
|
||||
});
|
||||
ctx.strokeStyle = '#818cf8';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Phase markers
|
||||
const phaseMarkers = [
|
||||
{ day: 0, emoji: '🌑', label: ts('new_moon', locale) },
|
||||
{ day: 7.38, emoji: '🌓', label: ts('first_quarter', locale) },
|
||||
{ day: 14.77, emoji: '🌕', label: ts('full_moon', locale) },
|
||||
{ day: 22.15, emoji: '🌗', label: ts('last_quarter', locale) },
|
||||
{ day: 29.53, emoji: '🌑', label: ts('new_moon', locale) },
|
||||
];
|
||||
|
||||
phaseMarkers.forEach(m => {
|
||||
const x = pad.left + (m.day / days) * chartW;
|
||||
|
||||
// Vertical line
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, pad.top);
|
||||
ctx.lineTo(x, h - pad.bottom);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Emoji
|
||||
ctx.font = '16px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(m.emoji, x, h - pad.bottom + 20);
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '9px system-ui';
|
||||
ctx.fillText(String(m.label).substring(0, 12), x, h - pad.bottom + 38);
|
||||
});
|
||||
|
||||
// Spring/Neap labels
|
||||
ctx.fillStyle = 'rgba(251, 191, 36, 0.7)';
|
||||
ctx.font = 'bold 10px system-ui';
|
||||
const springX1 = pad.left + (0 / days) * chartW + 20;
|
||||
ctx.fillText(locale === 'fr' ? 'Vives-eaux' : 'Spring Tide', springX1 + 30, pad.top + 15);
|
||||
const springX2 = pad.left + (14.77 / days) * chartW;
|
||||
ctx.fillText(locale === 'fr' ? 'Vives-eaux' : 'Spring Tide', springX2, pad.top + 15);
|
||||
|
||||
ctx.fillStyle = 'rgba(34, 197, 94, 0.7)';
|
||||
const neapX1 = pad.left + (7.38 / days) * chartW;
|
||||
ctx.fillText(locale === 'fr' ? 'Mortes-eaux' : 'Neap Tide', neapX1, pad.top + 15);
|
||||
const neapX2 = pad.left + (22.15 / days) * chartW;
|
||||
ctx.fillText(locale === 'fr' ? 'Mortes-eaux' : 'Neap Tide', neapX2, pad.top + 15);
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
drawPhasesCycle();
|
||||
drawTidesChart();
|
||||
}, [drawPhasesCycle, drawTidesChart]);
|
||||
|
||||
return (
|
||||
<section id="infographics" aria-label="Lunar Infographics and Charts" className="section-container">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="section-title">{ts('infographics_title', locale)}</h2>
|
||||
<p className="section-subtitle mx-auto">{ts('infographics_subtitle', locale)}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 max-w-6xl mx-auto">
|
||||
<div className="glass-card overflow-hidden">
|
||||
<canvas ref={phasesCanvasRef} width={500} height={500} className="w-full h-auto" />
|
||||
</div>
|
||||
<div className="glass-card overflow-hidden">
|
||||
<canvas ref={tidesCanvasRef} width={500} height={350} className="w-full h-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { Locale, detectLocale } from '@/lib/i18n';
|
||||
|
||||
interface LocaleContextType {
|
||||
locale: Locale;
|
||||
setLocale: (l: Locale) => void;
|
||||
}
|
||||
|
||||
const LocaleContext = createContext<LocaleContextType>({ locale: 'en', setLocale: () => {} });
|
||||
|
||||
export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(() => {
|
||||
if (typeof window === 'undefined') return 'en';
|
||||
const saved = localStorage.getItem('moon-locale') as Locale | null;
|
||||
return saved || detectLocale();
|
||||
});
|
||||
|
||||
const setLocale = (l: Locale) => {
|
||||
setLocaleState(l);
|
||||
localStorage.setItem('moon-locale', l);
|
||||
};
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={{ locale, setLocale }}>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
return useContext(LocaleContext);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useLocale } from './LocaleProvider';
|
||||
import { ts, getMonths } from '@/lib/i18n';
|
||||
import { getMoonEventsForYear, type MoonEvent } from '@/lib/lunar';
|
||||
|
||||
const PHASE_EMOJIS: Record<string, string> = {
|
||||
new_moon: '🌑',
|
||||
first_quarter: '🌓',
|
||||
full_moon: '🌕',
|
||||
last_quarter: '🌗',
|
||||
};
|
||||
|
||||
export default function LunarCalendar() {
|
||||
const { locale } = useLocale();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [year, setYear] = useState(currentYear);
|
||||
const [viewMode, setViewMode] = useState<'year' | 'month'>('year');
|
||||
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth());
|
||||
|
||||
const events = useMemo(() => getMoonEventsForYear(year), [year]);
|
||||
const months = getMonths(locale);
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
try {
|
||||
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' });
|
||||
} catch {
|
||||
return date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDay = (date: Date) => {
|
||||
try {
|
||||
return date.toLocaleDateString(locale, { weekday: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return date.toLocaleDateString('en', { weekday: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
const getMonthEvents = (monthIndex: number): MoonEvent[] => {
|
||||
return events.filter(e => e.date.getMonth() === monthIndex);
|
||||
};
|
||||
|
||||
const phaseKey = (phase: string) => {
|
||||
return phase as 'new_moon' | 'first_quarter' | 'full_moon' | 'last_quarter';
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="calendar" aria-label="Lunar Calendar 2026" className="section-container">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="section-title">{ts('calendar_title', locale)}</h2>
|
||||
<p className="section-subtitle mx-auto">{ts('calendar_subtitle', locale)}</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 mb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setYear(y => y - 1)}
|
||||
className="px-3 py-2 rounded-lg bg-white/5 border border-white/10 hover:border-indigo-400/50 transition-all"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span className="text-xl font-bold px-4 min-w-20 text-center">{year}</span>
|
||||
<button
|
||||
onClick={() => setYear(y => y + 1)}
|
||||
className="px-3 py-2 rounded-lg bg-white/5 border border-white/10 hover:border-indigo-400/50 transition-all"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex rounded-full bg-white/5 border border-white/10 p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('year')}
|
||||
className={`px-4 py-1.5 rounded-full text-sm transition-all ${
|
||||
viewMode === 'year' ? 'bg-indigo-500/30 text-indigo-200' : 'text-white/50 hover:text-white/70'
|
||||
}`}
|
||||
>
|
||||
📅 {locale === 'fr' ? 'Année' : 'Year'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('month')}
|
||||
className={`px-4 py-1.5 rounded-full text-sm transition-all ${
|
||||
viewMode === 'month' ? 'bg-indigo-500/30 text-indigo-200' : 'text-white/50 hover:text-white/70'
|
||||
}`}
|
||||
>
|
||||
📆 {locale === 'fr' ? 'Mois' : 'Month'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Month selector (when in month mode) */}
|
||||
{viewMode === 'month' && (
|
||||
<div className="flex flex-wrap justify-center gap-2 mb-8">
|
||||
{months.map((m, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedMonth(i)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm transition-all ${
|
||||
selectedMonth === i ? 'bg-indigo-500/30 text-indigo-200 border border-indigo-400/50' : 'bg-white/5 text-white/50 hover:text-white/70 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendar grid */}
|
||||
{viewMode === 'year' ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{months.map((month, i) => {
|
||||
const monthEvents = getMonthEvents(i);
|
||||
return (
|
||||
<div key={i} className="glass-card p-4">
|
||||
<h3 className="text-lg font-semibold mb-3 text-indigo-200">{month}</h3>
|
||||
<div className="space-y-2">
|
||||
{monthEvents.length === 0 ? (
|
||||
<p className="text-white/30 text-sm italic">—</p>
|
||||
) : (
|
||||
monthEvents.map((event, j) => (
|
||||
<div key={j} className="flex items-center justify-between gap-2">
|
||||
<div className={`phase-badge ${event.phase}`}>
|
||||
<span>{PHASE_EMOJIS[event.phase]}</span>
|
||||
<span>{ts(phaseKey(event.phase), locale)}</span>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<div className="text-white/70">{formatDay(event.date)}</div>
|
||||
<div className="text-white/40 text-xs">{formatTime(event.date)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card max-w-2xl mx-auto p-6">
|
||||
<h3 className="text-2xl font-semibold mb-6 text-center text-indigo-200">
|
||||
{months[selectedMonth]} {year}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{getMonthEvents(selectedMonth).length === 0 ? (
|
||||
<p className="text-center text-white/40">—</p>
|
||||
) : (
|
||||
getMonthEvents(selectedMonth).map((event, j) => (
|
||||
<div key={j} className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/5">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-3xl">{PHASE_EMOJIS[event.phase]}</span>
|
||||
<div>
|
||||
<p className="font-semibold">{ts(phaseKey(event.phase), locale)}</p>
|
||||
<p className="text-white/50 text-sm">{formatDay(event.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-indigo-300 font-mono">{formatTime(event.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap justify-center gap-4 mt-8">
|
||||
{(['new_moon', 'first_quarter', 'full_moon', 'last_quarter'] as const).map((phase) => (
|
||||
<div key={phase} className={`phase-badge ${phase}`}>
|
||||
<span>{PHASE_EMOJIS[phase]}</span>
|
||||
<span>{ts(phase, locale)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocale } from './LocaleProvider';
|
||||
import { ts } from '@/lib/i18n';
|
||||
import { LOCALES, Locale } from '@/lib/i18n';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: 'nav_home' as const, href: '#hero' },
|
||||
{ key: 'nav_calendar' as const, href: '#calendar' },
|
||||
{ key: 'nav_fullmoons' as const, href: '#fullmoons' },
|
||||
{ key: 'nav_simulator' as const, href: '#simulator' },
|
||||
{ key: 'nav_articles' as const, href: '#articles' },
|
||||
{ key: 'nav_quiz' as const, href: '#quiz' },
|
||||
];
|
||||
|
||||
export default function Navigation() {
|
||||
const { locale, setLocale } = useLocale();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 50);
|
||||
window.addEventListener('scroll', onScroll);
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
scrolled ? 'bg-[rgba(10,10,26,0.95)] backdrop-blur-xl shadow-lg shadow-indigo-500/5' : 'bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<nav className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<a href="#hero" className="flex items-center gap-2 text-lg font-bold text-white">
|
||||
<span className="text-2xl">🌕</span>
|
||||
<span className="hidden sm:inline gradient-text-brand">
|
||||
Moon Phases
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a key={item.key} href={item.href} className="nav-link">
|
||||
{ts(item.key, locale)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Language selector */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setLangOpen(!langOpen)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-white/5 border border-white/10 hover:border-indigo-400/50 transition-all"
|
||||
aria-label="Select language"
|
||||
>
|
||||
{LOCALES.find(l => l.code === locale)?.flag} <span className="hidden sm:inline text-xs uppercase">{locale}</span>
|
||||
</button>
|
||||
|
||||
{langOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-48 glass-card p-2 grid grid-cols-2 gap-1 max-h-64 overflow-y-auto">
|
||||
{LOCALES.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => { setLocale(l.code as Locale); setLangOpen(false); }}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-all hover:bg-indigo-500/20 ${
|
||||
locale === l.code ? 'bg-indigo-500/20 text-indigo-300' : 'text-white/70'
|
||||
}`}
|
||||
>
|
||||
<span>{l.flag}</span>
|
||||
<span className="truncate">{l.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="md:hidden p-2 rounded-lg hover:bg-white/10 transition-all"
|
||||
aria-label="Menu"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{menuOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{menuOpen && (
|
||||
<div className="md:hidden bg-[rgba(10,10,26,0.98)] backdrop-blur-xl border-t border-white/10 px-4 py-4">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block py-3 px-4 text-white/70 hover:text-indigo-300 hover:bg-indigo-500/10 rounded-lg transition-all"
|
||||
>
|
||||
{ts(item.key, locale)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
<div className="mobile-nav">
|
||||
{NAV_ITEMS.slice(0, 5).map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className="flex flex-col items-center gap-0.5 text-xs text-white/50 hover:text-indigo-300 transition-all py-1"
|
||||
>
|
||||
<span className="text-base">
|
||||
{item.key === 'nav_home' ? '🏠' :
|
||||
item.key === 'nav_calendar' ? '📅' :
|
||||
item.key === 'nav_fullmoons' ? '🌕' :
|
||||
item.key === 'nav_simulator' ? '🔭' : '🌐'}
|
||||
</span>
|
||||
<span className="truncate max-w-15">{ts(item.key, locale)}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale } from './LocaleProvider';
|
||||
import { ts, getMonths } from '@/lib/i18n';
|
||||
import { getFullMoonsForYear, getMoonPhaseInfo } from '@/lib/lunar';
|
||||
|
||||
export default function PDFDownload() {
|
||||
const { locale } = useLocale();
|
||||
|
||||
const generatePDF = async () => {
|
||||
const { jsPDF } = await import('jspdf');
|
||||
const year = new Date().getFullYear();
|
||||
const fullMoons = getFullMoonsForYear(year);
|
||||
const months = getMonths(locale);
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Colors
|
||||
const dark = '#0a0a1a';
|
||||
const accent = '#6366f1';
|
||||
const white = '#ffffff';
|
||||
const gray = '#a0a0b0';
|
||||
|
||||
// Background
|
||||
doc.setFillColor(dark);
|
||||
doc.rect(0, 0, 210, 297, 'F');
|
||||
|
||||
// Title
|
||||
doc.setTextColor(accent);
|
||||
doc.setFontSize(28);
|
||||
doc.text(`🌕 ${ts('pdf_title', locale)}`, 105, 30, { align: 'center' });
|
||||
|
||||
doc.setTextColor(white);
|
||||
doc.setFontSize(18);
|
||||
doc.text(`${year}`, 105, 42, { align: 'center' });
|
||||
|
||||
doc.setTextColor(gray);
|
||||
doc.setFontSize(9);
|
||||
doc.text(ts('pdf_generated', locale) + ': ' + new Date().toLocaleDateString(locale), 105, 50, { align: 'center' });
|
||||
|
||||
// Table header
|
||||
const startY = 65;
|
||||
const colMonth = 20;
|
||||
const colDate = 70;
|
||||
const colTime = 110;
|
||||
const colName = 140;
|
||||
|
||||
doc.setFillColor('#1a1a2e');
|
||||
doc.rect(15, startY - 6, 180, 10, 'F');
|
||||
|
||||
doc.setTextColor(accent);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(ts('pdf_col_month', locale), colMonth, startY);
|
||||
doc.text(ts('pdf_col_date', locale), colDate, startY);
|
||||
doc.text(ts('pdf_col_time', locale), colTime, startY);
|
||||
doc.text(ts('pdf_col_name', locale), colName, startY);
|
||||
|
||||
// Table rows
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
|
||||
fullMoons.forEach((fm, i) => {
|
||||
const y = startY + 14 + i * 16;
|
||||
const phaseInfo = getMoonPhaseInfo(fm.date);
|
||||
|
||||
// Alternate row background
|
||||
if (i % 2 === 0) {
|
||||
doc.setFillColor('#12122a');
|
||||
doc.rect(15, y - 6, 180, 16, 'F');
|
||||
}
|
||||
|
||||
// Row separator
|
||||
doc.setDrawColor('#2a2a4a');
|
||||
doc.line(15, y + 10, 195, y + 10);
|
||||
|
||||
doc.setTextColor(white);
|
||||
doc.text(months[fm.date.getMonth()], colMonth, y);
|
||||
|
||||
doc.setTextColor(gray);
|
||||
doc.text(fm.date.toLocaleDateString(locale, { day: 'numeric', month: 'short' }), colDate, y);
|
||||
doc.text(fm.date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }), colTime, y);
|
||||
|
||||
doc.setTextColor('#c4b5fd');
|
||||
doc.text(fm.traditionalName || phaseInfo.phaseName, colName, y);
|
||||
});
|
||||
|
||||
// Footer
|
||||
const footY = startY + 14 + fullMoons.length * 16 + 15;
|
||||
|
||||
// Stats box
|
||||
doc.setFillColor('#1a1a2e');
|
||||
doc.roundedRect(15, footY, 180, 30, 3, 3, 'F');
|
||||
|
||||
doc.setTextColor(accent);
|
||||
doc.setFontSize(11);
|
||||
doc.text(`📊 ${locale === 'fr' ? 'Statistiques' : 'Statistics'} ${year}`, 25, footY + 10);
|
||||
|
||||
doc.setTextColor(gray);
|
||||
doc.setFontSize(9);
|
||||
doc.text(`${locale === 'fr' ? 'Nombre de pleines lunes' : 'Number of full moons'}: ${fullMoons.length}`, 25, footY + 18);
|
||||
|
||||
if (fullMoons.length > 0) {
|
||||
const first = fullMoons[0].date.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
|
||||
const last = fullMoons[fullMoons.length - 1].date.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
|
||||
doc.text(`${locale === 'fr' ? 'Première' : 'First'}: ${first} | ${locale === 'fr' ? 'Dernière' : 'Last'}: ${last}`, 25, footY + 24);
|
||||
}
|
||||
|
||||
// Credit
|
||||
doc.setTextColor('#4a4a6a');
|
||||
doc.setFontSize(7);
|
||||
doc.text('Generated by Moon Phases - moon.arthurp.fr', 105, 290, { align: 'center' });
|
||||
|
||||
doc.save(`calendrier-lunaire-${year}.pdf`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="pdf" aria-label="Download PDF Lunar Calendar" className="section-container">
|
||||
<div className="glass-card max-w-xl mx-auto p-5 sm:p-8 text-center">
|
||||
<p className="text-5xl mb-4">📥</p>
|
||||
<h2 className="text-2xl font-bold mb-3">{ts('pdf_download', locale)}</h2>
|
||||
<p className="text-white/50 mb-6">
|
||||
{locale === 'fr'
|
||||
? `Téléchargez le calendrier des pleines lunes ${new Date().getFullYear()} en PDF pour l'imprimer ou le consulter hors ligne.`
|
||||
: `Download the ${new Date().getFullYear()} full moon calendar as a PDF to print or view offline.`}
|
||||
</p>
|
||||
<button onClick={generatePDF} className="glow-btn text-lg px-8 py-3">
|
||||
{ts('pdf_download', locale)} (PDF)
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useLocale } from './LocaleProvider';
|
||||
import { ts } from '@/lib/i18n';
|
||||
|
||||
interface QuizQuestion {
|
||||
question: Record<string, string>;
|
||||
options: Record<string, string[]>;
|
||||
correctIndex: number;
|
||||
explanation: Record<string, string>;
|
||||
}
|
||||
|
||||
const QUESTIONS: QuizQuestion[] = [
|
||||
{
|
||||
question: {
|
||||
en: 'How long is one complete lunar cycle (synodic month)?',
|
||||
fr: 'Quelle est la durée d\'un cycle lunaire complet (mois synodique) ?',
|
||||
},
|
||||
options: {
|
||||
en: ['27.32 days', '28 days', '29.53 days', '30 days'],
|
||||
fr: ['27,32 jours', '28 jours', '29,53 jours', '30 jours'],
|
||||
},
|
||||
correctIndex: 2,
|
||||
explanation: {
|
||||
en: 'The synodic month lasts 29.53 days — the time between two identical phases (e.g., full moon to full moon).',
|
||||
fr: 'Le mois synodique dure 29,53 jours — le temps entre deux phases identiques.',
|
||||
},
|
||||
},
|
||||
{
|
||||
question: {
|
||||
en: 'What is the traditional name for the September full moon?',
|
||||
fr: 'Quel est le nom traditionnel de la pleine lune de septembre ?',
|
||||
},
|
||||
options: {
|
||||
en: ['Wolf Moon', 'Harvest Moon', 'Hunter\'s Moon', 'Blood Moon'],
|
||||
fr: ['Lune du Loup', 'Lune des Moissons', 'Lune du Chasseur', 'Lune de Sang'],
|
||||
},
|
||||
correctIndex: 1,
|
||||
explanation: {
|
||||
en: 'The Harvest Moon rises near sunset during harvest season, providing extra light for farmers.',
|
||||
fr: 'La Lune des Moissons se lève près du coucher du soleil, offrant de la lumière aux agriculteurs.',
|
||||
},
|
||||
},
|
||||
{
|
||||
question: {
|
||||
en: 'What causes the tides on Earth?',
|
||||
fr: 'Qu\'est-ce qui cause les marées sur Terre ?',
|
||||
},
|
||||
options: {
|
||||
en: ['Wind', 'Earth\'s rotation only', 'Gravitational pull of Moon and Sun', 'Ocean currents'],
|
||||
fr: ['Le vent', 'La rotation terrestre seule', 'L\'attraction gravitationnelle de la Lune et du Soleil', 'Les courants océaniques'],
|
||||
},
|
||||
correctIndex: 2,
|
||||
explanation: {
|
||||
en: 'Tides are primarily caused by the gravitational pull of the Moon and, to a lesser extent, the Sun.',
|
||||
fr: 'Les marées sont principalement causées par l\'attraction gravitationnelle de la Lune et du Soleil.',
|
||||
},
|
||||
},
|
||||
{
|
||||
question: {
|
||||
en: 'During which moon phase do we see the most light?',
|
||||
fr: 'Pendant quelle phase voit-on le plus de lumière ?',
|
||||
},
|
||||
options: {
|
||||
en: ['New Moon', 'First Quarter', 'Full Moon', 'Waning Crescent'],
|
||||
fr: ['Nouvelle Lune', 'Premier Quartier', 'Pleine Lune', 'Croissant Décroissant'],
|
||||
},
|
||||
correctIndex: 2,
|
||||
explanation: {
|
||||
en: 'The full moon is 100% illuminated, reflecting maximum sunlight toward Earth.',
|
||||
fr: 'La pleine lune est illuminée à 100%, reflétant le maximum de lumière solaire.',
|
||||
},
|
||||
},
|
||||
{
|
||||
question: {
|
||||
en: 'What is a "supermoon"?',
|
||||
fr: 'Qu\'est-ce qu\'une "super lune" ?',
|
||||
},
|
||||
options: {
|
||||
en: ['A moon larger than usual', 'A full moon at its closest point to Earth (perigee)', 'Two full moons in one month', 'A full moon during an eclipse'],
|
||||
fr: ['Une lune plus grande que d\'habitude', 'Une pleine lune au point le plus proche de la Terre (périgée)', 'Deux pleines lunes dans un mois', 'Une pleine lune pendant une éclipse'],
|
||||
},
|
||||
correctIndex: 1,
|
||||
explanation: {
|
||||
en: 'A supermoon occurs when the full moon coincides with perigee, appearing about 14% bigger and 30% brighter.',
|
||||
fr: 'Une super lune se produit quand la pleine lune coïncide avec le périgée, paraissant ~14% plus grande.',
|
||||
},
|
||||
},
|
||||
{
|
||||
question: {
|
||||
en: 'How much of the Moon\'s surface can we see from Earth?',
|
||||
fr: 'Quelle proportion de la surface lunaire peut-on voir depuis la Terre ?',
|
||||
},
|
||||
options: {
|
||||
en: ['50%', '41%', '59%', '100%'],
|
||||
fr: ['50%', '41%', '59%', '100%'],
|
||||
},
|
||||
correctIndex: 2,
|
||||
explanation: {
|
||||
en: 'Due to libration (slight wobble), we can see about 59% of the Moon\'s surface over time, though only 50% at any given moment.',
|
||||
fr: 'Grâce à la libration, on peut voir environ 59% de la surface lunaire au fil du temps.',
|
||||
},
|
||||
},
|
||||
{
|
||||
question: {
|
||||
en: 'What are "spring tides"?',
|
||||
fr: 'Que sont les "vives-eaux" ?',
|
||||
},
|
||||
options: {
|
||||
en: ['Tides in springtime', 'Extra high tides during full and new moons', 'Tides caused by storms', 'Low tides only'],
|
||||
fr: ['Les marées au printemps', 'Marées extra hautes pendant pleine et nouvelle lune', 'Marées causées par les tempêtes', 'Marées basses uniquement'],
|
||||
},
|
||||
correctIndex: 1,
|
||||
explanation: {
|
||||
en: 'Spring tides occur when Moon and Sun align (full and new moon), creating the highest and lowest tides.',
|
||||
fr: 'Les vives-eaux se produisent quand Lune et Soleil sont alignés, créant les marées les plus extrêmes.',
|
||||
},
|
||||
},
|
||||
{
|
||||
question: {
|
||||
en: 'Why does the Moon always show the same face to Earth?',
|
||||
fr: 'Pourquoi la Lune montre-t-elle toujours la même face à la Terre ?',
|
||||
},
|
||||
options: {
|
||||
en: ['It doesn\'t rotate', 'Tidal locking — rotation period equals orbital period', 'It\'s a coincidence', 'Earth\'s magnetic field keeps it locked'],
|
||||
fr: ['Elle ne tourne pas', 'Verrouillage gravitationnel — période de rotation = période orbitale', 'C\'est une coïncidence', 'Le champ magnétique terrestre la maintient'],
|
||||
},
|
||||
correctIndex: 1,
|
||||
explanation: {
|
||||
en: 'Tidal locking means the Moon rotates on its axis in the same time it takes to orbit Earth (~27.3 days).',
|
||||
fr: 'Le verrouillage gravitationnel fait que la Lune tourne sur elle-même en même temps qu\'elle orbite la Terre.',
|
||||
},
|
||||
},
|
||||
{
|
||||
question: {
|
||||
en: 'What is a "blue moon"?',
|
||||
fr: 'Qu\'est-ce qu\'une "lune bleue" ?',
|
||||
},
|
||||
options: {
|
||||
en: ['A moon that appears blue', 'The second full moon in a calendar month', 'A lunar eclipse', 'A new moon visible during daytime'],
|
||||
fr: ['Une lune de couleur bleue', 'La deuxième pleine lune dans un mois', 'Une éclipse lunaire', 'Une nouvelle lune visible le jour'],
|
||||
},
|
||||
correctIndex: 1,
|
||||
explanation: {
|
||||
en: 'A blue moon is the second full moon in a single calendar month, occurring roughly every 2.7 years.',
|
||||
fr: 'Une lune bleue est la deuxième pleine lune d\'un même mois, survenant environ tous les 2,7 ans.',
|
||||
},
|
||||
},
|
||||
{
|
||||
question: {
|
||||
en: 'What is the Moon\'s average distance from Earth?',
|
||||
fr: 'Quelle est la distance moyenne Terre-Lune ?',
|
||||
},
|
||||
options: {
|
||||
en: ['238,900 miles (384,400 km)', '150,000 miles (241,000 km)', '500,000 miles (800,000 km)', '93 million miles (150 million km)'],
|
||||
fr: ['384 400 km', '241 000 km', '800 000 km', '150 millions km'],
|
||||
},
|
||||
correctIndex: 0,
|
||||
explanation: {
|
||||
en: 'The Moon orbits at an average distance of 384,400 km (238,900 miles) from Earth.',
|
||||
fr: 'La Lune orbite à une distance moyenne de 384 400 km de la Terre.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function Quiz() {
|
||||
const { locale } = useLocale();
|
||||
const lang = (locale === 'fr') ? 'fr' : 'en';
|
||||
|
||||
const [started, setStarted] = useState(false);
|
||||
const [currentQ, setCurrentQ] = useState(0);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [score, setScore] = useState(0);
|
||||
const [finished, setFinished] = useState(false);
|
||||
const [answered, setAnswered] = useState(false);
|
||||
|
||||
const question = QUESTIONS[currentQ];
|
||||
|
||||
const handleSelect = (index: number) => {
|
||||
if (answered) return;
|
||||
setSelected(index);
|
||||
setAnswered(true);
|
||||
if (index === question.correctIndex) {
|
||||
setScore(s => s + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentQ + 1 >= QUESTIONS.length) {
|
||||
setFinished(true);
|
||||
} else {
|
||||
setCurrentQ(q => q + 1);
|
||||
setSelected(null);
|
||||
setAnswered(false);
|
||||
}
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
setStarted(true);
|
||||
setCurrentQ(0);
|
||||
setSelected(null);
|
||||
setScore(0);
|
||||
setFinished(false);
|
||||
setAnswered(false);
|
||||
};
|
||||
|
||||
const scorePercentage = Math.round((score / QUESTIONS.length) * 100);
|
||||
const getScoreMessage = () => {
|
||||
if (scorePercentage >= 90) return locale === 'fr' ? '🏆 Expert lunaire !' : '🏆 Lunar Expert!';
|
||||
if (scorePercentage >= 70) return locale === 'fr' ? '🌟 Très bien !' : '🌟 Great job!';
|
||||
if (scorePercentage >= 50) return locale === 'fr' ? '👍 Pas mal !' : '👍 Not bad!';
|
||||
return locale === 'fr' ? '📚 À réviser !' : '📚 Keep learning!';
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="quiz" aria-label="Moon Knowledge Quiz" className="section-container">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="section-title">{ts('quiz_title', locale)}</h2>
|
||||
<p className="section-subtitle mx-auto">{ts('quiz_subtitle', locale)}</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-card max-w-2xl mx-auto p-4 sm:p-6 md:p-8">
|
||||
{!started ? (
|
||||
<div className="text-center">
|
||||
<p className="text-6xl mb-6">🌙</p>
|
||||
<p className="text-lg text-white/60 mb-8">
|
||||
{QUESTIONS.length} {locale === 'fr' ? 'questions sur la lune et ses mystères' : 'questions about the moon and its mysteries'}
|
||||
</p>
|
||||
<button onClick={() => setStarted(true)} className="glow-btn text-lg px-8 py-3">
|
||||
{ts('quiz_start', locale)}
|
||||
</button>
|
||||
</div>
|
||||
) : finished ? (
|
||||
<div className="text-center">
|
||||
<p className="text-6xl mb-4">{scorePercentage >= 70 ? '🎉' : '🌙'}</p>
|
||||
<h3 className="text-2xl font-bold mb-2">{ts('quiz_results', locale)}</h3>
|
||||
<p className="text-4xl font-bold text-indigo-300 mb-2">{score}/{QUESTIONS.length}</p>
|
||||
<p className="text-lg text-white/60 mb-2">{scorePercentage}%</p>
|
||||
<p className="text-xl mb-8">{getScoreMessage()}</p>
|
||||
|
||||
{/* Score bar */}
|
||||
<div className="w-full h-3 bg-white/10 rounded-full mb-8 max-w-xs mx-auto">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-1000 ${
|
||||
scorePercentage >= 70 ? 'bg-green-400' : scorePercentage >= 50 ? 'bg-yellow-400' : 'bg-red-400'
|
||||
}`}
|
||||
style={{ width: `${scorePercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button onClick={restart} className="glow-btn">
|
||||
{ts('quiz_restart', locale)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Progress */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="text-sm text-white/40">
|
||||
{currentQ + 1}/{QUESTIONS.length}
|
||||
</span>
|
||||
<div className="flex-1 mx-4 h-1.5 bg-white/10 rounded-full">
|
||||
<div
|
||||
className="h-full bg-indigo-400 rounded-full transition-all duration-300"
|
||||
style={{ width: `${((currentQ + 1) / QUESTIONS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-indigo-300">{ts('quiz_score', locale)}: {score}</span>
|
||||
</div>
|
||||
|
||||
{/* Question */}
|
||||
<h3 className="text-xl font-semibold mb-6">
|
||||
{question.question[lang] || question.question.en}
|
||||
</h3>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-3 mb-6">
|
||||
{(question.options[lang] || question.options.en).map((option, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleSelect(i)}
|
||||
className={`quiz-option w-full text-left ${
|
||||
answered
|
||||
? i === question.correctIndex
|
||||
? 'correct'
|
||||
: i === selected
|
||||
? 'wrong'
|
||||
: ''
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border ${
|
||||
answered && i === question.correctIndex
|
||||
? 'bg-green-500/20 border-green-400 text-green-300'
|
||||
: answered && i === selected
|
||||
? 'bg-red-500/20 border-red-400 text-red-300'
|
||||
: 'border-white/20 text-white/50'
|
||||
}`}>
|
||||
{String.fromCharCode(65 + i)}
|
||||
</span>
|
||||
{option}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Explanation */}
|
||||
{answered && (
|
||||
<div className={`p-4 rounded-xl mb-6 ${
|
||||
selected === question.correctIndex
|
||||
? 'bg-green-500/10 border border-green-500/20'
|
||||
: 'bg-red-500/10 border border-red-500/20'
|
||||
}`}>
|
||||
<p className="font-semibold mb-1">
|
||||
{selected === question.correctIndex ? ts('quiz_correct', locale) : ts('quiz_wrong', locale)}
|
||||
</p>
|
||||
<p className="text-sm text-white/70">
|
||||
{question.explanation[lang] || question.explanation.en}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{answered && (
|
||||
<button onClick={handleNext} className="glow-btn w-full">
|
||||
{currentQ + 1 >= QUESTIONS.length ? ts('quiz_results', locale) : ts('quiz_next', locale)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { useLocale } from './LocaleProvider';
|
||||
import { ts } from '@/lib/i18n';
|
||||
import { getMoonDeclination, getMoonRightAscension, getMoonPhaseInfo } from '@/lib/lunar';
|
||||
|
||||
export default function VisibilityMap() {
|
||||
const { locale } = useLocale();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const mapImageRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const dateToJD = useCallback((date: Date): number => {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = date.getUTCMonth() + 1;
|
||||
const d = date.getUTCDate() + date.getUTCHours() / 24;
|
||||
let yr = y;
|
||||
let mo = m;
|
||||
if (mo <= 2) {
|
||||
yr -= 1;
|
||||
mo += 12;
|
||||
}
|
||||
const A = Math.floor(yr / 100);
|
||||
const B = 2 - A + Math.floor(A / 4);
|
||||
return Math.floor(365.25 * (yr + 4716)) + Math.floor(30.6001 * (mo + 1)) + d + B - 1524.5;
|
||||
}, []);
|
||||
|
||||
const drawMap = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const now = new Date();
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const mapImage = mapImageRef.current;
|
||||
if (mapImage && mapImage.complete && mapImage.naturalWidth > 0) {
|
||||
ctx.drawImage(mapImage, 0, 0, w, h);
|
||||
|
||||
// Slight dark layer for readability of overlays
|
||||
ctx.fillStyle = 'rgba(10, 14, 39, 0.25)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
} else {
|
||||
// Fallback background if image is missing
|
||||
ctx.fillStyle = '#0a0e27';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
ctx.strokeStyle = 'rgba(99, 102, 241, 0.1)';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let lat = -60; lat <= 60; lat += 30) {
|
||||
const y = h / 2 - (lat / 90) * (h / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let lon = -150; lon <= 180; lon += 30) {
|
||||
const x = w / 2 + (lon / 180) * (w / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Moon position
|
||||
const dec = getMoonDeclination(now);
|
||||
const ra = getMoonRightAscension(now);
|
||||
|
||||
// Convert RA to longitude (approximate, accounting for Earth's rotation)
|
||||
const gmst = (280.46061837 + 360.98564736629 * ((dateToJD(now) - 2451545.0))) % 360;
|
||||
let moonLon = ra - gmst;
|
||||
while (moonLon > 180) moonLon -= 360;
|
||||
while (moonLon < -180) moonLon += 360;
|
||||
|
||||
const moonX = w / 2 + (moonLon / 180) * (w / 2);
|
||||
const moonY = h / 2 - (dec / 90) * (h / 2);
|
||||
|
||||
// Visibility zone (large circle)
|
||||
const visibilityRadius = w * 0.25;
|
||||
const visGrad = ctx.createRadialGradient(moonX, moonY, 0, moonX, moonY, visibilityRadius);
|
||||
visGrad.addColorStop(0, 'rgba(251, 191, 36, 0.2)');
|
||||
visGrad.addColorStop(0.5, 'rgba(251, 191, 36, 0.08)');
|
||||
visGrad.addColorStop(1, 'rgba(251, 191, 36, 0)');
|
||||
ctx.fillStyle = visGrad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(moonX, moonY, visibilityRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Moon position marker
|
||||
const moonPhase = getMoonPhaseInfo(now);
|
||||
|
||||
// Glow
|
||||
const glowGrad = ctx.createRadialGradient(moonX, moonY, 0, moonX, moonY, 25);
|
||||
glowGrad.addColorStop(0, 'rgba(251, 191, 36, 0.8)');
|
||||
glowGrad.addColorStop(0.5, 'rgba(251, 191, 36, 0.3)');
|
||||
glowGrad.addColorStop(1, 'rgba(251, 191, 36, 0)');
|
||||
ctx.fillStyle = glowGrad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(moonX, moonY, 25, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Moon dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(moonX, moonY, 8, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#fbbf24';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fef9c3';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#fef9c3';
|
||||
ctx.font = '12px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`${moonPhase.emoji} ${moonPhase.illumination}%`, moonX, moonY - 18);
|
||||
|
||||
// Equator label
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
||||
ctx.font = '10px system-ui';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('0°', 4, h / 2 + 4);
|
||||
}, [dateToJD]);
|
||||
|
||||
useEffect(() => {
|
||||
const mapImage = new Image();
|
||||
mapImage.src = '/moon-visibility-map.webp';
|
||||
mapImage.onload = () => {
|
||||
mapImageRef.current = mapImage;
|
||||
drawMap();
|
||||
};
|
||||
mapImage.onerror = () => {
|
||||
mapImageRef.current = null;
|
||||
drawMap();
|
||||
};
|
||||
|
||||
drawMap();
|
||||
const interval = setInterval(drawMap, 60000); // Update every minute
|
||||
return () => clearInterval(interval);
|
||||
}, [drawMap]);
|
||||
|
||||
return (
|
||||
<section id="visibility" aria-label="Moon Visibility World Map" className="section-container">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="section-title">{ts('visibility_title', locale)}</h2>
|
||||
<p className="section-subtitle mx-auto">{ts('visibility_subtitle', locale)}</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-card overflow-hidden max-w-5xl mx-auto">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={900}
|
||||
height={450}
|
||||
className="w-full h-auto min-h-50"
|
||||
/>
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-6 py-3 border-t border-white/5 text-xs text-white/40">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 rounded-full bg-yellow-400 inline-block" /> Moon Position
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 rounded-full bg-yellow-400/20 inline-block" /> Visibility Zone
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user