nategodd/static/app.js
type-two 338614ee06 Aspect patterns + click-info interactivity
- detect_patterns(): stelliums, grand trines, kites, T-squares, grand
  crosses, mystic rectangles, yods — with element/mode/apex flavor
- Patterns panel on the Wheel tab: hover/click highlights the
  configuration on the wheel (gold overlay, other aspect lines dimmed)
  and pops an explainer
- Click-info popovers everywhere: planet glyphs (position, house,
  dignities, interpretation, its aspects), aspect lines, natal and
  synastry grid cells (with harmony score), biwheel glyphs attribute
  to the right person's chart
- Patterns section added to the written report; annotate_chart() ships
  popover text with the chart JSON

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 13:45:09 +10:00

785 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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}<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;
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 = '<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();
/* ---------------- 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)),
patterns: (d.patterns || []).filter((p) => !p.members.some((m) => hidden.has(m))),
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;
let synSwapped = false;
/* ---------------- 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 =
`<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(f);
renderPatterns(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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 = `<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 (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 += `<path d="M${x0o},${y0o} A330,330 0 0 0 ${x1o},${y1o} L${x1i},${y1i} A292,292 0 0 1 ${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]}&#xFE0E;</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) — 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 += `<line class="asp-line clickable" data-a="${a.a}" data-b="${a.b}" x1="${x0}" y1="${y0}" x2="${x1}" y2="${y1}" stroke="${ASPECT_COLOR[a.aspect]}" stroke-width="1" opacity="${a.orb < 2 ? 0.9 : 0.45}" pointer-events="stroke" stroke-linecap="round"/>`;
});
}
s += drawRing(d.bodies, pt, { glyphR: 250, tickR0: 282, tickR1: 274, leadR0: 272, leadR1: 262, degR: 228, color: "#e8e4f0", degColor: "#9a93ad", src: "base" });
if (overlay) {
s += `<circle cx="${C}" cy="${C}" r="207" fill="none" stroke="#2c2c42"/>`;
s += drawRing(overlay.bodies, pt, { glyphR: 180, tickR0: 207, tickR1: 199, leadR0: 197, leadR1: 191, degR: 156, color: "#e8c66a", degColor: "#8a7a4a", src: "over" });
}
s += `</svg>`;
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 += `<line x1="${tx0}" y1="${ty0}" x2="${tx1}" y2="${ty1}" stroke="${o.color}" stroke-width="1.5"/>`;
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 += `<line x1="${lx0}" y1="${ly0}" x2="${lx1}" y2="${ly1}" stroke="#55557a" stroke-width="0.7"/>`;
s += `<text class="clickable" data-body="${b.name}" data-src="${o.src || "base"}" x="${gx}" y="${gy}" font-size="20" fill="${o.color}" text-anchor="middle" dominant-baseline="central">${b.glyph}</text>`;
const p = b.position;
const [dx, dy] = pt(b.draw, o.degR);
s += `<text x="${dx}" y="${dy}" font-size="9" fill="${o.degColor}" text-anchor="middle" dominant-baseline="central">${p.deg}°${String(p.min).padStart(2, "0")}${b.retrograde ? "℞" : ""}</text>`;
});
return s;
}
/* ---------------- aspect patterns panel + wheel highlight ---------------- */
function renderPatterns(d) {
const box = $("#patterns");
let h = "<h3>Aspect Patterns</h3>";
if (!d.patterns || !d.patterns.length) {
h += '<p class="none">No major configurations detected.</p>';
} else {
d.patterns.forEach((p, i) => {
h += `<div class="pat" data-pat="${i}"><b>${p.type}${p.flavor ? " · " + esc(p.flavor) : ""}</b>
<small>${p.members.join(" ")}</small></div>`;
});
}
box.innerHTML = h;
const svgOf = () => $("#wheel-box svg");
box.querySelectorAll(".pat").forEach((el) => {
const p = d.patterns[Number(el.dataset.pat)];
el.addEventListener("mouseenter", () => highlightPattern(svgOf(), d, p));
el.addEventListener("mouseleave", () => {
if (!el.classList.contains("active")) clearHighlight(svgOf());
});
el.addEventListener("click", (ev) => {
const was = el.classList.contains("active");
box.querySelectorAll(".pat").forEach((x) => x.classList.remove("active"));
clearHighlight(svgOf());
if (!was) {
el.classList.add("active");
highlightPattern(svgOf(), d, p);
const expl = PATTERN_TEXT[p.type] || "";
showPopover(ev.clientX, ev.clientY,
`<h5>${p.type}${p.flavor ? " · " + esc(p.flavor) : ""}</h5>
<div class="meta">${p.members.join(" ")}</div><p>${esc(expl)}</p>`);
} else {
hidePopover();
}
});
});
}
function highlightPattern(svg, d, p) {
if (!svg) return;
clearHighlight(svg);
svg.classList.add("pat-dim");
const lonOf = {};
d.bodies.forEach((b) => (lonOf[b.name] = b.lon));
const asc = d.angles[0].lon;
const pt = (lonDeg, r) => {
const a = ((lonDeg - asc + 180) * Math.PI) / 180;
return [360 + r * Math.cos(a), 360 - r * Math.sin(a)];
};
const members = p.members.filter((m) => m in lonOf);
let g = `<g class="pat-hl">`;
// connect every aspected pair among members (fall back to all pairs for stelliums)
const aspected = new Set();
(lastChart ? lastChart.aspects : d.aspects).forEach((a) => {
if (members.includes(a.a) && members.includes(a.b)) aspected.add(a.a + "|" + a.b);
});
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) {
const key1 = members[i] + "|" + members[j], key2 = members[j] + "|" + members[i];
if (p.type !== "Stellium" && !aspected.has(key1) && !aspected.has(key2)) continue;
const [x0, y0] = pt(lonOf[members[i]], 118), [x1, y1] = pt(lonOf[members[j]], 118);
g += `<line x1="${x0}" y1="${y0}" x2="${x1}" y2="${y1}" stroke="#e8c66a" stroke-width="2.5" opacity="0.95"/>`;
}
const [mx, my] = pt(lonOf[members[i]], 118);
g += `<circle cx="${mx}" cy="${my}" r="5" fill="#e8c66a"/>`;
}
g += `</g>`;
svg.insertAdjacentHTML("beforeend", g);
}
function clearHighlight(svg) {
if (!svg) return;
svg.classList.remove("pat-dim");
svg.querySelectorAll(".pat-hl").forEach((el) => el.remove());
}
// pattern explainers (mirrors the server's PATTERN_EXPLAINER, for instant popovers)
const PATTERN_TEXT = {
"Stellium": "Three or more planets gathered in one sign — a massive concentration of energy in a single style of being. Whatever this sign represents, you do it in bulk: it dominates the personality the way a lead instrument dominates a band. The life task is not to tone it down but to give it a worthy stage.",
"Grand Trine": "Three planets in mutual trine — a closed, self-sustaining circuit of talent, usually in one element. Energy flows around it effortlessly. Its famous danger is complacency: a gift so frictionless you may coast on it rather than build with it.",
"T-Square": "An opposition with both ends square a third planet — the apex, which takes constant pressure and becomes hyper-developed: the chart's engine room and its sore point at once. Most driven charts carry one.",
"Grand Cross": "Two oppositions crossing at right angles — four squares, maximum structural tension. Life demands you juggle all four agendas at once. Exhausting when young; forges extraordinary resilience.",
"Mystic Rectangle": "Two oppositions stabilised by trines and sextiles — tension wrapped in ease. Every hard line has a harmonious detour, so strain naturally finds a practical outlet. An unusually self-contained, problem-solving geometry.",
"Kite": "A grand trine with a fourth planet opposite one corner — the opposition gives the frictionless talent circuit a rudder and a target. The focal planet is where the gift discharges into the world.",
"Yod": "The 'Finger of God': two planets sextile, both quincunx a distant apex. A nagging, fated-feeling itch that ordinary effort never quite scratches — usually resolving as a calling discovered late.",
};
/* ---------------- popover ---------------- */
function showPopover(x, y, html) {
const pop = $("#popover");
pop.innerHTML = `<button class="close" onclick="hidePopover()">✕</button>` + html;
pop.hidden = false;
const pad = 12;
const w = Math.min(380, window.innerWidth - 2 * pad);
pop.style.maxWidth = w + "px";
let left = x + 14, top = y + 14;
if (left + w + pad > window.innerWidth) left = Math.max(pad, x - w - 14);
pop.style.left = left + "px";
pop.style.top = Math.min(top, window.innerHeight - 100) + "px";
const r = pop.getBoundingClientRect();
if (r.bottom > window.innerHeight - pad) pop.style.top = Math.max(pad, window.innerHeight - r.height - pad) + "px";
}
function hidePopover() { $("#popover").hidden = true; }
document.addEventListener("click", (e) => {
if (!$("#popover").hidden &&
!e.target.closest("#popover") && !e.target.closest(".clickable") &&
!e.target.closest(".pat") && !e.target.closest("td.asp")) {
hidePopover();
document.querySelectorAll("#patterns .pat.active").forEach((x) => x.classList.remove("active"));
clearHighlight($("#wheel-box svg"));
}
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") hidePopover(); });
function bodyPopoverHTML(b, chart, personName) {
const dg = b.dignities && b.dignities.length ? ` · <span style="color:var(--gold)">${b.dignities.join(", ")}</span>` : "";
const rx = b.retrograde ? ' · <span style="color:var(--red)">℞ retrograde</span>' : "";
const house = chart && !chart.time_unknown && b.house ? ` · House ${b.house}` : "";
let asps = "";
if (chart) {
const rel = (chart.aspects || []).filter((a) => a.a === b.name || a.b === b.name).slice(0, 8);
if (rel.length) {
asps = "<p class='asprow'>" + rel.map((a) =>
`${a.a === b.name ? a.b : a.a} ${a.glyph} (${a.orb}°)`).join(" · ") + "</p>";
}
}
return `<h5>${b.glyph || ""} ${personName ? esc(personName) + "'s " : ""}${b.name}</h5>
<div class="meta">${b.position.text}${house}${rx}${dg}</div>
<p>${esc(b.about || "")}</p>${asps}`;
}
function wheelClickHandler(e) {
const t = e.target;
if (t.dataset && t.dataset.body) {
let chart = lastChart, person = "";
if (this.id === "syn-wheel" && lastSyn) {
const base = synSwapped ? lastSyn.chart_b : lastSyn.chart_a;
const over = synSwapped ? lastSyn.chart_a : lastSyn.chart_b;
chart = t.dataset.src === "over" ? over : base;
person = chart.input.name;
}
const b = (chart.bodies || []).find((x) => x.name === t.dataset.body) ||
(chart.angles || []).find((x) => x.name === t.dataset.body);
if (b) showPopover(e.clientX, e.clientY, bodyPopoverHTML(b, chart, person));
} else if (t.dataset && t.dataset.a) {
const a = (lastChart.aspects || []).find((x) =>
(x.a === t.dataset.a && x.b === t.dataset.b) || (x.a === t.dataset.b && x.b === t.dataset.a));
if (a) showPopover(e.clientX, e.clientY,
`<h5>${a.a} ${a.glyph} ${a.b}</h5>
<div class="meta">${a.aspect} · orb ${a.orb}°${a.applying === null ? "" : a.applying ? " · applying" : " · separating"}</div>
<p>${esc(a.text || "")}</p>`);
}
}
$("#wheel-box").addEventListener("click", wheelClickHandler);
$("#syn-wheel").addEventListener("click", wheelClickHandler);
$("#aspect-grid").addEventListener("click", (e) => {
const td = e.target.closest("td.asp");
if (!td || !lastChart) return;
const a = lastChart.aspects.find((x) =>
(x.a === td.dataset.a && x.b === td.dataset.b) || (x.a === td.dataset.b && x.b === td.dataset.a));
if (a) showPopover(e.clientX, e.clientY,
`<h5>${a.a} ${a.glyph} ${a.b}</h5>
<div class="meta">${a.aspect} · orb ${a.orb}°</div><p>${esc(a.text || "")}</p>`);
});
$("#syn-grid").addEventListener("click", (e) => {
const td = e.target.closest("td.asp");
if (!td || !lastSyn) return;
const x = lastSyn.interaspects.find((z) => z.a === td.dataset.a && z.b === td.dataset.b);
if (x) showPopover(e.clientX, e.clientY,
`<h5>${x.a} ${x.glyph} ${x.b}</h5>
<div class="meta">${x.aspect} · orb ${x.orb}° · ${x.score >= 0 ? "harmonious" : "challenging"} (${x.score})</div>
<p>${esc(x.text || "")}</p>`);
});
/* ---------------- 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)}" data-a="${a.a}" data-b="${a.b}" style="cursor:pointer" 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>";
}
/* ---------------- 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(['<?xml version="1.0"?>\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 = '<option value="">— choose partner chart —</option>';
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);
synSwapped = false;
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 =
`<div class="gauge"><div class="num">${sc.blend}%</div><div class="lbl">flowing</div></div>
<div class="gauge"><div class="num" style="color:var(--green)">${sc.harmony}</div><div class="lbl">harmony</div></div>
<div class="gauge"><div class="num" style="color:var(--red)">${sc.tension}</div><div class="lbl">tension</div></div>
<div class="gauge"><div class="num" style="color:var(--ink)">${sc.contacts}</div><div class="lbl">contacts</div></div>
<div id="syn-blendbar"><div style="width:${sc.blend}%"></div></div>`;
const base = synSwapped ? d.chart_b : d.chart_a;
const over = synSwapped ? d.chart_a : d.chart_b;
const baseName = synSwapped ? nameB : nameA;
const overName = synSwapped ? nameA : nameB;
if (base.time_unknown && !over.time_unknown) {
$("#syn-legend").innerHTML = `<span class="hint">${esc(baseName)}'s birth time is unknown — houses/rotation are its noon solar chart. Swap to frame by ${esc(overName)}.</span>`;
} else {
$("#syn-legend").innerHTML =
`Biwheel: <span style="color:#e8e4f0">${esc(baseName)} outer</span> · <span style="color:var(--gold)">${esc(overName)} inner</span>, in ${esc(baseName)}'s houses.`;
}
$("#syn-wheel").innerHTML = wheelSVG(base, over);
// 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 = "<tr><th></th>" + ptsB.map((p) => `<th title="${p}">${glyphOf[p] || p}</th>`).join("") + "</tr>";
ptsA.forEach((pa) => {
h += `<tr><th title="${pa}">${glyphOf[pa] || pa}</th>`;
ptsB.forEach((pb) => {
const x = cell[pa + "|" + pb];
h += x ? `<td class="asp ${cls(x)}" data-a="${x.a}" data-b="${x.b}" data-syn="1" style="cursor:pointer" title="${nameA}'s ${x.a} ${x.aspect} ${nameB}'s ${x.b} · orb ${x.orb}°">${x.glyph}</td>` : "<td></td>";
});
h += "</tr>";
});
$("#syn-grid").innerHTML = h;
let r = "";
d.interpretation.forEach((sec) => {
r += `<section><h4>${esc(sec.heading)}</h4>`;
sec.items.forEach((it) => { r += `<div class="item"><b>${esc(it.title)}</b><p>${esc(it.body)}</p></div>`; });
r += "</section>";
});
$("#syn-report").innerHTML = r;
if (scroll) $("#syn-results").scrollIntoView({ behavior: "smooth" });
}
$("#syn-print").addEventListener("click", () => window.print());
$("#syn-swap").addEventListener("click", () => {
synSwapped = !synSwapped;
if (lastSyn) renderSynastry(lastSyn, false);
});
/* ---------------- aspect reference ---------------- */
(async function loadReference() {
try {
const res = await fetch("/api/reference");
const d = await res.json();
$("#aspect-ref").innerHTML = d.aspects.map((a) =>
`<div class="item"><b>${a.glyph} ${a.name}<small>${esc(a.geometry)}</small></b><p>${esc(a.text)}</p></div>`).join("");
} catch {}
})();
/* ---------------- 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;
}