Synastry, exports, aspect education

- /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>
This commit is contained in:
type-two 2026-08-30 20:23:19 +10:00
parent 1be39727f8
commit 9c284edc9f
7 changed files with 519 additions and 39 deletions

View File

@ -21,6 +21,13 @@ Natal chart generator in the spirit of the great 90s desktop astrology programs
- **Time unknown** mode: casts a noon solar chart, drops houses/angles honestly.
- SVG chart wheel (element-tinted zodiac ring, houses, aspect lines, collision-spread glyphs),
saved charts in localStorage.
- **Synastry / compatibility**: compare any two charts — weighted harmony/tension scoring,
biwheel (partner's planets overlaid in your houses), inter-aspect grid, house overlays,
and a written compatibility report.
- **Export**: chart as re-loadable `.json` file, wheel as SVG or PNG, any report via
print → PDF. `📂 Load file` re-imports an exported chart.
- **Aspect reference**: every aspect type explained (geometry, orb, feel) in the Aspects tab,
plus pair-specific interpretation notes for 30+ classic planet pairs.
## Run

58
app.py
View File

@ -19,9 +19,16 @@ def api_search():
return jsonify(geo.search_cities(q))
@app.post("/api/chart")
def api_chart():
d = request.get_json(force=True)
@app.get("/api/reference")
def api_reference():
return jsonify({"aspects": [
{"name": k, "glyph": chart_engine.ASPECT_GLYPHS[k], "geometry": v[0], "text": v[1]}
for k, v in interp.ASPECT_EXPLAINER.items()
]})
def cast(d):
"""Validate one chart-input dict and compute its chart. Raises ValueError."""
try:
year, month, day = int(d["year"]), int(d["month"]), int(d["day"])
time_unknown = bool(d.get("time_unknown"))
@ -32,8 +39,8 @@ def api_chart():
house_system = d.get("house_system", "P")
if house_system not in chart_engine.HOUSE_SYSTEMS:
house_system = "P"
except (KeyError, ValueError, TypeError) as e:
return jsonify({"error": f"bad input: {e}"}), 400
except (KeyError, TypeError) as e:
raise ValueError(f"bad input: {e}")
if d.get("utc_offset") not in (None, ""):
offset, tzname = float(d["utc_offset"]), "manual"
@ -42,23 +49,50 @@ def api_chart():
offset, tzname = geo.resolve_offset(lat, lon, year, month, day, hour, minute,
tz_hint=d.get("tz"))
except Exception as e:
return jsonify({"error": f"could not resolve timezone: {e}"}), 400
try:
result = chart_engine.compute_chart(year, month, day, hour, minute, second,
offset, lat, lon, house_system, time_unknown)
except Exception as e:
return jsonify({"error": f"computation failed: {e}"}), 400
raise ValueError(f"could not resolve timezone: {e}")
result = chart_engine.compute_chart(year, month, day, hour, minute, second,
offset, lat, lon, house_system, time_unknown)
result["input"] = {
"name": d.get("name", ""), "place": d.get("place", ""),
"lat": lat, "lon": lon, "tz": tzname, "utc_offset": offset,
"date": f"{year:04d}-{month:02d}-{day:02d}",
"time": None if time_unknown else f"{hour:02d}:{minute:02d}",
}
return result
@app.post("/api/chart")
def api_chart():
d = request.get_json(force=True)
try:
result = cast(d)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": f"computation failed: {e}"}), 400
result["interpretation"] = interp.build_interpretation(result)
return jsonify(result)
@app.post("/api/synastry")
def api_synastry():
d = request.get_json(force=True)
if not isinstance(d, dict) or "a" not in d or "b" not in d:
return jsonify({"error": "expected {a: <chart input>, b: <chart input>}"}), 400
try:
chart_a, chart_b = cast(d["a"]), cast(d["b"])
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": f"computation failed: {e}"}), 400
syn = chart_engine.compute_synastry(chart_a, chart_b)
syn["interpretation"] = interp.synastry_interpretation(
syn, chart_a["input"]["name"], chart_b["input"]["name"])
syn["chart_a"] = chart_a
syn["chart_b"] = chart_b
return jsonify(syn)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=7799, debug=False)

