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 += ``; + s += ``; }); } - 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) { s += ``; - 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 += ``; @@ -321,7 +323,7 @@ function drawRing(bodies, pt, o) { 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, o.degR); s += `${p.deg}°${String(p.min).padStart(2, "0")}${b.retrograde ? "℞" : ""}`; @@ -329,6 +331,180 @@ function drawRing(bodies, pt, o) { return s; } +/* ---------------- aspect patterns panel + wheel highlight ---------------- */ +function renderPatterns(d) { + const box = $("#patterns"); + let h = "

Aspect Patterns

"; + if (!d.patterns || !d.patterns.length) { + h += '

No major configurations detected.

'; + } else { + d.patterns.forEach((p, i) => { + h += `
${p.type}${p.flavor ? " · " + esc(p.flavor) : ""} + ${p.members.join(" – ")}
`; + }); + } + 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, + `
${p.type}${p.flavor ? " · " + esc(p.flavor) : ""}
+
${p.members.join(" – ")}

${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 = ``; + // 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 += ``; + } + const [mx, my] = pt(lonOf[members[i]], 118); + 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 = `` + 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 ? ` · ${b.dignities.join(", ")}` : ""; + const rx = b.retrograde ? ' · ℞ retrograde' : ""; + 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 = "

" + rel.map((a) => + `${a.a === b.name ? a.b : a.a} ${a.glyph} (${a.orb}°)`).join(" · ") + "

"; + } + } + return `
${b.glyph || ""} ${personName ? esc(personName) + "'s " : ""}${b.name}
+
${b.position.text}${house}${rx}${dg}
+

${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, + `
${a.a} ${a.glyph} ${a.b}
+
${a.aspect} · orb ${a.orb}°${a.applying === null ? "" : a.applying ? " · applying" : " · separating"}
+

${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, + `
${a.a} ${a.glyph} ${a.b}
+
${a.aspect} · orb ${a.orb}°

${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, + `
${x.a} ${x.glyph} ${x.b}
+
${x.aspect} · orb ${x.orb}° · ${x.score >= 0 ? "harmonious" : "challenging"} (${x.score})
+

${esc(x.text || "")}

`); +}); + /* ---------------- side panels ---------------- */ function renderBalances(d) { const b = d.balances; @@ -385,7 +561,7 @@ function renderAspects(d) { h += "" + `${glyph[names[i]]}`; for (let j = 0; j < i; j++) { const a = cell[names[i] + "|" + names[j]]; - h += a ? `${a.glyph}` : ""; + h += a ? `${a.glyph}` : ""; } h += ""; } @@ -563,7 +739,7 @@ function renderSynastry(d0, scroll = true) { h += `${glyphOf[pa] || pa}`; ptsB.forEach((pb) => { const x = cell[pa + "|" + pb]; - h += x ? `${x.glyph}` : ""; + h += x ? `${x.glyph}` : ""; }); h += ""; }); diff --git a/static/index.html b/static/index.html index b0f2b02..3e6d9df 100644 --- a/static/index.html +++ b/static/index.html @@ -92,6 +92,7 @@
+
@@ -142,6 +143,7 @@ +
NATEGODD · geocentric tropical zodiac · Swiss Ephemeris · IANA historical time atlas
diff --git a/static/style.css b/static/style.css index 85c1857..73f35c9 100644 --- a/static/style.css +++ b/static/style.css @@ -153,6 +153,31 @@ td.dign { color: var(--gold); font-size: 12px; } #aspect-ref .item { margin-bottom: 14px; } #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 { 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; }