Aspect patterns + click-info interactivity
- detect_patterns(): stelliums, grand trines, kites, T-squares, grand crosses, mystic rectangles, yods — with element/mode/apex flavor - Patterns panel on the Wheel tab: hover/click highlights the configuration on the wheel (gold overlay, other aspect lines dimmed) and pops an explainer - Click-info popovers everywhere: planet glyphs (position, house, dignities, interpretation, its aspects), aspect lines, natal and synastry grid cells (with harmony score), biwheel glyphs attribute to the right person's chart - Patterns section added to the written report; annotate_chart() ships popover text with the chart JSON Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5c03a5158b
commit
338614ee06
7
app.py
7
app.py
@ -72,6 +72,7 @@ def api_chart():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": f"computation failed: {e}"}), 400
|
return jsonify({"error": f"computation failed: {e}"}), 400
|
||||||
result["interpretation"] = interp.build_interpretation(result)
|
result["interpretation"] = interp.build_interpretation(result)
|
||||||
|
interp.annotate_chart(result)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@ -87,6 +88,12 @@ def api_synastry():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": f"computation failed: {e}"}), 400
|
return jsonify({"error": f"computation failed: {e}"}), 400
|
||||||
syn = chart_engine.compute_synastry(chart_a, chart_b)
|
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["interpretation"] = interp.synastry_interpretation(
|
||||||
syn, chart_a["input"]["name"], chart_b["input"]["name"])
|
syn, chart_a["input"]["name"], chart_b["input"]["name"])
|
||||||
syn["chart_a"] = chart_a
|
syn["chart_a"] = chart_a
|
||||||
|
|||||||
96
chart.py
96
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)]
|
houses = [{"num": i + 1, "lon": c, "position": fmt_dms(c)} for i, c in enumerate(cusps)]
|
||||||
|
|
||||||
|
patterns = detect_patterns(lons, aspects)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"patterns": patterns,
|
||||||
"julian_day": jd,
|
"julian_day": jd,
|
||||||
"house_system": HOUSE_SYSTEMS.get(house_system, house_system),
|
"house_system": HOUSE_SYSTEMS.get(house_system, house_system),
|
||||||
"time_unknown": time_unknown,
|
"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",
|
SYN_POINTS = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn",
|
||||||
"Uranus", "Neptune", "Pluto", "Chiron", "North Node", "Ascendant", "Midheaven"]
|
"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,
|
SYN_PLANET_WEIGHT = {"Sun": 3.0, "Moon": 3.0, "Venus": 2.5, "Mars": 2.5, "Ascendant": 2.5,
|
||||||
|
|||||||
49
interp.py
49
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."),
|
"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 = {
|
SYN_DYNAMICS = {
|
||||||
"Conjunction": "occupy the same degree — instant recognition; you amplify this part of each other, for better and worse",
|
"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",
|
"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
|
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):
|
def synastry_interpretation(syn, name_a, name_b):
|
||||||
"""Written compatibility report from compute_synastry() output."""
|
"""Written compatibility report from compute_synastry() output."""
|
||||||
A = name_a or "Person A"
|
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]})
|
big3.append({"title": f"{asc_sign} Rising", "body": RISING_IN_SIGN[asc_sign]})
|
||||||
sections.append({"heading": "The Big Three", "items": big3})
|
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 = []
|
items = []
|
||||||
for b in chart["bodies"]:
|
for b in chart["bodies"]:
|
||||||
name = b["name"]
|
name = b["name"]
|
||||||
|
|||||||
188
static/app.js
188
static/app.js
@ -167,6 +167,7 @@ function filterChart(d) {
|
|||||||
bodies: d.bodies.filter((b) => !hidden.has(b.name)),
|
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)),
|
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)),
|
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) => ({
|
interpretation: (d.interpretation || []).map((sec) => ({
|
||||||
...sec,
|
...sec,
|
||||||
items: sec.items.filter((it) => ![...hidden].some((n) => it.title.includes(n))),
|
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})
|
· ${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>`;
|
· ${d.house_system} houses · ${d.is_day_chart ? "Day" : "Night"} chart</p>`;
|
||||||
drawWheel(f);
|
drawWheel(f);
|
||||||
|
renderPatterns(f);
|
||||||
renderBalances(d);
|
renderBalances(d);
|
||||||
renderPositions(f);
|
renderPositions(f);
|
||||||
renderAspects(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.major || !(a.a in lonOf) || !(a.b in lonOf)) return;
|
||||||
if (a.aspect === "Conjunction") return;
|
if (a.aspect === "Conjunction") return;
|
||||||
const [x0, y0] = pt(lonOf[a.a], 118), [x1, y1] = pt(lonOf[a.b], 118);
|
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 += `<line class="asp-line clickable" data-a="${a.a}" data-b="${a.b}" x1="${x0}" y1="${y0}" x2="${x1}" y2="${y1}" stroke="${ASPECT_COLOR[a.aspect]}" stroke-width="1" opacity="${a.orb < 2 ? 0.9 : 0.45}" pointer-events="stroke" stroke-linecap="round"/>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
s += drawRing(d.bodies, pt, { glyphR: 250, tickR0: 282, tickR1: 274, leadR0: 272, leadR1: 262, degR: 228, color: "#e8e4f0", degColor: "#9a93ad" });
|
s += drawRing(d.bodies, pt, { glyphR: 250, tickR0: 282, tickR1: 274, leadR0: 272, leadR1: 262, degR: 228, color: "#e8e4f0", degColor: "#9a93ad", src: "base" });
|
||||||
|
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
s += `<circle cx="${C}" cy="${C}" r="207" fill="none" stroke="#2c2c42"/>`;
|
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 += drawRing(overlay.bodies, pt, { glyphR: 180, tickR0: 207, tickR1: 199, leadR0: 197, leadR1: 191, degR: 156, color: "#e8c66a", degColor: "#8a7a4a", src: "over" });
|
||||||
}
|
}
|
||||||
|
|
||||||
s += `</svg>`;
|
s += `</svg>`;
|
||||||
@ -321,7 +323,7 @@ function drawRing(bodies, pt, o) {
|
|||||||
const [gx, gy] = pt(b.draw, o.glyphR);
|
const [gx, gy] = pt(b.draw, o.glyphR);
|
||||||
const [lx0, ly0] = pt(b.lon, o.leadR0), [lx1, ly1] = pt(b.draw, o.leadR0 - 10);
|
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 += `<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>`;
|
s += `<text class="clickable" data-body="${b.name}" data-src="${o.src || "base"}" x="${gx}" y="${gy}" font-size="20" fill="${o.color}" text-anchor="middle" dominant-baseline="central">${b.glyph}</text>`;
|
||||||
const p = b.position;
|
const p = b.position;
|
||||||
const [dx, dy] = pt(b.draw, o.degR);
|
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 += `<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>`;
|
||||||
@ -329,6 +331,180 @@ function drawRing(bodies, pt, o) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- aspect patterns panel + wheel highlight ---------------- */
|
||||||
|
function renderPatterns(d) {
|
||||||
|
const box = $("#patterns");
|
||||||
|
let h = "<h3>Aspect Patterns</h3>";
|
||||||
|
if (!d.patterns || !d.patterns.length) {
|
||||||
|
h += '<p class="none">No major configurations detected.</p>';
|
||||||
|
} else {
|
||||||
|
d.patterns.forEach((p, i) => {
|
||||||
|
h += `<div class="pat" data-pat="${i}"><b>${p.type}${p.flavor ? " · " + esc(p.flavor) : ""}</b>
|
||||||
|
<small>${p.members.join(" – ")}</small></div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
box.innerHTML = h;
|
||||||
|
const svgOf = () => $("#wheel-box svg");
|
||||||
|
box.querySelectorAll(".pat").forEach((el) => {
|
||||||
|
const p = d.patterns[Number(el.dataset.pat)];
|
||||||
|
el.addEventListener("mouseenter", () => highlightPattern(svgOf(), d, p));
|
||||||
|
el.addEventListener("mouseleave", () => {
|
||||||
|
if (!el.classList.contains("active")) clearHighlight(svgOf());
|
||||||
|
});
|
||||||
|
el.addEventListener("click", (ev) => {
|
||||||
|
const was = el.classList.contains("active");
|
||||||
|
box.querySelectorAll(".pat").forEach((x) => x.classList.remove("active"));
|
||||||
|
clearHighlight(svgOf());
|
||||||
|
if (!was) {
|
||||||
|
el.classList.add("active");
|
||||||
|
highlightPattern(svgOf(), d, p);
|
||||||
|
const expl = PATTERN_TEXT[p.type] || "";
|
||||||
|
showPopover(ev.clientX, ev.clientY,
|
||||||
|
`<h5>${p.type}${p.flavor ? " · " + esc(p.flavor) : ""}</h5>
|
||||||
|
<div class="meta">${p.members.join(" – ")}</div><p>${esc(expl)}</p>`);
|
||||||
|
} else {
|
||||||
|
hidePopover();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightPattern(svg, d, p) {
|
||||||
|
if (!svg) return;
|
||||||
|
clearHighlight(svg);
|
||||||
|
svg.classList.add("pat-dim");
|
||||||
|
const lonOf = {};
|
||||||
|
d.bodies.forEach((b) => (lonOf[b.name] = b.lon));
|
||||||
|
const asc = d.angles[0].lon;
|
||||||
|
const pt = (lonDeg, r) => {
|
||||||
|
const a = ((lonDeg - asc + 180) * Math.PI) / 180;
|
||||||
|
return [360 + r * Math.cos(a), 360 - r * Math.sin(a)];
|
||||||
|
};
|
||||||
|
const members = p.members.filter((m) => m in lonOf);
|
||||||
|
let g = `<g class="pat-hl">`;
|
||||||
|
// connect every aspected pair among members (fall back to all pairs for stelliums)
|
||||||
|
const aspected = new Set();
|
||||||
|
(lastChart ? lastChart.aspects : d.aspects).forEach((a) => {
|
||||||
|
if (members.includes(a.a) && members.includes(a.b)) aspected.add(a.a + "|" + a.b);
|
||||||
|
});
|
||||||
|
for (let i = 0; i < members.length; i++) {
|
||||||
|
for (let j = i + 1; j < members.length; j++) {
|
||||||
|
const key1 = members[i] + "|" + members[j], key2 = members[j] + "|" + members[i];
|
||||||
|
if (p.type !== "Stellium" && !aspected.has(key1) && !aspected.has(key2)) continue;
|
||||||
|
const [x0, y0] = pt(lonOf[members[i]], 118), [x1, y1] = pt(lonOf[members[j]], 118);
|
||||||
|
g += `<line x1="${x0}" y1="${y0}" x2="${x1}" y2="${y1}" stroke="#e8c66a" stroke-width="2.5" opacity="0.95"/>`;
|
||||||
|
}
|
||||||
|
const [mx, my] = pt(lonOf[members[i]], 118);
|
||||||
|
g += `<circle cx="${mx}" cy="${my}" r="5" fill="#e8c66a"/>`;
|
||||||
|
}
|
||||||
|
g += `</g>`;
|
||||||
|
svg.insertAdjacentHTML("beforeend", g);
|
||||||
|
}
|
||||||
|
function clearHighlight(svg) {
|
||||||
|
if (!svg) return;
|
||||||
|
svg.classList.remove("pat-dim");
|
||||||
|
svg.querySelectorAll(".pat-hl").forEach((el) => el.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
// pattern explainers (mirrors the server's PATTERN_EXPLAINER, for instant popovers)
|
||||||
|
const PATTERN_TEXT = {
|
||||||
|
"Stellium": "Three or more planets gathered in one sign — a massive concentration of energy in a single style of being. Whatever this sign represents, you do it in bulk: it dominates the personality the way a lead instrument dominates a band. The life task is not to tone it down but to give it a worthy stage.",
|
||||||
|
"Grand Trine": "Three planets in mutual trine — a closed, self-sustaining circuit of talent, usually in one element. Energy flows around it effortlessly. Its famous danger is complacency: a gift so frictionless you may coast on it rather than build with it.",
|
||||||
|
"T-Square": "An opposition with both ends square a third planet — the apex, which takes constant pressure and becomes hyper-developed: the chart's engine room and its sore point at once. Most driven charts carry one.",
|
||||||
|
"Grand Cross": "Two oppositions crossing at right angles — four squares, maximum structural tension. Life demands you juggle all four agendas at once. Exhausting when young; forges extraordinary resilience.",
|
||||||
|
"Mystic Rectangle": "Two oppositions stabilised by trines and sextiles — tension wrapped in ease. Every hard line has a harmonious detour, so strain naturally finds a practical outlet. An unusually self-contained, problem-solving geometry.",
|
||||||
|
"Kite": "A grand trine with a fourth planet opposite one corner — the opposition gives the frictionless talent circuit a rudder and a target. The focal planet is where the gift discharges into the world.",
|
||||||
|
"Yod": "The 'Finger of God': two planets sextile, both quincunx a distant apex. A nagging, fated-feeling itch that ordinary effort never quite scratches — usually resolving as a calling discovered late.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- popover ---------------- */
|
||||||
|
function showPopover(x, y, html) {
|
||||||
|
const pop = $("#popover");
|
||||||
|
pop.innerHTML = `<button class="close" onclick="hidePopover()">✕</button>` + html;
|
||||||
|
pop.hidden = false;
|
||||||
|
const pad = 12;
|
||||||
|
const w = Math.min(380, window.innerWidth - 2 * pad);
|
||||||
|
pop.style.maxWidth = w + "px";
|
||||||
|
let left = x + 14, top = y + 14;
|
||||||
|
if (left + w + pad > window.innerWidth) left = Math.max(pad, x - w - 14);
|
||||||
|
pop.style.left = left + "px";
|
||||||
|
pop.style.top = Math.min(top, window.innerHeight - 100) + "px";
|
||||||
|
const r = pop.getBoundingClientRect();
|
||||||
|
if (r.bottom > window.innerHeight - pad) pop.style.top = Math.max(pad, window.innerHeight - r.height - pad) + "px";
|
||||||
|
}
|
||||||
|
function hidePopover() { $("#popover").hidden = true; }
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (!$("#popover").hidden &&
|
||||||
|
!e.target.closest("#popover") && !e.target.closest(".clickable") &&
|
||||||
|
!e.target.closest(".pat") && !e.target.closest("td.asp")) {
|
||||||
|
hidePopover();
|
||||||
|
document.querySelectorAll("#patterns .pat.active").forEach((x) => x.classList.remove("active"));
|
||||||
|
clearHighlight($("#wheel-box svg"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") hidePopover(); });
|
||||||
|
|
||||||
|
function bodyPopoverHTML(b, chart, personName) {
|
||||||
|
const dg = b.dignities && b.dignities.length ? ` · <span style="color:var(--gold)">${b.dignities.join(", ")}</span>` : "";
|
||||||
|
const rx = b.retrograde ? ' · <span style="color:var(--red)">℞ retrograde</span>' : "";
|
||||||
|
const house = chart && !chart.time_unknown && b.house ? ` · House ${b.house}` : "";
|
||||||
|
let asps = "";
|
||||||
|
if (chart) {
|
||||||
|
const rel = (chart.aspects || []).filter((a) => a.a === b.name || a.b === b.name).slice(0, 8);
|
||||||
|
if (rel.length) {
|
||||||
|
asps = "<p class='asprow'>" + rel.map((a) =>
|
||||||
|
`${a.a === b.name ? a.b : a.a} ${a.glyph} (${a.orb}°)`).join(" · ") + "</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `<h5>${b.glyph || ""} ${personName ? esc(personName) + "'s " : ""}${b.name}</h5>
|
||||||
|
<div class="meta">${b.position.text}${house}${rx}${dg}</div>
|
||||||
|
<p>${esc(b.about || "")}</p>${asps}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wheelClickHandler(e) {
|
||||||
|
const t = e.target;
|
||||||
|
if (t.dataset && t.dataset.body) {
|
||||||
|
let chart = lastChart, person = "";
|
||||||
|
if (this.id === "syn-wheel" && lastSyn) {
|
||||||
|
const base = synSwapped ? lastSyn.chart_b : lastSyn.chart_a;
|
||||||
|
const over = synSwapped ? lastSyn.chart_a : lastSyn.chart_b;
|
||||||
|
chart = t.dataset.src === "over" ? over : base;
|
||||||
|
person = chart.input.name;
|
||||||
|
}
|
||||||
|
const b = (chart.bodies || []).find((x) => x.name === t.dataset.body) ||
|
||||||
|
(chart.angles || []).find((x) => x.name === t.dataset.body);
|
||||||
|
if (b) showPopover(e.clientX, e.clientY, bodyPopoverHTML(b, chart, person));
|
||||||
|
} else if (t.dataset && t.dataset.a) {
|
||||||
|
const a = (lastChart.aspects || []).find((x) =>
|
||||||
|
(x.a === t.dataset.a && x.b === t.dataset.b) || (x.a === t.dataset.b && x.b === t.dataset.a));
|
||||||
|
if (a) showPopover(e.clientX, e.clientY,
|
||||||
|
`<h5>${a.a} ${a.glyph} ${a.b}</h5>
|
||||||
|
<div class="meta">${a.aspect} · orb ${a.orb}°${a.applying === null ? "" : a.applying ? " · applying" : " · separating"}</div>
|
||||||
|
<p>${esc(a.text || "")}</p>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$("#wheel-box").addEventListener("click", wheelClickHandler);
|
||||||
|
$("#syn-wheel").addEventListener("click", wheelClickHandler);
|
||||||
|
|
||||||
|
$("#aspect-grid").addEventListener("click", (e) => {
|
||||||
|
const td = e.target.closest("td.asp");
|
||||||
|
if (!td || !lastChart) return;
|
||||||
|
const a = lastChart.aspects.find((x) =>
|
||||||
|
(x.a === td.dataset.a && x.b === td.dataset.b) || (x.a === td.dataset.b && x.b === td.dataset.a));
|
||||||
|
if (a) showPopover(e.clientX, e.clientY,
|
||||||
|
`<h5>${a.a} ${a.glyph} ${a.b}</h5>
|
||||||
|
<div class="meta">${a.aspect} · orb ${a.orb}°</div><p>${esc(a.text || "")}</p>`);
|
||||||
|
});
|
||||||
|
$("#syn-grid").addEventListener("click", (e) => {
|
||||||
|
const td = e.target.closest("td.asp");
|
||||||
|
if (!td || !lastSyn) return;
|
||||||
|
const x = lastSyn.interaspects.find((z) => z.a === td.dataset.a && z.b === td.dataset.b);
|
||||||
|
if (x) showPopover(e.clientX, e.clientY,
|
||||||
|
`<h5>${x.a} ${x.glyph} ${x.b}</h5>
|
||||||
|
<div class="meta">${x.aspect} · orb ${x.orb}° · ${x.score >= 0 ? "harmonious" : "challenging"} (${x.score})</div>
|
||||||
|
<p>${esc(x.text || "")}</p>`);
|
||||||
|
});
|
||||||
|
|
||||||
/* ---------------- side panels ---------------- */
|
/* ---------------- side panels ---------------- */
|
||||||
function renderBalances(d) {
|
function renderBalances(d) {
|
||||||
const b = d.balances;
|
const b = d.balances;
|
||||||
@ -385,7 +561,7 @@ function renderAspects(d) {
|
|||||||
h += "<tr>" + `<th>${glyph[names[i]]}</th>`;
|
h += "<tr>" + `<th>${glyph[names[i]]}</th>`;
|
||||||
for (let j = 0; j < i; j++) {
|
for (let j = 0; j < i; j++) {
|
||||||
const a = cell[names[i] + "|" + names[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 += a ? `<td class="asp ${cls(a)}" data-a="${a.a}" data-b="${a.b}" style="cursor:pointer" title="${a.a} ${a.aspect} ${a.b} · orb ${a.orb}°">${a.glyph}</td>` : "<td></td>";
|
||||||
}
|
}
|
||||||
h += "</tr>";
|
h += "</tr>";
|
||||||
}
|
}
|
||||||
@ -563,7 +739,7 @@ function renderSynastry(d0, scroll = true) {
|
|||||||
h += `<tr><th title="${pa}">${glyphOf[pa] || pa}</th>`;
|
h += `<tr><th title="${pa}">${glyphOf[pa] || pa}</th>`;
|
||||||
ptsB.forEach((pb) => {
|
ptsB.forEach((pb) => {
|
||||||
const x = cell[pa + "|" + 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 += x ? `<td class="asp ${cls(x)}" data-a="${x.a}" data-b="${x.b}" data-syn="1" style="cursor:pointer" title="${nameA}'s ${x.a} ${x.aspect} ${nameB}'s ${x.b} · orb ${x.orb}°">${x.glyph}</td>` : "<td></td>";
|
||||||
});
|
});
|
||||||
h += "</tr>";
|
h += "</tr>";
|
||||||
});
|
});
|
||||||
|
|||||||
@ -92,6 +92,7 @@
|
|||||||
<div id="tab-wheel" class="tab active">
|
<div id="tab-wheel" class="tab active">
|
||||||
<div id="wheel-box"></div>
|
<div id="wheel-box"></div>
|
||||||
<div id="wheel-side">
|
<div id="wheel-side">
|
||||||
|
<div id="patterns"></div>
|
||||||
<div id="balances"></div>
|
<div id="balances"></div>
|
||||||
<div id="moonphase"></div>
|
<div id="moonphase"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -142,6 +143,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div id="popover" hidden></div>
|
||||||
<footer>NATEGODD · geocentric tropical zodiac · Swiss Ephemeris · IANA historical time atlas</footer>
|
<footer>NATEGODD · geocentric tropical zodiac · Swiss Ephemeris · IANA historical time atlas</footer>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -153,6 +153,31 @@ td.dign { color: var(--gold); font-size: 12px; }
|
|||||||
#aspect-ref .item { margin-bottom: 14px; }
|
#aspect-ref .item { margin-bottom: 14px; }
|
||||||
#aspect-ref small { color: var(--dim); margin-left: 8px; }
|
#aspect-ref small { color: var(--dim); margin-left: 8px; }
|
||||||
|
|
||||||
|
#patterns .pat {
|
||||||
|
border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; margin-bottom: 8px;
|
||||||
|
cursor: pointer; transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
#patterns .pat:hover, #patterns .pat.active { border-color: var(--gold); background: #1a1830; }
|
||||||
|
#patterns .pat b { color: var(--gold); font-weight: 500; }
|
||||||
|
#patterns .pat small { display: block; color: var(--dim); }
|
||||||
|
#patterns .none { color: var(--dim); font-size: 13px; }
|
||||||
|
|
||||||
|
#popover {
|
||||||
|
position: fixed; z-index: 100; max-width: 380px; max-height: 60vh; overflow-y: auto;
|
||||||
|
background: #1a1a2ef2; border: 1px solid var(--gold); border-radius: 10px;
|
||||||
|
padding: 14px 16px; font-size: 13px; line-height: 1.5; box-shadow: 0 8px 30px #000c;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
#popover h5 { margin: 0 0 6px; color: var(--gold); font-size: 15px; font-weight: 500; letter-spacing: 1px; }
|
||||||
|
#popover .meta { color: var(--dim); margin-bottom: 8px; }
|
||||||
|
#popover p { margin: 0 0 8px; }
|
||||||
|
#popover .asprow { color: var(--dim); font-size: 12px; }
|
||||||
|
#popover .close { position: sticky; float: right; top: 0; background: none; border: none; color: var(--dim); font-size: 16px; padding: 0 0 4px 10px; }
|
||||||
|
|
||||||
|
svg .clickable { cursor: pointer; }
|
||||||
|
svg.pat-dim line.asp-line { opacity: 0.06 !important; }
|
||||||
|
svg .pat-hl { pointer-events: none; }
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
body { background: #fff; color: #000; font-size: 12px; }
|
body { background: #fff; color: #000; font-size: 12px; }
|
||||||
header, #form-card, #tabs, footer, #print-btn, #toolbar, #layers, #syn-picker, #syn-print, #syn-swap { display: none !important; }
|
header, #form-card, #tabs, footer, #print-btn, #toolbar, #layers, #syn-picker, #syn-print, #syn-swap { display: none !important; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user