From 9c284edc9f6cdb2e25ce0d315a3c93312073c12e Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 30 Aug 2026 20:23:19 +1000 Subject: [PATCH] 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 --- README.md | 7 ++ app.py | 58 +++++++++--- chart.py | 79 ++++++++++++++++ interp.py | 124 +++++++++++++++++++++++- static/app.js | 234 +++++++++++++++++++++++++++++++++++++++++----- static/index.html | 28 ++++++ static/style.css | 28 +++++- 7 files changed, 519 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index f4b57ac..3d1f2fc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app.py b/app.py index be325a3..a712976 100644 --- a/app.py +++ b/app.py @@ -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: , b: }"}), 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) diff --git a/chart.py b/chart.py index f9355e3..ed09e35 100644 --- a/chart.py +++ b/chart.py @@ -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"} diff --git a/interp.py b/interp.py index 3a7c4d6..16d3b4b 100644 --- a/interp.py +++ b/interp.py @@ -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 Sun–Moon 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: Venus–Jupiter conjunct is champagne; Mars–Saturn 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 (fire–air, earth–water). 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"]} diff --git a/static/app.js b/static/app.js index 8741d66..a53598c 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = ``; s += ``; - // 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 += ``; const [gx, gy] = pt(a0 + 15, 311); - s += `${SIGN_GLYPHS[i]}`; + s += `${SIGN_GLYPHS[i]}︎`; } // degree ticks for (let deg = 0; deg < 360; deg += 5) { @@ -215,19 +223,33 @@ function drawWheel(d) { } s += ``; - // 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 += ``; - }); + // 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 += ``; + }); + } - // 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 += ``; + s += drawRing(overlay.bodies, pt, { glyphR: 180, tickR0: 207, tickR1: 199, leadR0: 197, leadR1: 191, degR: 156, color: "#e8c66a", degColor: "#8a7a4a" }); + } + + s += ``; + 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 += ``; - 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 += ``; + 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 += ``; - s += `${b.glyph}`; + s += `${b.glyph}`; const p = b.position; - const [dx, dy] = pt(b.draw, 228); - s += `${p.deg}Β°${String(p.min).padStart(2, "0")}${b.retrograde ? "β„ž" : ""}`; + const [dx, dy] = pt(b.draw, o.degR); + s += `${p.deg}Β°${String(p.min).padStart(2, "0")}${b.retrograde ? "β„ž" : ""}`; }); - - s += ``; - $("#wheel-box").innerHTML = s; + return s; } /* ---------------- side panels ---------------- */ @@ -328,6 +349,169 @@ function renderAspects(d) { $("#parallel-list").innerHTML = d.parallels.length ? par : "None within 1Β°."; } +/* ---------------- 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(['\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 = ''; + 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 = + `
${sc.blend}%
flowing
+
${sc.harmony}
harmony
+
${sc.tension}
tension
+
${sc.contacts}
contacts
+
+

Biwheel: ${esc(nameA)} outer Β· ${esc(nameB)} inner, in ${esc(nameA)}'s houses.

`; + $("#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 = "" + ptsB.map((p) => `${glyphOf[p] || p}`).join("") + ""; + ptsA.forEach((pa) => { + h += `${glyphOf[pa] || pa}`; + ptsB.forEach((pb) => { + const x = cell[pa + "|" + pb]; + h += x ? `${x.glyph}` : ""; + }); + h += ""; + }); + $("#syn-grid").innerHTML = h; + + let r = ""; + d.interpretation.forEach((sec) => { + r += `

${esc(sec.heading)}

`; + sec.items.forEach((it) => { r += `
${esc(it.title)}

${esc(it.body)}

`; }); + r += "
"; + }); + $("#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) => + `
${a.glyph} ${a.name}${esc(a.geometry)}

${esc(a.text)}

`).join(""); + } catch {} +})(); + /* ---------------- report ---------------- */ function renderReport(d) { let h = ""; diff --git a/static/index.html b/static/index.html index d3a3244..9ca8317 100644 --- a/static/index.html +++ b/static/index.html @@ -57,6 +57,8 @@ + + @@ -64,11 +66,18 @@ diff --git a/static/style.css b/static/style.css index fb6c9b6..5bacd99 100644 --- a/static/style.css +++ b/static/style.css @@ -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; }