Full-detail natal charts: 21 bodies/points, 7 house systems, 11 aspect types with applying/separating, dignities, declination parallels, sect/balances/moon phase, offline city search with historical IANA time atlas, SVG wheel, and a written interpretation report. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
343 lines
14 KiB
JavaScript
343 lines
14 KiB
JavaScript
/* 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}<small>${r.country} · ${r.lat.toFixed(2)}, ${r.lon.toFixed(2)}</small>`;
|
|
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 = '<option value="">— saved charts —</option>';
|
|
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 =
|
|
`<h2>${esc(inp.name) || "Natal Chart"}</h2>
|
|
<p>${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</p>`;
|
|
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 = `<svg viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">`;
|
|
s += `<circle cx="${C}" cy="${C}" r="332" fill="#0e0e1a" stroke="#2c2c42"/>`;
|
|
|
|
// 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 += `<path d="M${x0o},${y0o} A330,330 0 0 1 ${x1o},${y1o} L${x1i},${y1i} A292,292 0 0 0 ${x0i},${y0i} Z"
|
|
fill="${ELEM_COLOR[i % 4]}22" stroke="#2c2c42"/>`;
|
|
const [gx, gy] = pt(a0 + 15, 311);
|
|
s += `<text x="${gx}" y="${gy}" font-size="22" fill="${ELEM_COLOR[i % 4]}" text-anchor="middle" dominant-baseline="central">${SIGN_GLYPHS[i]}</text>`;
|
|
}
|
|
// 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 += `<line x1="${x0}" y1="${y0}" x2="${x1}" y2="${y1}" stroke="#4a4a66" stroke-width="1"/>`;
|
|
}
|
|
|
|
// 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 += `<line x1="${x0}" y1="${y0}" x2="${x1}" y2="${y1}" stroke="${angular ? "#8f7bd8" : "#33334d"}" stroke-width="${angular ? 2 : 1}"/>`;
|
|
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 += `<text x="${nx}" y="${ny}" font-size="12" fill="#6a6a8a" text-anchor="middle" dominant-baseline="central">${h.num}</text>`;
|
|
});
|
|
// ASC / MC labels
|
|
const [ax, ay] = pt(asc, 345);
|
|
s += `<text x="${ax}" y="${ay}" font-size="13" fill="#e8c66a" text-anchor="middle" dominant-baseline="central">AC</text>`;
|
|
const [mx, my] = pt(d.angles[1].lon, 345);
|
|
s += `<text x="${mx}" y="${my}" font-size="13" fill="#e8c66a" text-anchor="middle" dominant-baseline="central">MC</text>`;
|
|
}
|
|
s += `<circle cx="${C}" cy="${C}" r="120" fill="none" stroke="#2c2c42"/>`;
|
|
|
|
// 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 += `<line x1="${x0}" y1="${y0}" x2="${x1}" y2="${y1}" stroke="${ASPECT_COLOR[a.aspect]}" stroke-width="1" opacity="${a.orb < 2 ? 0.9 : 0.45}"/>`;
|
|
});
|
|
|
|
// 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 += `<line x1="${tx0}" y1="${ty0}" x2="${tx1}" y2="${ty1}" stroke="#e8e4f0" stroke-width="1.5"/>`;
|
|
const [gx, gy] = pt(b.draw, 250);
|
|
const [lx0, ly0] = pt(b.lon, 272), [lx1, ly1] = pt(b.draw, 262);
|
|
s += `<line x1="${lx0}" y1="${ly0}" x2="${lx1}" y2="${ly1}" stroke="#55557a" stroke-width="0.7"/>`;
|
|
s += `<text x="${gx}" y="${gy}" font-size="20" fill="#e8e4f0" text-anchor="middle" dominant-baseline="central">${b.glyph}</text>`;
|
|
const p = b.position;
|
|
const [dx, dy] = pt(b.draw, 228);
|
|
s += `<text x="${dx}" y="${dy}" font-size="9" fill="#9a93ad" text-anchor="middle" dominant-baseline="central">${p.deg}°${String(p.min).padStart(2, "0")}${b.retrograde ? "℞" : ""}</text>`;
|
|
});
|
|
|
|
s += `</svg>`;
|
|
$("#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 = "<h3>Balances</h3>";
|
|
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 += `<div class="bal-row"><span>${k}</span><div class="bal-bar" style="width:${(v / max) * 130}px;background:${colors[k]}"></div><span>${v}</span></div>`;
|
|
}
|
|
html += `<div style="height:10px"></div>`;
|
|
}
|
|
$("#balances").innerHTML = html;
|
|
const mp = d.moon_phase;
|
|
$("#moonphase").innerHTML = mp
|
|
? `<h3>Moon Phase</h3><strong>${mp.name}</strong> · ${mp.illumination}% illuminated · ${mp.angle}° from Sun`
|
|
: "";
|
|
}
|
|
|
|
/* ---------------- tables ---------------- */
|
|
function renderPositions(d) {
|
|
let h = `<tr><th></th><th>Body</th><th>Position</th><th>House</th><th>Motion</th><th>Decl.</th><th>Dignity</th></tr>`;
|
|
d.bodies.forEach((b) => {
|
|
h += `<tr><td class="glyph">${b.glyph}</td><td>${b.name}</td><td>${b.position.text}</td>
|
|
<td>${d.time_unknown ? "—" : b.house}</td>
|
|
<td class="${b.retrograde ? "rx" : ""}">${b.retrograde ? "℞ retrograde" : b.speed ? "direct" : "—"}</td>
|
|
<td>${b.declination == null ? "—" : b.declination.toFixed(2) + "°"}</td>
|
|
<td class="dign">${b.dignities.join(", ")}</td></tr>`;
|
|
});
|
|
$("#pos-table").innerHTML = h;
|
|
|
|
let a = `<tr><th>Angle</th><th>Position</th></tr>`;
|
|
d.angles.forEach((x) => { a += `<tr><td>${x.name} (${x.glyph})</td><td>${x.position.text}</td></tr>`; });
|
|
$("#angle-table").innerHTML = d.time_unknown ? "<tr><td>Time unknown — angles not calculated.</td></tr>" : a;
|
|
|
|
let ht = `<tr><th>House</th><th>Cusp</th></tr>`;
|
|
d.houses.forEach((x) => { ht += `<tr><td>${x.num}</td><td>${x.position.text}</td></tr>`; });
|
|
$("#house-table").innerHTML = d.time_unknown ? "<tr><td>Time unknown — houses not calculated.</td></tr>" : 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 += "<tr>" + `<th>${glyph[names[i]]}</th>`;
|
|
for (let j = 0; j < i; j++) {
|
|
const a = cell[names[i] + "|" + names[j]];
|
|
h += a ? `<td class="asp ${cls(a)}" title="${a.a} ${a.aspect} ${a.b} · orb ${a.orb}°">${a.glyph}</td>` : "<td></td>";
|
|
}
|
|
h += "</tr>";
|
|
}
|
|
h += "<tr><th></th>" + names.slice(0, -1).map((n) => `<th>${glyph[n]}</th>`).join("") + "</tr>";
|
|
$("#aspect-grid").innerHTML = h;
|
|
|
|
let list = `<tr><th>Aspect</th><th>Orb</th><th>Phase</th></tr>`;
|
|
d.aspects.forEach((a) => {
|
|
list += `<tr><td class="${cls(a)}">${a.a} ${a.glyph} ${a.b} <span style="color:var(--dim)">(${a.aspect.toLowerCase()})</span></td>
|
|
<td>${a.orb}°</td><td>${a.applying === null ? "—" : a.applying ? "applying" : "separating"}</td></tr>`;
|
|
});
|
|
$("#aspect-list").innerHTML = list;
|
|
|
|
let par = `<tr><th>Pair</th><th>Type</th><th>Orb</th></tr>`;
|
|
d.parallels.forEach((p) => { par += `<tr><td>${p.a} · ${p.b}</td><td>${p.type}</td><td>${p.orb}°</td></tr>`; });
|
|
$("#parallel-list").innerHTML = d.parallels.length ? par : "<tr><td>None within 1°.</td></tr>";
|
|
}
|
|
|
|
/* ---------------- report ---------------- */
|
|
function renderReport(d) {
|
|
let h = "";
|
|
d.interpretation.forEach((sec) => {
|
|
h += `<section><h4>${esc(sec.heading)}</h4>`;
|
|
sec.items.forEach((it) => {
|
|
h += `<div class="item"><b>${esc(it.title)}</b><p>${esc(it.body)}</p></div>`;
|
|
});
|
|
h += "</section>";
|
|
});
|
|
$("#report").innerHTML = h;
|
|
}
|