- /api/synastry: inter-chart aspects with weighted harmony/tension scores, house overlays, written compatibility report - Compatibility tab: biwheel (partner ring in gold), inter-aspect grid, score gauges - Export toolbar: chart .json (re-loadable via Load file), wheel SVG/PNG, print CSS covers synastry - Aspect types explained reference + pair notes for 30+ classic pairs woven into natal aspect texts - Wheel fixes: correct arc sweep direction, text-presentation zodiac glyphs (no more emoji rendering) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
527 lines
22 KiB
JavaScript
527 lines
22 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();
|
|
|
|
/* ---------------- 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);
|
|
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 x1="${x0}" y1="${y0}" x2="${x1}" y2="${y1}" stroke="${ASPECT_COLOR[a.aspect]}" stroke-width="1" opacity="${a.orb < 2 ? 0.9 : 0.45}"/>`;
|
|
});
|
|
}
|
|
|
|
s += drawRing(d.bodies, pt, { glyphR: 250, tickR0: 282, tickR1: 274, leadR0: 272, leadR1: 262, degR: 228, color: "#e8e4f0", degColor: "#9a93ad" });
|
|
|
|
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" });
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
/* ---------------- 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>";
|
|
}
|
|
|
|
/* ---------------- 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);
|
|
renderSynastry(d);
|
|
} catch (ex) {
|
|
err.textContent = ex.message;
|
|
err.hidden = false;
|
|
} finally {
|
|
$("#syn-go").textContent = "Compare ♡";
|
|
}
|
|
});
|
|
|
|
function renderSynastry(d) {
|
|
$("#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>
|
|
<p class="hint" style="flex-basis:100%">Biwheel: <span style="color:#e8e4f0">${esc(nameA)} outer</span> · <span style="color:var(--gold)">${esc(nameB)} inner</span>, in ${esc(nameA)}'s houses.</p>`;
|
|
$("#syn-wheel").innerHTML = wheelSVG(d.chart_a, d.chart_b);
|
|
|
|
// inter-aspect grid: rows = A points, cols = B points
|
|
const cls = (a) => ({ Conjunction: "asp-gold", Opposition: "asp-red", Square: "asp-red", Trine: "asp-blue", Sextile: "asp-green" }[a.aspect] || "asp-dim");
|
|
const glyphOf = {};
|
|
d.chart_a.bodies.concat(d.chart_b.bodies).forEach((b) => (glyphOf[b.name] = b.glyph));
|
|
glyphOf["Ascendant"] = "AC"; glyphOf["Midheaven"] = "MC";
|
|
const ptsA = [...new Set(d.interaspects.map((x) => x.a))];
|
|
const ptsB = [...new Set(d.interaspects.map((x) => x.b))];
|
|
const cell = {};
|
|
d.interaspects.forEach((x) => (cell[x.a + "|" + x.b] = x));
|
|
let h = "<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)}" 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;
|
|
$("#syn-results").scrollIntoView({ behavior: "smooth" });
|
|
}
|
|
$("#syn-print").addEventListener("click", () => window.print());
|
|
|
|
/* ---------------- aspect reference ---------------- */
|
|
(async function loadReference() {
|
|
try {
|
|
const res = await fetch("/api/reference");
|
|
const d = await res.json();
|
|
$("#aspect-ref").innerHTML = d.aspects.map((a) =>
|
|
`<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;
|
|
}
|