/* NATEGODD frontend — form, city search, SVG wheel, tables, report. */ "use strict"; const $ = (s) => document.querySelector(s); let selectedCity = null; let lastChart = null; let lastBody = null; /* ---------------- city autocomplete ---------------- */ const placeInput = $("#f-place"); const cityBox = $("#city-results"); let searchTimer = null; placeInput.addEventListener("input", () => { selectedCity = null; clearTimeout(searchTimer); const q = placeInput.value.trim(); if (q.length < 2) { cityBox.hidden = true; return; } searchTimer = setTimeout(async () => { const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`); const rows = await res.json(); cityBox.innerHTML = ""; rows.forEach((r) => { const div = document.createElement("div"); div.innerHTML = `${r.name}${r.country} · ${r.lat.toFixed(2)}, ${r.lon.toFixed(2)}`; div.addEventListener("mousedown", () => pickCity(r)); cityBox.appendChild(div); }); cityBox.hidden = rows.length === 0; }, 180); }); placeInput.addEventListener("blur", () => setTimeout(() => (cityBox.hidden = true), 200)); function pickCity(r) { selectedCity = r; placeInput.value = r.label; $("#f-lat").value = r.lat; $("#f-lon").value = r.lon; cityBox.hidden = true; } /* ---------------- form submit ---------------- */ $("#f-unknown").addEventListener("change", (e) => { $("#f-time").disabled = e.target.checked; }); $("#chart-form").addEventListener("submit", async (e) => { e.preventDefault(); const err = $("#form-error"); err.hidden = true; const date = $("#f-date").value; if (!date) return showErr("Enter a birth date."); const [year, month, day] = date.split("-").map(Number); const unknown = $("#f-unknown").checked; const time = $("#f-time").value || "12:00"; const [hour, minute] = time.split(":").map(Number); const lat = parseFloat($("#f-lat").value); const lon = parseFloat($("#f-lon").value); if (isNaN(lat) || isNaN(lon)) return showErr("Pick a city from the list, or enter coordinates manually."); const body = { name: $("#f-name").value, place: selectedCity ? selectedCity.label : placeInput.value, year, month, day, hour, minute, time_unknown: unknown, lat, lon, tz: selectedCity ? selectedCity.tz : null, utc_offset: $("#f-offset").value, house_system: $("#f-houses").value, }; $("#go").textContent = "Casting…"; try { const res = await fetch("/api/chart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json(); if (data.error) return showErr(data.error); lastChart = data; lastBody = body; saveChart(body); render(data); } catch (ex) { showErr("Server error: " + ex.message); } finally { $("#go").textContent = "Cast Chart ✶"; } }); function showErr(msg) { const err = $("#form-error"); err.textContent = msg; err.hidden = false; $("#go").textContent = "Cast Chart ✶"; } /* ---------------- saved charts (localStorage) ---------------- */ function loadSaved() { try { return JSON.parse(localStorage.getItem("nategodd") || "[]"); } catch { return []; } } function saveChart(body) { try { let saved = loadSaved().filter((s) => !(s.name === body.name && s.year === body.year && s.month === body.month && s.day === body.day)); saved.unshift(body); saved = saved.slice(0, 20); localStorage.setItem("nategodd", JSON.stringify(saved)); fillSaved(); } catch {} } function fillSaved() { const sel = $("#saved-charts"); sel.innerHTML = ''; loadSaved().forEach((s, i) => { const o = document.createElement("option"); o.value = i; o.textContent = `${s.name || "unnamed"} · ${s.year}-${String(s.month).padStart(2, "0")}-${String(s.day).padStart(2, "0")}`; sel.appendChild(o); }); } $("#saved-charts").addEventListener("change", (e) => { if (e.target.value === "") return; const s = loadSaved()[Number(e.target.value)]; if (!s) return; $("#f-name").value = s.name || ""; $("#f-date").value = `${s.year}-${String(s.month).padStart(2, "0")}-${String(s.day).padStart(2, "0")}`; $("#f-time").value = `${String(s.hour).padStart(2, "0")}:${String(s.minute).padStart(2, "0")}`; $("#f-unknown").checked = !!s.time_unknown; $("#f-time").disabled = !!s.time_unknown; placeInput.value = s.place || ""; selectedCity = s.tz ? { label: s.place, lat: s.lat, lon: s.lon, tz: s.tz } : null; $("#f-lat").value = s.lat; $("#f-lon").value = s.lon; $("#f-houses").value = s.house_system || "P"; }); fillSaved(); /* ---------------- layer toggles ---------------- */ const LAYER_GROUPS = { chiron: ["Chiron"], asteroids: ["Ceres", "Pallas", "Juno", "Vesta"], nodes: ["North Node", "South Node"], lilith: ["Lilith"], fortune: ["Part of Fortune"], }; const LAYER_IDS = ["chiron", "asteroids", "nodes", "lilith", "fortune", "minor"]; function layerState() { const st = {}; LAYER_IDS.forEach((k) => (st[k] = $("#ly-" + k).checked)); return st; } function hiddenBodies() { const st = layerState(); const hidden = new Set(); for (const [k, names] of Object.entries(LAYER_GROUPS)) { if (!st[k]) names.forEach((n) => hidden.add(n)); } return hidden; } function filterChart(d) { const hidden = hiddenBodies(); const showMinor = layerState().minor; return { ...d, bodies: d.bodies.filter((b) => !hidden.has(b.name)), aspects: d.aspects.filter((a) => !hidden.has(a.a) && !hidden.has(a.b) && (showMinor || a.major)), parallels: d.parallels.filter((p) => !hidden.has(p.a) && !hidden.has(p.b)), interpretation: (d.interpretation || []).map((sec) => ({ ...sec, items: sec.items.filter((it) => ![...hidden].some((n) => it.title.includes(n))), })), }; } (function initLayers() { let saved = {}; try { saved = JSON.parse(localStorage.getItem("nategodd-layers") || "{}"); } catch {} LAYER_IDS.forEach((k) => { if (k in saved) $("#ly-" + k).checked = saved[k]; }); LAYER_IDS.forEach((k) => $("#ly-" + k).addEventListener("change", () => { try { localStorage.setItem("nategodd-layers", JSON.stringify(layerState())); } catch {} if (lastChart) render(lastChart, true); if (lastSyn && !$("#syn-results").hidden) renderSynastry(lastSyn, false); }) ); })(); let lastSyn = null; /* ---------------- tabs ---------------- */ document.querySelectorAll("#tabs button").forEach((b) => b.addEventListener("click", () => { document.querySelectorAll("#tabs button").forEach((x) => x.classList.remove("active")); document.querySelectorAll(".tab").forEach((x) => x.classList.remove("active")); b.classList.add("active"); $("#tab-" + b.dataset.tab).classList.add("active"); }) ); $("#print-btn").addEventListener("click", () => window.print()); /* ---------------- render ---------------- */ function render(d, rerender = false) { $("#results").hidden = false; const inp = d.input; const f = filterChart(d); $("#chart-header").innerHTML = `

${esc(inp.name) || "Natal Chart"}

${inp.date}${inp.time ? " · " + inp.time + " local" : " · time unknown (solar chart)"} · ${esc(inp.place)} · ${inp.lat.toFixed(3)}°, ${inp.lon.toFixed(3)}° · TZ ${esc(String(inp.tz))} (UTC${inp.utc_offset >= 0 ? "+" : ""}${inp.utc_offset}) · ${d.house_system} houses · ${d.is_day_chart ? "Day" : "Night"} chart

`; drawWheel(f); renderBalances(d); renderPositions(f); renderAspects(f); renderReport(f); if (!rerender) { fillSynPartners(); $("#syn-results").hidden = true; $("#results").scrollIntoView({ behavior: "smooth" }); } } function esc(s) { return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); } /* ---------------- SVG wheel ---------------- */ const ELEM_COLOR = ["#e06a4f", "#7aa85c", "#d9c86a", "#5f8fd0"]; // fire earth air water const SIGN_GLYPHS = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"]; function drawWheel(d) { $("#wheel-box").innerHTML = wheelSVG(d, null); } function wheelSVG(d, overlay) { const C = 360, size = 720; const asc = d.angles[0].lon; // longitude -> screen point. ASC at 9 o'clock, zodiac counterclockwise. const pt = (lonDeg, r) => { const a = ((lonDeg - asc + 180) * Math.PI) / 180; return [C + r * Math.cos(a), C - r * Math.sin(a)]; }; let s = ``; s += ``; // zodiac ring (zodiac runs counterclockwise on screen → sweep 0 outward, 1 back) for (let i = 0; i < 12; i++) { const a0 = i * 30, a1 = a0 + 30; const [x0o, y0o] = pt(a0, 330), [x1o, y1o] = pt(a1, 330); const [x1i, y1i] = pt(a1, 292), [x0i, y0i] = pt(a0, 292); s += ``; const [gx, gy] = pt(a0 + 15, 311); s += `${SIGN_GLYPHS[i]}︎`; } // degree ticks for (let deg = 0; deg < 360; deg += 5) { const len = deg % 30 === 0 ? 10 : deg % 10 === 0 ? 7 : 4; const [x0, y0] = pt(deg, 292), [x1, y1] = pt(deg, 292 - len); s += ``; } // houses if (!d.time_unknown) { d.houses.forEach((h, i) => { const angular = i % 3 === 0; const [x0, y0] = pt(h.lon, 120), [x1, y1] = pt(h.lon, 292); s += ``; const next = d.houses[(i + 1) % 12].lon; let mid = h.lon + (((next - h.lon) % 360) + 360) % 360 / 2; const [nx, ny] = pt(mid, 135); s += `${h.num}`; }); // ASC / MC labels const [ax, ay] = pt(asc, 345); s += `AC`; const [mx, my] = pt(d.angles[1].lon, 345); s += `MC`; } s += ``; // aspect lines (majors, planets only) — natal wheel only if (!overlay) { const lonOf = {}; d.bodies.forEach((b) => (lonOf[b.name] = b.lon)); const ASPECT_COLOR = { Conjunction: "#e8c66a", Opposition: "#e05555", Square: "#e05555", Trine: "#5fa8e0", Sextile: "#5cc58a" }; d.aspects.forEach((a) => { if (!a.major || !(a.a in lonOf) || !(a.b in lonOf)) return; if (a.aspect === "Conjunction") return; const [x0, y0] = pt(lonOf[a.a], 118), [x1, y1] = pt(lonOf[a.b], 118); s += ``; }); } s += drawRing(d.bodies, pt, { glyphR: 250, tickR0: 282, tickR1: 274, leadR0: 272, leadR1: 262, degR: 228, color: "#e8e4f0", degColor: "#9a93ad" }); if (overlay) { s += ``; s += drawRing(overlay.bodies, pt, { glyphR: 180, tickR0: 207, tickR1: 199, leadR0: 197, leadR1: 191, degR: 156, color: "#e8c66a", degColor: "#8a7a4a" }); } s += ``; return s; } function drawRing(bodies, pt, o) { // collision-spread planet glyphs on one ring const placed = bodies.map((b) => ({ ...b, draw: b.lon })).sort((a, b) => a.lon - b.lon); const MIN = 7.5; for (let pass = 0; pass < 6; pass++) { for (let i = 0; i < placed.length; i++) { const p = placed[i], q = placed[(i + 1) % placed.length]; let gap = (q.draw - p.draw + 360) % 360; if (gap < MIN) { const push = (MIN - gap) / 2; p.draw = (p.draw - push + 360) % 360; q.draw = (q.draw + push) % 360; } } placed.sort((a, b) => a.draw - b.draw); } let s = ""; placed.forEach((b) => { const [tx0, ty0] = pt(b.lon, o.tickR0), [tx1, ty1] = pt(b.lon, o.tickR1); s += ``; const [gx, gy] = pt(b.draw, o.glyphR); const [lx0, ly0] = pt(b.lon, o.leadR0), [lx1, ly1] = pt(b.draw, o.leadR0 - 10); s += ``; s += `${b.glyph}`; const p = b.position; const [dx, dy] = pt(b.draw, o.degR); s += `${p.deg}°${String(p.min).padStart(2, "0")}${b.retrograde ? "℞" : ""}`; }); return s; } /* ---------------- side panels ---------------- */ function renderBalances(d) { const b = d.balances; const colors = { Fire: "#e06a4f", Earth: "#7aa85c", Air: "#d9c86a", Water: "#5f8fd0", Cardinal: "#8f7bd8", Fixed: "#e8c66a", Mutable: "#5fa8e0", Positive: "#e8e4f0", Negative: "#9a93ad" }; let html = "

Balances

"; for (const group of [b.elements, b.modalities, b.polarities]) { const max = Math.max(...Object.values(group), 1); for (const [k, v] of Object.entries(group)) { html += `
${k}
${v}
`; } html += `
`; } $("#balances").innerHTML = html; const mp = d.moon_phase; $("#moonphase").innerHTML = mp ? `

Moon Phase

${mp.name} · ${mp.illumination}% illuminated · ${mp.angle}° from Sun` : ""; } /* ---------------- tables ---------------- */ function renderPositions(d) { let h = `BodyPositionHouseMotionDecl.Dignity`; d.bodies.forEach((b) => { h += `${b.glyph}${b.name}${b.position.text} ${d.time_unknown ? "—" : b.house} ${b.retrograde ? "℞ retrograde" : b.speed ? "direct" : "—"} ${b.declination == null ? "—" : b.declination.toFixed(2) + "°"} ${b.dignities.join(", ")}`; }); $("#pos-table").innerHTML = h; let a = `AnglePosition`; d.angles.forEach((x) => { a += `${x.name} (${x.glyph})${x.position.text}`; }); $("#angle-table").innerHTML = d.time_unknown ? "Time unknown — angles not calculated." : a; let ht = `HouseCusp`; d.houses.forEach((x) => { ht += `${x.num}${x.position.text}`; }); $("#house-table").innerHTML = d.time_unknown ? "Time unknown — houses not calculated." : ht; } function renderAspects(d) { const names = [...new Set(d.bodies.map((b) => b.name))]; if (!d.time_unknown) names.push("Ascendant", "Midheaven"); const glyph = {}; d.bodies.forEach((b) => (glyph[b.name] = b.glyph)); glyph["Ascendant"] = "AC"; glyph["Midheaven"] = "MC"; const cell = {}; d.aspects.forEach((a) => { cell[a.a + "|" + a.b] = a; cell[a.b + "|" + a.a] = a; }); const cls = (a) => ({ Conjunction: "asp-gold", Opposition: "asp-red", Square: "asp-red", Trine: "asp-blue", Sextile: "asp-green" }[a.aspect] || "asp-dim"); let h = ""; for (let i = 1; i < names.length; i++) { h += "" + `${glyph[names[i]]}`; for (let j = 0; j < i; j++) { const a = cell[names[i] + "|" + names[j]]; h += a ? `${a.glyph}` : ""; } h += ""; } h += "" + names.slice(0, -1).map((n) => `${glyph[n]}`).join("") + ""; $("#aspect-grid").innerHTML = h; let list = `AspectOrbPhase`; d.aspects.forEach((a) => { list += `${a.a} ${a.glyph} ${a.b} (${a.aspect.toLowerCase()}) ${a.orb}°${a.applying === null ? "—" : a.applying ? "applying" : "separating"}`; }); $("#aspect-list").innerHTML = list; let par = `PairTypeOrb`; d.parallels.forEach((p) => { par += `${p.a} · ${p.b}${p.type}${p.orb}°`; }); $("#parallel-list").innerHTML = d.parallels.length ? par : "None within 1°."; } /* ---------------- export / import ---------------- */ function download(filename, blob) { const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); } function chartSlug() { const n = (lastBody && lastBody.name) || "chart"; return n.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "chart"; } $("#exp-json").addEventListener("click", () => { if (!lastChart) return; download(`nategodd-${chartSlug()}.json`, new Blob([JSON.stringify({ input: lastBody, chart: lastChart }, null, 2)], { type: "application/json" })); }); $("#exp-svg").addEventListener("click", () => { const svg = $("#wheel-box svg"); if (!svg) return; download(`nategodd-${chartSlug()}.svg`, new Blob(['\n' + svg.outerHTML], { type: "image/svg+xml" })); }); $("#exp-png").addEventListener("click", () => { const svg = $("#wheel-box svg"); if (!svg) return; const img = new Image(); const url = URL.createObjectURL(new Blob([svg.outerHTML], { type: "image/svg+xml" })); img.onload = () => { const c = document.createElement("canvas"); c.width = c.height = 1440; // 2x for crispness const ctx = c.getContext("2d"); ctx.fillStyle = "#0b0b14"; ctx.fillRect(0, 0, 1440, 1440); ctx.drawImage(img, 0, 0, 1440, 1440); URL.revokeObjectURL(url); c.toBlob((blob) => download(`nategodd-${chartSlug()}.png`, blob), "image/png"); }; img.src = url; }); $("#exp-print").addEventListener("click", () => window.print()); $("#import-btn").addEventListener("click", () => $("#import-file").click()); $("#import-file").addEventListener("change", async (e) => { const f = e.target.files[0]; if (!f) return; try { const data = JSON.parse(await f.text()); const s = data.input || data; // accept full export or bare input if (!s.year || !s.lat) throw new Error("not a nategodd chart file"); $("#f-name").value = s.name || ""; $("#f-date").value = `${s.year}-${String(s.month).padStart(2, "0")}-${String(s.day).padStart(2, "0")}`; $("#f-time").value = `${String(s.hour ?? 12).padStart(2, "0")}:${String(s.minute ?? 0).padStart(2, "0")}`; $("#f-unknown").checked = !!s.time_unknown; $("#f-time").disabled = !!s.time_unknown; placeInput.value = s.place || ""; selectedCity = s.tz ? { label: s.place, lat: s.lat, lon: s.lon, tz: s.tz } : null; $("#f-lat").value = s.lat; $("#f-lon").value = s.lon; $("#f-houses").value = s.house_system || "P"; $("#chart-form").requestSubmit(); } catch (ex) { showErr("Could not read chart file: " + ex.message); } e.target.value = ""; }); /* ---------------- synastry ---------------- */ function fillSynPartners() { const sel = $("#syn-partner"); sel.innerHTML = ''; loadSaved().forEach((s, i) => { if (lastBody && s.name === lastBody.name && s.year === lastBody.year && s.month === lastBody.month && s.day === lastBody.day) return; // skip self const o = document.createElement("option"); o.value = i; o.textContent = `${s.name || "unnamed"} · ${s.year}-${String(s.month).padStart(2, "0")}-${String(s.day).padStart(2, "0")}`; sel.appendChild(o); }); } $("#syn-go").addEventListener("click", async () => { const err = $("#syn-error"); err.hidden = true; const idx = $("#syn-partner").value; if (idx === "" || !lastBody) { err.textContent = "Cast a chart first, then choose a partner from the list."; err.hidden = false; return; } const partner = loadSaved()[Number(idx)]; $("#syn-go").textContent = "Comparing…"; try { const res = await fetch("/api/synastry", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ a: lastBody, b: partner }), }); const d = await res.json(); if (d.error) throw new Error(d.error); renderSynastry(d); } catch (ex) { err.textContent = ex.message; err.hidden = false; } finally { $("#syn-go").textContent = "Compare ♡"; } }); function renderSynastry(d0, scroll = true) { lastSyn = d0; const hidden = hiddenBodies(); const showMinor = layerState().minor; const d = { ...d0, chart_a: { ...d0.chart_a, bodies: d0.chart_a.bodies.filter((b) => !hidden.has(b.name)) }, chart_b: { ...d0.chart_b, bodies: d0.chart_b.bodies.filter((b) => !hidden.has(b.name)) }, interaspects: d0.interaspects.filter((x) => !hidden.has(x.a) && !hidden.has(x.b) && (showMinor || x.major)), interpretation: d0.interpretation.map((sec) => ({ ...sec, items: sec.items.filter((it) => ![...hidden].some((n) => it.title.includes(n))), })), }; $("#syn-results").hidden = false; const sc = d.scores; const nameA = d.chart_a.input.name || "Chart A"; const nameB = d.chart_b.input.name || "Chart B"; $("#syn-scores").innerHTML = `
${sc.blend}%
flowing
${sc.harmony}
harmony
${sc.tension}
tension
${sc.contacts}
contacts

Biwheel: ${esc(nameA)} outer · ${esc(nameB)} inner, in ${esc(nameA)}'s houses.

`; $("#syn-wheel").innerHTML = wheelSVG(d.chart_a, d.chart_b); // inter-aspect grid: rows = A points, cols = B points const cls = (a) => ({ Conjunction: "asp-gold", Opposition: "asp-red", Square: "asp-red", Trine: "asp-blue", Sextile: "asp-green" }[a.aspect] || "asp-dim"); const glyphOf = {}; d.chart_a.bodies.concat(d.chart_b.bodies).forEach((b) => (glyphOf[b.name] = b.glyph)); glyphOf["Ascendant"] = "AC"; glyphOf["Midheaven"] = "MC"; const ptsA = [...new Set(d.interaspects.map((x) => x.a))]; const ptsB = [...new Set(d.interaspects.map((x) => x.b))]; const cell = {}; d.interaspects.forEach((x) => (cell[x.a + "|" + x.b] = x)); let h = "" + ptsB.map((p) => `${glyphOf[p] || p}`).join("") + ""; ptsA.forEach((pa) => { h += `${glyphOf[pa] || pa}`; ptsB.forEach((pb) => { const x = cell[pa + "|" + pb]; h += x ? `${x.glyph}` : ""; }); h += ""; }); $("#syn-grid").innerHTML = h; let r = ""; d.interpretation.forEach((sec) => { r += `

${esc(sec.heading)}

`; sec.items.forEach((it) => { r += `
${esc(it.title)}

${esc(it.body)}

`; }); r += "
"; }); $("#syn-report").innerHTML = r; if (scroll) $("#syn-results").scrollIntoView({ behavior: "smooth" }); } $("#syn-print").addEventListener("click", () => window.print()); /* ---------------- aspect reference ---------------- */ (async function loadReference() { try { const res = await fetch("/api/reference"); const d = await res.json(); $("#aspect-ref").innerHTML = d.aspects.map((a) => `
${a.glyph} ${a.name}${esc(a.geometry)}

${esc(a.text)}

`).join(""); } catch {} })(); /* ---------------- report ---------------- */ function renderReport(d) { let h = ""; d.interpretation.forEach((sec) => { h += `

${esc(sec.heading)}

`; sec.items.forEach((it) => { h += `
${esc(it.title)}

${esc(it.body)}

`; }); h += "
"; }); $("#report").innerHTML = h; }