viz: the natal chart — a verified ephemeris in the browser

 your stars (sky menu): birth date/time/UTC-offset -> the chart is cast
client-side and kept in localStorage; the server never sees it. Ephemeris:
Meeus truncations (Sun ~0.01°, Moon <=0.1° — 13-term ch.47 series) + JPL/
Standish Keplerian elements (planets <=~0.2°, 1800-2050), Kepler-solved and
precessed J2000->equinox of date. Verified: eclipse anchors (0.02°/0.03°),
Meeus worked examples, agent-checked element table (all 72 numbers), 2020
great conjunction reproduced to 0.08°, and an independent Meeus-vs-Kepler
solar cross-check that caught the precession frame.

Transit-vs-natal aspect engine with TIGHT transit orbs (conj/opp 5°, sq/tr 4°,
sex 3°, luminaries x1.3, quadratic peak at exactness — per transit practice,
not natal-chart orbs). Seven sources: natal.harmony / tension / hits / moon /
saturn / birthday / and natal.web — the chart's own tight aspects as a
resonance network, rung whole when a transit strikes either end. Multiple
charts (self, lover, friend), switchable; natal planets drawn as gold ghosts
under the godtime dial so transits are visible approaching. Routed per skin:
third eye, Buddhic, Tiferet, the East, Sol's gate, Asgard, Sirr, Water, the
Crown of the spread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-05 21:00:25 +10:00
parent fa383179f9
commit ea2920ee65

View File

@ -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 @@
<p><b>💰 Your holdings — live now.</b> Right-click the sky → <b>💰 your holdings</b>. Declare what you hold — coins, and metals through their tokens (<kbd>PAXG</kbd> is an ounce of gold) — and <i>how much of each</i>, and the amounts become <b>weights</b>: 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 <i>your browser</i> — the server never learns your bag, and the ledger lives only on your machine. Each holding becomes <kbd>me.btc</kbd>, <kbd>me.paxg</kbd>…, the whole bag breathes as <kbd>me.portfolio</kbd> (drift) with <kbd>me.portfolio.vel</kbd> 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.</p>
<p><b>✨ Your stars — designed, coming.</b> 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 <b>live transits against them</b> 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.</p>
<p><b>✨ Your stars — live now.</b> Right-click the sky → <b>✨ your stars</b>: birth date, time to the ten-minute, the UTC offset where you were born — and the instrument <b>casts your chart in the browser</b>, 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 <i>your</i> sky becomes seven sources: <kbd>natal.harmony</kbd> (transiting trines &amp; sextiles to your points — grace), <kbd>natal.tension</kbd> (squares &amp; <b>oppositions</b> — friction), <kbd>natal.hits</kbd> (conjunctions — emphasis), <kbd>natal.moon</kbd> (the fast hand: the Moon crossing your chart, fresh every few hours), <kbd>natal.saturn</kbd> (the slow teacher, a months-long pressure), <kbd>natal.birthday</kbd> (the solar return, swelling as your day approaches) — and the deep one, <kbd>natal.web</kbd>: your chart's own tight aspects form a <b>resonance network</b>, and a transit striking one end of your natal opposition rings the <i>whole figure</i>, exactly as astrology reads it. Your natal planets also appear as <b>gold ghosts under the godtime dial</b>, 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.</p>
<p class="foot">One Source, many Spirits, one Matter — and the Source was never only "out there." The instrument's ambition is simple: <b>everything that varies can sing</b>, and the first citizen of that everything is you.</p>
@ -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 18002049 — 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) {