View File

@ -243,6 +243,85 @@ def compute_chart(year, month, day, hour, minute, second, ut_offset_hours,
}
SYN_POINTS = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn",
"Uranus", "Neptune", "Pluto", "Chiron", "North Node", "Ascendant", "Midheaven"]
SYN_PLANET_WEIGHT = {"Sun": 3.0, "Moon": 3.0, "Venus": 2.5, "Mars": 2.5, "Ascendant": 2.5,
"Mercury": 2.0, "Jupiter": 1.5, "Saturn": 1.5, "North Node": 1.5,
"Uranus": 1.0, "Neptune": 1.0, "Pluto": 1.0, "Chiron": 1.0, "Midheaven": 1.0}
SOFT_SET = {"Sun", "Moon", "Mercury", "Venus", "Jupiter"}
def _syn_lons(chart):
lons = {b["name"]: b["lon"] for b in chart["bodies"]}
if not chart.get("time_unknown"):
for a in chart["angles"]:
lons[a["name"]] = a["lon"]
return {p: lons[p] for p in SYN_POINTS if p in lons}
def compute_synastry(chart_a, chart_b):
"""Inter-chart aspects, house overlays and a weighted compatibility score."""
la, lb = _syn_lons(chart_a), _syn_lons(chart_b)
inter = []
harmony = tension = 0.0
for pa, lona in la.items():
for pb, lonb in lb.items():
sep = abs(norm(lona) - norm(lonb))
if sep > 180:
sep = 360 - sep
best = None
for name, (angle, orb, major) in ASPECTS.items():
o = (orb - 1.0) if major else 1.5 # synastry orbs a touch tighter
if major and (pa in LUMINARIES or pb in LUMINARIES):
o += 1.0
diff = abs(sep - angle)
if diff <= o and (best is None or diff < best[1]):
best = (name, diff, angle, major, o)
if not best:
continue
name, orbv, angle, major, omax = best
if name == "Conjunction":
val = 0.9 if (pa in SOFT_SET and pb in SOFT_SET) else 0.25
else:
val = {"Trine": 1.0, "Sextile": 0.8, "Square": -1.0, "Opposition": -0.7,
"Quincunx": -0.4, "Semisextile": 0.15, "Semisquare": -0.25,
"Sesquiquadrate": -0.25, "Quintile": 0.3, "Biquintile": 0.3}[name]
w = (SYN_PLANET_WEIGHT.get(pa, 1.0) + SYN_PLANET_WEIGHT.get(pb, 1.0)) / 2
score = val * w * (1.0 - (orbv / omax) * 0.5)
if score >= 0:
harmony += score
else:
tension += -score
inter.append({"a": pa, "b": pb, "aspect": name, "glyph": ASPECT_GLYPHS[name],
"orb": round(orbv, 2), "major": major, "score": round(score, 2),
"weight": round(abs(score), 2)})
inter.sort(key=lambda x: -x["weight"])
overlays = {"a_in_b": [], "b_in_a": []}
if not chart_b.get("time_unknown"):
cusps_b = [h["lon"] for h in chart_b["houses"]]
overlays["a_in_b"] = [{"body": b["name"], "glyph": b["glyph"],
"house": house_of(b["lon"], cusps_b)}
for b in chart_a["bodies"] if b["name"] in SYN_PLANET_WEIGHT]
if not chart_a.get("time_unknown"):
cusps_a = [h["lon"] for h in chart_a["houses"]]
overlays["b_in_a"] = [{"body": b["name"], "glyph": b["glyph"],
"house": house_of(b["lon"], cusps_a)}
for b in chart_b["bodies"] if b["name"] in SYN_PLANET_WEIGHT]
total = harmony + tension
return {
"interaspects": inter,
"overlays": overlays,
"scores": {
"harmony": round(harmony, 1),
"tension": round(tension, 1),
"blend": round(100 * harmony / total, 0) if total else 50,
"contacts": len(inter),
},
}
ASPECT_POINTS = [b[0] for b in BODIES] + ["South Node", "Part of Fortune", "Ascendant", "Midheaven"]
LUMINARIES = {"Sun", "Moon"}
MINOR_POINTS = {"Ceres", "Pallas", "Juno", "Vesta", "Lilith", "Part of Fortune", "South Node"}

