diff --git a/viz/index.html b/viz/index.html index 1d709c2..86b5658 100644 --- a/viz/index.html +++ b/viz/index.html @@ -76,13 +76,20 @@ #inspect .midibtn:disabled { opacity: 0.4; cursor: default; } /* the holdings panel β your private ledger, localStorage only */ - #holdings { + #holdings, #natal { position: fixed; top: 96px; right: 14px; z-index: 12; width: 272px; background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35); border-radius: 12px; padding: 12px 14px; display: none; color: #c7d4ea; font-size: 12px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); } - #holdings.open { display: block; } + #holdings.open, #natal.open { display: block; } + #natal h3 { margin: 0 24px 4px 0; font-size: 13px; color: #cbb3ff; } + #natal .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0 8px; line-height: 1.5; } + #natal .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; } + #natal .hrow { display: flex; gap: 6px; align-items: center; margin: 4px 0; } + #natal input { flex: 1; min-width: 0; background: rgba(30,40,60,0.8); border: 1px solid rgba(120,150,200,0.3); border-radius: 6px; color: #dbe6f5; padding: 4px 8px; font-size: 12px; } + #natal .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; } + #natal .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25); border-radius: 6px; padding: 3px 10px; font-size: 11px; cursor: pointer; } #holdings h3 { margin: 0 24px 4px 0; font-size: 13px; color: #ffd98a; } #holdings .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0 8px; line-height: 1.5; } #holdings .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; } @@ -530,7 +537,7 @@
π° Your holdings β live now. Right-click the sky β π° your holdings. Declare what you hold β coins, and metals through their tokens (PAXG is an ounce of gold) β and how much of each, and the amounts become weights: a bigger bag sings louder, each holding's influence auto-scaled by its share of the total. Prices poll straight from the public market to your browser β the server never learns your bag, and the ledger lives only on your machine. Each holding becomes me.btc, me.paxgβ¦, the whole bag breathes as me.portfolio (drift) with me.portfolio.vel as its churn (flux) β and it routes where money lives in every tradition: Hod's quicksilver, the Wardens' wages in the West, the dwarves' gold in Nidavellir, the Metal phase, your hopes and fears in the spread. Stock tickers come later (they need a keyed API); your coins and your gold can sing today. Your finances were already an instrument; we only added the speakers.
-β¨ Your stars β designed, coming. The natal layer: your birth β date, time to the ten-minute, place β computed by the same Swiss Ephemeris that already turns tonight's sky. Your natal positions become fixed reference tones, and the live transits against them become sources: the sky's slow conversation with the chart you were born under, audible. Saturn crossing your natal Sun as a months-long bass note. And nothing stops you loading a lover's chart, or a friend's β the instrument plays relationships too.
+β¨ Your stars β live now. Right-click the sky β β¨ your stars: birth date, time to the ten-minute, the UTC offset where you were born β and the instrument casts your chart in the browser, with a real ephemeris (truncated Meeus for the lights, JPL orbital elements for the planets, precessed to the tropical zodiac; verified against eclipse timings and JPL positions to a fraction of a degree). Computed here, kept here: the server never sees a chart. Then the sky's living conversation with your sky becomes seven sources: natal.harmony (transiting trines & sextiles to your points β grace), natal.tension (squares & oppositions β friction), natal.hits (conjunctions β emphasis), natal.moon (the fast hand: the Moon crossing your chart, fresh every few hours), natal.saturn (the slow teacher, a months-long pressure), natal.birthday (the solar return, swelling as your day approaches) β and the deep one, natal.web: your chart's own tight aspects form a resonance network, and a transit striking one end of your natal opposition rings the whole figure, exactly as astrology reads it. Your natal planets also appear as gold ghosts under the godtime dial, so you can watch Saturn creep toward your natal Sun for weeks before you hear it arrive. Transit orbs are kept tight (aspects peak sharply at exactness, as transit practice demands) so these sources spike and breathe rather than drone. Load anyone's chart β yours, a lover's, a friend's; the instrument plays relationships too.
One Source, many Spirits, one Matter β and the Source was never only "out there." The instrument's ambition is simple: everything that varies can sing, and the first citizen of that everything is you.
@@ -981,7 +988,7 @@ const p = (key || "").split(".")[0].toLowerCase(); if (["tof","light","hand","audio","mic","touch","room","cam","body"].includes(p)) return "body"; if (["weather","air","quake","seismic","tide","temp"].includes(p)) return "planet"; - if (["sun","iss","planes","plane","sky","aurora","moon","space"].includes(p)) return "sky"; + if (["sun","iss","planes","plane","sky","aurora","moon","space","natal"].includes(p)) return "sky"; if (["crypto","market","stock","btc","eth","fx","me"].includes(p)) return "markets"; if (["wiki","net","news","social","edit"].includes(p)) return "human"; return "other"; @@ -2030,6 +2037,7 @@ const k = (key || "").toLowerCase(); const p = k.split(".")[0]; if (p === "body") return "heart"; // your pulse at anahata + if (p === "natal") return "eye"; // your own sky, seen // crown β cosmos & consciousness: the actual machinery of the sky if (p === "iss" || p === "space" || /_lon|mercury|venus|jupiter|saturn/.test(k)) return "crown"; if (p === "sun" && !/elev/.test(k)) return "crown"; // solar wind @@ -2099,6 +2107,7 @@ /^astro\.(saturn|mars|jupiter|venus|mercury)$/.test(k)) return "monadic"; if (k === "astro.moon" || /zodiac|season/.test(k) || // III Β· the great cyclic wheels k === "almanac.dec" || k === "almanac.element") return "atmic"; + if (p === "natal") return "buddhic"; // your chart: pure omen if (p === "astro" || /aspect|harmon|tension|retro|fertil|aurora/.test(k)) return "buddhic"; // IV Β· omen & aspect if (["wiki","net","news","social","edit","econ","debt"].includes(p) || // V Β· abstract thought & money-as-idea /inflation/.test(k) || k === "market.turnover" || k === "crypto.price") return "mental"; @@ -2141,6 +2150,7 @@ function treeOf(key) { const k = (key || "").toLowerCase(), p = k.split(".")[0]; if (p === "body") return "tiferet"; // the heart at the Tree's solar centre + if (p === "natal") return "tiferet"; // your own sky at the centre of the self if (k === "sun.speed") return "keter"; // the first swirlings if (/sun_lon|zodiac/.test(k) || p === "iss" || k === "clock.births" || k === "clock.popvel") return "chokmah"; if (k === "astro.saturn" || p === "debt" || /inflation/.test(k)) return "binah"; // Saturn: time, weight, form @@ -2178,6 +2188,7 @@ }; function lodgeOf(key) { const k = (key || "").toLowerCase(), p = k.split(".")[0]; + if (p === "natal") return "east"; // the heavens, read for you if (p === "body") return "altar"; // your living heart, laid at the centre if (p === "hand" || p === "tof" || p === "touch") // the candidate at the porch return /\.x$|open/.test(k) ? "boaz" : "jachin"; @@ -2211,6 +2222,7 @@ function ladderOf(key) { const k = (key || "").toLowerCase(), p = k.split(".")[0]; if (p === "body") return "sun"; // the heart at Sol's gate + if (p === "natal") return "sun"; // your chart at the heart gate if (k === "astro.saturn" || p === "debt" || /inflation|poverty|hunger|zodiac/.test(k)) return "saturn"; if (/lifeexp|fertile|harmony|births|popvel/.test(k)) return "jupiter"; if (k === "astro.mars" || p === "fire" || /tension|quake\.event|deaths/.test(k)) return "mars"; @@ -2242,6 +2254,7 @@ }; function nineOf(key) { const k = (key || "").toLowerCase(), p = k.split(".")[0]; + if (p === "natal") return "asgard"; if (p === "sun" || p === "sky" || p === "iss" || p === "astro" || /zodiac/.test(k)) return "asgard"; if (/fertile|season|births|almanac/.test(k)) return "vanaheim"; if (/ambient|light\.|harmony/.test(k)) return "alfheim"; @@ -2272,6 +2285,7 @@ function lataifOf(key) { const k = (key || "").toLowerCase(), p = k.split(".")[0]; if (p === "body") return "qalb"; // Qalb IS the heart + if (p === "natal") return "sirr"; // the secret β your hidden sky if (k === "sun.speed") return "akhfa"; // the most rarefied if (p === "astro" || /zodiac|almanac/.test(k)) return "khafi"; // the hidden influences if (/^sky\.|_lon/.test(k)) return "sirr"; // the secret geometry above @@ -2300,6 +2314,7 @@ function wuxingOf(key) { const k = (key || "").toLowerCase(), p = k.split(".")[0]; if (p === "body") return "fire"; // the heart is the fire-organ (TCM) + if (p === "natal") return "water"; // fate flows in the deep if (/births|popvel|season|fertile|lifeexp|wind|harmony|almanac\.element/.test(k)) return "wood"; if (p === "fire" || /temp|mars|tension|ambient|light\.|sky\./.test(k) || k === "sun.speed") return "fire"; if (/saturn|quake|hunger|poverty|pressure|debt\.total/.test(k)) return "earth"; @@ -2335,6 +2350,7 @@ function spreadOf(key) { const k = (key || "").toLowerCase(), p = k.split(".")[0]; if (p === "hand" || p === "tof" || p === "touch" || p === "body") return "querent"; // you are the one asking + if (p === "natal") return "crown"; // your stars, above you if (/retro/.test(k) || k === "clock.deaths") return "behind"; // what recedes, what looks backward if (/^sky\.|sun\.|_lon|zodiac/.test(k) || p === "iss") return "crown"; // what is above you if (/quake\.depth|debt\.total|saturn|poverty/.test(k)) return "foundation"; // the deep structure beneath @@ -2884,6 +2900,18 @@ ctx.fillStyle = "rgba(150,170,205,0.28)"; ctx.fillText(GT_ZODIAC[i], x, y - 11); } + // natal ghosts β the sky you were born under, fixed in gold beneath the + // wheel; watch the living planets close in on your chart + if (natalActive) { + const NG = { sun: "β", moon: "β½", mercury: "βΏ", venus: "β", mars: "β", jupiter: "β", saturn: "β" }; + ctx.font = "10px 'Helvetica Neue', Arial, sans-serif"; + for (const p in NG) { + const x = b.left + b.w * (natalActive.pos[p] / 360), y = godArcY(x, b); + ctx.fillStyle = "rgba(255,214,130,0.42)"; + ctx.fillText(NG[p], x, y + 13); + } + } + // real planets, placed by their live longitude for (const [k, g] of GT_PLANETS) { const n = sources.get(k); if (!n) continue; @@ -3361,6 +3389,8 @@ } ctxItem("π° your holdingsβ¦" + (holdings.length ? " (" + holdings.length + ")" : ""), () => openHoldings()); + ctxItem("β¨ your starsβ¦" + (natalActive ? " (" + natalActive.name + ")" : ""), + () => openNatal()); } ctxEl.classList.add("open"); ctxEl.style.left = Math.min(px, window.innerWidth - 252) + "px"; @@ -3726,6 +3756,225 @@ } if (holdings.length) holdingsStart(); // returning holders rejoin the choir on load + // ---- your stars (the natal source β a real ephemeris, in the browser) ----- + // Truncated Meeus (Sun ~0.01Β°, Moon β€0.1Β°) + JPL/Standish Keplerian elements + // (planets β€~0.2Β°, valid 1800-2050), precessed J2000βequinox of date so the + // zodiac is tropical. Verified against eclipse timings, Meeus's worked + // examples and JPL-checked positions β errors sit far inside transit orbs. + // Birth data lives in localStorage only; the server never sees a chart. + const D2R = Math.PI / 180; + const norm360 = (x) => ((x % 360) + 360) % 360; + const sepDeg = (a, b) => { const d = Math.abs(norm360(a - b)); return Math.min(d, 360 - d); }; + + function julianDay(y, mo, d, hUTC) { + if (mo <= 2) { y -= 1; mo += 12; } + const A = Math.floor(y / 100), B = 2 - A + Math.floor(A / 4); + return Math.floor(365.25 * (y + 4716)) + Math.floor(30.6001 * (mo + 1)) + d + B - 1524.5 + hUTC / 24; + } + const jCent = (jd) => (jd - 2451545.0) / 36525; + const precession = (T) => 1.39697 * T + 0.000308 * T * T; + + function sunLon(T) { // Meeus ch.25 β geometric, of date + const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T; + const M = (357.52911 + 35999.05029 * T - 0.0001537 * T * T) * D2R; + return norm360(L0 + + (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M) + + (0.019993 - 0.000101 * T) * Math.sin(2 * M) + + 0.000289 * Math.sin(3 * M)); + } + function moonLon(T) { // Meeus ch.47, 13 main terms + const Lp = 218.3164477 + 481267.88123421 * T; + const D = (297.8501921 + 445267.1114034 * T) * D2R; + const M = (357.5291092 + 35999.0502909 * T) * D2R; + const Mp = (134.9633964 + 477198.8675055 * T) * D2R; + const F = (93.2720950 + 483202.0175233 * T) * D2R; + return norm360(Lp + + 6.288774 * Math.sin(Mp) + 1.274027 * Math.sin(2 * D - Mp) + 0.658314 * Math.sin(2 * D) + + 0.213618 * Math.sin(2 * Mp) - 0.185116 * Math.sin(M) - 0.114332 * Math.sin(2 * F) + + 0.058793 * Math.sin(2 * D - 2 * Mp) + 0.057066 * Math.sin(2 * D - M - Mp) + + 0.053322 * Math.sin(2 * D + Mp) + 0.045758 * Math.sin(2 * D - M) + - 0.040923 * Math.sin(M - Mp) - 0.034720 * Math.sin(D) - 0.030383 * Math.sin(M + Mp)); + } + const KEP = { // a,da e,de I,dI L,dL peri,dperi node,dnode (per century) + mercury: [0.38709927, 0.00000037, 0.20563593, 0.00001906, 7.00497902, -0.00594749, 252.25032350, 149472.67411175, 77.45779628, 0.16047689, 48.33076593, -0.12534081], + venus: [0.72333566, 0.00000390, 0.00677672, -0.00004107, 3.39467605, -0.00078890, 181.97909950, 58517.81538729, 131.60246718, 0.00268329, 76.67984255, -0.27769418], + earth: [1.00000261, 0.00000562, 0.01671123, -0.00004392, -0.00001531, -0.01294668, 100.46457166, 35999.37244981, 102.93768193, 0.32327364, 0.0, 0.0], + mars: [1.52371034, 0.00001847, 0.09339410, 0.00007882, 1.84969142, -0.00813131, -4.55343205, 19140.30268499, -23.94362959, 0.44441088, 49.55953891, -0.29257343], + jupiter: [5.20288700, -0.00011607, 0.04838624, -0.00013253, 1.30439695, -0.00183714, 34.39644051, 3034.74612775, 14.72847983, 0.21252668, 100.47390909, 0.20469106], + saturn: [9.53667594, -0.00125060, 0.05386179, -0.00050991, 2.48599187, 0.00193609, 49.95424423, 1222.49362201, 92.59887831, -0.41897216, 113.66242448, -0.28867794], + }; + function helio(el, T) { + const a = el[0] + el[1] * T, e = el[2] + el[3] * T, I = (el[4] + el[5] * T) * D2R; + const L = el[6] + el[7] * T, w = el[8] + el[9] * T, O = el[10] + el[11] * T; + const Mr = (norm360(L - w + 180) - 180) * D2R; + let E = Mr + e * Math.sin(Mr); + for (let i = 0; i < 8; i++) E -= (E - e * Math.sin(E) - Mr) / (1 - e * Math.cos(E)); + const xv = a * (Math.cos(E) - e), yv = a * Math.sqrt(1 - e * e) * Math.sin(E); + const v = Math.atan2(yv, xv), r = Math.sqrt(xv * xv + yv * yv); + const wa = (el[8] - el[10]) * D2R + v, Or = O * D2R; + return { + x: r * (Math.cos(Or) * Math.cos(wa) - Math.sin(Or) * Math.sin(wa) * Math.cos(I)), + y: r * (Math.sin(Or) * Math.cos(wa) + Math.cos(Or) * Math.sin(wa) * Math.cos(I)), + }; + } + function planetLon(name, T) { + const p = helio(KEP[name], T), e2 = helio(KEP.earth, T); + return norm360(Math.atan2(p.y - e2.y, p.x - e2.x) / D2R + precession(T)); + } + const PLANET7 = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"]; + function skyAt(T) { + const S = { sun: sunLon(T), moon: moonLon(T) }; + for (let i = 2; i < 7; i++) S[PLANET7[i]] = planetLon(PLANET7[i], T); + return S; + } + + // aspect engine β TIGHT transit orbs (wide orbs are a natal-chart convention; + // transit practice peaks hard at exactness), strength quadratic toward exact + const NATAL_ASPECTS = [ + { a: 0, orb: 5, kind: "conj" }, + { a: 60, orb: 3, kind: "soft" }, + { a: 90, orb: 4, kind: "hard" }, + { a: 120, orb: 4, kind: "soft" }, + { a: 180, orb: 5, kind: "hard" }, + ]; + function aspectHit(l1, l2, lum) { + const d = sepDeg(l1, l2); + let best = null; + for (const A of NATAL_ASPECTS) { + const orb = A.orb * (lum ? 1.3 : 1); + const off = Math.abs(d - A.a); + if (off < orb) { + const s = Math.pow(1 - off / orb, 2); + if (!best || s > best.s) best = { kind: A.kind, s }; + } + } + return best; + } + + let charts = { list: [], active: 0 }; + try { charts = JSON.parse(localStorage.getItem("gs_charts")) || charts; } catch (e) {} + let natalActive = null; // {name, pos{7}, web[{a,b,kind,w}]} + + function computeNatal(c) { + const T = jCent(julianDay(+c.y, +c.mo, +c.d, +c.hh + (+c.mm) / 60 - (+c.utcoff || 0))); + const pos = skyAt(T); + const web = []; // the chart's own tight aspects β what transits will ring + for (let i = 0; i < 7; i++) for (let j = i + 1; j < 7; j++) { + const h = aspectHit(pos[PLANET7[i]], pos[PLANET7[j]], false); + if (h && h.s > 0.1) web.push({ a: PLANET7[i], b: PLANET7[j], kind: h.kind, w: h.s }); + } + return { pos, web }; + } + + function setNatalSource(key, label, norm) { + const n = ensureSource(key, { label }); + n.label = label; + n.raw = Math.round(norm * 100); + n.norm = clamp(norm, 0, 1); + } + function natalTick() { + if (!natalActive) return; + const d0 = new Date(); + const T = jCent(julianDay(d0.getUTCFullYear(), d0.getUTCMonth() + 1, d0.getUTCDate(), + d0.getUTCHours() + d0.getUTCMinutes() / 60)); + const Tr = skyAt(T); + let harmony = 0, tension = 0, conj = 0, moonS = 0, satS = 0; + const hitOn = {}; // strongest transit landing on each natal point + for (const tp of PLANET7) for (const np of PLANET7) { + const h = aspectHit(Tr[tp], natalActive.pos[np], tp === "sun" || tp === "moon" || np === "sun" || np === "moon"); + if (!h) continue; + if (h.kind === "soft") harmony += h.s; + else if (h.kind === "hard") tension += h.s; + else conj += h.s; + if (tp === "moon") moonS = Math.max(moonS, h.s); + if (tp === "saturn") satS = Math.max(satS, h.s); + hitOn[np] = Math.max(hitOn[np] || 0, h.s); + } + let web = 0; // a hit on one end of a natal aspect rings the whole figure + for (const wa of natalActive.web) web += wa.w * ((hitOn[wa.a] || 0) + (hitOn[wa.b] || 0)); + const who = natalActive.name; + setNatalSource("natal.harmony", who + " Β· sky grace", harmony / 2.5); + setNatalSource("natal.tension", who + " Β· sky friction", tension / 2.5); + setNatalSource("natal.hits", who + " Β· conjunctions", conj / 1.5); + setNatalSource("natal.web", who + " Β· natal web ringing", web / 1.5); + setNatalSource("natal.moon", "the moon on " + who + "'s chart", moonS); + setNatalSource("natal.saturn", "saturn on " + who + "'s chart", satS); + setNatalSource("natal.birthday", who + " Β· solar return", Math.max(0, 1 - sepDeg(Tr.sun, natalActive.pos.sun) / 15)); + } + function activateChart(i) { + charts.active = i; + try { localStorage.setItem("gs_charts", JSON.stringify(charts)); } catch (e) {} + const c = charts.list[i]; + if (!c) { natalActive = null; return; } + const n = computeNatal(c); + natalActive = { name: c.name || "you", pos: n.pos, web: n.web }; + natalTick(); + eventTicker.unshift({ text: "β¨ " + natalActive.name + "'s sky is in the choir Β· " + n.web.length + " natal aspects in the web", tAdded: now(), src: "sys" }); + } + setInterval(natalTick, 60000); // the moon moves ~0.5Β°/hour β a minute is plenty + if (charts.list.length) setTimeout(() => activateChart(charts.active), 1500); + + // the natal panel β birth data in, private + const natalEl = document.createElement("div"); + natalEl.id = "natal"; + document.body.appendChild(natalEl); + function closeNatal() { natalEl.classList.remove("open"); } + function openNatal() { + natalEl.innerHTML = ""; + const x = document.createElement("span"); x.className = "close"; x.textContent = "Γ"; + x.onclick = closeNatal; natalEl.appendChild(x); + const h3 = document.createElement("h3"); h3.textContent = "β¨ your stars"; natalEl.appendChild(h3); + const sub = document.createElement("div"); sub.className = "sub"; + sub.textContent = "birth date, time (to ~10 min), and the UTC offset where you were born. computed here, kept here β the server never sees a chart. load anyone's: yours, a lover's, a friend's."; + natalEl.appendChild(sub); + // saved charts + for (let i = 0; i < charts.list.length; i++) { + const c = charts.list[i]; + const row = document.createElement("div"); row.className = "hrow"; + const btn = document.createElement("button"); btn.className = "midibtn"; + btn.textContent = (i === charts.active && natalActive ? "β¦ " : "") + (c.name || "chart " + (i + 1)); + btn.style.flex = "1"; + btn.onclick = () => { activateChart(i); openNatal(); }; + const rm = document.createElement("span"); rm.className = "x"; rm.textContent = "Γ"; + rm.onclick = () => { + charts.list.splice(i, 1); + if (charts.active >= charts.list.length) charts.active = 0; + if (!charts.list.length) natalActive = null; + try { localStorage.setItem("gs_charts", JSON.stringify(charts)); } catch (e) {} + openNatal(); + }; + row.appendChild(btn); row.appendChild(rm); + natalEl.appendChild(row); + } + // new chart form + const mk = (ph, type, val) => { + const inp = document.createElement("input"); + inp.placeholder = ph; inp.type = type || "text"; if (val != null) inp.value = val; + return inp; + }; + const nameI = mk("name (you, a friendβ¦)"), dateI = mk("", "date"), timeI = mk("", "time"), + utcI = mk("UTC offset at birth, e.g. +10 or -5.5", "number"); + utcI.step = "0.5"; + const r1 = document.createElement("div"); r1.className = "hrow"; r1.appendChild(nameI); + const r2 = document.createElement("div"); r2.className = "hrow"; r2.appendChild(dateI); r2.appendChild(timeI); + const r3 = document.createElement("div"); r3.className = "hrow"; r3.appendChild(utcI); + natalEl.appendChild(r1); natalEl.appendChild(r2); natalEl.appendChild(r3); + const save = document.createElement("button"); save.className = "midibtn"; save.style.marginTop = "8px"; + save.textContent = "cast the chart"; + save.onclick = () => { + if (!dateI.value || !timeI.value) return; + const [y, mo, d] = dateI.value.split("-").map(Number); + const [hh, mm] = timeI.value.split(":").map(Number); + if (y < 1800 || y > 2049) { sub.textContent = "the ephemeris holds 1800β2049 β outside that the sky drifts."; return; } + charts.list.push({ name: nameI.value.trim() || "you", y, mo, d, hh, mm, utcoff: parseFloat(utcI.value) || 0 }); + try { localStorage.setItem("gs_charts", JSON.stringify(charts)); } catch (e) {} + activateChart(charts.list.length - 1); + openNatal(); + }; + natalEl.appendChild(save); + natalEl.classList.add("open"); + } + // ---- Web MIDI ---- async function enableMidi() { if (!navigator.requestMIDIAccess) {