/* NATEGODD frontend — form, city search, SVG wheel, tables, report. */ "use strict"; const $ = (s) => document.querySelector(s); let selectedCity = null; let lastChart = 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; 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(); /* ---------------- 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) { $("#results").hidden = false; const inp = d.input; $("#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(d); renderBalances(d); renderPositions(d); renderAspects(d); renderReport(d); $("#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) { 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 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) 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 += ``; }); // planets, with collision spread const placed = d.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); } placed.forEach((b) => { const [tx0, ty0] = pt(b.lon, 282), [tx1, ty1] = pt(b.lon, 274); s += ``; const [gx, gy] = pt(b.draw, 250); const [lx0, ly0] = pt(b.lon, 272), [lx1, ly1] = pt(b.draw, 262); s += ``; s += `${b.glyph}`; const p = b.position; const [dx, dy] = pt(b.draw, 228); s += `${p.deg}°${String(p.min).padStart(2, "0")}${b.retrograde ? "℞" : ""}`; }); s += ``; $("#wheel-box").innerHTML = 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°."; } /* ---------------- 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; }