- 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>
785 lines
34 KiB
JavaScript
785 lines
34 KiB
JavaScript
/* 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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]}︎</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;
|
||
}
|