124
interp.py
View File

@ -165,6 +165,72 @@ ASPECT_DYNAMICS = {
}
# Deeper notes for the classic planet pairs, keyed by frozenset. Appended to the
# synthesized aspect text in natal charts; hard aspects lean on the second clause.
PAIR_NOTES = {
frozenset(["Sun", "Moon"]): "The SunMoon relationship is the axis of the whole chart — how your conscious aims and your instinctive needs get along sets the tone for everything else.",
frozenset(["Sun", "Mercury"]): "Mind and identity travel together; you think in the first person and take ideas personally.",
frozenset(["Sun", "Venus"]): "Charm is woven into identity — you need to create, please and be found pleasing.",
frozenset(["Sun", "Mars"]): "Will and identity are yoked: enormous drive, and a temper that flares exactly when your selfhood is questioned.",
frozenset(["Sun", "Jupiter"]): "A native optimism and sense of luck — life tends to say yes to you, and excess is the only real danger.",
frozenset(["Sun", "Saturn"]): "Authority is the lifelong theme — doubting it, earning it, becoming it. Late-blooming and durable.",
frozenset(["Sun", "Uranus"]): "You are constitutionally unable to be ordinary; expect a life punctuated by sudden reinventions.",
frozenset(["Sun", "Neptune"]): "The self has soft edges — great for art and empathy, risky for boundaries and self-definition.",
frozenset(["Sun", "Pluto"]): "An all-or-nothing intensity to the personality; you periodically demolish and rebuild who you are.",
frozenset(["Moon", "Mercury"]): "Feeling and language are wired together — you can say what you feel, and you feel what is said.",
frozenset(["Moon", "Venus"]): "Affection comes naturally; you soothe people, and comfort and love are almost the same word.",
frozenset(["Moon", "Mars"]): "Emotions arrive hot and fast — passionate, brave, quick to flare and quick to defend your own.",
frozenset(["Moon", "Saturn"]): "Feelings are rationed and guarded — often from an early experience of emotional economy. Tenderness must be deliberately practised.",
frozenset(["Moon", "Neptune"]): "A psychic sponge — exquisite sensitivity to atmosphere, and a need to check whose feelings you're feeling.",
frozenset(["Moon", "Pluto"]): "Emotional X-ray vision and volcanic depths; you bond totally or not at all.",
frozenset(["Moon", "Uranus"]): "Feelings switch suddenly — you need emotional freedom and surprise, and closeness that doesn't cage.",
frozenset(["Mercury", "Neptune"]): "The imaginal mind — poetry, imagery and intuition strong, literal precision optional.",
frozenset(["Mercury", "Pluto"]): "The detective's mind — you probe, you keep secrets, and small talk bores you to death.",
frozenset(["Mercury", "Saturn"]): "The engineer's mind — slow, rigorous, sceptical, and right more often than it is quick.",
frozenset(["Mercury", "Uranus"]): "The lightning mind — intuitive leaps, brilliant and contrary in equal measure.",
frozenset(["Venus", "Mars"]): "Desire and affection are fused — magnetism, artistry and appetite; you pursue what you love.",
frozenset(["Venus", "Saturn"]): "Love is serious business — loyal, slow to trust, and inclined to test affection before believing it.",
frozenset(["Venus", "Neptune"]): "The romantic idealist — you fall in love with the dream first; art is the safest container for it.",
frozenset(["Venus", "Pluto"]): "Love as transformation — attractions are fated-feeling, intense and never casual.",
frozenset(["Venus", "Uranus"]): "Love needs freedom and novelty — sudden attractions, unconventional arrangements.",
frozenset(["Mars", "Saturn"]): "The disciplined will — drive under control, like a braked engine: frustrating young, formidable later.",
frozenset(["Mars", "Pluto"]): "Immense force of will — you don't do anything halfway, and you must choose your battles like a surgeon.",
frozenset(["Mars", "Uranus"]): "Explosive, original energy — sudden action, physical daring, zero tolerance for being told how.",
frozenset(["Mars", "Neptune"]): "The will works by inspiration, not force — best aimed at creative or compassionate ends, foggy elsewhere.",
frozenset(["Jupiter", "Saturn"]): "Expansion and contraction in one engine — the master builder's aspect when the two learn to take turns.",
frozenset(["Saturn", "Pluto"]): "Endurance itself — you can survive and rebuild what would break most people.",
}
# What each aspect TYPE is — geometry, orb, feel. Educational reference.
ASPECT_EXPLAINER = {
"Conjunction": ("0° — same place", "Two planets standing together in the zodiac. Their functions fuse into a single, amplified drive — neither operates without the other ever again. Whether that is a blessing depends entirely on the planets involved: VenusJupiter conjunct is champagne; MarsSaturn conjunct is a handbrake and an accelerator in one foot. The most powerful aspect of all."),
"Opposition": ("180° — opposite signs", "Two planets facing each other across the wheel, like people at either end of a see-saw. You tend to identify with one end and meet the other end in partners — until you notice both are you. Oppositions bring awareness through relationship: tension, yes, but tension you can see, which makes it the most workable of the hard aspects."),
"Trine": ("120° — same element", "The classic 'easy' aspect: both planets sit in signs of the same element and cooperate effortlessly. Trines are talents so native you barely notice them — which is their one danger: unexamined gifts stay undeveloped. A chart of nothing but trines is pleasant and goes nowhere; trines are the oil, not the engine."),
"Square": ("90° — same modality", "Two planets at cross-purposes in clashing signs of the same modality. This is friction you cannot ignore — an itch that demands action, which is exactly why squares build the strongest muscles in the chart. Most high achievers run on their squares. Unworked, they repeat as conflict; worked, they become your engine room."),
"Sextile": ("60° — compatible elements", "A friendly aspect between naturally compatible elements (fireair, earthwater). Like the trine but politer: it offers an opportunity rather than hands you a gift. Sextiles reward deliberate use — a door that opens easily, but only if you walk over and try the handle."),
"Quincunx": ("150° — no shared ground", "Also called the inconjunct. The two signs share no element, no modality, no polarity — the planets simply cannot see each other. The result is a chronic, low-grade maladjustment that needs periodic conscious re-balancing, like a door that never quite hangs straight. Common in health and habit matters."),
"Semisextile": ("30° — next-door signs", "Adjacent signs — related by proximity but nothing else. A subtle irritant or a subtle resource; mostly felt as a faint background friction between two areas of life that ought to get along and don't quite."),
"Semisquare": ("45° — half a square", "A minor hard aspect: the square's friction at half strength but often more nagging, because it is easier to ignore. Shows up as recurring petty annoyances between the two planets' agendas."),
"Sesquiquadrate": ("135° — square and a half", "A minor hard aspect with an odd, ambushing quality — the tension surfaces at unexpected moments, often provoked by outside events. Handled consciously, it behaves like a small square."),
"Quintile": ("72° — a fifth of the circle", "A minor harmonious aspect linked to creative pattern-making. Quintiles mark a signature talent — a distinctly personal way of combining the two planets, often visible in an artist's or craftsman's style."),
"Biquintile": ("144° — two fifths", "The quintile's subtler sibling: a refined, almost private creative link between two planets. Rarely dramatic, often present in charts of people with a highly individual aesthetic."),
}
SYN_DYNAMICS = {
"Conjunction": "occupy the same degree — instant recognition; you amplify this part of each other, for better and worse",
"Opposition": "face each other across the wheel — classic magnetic attraction, with a built-in risk of projection and tug-of-war",
"Trine": "flow together — this part of each of you is effortlessly welcomed by the other; a genuine ease you'll both take for granted",
"Square": "collide — friction and chemistry in equal measure; this is where the relationship works or where it grinds",
"Sextile": "click when engaged — easy allies whenever you actually collaborate here",
"Quincunx": "keep just missing each other — a chronic small misalignment that needs humour and adjustment",
"Semisextile": "sit awkwardly adjacent — minor friction, easily ignored",
"Semisquare": "chafe now and then — a background irritant worth naming before it compounds",
"Sesquiquadrate": "provoke each other at odd moments — flare-ups that seem to come from nowhere",
"Quintile": "spark creatively — an unusual, inventive chemistry",
"Biquintile": "share a subtle creative wavelength",
}
def aspect_text(a, b, aspect, orb, applying=None):
da = PLANET_MEANING.get(a, (a.lower(), ""))[0]
db = PLANET_MEANING.get(b, (b.lower(), ""))[0]
@ -175,8 +241,11 @@ def aspect_text(a, b, aspect, orb, applying=None):
app = " (applying — its influence strengthens through life)"
elif applying is False:
app = " (separating — an influence you were born already knowing)"
note = PAIR_NOTES.get(frozenset([a, b]), "")
if note:
note = " " + note
return (f"{a} {aspect.lower()} {b} (orb {orb}°{app}): "
f"{da.capitalize()} and {db} {dyn}. {tight}").strip()
f"{da.capitalize()} and {db} {dyn}. {tight}{note}").strip()
BALANCE_TEXT = {
@ -205,6 +274,59 @@ def balance_summary(balances):
return lines
def synastry_interpretation(syn, name_a, name_b):
"""Written compatibility report from compute_synastry() output."""
A = name_a or "Person A"
B = name_b or "Person B"
sections = []
sc = syn["scores"]
if sc["blend"] >= 60:
verdict = "an easy, mutually supportive connection — the harmony clearly outweighs the friction"
elif sc["blend"] >= 45:
verdict = "a balanced connection — real ease and real friction, which is the recipe for a relationship that stays interesting"
elif sc["blend"] >= 30:
verdict = "a high-voltage connection — more friction than flow, which brings intensity, growth and the need for honest work"
else:
verdict = "a demanding connection — the charts challenge each other at almost every turn; transformative if both choose it consciously"
sections.append({"heading": "Overview", "items": [{
"title": f"{A} × {B}",
"body": (f"{sc['contacts']} cross-chart contacts: harmony {sc['harmony']}, tension {sc['tension']} "
f"({sc['blend']:.0f}% flowing). This reads as {verdict}. "
"Remember: 'easy' is not the same as 'good' — squares and oppositions are what make two people matter to each other; "
"trines are what make it feel like home.")}]})
items = []
for x in syn["interaspects"]:
if x["weight"] < 1.0 and len(items) >= 12:
continue
da = PLANET_MEANING.get(x["a"], (x["a"].lower(), ""))[0]
db = PLANET_MEANING.get(x["b"], (x["b"].lower(), ""))[0]
dyn = SYN_DYNAMICS.get(x["aspect"], "interact")
body = (f"{A}'s {x['a']} and {B}'s {x['b']} {dyn} (orb {x['orb']}°). "
f"{A}'s {da} meets {B}'s {db} here.")
note = PAIR_NOTES.get(frozenset([x["a"], x["b"]]), "")
if note and x["weight"] >= 1.5:
body += " " + note
items.append({"title": f"{x['a']} {x['glyph']} {x['b']}", "body": body})
if len(items) >= 24:
break
sections.append({"heading": "Key Contacts", "items": items})
ov_items = []
for direction, rows, who, whose in (("a_in_b", syn["overlays"]["a_in_b"], A, B),
("b_in_a", syn["overlays"]["b_in_a"], B, A)):
picks = [r for r in rows if r["body"] in ("Sun", "Moon", "Venus", "Mars", "Saturn")]
for r in picks:
ov_items.append({"title": f"{who}'s {r['body']} in {whose}'s House {r['house']}",
"body": (f"{who}'s {PLANET_MEANING[r['body']][0]} lands in {whose}'s "
f"{HOUSE_MEANING[r['house']]}. {who} activates this whole area of "
f"{whose}'s life simply by being present.")})
if ov_items:
sections.append({"heading": "House Overlays", "items": ov_items})
return sections
def build_interpretation(chart):
"""Assemble the full report from a compute_chart() result."""
by_name = {b["name"]: b for b in chart["bodies"]}

View File

@ -4,6 +4,7 @@
const $ = (s) => document.querySelector(s);
let selectedCity = null;
let lastChart = null;
let lastBody = null;
/* ---------------- city autocomplete ---------------- */
const placeInput = $("#f-place");
@ -78,6 +79,7 @@ $("#chart-form").addEventListener("submit", async (e) => {
const data = await res.json();
if (data.error) return showErr(data.error);
lastChart = data;
lastBody = body;
saveChart(body);
render(data);
} catch (ex) {
@ -159,6 +161,8 @@ function render(d) {
renderPositions(d);
renderAspects(d);
renderReport(d);
fillSynPartners();
$("#syn-results").hidden = true;
$("#results").scrollIntoView({ behavior: "smooth" });
}
@ -169,6 +173,10 @@ const ELEM_COLOR = ["#e06a4f", "#7aa85c", "#d9c86a", "#5f8fd0"]; // fire earth a
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.
@ -179,15 +187,15 @@ function drawWheel(d) {
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 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 1 ${x1o},${y1o} L${x1i},${y1i} A292,292 0 0 0 ${x0i},${y0i} Z"
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>`;
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) {
@ -215,19 +223,33 @@ function drawWheel(d) {
}
s += `<circle cx="${C}" cy="${C}" r="120" fill="none" stroke="#2c2c42"/>`;
// aspect lines (majors, planets only)
const lonOf = {};
d.bodies.forEach((b) => (lonOf[b.name] = b.lon));
const ASPECT_COLOR = { Conjunction: "#e8c66a", Opposition: "#e05555", Square: "#e05555", Trine: "#5fa8e0", Sextile: "#5cc58a" };
d.aspects.forEach((a) => {
if (!a.major || !(a.a in lonOf) || !(a.b in lonOf)) return;
if (a.aspect === "Conjunction") return;
const [x0, y0] = pt(lonOf[a.a], 118), [x1, y1] = pt(lonOf[a.b], 118);
s += `<line x1="${x0}" y1="${y0}" x2="${x1}" y2="${y1}" stroke="${ASPECT_COLOR[a.aspect]}" stroke-width="1" opacity="${a.orb < 2 ? 0.9 : 0.45}"/>`;
});
// 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}"/>`;
});
}
// planets, with collision spread
const placed = d.bodies.map((b) => ({ ...b, draw: b.lon })).sort((a, b) => a.lon - b.lon);
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++) {
@ -237,20 +259,19 @@ function drawWheel(d) {
}
placed.sort((a, b) => a.draw - b.draw);
}
let s = "";
placed.forEach((b) => {
const [tx0, ty0] = pt(b.lon, 282), [tx1, ty1] = pt(b.lon, 274);
s += `<line x1="${tx0}" y1="${ty0}" x2="${tx1}" y2="${ty1}" stroke="#e8e4f0" stroke-width="1.5"/>`;
const [gx, gy] = pt(b.draw, 250);
const [lx0, ly0] = pt(b.lon, 272), [lx1, ly1] = pt(b.draw, 262);
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="#e8e4f0" text-anchor="middle" dominant-baseline="central">${b.glyph}</text>`;
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, 228);
s += `<text x="${dx}" y="${dy}" font-size="9" fill="#9a93ad" text-anchor="middle" dominant-baseline="central">${p.deg}°${String(p.min).padStart(2, "0")}${b.retrograde ? "℞" : ""}</text>`;
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>`;
});
s += `</svg>`;
$("#wheel-box").innerHTML = s;
return s;
}
/* ---------------- side panels ---------------- */
@ -328,6 +349,169 @@ function renderAspects(d) {
$("#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 = "";

View File

@ -57,6 +57,8 @@
</label>
<button type="submit" id="go">Cast Chart ✶</button>
<select id="saved-charts" title="saved charts"><option value="">— saved charts —</option></select>
<button type="button" id="import-btn" title="load a .json chart file">📂 Load file</button>
<input type="file" id="import-file" accept=".json" hidden>
</div>
<p id="form-error" hidden></p>
</form>
@ -64,11 +66,18 @@
<section id="results" hidden>
<div id="chart-header"></div>
<div id="toolbar">
<button type="button" id="exp-json" title="save this chart as a .json file (re-loadable)">💾 JSON</button>
<button type="button" id="exp-svg" title="download wheel as SVG">⬇ SVG</button>
<button type="button" id="exp-png" title="download wheel as PNG">⬇ PNG</button>
<button type="button" id="exp-print" title="print / save as PDF">🖨 Print</button>
</div>
<nav id="tabs">
<button data-tab="wheel" class="active">Wheel</button>
<button data-tab="positions">Positions</button>
<button data-tab="aspects">Aspects</button>
<button data-tab="report">Report</button>
<button data-tab="synastry">Compatibility</button>
</nav>
<div id="tab-wheel" class="tab active">
<div id="wheel-box"></div>
@ -92,11 +101,30 @@
<table id="aspect-list"></table>
<h3>Declination Parallels</h3>
<table id="parallel-list"></table>
<h3>Aspect Types Explained</h3>
<div id="aspect-ref"></div>
</div>
<div id="tab-report" class="tab">
<div id="report"></div>
<button id="print-btn" type="button">🖨 Print report</button>
</div>
<div id="tab-synastry" class="tab">
<div id="syn-picker">
<p>Compare this chart with a saved chart (cast a chart to save it, or 📂 load a .json file):</p>
<select id="syn-partner"><option value="">— choose partner chart —</option></select>
<button type="button" id="syn-go">Compare ♡</button>
<p id="syn-error" hidden></p>
</div>
<div id="syn-results" hidden>
<div id="syn-scores"></div>
<div id="syn-wheel"></div>
<h3>Inter-Aspect Grid <span class="hint">(rows = first chart, columns = partner)</span></h3>
<div class="scrollx"><table id="syn-grid"></table></div>
<h3>Compatibility Report</h3>
<div id="syn-report"></div>
<button type="button" id="syn-print">🖨 Print report</button>
</div>
</div>
</section>
</main>

View File

@ -118,9 +118,35 @@ td.dign { color: var(--gold); font-size: 12px; }
#report .item p { margin: 0; color: var(--ink); }
#print-btn { margin-top: 10px; }
#toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
#toolbar button { font-size: 13px; padding: 6px 12px; background: #1a1a2e; }
#toolbar button:hover { background: #262640; }
.hint { color: var(--dim); font-size: 11px; text-transform: none; letter-spacing: 0; }
#syn-picker { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 18px; }
#syn-picker p { color: var(--dim); font-size: 13px; flex-basis: 100%; margin: 0 0 4px; }
#syn-go { background: linear-gradient(135deg, #c25573, #d86a9b); border: none; font-weight: 600; padding: 9px 22px; }
#syn-go:hover { filter: brightness(1.15); }
#syn-error { color: var(--red); flex-basis: 100%; }
#syn-scores { display: flex; gap: 26px; align-items: center; flex-wrap: wrap; margin-bottom: 18px; }
.gauge { text-align: center; }
.gauge .num { font-size: 34px; color: var(--gold); }
.gauge .lbl { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: 1px; }
#syn-blendbar { flex: 1; min-width: 220px; height: 14px; border-radius: 7px; background: var(--red); overflow: hidden; }
#syn-blendbar div { height: 100%; background: linear-gradient(90deg, #5cc58a, #5fa8e0); }
#syn-wheel svg { width: 100%; max-width: 720px; height: auto; display: block; margin: 0 auto; }
#syn-grid th { color: var(--gold); }
#syn-grid td, #syn-grid th { text-align: center; padding: 4px 6px; min-width: 30px; }
#syn-grid td.asp { font-size: 16px; cursor: default; }
#syn-report .item { margin-bottom: 16px; }
#syn-report .item b, #aspect-ref b { color: var(--violet); display: block; margin-bottom: 2px; font-size: 15px; }
#syn-report .item p, #aspect-ref p { margin: 0; }
#aspect-ref .item { margin-bottom: 14px; }
#aspect-ref small { color: var(--dim); margin-left: 8px; }
@media print {
body { background: #fff; color: #000; font-size: 12px; }
header, #form-card, #tabs, footer, #print-btn { display: none !important; }
header, #form-card, #tabs, footer, #print-btn, #toolbar, #syn-picker, #syn-print { display: none !important; }
#results { border: none; background: none; }
.tab { display: block !important; }
#report .item b, #report h4, h3 { color: #000; }