diff --git a/app.py b/app.py index a712976..3792a37 100644 --- a/app.py +++ b/app.py @@ -72,6 +72,7 @@ def api_chart(): except Exception as e: return jsonify({"error": f"computation failed: {e}"}), 400 result["interpretation"] = interp.build_interpretation(result) + interp.annotate_chart(result) return jsonify(result) @@ -87,6 +88,12 @@ def api_synastry(): except Exception as e: return jsonify({"error": f"computation failed: {e}"}), 400 syn = chart_engine.compute_synastry(chart_a, chart_b) + na = chart_a["input"]["name"] or "A" + nb = chart_b["input"]["name"] or "B" + for x in syn["interaspects"]: + x["text"] = interp.syn_aspect_blurb(x, na, nb) + interp.annotate_chart(chart_a) + interp.annotate_chart(chart_b) syn["interpretation"] = interp.synastry_interpretation( syn, chart_a["input"]["name"], chart_b["input"]["name"]) syn["chart_a"] = chart_a diff --git a/chart.py b/chart.py index ed09e35..8a75d3f 100644 --- a/chart.py +++ b/chart.py @@ -228,7 +228,10 @@ def compute_chart(year, month, day, hour, minute, second, ut_offset_hours, houses = [{"num": i + 1, "lon": c, "position": fmt_dms(c)} for i, c in enumerate(cusps)] + patterns = detect_patterns(lons, aspects) + return { + "patterns": patterns, "julian_day": jd, "house_system": HOUSE_SYSTEMS.get(house_system, house_system), "time_unknown": time_unknown, @@ -243,6 +246,99 @@ def compute_chart(year, month, day, hour, minute, second, ut_offset_hours, } +PATTERN_BODIES = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", + "Uranus", "Neptune", "Pluto", "Chiron"] +STELLIUM_BODIES = PATTERN_BODIES + ["North Node"] + + +def detect_patterns(lons, aspects): + """Find classic aspect configurations among the main bodies.""" + from itertools import combinations + names = [n for n in PATTERN_BODIES if n in lons] + amap = {} + for a in aspects: + if a["a"] in PATTERN_BODIES and a["b"] in PATTERN_BODIES: + amap[frozenset((a["a"], a["b"]))] = a["aspect"] + + def asp(x, y): + return amap.get(frozenset((x, y))) + + patterns = [] + + # Stellium: 3+ bodies in one sign + by_sign = {} + for n in STELLIUM_BODIES: + if n in lons: + by_sign.setdefault(int(lons[n] // 30), []).append(n) + for si, group in sorted(by_sign.items()): + if len(group) >= 3: + group.sort(key=lambda n: lons[n]) + patterns.append({"type": "Stellium", "members": group, + "flavor": SIGNS[si]}) + + # Grand Trine: 3 mutually trine + grand_trines = [] + for c in combinations(names, 3): + if all(asp(x, y) == "Trine" for x, y in combinations(c, 2)): + elems = {SIGN_ELEMENT[int(lons[n] // 30) % 4] for n in c} + grand_trines.append(set(c)) + patterns.append({"type": "Grand Trine", "members": list(c), + "flavor": elems.pop() if len(elems) == 1 else "Mixed"}) + + # Kite: grand trine + a 4th body opposite one member, sextile the other two + for gt in grand_trines: + for d in names: + if d in gt: + continue + for m in gt: + others = gt - {m} + if asp(d, m) == "Opposition" and all(asp(d, o) == "Sextile" for o in others): + patterns.append({"type": "Kite", "members": sorted(gt) + [d], + "flavor": f"apex {d}"}) + + # Grand Cross: 4 bodies, 2 oppositions + 4 squares + grand_crosses = [] + for c in combinations(names, 4): + kinds = [asp(x, y) for x, y in combinations(c, 2)] + if None in kinds: + continue + if kinds.count("Opposition") == 2 and kinds.count("Square") == 4: + grand_crosses.append(set(c)) + modes = {SIGN_MODE[int(lons[n] // 30) % 3] for n in c} + patterns.append({"type": "Grand Cross", "members": list(c), + "flavor": modes.pop() if len(modes) == 1 else "Mixed"}) + + # T-Square: opposition, both ends square an apex (skip subsets of a grand cross) + for c in combinations(names, 3): + if any(set(c) <= gc for gc in grand_crosses): + continue + for apex in c: + ends = [n for n in c if n != apex] + if asp(*ends) == "Opposition" and all(asp(apex, e) == "Square" for e in ends): + patterns.append({"type": "T-Square", "members": [ends[0], ends[1], apex], + "flavor": f"apex {apex}"}) + break + + # Mystic Rectangle: 2 oppositions + 2 trines + 2 sextiles + for c in combinations(names, 4): + kinds = [asp(x, y) for x, y in combinations(c, 2)] + if None in kinds: + continue + if kinds.count("Opposition") == 2 and kinds.count("Trine") == 2 and kinds.count("Sextile") == 2: + patterns.append({"type": "Mystic Rectangle", "members": list(c), "flavor": ""}) + + # Yod: two quincunxes to an apex from a sextile pair + for c in combinations(names, 3): + for apex in c: + ends = [n for n in c if n != apex] + if asp(*ends) == "Sextile" and all(asp(apex, e) == "Quincunx" for e in ends): + patterns.append({"type": "Yod", "members": [ends[0], ends[1], apex], + "flavor": f"apex {apex}"}) + break + + return patterns + + 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, diff --git a/interp.py b/interp.py index 16d3b4b..cfa2844 100644 --- a/interp.py +++ b/interp.py @@ -216,6 +216,17 @@ ASPECT_EXPLAINER = { "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."), } +PATTERN_EXPLAINER = { + "Stellium": "Three or more planets gathered in one sign — a massive concentration of energy in a single style of being. Whatever this sign represents, you do it in bulk: it dominates the personality the way a lead instrument dominates a band, and the rest of the chart plays accompaniment. The life task is not to tone it down but to give it a worthy stage.", + "Grand Trine": "Three planets in mutual trine, forming a closed triangle — usually all in one element. This is a self-sustaining circuit of talent: energy flows around it effortlessly and endlessly. Its famous danger is complacency — a gift so frictionless you may coast on it rather than build with it. Worked deliberately, it is a lifelong renewable resource.", + "T-Square": "An opposition with both ends square to a third planet — the apex. The apex planet takes constant pressure from the tug-of-war behind it and becomes hyper-developed: the chart's engine room and its sore point at once. Most driven, high-achieving charts carry a T-square; the empty point opposite the apex shows what needs conscious cultivation.", + "Grand Cross": "Two oppositions crossing at right angles — four planets, four squares, maximum structural tension. Life repeatedly demands you juggle all four agendas at once, and no corner will accept neglect. Exhausting when young, but it forges extraordinary resilience: people with grand crosses tend to be the ones still standing.", + "Mystic Rectangle": "Two oppositions stabilised by a web of trines and sextiles — tension wrapped in ease. The oppositions supply real inner polarity, but every hard line has a harmonious detour, so strain naturally finds a practical outlet. It gives an unusually self-contained, balanced temperament: a problem-solving geometry where pressure converts to output.", + "Kite": "A grand trine with a fourth planet opposite one of its corners, sextile the other two. The opposition gives the frictionless grand trine exactly what it lacks — a rudder and a target. The focal planet (the tail of the kite) is where the talent circuit discharges into the world; this is a grand trine that actually ships.", + "Yod": "Two planets in sextile, both quincunx a distant apex — the 'Finger of God'. The apex planet sits at an awkward angle to everything feeding it, producing a nagging, fated-feeling itch that ordinary effort never quite scratches. Yods tend to work out as a calling discovered late: the apex names a task you were aimed at before you knew it.", +} + + 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", @@ -274,6 +285,35 @@ def balance_summary(balances): return lines +def annotate_chart(chart): + """Attach popover text to bodies and aspects (used by the click-info UI).""" + for b in chart["bodies"]: + txt = planet_in_sign(b["name"], b["position"]["sign"]) + if not txt and b["name"] in PLANET_MEANING: + txt = f"{b['name']} represents {PLANET_MEANING[b['name']][0]}." + chart_house = "" if chart.get("time_unknown") else planet_in_house(b["name"], b["house"]) + b["about"] = (txt + (" " + chart_house if chart_house else "")).strip() + for a in chart["aspects"]: + a["text"] = aspect_text(a["a"], a["b"], a["aspect"], a["orb"], a["applying"]) + for ang in chart.get("angles", []): + if ang["name"] in PLANET_MEANING: + base = f"The {ang['name']} marks {PLANET_MEANING[ang['name']][0]}." + else: + base = "" + if ang["name"] == "Ascendant": + base = RISING_IN_SIGN[ang["position"]["sign"]] + ang["about"] = base + + +def syn_aspect_blurb(x, name_a, name_b): + 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") + note = PAIR_NOTES.get(frozenset([x["a"], x["b"]]), "") + return (f"{name_a}'s {x['a']} and {name_b}'s {x['b']} {dyn} (orb {x['orb']}°). " + f"{name_a}'s {da} meets {name_b}'s {db} here." + (" " + note if note else "")) + + def synastry_interpretation(syn, name_a, name_b): """Written compatibility report from compute_synastry() output.""" A = name_a or "Person A" @@ -344,6 +384,15 @@ def build_interpretation(chart): big3.append({"title": f"{asc_sign} Rising", "body": RISING_IN_SIGN[asc_sign]}) sections.append({"heading": "The Big Three", "items": big3}) + if chart.get("patterns"): + items = [] + for p in chart["patterns"]: + flavor = f" ({p['flavor']})" if p["flavor"] else "" + members = " – ".join(p["members"]) + items.append({"title": f"{p['type']}{flavor}: {members}", + "body": PATTERN_EXPLAINER.get(p["type"], "")}) + sections.append({"heading": "Aspect Patterns", "items": items}) + items = [] for b in chart["bodies"]: name = b["name"] diff --git a/static/app.js b/static/app.js index 699dee5..27e4a47 100644 --- a/static/app.js +++ b/static/app.js @@ -167,6 +167,7 @@ function filterChart(d) { bodies: d.bodies.filter((b) => !hidden.has(b.name)), aspects: d.aspects.filter((a) => !hidden.has(a.a) && !hidden.has(a.b) && (showMinor || a.major)), parallels: d.parallels.filter((p) => !hidden.has(p.a) && !hidden.has(p.b)), + patterns: (d.patterns || []).filter((p) => !p.members.some((m) => hidden.has(m))), interpretation: (d.interpretation || []).map((sec) => ({ ...sec, items: sec.items.filter((it) => ![...hidden].some((n) => it.title.includes(n))), @@ -210,6 +211,7 @@ function render(d, rerender = false) { · ${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
`; drawWheel(f); + renderPatterns(f); renderBalances(d); renderPositions(f); renderAspects(f); @@ -287,15 +289,15 @@ function wheelSVG(d, overlay) { 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 += `No major configurations detected.
'; + } else { + d.patterns.forEach((p, i) => { + h += `${esc(expl)}
`); + } else { + hidePopover(); + } + }); + }); +} + +function highlightPattern(svg, d, p) { + if (!svg) return; + clearHighlight(svg); + svg.classList.add("pat-dim"); + const lonOf = {}; + d.bodies.forEach((b) => (lonOf[b.name] = b.lon)); + const asc = d.angles[0].lon; + const pt = (lonDeg, r) => { + const a = ((lonDeg - asc + 180) * Math.PI) / 180; + return [360 + r * Math.cos(a), 360 - r * Math.sin(a)]; + }; + const members = p.members.filter((m) => m in lonOf); + let g = `" + rel.map((a) => + `${a.a === b.name ? a.b : a.a} ${a.glyph} (${a.orb}°)`).join(" · ") + "
"; + } + } + return `${esc(b.about || "")}
${asps}`; +} + +function wheelClickHandler(e) { + const t = e.target; + if (t.dataset && t.dataset.body) { + let chart = lastChart, person = ""; + if (this.id === "syn-wheel" && lastSyn) { + const base = synSwapped ? lastSyn.chart_b : lastSyn.chart_a; + const over = synSwapped ? lastSyn.chart_a : lastSyn.chart_b; + chart = t.dataset.src === "over" ? over : base; + person = chart.input.name; + } + const b = (chart.bodies || []).find((x) => x.name === t.dataset.body) || + (chart.angles || []).find((x) => x.name === t.dataset.body); + if (b) showPopover(e.clientX, e.clientY, bodyPopoverHTML(b, chart, person)); + } else if (t.dataset && t.dataset.a) { + const a = (lastChart.aspects || []).find((x) => + (x.a === t.dataset.a && x.b === t.dataset.b) || (x.a === t.dataset.b && x.b === t.dataset.a)); + if (a) showPopover(e.clientX, e.clientY, + `${esc(a.text || "")}
`); + } +} +$("#wheel-box").addEventListener("click", wheelClickHandler); +$("#syn-wheel").addEventListener("click", wheelClickHandler); + +$("#aspect-grid").addEventListener("click", (e) => { + const td = e.target.closest("td.asp"); + if (!td || !lastChart) return; + const a = lastChart.aspects.find((x) => + (x.a === td.dataset.a && x.b === td.dataset.b) || (x.a === td.dataset.b && x.b === td.dataset.a)); + if (a) showPopover(e.clientX, e.clientY, + `${esc(a.text || "")}
`); +}); +$("#syn-grid").addEventListener("click", (e) => { + const td = e.target.closest("td.asp"); + if (!td || !lastSyn) return; + const x = lastSyn.interaspects.find((z) => z.a === td.dataset.a && z.b === td.dataset.b); + if (x) showPopover(e.clientX, e.clientY, + `${esc(x.text || "")}
`); +}); + /* ---------------- side panels ---------------- */ function renderBalances(d) { const b = d.balances; @@ -385,7 +561,7 @@ function renderAspects(d) { h += "