- 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.
192 lines
5.5 KiB
Svelte
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>
|