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.
This commit is contained in:
parent
2eb34a709b
commit
97b676093a
581
dist/index.html
vendored
581
dist/index.html
vendored
@ -229,6 +229,8 @@ function svelte_boundary_reset_onerror() {
|
|||||||
var HYDRATION_ERROR = {};
|
var HYDRATION_ERROR = {};
|
||||||
var UNINITIALIZED = Symbol("uninitialized");
|
var UNINITIALIZED = Symbol("uninitialized");
|
||||||
var NAMESPACE_HTML = "http://www.w3.org/1999/xhtml";
|
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
|
* 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}
|
* @returns {TemplateNode | DocumentFragment}
|
||||||
*/
|
*/
|
||||||
function comment() {
|
function comment() {
|
||||||
@ -4234,6 +4254,66 @@ function link(state, prev, next) {
|
|||||||
if (next === null) state.effect.last = prev;
|
if (next === null) state.effect.last = prev;
|
||||||
else next.prev = 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
|
//#endregion
|
||||||
//#region node_modules/svelte/src/internal/client/dom/elements/actions.js
|
//#region node_modules/svelte/src/internal/client/dom/elements/actions.js
|
||||||
/** @import { ActionPayload } from '#client' */
|
/** @import { ActionPayload } from '#client' */
|
||||||
@ -4849,11 +4929,11 @@ function replaceTraps(callback) {
|
|||||||
}
|
}
|
||||||
function wrapFunction(func) {
|
function wrapFunction(func) {
|
||||||
if (getCursorAdvanceMethods().includes(func)) return function(...args) {
|
if (getCursorAdvanceMethods().includes(func)) return function(...args) {
|
||||||
func.apply(unwrap(this), args);
|
func.apply(unwrap$1(this), args);
|
||||||
return wrap(this.request);
|
return wrap(this.request);
|
||||||
};
|
};
|
||||||
return function(...args) {
|
return function(...args) {
|
||||||
return wrap(func.apply(unwrap(this), args));
|
return wrap(func.apply(unwrap$1(this), args));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function transformCachableValue(value) {
|
function transformCachableValue(value) {
|
||||||
@ -4915,7 +4995,7 @@ async function* iterate(...args) {
|
|||||||
cursor = cursor;
|
cursor = cursor;
|
||||||
const proxiedCursor = new Proxy(cursor, cursorIteratorTraps);
|
const proxiedCursor = new Proxy(cursor, cursorIteratorTraps);
|
||||||
ittrProxiedCursorToOriginalProxy.set(proxiedCursor, cursor);
|
ittrProxiedCursorToOriginalProxy.set(proxiedCursor, cursor);
|
||||||
reverseTransformCache.set(proxiedCursor, unwrap(cursor));
|
reverseTransformCache.set(proxiedCursor, unwrap$1(cursor));
|
||||||
while (cursor) {
|
while (cursor) {
|
||||||
yield proxiedCursor;
|
yield proxiedCursor;
|
||||||
cursor = await (advanceResults.get(proxiedCursor) || cursor.continue());
|
cursor = await (advanceResults.get(proxiedCursor) || cursor.continue());
|
||||||
@ -4929,7 +5009,7 @@ function isIteratorProp(target, prop) {
|
|||||||
IDBCursor
|
IDBCursor
|
||||||
]) || prop === "iterate" && instanceOfAny(target, [IDBIndex, IDBObjectStore]);
|
]) || 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((() => {
|
var init_build = __esmMin((() => {
|
||||||
instanceOfAny = (object, constructors) => constructors.some((c) => object instanceof c);
|
instanceOfAny = (object, constructors) => constructors.some((c) => object instanceof c);
|
||||||
transactionDoneMap = /* @__PURE__ */ new WeakMap();
|
transactionDoneMap = /* @__PURE__ */ new WeakMap();
|
||||||
@ -4952,7 +5032,7 @@ var init_build = __esmMin((() => {
|
|||||||
return prop in target;
|
return prop in target;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
unwrap = (value) => reverseTransformCache.get(value);
|
unwrap$1 = (value) => reverseTransformCache.get(value);
|
||||||
readMethods = [
|
readMethods = [
|
||||||
"get",
|
"get",
|
||||||
"getKey",
|
"getKey",
|
||||||
@ -5202,7 +5282,7 @@ async function fetchForecast(lat, lon, units = "metric") {
|
|||||||
temperature_unit: tempUnit,
|
temperature_unit: tempUnit,
|
||||||
wind_speed_unit: windUnit
|
wind_speed_unit: windUnit
|
||||||
});
|
});
|
||||||
const url = `${FORECAST_BASE}?${params.toString()}`;
|
const url = `${FORECAST_BASE$1}?${params.toString()}`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error(`Weather API error: ${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`Weather API error: ${res.status} ${res.statusText}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
@ -5232,9 +5312,9 @@ async function searchLocations(query) {
|
|||||||
timezone: r.timezone || "UTC"
|
timezone: r.timezone || "UTC"
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
var FORECAST_BASE, GEOCODING_BASE;
|
var FORECAST_BASE$1, GEOCODING_BASE;
|
||||||
var init_weather = __esmMin((() => {
|
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";
|
GEOCODING_BASE = "https://geocoding-api.open-meteo.com/v1/search";
|
||||||
}));
|
}));
|
||||||
//#endregion
|
//#endregion
|
||||||
@ -6283,10 +6363,10 @@ function autofocus(node) {
|
|||||||
//#endregion
|
//#endregion
|
||||||
//#region src/components/LocationSidebar.svelte
|
//#region src/components/LocationSidebar.svelte
|
||||||
init_db();
|
init_db();
|
||||||
var root$10 = /* @__PURE__ */ from_html(`<span class="search-spinner svelte-1j1qnn9">⏳</span>`);
|
var root$11 = /* @__PURE__ */ from_html(`<span class="search-spinner svelte-1j1qnn9">⏳</span>`);
|
||||||
var root_1$9 = /* @__PURE__ */ from_html(`<button class="geocoding-item svelte-1j1qnn9"><span class="geo-name svelte-1j1qnn9"> </span> <span class="geo-detail svelte-1j1qnn9"> </span></button>`);
|
var root_1$10 = /* @__PURE__ */ from_html(`<button class="geocoding-item svelte-1j1qnn9"><span class="geo-name svelte-1j1qnn9"> </span> <span class="geo-detail svelte-1j1qnn9"> </span></button>`);
|
||||||
var root_2$9 = /* @__PURE__ */ from_html(`<div class="geocoding-dropdown svelte-1j1qnn9"></div>`);
|
var root_2$10 = /* @__PURE__ */ from_html(`<div class="geocoding-dropdown svelte-1j1qnn9"></div>`);
|
||||||
var root_3$7 = /* @__PURE__ */ from_html(`<div class="location-row current-row svelte-1j1qnn9"><button><span class="loc-icon svelte-1j1qnn9">📍</span> <span class="loc-name truncate svelte-1j1qnn9"> </span></button></div>`);
|
var root_3$8 = /* @__PURE__ */ from_html(`<div class="location-row current-row svelte-1j1qnn9"><button><span class="loc-icon svelte-1j1qnn9">📍</span> <span class="loc-name truncate svelte-1j1qnn9"> </span></button></div>`);
|
||||||
var root_4$5 = /* @__PURE__ */ from_html(`<div class="location-row svelte-1j1qnn9"><button><span class="loc-icon svelte-1j1qnn9">🏙️</span> <span class="loc-name truncate svelte-1j1qnn9"> </span></button> <div class="loc-actions svelte-1j1qnn9"><button class="loc-action-btn svelte-1j1qnn9" title="Set as current location">📍</button> <button class="loc-action-btn svelte-1j1qnn9" title="Remove location">✕</button></div></div>`);
|
var root_4$5 = /* @__PURE__ */ from_html(`<div class="location-row svelte-1j1qnn9"><button><span class="loc-icon svelte-1j1qnn9">🏙️</span> <span class="loc-name truncate svelte-1j1qnn9"> </span></button> <div class="loc-actions svelte-1j1qnn9"><button class="loc-action-btn svelte-1j1qnn9" title="Set as current location">📍</button> <button class="loc-action-btn svelte-1j1qnn9" title="Remove location">✕</button></div></div>`);
|
||||||
var root_5$5 = /* @__PURE__ */ from_html(`<div class="empty-locations svelte-1j1qnn9"><p class="text-muted text-sm">No saved locations.</p> <p class="text-muted text-xs mt-1">Enable location access or add one below.</p></div>`);
|
var root_5$5 = /* @__PURE__ */ from_html(`<div class="empty-locations svelte-1j1qnn9"><p class="text-muted text-sm">No saved locations.</p> <p class="text-muted text-xs mt-1">Enable location access or add one below.</p></div>`);
|
||||||
var root_6$3 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-1j1qnn9"><div class="sidebar-header svelte-1j1qnn9"><h2 class="svelte-1j1qnn9">🌤️ WeatherLens</h2></div> <div class="search-box svelte-1j1qnn9"><input type="search" placeholder="Search locations..." autocomplete="off" class="svelte-1j1qnn9"/> <!> <!></div> <nav class="locations-nav svelte-1j1qnn9"><!> <!> <!></nav> <div class="sidebar-footer svelte-1j1qnn9"><button class="btn btn-ghost btn-sm w-full">⚙️ Settings</button> <button class="btn btn-primary btn-sm w-full mt-2">+ Add Location</button></div></div>`);
|
var root_6$3 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-1j1qnn9"><div class="sidebar-header svelte-1j1qnn9"><h2 class="svelte-1j1qnn9">🌤️ WeatherLens</h2></div> <div class="search-box svelte-1j1qnn9"><input type="search" placeholder="Search locations..." autocomplete="off" class="svelte-1j1qnn9"/> <!> <!></div> <nav class="locations-nav svelte-1j1qnn9"><!> <!> <!></nav> <div class="sidebar-footer svelte-1j1qnn9"><button class="btn btn-ghost btn-sm w-full">⚙️ Settings</button> <button class="btn btn-primary btn-sm w-full mt-2">+ Add Location</button></div></div>`);
|
||||||
@ -6348,16 +6428,16 @@ function LocationSidebar($$anchor, $$props) {
|
|||||||
remove_input_defaults(input);
|
remove_input_defaults(input);
|
||||||
var node = sibling(input, 2);
|
var node = sibling(input, 2);
|
||||||
var consequent = ($$anchor) => {
|
var consequent = ($$anchor) => {
|
||||||
append($$anchor, root$10());
|
append($$anchor, root$11());
|
||||||
};
|
};
|
||||||
if_block(node, ($$render) => {
|
if_block(node, ($$render) => {
|
||||||
if (app$1.geocodingLoading) $$render(consequent);
|
if (app$1.geocodingLoading) $$render(consequent);
|
||||||
});
|
});
|
||||||
var node_1 = sibling(node, 2);
|
var node_1 = sibling(node, 2);
|
||||||
var consequent_1 = ($$anchor) => {
|
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) => {
|
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 span_1 = child(button);
|
||||||
var text = child(span_1, true);
|
var text = child(span_1, true);
|
||||||
reset(span_1);
|
reset(span_1);
|
||||||
@ -6385,7 +6465,7 @@ function LocationSidebar($$anchor, $$props) {
|
|||||||
var fragment = comment();
|
var fragment = comment();
|
||||||
var node_3 = first_child(fragment);
|
var node_3 = first_child(fragment);
|
||||||
var consequent_2 = ($$anchor) => {
|
var consequent_2 = ($$anchor) => {
|
||||||
var div_3 = root_3$7();
|
var div_3 = root_3$8();
|
||||||
var button_1 = child(div_3);
|
var button_1 = child(div_3);
|
||||||
let classes;
|
let classes;
|
||||||
var span_3 = sibling(child(button_1), 2);
|
var span_3 = sibling(child(button_1), 2);
|
||||||
@ -6459,12 +6539,12 @@ function LocationSidebar($$anchor, $$props) {
|
|||||||
delegate(["input", "click"]);
|
delegate(["input", "click"]);
|
||||||
//#endregion
|
//#endregion
|
||||||
//#region src/components/SourceTooltip.svelte
|
//#region src/components/SourceTooltip.svelte
|
||||||
var root$9 = /* @__PURE__ */ from_html(`<span class="srctip svelte-1hhb1ss" tabindex="0" role="note"><span class="srctip-icon svelte-1hhb1ss" aria-hidden="true">ⓘ</span> <span class="srctip-bubble svelte-1hhb1ss"> </span></span>`);
|
var root$10 = /* @__PURE__ */ from_html(`<span class="srctip svelte-1hhb1ss" tabindex="0" role="note"><span class="srctip-icon svelte-1hhb1ss" aria-hidden="true">ⓘ</span> <span class="srctip-bubble svelte-1hhb1ss"> </span></span>`);
|
||||||
function SourceTooltip($$anchor, $$props) {
|
function SourceTooltip($$anchor, $$props) {
|
||||||
/** Small info badge whose tooltip explains the value came from the
|
/** Small info badge whose tooltip explains the value came from the
|
||||||
* non-selected source (e.g. Open-Meteo while NWS is selected). */
|
* non-selected source (e.g. Open-Meteo while NWS is selected). */
|
||||||
let label = prop($$props, "label", 3, "");
|
let label = prop($$props, "label", 3, "");
|
||||||
var span = root$9();
|
var span = root$10();
|
||||||
var span_1 = sibling(child(span), 2);
|
var span_1 = sibling(child(span), 2);
|
||||||
var text = child(span_1, true);
|
var text = child(span_1, true);
|
||||||
reset(span_1);
|
reset(span_1);
|
||||||
@ -6477,10 +6557,10 @@ function SourceTooltip($$anchor, $$props) {
|
|||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
//#region src/components/CurrentWeather.svelte
|
//#region src/components/CurrentWeather.svelte
|
||||||
var root$8 = /* @__PURE__ */ from_html(`<span class="feels-like svelte-1k8dsh"> </span>`);
|
var root$9 = /* @__PURE__ */ from_html(`<span class="feels-like svelte-1k8dsh"> </span>`);
|
||||||
var root_1$8 = /* @__PURE__ */ from_html(`<div class="hero-range svelte-1k8dsh"><span class="range-high svelte-1k8dsh"> </span> <span class="range-low svelte-1k8dsh"> </span></div>`);
|
var root_1$9 = /* @__PURE__ */ from_html(`<div class="hero-range svelte-1k8dsh"><span class="range-high svelte-1k8dsh"> </span> <span class="range-low svelte-1k8dsh"> </span></div>`);
|
||||||
var root_2$8 = /* @__PURE__ */ from_html(`<span class="metric-sub svelte-1k8dsh"> </span>`);
|
var root_2$9 = /* @__PURE__ */ from_html(`<span class="metric-sub svelte-1k8dsh"> </span>`);
|
||||||
var root_3$6 = /* @__PURE__ */ from_html(`<div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">🌪️</span> <span class="metric-label svelte-1k8dsh">Gusts</span> <span class="metric-value svelte-1k8dsh"> </span></div>`);
|
var root_3$7 = /* @__PURE__ */ from_html(`<div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">🌪️</span> <span class="metric-label svelte-1k8dsh">Gusts</span> <span class="metric-value svelte-1k8dsh"> </span></div>`);
|
||||||
var root_4$4 = /* @__PURE__ */ from_html(`<div class="sun-times svelte-1k8dsh"><div class="sun-time svelte-1k8dsh"><span class="sun-icon svelte-1k8dsh">🌅</span> <span> </span></div> <div class="sun-divider svelte-1k8dsh"></div> <div class="sun-time svelte-1k8dsh"><span class="sun-icon svelte-1k8dsh">🌇</span> <span> </span></div></div>`);
|
var root_4$4 = /* @__PURE__ */ from_html(`<div class="sun-times svelte-1k8dsh"><div class="sun-time svelte-1k8dsh"><span class="sun-icon svelte-1k8dsh">🌅</span> <span> </span></div> <div class="sun-divider svelte-1k8dsh"></div> <div class="sun-time svelte-1k8dsh"><span class="sun-icon svelte-1k8dsh">🌇</span> <span> </span></div></div>`);
|
||||||
var root_5$4 = /* @__PURE__ */ from_html(`<div><div class="hero-main svelte-1k8dsh"><div class="hero-temp svelte-1k8dsh"><span class="temp-value svelte-1k8dsh"> </span> <span class="temp-unit svelte-1k8dsh"> </span></div> <div class="hero-icon svelte-1k8dsh"> </div></div> <div class="hero-desc svelte-1k8dsh"> <!></div> <!> <div class="metrics-grid svelte-1k8dsh"><div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">💧</span> <span class="metric-label svelte-1k8dsh">Humidity</span> <span class="metric-value svelte-1k8dsh"> </span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">💨</span> <span class="metric-label svelte-1k8dsh">Wind</span> <span class="metric-value svelte-1k8dsh"> </span> <span class="metric-sub svelte-1k8dsh"> </span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">🌡️</span> <span class="metric-label svelte-1k8dsh">Pressure</span> <span class="metric-value svelte-1k8dsh"> <!></span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">☀️</span> <span class="metric-label svelte-1k8dsh">UV Index</span> <span class="metric-value svelte-1k8dsh"> <!></span> <!></div> <!></div> <!></div>`);
|
var root_5$4 = /* @__PURE__ */ from_html(`<div><div class="hero-main svelte-1k8dsh"><div class="hero-temp svelte-1k8dsh"><span class="temp-value svelte-1k8dsh"> </span> <span class="temp-unit svelte-1k8dsh"> </span></div> <div class="hero-icon svelte-1k8dsh"> </div></div> <div class="hero-desc svelte-1k8dsh"> <!></div> <!> <div class="metrics-grid svelte-1k8dsh"><div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">💧</span> <span class="metric-label svelte-1k8dsh">Humidity</span> <span class="metric-value svelte-1k8dsh"> </span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">💨</span> <span class="metric-label svelte-1k8dsh">Wind</span> <span class="metric-value svelte-1k8dsh"> </span> <span class="metric-sub svelte-1k8dsh"> </span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">🌡️</span> <span class="metric-label svelte-1k8dsh">Pressure</span> <span class="metric-value svelte-1k8dsh"> <!></span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">☀️</span> <span class="metric-label svelte-1k8dsh">UV Index</span> <span class="metric-value svelte-1k8dsh"> <!></span> <!></div> <!></div> <!></div>`);
|
||||||
function CurrentWeather($$anchor, $$props) {
|
function CurrentWeather($$anchor, $$props) {
|
||||||
@ -6546,7 +6626,7 @@ function CurrentWeather($$anchor, $$props) {
|
|||||||
var text_3 = child(div_4);
|
var text_3 = child(div_4);
|
||||||
var node_1 = sibling(text_3);
|
var node_1 = sibling(text_3);
|
||||||
var consequent = ($$anchor) => {
|
var consequent = ($$anchor) => {
|
||||||
var span_2 = root$8();
|
var span_2 = root$9();
|
||||||
var text_4 = child(span_2);
|
var text_4 = child(span_2);
|
||||||
reset(span_2);
|
reset(span_2);
|
||||||
template_effect(($0) => set_text(text_4, `· Feels like ${$0 ?? ""}${get(unit) ?? ""}`), [() => Math.round(get(current).apparent_temperature)]);
|
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);
|
reset(div_4);
|
||||||
var node_2 = sibling(div_4, 2);
|
var node_2 = sibling(div_4, 2);
|
||||||
var consequent_1 = ($$anchor) => {
|
var consequent_1 = ($$anchor) => {
|
||||||
var div_5 = root_1$8();
|
var div_5 = root_1$9();
|
||||||
var span_3 = child(div_5);
|
var span_3 = child(div_5);
|
||||||
var text_5 = child(span_3);
|
var text_5 = child(span_3);
|
||||||
reset(span_3);
|
reset(span_3);
|
||||||
@ -6615,7 +6695,7 @@ function CurrentWeather($$anchor, $$props) {
|
|||||||
reset(span_9);
|
reset(span_9);
|
||||||
var node_5 = sibling(span_9, 2);
|
var node_5 = sibling(span_9, 2);
|
||||||
var consequent_4 = ($$anchor) => {
|
var consequent_4 = ($$anchor) => {
|
||||||
var span_10 = root_2$8();
|
var span_10 = root_2$9();
|
||||||
var text_12 = child(span_10, true);
|
var text_12 = child(span_10, true);
|
||||||
reset(span_10);
|
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"));
|
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);
|
reset(div_10);
|
||||||
var node_6 = sibling(div_10, 2);
|
var node_6 = sibling(div_10, 2);
|
||||||
var consequent_5 = ($$anchor) => {
|
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 span_11 = sibling(child(div_11), 4);
|
||||||
var text_13 = child(span_11);
|
var text_13 = child(span_11);
|
||||||
reset(span_11);
|
reset(span_11);
|
||||||
@ -6691,10 +6771,10 @@ function CurrentWeather($$anchor, $$props) {
|
|||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
//#region src/components/HourlyForecast.svelte
|
//#region src/components/HourlyForecast.svelte
|
||||||
var root$7 = /* @__PURE__ */ from_html(`<div class="precip-bar-container svelte-1q03bs0"><div class="precip-bar svelte-1q03bs0"></div></div> <span class="precip-value svelte-1q03bs0"> </span>`, 1);
|
var root$8 = /* @__PURE__ */ from_html(`<div class="precip-bar-container svelte-1q03bs0"><div class="precip-bar svelte-1q03bs0"></div></div> <span class="precip-value svelte-1q03bs0"> </span>`, 1);
|
||||||
var root_1$7 = /* @__PURE__ */ from_html(`<div class="precip-bar-container svelte-1q03bs0"><div class="precip-bar svelte-1q03bs0" style="--precip: 0%"></div></div> <span class="precip-value svelte-1q03bs0">--</span>`, 1);
|
var root_1$8 = /* @__PURE__ */ from_html(`<div class="precip-bar-container svelte-1q03bs0"><div class="precip-bar svelte-1q03bs0" style="--precip: 0%"></div></div> <span class="precip-value svelte-1q03bs0">--</span>`, 1);
|
||||||
var root_2$7 = /* @__PURE__ */ from_html(`<div><span class="hour-time svelte-1q03bs0"> </span> <span class="hour-icon svelte-1q03bs0"> </span> <span class="hour-temp svelte-1q03bs0"> </span> <!> <span class="hour-wind svelte-1q03bs0" title="Wind speed"> </span></div>`);
|
var root_2$8 = /* @__PURE__ */ from_html(`<div><span class="hour-time svelte-1q03bs0"> </span> <span class="hour-icon svelte-1q03bs0"> </span> <span class="hour-temp svelte-1q03bs0"> </span> <!> <span class="hour-wind svelte-1q03bs0" title="Wind speed"> </span></div>`);
|
||||||
var root_3$5 = /* @__PURE__ */ from_html(`<div class="hourly-section svelte-1q03bs0"><h3 class="section-title svelte-1q03bs0">Hourly Forecast</h3> <div class="hourly-scroll svelte-1q03bs0"></div></div>`);
|
var root_3$6 = /* @__PURE__ */ from_html(`<div class="hourly-section svelte-1q03bs0"><h3 class="section-title svelte-1q03bs0">Hourly Forecast</h3> <div class="hourly-scroll svelte-1q03bs0"></div></div>`);
|
||||||
function HourlyForecast($$anchor, $$props) {
|
function HourlyForecast($$anchor, $$props) {
|
||||||
push($$props, true);
|
push($$props, true);
|
||||||
const data = /* @__PURE__ */ user_derived(() => app$1.forecastData);
|
const data = /* @__PURE__ */ user_derived(() => app$1.forecastData);
|
||||||
@ -6729,11 +6809,11 @@ function HourlyForecast($$anchor, $$props) {
|
|||||||
var fragment = comment();
|
var fragment = comment();
|
||||||
var node = first_child(fragment);
|
var node = first_child(fragment);
|
||||||
var consequent_1 = ($$anchor) => {
|
var consequent_1 = ($$anchor) => {
|
||||||
var div = root_3$5();
|
var div = root_3$6();
|
||||||
var div_1 = sibling(child(div), 2);
|
var div_1 = sibling(child(div), 2);
|
||||||
each(div_1, 21, () => get(next24Hours), index, ($$anchor, hour, i) => {
|
each(div_1, 21, () => get(next24Hours), index, ($$anchor, hour, i) => {
|
||||||
const info = /* @__PURE__ */ user_derived(() => getWeatherInfo(get(hour).code));
|
const info = /* @__PURE__ */ user_derived(() => getWeatherInfo(get(hour).code));
|
||||||
var div_2 = root_2$7();
|
var div_2 = root_2$8();
|
||||||
let classes;
|
let classes;
|
||||||
var span = child(div_2);
|
var span = child(div_2);
|
||||||
var text = child(span, true);
|
var text = child(span, true);
|
||||||
@ -6746,7 +6826,7 @@ function HourlyForecast($$anchor, $$props) {
|
|||||||
reset(span_2);
|
reset(span_2);
|
||||||
var node_1 = sibling(span_2, 2);
|
var node_1 = sibling(span_2, 2);
|
||||||
var consequent = ($$anchor) => {
|
var consequent = ($$anchor) => {
|
||||||
var fragment_1 = root$7();
|
var fragment_1 = root$8();
|
||||||
var div_3 = first_child(fragment_1);
|
var div_3 = first_child(fragment_1);
|
||||||
var div_4 = child(div_3);
|
var div_4 = child(div_3);
|
||||||
reset(div_3);
|
reset(div_3);
|
||||||
@ -6760,7 +6840,7 @@ function HourlyForecast($$anchor, $$props) {
|
|||||||
append($$anchor, fragment_1);
|
append($$anchor, fragment_1);
|
||||||
};
|
};
|
||||||
var alternate = ($$anchor) => {
|
var alternate = ($$anchor) => {
|
||||||
var fragment_2 = root_1$7();
|
var fragment_2 = root_1$8();
|
||||||
next(2);
|
next(2);
|
||||||
append($$anchor, fragment_2);
|
append($$anchor, fragment_2);
|
||||||
};
|
};
|
||||||
@ -6798,10 +6878,10 @@ function HourlyForecast($$anchor, $$props) {
|
|||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
//#region src/components/DailyForecast.svelte
|
//#region src/components/DailyForecast.svelte
|
||||||
var root$6 = /* @__PURE__ */ from_html(`<div class="precip-row svelte-ss3yj8"><span class="precip-icon svelte-ss3yj8">💧</span> <div class="precip-bar-wrap svelte-ss3yj8"><div class="precip-fill svelte-ss3yj8"></div></div> <span class="precip-label svelte-ss3yj8"> </span></div>`);
|
var root$7 = /* @__PURE__ */ from_html(`<div class="precip-row svelte-ss3yj8"><span class="precip-icon svelte-ss3yj8">💧</span> <div class="precip-bar-wrap svelte-ss3yj8"><div class="precip-fill svelte-ss3yj8"></div></div> <span class="precip-label svelte-ss3yj8"> </span></div>`);
|
||||||
var root_1$6 = /* @__PURE__ */ from_html(`<span class="wind-info svelte-ss3yj8"> </span>`);
|
var root_1$7 = /* @__PURE__ */ from_html(`<span class="wind-info svelte-ss3yj8"> </span>`);
|
||||||
var root_2$6 = /* @__PURE__ */ from_html(`<div><div class="day-left svelte-ss3yj8"><span class="day-name svelte-ss3yj8"> </span> <span class="day-date text-xs text-muted svelte-ss3yj8"> </span></div> <div class="day-center svelte-ss3yj8"><span class="day-icon svelte-ss3yj8"> </span> <span class="day-desc svelte-ss3yj8"> </span></div> <div class="day-right svelte-ss3yj8"><div class="day-temps svelte-ss3yj8"><span class="temp-high svelte-ss3yj8"> </span> <span class="temp-low svelte-ss3yj8"> </span></div> <!> <!></div></div>`);
|
var root_2$7 = /* @__PURE__ */ from_html(`<div><div class="day-left svelte-ss3yj8"><span class="day-name svelte-ss3yj8"> </span> <span class="day-date text-xs text-muted svelte-ss3yj8"> </span></div> <div class="day-center svelte-ss3yj8"><span class="day-icon svelte-ss3yj8"> </span> <span class="day-desc svelte-ss3yj8"> </span></div> <div class="day-right svelte-ss3yj8"><div class="day-temps svelte-ss3yj8"><span class="temp-high svelte-ss3yj8"> </span> <span class="temp-low svelte-ss3yj8"> </span></div> <!> <!></div></div>`);
|
||||||
var root_3$4 = /* @__PURE__ */ from_html(`<div class="daily-section svelte-ss3yj8"><h3 class="section-title svelte-ss3yj8">7-Day Forecast</h3> <div class="daily-list svelte-ss3yj8"></div></div>`);
|
var root_3$5 = /* @__PURE__ */ from_html(`<div class="daily-section svelte-ss3yj8"><h3 class="section-title svelte-ss3yj8">7-Day Forecast</h3> <div class="daily-list svelte-ss3yj8"></div></div>`);
|
||||||
function DailyForecast($$anchor, $$props) {
|
function DailyForecast($$anchor, $$props) {
|
||||||
push($$props, true);
|
push($$props, true);
|
||||||
const data = /* @__PURE__ */ user_derived(() => app$1.forecastData);
|
const data = /* @__PURE__ */ user_derived(() => app$1.forecastData);
|
||||||
@ -6825,7 +6905,7 @@ function DailyForecast($$anchor, $$props) {
|
|||||||
var fragment = comment();
|
var fragment = comment();
|
||||||
var node = first_child(fragment);
|
var node = first_child(fragment);
|
||||||
var consequent_3 = ($$anchor) => {
|
var consequent_3 = ($$anchor) => {
|
||||||
var div = root_3$4();
|
var div = root_3$5();
|
||||||
var div_1 = sibling(child(div), 2);
|
var div_1 = sibling(child(div), 2);
|
||||||
each(div_1, 21, () => get(daily).time, index, ($$anchor, day, i) => {
|
each(div_1, 21, () => get(daily).time, index, ($$anchor, day, i) => {
|
||||||
var fragment_1 = comment();
|
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 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 tempMin = /* @__PURE__ */ user_derived(() => Math.round(get(daily).temperature_2m_min[i]));
|
||||||
const wind = /* @__PURE__ */ user_derived(() => get(daily).wind_speed_10m_max?.[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 });
|
set_class(div_2, 1, "day-card card-glass svelte-ss3yj8", null, {}, { today: i === 0 });
|
||||||
var div_3 = child(div_2);
|
var div_3 = child(div_2);
|
||||||
var span = child(div_3);
|
var span = child(div_3);
|
||||||
@ -6865,7 +6945,7 @@ function DailyForecast($$anchor, $$props) {
|
|||||||
reset(div_6);
|
reset(div_6);
|
||||||
var node_2 = sibling(div_6, 2);
|
var node_2 = sibling(div_6, 2);
|
||||||
var consequent = ($$anchor) => {
|
var consequent = ($$anchor) => {
|
||||||
var div_7 = root$6();
|
var div_7 = root$7();
|
||||||
var div_8 = sibling(child(div_7), 2);
|
var div_8 = sibling(child(div_7), 2);
|
||||||
var div_9 = child(div_8);
|
var div_9 = child(div_8);
|
||||||
reset(div_8);
|
reset(div_8);
|
||||||
@ -6884,7 +6964,7 @@ function DailyForecast($$anchor, $$props) {
|
|||||||
});
|
});
|
||||||
var node_3 = sibling(node_2, 2);
|
var node_3 = sibling(node_2, 2);
|
||||||
var consequent_1 = ($$anchor) => {
|
var consequent_1 = ($$anchor) => {
|
||||||
var span_7 = root_1$6();
|
var span_7 = root_1$7();
|
||||||
var text_7 = child(span_7);
|
var text_7 = child(span_7);
|
||||||
reset(span_7);
|
reset(span_7);
|
||||||
template_effect(($0) => set_text(text_7, `💨 ${$0 ?? ""} ${get(windUnit) ?? ""}`), [() => Math.round(get(wind))]);
|
template_effect(($0) => set_text(text_7, `💨 ${$0 ?? ""} ${get(windUnit) ?? ""}`), [() => Math.round(get(wind))]);
|
||||||
@ -6921,10 +7001,275 @@ function DailyForecast($$anchor, $$props) {
|
|||||||
pop();
|
pop();
|
||||||
}
|
}
|
||||||
//#endregion
|
//#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<Array<{ date: string, phase: number }>>} 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(`<div class="moon-card card svelte-d2g8r6" aria-busy="true"><div class="detail-header svelte-d2g8r6"><span class="detail-icon svelte-d2g8r6">🌙</span><span class="detail-label svelte-d2g8r6">Moon</span></div> <div class="moon-loading svelte-d2g8r6">Loading moon phase…</div></div>`);
|
||||||
|
var root_1$6 = /* @__PURE__ */ from_html(`<div class="moon-next svelte-d2g8r6"><span class="detail-icon svelte-d2g8r6">🕓</span> <div class="moon-next-text svelte-d2g8r6"><span class="moon-next-line svelte-d2g8r6"><strong> </strong> <!></span> <span class="moon-next-date svelte-d2g8r6"> </span></div></div>`);
|
||||||
|
var root_2$6 = /* @__PURE__ */ from_html(`<div class="moon-card card svelte-d2g8r6"><div class="detail-header svelte-d2g8r6"><span class="detail-icon svelte-d2g8r6">🌙</span><span class="detail-label svelte-d2g8r6">Moon</span></div> <div class="moon-body svelte-d2g8r6"><div class="moon-visual svelte-d2g8r6"></div> <div class="moon-info svelte-d2g8r6"><div class="moon-title svelte-d2g8r6"> </div> <div class="moon-sub svelte-d2g8r6"> </div></div></div> <!></div>`);
|
||||||
|
var root_3$4 = /* @__PURE__ */ from_html(`<div class="moon-card card svelte-d2g8r6"><div class="detail-header svelte-d2g8r6"><span class="detail-icon svelte-d2g8r6">🌙</span><span class="detail-label svelte-d2g8r6">Moon</span></div> <div class="moon-body svelte-d2g8r6"><span class="moon-sub svelte-d2g8r6">Moon data unavailable</span></div></div>`);
|
||||||
|
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 `<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 "--";
|
||||||
|
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
|
//#region src/components/WeatherDetail.svelte
|
||||||
var root$5 = /* @__PURE__ */ from_html(`<span> </span>`);
|
var root$5 = /* @__PURE__ */ from_html(`<span> </span>`);
|
||||||
var root_1$5 = /* @__PURE__ */ from_html(`<div class="detail-row svelte-8lxt71"><span>24h Average</span> <span class="detail-value svelte-8lxt71"> </span></div>`);
|
var root_1$5 = /* @__PURE__ */ from_html(`<div class="detail-row svelte-8lxt71"><span>24h Average</span> <span class="detail-value svelte-8lxt71"> </span></div>`);
|
||||||
var root_2$5 = /* @__PURE__ */ from_html(`<div class="detail-section svelte-8lxt71"><h3 class="section-title svelte-8lxt71">Weather Details</h3> <div class="detail-grid svelte-8lxt71"><div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">💨</span> <span class="detail-label svelte-8lxt71">Wind</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Speed</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Direction</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Gusts</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Condition</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">🌡️</span> <span class="detail-label svelte-8lxt71">Atmosphere</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Humidity</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Pressure</span> <span class="detail-value svelte-8lxt71"> <!></span></div> <div class="detail-row svelte-8lxt71"><span>Feels Like</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Is Day</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">☀️</span> <span class="detail-label svelte-8lxt71">Sun & UV</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>UV Index</span> <span class="detail-value svelte-8lxt71"> <!> <!></span></div> <div class="detail-row svelte-8lxt71"><span>Sunrise</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Sunset</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Max UV Today</span> <span class="detail-value svelte-8lxt71"> <!></span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">📊</span> <span class="detail-label svelte-8lxt71">Temperature Range</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Today's High</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Today's Low</span> <span class="detail-value svelte-8lxt71"> </span></div> <!> <div class="detail-row svelte-8lxt71"><span>Precip Chance</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div></div> <div class="time-info card mt-3 svelte-8lxt71"><div class="time-row svelte-8lxt71"><span>Data source</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Local time</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Data updated</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Elevation</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div>`);
|
var root_2$5 = /* @__PURE__ */ from_html(`<div class="detail-section svelte-8lxt71"><h3 class="section-title svelte-8lxt71">Weather Details</h3> <div class="detail-grid svelte-8lxt71"><div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">💨</span> <span class="detail-label svelte-8lxt71">Wind</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Speed</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Direction</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Gusts</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Condition</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">🌡️</span> <span class="detail-label svelte-8lxt71">Atmosphere</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Humidity</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Pressure</span> <span class="detail-value svelte-8lxt71"> <!></span></div> <div class="detail-row svelte-8lxt71"><span>Feels Like</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Is Day</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">☀️</span> <span class="detail-label svelte-8lxt71">Sun & UV</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>UV Index</span> <span class="detail-value svelte-8lxt71"> <!> <!></span></div> <div class="detail-row svelte-8lxt71"><span>Sunrise</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Sunset</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Max UV Today</span> <span class="detail-value svelte-8lxt71"> <!></span></div></div></div> <div class="detail-card card svelte-8lxt71"><!></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">📊</span> <span class="detail-label svelte-8lxt71">Temperature Range</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Today's High</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Today's Low</span> <span class="detail-value svelte-8lxt71"> </span></div> <!> <div class="detail-row svelte-8lxt71"><span>Precip Chance</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div></div> <div class="time-info card mt-3 svelte-8lxt71"><div class="time-row svelte-8lxt71"><span>Data source</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Local time</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Data updated</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Elevation</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div>`);
|
||||||
function WeatherDetail($$anchor, $$props) {
|
function WeatherDetail($$anchor, $$props) {
|
||||||
push($$props, true);
|
push($$props, true);
|
||||||
const data = /* @__PURE__ */ user_derived(() => app$1.forecastData);
|
const data = /* @__PURE__ */ user_derived(() => app$1.forecastData);
|
||||||
@ -7097,70 +7442,73 @@ function WeatherDetail($$anchor, $$props) {
|
|||||||
reset(div_15);
|
reset(div_15);
|
||||||
reset(div_14);
|
reset(div_14);
|
||||||
var div_20 = sibling(div_14, 2);
|
var div_20 = sibling(div_14, 2);
|
||||||
var div_21 = sibling(child(div_20), 2);
|
MoonPhaseCard(child(div_20), {});
|
||||||
var div_22 = child(div_21);
|
reset(div_20);
|
||||||
var span_13 = sibling(child(div_22), 2);
|
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);
|
var text_13 = child(span_13, true);
|
||||||
reset(span_13);
|
reset(span_13);
|
||||||
reset(div_22);
|
reset(div_23);
|
||||||
var div_23 = sibling(div_22, 2);
|
var div_24 = sibling(div_23, 2);
|
||||||
var span_14 = sibling(child(div_23), 2);
|
var span_14 = sibling(child(div_24), 2);
|
||||||
var text_14 = child(span_14, true);
|
var text_14 = child(span_14, true);
|
||||||
reset(span_14);
|
reset(span_14);
|
||||||
reset(div_23);
|
reset(div_24);
|
||||||
var node_5 = sibling(div_23, 2);
|
var node_6 = sibling(div_24, 2);
|
||||||
var consequent_5 = ($$anchor) => {
|
var consequent_5 = ($$anchor) => {
|
||||||
const temps = /* @__PURE__ */ user_derived(() => get(hourly).temperature_2m || []);
|
const temps = /* @__PURE__ */ user_derived(() => get(hourly).temperature_2m || []);
|
||||||
const validTemps = /* @__PURE__ */ user_derived(() => get(temps).filter((t) => t != null));
|
const validTemps = /* @__PURE__ */ user_derived(() => get(temps).filter((t) => t != null));
|
||||||
var fragment_4 = comment();
|
var fragment_4 = comment();
|
||||||
var node_6 = first_child(fragment_4);
|
var node_7 = first_child(fragment_4);
|
||||||
var consequent_4 = ($$anchor) => {
|
var consequent_4 = ($$anchor) => {
|
||||||
var div_24 = root_1$5();
|
var div_25 = root_1$5();
|
||||||
var span_15 = sibling(child(div_24), 2);
|
var span_15 = sibling(child(div_25), 2);
|
||||||
var text_15 = child(span_15);
|
var text_15 = child(span_15);
|
||||||
reset(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)]);
|
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);
|
if (get(validTemps).length > 0) $$render(consequent_4);
|
||||||
});
|
});
|
||||||
append($$anchor, fragment_4);
|
append($$anchor, fragment_4);
|
||||||
};
|
};
|
||||||
if_block(node_5, ($$render) => {
|
if_block(node_6, ($$render) => {
|
||||||
if (get(hourly)) $$render(consequent_5);
|
if (get(hourly)) $$render(consequent_5);
|
||||||
});
|
});
|
||||||
var div_25 = sibling(node_5, 2);
|
var div_26 = sibling(node_6, 2);
|
||||||
var span_16 = sibling(child(div_25), 2);
|
var span_16 = sibling(child(div_26), 2);
|
||||||
var text_16 = child(span_16);
|
var text_16 = child(span_16);
|
||||||
reset(span_16);
|
reset(span_16);
|
||||||
reset(div_25);
|
reset(div_26);
|
||||||
|
reset(div_22);
|
||||||
reset(div_21);
|
reset(div_21);
|
||||||
reset(div_20);
|
|
||||||
reset(div_1);
|
reset(div_1);
|
||||||
var div_26 = sibling(div_1, 2);
|
var div_27 = sibling(div_1, 2);
|
||||||
var div_27 = child(div_26);
|
var div_28 = child(div_27);
|
||||||
var span_17 = sibling(child(div_27), 2);
|
var span_17 = sibling(child(div_28), 2);
|
||||||
var text_17 = child(span_17, true);
|
var text_17 = child(span_17, true);
|
||||||
reset(span_17);
|
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);
|
reset(div_28);
|
||||||
var div_29 = sibling(div_28, 2);
|
var div_29 = sibling(div_28, 2);
|
||||||
var span_19 = sibling(child(div_29), 2);
|
var span_18 = sibling(child(div_29), 2);
|
||||||
var text_19 = child(span_19, true);
|
var text_18 = child(span_18, true);
|
||||||
reset(span_19);
|
reset(span_18);
|
||||||
reset(div_29);
|
reset(div_29);
|
||||||
var div_30 = sibling(div_29, 2);
|
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);
|
var text_20 = child(span_20, true);
|
||||||
reset(span_20);
|
reset(span_20);
|
||||||
reset(div_30);
|
reset(div_31);
|
||||||
reset(div_26);
|
reset(div_27);
|
||||||
reset(div);
|
reset(div);
|
||||||
template_effect(($0, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) => {
|
template_effect(($0, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) => {
|
||||||
set_text(text, `${$0 ?? ""} ${get(windUnit) ?? ""}`);
|
set_text(text, `${$0 ?? ""} ${get(windUnit) ?? ""}`);
|
||||||
@ -8792,7 +9140,7 @@ delegate([
|
|||||||
]);
|
]);
|
||||||
//#endregion
|
//#endregion
|
||||||
//#region src/main.js
|
//#region src/main.js
|
||||||
console.info({ commit_hash: "897f49ae2209cb2213ea97a4a082bb4bdfa2889a" });
|
console.info({ commit_hash: "2eb34a709b0f3e5322756e91e014abdaf754a526" });
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
mount(App, { target: document.getElementById("app") });
|
mount(App, { target: document.getElementById("app") });
|
||||||
//#endregion</script>
|
//#endregion</script>
|
||||||
@ -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 {
|
.detail-section.svelte-8lxt71 {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
191
src/components/MoonPhaseCard.svelte
Normal file
191
src/components/MoonPhaseCard.svelte
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
<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>
|
||||||
@ -2,6 +2,7 @@
|
|||||||
import { app } from '../lib/stores/app.svelte.js'
|
import { app } from '../lib/stores/app.svelte.js'
|
||||||
import { getWeatherInfo } from '../lib/api/weather-codes.js'
|
import { getWeatherInfo } from '../lib/api/weather-codes.js'
|
||||||
import SourceTooltip from './SourceTooltip.svelte'
|
import SourceTooltip from './SourceTooltip.svelte'
|
||||||
|
import MoonPhaseCard from './MoonPhaseCard.svelte'
|
||||||
|
|
||||||
const data = $derived(app.forecastData)
|
const data = $derived(app.forecastData)
|
||||||
const current = $derived(data?.current)
|
const current = $derived(data?.current)
|
||||||
@ -143,6 +144,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Moon phase -->
|
||||||
|
<div class="detail-card card">
|
||||||
|
<MoonPhaseCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Temperature range -->
|
<!-- Temperature range -->
|
||||||
<div class="detail-card card">
|
<div class="detail-card card">
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
|
|||||||
141
src/lib/api/moon.js
Normal file
141
src/lib/api/moon.js
Normal file
@ -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<Array<{ date: string, phase: number }>>} 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)
|
||||||
|
}
|
||||||
84
tests/lib/api/moon.test.js
Normal file
84
tests/lib/api/moon.test.js
Normal file
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user