weather_app/src/components/MoonPhaseCard.svelte
hermes-explorigin 97b676093a
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 23s
Test, Build & Deploy / deploy (push) Successful in 21s
Add moon phase card to Weather Details
- New src/lib/api/moon.js: fetches daily moon_phase from Open-Meteo
  (independent of the NWS/Open-Meteo weather source, since NWS has no moon
  data), plus pure helpers: moonName (8 phases), illumination fraction, and
  nextMoonPhase, which unwraps the ~29.5-day cycle and interpolates the next
  quarter boundary (new/first quarter/full/last quarter), returning its name,
  date, and days until it.
- New MoonPhaseCard.svelte: renders an SVG moon (two-circle terminator that
  correctly shows crescent/quarter/gibbous shapes), the current phase name,
  percent illuminated, and the next phase with date. Caches per-location via
  an effect; degrades gracefully when unavailable.
- Wired into WeatherDetail between Sun and UV and Temperature Range.
- 11 unit tests for moonName/illumination/interpolation/nextMoonPhase.
- Verified in headless Chromium: card renders, moon shape correct, no errors.
2026-09-08 02:25:01 +00:00

192 lines
5.5 KiB
Svelte

<script>
import { app } from '../lib/stores/app.svelte.js'
import { nextMoonPhase, moonName, illumination, fetchMoonPhases } from '../lib/api/moon.js'
// Unique clip-path id per card instance (SVG defs are document-global).
let _clipId = `moonclip-${Math.random().toString(36).slice(2, 7)}`
let status = $state('loading') // 'loading' | 'ready' | 'error'
let today = $state(null) // { date, phase }
let info = $state(null) // { name, date, daysUntil } from nextMoonPhase
let phaseName = $state('')
let illum = $state(0)
/**
* SVG moon: a lit disc with the unlit side carved by an offset dark circle.
* `phase` is the synodic fraction (0=new … 0.5=full … 1=new). The two-circle
* intersection produces correct crescent / quarter / gibbous shapes.
*/
function moonSVG(phase, size = 96) {
const p = phase % 1.0
const r = size / 2
const k = illumination(p) // 0 (new) … 1 (full)
// Overlay a dark circle of radius r, offset from the moon's center, to
// carve the unlit side. offset=0 → fully dark (new); offset=2r → tangent
// (full). The two-circle intersection yields correct crescent/gibbous
// shapes; at offset=r it is exactly a half moon.
const off = 2 * r * k
// Waxing (p<0.5): lit on the right, so shade the left (negative x).
// Waning (p>0.5): lit on the left, shade the right (positive x).
const shadeDir = p < 0.5 ? -1 : 1
const cx = shadeDir * off
return `<svg viewBox="${-r} ${-r} ${size} ${size}" width="${size}" height="${size}" role="img" aria-label="Moon: ${moonName(phase)}">
<defs>
<clipPath id="${_clipId}"><circle cx="0" cy="0" r="${r}"/></clipPath>
</defs>
<g clip-path="url(#${_clipId})">
<circle cx="0" cy="0" r="${r}" fill="#e8e6df"/>
<circle cx="${cx.toFixed(2)}" cy="0" r="${r}" fill="#0b0e1a"/>
</g>
<circle cx="0" cy="0" r="${r}" fill="none" stroke="rgba(255,255,255,0.18)" stroke-width="1"/>
</svg>`
}
function formatDate(iso) {
if (!iso) return '--'
const d = new Date(`${iso}T12:00:00`)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
async function load() {
const loc = app.selectedLocation
if (!loc) return
status = 'loading'
try {
const phases = await fetchMoonPhases(loc.lat, loc.lon)
if (!phases.length) throw new Error('no moon data')
today = phases[0]
info = nextMoonPhase(phases)
phaseName = moonName(phases[0].phase)
illum = illumination(phases[0].phase)
status = 'ready'
} catch (e) {
console.warn('Moon data unavailable:', e.message)
status = 'error'
}
}
// Load when the location changes or a fresh forecast arrives.
$effect(() => {
app.selectedLocation
if (app.selectedLocation) load()
})
</script>
{#if status === 'loading'}
<div class="moon-card card" aria-busy="true">
<div class="detail-header"><span class="detail-icon">🌙</span><span class="detail-label">Moon</span></div>
<div class="moon-loading">Loading moon phase…</div>
</div>
{:else if status === 'ready' && today}
<div class="moon-card card">
<div class="detail-header"><span class="detail-icon">🌙</span><span class="detail-label">Moon</span></div>
<div class="moon-body">
<div class="moon-visual">
{@html moonSVG(today.phase)}
</div>
<div class="moon-info">
<div class="moon-title">{phaseName}</div>
<div class="moon-sub">{Math.round(illum * 100)}% illuminated</div>
</div>
</div>
{#if info}
<div class="moon-next">
<span class="detail-icon">🕓</span>
<div class="moon-next-text">
<span class="moon-next-line">
<strong>{info.name}</strong>
{#if info.daysUntil === 0}is today{:else if info.daysUntil === 1}in 1 day{:else}in {info.daysUntil} days{/if}
</span>
<span class="moon-next-date">{formatDate(info.date)}</span>
</div>
</div>
{/if}
</div>
{:else}
<div class="moon-card card">
<div class="detail-header"><span class="detail-icon">🌙</span><span class="detail-label">Moon</span></div>
<div class="moon-body"><span class="moon-sub">Moon data unavailable</span></div>
</div>
{/if}
<style>
.moon-card {
padding: 16px;
}
.detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--color-border);
}
.detail-icon { font-size: 1.1rem; }
.detail-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.moon-body {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
}
.moon-visual {
flex-shrink: 0;
filter: drop-shadow(0 0 6px rgba(232, 230, 223, 0.25));
}
.moon-info {
display: flex;
flex-direction: column;
gap: 3px;
}
.moon-title {
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
}
.moon-sub {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.moon-next {
display: flex;
align-items: center;
gap: 10px;
padding-top: 6px;
border-top: 1px solid var(--color-border);
}
.moon-next-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.moon-next-line {
font-size: 0.95rem;
color: var(--color-text);
}
.moon-next-date {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.moon-loading {
font-size: 0.82rem;
color: var(--color-text-muted);
padding: 6px 0;
}
</style>