diff --git a/dist/index.html b/dist/index.html index d3127b4..1d62f07 100644 --- a/dist/index.html +++ b/dist/index.html @@ -229,6 +229,8 @@ function svelte_boundary_reset_onerror() { var HYDRATION_ERROR = {}; var UNINITIALIZED = Symbol("uninitialized"); var NAMESPACE_HTML = "http://www.w3.org/1999/xhtml"; +var NAMESPACE_SVG = "http://www.w3.org/2000/svg"; +var NAMESPACE_MATHML = "http://www.w3.org/1998/Math/MathML"; /** * Reading a derived belonging to a now-destroyed effect may result in stale values */ @@ -3497,6 +3499,24 @@ function from_html(content, flags) { }; } /** +* Don't mark this as side-effect-free, hydration needs to walk all nodes +* @param {any} value +*/ +function text(value = "") { + if (!hydrating) { + var t = create_text(value + ""); + assign_nodes(t, t); + return t; + } + var node = hydrate_node; + if (node.nodeType !== 3) { + node.before(node = create_text()); + set_hydrate_node(node); + } else merge_text_nodes(node); + assign_nodes(node, node); + return node; +} +/** * @returns {TemplateNode | DocumentFragment} */ function comment() { @@ -4234,6 +4254,66 @@ function link(state, prev, next) { if (next === null) state.effect.last = prev; else next.prev = prev; } +/** +* @param {Element | Text | Comment} node +* @param {() => string | TrustedHTML} get_value +* @param {boolean} [is_controlled] +* @param {boolean} [svg] +* @param {boolean} [mathml] +* @param {boolean} [skip_warning] +* @returns {void} +*/ +function html(node, get_value, is_controlled = false, svg = false, mathml = false, skip_warning = false) { + var anchor = node; + /** @type {string | TrustedHTML} */ + var value = ""; + if (is_controlled) { + var parent_node = node; + if (hydrating) anchor = set_hydrate_node(/* @__PURE__ */ get_first_child(parent_node)); + } + template_effect(() => { + var effect = active_effect; + if (value === (value = get_value() ?? "")) { + if (hydrating) hydrate_next(); + return; + } + if (is_controlled && !hydrating) { + effect.nodes = null; + parent_node.innerHTML = value; + if (value !== "") assign_nodes(/* @__PURE__ */ get_first_child(parent_node), parent_node.lastChild); + return; + } + if (effect.nodes !== null) { + remove_effect_dom(effect.nodes.start, effect.nodes.end); + effect.nodes = null; + } + if (value === "") return; + if (hydrating) { + hydrate_node.data; + /** @type {TemplateNode | null} */ + var next = hydrate_next(); + var last = next; + while (next !== null && (next.nodeType !== 8 || next.data !== "")) { + last = next; + next = /* @__PURE__ */ get_next_sibling(next); + } + if (next === null) { + hydration_mismatch(); + throw HYDRATION_ERROR; + } + assign_nodes(hydrate_node, last); + anchor = set_hydrate_node(next); + return; + } + var wrapper = create_element(svg ? "svg" : mathml ? "math" : "template", svg ? NAMESPACE_SVG : mathml ? NAMESPACE_MATHML : void 0); + wrapper.innerHTML = value; + /** @type {DocumentFragment | Element} */ + var node = svg || mathml ? wrapper : /** @type {HTMLTemplateElement} */ wrapper.content; + assign_nodes(/* @__PURE__ */ get_first_child(node), node.lastChild); + if (svg || mathml) while (/* @__PURE__ */ get_first_child(node)) anchor.before(/* @__PURE__ */ get_first_child(node)); + else anchor.before(node); + }); +} //#endregion //#region node_modules/svelte/src/internal/client/dom/elements/actions.js /** @import { ActionPayload } from '#client' */ @@ -4849,11 +4929,11 @@ function replaceTraps(callback) { } function wrapFunction(func) { if (getCursorAdvanceMethods().includes(func)) return function(...args) { - func.apply(unwrap(this), args); + func.apply(unwrap$1(this), args); return wrap(this.request); }; return function(...args) { - return wrap(func.apply(unwrap(this), args)); + return wrap(func.apply(unwrap$1(this), args)); }; } function transformCachableValue(value) { @@ -4915,7 +4995,7 @@ async function* iterate(...args) { cursor = cursor; const proxiedCursor = new Proxy(cursor, cursorIteratorTraps); ittrProxiedCursorToOriginalProxy.set(proxiedCursor, cursor); - reverseTransformCache.set(proxiedCursor, unwrap(cursor)); + reverseTransformCache.set(proxiedCursor, unwrap$1(cursor)); while (cursor) { yield proxiedCursor; cursor = await (advanceResults.get(proxiedCursor) || cursor.continue()); @@ -4929,7 +5009,7 @@ function isIteratorProp(target, prop) { IDBCursor ]) || prop === "iterate" && instanceOfAny(target, [IDBIndex, IDBObjectStore]); } -var instanceOfAny, idbProxyableTypes, cursorAdvanceMethods, transactionDoneMap, transformCache, reverseTransformCache, idbProxyTraps, unwrap, readMethods, writeMethods, cachedMethods, advanceMethodProps, methodMap, advanceResults, ittrProxiedCursorToOriginalProxy, cursorIteratorTraps; +var instanceOfAny, idbProxyableTypes, cursorAdvanceMethods, transactionDoneMap, transformCache, reverseTransformCache, idbProxyTraps, unwrap$1, readMethods, writeMethods, cachedMethods, advanceMethodProps, methodMap, advanceResults, ittrProxiedCursorToOriginalProxy, cursorIteratorTraps; var init_build = __esmMin((() => { instanceOfAny = (object, constructors) => constructors.some((c) => object instanceof c); transactionDoneMap = /* @__PURE__ */ new WeakMap(); @@ -4952,7 +5032,7 @@ var init_build = __esmMin((() => { return prop in target; } }; - unwrap = (value) => reverseTransformCache.get(value); + unwrap$1 = (value) => reverseTransformCache.get(value); readMethods = [ "get", "getKey", @@ -5202,7 +5282,7 @@ async function fetchForecast(lat, lon, units = "metric") { temperature_unit: tempUnit, wind_speed_unit: windUnit }); - const url = `${FORECAST_BASE}?${params.toString()}`; + const url = `${FORECAST_BASE$1}?${params.toString()}`; const res = await fetch(url); if (!res.ok) throw new Error(`Weather API error: ${res.status} ${res.statusText}`); return res.json(); @@ -5232,9 +5312,9 @@ async function searchLocations(query) { timezone: r.timezone || "UTC" })); } -var FORECAST_BASE, GEOCODING_BASE; +var FORECAST_BASE$1, GEOCODING_BASE; var init_weather = __esmMin((() => { - FORECAST_BASE = "https://api.open-meteo.com/v1/forecast"; + FORECAST_BASE$1 = "https://api.open-meteo.com/v1/forecast"; GEOCODING_BASE = "https://geocoding-api.open-meteo.com/v1/search"; })); //#endregion @@ -6283,10 +6363,10 @@ function autofocus(node) { //#endregion //#region src/components/LocationSidebar.svelte init_db(); -var root$10 = /* @__PURE__ */ from_html(`โณ`); -var root_1$9 = /* @__PURE__ */ from_html(``); -var root_2$9 = /* @__PURE__ */ from_html(`
`); -var root_3$7 = /* @__PURE__ */ from_html(`
`); +var root$11 = /* @__PURE__ */ from_html(`โณ`); +var root_1$10 = /* @__PURE__ */ from_html(``); +var root_2$10 = /* @__PURE__ */ from_html(`
`); +var root_3$8 = /* @__PURE__ */ from_html(`
`); var root_4$5 = /* @__PURE__ */ from_html(`
`); var root_5$5 = /* @__PURE__ */ from_html(`

No saved locations.

Enable location access or add one below.

`); var root_6$3 = /* @__PURE__ */ from_html(``); @@ -6348,16 +6428,16 @@ function LocationSidebar($$anchor, $$props) { remove_input_defaults(input); var node = sibling(input, 2); var consequent = ($$anchor) => { - append($$anchor, root$10()); + append($$anchor, root$11()); }; if_block(node, ($$render) => { if (app$1.geocodingLoading) $$render(consequent); }); var node_1 = sibling(node, 2); var consequent_1 = ($$anchor) => { - var div_2 = root_2$9(); + var div_2 = root_2$10(); each(div_2, 21, () => app$1.geocodingResults, index, ($$anchor, result) => { - var button = root_1$9(); + var button = root_1$10(); var span_1 = child(button); var text = child(span_1, true); reset(span_1); @@ -6385,7 +6465,7 @@ function LocationSidebar($$anchor, $$props) { var fragment = comment(); var node_3 = first_child(fragment); var consequent_2 = ($$anchor) => { - var div_3 = root_3$7(); + var div_3 = root_3$8(); var button_1 = child(div_3); let classes; var span_3 = sibling(child(button_1), 2); @@ -6459,12 +6539,12 @@ function LocationSidebar($$anchor, $$props) { delegate(["input", "click"]); //#endregion //#region src/components/SourceTooltip.svelte -var root$9 = /* @__PURE__ */ from_html(` `); +var root$10 = /* @__PURE__ */ from_html(` `); function SourceTooltip($$anchor, $$props) { /** Small info badge whose tooltip explains the value came from the * non-selected source (e.g. Open-Meteo while NWS is selected). */ let label = prop($$props, "label", 3, ""); - var span = root$9(); + var span = root$10(); var span_1 = sibling(child(span), 2); var text = child(span_1, true); reset(span_1); @@ -6477,10 +6557,10 @@ function SourceTooltip($$anchor, $$props) { } //#endregion //#region src/components/CurrentWeather.svelte -var root$8 = /* @__PURE__ */ from_html(` `); -var root_1$8 = /* @__PURE__ */ from_html(`
`); -var root_2$8 = /* @__PURE__ */ from_html(` `); -var root_3$6 = /* @__PURE__ */ from_html(`
๐ŸŒช๏ธ Gusts
`); +var root$9 = /* @__PURE__ */ from_html(` `); +var root_1$9 = /* @__PURE__ */ from_html(`
`); +var root_2$9 = /* @__PURE__ */ from_html(` `); +var root_3$7 = /* @__PURE__ */ from_html(`
๐ŸŒช๏ธ Gusts
`); var root_4$4 = /* @__PURE__ */ from_html(`
๐ŸŒ…
๐ŸŒ‡
`); var root_5$4 = /* @__PURE__ */ from_html(`
๐Ÿ’ง Humidity
๐Ÿ’จ Wind
๐ŸŒก๏ธ Pressure
โ˜€๏ธ UV Index
`); function CurrentWeather($$anchor, $$props) { @@ -6546,7 +6626,7 @@ function CurrentWeather($$anchor, $$props) { var text_3 = child(div_4); var node_1 = sibling(text_3); var consequent = ($$anchor) => { - var span_2 = root$8(); + var span_2 = root$9(); var text_4 = child(span_2); reset(span_2); template_effect(($0) => set_text(text_4, `ยท Feels like ${$0 ?? ""}${get(unit) ?? ""}`), [() => Math.round(get(current).apparent_temperature)]); @@ -6559,7 +6639,7 @@ function CurrentWeather($$anchor, $$props) { reset(div_4); var node_2 = sibling(div_4, 2); var consequent_1 = ($$anchor) => { - var div_5 = root_1$8(); + var div_5 = root_1$9(); var span_3 = child(div_5); var text_5 = child(span_3); reset(span_3); @@ -6615,7 +6695,7 @@ function CurrentWeather($$anchor, $$props) { reset(span_9); var node_5 = sibling(span_9, 2); var consequent_4 = ($$anchor) => { - var span_10 = root_2$8(); + var span_10 = root_2$9(); var text_12 = child(span_10, true); reset(span_10); template_effect(() => set_text(text_12, get(current).uv_index <= 2 ? "Low" : get(current).uv_index <= 5 ? "Moderate" : get(current).uv_index <= 7 ? "High" : "Very High")); @@ -6627,7 +6707,7 @@ function CurrentWeather($$anchor, $$props) { reset(div_10); var node_6 = sibling(div_10, 2); var consequent_5 = ($$anchor) => { - var div_11 = root_3$6(); + var div_11 = root_3$7(); var span_11 = sibling(child(div_11), 4); var text_13 = child(span_11); reset(span_11); @@ -6691,10 +6771,10 @@ function CurrentWeather($$anchor, $$props) { } //#endregion //#region src/components/HourlyForecast.svelte -var root$7 = /* @__PURE__ */ from_html(`
`, 1); -var root_1$7 = /* @__PURE__ */ from_html(`
--`, 1); -var root_2$7 = /* @__PURE__ */ from_html(`
`); -var root_3$5 = /* @__PURE__ */ from_html(`

Hourly Forecast

`); +var root$8 = /* @__PURE__ */ from_html(`
`, 1); +var root_1$8 = /* @__PURE__ */ from_html(`
--`, 1); +var root_2$8 = /* @__PURE__ */ from_html(`
`); +var root_3$6 = /* @__PURE__ */ from_html(`

Hourly Forecast

`); function HourlyForecast($$anchor, $$props) { push($$props, true); const data = /* @__PURE__ */ user_derived(() => app$1.forecastData); @@ -6729,11 +6809,11 @@ function HourlyForecast($$anchor, $$props) { var fragment = comment(); var node = first_child(fragment); var consequent_1 = ($$anchor) => { - var div = root_3$5(); + var div = root_3$6(); var div_1 = sibling(child(div), 2); each(div_1, 21, () => get(next24Hours), index, ($$anchor, hour, i) => { const info = /* @__PURE__ */ user_derived(() => getWeatherInfo(get(hour).code)); - var div_2 = root_2$7(); + var div_2 = root_2$8(); let classes; var span = child(div_2); var text = child(span, true); @@ -6746,7 +6826,7 @@ function HourlyForecast($$anchor, $$props) { reset(span_2); var node_1 = sibling(span_2, 2); var consequent = ($$anchor) => { - var fragment_1 = root$7(); + var fragment_1 = root$8(); var div_3 = first_child(fragment_1); var div_4 = child(div_3); reset(div_3); @@ -6760,7 +6840,7 @@ function HourlyForecast($$anchor, $$props) { append($$anchor, fragment_1); }; var alternate = ($$anchor) => { - var fragment_2 = root_1$7(); + var fragment_2 = root_1$8(); next(2); append($$anchor, fragment_2); }; @@ -6798,10 +6878,10 @@ function HourlyForecast($$anchor, $$props) { } //#endregion //#region src/components/DailyForecast.svelte -var root$6 = /* @__PURE__ */ from_html(`
๐Ÿ’ง
`); -var root_1$6 = /* @__PURE__ */ from_html(` `); -var root_2$6 = /* @__PURE__ */ from_html(`
`); -var root_3$4 = /* @__PURE__ */ from_html(`

7-Day Forecast

`); +var root$7 = /* @__PURE__ */ from_html(`
๐Ÿ’ง
`); +var root_1$7 = /* @__PURE__ */ from_html(` `); +var root_2$7 = /* @__PURE__ */ from_html(`
`); +var root_3$5 = /* @__PURE__ */ from_html(`

7-Day Forecast

`); function DailyForecast($$anchor, $$props) { push($$props, true); const data = /* @__PURE__ */ user_derived(() => app$1.forecastData); @@ -6825,7 +6905,7 @@ function DailyForecast($$anchor, $$props) { var fragment = comment(); var node = first_child(fragment); var consequent_3 = ($$anchor) => { - var div = root_3$4(); + var div = root_3$5(); var div_1 = sibling(child(div), 2); each(div_1, 21, () => get(daily).time, index, ($$anchor, day, i) => { var fragment_1 = comment(); @@ -6836,7 +6916,7 @@ function DailyForecast($$anchor, $$props) { const tempMax = /* @__PURE__ */ user_derived(() => Math.round(get(daily).temperature_2m_max[i])); const tempMin = /* @__PURE__ */ user_derived(() => Math.round(get(daily).temperature_2m_min[i])); const wind = /* @__PURE__ */ user_derived(() => get(daily).wind_speed_10m_max?.[i]); - var div_2 = root_2$6(); + var div_2 = root_2$7(); set_class(div_2, 1, "day-card card-glass svelte-ss3yj8", null, {}, { today: i === 0 }); var div_3 = child(div_2); var span = child(div_3); @@ -6865,7 +6945,7 @@ function DailyForecast($$anchor, $$props) { reset(div_6); var node_2 = sibling(div_6, 2); var consequent = ($$anchor) => { - var div_7 = root$6(); + var div_7 = root$7(); var div_8 = sibling(child(div_7), 2); var div_9 = child(div_8); reset(div_8); @@ -6884,7 +6964,7 @@ function DailyForecast($$anchor, $$props) { }); var node_3 = sibling(node_2, 2); var consequent_1 = ($$anchor) => { - var span_7 = root_1$6(); + var span_7 = root_1$7(); var text_7 = child(span_7); reset(span_7); template_effect(($0) => set_text(text_7, `๐Ÿ’จ ${$0 ?? ""} ${get(windUnit) ?? ""}`), [() => Math.round(get(wind))]); @@ -6921,10 +7001,275 @@ function DailyForecast($$anchor, $$props) { pop(); } //#endregion +//#region src/lib/api/moon.js +/** +* Moon phase helpers. +* +* Moon phase data is fetched from the Open-Meteo forecast API (daily +* `moon_phase`), which returns the moon's position in its synodic cycle as a +* fraction: 0 = new moon, 0.25 = first quarter, 0.5 = full moon, +* 0.75 = last quarter, approaching 1.0 = next new moon. We fetch it +* independently of the weather source (NWS or Open-Meteo) since NWS does not +* provide moon data and Open-Meteo covers the globe without a key. +* +* The pure functions here are unit-testable; the fetch is thin. +*/ +var FORECAST_BASE = "https://api.open-meteo.com/v1/forecast"; +var MOON_NAMES = [ + "New Moon", + "Waxing Crescent", + "First Quarter", + "Waxing Gibbous", + "Full Moon", + "Waning Gibbous", + "Last Quarter", + "Waning Crescent" +]; +/** +* Fetch daily moon_phase values for a location. +* @param {number} lat +* @param {number} lon +* @returns {Promise>} oldest โ†’ newest +*/ +async function fetchMoonPhases(lat, lon) { + const url = `${FORECAST_BASE}?${new URLSearchParams({ + latitude: lat.toString(), + longitude: lon.toString(), + daily: "moon_phase", + timezone: "auto", + forecast_days: String(9) + }).toString()}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Moon API error: ${res.status} ${res.statusText}`); + const data = await res.json(); + const times = data.daily?.time || []; + const phases = data.daily?.moon_phase || []; + return times.map((date, i) => ({ + date, + phase: phases[i] + })); +} +/** +* Name of the moon phase for a synodic-cycle position (0 โ†’ next new moon). +* @param {number} phase 0..1 fraction of the lunar month +* @returns {string} +*/ +function moonName(phase) { + return MOON_NAMES[Math.floor(phase % 1 * 8) % 8]; +} +/** +* Fraction of the lunar disc illuminated, 0 (new) โ†’ 1 (full). +* @param {number} phase 0..1 synodic position (0=new, 0.5=full) +* @returns {number} 0..1 +*/ +function illumination(phase) { + return (1 - Math.cos(2 * Math.PI * (phase % 1))) / 2; +} +/** +* Convert a synodic position to an unwrapped, strictly increasing value so +* the cycle wrapping (new moon) doesn't confuse "which boundary is next". +* @param {number} phase 0..1 +* @param {number} wraps how many full cycles precede it +* @returns {number} +*/ +function unwrap(phase, wraps) { + return phase + wraps; +} +/** +* Given consecutive daily samples, find the next major phase boundary (one of +* new / first quarter / full / last quarter) strictly after the first sample: +* which boundary it is, an interpolated crossing date, and days until it. +* +* @param {Array<{ date: string, phase: number }>} days oldest โ†’ newest +* @returns {{ name: string, date: string, daysUntil: number } | null} +* null when no boundary is reached within the samples. +*/ +function nextMoonPhase(days) { + if (!days || days.length < 2) return null; + const unwrapped = []; + let wraps = 0; + for (let i = 0; i < days.length; i++) { + if (i > 0 && days[i].phase < days[i - 1].phase) wraps++; + unwrapped.push(unwrap(days[i].phase, wraps)); + } + const p0 = unwrapped[0]; + const target = (Math.floor(p0 / .25) + 1) * .25; + for (let i = 0; i < days.length; i++) { + if (unwrapped[i] < target) continue; + const prev = unwrapped[Math.max(i - 1, 0)]; + const next = unwrapped[i]; + const prevDate = days[Math.max(i - 1, 0)].date; + const nextDate = days[i].date; + const span = next - prev; + const date = interpolateDate(prevDate, nextDate, span === 0 ? 0 : (target - prev) / span); + const daysUntil = Math.ceil(diffDays(days[0].date, date)); + return { + name: moonName(target % 1), + date, + daysUntil + }; + } + return null; +} +/** Interpolate a YYYY-MM-DD date between two dates by ratio [0,1]. */ +function interpolateDate(from, to, ratio) { + const t0 = Date.parse(`${from}T12:00:00`); + const ms = t0 + (Date.parse(`${to}T12:00:00`) - t0) * Math.max(0, Math.min(1, ratio)); + return new Date(ms).toISOString().slice(0, 10); +} +/** Whole days from date `a` to date `b` (b >= a), at day granularity. */ +function diffDays(a, b) { + const ta = Date.parse(`${a}T12:00:00`); + const tb = Date.parse(`${b}T12:00:00`); + return Math.round((tb - ta) / 864e5); +} +//#endregion +//#region src/components/MoonPhaseCard.svelte +var root$6 = /* @__PURE__ */ from_html(`
๐ŸŒ™Moon
Loading moon phaseโ€ฆ
`); +var root_1$6 = /* @__PURE__ */ from_html(`
๐Ÿ•“
`); +var root_2$6 = /* @__PURE__ */ from_html(`
๐ŸŒ™Moon
`); +var root_3$4 = /* @__PURE__ */ from_html(`
๐ŸŒ™Moon
Moon data unavailable
`); +function MoonPhaseCard($$anchor, $$props) { + push($$props, true); + let _clipId = `moonclip-${Math.random().toString(36).slice(2, 7)}`; + let status = /* @__PURE__ */ state("loading"); + let today = /* @__PURE__ */ state(null); + let info = /* @__PURE__ */ state(null); + let phaseName = /* @__PURE__ */ state(""); + let illum = /* @__PURE__ */ 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; + const r = size / 2; + const k = illumination(p); + const off = 2 * r * k; + const cx = (p < .5 ? -1 : 1) * off; + return ` + + + + + + + + + `; + } + function formatDate(iso) { + if (!iso) return "--"; + return (/* @__PURE__ */ new Date(`${iso}T12:00:00`)).toLocaleDateString("en-US", { + month: "short", + day: "numeric" + }); + } + async function load() { + const loc = app$1.selectedLocation; + if (!loc) return; + set(status, "loading"); + try { + const phases = await fetchMoonPhases(loc.lat, loc.lon); + if (!phases.length) throw new Error("no moon data"); + set(today, phases[0], true); + set(info, nextMoonPhase(phases), true); + set(phaseName, moonName(phases[0].phase), true); + set(illum, illumination(phases[0].phase), true); + set(status, "ready"); + } catch (e) { + console.warn("Moon data unavailable:", e.message); + set(status, "error"); + } + } + user_effect(() => { + app$1.selectedLocation; + if (app$1.selectedLocation) load(); + }); + var fragment = comment(); + var node = first_child(fragment); + var consequent = ($$anchor) => { + append($$anchor, root$6()); + }; + var consequent_4 = ($$anchor) => { + var div_1 = root_2$6(); + var div_2 = sibling(child(div_1), 2); + var div_3 = child(div_2); + html(div_3, () => moonSVG(get(today).phase), true); + reset(div_3); + var div_4 = sibling(div_3, 2); + var div_5 = child(div_4); + var text$1 = child(div_5, true); + reset(div_5); + var div_6 = sibling(div_5, 2); + var text_1 = child(div_6); + reset(div_6); + reset(div_4); + reset(div_2); + var node_1 = sibling(div_2, 2); + var consequent_3 = ($$anchor) => { + var div_7 = root_1$6(); + var div_8 = sibling(child(div_7), 2); + var span = child(div_8); + var strong = child(span); + var text_2 = child(strong, true); + reset(strong); + var node_2 = sibling(strong, 2); + var consequent_1 = ($$anchor) => { + append($$anchor, text("is today")); + }; + var consequent_2 = ($$anchor) => { + append($$anchor, text("in 1 day")); + }; + var alternate = ($$anchor) => { + var text_5 = text(); + template_effect(() => set_text(text_5, `in ${get(info).daysUntil ?? ""} days`)); + append($$anchor, text_5); + }; + if_block(node_2, ($$render) => { + if (get(info).daysUntil === 0) $$render(consequent_1); + else if (get(info).daysUntil === 1) $$render(consequent_2, 1); + else $$render(alternate, -1); + }); + reset(span); + var span_1 = sibling(span, 2); + var text_6 = child(span_1, true); + reset(span_1); + reset(div_8); + reset(div_7); + template_effect(($0) => { + set_text(text_2, get(info).name); + set_text(text_6, $0); + }, [() => formatDate(get(info).date)]); + append($$anchor, div_7); + }; + if_block(node_1, ($$render) => { + if (get(info)) $$render(consequent_3); + }); + reset(div_1); + template_effect(($0) => { + set_text(text$1, get(phaseName)); + set_text(text_1, `${$0 ?? ""}% illuminated`); + }, [() => Math.round(get(illum) * 100)]); + append($$anchor, div_1); + }; + var alternate_1 = ($$anchor) => { + append($$anchor, root_3$4()); + }; + if_block(node, ($$render) => { + if (get(status) === "loading") $$render(consequent); + else if (get(status) === "ready" && get(today)) $$render(consequent_4, 1); + else $$render(alternate_1, -1); + }); + append($$anchor, fragment); + pop(); +} +//#endregion //#region src/components/WeatherDetail.svelte var root$5 = /* @__PURE__ */ from_html(` `); var root_1$5 = /* @__PURE__ */ from_html(`
24h Average
`); -var root_2$5 = /* @__PURE__ */ from_html(`

Weather Details

๐Ÿ’จ Wind
Speed
Direction
Gusts
Condition
๐ŸŒก๏ธ Atmosphere
Humidity
Pressure
Feels Like
Is Day
โ˜€๏ธ Sun & UV
UV Index
Sunrise
Sunset
Max UV Today
๐Ÿ“Š Temperature Range
Today's High
Today's Low
Precip Chance
Data source
Local time
Data updated
Elevation
`); +var root_2$5 = /* @__PURE__ */ from_html(`

Weather Details

๐Ÿ’จ Wind
Speed
Direction
Gusts
Condition
๐ŸŒก๏ธ Atmosphere
Humidity
Pressure
Feels Like
Is Day
โ˜€๏ธ Sun & UV
UV Index
Sunrise
Sunset
Max UV Today
๐Ÿ“Š Temperature Range
Today's High
Today's Low
Precip Chance
Data source
Local time
Data updated
Elevation
`); function WeatherDetail($$anchor, $$props) { push($$props, true); const data = /* @__PURE__ */ user_derived(() => app$1.forecastData); @@ -7097,70 +7442,73 @@ function WeatherDetail($$anchor, $$props) { reset(div_15); reset(div_14); var div_20 = sibling(div_14, 2); - var div_21 = sibling(child(div_20), 2); - var div_22 = child(div_21); - var span_13 = sibling(child(div_22), 2); + MoonPhaseCard(child(div_20), {}); + reset(div_20); + var div_21 = sibling(div_20, 2); + var div_22 = sibling(child(div_21), 2); + var div_23 = child(div_22); + var span_13 = sibling(child(div_23), 2); var text_13 = child(span_13, true); reset(span_13); - reset(div_22); - var div_23 = sibling(div_22, 2); - var span_14 = sibling(child(div_23), 2); + reset(div_23); + var div_24 = sibling(div_23, 2); + var span_14 = sibling(child(div_24), 2); var text_14 = child(span_14, true); reset(span_14); - reset(div_23); - var node_5 = sibling(div_23, 2); + reset(div_24); + var node_6 = sibling(div_24, 2); var consequent_5 = ($$anchor) => { const temps = /* @__PURE__ */ user_derived(() => get(hourly).temperature_2m || []); const validTemps = /* @__PURE__ */ user_derived(() => get(temps).filter((t) => t != null)); var fragment_4 = comment(); - var node_6 = first_child(fragment_4); + var node_7 = first_child(fragment_4); var consequent_4 = ($$anchor) => { - var div_24 = root_1$5(); - var span_15 = sibling(child(div_24), 2); + var div_25 = root_1$5(); + var span_15 = sibling(child(div_25), 2); var text_15 = child(span_15); reset(span_15); - reset(div_24); + reset(div_25); template_effect(($0) => set_text(text_15, `${$0 ?? ""}${get(unit) ?? ""}`), [() => Math.round(get(validTemps).reduce((a, b) => a + b, 0) / get(validTemps).length)]); - append($$anchor, div_24); + append($$anchor, div_25); }; - if_block(node_6, ($$render) => { + if_block(node_7, ($$render) => { if (get(validTemps).length > 0) $$render(consequent_4); }); append($$anchor, fragment_4); }; - if_block(node_5, ($$render) => { + if_block(node_6, ($$render) => { if (get(hourly)) $$render(consequent_5); }); - var div_25 = sibling(node_5, 2); - var span_16 = sibling(child(div_25), 2); + var div_26 = sibling(node_6, 2); + var span_16 = sibling(child(div_26), 2); var text_16 = child(span_16); reset(span_16); - reset(div_25); + reset(div_26); + reset(div_22); reset(div_21); - reset(div_20); reset(div_1); - var div_26 = sibling(div_1, 2); - var div_27 = child(div_26); - var span_17 = sibling(child(div_27), 2); + var div_27 = sibling(div_1, 2); + var div_28 = child(div_27); + var span_17 = sibling(child(div_28), 2); var text_17 = child(span_17, true); reset(span_17); - reset(div_27); - var div_28 = sibling(div_27, 2); - var span_18 = sibling(child(div_28), 2); - var text_18 = child(span_18, true); - reset(span_18); reset(div_28); var div_29 = sibling(div_28, 2); - var span_19 = sibling(child(div_29), 2); - var text_19 = child(span_19, true); - reset(span_19); + var span_18 = sibling(child(div_29), 2); + var text_18 = child(span_18, true); + reset(span_18); reset(div_29); var div_30 = sibling(div_29, 2); - var span_20 = sibling(child(div_30), 2); + var span_19 = sibling(child(div_30), 2); + var text_19 = child(span_19, true); + reset(span_19); + reset(div_30); + var div_31 = sibling(div_30, 2); + var span_20 = sibling(child(div_31), 2); var text_20 = child(span_20, true); reset(span_20); - reset(div_30); - reset(div_26); + reset(div_31); + reset(div_27); reset(div); template_effect(($0, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) => { set_text(text, `${$0 ?? ""} ${get(windUnit) ?? ""}`); @@ -8792,7 +9140,7 @@ delegate([ ]); //#endregion //#region src/main.js -console.info({ commit_hash: "897f49ae2209cb2213ea97a4a082bb4bdfa2889a" }); +console.info({ commit_hash: "2eb34a709b0f3e5322756e91e014abdaf754a526" }); registerServiceWorker(); mount(App, { target: document.getElementById("app") }); //#endregion @@ -9841,6 +10189,85 @@ input::placeholder { } } + .moon-card.svelte-d2g8r6 { + padding: 16px; + } + + .detail-header.svelte-d2g8r6 { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid var(--color-border); + } + + .detail-icon.svelte-d2g8r6 { font-size: 1.1rem; } + .detail-label.svelte-d2g8r6 { + font-size: 0.85rem; + font-weight: 600; + color: var(--color-text-secondary); + } + + .moon-body.svelte-d2g8r6 { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 10px; + } + + .moon-visual.svelte-d2g8r6 { + flex-shrink: 0; + filter: drop-shadow(0 0 6px rgba(232, 230, 223, 0.25)); + } + + .moon-info.svelte-d2g8r6 { + display: flex; + flex-direction: column; + gap: 3px; + } + + .moon-title.svelte-d2g8r6 { + font-size: 1.05rem; + font-weight: 700; + color: var(--color-text); + } + + .moon-sub.svelte-d2g8r6 { + font-size: 0.78rem; + color: var(--color-text-muted); + } + + .moon-next.svelte-d2g8r6 { + display: flex; + align-items: center; + gap: 10px; + padding-top: 6px; + border-top: 1px solid var(--color-border); + } + + .moon-next-text.svelte-d2g8r6 { + display: flex; + flex-direction: column; + gap: 2px; + } + + .moon-next-line.svelte-d2g8r6 { + font-size: 0.95rem; + color: var(--color-text); + } + + .moon-next-date.svelte-d2g8r6 { + font-size: 0.78rem; + color: var(--color-text-muted); + } + + .moon-loading.svelte-d2g8r6 { + font-size: 0.82rem; + color: var(--color-text-muted); + padding: 6px 0; + } + .detail-section.svelte-8lxt71 { margin-bottom: 4px; } diff --git a/src/components/MoonPhaseCard.svelte b/src/components/MoonPhaseCard.svelte new file mode 100644 index 0000000..001ddab --- /dev/null +++ b/src/components/MoonPhaseCard.svelte @@ -0,0 +1,191 @@ + + +{#if status === 'loading'} +
+
๐ŸŒ™Moon
+
Loading moon phaseโ€ฆ
+
+{:else if status === 'ready' && today} +
+
๐ŸŒ™Moon
+
+
+ {@html moonSVG(today.phase)} +
+
+
{phaseName}
+
{Math.round(illum * 100)}% illuminated
+
+
+ {#if info} +
+ ๐Ÿ•“ +
+ + {info.name} + {#if info.daysUntil === 0}is today{:else if info.daysUntil === 1}in 1 day{:else}in {info.daysUntil} days{/if} + + {formatDate(info.date)} +
+
+ {/if} +
+{:else} +
+
๐ŸŒ™Moon
+
Moon data unavailable
+
+{/if} + + diff --git a/src/components/WeatherDetail.svelte b/src/components/WeatherDetail.svelte index 96932de..b9416d1 100644 --- a/src/components/WeatherDetail.svelte +++ b/src/components/WeatherDetail.svelte @@ -2,6 +2,7 @@ import { app } from '../lib/stores/app.svelte.js' import { getWeatherInfo } from '../lib/api/weather-codes.js' import SourceTooltip from './SourceTooltip.svelte' + import MoonPhaseCard from './MoonPhaseCard.svelte' const data = $derived(app.forecastData) const current = $derived(data?.current) @@ -143,6 +144,11 @@ + +
+ +
+
diff --git a/src/lib/api/moon.js b/src/lib/api/moon.js new file mode 100644 index 0000000..348ce05 --- /dev/null +++ b/src/lib/api/moon.js @@ -0,0 +1,141 @@ +/** + * Moon phase helpers. + * + * Moon phase data is fetched from the Open-Meteo forecast API (daily + * `moon_phase`), which returns the moon's position in its synodic cycle as a + * fraction: 0 = new moon, 0.25 = first quarter, 0.5 = full moon, + * 0.75 = last quarter, approaching 1.0 = next new moon. We fetch it + * independently of the weather source (NWS or Open-Meteo) since NWS does not + * provide moon data and Open-Meteo covers the globe without a key. + * + * The pure functions here are unit-testable; the fetch is thin. + */ + +const FORECAST_BASE = 'https://api.open-meteo.com/v1/forecast' + +export const MOON_NAMES = [ + 'New Moon', + 'Waxing Crescent', + 'First Quarter', + 'Waxing Gibbous', + 'Full Moon', + 'Waning Gibbous', + 'Last Quarter', + 'Waning Crescent', +] + +/** Number of daily samples needed to always catch the next quarter phase + * (quarters are ~7.4 days apart; a little headroom for interpolation). */ +export const MOON_FETCH_DAYS = 9 + +/** + * Fetch daily moon_phase values for a location. + * @param {number} lat + * @param {number} lon + * @returns {Promise>} oldest โ†’ newest + */ +export async function fetchMoonPhases(lat, lon) { + const params = new URLSearchParams({ + latitude: lat.toString(), + longitude: lon.toString(), + daily: 'moon_phase', + timezone: 'auto', + forecast_days: String(MOON_FETCH_DAYS), + }) + const url = `${FORECAST_BASE}?${params.toString()}` + const res = await fetch(url) + if (!res.ok) throw new Error(`Moon API error: ${res.status} ${res.statusText}`) + const data = await res.json() + const times = data.daily?.time || [] + const phases = data.daily?.moon_phase || [] + return times.map((date, i) => ({ date, phase: phases[i] })) +} + +/** + * Name of the moon phase for a synodic-cycle position (0 โ†’ next new moon). + * @param {number} phase 0..1 fraction of the lunar month + * @returns {string} + */ +export function moonName(phase) { + const idx = Math.floor((phase % 1.0) * 8) % 8 + return MOON_NAMES[idx] +} + +/** + * Fraction of the lunar disc illuminated, 0 (new) โ†’ 1 (full). + * @param {number} phase 0..1 synodic position (0=new, 0.5=full) + * @returns {number} 0..1 + */ +export function illumination(phase) { + return (1 - Math.cos(2 * Math.PI * (phase % 1.0))) / 2 +} + +/** + * Convert a synodic position to an unwrapped, strictly increasing value so + * the cycle wrapping (new moon) doesn't confuse "which boundary is next". + * @param {number} phase 0..1 + * @param {number} wraps how many full cycles precede it + * @returns {number} + */ +function unwrap(phase, wraps) { + return phase + wraps +} + +/** + * Given consecutive daily samples, find the next major phase boundary (one of + * new / first quarter / full / last quarter) strictly after the first sample: + * which boundary it is, an interpolated crossing date, and days until it. + * + * @param {Array<{ date: string, phase: number }>} days oldest โ†’ newest + * @returns {{ name: string, date: string, daysUntil: number } | null} + * null when no boundary is reached within the samples. + */ +export function nextMoonPhase(days) { + if (!days || days.length < 2) return null + + // Unwrap so the cycle is monotonically increasing (adds 1.0 at each wrap). + const unwrapped = [] + let wraps = 0 + for (let i = 0; i < days.length; i++) { + if (i > 0 && days[i].phase < days[i - 1].phase) wraps++ + unwrapped.push(unwrap(days[i].phase, wraps)) + } + + const p0 = unwrapped[0] + // Next strictly-greater quarter in unwrapped space. + const target = (Math.floor(p0 / 0.25) + 1) * 0.25 + + // Find the first sample at/after the crossing. + for (let i = 0; i < days.length; i++) { + if (unwrapped[i] < target) continue + + const prev = unwrapped[Math.max(i - 1, 0)] + const next = unwrapped[i] + const prevDate = days[Math.max(i - 1, 0)].date + const nextDate = days[i].date + // Linear interpolation: how far between prev and next the crossing sits. + const span = next - prev + const ratio = span === 0 ? 0 : (target - prev) / span + const date = interpolateDate(prevDate, nextDate, ratio) + // Days until, from the first sample's date to the crossing date. + const daysUntil = Math.ceil(diffDays(days[0].date, date)) + return { name: moonName(target % 1.0), date, daysUntil } + } + + return null +} + +/** Interpolate a YYYY-MM-DD date between two dates by ratio [0,1]. */ +export function interpolateDate(from, to, ratio) { + const t0 = Date.parse(`${from}T12:00:00`) + const t1 = Date.parse(`${to}T12:00:00`) + const ms = t0 + (t1 - t0) * Math.max(0, Math.min(1, ratio)) + return new Date(ms).toISOString().slice(0, 10) +} + +/** Whole days from date `a` to date `b` (b >= a), at day granularity. */ +export function diffDays(a, b) { + const ta = Date.parse(`${a}T12:00:00`) + const tb = Date.parse(`${b}T12:00:00`) + return Math.round((tb - ta) / 86400000) +} diff --git a/tests/lib/api/moon.test.js b/tests/lib/api/moon.test.js new file mode 100644 index 0000000..1c65cef --- /dev/null +++ b/tests/lib/api/moon.test.js @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest' +import { moonName, illumination, nextMoonPhase, interpolateDate, diffDays } from '../../../src/lib/api/moon.js' + +describe('moonName', () => { + it('names the four principal phases', () => { + expect(moonName(0.0)).toBe('New Moon') + expect(moonName(0.25)).toBe('First Quarter') + expect(moonName(0.5)).toBe('Full Moon') + expect(moonName(0.75)).toBe('Last Quarter') + }) + + it('names the intermediate phases', () => { + expect(moonName(0.125)).toBe('Waxing Crescent') + expect(moonName(0.375)).toBe('Waxing Gibbous') + expect(moonName(0.625)).toBe('Waning Gibbous') + expect(moonName(0.875)).toBe('Waning Crescent') + }) + + it('wraps 1.0 back to new moon', () => { + expect(moonName(1.0)).toBe('New Moon') + expect(moonName(0.98)).toBe('Waning Crescent') + }) +}) + +describe('illumination', () => { + it('is 0 at new moon and 1 at full', () => { + expect(illumination(0)).toBeCloseTo(0, 5) + expect(illumination(0.5)).toBeCloseTo(1, 5) + }) + + it('is symmetric about full: quarter both ~0.5', () => { + expect(illumination(0.25)).toBeCloseTo(0.5, 5) + expect(illumination(0.75)).toBeCloseTo(0.5, 5) + }) + + it('near new gives small illumination', () => { + expect(illumination(0.912)).toBeLessThan(0.1) + }) +}) + +describe('interpolateDate / diffDays', () => { + it('interpolates dates', () => { + expect(interpolateDate('2026-09-10', '2026-09-12', 0.5)).toBe('2026-09-11') + expect(interpolateDate('2026-09-10', '2026-09-12', 0)).toBe('2026-09-10') + expect(interpolateDate('2026-09-10', '2026-09-12', 1)).toBe('2026-09-12') + }) + + it('computes whole days between dates', () => { + expect(diffDays('2026-09-08', '2026-09-10')).toBe(2) + expect(diffDays('2026-09-08', '2026-09-08')).toBe(0) + }) +}) + +describe('nextMoonPhase', () => { + const mk = (arr) => arr.map(([date, phase]) => ({ date, phase })) + + it('finds next new moon from a waning-crescent sample', () => { + const days = mk([ + ['2026-09-08', 0.912], ['2026-09-09', 0.948], ['2026-09-10', 0.985], + ['2026-09-11', 0.020], ['2026-09-12', 0.054], ['2026-09-13', 0.088], + ['2026-09-14', 0.121], ['2026-09-15', 0.153], + ]) + const r = nextMoonPhase(days) + expect(r.name).toBe('New Moon') + expect(r.daysUntil).toBe(2) + expect(r.date).toBe('2026-09-10') + }) + + it('finds next first quarter from a new-moon sample', () => { + const days = mk([ + ['2026-09-11', 0.020], ['2026-09-12', 0.054], ['2026-09-13', 0.088], + ['2026-09-14', 0.121], ['2026-09-15', 0.153], ['2026-09-16', 0.184], + ['2026-09-17', 0.215], ['2026-09-18', 0.245], ['2026-09-19', 0.276], + ]) + const r = nextMoonPhase(days) + expect(r.name).toBe('First Quarter') + expect(r.daysUntil).toBe(7) + }) + + it('returns null with too few samples', () => { + expect(nextMoonPhase(null)).toBeNull() + expect(nextMoonPhase(mk([['2026-09-08', 0.5]]))).toBeNull() + }) +})