NATEGODD: Swiss Ephemeris natal chart generator
Full-detail natal charts: 21 bodies/points, 7 house systems, 11 aspect types with applying/separating, dignities, declination parallels, sect/balances/moon phase, offline city search with historical IANA time atlas, SVG wheel, and a written interpretation report. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
1be39727f8
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
45
README.md
Normal file
45
README.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# NATEGODD
|
||||||
|
|
||||||
|
Natal chart generator in the spirit of the great 90s desktop astrology programs
|
||||||
|
(Win\*Star / Solar Fire era) — full detail, real ephemeris, written report.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Swiss Ephemeris** computation (bundled `ephe/` files, valid 1800–2400 AD):
|
||||||
|
Sun–Pluto, Chiron, Ceres, Pallas, Juno, Vesta, True North/South Node,
|
||||||
|
Mean Lilith, Part of Fortune, Vertex, ASC/MC/DSC/IC.
|
||||||
|
- **Historical time atlas**: offline city search (25k cities via geonamescache),
|
||||||
|
timezonefinder + IANA tzdata for correct historical offsets and DST
|
||||||
|
(wartime double summer time and all). Manual lat/lon/offset override available.
|
||||||
|
- **7 house systems**: Placidus, Koch, Whole Sign, Equal, Porphyry, Campanus, Regiomontanus.
|
||||||
|
- **Aspects**: 11 aspect types with per-body orbs, luminary bonus, applying/separating,
|
||||||
|
triangular aspect grid, declination parallels/contraparallels.
|
||||||
|
- **Detail**: retrogrades, essential dignities (domicile/exaltation/detriment/fall),
|
||||||
|
element/modality/polarity balances, day/night sect, moon phase + illumination.
|
||||||
|
- **Interpretation report**: hand-written Big Three texts (Sun/Moon/Rising × 12) plus a
|
||||||
|
keyword-synthesis engine for every planet-in-sign, planet-in-house and aspect. Printable.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
.venv/bin/python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:7799.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `chart.py` — ephemeris computation engine
|
||||||
|
- `geo.py` — city search + historical timezone resolution
|
||||||
|
- `interp.py` — interpretation texts + synthesis engine
|
||||||
|
- `app.py` — Flask server (`/api/search`, `/api/chart`)
|
||||||
|
- `static/` — single-page frontend (vanilla JS, SVG wheel)
|
||||||
|
- `ephe/` — Swiss Ephemeris data files (sepl/semo/seas 1800–2400)
|
||||||
|
|
||||||
|
Ephemeris data files are from the official Swiss Ephemeris distribution (AGPL,
|
||||||
|
astro.com / github.com/aloistr/swisseph).
|
||||||
64
app.py
Normal file
64
app.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"""NATEGODD — natal chart generator. Run: .venv/bin/python app.py → http://localhost:7799"""
|
||||||
|
from flask import Flask, jsonify, request, send_from_directory
|
||||||
|
|
||||||
|
import chart as chart_engine
|
||||||
|
import geo
|
||||||
|
import interp
|
||||||
|
|
||||||
|
app = Flask(__name__, static_folder="static", static_url_path="")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index():
|
||||||
|
return send_from_directory("static", "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/search")
|
||||||
|
def api_search():
|
||||||
|
q = request.args.get("q", "")
|
||||||
|
return jsonify(geo.search_cities(q))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/chart")
|
||||||
|
def api_chart():
|
||||||
|
d = request.get_json(force=True)
|
||||||
|
try:
|
||||||
|
year, month, day = int(d["year"]), int(d["month"]), int(d["day"])
|
||||||
|
time_unknown = bool(d.get("time_unknown"))
|
||||||
|
hour = 12 if time_unknown else int(d.get("hour", 12))
|
||||||
|
minute = 0 if time_unknown else int(d.get("minute", 0))
|
||||||
|
second = int(d.get("second", 0) or 0)
|
||||||
|
lat, lon = float(d["lat"]), float(d["lon"])
|
||||||
|
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
|
||||||
|
|
||||||
|
if d.get("utc_offset") not in (None, ""):
|
||||||
|
offset, tzname = float(d["utc_offset"]), "manual"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
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}",
|
||||||
|
}
|
||||||
|
result["interpretation"] = interp.build_interpretation(result)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=7799, debug=False)
|
||||||
293
chart.py
Normal file
293
chart.py
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
"""NATEGODD chart engine — Swiss Ephemeris natal chart computation."""
|
||||||
|
import os
|
||||||
|
import swisseph as swe
|
||||||
|
|
||||||
|
swe.set_ephe_path(os.path.join(os.path.dirname(os.path.abspath(__file__)), "ephe"))
|
||||||
|
|
||||||
|
FLG = swe.FLG_SWIEPH | swe.FLG_SPEED
|
||||||
|
|
||||||
|
SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||||
|
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
|
||||||
|
SIGN_GLYPHS = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"]
|
||||||
|
ELEMENTS = ["Fire", "Earth", "Air", "Water"] * 3 # index by sign % 4... careful: use sign_index % 4 mapping below
|
||||||
|
SIGN_ELEMENT = ["Fire", "Earth", "Air", "Water"] # sign i -> SIGN_ELEMENT[i % 4]
|
||||||
|
SIGN_MODE = ["Cardinal", "Fixed", "Mutable"] # sign i -> SIGN_MODE[i % 3]
|
||||||
|
|
||||||
|
BODIES = [
|
||||||
|
("Sun", swe.SUN, "☉"),
|
||||||
|
("Moon", swe.MOON, "☽"),
|
||||||
|
("Mercury", swe.MERCURY, "☿"),
|
||||||
|
("Venus", swe.VENUS, "♀"),
|
||||||
|
("Mars", swe.MARS, "♂"),
|
||||||
|
("Jupiter", swe.JUPITER, "♃"),
|
||||||
|
("Saturn", swe.SATURN, "♄"),
|
||||||
|
("Uranus", swe.URANUS, "♅"),
|
||||||
|
("Neptune", swe.NEPTUNE, "♆"),
|
||||||
|
("Pluto", swe.PLUTO, "♇"),
|
||||||
|
("Chiron", swe.CHIRON, "⚷"),
|
||||||
|
("Ceres", swe.CERES, "⚳"),
|
||||||
|
("Pallas", swe.PALLAS, "⚴"),
|
||||||
|
("Juno", swe.JUNO, "⚵"),
|
||||||
|
("Vesta", swe.VESTA, "⚶"),
|
||||||
|
("North Node", swe.TRUE_NODE, "☊"),
|
||||||
|
("Lilith", swe.MEAN_APOG, "⚸"),
|
||||||
|
]
|
||||||
|
|
||||||
|
HOUSE_SYSTEMS = {
|
||||||
|
"P": "Placidus", "K": "Koch", "W": "Whole Sign", "E": "Equal",
|
||||||
|
"O": "Porphyry", "C": "Campanus", "R": "Regiomontanus",
|
||||||
|
}
|
||||||
|
|
||||||
|
# aspect name -> (angle, base orb, is_major)
|
||||||
|
ASPECTS = {
|
||||||
|
"Conjunction": (0, 8.0, True),
|
||||||
|
"Opposition": (180, 8.0, True),
|
||||||
|
"Trine": (120, 7.0, True),
|
||||||
|
"Square": (90, 7.0, True),
|
||||||
|
"Sextile": (60, 5.0, True),
|
||||||
|
"Quincunx": (150, 3.0, False),
|
||||||
|
"Semisextile": (30, 2.0, False),
|
||||||
|
"Semisquare": (45, 2.0, False),
|
||||||
|
"Sesquiquadrate": (135, 2.0, False),
|
||||||
|
"Quintile": (72, 1.5, False),
|
||||||
|
"Biquintile": (144, 1.5, False),
|
||||||
|
}
|
||||||
|
ASPECT_GLYPHS = {
|
||||||
|
"Conjunction": "☌", "Opposition": "☍", "Trine": "△", "Square": "□",
|
||||||
|
"Sextile": "⚹", "Quincunx": "⚻", "Semisextile": "⚺", "Semisquare": "∠",
|
||||||
|
"Sesquiquadrate": "⚼", "Quintile": "Q", "Biquintile": "bQ",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Essential dignities (traditional rulers; modern rulers noted separately)
|
||||||
|
RULERS = { # sign index -> traditional ruler
|
||||||
|
0: "Mars", 1: "Venus", 2: "Mercury", 3: "Moon", 4: "Sun", 5: "Mercury",
|
||||||
|
6: "Venus", 7: "Mars", 8: "Jupiter", 9: "Saturn", 10: "Saturn", 11: "Jupiter",
|
||||||
|
}
|
||||||
|
MODERN_RULERS = {7: "Pluto", 10: "Uranus", 11: "Neptune"}
|
||||||
|
EXALTATIONS = {"Sun": 0, "Moon": 1, "Mercury": 5, "Venus": 11, "Mars": 9,
|
||||||
|
"Jupiter": 3, "Saturn": 6, "North Node": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def norm(deg):
|
||||||
|
return deg % 360.0
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_dms(lon):
|
||||||
|
"""248.37 -> {'sign': 'Sagittarius', 'deg': 8, 'min': 22, 'sec': 12, 'text': "8°22'12\" Sagittarius"}"""
|
||||||
|
lon = norm(lon)
|
||||||
|
si = int(lon // 30)
|
||||||
|
rem = lon - si * 30
|
||||||
|
d = int(rem)
|
||||||
|
m_f = (rem - d) * 60
|
||||||
|
m = int(m_f)
|
||||||
|
s = int(round((m_f - m) * 60))
|
||||||
|
if s == 60:
|
||||||
|
s = 0
|
||||||
|
m += 1
|
||||||
|
if m == 60:
|
||||||
|
m = 0
|
||||||
|
d += 1
|
||||||
|
return {"sign": SIGNS[si], "sign_glyph": SIGN_GLYPHS[si], "sign_index": si,
|
||||||
|
"deg": d, "min": m, "sec": s,
|
||||||
|
"text": f"{d}°{m:02d}'{s:02d}\" {SIGNS[si]}"}
|
||||||
|
|
||||||
|
|
||||||
|
def house_of(lon, cusps):
|
||||||
|
"""Which house (1-12) a longitude falls in, given 12 cusp longitudes."""
|
||||||
|
lon = norm(lon)
|
||||||
|
for i in range(12):
|
||||||
|
a, b = cusps[i], cusps[(i + 1) % 12]
|
||||||
|
if a <= b:
|
||||||
|
if a <= lon < b:
|
||||||
|
return i + 1
|
||||||
|
else: # wraps 360
|
||||||
|
if lon >= a or lon < b:
|
||||||
|
return i + 1
|
||||||
|
return 12
|
||||||
|
|
||||||
|
|
||||||
|
def dignity(name, sign_index):
|
||||||
|
out = []
|
||||||
|
if RULERS.get(sign_index) == name:
|
||||||
|
out.append("Domicile")
|
||||||
|
if MODERN_RULERS.get(sign_index) == name:
|
||||||
|
out.append("Domicile (modern)")
|
||||||
|
if RULERS.get((sign_index + 6) % 12) == name or MODERN_RULERS.get((sign_index + 6) % 12) == name:
|
||||||
|
out.append("Detriment")
|
||||||
|
if EXALTATIONS.get(name) == sign_index:
|
||||||
|
out.append("Exaltation")
|
||||||
|
if name in EXALTATIONS and EXALTATIONS[name] == (sign_index + 6) % 12:
|
||||||
|
out.append("Fall")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def compute_chart(year, month, day, hour, minute, second, ut_offset_hours,
|
||||||
|
lat, lon, house_system="P", time_unknown=False):
|
||||||
|
"""All angles in degrees. ut_offset_hours: local = UT + offset (e.g. +10 for AEST)."""
|
||||||
|
ut_hour = hour + minute / 60.0 + second / 3600.0 - ut_offset_hours
|
||||||
|
jd = swe.julday(year, month, day, ut_hour)
|
||||||
|
if time_unknown:
|
||||||
|
house_system = "W" # degrees meaningless for angles; solar whole-sign fallback handled below
|
||||||
|
|
||||||
|
hs = house_system.encode() if isinstance(house_system, str) else house_system
|
||||||
|
cusps_raw, ascmc = swe.houses(jd, lat, lon, hs)
|
||||||
|
cusps = [norm(c) for c in cusps_raw[:12]]
|
||||||
|
asc, mc = norm(ascmc[0]), norm(ascmc[1])
|
||||||
|
vertex = norm(ascmc[3])
|
||||||
|
|
||||||
|
bodies = []
|
||||||
|
lons = {}
|
||||||
|
speeds = {}
|
||||||
|
for name, pid, glyph in BODIES:
|
||||||
|
try:
|
||||||
|
pos, _ = swe.calc_ut(jd, pid, FLG)
|
||||||
|
eq, _ = swe.calc_ut(jd, pid, FLG | swe.FLG_EQUATORIAL)
|
||||||
|
except swe.Error:
|
||||||
|
continue
|
||||||
|
L = norm(pos[0])
|
||||||
|
lons[name] = L
|
||||||
|
speeds[name] = pos[3]
|
||||||
|
bodies.append({
|
||||||
|
"name": name, "glyph": glyph, "lon": L, "lat": pos[1],
|
||||||
|
"speed": pos[3], "retrograde": pos[3] < 0,
|
||||||
|
"declination": eq[1],
|
||||||
|
"position": fmt_dms(L),
|
||||||
|
"house": house_of(L, cusps),
|
||||||
|
"dignities": dignity(name, int(L // 30)),
|
||||||
|
})
|
||||||
|
|
||||||
|
# South Node = opposite the North Node
|
||||||
|
if "North Node" in lons:
|
||||||
|
sn = norm(lons["North Node"] + 180)
|
||||||
|
bodies.append({"name": "South Node", "glyph": "☋", "lon": sn, "lat": 0.0,
|
||||||
|
"speed": speeds["North Node"], "retrograde": speeds["North Node"] < 0,
|
||||||
|
"declination": None, "position": fmt_dms(sn),
|
||||||
|
"house": house_of(sn, cusps), "dignities": []})
|
||||||
|
lons["South Node"] = sn
|
||||||
|
|
||||||
|
# Day/night chart: Sun in houses 7-12 (above horizon) = day
|
||||||
|
sun_house = house_of(lons["Sun"], cusps) if "Sun" in lons else 1
|
||||||
|
is_day = 7 <= sun_house <= 12
|
||||||
|
|
||||||
|
# Part of Fortune
|
||||||
|
if "Sun" in lons and "Moon" in lons:
|
||||||
|
pof = norm(asc + lons["Moon"] - lons["Sun"]) if is_day else norm(asc + lons["Sun"] - lons["Moon"])
|
||||||
|
bodies.append({"name": "Part of Fortune", "glyph": "⊗", "lon": pof, "lat": 0.0,
|
||||||
|
"speed": 0.0, "retrograde": False, "declination": None,
|
||||||
|
"position": fmt_dms(pof), "house": house_of(pof, cusps), "dignities": []})
|
||||||
|
lons["Part of Fortune"] = pof
|
||||||
|
|
||||||
|
angles = [
|
||||||
|
{"name": "Ascendant", "glyph": "AC", "lon": asc, "position": fmt_dms(asc)},
|
||||||
|
{"name": "Midheaven", "glyph": "MC", "lon": mc, "position": fmt_dms(mc)},
|
||||||
|
{"name": "Descendant", "glyph": "DC", "lon": norm(asc + 180), "position": fmt_dms(norm(asc + 180))},
|
||||||
|
{"name": "Imum Coeli", "glyph": "IC", "lon": norm(mc + 180), "position": fmt_dms(norm(mc + 180))},
|
||||||
|
{"name": "Vertex", "glyph": "Vx", "lon": vertex, "position": fmt_dms(vertex)},
|
||||||
|
]
|
||||||
|
lons["Ascendant"] = asc
|
||||||
|
lons["Midheaven"] = mc
|
||||||
|
|
||||||
|
aspects = compute_aspects(lons, speeds, time_unknown)
|
||||||
|
|
||||||
|
# Balances (classic 10 planets only, weighted)
|
||||||
|
weights = {"Sun": 2, "Moon": 2, "Mercury": 1, "Venus": 1, "Mars": 1,
|
||||||
|
"Jupiter": 1, "Saturn": 1, "Uranus": 1, "Neptune": 1, "Pluto": 1}
|
||||||
|
elem = {"Fire": 0, "Earth": 0, "Air": 0, "Water": 0}
|
||||||
|
mode = {"Cardinal": 0, "Fixed": 0, "Mutable": 0}
|
||||||
|
polarity = {"Positive": 0, "Negative": 0}
|
||||||
|
for name, w in weights.items():
|
||||||
|
if name not in lons:
|
||||||
|
continue
|
||||||
|
si = int(lons[name] // 30)
|
||||||
|
elem[SIGN_ELEMENT[si % 4]] += w
|
||||||
|
mode[SIGN_MODE[si % 3]] += w
|
||||||
|
polarity["Positive" if si % 2 == 0 else "Negative"] += w
|
||||||
|
|
||||||
|
# Declination parallels / contraparallels (orb 1°)
|
||||||
|
decls = {b["name"]: b["declination"] for b in bodies if b["declination"] is not None}
|
||||||
|
parallels = []
|
||||||
|
names_d = list(decls)
|
||||||
|
for i in range(len(names_d)):
|
||||||
|
for j in range(i + 1, len(names_d)):
|
||||||
|
d1, d2 = decls[names_d[i]], decls[names_d[j]]
|
||||||
|
if abs(d1 - d2) <= 1.0:
|
||||||
|
parallels.append({"a": names_d[i], "b": names_d[j], "type": "Parallel",
|
||||||
|
"orb": round(abs(d1 - d2), 2)})
|
||||||
|
elif abs(d1 + d2) <= 1.0:
|
||||||
|
parallels.append({"a": names_d[i], "b": names_d[j], "type": "Contraparallel",
|
||||||
|
"orb": round(abs(d1 + d2), 2)})
|
||||||
|
|
||||||
|
# Moon phase
|
||||||
|
phase = None
|
||||||
|
if "Sun" in lons and "Moon" in lons:
|
||||||
|
d = norm(lons["Moon"] - lons["Sun"])
|
||||||
|
phases = ["New Moon", "Waxing Crescent", "First Quarter", "Waxing Gibbous",
|
||||||
|
"Full Moon", "Waning Gibbous", "Last Quarter", "Waning Crescent"]
|
||||||
|
phase = {"angle": round(d, 2), "name": phases[int(((d + 22.5) % 360) // 45)],
|
||||||
|
"illumination": round((1 - __import__("math").cos(__import__("math").radians(d))) / 2 * 100, 1)}
|
||||||
|
|
||||||
|
houses = [{"num": i + 1, "lon": c, "position": fmt_dms(c)} for i, c in enumerate(cusps)]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"julian_day": jd,
|
||||||
|
"house_system": HOUSE_SYSTEMS.get(house_system, house_system),
|
||||||
|
"time_unknown": time_unknown,
|
||||||
|
"is_day_chart": is_day,
|
||||||
|
"bodies": bodies,
|
||||||
|
"angles": angles,
|
||||||
|
"houses": houses,
|
||||||
|
"aspects": aspects,
|
||||||
|
"parallels": parallels,
|
||||||
|
"balances": {"elements": elem, "modalities": mode, "polarities": polarity},
|
||||||
|
"moon_phase": phase,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_aspects(lons, speeds, time_unknown=False):
|
||||||
|
pts = [p for p in ASPECT_POINTS if p in lons]
|
||||||
|
if time_unknown:
|
||||||
|
pts = [p for p in pts if p not in ("Ascendant", "Midheaven", "Part of Fortune")]
|
||||||
|
out = []
|
||||||
|
for i in range(len(pts)):
|
||||||
|
for j in range(i + 1, len(pts)):
|
||||||
|
a, b = pts[i], pts[j]
|
||||||
|
if {a, b} == {"North Node", "South Node"}:
|
||||||
|
continue
|
||||||
|
sep = abs(norm(lons[a]) - norm(lons[b]))
|
||||||
|
if sep > 180:
|
||||||
|
sep = 360 - sep
|
||||||
|
best = None
|
||||||
|
for name, (angle, orb, major) in ASPECTS.items():
|
||||||
|
o = orb
|
||||||
|
if major and (a in LUMINARIES or b in LUMINARIES):
|
||||||
|
o += 1.0
|
||||||
|
if (a in MINOR_POINTS or b in MINOR_POINTS):
|
||||||
|
o = min(o, 3.0)
|
||||||
|
if a in ("Ascendant", "Midheaven") or b in ("Ascendant", "Midheaven"):
|
||||||
|
o = min(o, 6.0) if major else min(o, 2.0)
|
||||||
|
diff = abs(sep - angle)
|
||||||
|
if diff <= o and (best is None or diff < best[1]):
|
||||||
|
best = (name, diff, angle, major)
|
||||||
|
if best:
|
||||||
|
name, orbv, angle, major = best
|
||||||
|
sa, sb = speeds.get(a, 0.0), speeds.get(b, 0.0)
|
||||||
|
applying = None
|
||||||
|
if sa or sb:
|
||||||
|
# faster body approaching exact angle?
|
||||||
|
cur = norm(lons[a] - lons[b])
|
||||||
|
if cur > 180:
|
||||||
|
cur = 360 - cur
|
||||||
|
rel = -(sa - sb)
|
||||||
|
else:
|
||||||
|
rel = sa - sb
|
||||||
|
applying = (cur < angle and rel > 0) or (cur > angle and rel < 0)
|
||||||
|
out.append({"a": a, "b": b, "aspect": name, "glyph": ASPECT_GLYPHS[name],
|
||||||
|
"angle": angle, "orb": round(orbv, 2), "major": major,
|
||||||
|
"applying": applying})
|
||||||
|
out.sort(key=lambda x: (not x["major"], x["orb"]))
|
||||||
|
return out
|
||||||
BIN
ephe/seas_18.se1
Normal file
BIN
ephe/seas_18.se1
Normal file
Binary file not shown.
BIN
ephe/semo_18.se1
Normal file
BIN
ephe/semo_18.se1
Normal file
Binary file not shown.
BIN
ephe/sepl_18.se1
Normal file
BIN
ephe/sepl_18.se1
Normal file
Binary file not shown.
67
geo.py
Normal file
67
geo.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""City search (offline, geonamescache) + historical timezone resolution."""
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import geonamescache
|
||||||
|
from timezonefinder import TimezoneFinder
|
||||||
|
|
||||||
|
_gc = geonamescache.GeonamesCache()
|
||||||
|
_tf = TimezoneFinder()
|
||||||
|
_countries = {c["iso"]: c["name"] for c in _gc.get_countries().values()}
|
||||||
|
|
||||||
|
_CITY_INDEX = None
|
||||||
|
|
||||||
|
|
||||||
|
def _index():
|
||||||
|
global _CITY_INDEX
|
||||||
|
if _CITY_INDEX is None:
|
||||||
|
rows = []
|
||||||
|
for c in _gc.get_cities().values():
|
||||||
|
rows.append({
|
||||||
|
"name": c["name"],
|
||||||
|
"search": c["name"].lower(),
|
||||||
|
"alt": [a.lower() for a in c.get("alternatenames", [])[:20]],
|
||||||
|
"country": _countries.get(c["countrycode"], c["countrycode"]),
|
||||||
|
"cc": c["countrycode"],
|
||||||
|
"admin": c.get("admin1code", ""),
|
||||||
|
"lat": c["latitude"],
|
||||||
|
"lon": c["longitude"],
|
||||||
|
"pop": c["population"],
|
||||||
|
"tz": c["timezone"],
|
||||||
|
})
|
||||||
|
rows.sort(key=lambda r: -r["pop"])
|
||||||
|
_CITY_INDEX = rows
|
||||||
|
return _CITY_INDEX
|
||||||
|
|
||||||
|
|
||||||
|
def search_cities(q, limit=12):
|
||||||
|
q = q.strip().lower()
|
||||||
|
if len(q) < 2:
|
||||||
|
return []
|
||||||
|
starts, contains = [], []
|
||||||
|
for r in _index():
|
||||||
|
if r["search"].startswith(q):
|
||||||
|
starts.append(r)
|
||||||
|
elif q in r["search"] or any(a.startswith(q) for a in r["alt"]):
|
||||||
|
contains.append(r)
|
||||||
|
if len(starts) >= limit:
|
||||||
|
break
|
||||||
|
rows = (starts + contains)[:limit]
|
||||||
|
return [{"name": r["name"], "country": r["country"], "cc": r["cc"],
|
||||||
|
"admin": r["admin"], "lat": r["lat"], "lon": r["lon"], "tz": r["tz"],
|
||||||
|
"label": f'{r["name"]}, {r["country"]}'} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_offset(lat, lon, year, month, day, hour, minute, tz_hint=None):
|
||||||
|
"""Return (utc_offset_hours, tz_name) for the LOCAL wall-clock birth time.
|
||||||
|
|
||||||
|
Uses the IANA tz database, which carries historical offsets and DST rules —
|
||||||
|
the same thing the 90s programs shipped as their 'atlas'.
|
||||||
|
"""
|
||||||
|
tzname = tz_hint or _tf.timezone_at(lat=lat, lng=lon)
|
||||||
|
if not tzname:
|
||||||
|
tzname = _tf.closest_timezone_at(lat=lat, lng=lon) if hasattr(_tf, "closest_timezone_at") else "UTC"
|
||||||
|
tz = ZoneInfo(tzname)
|
||||||
|
local = datetime(year, month, day, hour, minute, tzinfo=tz)
|
||||||
|
off = local.utcoffset().total_seconds() / 3600.0
|
||||||
|
return off, tzname
|
||||||
270
interp.py
Normal file
270
interp.py
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
"""NATEGODD interpretation engine.
|
||||||
|
|
||||||
|
Hand-written texts for the big three (Sun / Moon / Ascendant in each sign),
|
||||||
|
plus a keyword-synthesis engine that generates readable prose for every
|
||||||
|
planet-in-sign, planet-in-house and aspect combination — the same trick the
|
||||||
|
90s desktop programs used, with a bigger vocabulary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||||
|
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
|
||||||
|
|
||||||
|
PLANET_MEANING = {
|
||||||
|
"Sun": ("your core identity and vital force", "identity"),
|
||||||
|
"Moon": ("your emotional nature and instinctive needs", "feelings"),
|
||||||
|
"Mercury": ("how you think, learn and communicate", "mind"),
|
||||||
|
"Venus": ("how you love, relate and what you find beautiful", "affections"),
|
||||||
|
"Mars": ("your drive, desire and how you assert yourself", "will"),
|
||||||
|
"Jupiter": ("where you expand, trust and find meaning", "growth"),
|
||||||
|
"Saturn": ("where you meet limits, discipline and mastery", "structure"),
|
||||||
|
"Uranus": ("where you break rules and demand freedom", "individuality"),
|
||||||
|
"Neptune": ("where you dream, idealise and dissolve boundaries", "imagination"),
|
||||||
|
"Pluto": ("where you transform, purge and confront power", "depths"),
|
||||||
|
"Chiron": ("your deepest wound and your gift for healing others", "wound"),
|
||||||
|
"Ceres": ("how you nurture and wish to be nurtured", "care"),
|
||||||
|
"Pallas": ("your style of wisdom, strategy and pattern-seeing", "strategy"),
|
||||||
|
"Juno": ("what you need from committed partnership", "commitment"),
|
||||||
|
"Vesta": ("what you devote yourself to with sacred focus", "devotion"),
|
||||||
|
"North Node": ("the direction of growth your life keeps pulling you toward", "destiny"),
|
||||||
|
"South Node": ("the well-worn gifts and habits you are meant to grow beyond", "past"),
|
||||||
|
"Lilith": ("your untamed, uncompromising instinct", "wildness"),
|
||||||
|
"Part of Fortune": ("where circumstance tends to reward you", "fortune"),
|
||||||
|
"Ascendant": ("the face you show the world and how life approaches you", "persona"),
|
||||||
|
"Midheaven": ("your public role, vocation and reputation", "calling"),
|
||||||
|
}
|
||||||
|
|
||||||
|
SIGN_STYLE = {
|
||||||
|
"Aries": ("directly, impulsively and with courage", "a pioneer's urgency — first in, fast, and unafraid of a fight",
|
||||||
|
"impatience and starting more than you finish"),
|
||||||
|
"Taurus": ("steadily, sensually and with great persistence", "a builder's patience — slow to start, impossible to stop",
|
||||||
|
"stubbornness and resistance to any change you didn't choose"),
|
||||||
|
"Gemini": ("curiously, quickly and through words", "a storyteller's agility — collecting, connecting and re-telling everything",
|
||||||
|
"scatter, restlessness and skating on the surface"),
|
||||||
|
"Cancer": ("protectively, intuitively and from memory", "a caretaker's tenacity — sideways approaches and a long, long memory",
|
||||||
|
"moodiness and retreating into the shell"),
|
||||||
|
"Leo": ("dramatically, warmly and from the heart", "a performer's radiance — generous, loyal and impossible to ignore",
|
||||||
|
"pride and a hunger for applause"),
|
||||||
|
"Virgo": ("precisely, modestly and in service of improvement", "a craftsman's discrimination — nothing escapes your notice",
|
||||||
|
"worry, self-criticism and perfectionism"),
|
||||||
|
"Libra": ("gracefully, diplomatically and always in relation to others", "an artist's sense of balance — fairness is a physical need",
|
||||||
|
"indecision and keeping the peace at your own expense"),
|
||||||
|
"Scorpio": ("intensely, privately and all the way to the bottom", "an investigator's penetration — you find what is hidden",
|
||||||
|
"suspicion, control and scorched-earth endings"),
|
||||||
|
"Sagittarius": ("expansively, honestly and aimed at the horizon", "an explorer's optimism — meaning matters more than comfort",
|
||||||
|
"bluntness, excess and promising more than you deliver"),
|
||||||
|
"Capricorn": ("ambitiously, cautiously and for the long game", "a mountain goat's endurance — you climb whatever is in front of you",
|
||||||
|
"pessimism, coldness and working when you should rest"),
|
||||||
|
"Aquarius": ("independently, inventively and on principle", "a scientist's detachment — humanity in the abstract, freedom in the particular",
|
||||||
|
"contrariness and aloofness from your own feelings"),
|
||||||
|
"Pisces": ("compassionately, imaginatively and without hard edges", "a mystic's permeability — you feel everything in the room",
|
||||||
|
"escapism, vagueness and martyrdom"),
|
||||||
|
}
|
||||||
|
|
||||||
|
HOUSE_MEANING = {
|
||||||
|
1: "the house of self — your body, appearance and personal initiative",
|
||||||
|
2: "the house of resources — money, possessions, talent and self-worth",
|
||||||
|
3: "the house of communication — siblings, short journeys, learning and daily talk",
|
||||||
|
4: "the house of home — family, roots, ancestry and your private foundation",
|
||||||
|
5: "the house of creativity — romance, children, play and self-expression",
|
||||||
|
6: "the house of work and health — daily routine, service, craft and the body's upkeep",
|
||||||
|
7: "the house of partnership — marriage, close one-to-one bonds and open enemies",
|
||||||
|
8: "the house of shared depths — intimacy, other people's money, crisis and regeneration",
|
||||||
|
9: "the house of the far horizon — travel, higher learning, philosophy and publishing",
|
||||||
|
10: "the house of career — public standing, authority, ambition and reputation",
|
||||||
|
11: "the house of community — friends, groups, causes and hopes for the future",
|
||||||
|
12: "the house of the hidden — solitude, dreams, self-undoing and transcendence",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------- hand-written big three -------------------------
|
||||||
|
|
||||||
|
SUN_IN_SIGN = {
|
||||||
|
"Aries": "You are here to begin things. Aries Sun people run on raw initiative: the idea and the action arrive in the same instant, and waiting is a kind of pain. You are brave, direct and refreshingly free of hidden agendas — what others call recklessness is simply your faith that the door will open if you hit it hard enough. Your growth lies in finishing what you start and discovering that patience is not surrender.",
|
||||||
|
"Taurus": "You are here to build things that last. Taurus Sun people have a genius for the physical world — food, music, money, touch, gardens — and a calm that others lean on in a storm. You change slowly and on your own schedule, which is both your greatest strength and the thing that maddens everyone who loves you. Your growth lies in learning that security comes from within, not from what you can hold.",
|
||||||
|
"Gemini": "You are here to connect things. Gemini Sun people live through language and curiosity: two of everything, questions before breakfast, and a mind that moves faster than any conversation can. You are the messenger, the translator, the one who makes the room lighter. Your growth lies in depth — staying with one thing, one person, one question long enough to be changed by it.",
|
||||||
|
"Cancer": "You are here to care for things. Cancer Sun people feel everything and forget nothing; beneath the shell is the most loyal heart in the zodiac. Home — wherever and whatever that means to you — is not a place but a project, and you protect your people ferociously. Your growth lies in letting yourself be nurtured as fiercely as you nurture, and in leaving the shell even when it might rain.",
|
||||||
|
"Leo": "You are here to shine. Leo Sun people carry a warmth that is not performance — it is a genuine wish for everyone in the room to feel more alive. You are generous, dramatic and braver in public than most people are in private. The applause matters more than you admit. Your growth lies in learning that your light does not dim when someone else's is shining.",
|
||||||
|
"Virgo": "You are here to perfect things. Virgo Sun people see the flaw, the fix and the better way — instantly and always. Your service is real love in work clothes: you show devotion by improving things for people. The blade you turn on the world you turn hardest on yourself. Your growth lies in accepting that 'good enough' is sometimes the most precise judgement of all.",
|
||||||
|
"Libra": "You are here to balance things. Libra Sun people are drawn to beauty, fairness and the space between two people, where you do your finest work. You see every side of every question — a gift in a diplomat, a torment at a menu. Your growth lies in discovering what YOU want when nobody else's preference is available to orbit, and saying it out loud.",
|
||||||
|
"Scorpio": "You are here to go beneath things. Scorpio Sun people are the depth psychologists of the zodiac: you sense what is hidden, you keep secrets like a vault, and you love with a totality that frightens lighter souls. You die and are reborn several times in one life. Your growth lies in trusting without testing, and forgiving without forgetting the lesson.",
|
||||||
|
"Sagittarius": "You are here to seek the meaning of things. Sagittarius Sun people need a horizon — a journey, a philosophy, a wager, a question big enough to be worth a life. You are honest to a fault (ask anyone who's asked how they look), funny, and allergic to cages. Your growth lies in learning that commitment is not a cage — it is a country you haven't explored yet.",
|
||||||
|
"Capricorn": "You are here to master things. Capricorn Sun people are born old and get younger: duty first, the long climb, the dry wit that surprises everyone. You do not want to be given the summit — you want to have earned it. Your growth lies in remembering what the climb was for, and letting the people who love you sit by the fire you built.",
|
||||||
|
"Aquarius": "You are here to reinvent things. Aquarius Sun people belong to the future: you see the pattern everyone else is inside of, and you cannot un-see it. Friendship is your religion, principle your compass, and being ordinary the only thing you truly fear. Your growth lies in coming down from the observatory — letting one person, up close, matter as much as humanity in the abstract.",
|
||||||
|
"Pisces": "You are here to dissolve the walls between things. Pisces Sun people feel the whole ocean: other people's moods, the mood of the age, music no one else hears yet. You are the zodiac's artist and mystic, compassionate past the point of self-interest. Your growth lies in building a container for the ocean — boundaries, routines, a raft — so the tide carries you instead of sweeping you away.",
|
||||||
|
}
|
||||||
|
|
||||||
|
MOON_IN_SIGN = {
|
||||||
|
"Aries": "Emotionally you are a flash fire — quick to anger, quick to laugh, incapable of sulking for long. You need action when you're upset; sitting with a feeling feels like drowning in slow motion. You are safest with people who let you flare without flinching.",
|
||||||
|
"Taurus": "Emotionally you are bedrock. You need comfort you can touch: good food, familiar rooms, a body beside you, a predictable rhythm. It takes a great deal to upset you and a great deal more to change your mind afterwards. The Moon is exalted here — feelings, once given, do not waver.",
|
||||||
|
"Gemini": "Emotionally you need to talk it out — a feeling isn't real until it's been put into words, preferably with a sympathetic listener and several tangents. Restlessness is how sadness shows up in you. You are safest with people who find your changeability charming rather than alarming.",
|
||||||
|
"Cancer": "Emotionally you are at full strength — the Moon rules Cancer, and you feel in tides: high, low, and never negotiable. Memory and mood are fused; an old song can undo you. You need a shell — a home, a person, a ritual — that is unconditionally yours. Your care for others is oceanic.",
|
||||||
|
"Leo": "Emotionally you need to be seen. Warm, loyal and dramatic, you give affection like sunlight and wilt in rooms where you're ignored. Wounded pride hurts you more than almost anything real. You are safest with people who applaud first and critique later — or never.",
|
||||||
|
"Virgo": "Emotionally you cope by being useful. Anxiety goes into lists, care goes into acts of service, and love is shown by remembering exactly how everyone takes their tea. You need order when you're overwhelmed. Let people love the unedited version of you, too.",
|
||||||
|
"Libra": "Emotionally you need harmony the way others need air — conflict in the room registers in your body. You reach instinctively for the balancing word, the fair compromise, the beautiful gesture. Your work is noticing your own feelings before you've adjusted them to suit the company.",
|
||||||
|
"Scorpio": "Emotionally you are all-or-nothing in a sealed vault. You feel more intensely than anyone around you and show less of it. Trust is everything and is never fully finished being tested. When you finally open the vault, your loyalty is absolute — and so is your memory of betrayal.",
|
||||||
|
"Sagittarius": "Emotionally you need open sky. Heaviness makes you reach for the passport, the joke, the philosophical reframe — anything but the swamp. Your optimism is genuine and medicinal to others. Just notice when the horizon is a direction and when it's an exit.",
|
||||||
|
"Capricorn": "Emotionally you are self-contained and self-controlled — feelings are handled, privately, on a schedule. You show love through reliability: being there, paying for it, fixing it. Your work is letting someone see you before the feeling has been fully processed and filed.",
|
||||||
|
"Aquarius": "Emotionally you observe yourself from a slight distance — feelings are interesting data, best examined with a friend, ideally about something other than the two of you. You need freedom inside closeness. Your work is staying in the room when the feeling is happening, not after.",
|
||||||
|
"Pisces": "Emotionally you are a tidal instrument — you register everyone's weather and sometimes lose track of which feelings are yours. Music, water, sleep and solitude restore you. Your compassion has no floor. Your work is a gentle boundary: you can feel with people without drowning for them.",
|
||||||
|
}
|
||||||
|
|
||||||
|
RISING_IN_SIGN = {
|
||||||
|
"Aries": "You come across as direct, energetic and slightly ahead of everyone else — first through the door, first to speak. Life meets you as a series of starts, dares and fresh campaigns. People underestimate your sensitivity because your armour looks so good.",
|
||||||
|
"Taurus": "You come across as calm, solid and unhurried — a settling presence people instinctively trust. Life meets you slowly and materially: things build, accumulate, take root. People underestimate how immovable you become once you've decided.",
|
||||||
|
"Gemini": "You come across as bright, quick and endlessly conversational — the one who knows a little about everything and someone in every room. Life meets you as a stream of messages, errands and interesting strangers. People underestimate the serious mind behind the banter.",
|
||||||
|
"Cancer": "You come across as gentle, receptive and a little guarded — people want to confide in you within minutes. Life meets you through belonging: family, home and the question of who is 'yours'. People underestimate the crab's grip beneath the softness.",
|
||||||
|
"Leo": "You come across as warm, confident and impossible to overlook — you have presence before you have said a word. Life meets you as a stage with your name somewhere on the bill. People underestimate how much courage it costs to keep the show running.",
|
||||||
|
"Virgo": "You come across as composed, attentive and precise — the person who noticed, and quietly fixed it. Life meets you as work to be done well. People underestimate the dry wit and the depth of care hiding inside the competence.",
|
||||||
|
"Libra": "You come across as charming, fair and beautifully put together — people relax around you and can't quite say why. Life meets you through others: partners, audiences, negotiations. People underestimate the steel it takes to keep everything so pleasant.",
|
||||||
|
"Scorpio": "You come across as magnetic, contained and faintly unknowable — people feel seen by you and slightly exposed. Life meets you in intensities: all-in bonds, deep endings, real stakes. People underestimate how much you've survived, because you never lead with it.",
|
||||||
|
"Sagittarius": "You come across as open, funny and larger than the room — the accent of somewhere else, even at home. Life meets you as a journey with luggage optional. People underestimate the philosopher under the punchlines.",
|
||||||
|
"Capricorn": "You come across as capable, reserved and older than your years — authority attaches itself to you unasked. Life meets you as a mountain, and you climb it. People underestimate the loyalty and humour beneath the executive surface.",
|
||||||
|
"Aquarius": "You come across as friendly, original and one degree removed — of the group and somehow watching it too. Life meets you as a series of systems to question. People underestimate how much you care, because you care in principles rather than displays.",
|
||||||
|
"Pisces": "You come across as soft-edged, kind and a little otherworldly — people project their favourite story onto you. Life meets you as atmosphere: moods, coincidences, callings. People underestimate the tidal strength with which you eventually go where you're going.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------- synthesis -------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def planet_in_sign(planet, sign):
|
||||||
|
if planet == "Sun":
|
||||||
|
return SUN_IN_SIGN[sign]
|
||||||
|
if planet == "Moon":
|
||||||
|
return MOON_IN_SIGN[sign]
|
||||||
|
if planet == "Ascendant":
|
||||||
|
return RISING_IN_SIGN[sign]
|
||||||
|
if planet not in PLANET_MEANING:
|
||||||
|
return ""
|
||||||
|
what, _ = PLANET_MEANING[planet]
|
||||||
|
style, gift, shadow = SIGN_STYLE[sign]
|
||||||
|
return (f"{planet} in {sign} colours {what}: it operates {style}. "
|
||||||
|
f"At its best this gives {gift}. "
|
||||||
|
f"The shadow side to watch is {shadow}.")
|
||||||
|
|
||||||
|
|
||||||
|
def planet_in_house(planet, house):
|
||||||
|
if planet not in PLANET_MEANING or house not in HOUSE_MEANING:
|
||||||
|
return ""
|
||||||
|
what, _ = PLANET_MEANING[planet]
|
||||||
|
return (f"{planet} falls in {HOUSE_MEANING[house]}. "
|
||||||
|
f"This is the arena of life where {what} plays out most visibly — "
|
||||||
|
f"expect the themes of this house to keep finding you.")
|
||||||
|
|
||||||
|
|
||||||
|
ASPECT_DYNAMICS = {
|
||||||
|
"Conjunction": "are fused — they act as one, each permanently coloured by the other",
|
||||||
|
"Opposition": "face each other across a divide — life keeps asking you to balance them, often through other people",
|
||||||
|
"Trine": "flow together effortlessly — a genuine talent, so natural you may undervalue it",
|
||||||
|
"Square": "grind against each other — a lifelong friction that, worked honestly, becomes your engine",
|
||||||
|
"Sextile": "cooperate when invited — an opportunity that rewards a little deliberate effort",
|
||||||
|
"Quincunx": "speak different languages entirely — they require constant, slightly awkward adjustment",
|
||||||
|
"Semisextile": "sit uneasily side by side — a subtle irritant and a subtle resource",
|
||||||
|
"Semisquare": "chafe in the background — minor but persistent friction",
|
||||||
|
"Sesquiquadrate": "provoke each other at odd moments — friction that surfaces when you least expect it",
|
||||||
|
"Quintile": "combine with unusual creativity — a signature talent with a personal twist",
|
||||||
|
"Biquintile": "combine with subtle artistry — a refined, almost private gift",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
dyn = ASPECT_DYNAMICS.get(aspect, "interact")
|
||||||
|
tight = "This is one of the defining aspects of your chart. " if orb <= 1.5 else ""
|
||||||
|
app = ""
|
||||||
|
if applying is True:
|
||||||
|
app = " (applying — its influence strengthens through life)"
|
||||||
|
elif applying is False:
|
||||||
|
app = " (separating — an influence you were born already knowing)"
|
||||||
|
return (f"{a} {aspect.lower()} {b} (orb {orb}°{app}): "
|
||||||
|
f"{da.capitalize()} and {db} {dyn}. {tight}").strip()
|
||||||
|
|
||||||
|
|
||||||
|
BALANCE_TEXT = {
|
||||||
|
"Fire": "spirit, enthusiasm and the urge to act",
|
||||||
|
"Earth": "practicality, patience and the physical world",
|
||||||
|
"Air": "ideas, language and social connection",
|
||||||
|
"Water": "feeling, intuition and emotional bonds",
|
||||||
|
"Cardinal": "initiating — you start things",
|
||||||
|
"Fixed": "sustaining — you keep things going",
|
||||||
|
"Mutable": "adapting — you adjust and redistribute",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def balance_summary(balances):
|
||||||
|
lines = []
|
||||||
|
for group_name, group in (("elements", balances["elements"]), ("modalities", balances["modalities"])):
|
||||||
|
items = sorted(group.items(), key=lambda kv: -kv[1])
|
||||||
|
hi, hi_v = items[0]
|
||||||
|
lo, lo_v = items[-1]
|
||||||
|
if hi_v >= 2 * max(1, lo_v):
|
||||||
|
lines.append(f"Your chart is strongly weighted toward {hi} ({BALANCE_TEXT[hi]}).")
|
||||||
|
if lo_v == 0:
|
||||||
|
lines.append(f"You have no planets in {lo} — {BALANCE_TEXT[lo]} is the language you must consciously learn, and often the one you seek in partners.")
|
||||||
|
elif lo_v <= 1:
|
||||||
|
lines.append(f"{lo} is scarce in your chart — {BALANCE_TEXT[lo]} takes deliberate effort.")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def build_interpretation(chart):
|
||||||
|
"""Assemble the full report from a compute_chart() result."""
|
||||||
|
by_name = {b["name"]: b for b in chart["bodies"]}
|
||||||
|
asc_sign = chart["angles"][0]["position"]["sign"]
|
||||||
|
sections = []
|
||||||
|
|
||||||
|
big3 = []
|
||||||
|
if "Sun" in by_name:
|
||||||
|
s = by_name["Sun"]["position"]["sign"]
|
||||||
|
big3.append({"title": f"Sun in {s}", "body": SUN_IN_SIGN[s]})
|
||||||
|
if "Moon" in by_name:
|
||||||
|
s = by_name["Moon"]["position"]["sign"]
|
||||||
|
big3.append({"title": f"Moon in {s}", "body": MOON_IN_SIGN[s]})
|
||||||
|
if not chart.get("time_unknown"):
|
||||||
|
big3.append({"title": f"{asc_sign} Rising", "body": RISING_IN_SIGN[asc_sign]})
|
||||||
|
sections.append({"heading": "The Big Three", "items": big3})
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for b in chart["bodies"]:
|
||||||
|
name = b["name"]
|
||||||
|
if name in ("Sun", "Moon", "South Node", "Part of Fortune"):
|
||||||
|
continue
|
||||||
|
sign = b["position"]["sign"]
|
||||||
|
txt = planet_in_sign(name, sign)
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
extra = ""
|
||||||
|
if b["retrograde"]:
|
||||||
|
extra += " Retrograde at your birth: this function turns inward — it matures late, privately, and on its own terms."
|
||||||
|
if b["dignities"]:
|
||||||
|
dg = ", ".join(b["dignities"])
|
||||||
|
extra += f" ({dg} — this placement carries special weight.)"
|
||||||
|
items.append({"title": f"{name} in {sign}", "body": txt + extra})
|
||||||
|
sections.append({"heading": "Planets in Signs", "items": items})
|
||||||
|
|
||||||
|
if not chart.get("time_unknown"):
|
||||||
|
items = []
|
||||||
|
for b in chart["bodies"]:
|
||||||
|
txt = planet_in_house(b["name"], b["house"])
|
||||||
|
if txt:
|
||||||
|
items.append({"title": f'{b["name"]} in House {b["house"]}', "body": txt})
|
||||||
|
sections.append({"heading": "Planets in Houses", "items": items})
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for a in chart["aspects"]:
|
||||||
|
if not a["major"] and a["orb"] > 1.5:
|
||||||
|
continue
|
||||||
|
items.append({"title": f'{a["a"]} {a["glyph"]} {a["b"]}',
|
||||||
|
"body": aspect_text(a["a"], a["b"], a["aspect"], a["orb"], a["applying"])})
|
||||||
|
sections.append({"heading": "Aspects", "items": items[:40]})
|
||||||
|
|
||||||
|
bal = balance_summary(chart["balances"])
|
||||||
|
extra_bits = []
|
||||||
|
if chart.get("moon_phase"):
|
||||||
|
mp = chart["moon_phase"]
|
||||||
|
extra_bits.append(f'You were born under a {mp["name"]} ({mp["illumination"]}% illuminated).')
|
||||||
|
extra_bits.append("This is a day chart — the Sun was above the horizon; solar themes (identity, visibility, purpose) lead."
|
||||||
|
if chart["is_day_chart"] else
|
||||||
|
"This is a night chart — the Sun was below the horizon; lunar themes (instinct, memory, belonging) lead.")
|
||||||
|
sections.append({"heading": "Chart Signature",
|
||||||
|
"items": [{"title": "Balances", "body": " ".join(bal) or "Your chart is unusually evenly balanced across elements and modalities — you can meet life in whatever language it speaks."},
|
||||||
|
{"title": "Sect & Moon Phase", "body": " ".join(extra_bits)}]})
|
||||||
|
return sections
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
pyswisseph==2.10.3.2
|
||||||
|
flask>=3.0
|
||||||
|
timezonefinder>=6.5
|
||||||
|
geonamescache>=2.0
|
||||||
342
static/app.js
Normal file
342
static/app.js
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
/* NATEGODD frontend — form, city search, SVG wheel, tables, report. */
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
let selectedCity = null;
|
||||||
|
let lastChart = null;
|
||||||
|
|
||||||
|
/* ---------------- city autocomplete ---------------- */
|
||||||
|
const placeInput = $("#f-place");
|
||||||
|
const cityBox = $("#city-results");
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
|
placeInput.addEventListener("input", () => {
|
||||||
|
selectedCity = null;
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
const q = placeInput.value.trim();
|
||||||
|
if (q.length < 2) { cityBox.hidden = true; return; }
|
||||||
|
searchTimer = setTimeout(async () => {
|
||||||
|
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
|
||||||
|
const rows = await res.json();
|
||||||
|
cityBox.innerHTML = "";
|
||||||
|
rows.forEach((r) => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.innerHTML = `${r.name}<small>${r.country} · ${r.lat.toFixed(2)}, ${r.lon.toFixed(2)}</small>`;
|
||||||
|
div.addEventListener("mousedown", () => pickCity(r));
|
||||||
|
cityBox.appendChild(div);
|
||||||
|
});
|
||||||
|
cityBox.hidden = rows.length === 0;
|
||||||
|
}, 180);
|
||||||
|
});
|
||||||
|
placeInput.addEventListener("blur", () => setTimeout(() => (cityBox.hidden = true), 200));
|
||||||
|
|
||||||
|
function pickCity(r) {
|
||||||
|
selectedCity = r;
|
||||||
|
placeInput.value = r.label;
|
||||||
|
$("#f-lat").value = r.lat;
|
||||||
|
$("#f-lon").value = r.lon;
|
||||||
|
cityBox.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- form submit ---------------- */
|
||||||
|
$("#f-unknown").addEventListener("change", (e) => {
|
||||||
|
$("#f-time").disabled = e.target.checked;
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#chart-form").addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const err = $("#form-error");
|
||||||
|
err.hidden = true;
|
||||||
|
|
||||||
|
const date = $("#f-date").value;
|
||||||
|
if (!date) return showErr("Enter a birth date.");
|
||||||
|
const [year, month, day] = date.split("-").map(Number);
|
||||||
|
const unknown = $("#f-unknown").checked;
|
||||||
|
const time = $("#f-time").value || "12:00";
|
||||||
|
const [hour, minute] = time.split(":").map(Number);
|
||||||
|
|
||||||
|
const lat = parseFloat($("#f-lat").value);
|
||||||
|
const lon = parseFloat($("#f-lon").value);
|
||||||
|
if (isNaN(lat) || isNaN(lon)) return showErr("Pick a city from the list, or enter coordinates manually.");
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name: $("#f-name").value,
|
||||||
|
place: selectedCity ? selectedCity.label : placeInput.value,
|
||||||
|
year, month, day, hour, minute, time_unknown: unknown,
|
||||||
|
lat, lon,
|
||||||
|
tz: selectedCity ? selectedCity.tz : null,
|
||||||
|
utc_offset: $("#f-offset").value,
|
||||||
|
house_system: $("#f-houses").value,
|
||||||
|
};
|
||||||
|
|
||||||
|
$("#go").textContent = "Casting…";
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/chart", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) return showErr(data.error);
|
||||||
|
lastChart = data;
|
||||||
|
saveChart(body);
|
||||||
|
render(data);
|
||||||
|
} catch (ex) {
|
||||||
|
showErr("Server error: " + ex.message);
|
||||||
|
} finally {
|
||||||
|
$("#go").textContent = "Cast Chart ✶";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showErr(msg) {
|
||||||
|
const err = $("#form-error");
|
||||||
|
err.textContent = msg;
|
||||||
|
err.hidden = false;
|
||||||
|
$("#go").textContent = "Cast Chart ✶";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- saved charts (localStorage) ---------------- */
|
||||||
|
function loadSaved() {
|
||||||
|
try { return JSON.parse(localStorage.getItem("nategodd") || "[]"); } catch { return []; }
|
||||||
|
}
|
||||||
|
function saveChart(body) {
|
||||||
|
try {
|
||||||
|
let saved = loadSaved().filter((s) => !(s.name === body.name && s.year === body.year && s.month === body.month && s.day === body.day));
|
||||||
|
saved.unshift(body);
|
||||||
|
saved = saved.slice(0, 20);
|
||||||
|
localStorage.setItem("nategodd", JSON.stringify(saved));
|
||||||
|
fillSaved();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
function fillSaved() {
|
||||||
|
const sel = $("#saved-charts");
|
||||||
|
sel.innerHTML = '<option value="">— saved charts —</option>';
|
||||||
|
loadSaved().forEach((s, i) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$("#saved-charts").addEventListener("change", (e) => {
|
||||||
|
if (e.target.value === "") return;
|
||||||
|
const s = loadSaved()[Number(e.target.value)];
|
||||||
|
if (!s) return;
|
||||||
|
$("#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).padStart(2, "0")}:${String(s.minute).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";
|
||||||
|
});
|
||||||
|
fillSaved();
|
||||||
|
|
||||||
|
/* ---------------- tabs ---------------- */
|
||||||
|
document.querySelectorAll("#tabs button").forEach((b) =>
|
||||||
|
b.addEventListener("click", () => {
|
||||||
|
document.querySelectorAll("#tabs button").forEach((x) => x.classList.remove("active"));
|
||||||
|
document.querySelectorAll(".tab").forEach((x) => x.classList.remove("active"));
|
||||||
|
b.classList.add("active");
|
||||||
|
$("#tab-" + b.dataset.tab).classList.add("active");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
$("#print-btn").addEventListener("click", () => window.print());
|
||||||
|
|
||||||
|
/* ---------------- render ---------------- */
|
||||||
|
function render(d) {
|
||||||
|
$("#results").hidden = false;
|
||||||
|
const inp = d.input;
|
||||||
|
$("#chart-header").innerHTML =
|
||||||
|
`<h2>${esc(inp.name) || "Natal Chart"}</h2>
|
||||||
|
<p>${inp.date}${inp.time ? " · " + inp.time + " local" : " · time unknown (solar chart)"} · ${esc(inp.place)}
|
||||||
|
· ${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>`;
|
||||||
|
drawWheel(d);
|
||||||
|
renderBalances(d);
|
||||||
|
renderPositions(d);
|
||||||
|
renderAspects(d);
|
||||||
|
renderReport(d);
|
||||||
|
$("#results").scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); }
|
||||||
|
|
||||||
|
/* ---------------- SVG wheel ---------------- */
|
||||||
|
const ELEM_COLOR = ["#e06a4f", "#7aa85c", "#d9c86a", "#5f8fd0"]; // fire earth air water
|
||||||
|
const SIGN_GLYPHS = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"];
|
||||||
|
|
||||||
|
function drawWheel(d) {
|
||||||
|
const C = 360, size = 720;
|
||||||
|
const asc = d.angles[0].lon;
|
||||||
|
// longitude -> screen point. ASC at 9 o'clock, zodiac counterclockwise.
|
||||||
|
const pt = (lonDeg, r) => {
|
||||||
|
const a = ((lonDeg - asc + 180) * Math.PI) / 180;
|
||||||
|
return [C + r * Math.cos(a), C - r * Math.sin(a)];
|
||||||
|
};
|
||||||
|
let s = `<svg viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">`;
|
||||||
|
s += `<circle cx="${C}" cy="${C}" r="332" fill="#0e0e1a" stroke="#2c2c42"/>`;
|
||||||
|
|
||||||
|
// zodiac ring
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const a0 = i * 30, a1 = a0 + 30;
|
||||||
|
const [x0o, y0o] = pt(a0, 330), [x1o, y1o] = pt(a1, 330);
|
||||||
|
const [x1i, y1i] = pt(a1, 292), [x0i, y0i] = pt(a0, 292);
|
||||||
|
s += `<path d="M${x0o},${y0o} A330,330 0 0 1 ${x1o},${y1o} L${x1i},${y1i} A292,292 0 0 0 ${x0i},${y0i} Z"
|
||||||
|
fill="${ELEM_COLOR[i % 4]}22" stroke="#2c2c42"/>`;
|
||||||
|
const [gx, gy] = pt(a0 + 15, 311);
|
||||||
|
s += `<text x="${gx}" y="${gy}" font-size="22" fill="${ELEM_COLOR[i % 4]}" text-anchor="middle" dominant-baseline="central">${SIGN_GLYPHS[i]}</text>`;
|
||||||
|
}
|
||||||
|
// degree ticks
|
||||||
|
for (let deg = 0; deg < 360; deg += 5) {
|
||||||
|
const len = deg % 30 === 0 ? 10 : deg % 10 === 0 ? 7 : 4;
|
||||||
|
const [x0, y0] = pt(deg, 292), [x1, y1] = pt(deg, 292 - len);
|
||||||
|
s += `<line x1="${x0}" y1="${y0}" x2="${x1}" y2="${y1}" stroke="#4a4a66" stroke-width="1"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// houses
|
||||||
|
if (!d.time_unknown) {
|
||||||
|
d.houses.forEach((h, i) => {
|
||||||
|
const angular = i % 3 === 0;
|
||||||
|
const [x0, y0] = pt(h.lon, 120), [x1, y1] = pt(h.lon, 292);
|
||||||
|
s += `<line x1="${x0}" y1="${y0}" x2="${x1}" y2="${y1}" stroke="${angular ? "#8f7bd8" : "#33334d"}" stroke-width="${angular ? 2 : 1}"/>`;
|
||||||
|
const next = d.houses[(i + 1) % 12].lon;
|
||||||
|
let mid = h.lon + (((next - h.lon) % 360) + 360) % 360 / 2;
|
||||||
|
const [nx, ny] = pt(mid, 135);
|
||||||
|
s += `<text x="${nx}" y="${ny}" font-size="12" fill="#6a6a8a" text-anchor="middle" dominant-baseline="central">${h.num}</text>`;
|
||||||
|
});
|
||||||
|
// ASC / MC labels
|
||||||
|
const [ax, ay] = pt(asc, 345);
|
||||||
|
s += `<text x="${ax}" y="${ay}" font-size="13" fill="#e8c66a" text-anchor="middle" dominant-baseline="central">AC</text>`;
|
||||||
|
const [mx, my] = pt(d.angles[1].lon, 345);
|
||||||
|
s += `<text x="${mx}" y="${my}" font-size="13" fill="#e8c66a" text-anchor="middle" dominant-baseline="central">MC</text>`;
|
||||||
|
}
|
||||||
|
s += `<circle cx="${C}" cy="${C}" r="120" fill="none" stroke="#2c2c42"/>`;
|
||||||
|
|
||||||
|
// aspect lines (majors, planets only)
|
||||||
|
const lonOf = {};
|
||||||
|
d.bodies.forEach((b) => (lonOf[b.name] = b.lon));
|
||||||
|
const ASPECT_COLOR = { Conjunction: "#e8c66a", Opposition: "#e05555", Square: "#e05555", Trine: "#5fa8e0", Sextile: "#5cc58a" };
|
||||||
|
d.aspects.forEach((a) => {
|
||||||
|
if (!a.major || !(a.a in lonOf) || !(a.b in lonOf)) return;
|
||||||
|
if (a.aspect === "Conjunction") return;
|
||||||
|
const [x0, y0] = pt(lonOf[a.a], 118), [x1, y1] = pt(lonOf[a.b], 118);
|
||||||
|
s += `<line x1="${x0}" y1="${y0}" x2="${x1}" y2="${y1}" stroke="${ASPECT_COLOR[a.aspect]}" stroke-width="1" opacity="${a.orb < 2 ? 0.9 : 0.45}"/>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// planets, with collision spread
|
||||||
|
const placed = d.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++) {
|
||||||
|
const p = placed[i], q = placed[(i + 1) % placed.length];
|
||||||
|
let gap = (q.draw - p.draw + 360) % 360;
|
||||||
|
if (gap < MIN) { const push = (MIN - gap) / 2; p.draw = (p.draw - push + 360) % 360; q.draw = (q.draw + push) % 360; }
|
||||||
|
}
|
||||||
|
placed.sort((a, b) => a.draw - b.draw);
|
||||||
|
}
|
||||||
|
placed.forEach((b) => {
|
||||||
|
const [tx0, ty0] = pt(b.lon, 282), [tx1, ty1] = pt(b.lon, 274);
|
||||||
|
s += `<line x1="${tx0}" y1="${ty0}" x2="${tx1}" y2="${ty1}" stroke="#e8e4f0" stroke-width="1.5"/>`;
|
||||||
|
const [gx, gy] = pt(b.draw, 250);
|
||||||
|
const [lx0, ly0] = pt(b.lon, 272), [lx1, ly1] = pt(b.draw, 262);
|
||||||
|
s += `<line x1="${lx0}" y1="${ly0}" x2="${lx1}" y2="${ly1}" stroke="#55557a" stroke-width="0.7"/>`;
|
||||||
|
s += `<text x="${gx}" y="${gy}" font-size="20" fill="#e8e4f0" text-anchor="middle" dominant-baseline="central">${b.glyph}</text>`;
|
||||||
|
const p = b.position;
|
||||||
|
const [dx, dy] = pt(b.draw, 228);
|
||||||
|
s += `<text x="${dx}" y="${dy}" font-size="9" fill="#9a93ad" text-anchor="middle" dominant-baseline="central">${p.deg}°${String(p.min).padStart(2, "0")}${b.retrograde ? "℞" : ""}</text>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
s += `</svg>`;
|
||||||
|
$("#wheel-box").innerHTML = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- side panels ---------------- */
|
||||||
|
function renderBalances(d) {
|
||||||
|
const b = d.balances;
|
||||||
|
const colors = { Fire: "#e06a4f", Earth: "#7aa85c", Air: "#d9c86a", Water: "#5f8fd0",
|
||||||
|
Cardinal: "#8f7bd8", Fixed: "#e8c66a", Mutable: "#5fa8e0", Positive: "#e8e4f0", Negative: "#9a93ad" };
|
||||||
|
let html = "<h3>Balances</h3>";
|
||||||
|
for (const group of [b.elements, b.modalities, b.polarities]) {
|
||||||
|
const max = Math.max(...Object.values(group), 1);
|
||||||
|
for (const [k, v] of Object.entries(group)) {
|
||||||
|
html += `<div class="bal-row"><span>${k}</span><div class="bal-bar" style="width:${(v / max) * 130}px;background:${colors[k]}"></div><span>${v}</span></div>`;
|
||||||
|
}
|
||||||
|
html += `<div style="height:10px"></div>`;
|
||||||
|
}
|
||||||
|
$("#balances").innerHTML = html;
|
||||||
|
const mp = d.moon_phase;
|
||||||
|
$("#moonphase").innerHTML = mp
|
||||||
|
? `<h3>Moon Phase</h3><strong>${mp.name}</strong> · ${mp.illumination}% illuminated · ${mp.angle}° from Sun`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- tables ---------------- */
|
||||||
|
function renderPositions(d) {
|
||||||
|
let h = `<tr><th></th><th>Body</th><th>Position</th><th>House</th><th>Motion</th><th>Decl.</th><th>Dignity</th></tr>`;
|
||||||
|
d.bodies.forEach((b) => {
|
||||||
|
h += `<tr><td class="glyph">${b.glyph}</td><td>${b.name}</td><td>${b.position.text}</td>
|
||||||
|
<td>${d.time_unknown ? "—" : b.house}</td>
|
||||||
|
<td class="${b.retrograde ? "rx" : ""}">${b.retrograde ? "℞ retrograde" : b.speed ? "direct" : "—"}</td>
|
||||||
|
<td>${b.declination == null ? "—" : b.declination.toFixed(2) + "°"}</td>
|
||||||
|
<td class="dign">${b.dignities.join(", ")}</td></tr>`;
|
||||||
|
});
|
||||||
|
$("#pos-table").innerHTML = h;
|
||||||
|
|
||||||
|
let a = `<tr><th>Angle</th><th>Position</th></tr>`;
|
||||||
|
d.angles.forEach((x) => { a += `<tr><td>${x.name} (${x.glyph})</td><td>${x.position.text}</td></tr>`; });
|
||||||
|
$("#angle-table").innerHTML = d.time_unknown ? "<tr><td>Time unknown — angles not calculated.</td></tr>" : a;
|
||||||
|
|
||||||
|
let ht = `<tr><th>House</th><th>Cusp</th></tr>`;
|
||||||
|
d.houses.forEach((x) => { ht += `<tr><td>${x.num}</td><td>${x.position.text}</td></tr>`; });
|
||||||
|
$("#house-table").innerHTML = d.time_unknown ? "<tr><td>Time unknown — houses not calculated.</td></tr>" : ht;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAspects(d) {
|
||||||
|
const names = [...new Set(d.bodies.map((b) => b.name))];
|
||||||
|
if (!d.time_unknown) names.push("Ascendant", "Midheaven");
|
||||||
|
const glyph = {};
|
||||||
|
d.bodies.forEach((b) => (glyph[b.name] = b.glyph));
|
||||||
|
glyph["Ascendant"] = "AC"; glyph["Midheaven"] = "MC";
|
||||||
|
const cell = {};
|
||||||
|
d.aspects.forEach((a) => { cell[a.a + "|" + a.b] = a; cell[a.b + "|" + a.a] = a; });
|
||||||
|
const cls = (a) => ({ Conjunction: "asp-gold", Opposition: "asp-red", Square: "asp-red", Trine: "asp-blue", Sextile: "asp-green" }[a.aspect] || "asp-dim");
|
||||||
|
|
||||||
|
let h = "";
|
||||||
|
for (let i = 1; i < names.length; i++) {
|
||||||
|
h += "<tr>" + `<th>${glyph[names[i]]}</th>`;
|
||||||
|
for (let j = 0; j < i; 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 += "</tr>";
|
||||||
|
}
|
||||||
|
h += "<tr><th></th>" + names.slice(0, -1).map((n) => `<th>${glyph[n]}</th>`).join("") + "</tr>";
|
||||||
|
$("#aspect-grid").innerHTML = h;
|
||||||
|
|
||||||
|
let list = `<tr><th>Aspect</th><th>Orb</th><th>Phase</th></tr>`;
|
||||||
|
d.aspects.forEach((a) => {
|
||||||
|
list += `<tr><td class="${cls(a)}">${a.a} ${a.glyph} ${a.b} <span style="color:var(--dim)">(${a.aspect.toLowerCase()})</span></td>
|
||||||
|
<td>${a.orb}°</td><td>${a.applying === null ? "—" : a.applying ? "applying" : "separating"}</td></tr>`;
|
||||||
|
});
|
||||||
|
$("#aspect-list").innerHTML = list;
|
||||||
|
|
||||||
|
let par = `<tr><th>Pair</th><th>Type</th><th>Orb</th></tr>`;
|
||||||
|
d.parallels.forEach((p) => { par += `<tr><td>${p.a} · ${p.b}</td><td>${p.type}</td><td>${p.orb}°</td></tr>`; });
|
||||||
|
$("#parallel-list").innerHTML = d.parallels.length ? par : "<tr><td>None within 1°.</td></tr>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- report ---------------- */
|
||||||
|
function renderReport(d) {
|
||||||
|
let h = "";
|
||||||
|
d.interpretation.forEach((sec) => {
|
||||||
|
h += `<section><h4>${esc(sec.heading)}</h4>`;
|
||||||
|
sec.items.forEach((it) => {
|
||||||
|
h += `<div class="item"><b>${esc(it.title)}</b><p>${esc(it.body)}</p></div>`;
|
||||||
|
});
|
||||||
|
h += "</section>";
|
||||||
|
});
|
||||||
|
$("#report").innerHTML = h;
|
||||||
|
}
|
||||||
106
static/index.html
Normal file
106
static/index.html
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>NATEGODD — Natal Chart Generator</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>NATE<span>GODD</span></h1>
|
||||||
|
<p class="tag">natal chart generator · swiss ephemeris precision</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="form-card">
|
||||||
|
<form id="chart-form" autocomplete="off">
|
||||||
|
<div class="row">
|
||||||
|
<label>Name
|
||||||
|
<input type="text" id="f-name" placeholder="optional">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row triple">
|
||||||
|
<label>Date of birth
|
||||||
|
<input type="date" id="f-date" required min="1800-01-01" max="2399-12-31">
|
||||||
|
</label>
|
||||||
|
<label>Time
|
||||||
|
<input type="time" id="f-time" value="12:00" step="60">
|
||||||
|
</label>
|
||||||
|
<label class="check"><input type="checkbox" id="f-unknown"> time unknown</label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Birthplace
|
||||||
|
<input type="text" id="f-place" placeholder="start typing a city…" spellcheck="false">
|
||||||
|
</label>
|
||||||
|
<div id="city-results" hidden></div>
|
||||||
|
</div>
|
||||||
|
<details id="manual-loc">
|
||||||
|
<summary>manual coordinates / timezone</summary>
|
||||||
|
<div class="row triple">
|
||||||
|
<label>Latitude <input type="number" id="f-lat" step="any" min="-90" max="90" placeholder="-33.8688"></label>
|
||||||
|
<label>Longitude <input type="number" id="f-lon" step="any" min="-180" max="180" placeholder="151.2093"></label>
|
||||||
|
<label>UTC offset <input type="number" id="f-offset" step="any" min="-14" max="14" placeholder="auto"></label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="row triple">
|
||||||
|
<label>House system
|
||||||
|
<select id="f-houses">
|
||||||
|
<option value="P" selected>Placidus</option>
|
||||||
|
<option value="K">Koch</option>
|
||||||
|
<option value="W">Whole Sign</option>
|
||||||
|
<option value="E">Equal</option>
|
||||||
|
<option value="O">Porphyry</option>
|
||||||
|
<option value="C">Campanus</option>
|
||||||
|
<option value="R">Regiomontanus</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" id="go">Cast Chart ✶</button>
|
||||||
|
<select id="saved-charts" title="saved charts"><option value="">— saved charts —</option></select>
|
||||||
|
</div>
|
||||||
|
<p id="form-error" hidden></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="results" hidden>
|
||||||
|
<div id="chart-header"></div>
|
||||||
|
<nav id="tabs">
|
||||||
|
<button data-tab="wheel" class="active">Wheel</button>
|
||||||
|
<button data-tab="positions">Positions</button>
|
||||||
|
<button data-tab="aspects">Aspects</button>
|
||||||
|
<button data-tab="report">Report</button>
|
||||||
|
</nav>
|
||||||
|
<div id="tab-wheel" class="tab active">
|
||||||
|
<div id="wheel-box"></div>
|
||||||
|
<div id="wheel-side">
|
||||||
|
<div id="balances"></div>
|
||||||
|
<div id="moonphase"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="tab-positions" class="tab">
|
||||||
|
<h3>Planets & Points</h3>
|
||||||
|
<table id="pos-table"></table>
|
||||||
|
<h3>Angles</h3>
|
||||||
|
<table id="angle-table"></table>
|
||||||
|
<h3>House Cusps</h3>
|
||||||
|
<table id="house-table"></table>
|
||||||
|
</div>
|
||||||
|
<div id="tab-aspects" class="tab">
|
||||||
|
<h3>Aspect Grid</h3>
|
||||||
|
<div class="scrollx"><table id="aspect-grid"></table></div>
|
||||||
|
<h3>Aspect List</h3>
|
||||||
|
<table id="aspect-list"></table>
|
||||||
|
<h3>Declination Parallels</h3>
|
||||||
|
<table id="parallel-list"></table>
|
||||||
|
</div>
|
||||||
|
<div id="tab-report" class="tab">
|
||||||
|
<div id="report"></div>
|
||||||
|
<button id="print-btn" type="button">🖨 Print report</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>NATEGODD · geocentric tropical zodiac · Swiss Ephemeris · IANA historical time atlas</footer>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
128
static/style.css
Normal file
128
static/style.css
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0b0b14;
|
||||||
|
--panel: #14142290;
|
||||||
|
--panel-solid: #161624;
|
||||||
|
--ink: #e8e4f0;
|
||||||
|
--dim: #9a93ad;
|
||||||
|
--gold: #e8c66a;
|
||||||
|
--violet: #8f7bd8;
|
||||||
|
--fire: #e06a4f;
|
||||||
|
--earth: #7aa85c;
|
||||||
|
--air: #d9c86a;
|
||||||
|
--water: #5f8fd0;
|
||||||
|
--red: #e05555;
|
||||||
|
--blue: #5fa8e0;
|
||||||
|
--green: #5cc58a;
|
||||||
|
--line: #2c2c42;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: radial-gradient(1200px 800px at 70% -10%, #1c1733 0%, var(--bg) 55%) fixed;
|
||||||
|
color: var(--ink);
|
||||||
|
font: 15px/1.55 "Avenir Next", "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
header { text-align: center; padding: 26px 0 6px; }
|
||||||
|
h1 { margin: 0; font-size: 40px; letter-spacing: 8px; font-weight: 300; }
|
||||||
|
h1 span { color: var(--gold); font-weight: 600; }
|
||||||
|
.tag { color: var(--dim); margin: 4px 0 0; letter-spacing: 2px; font-size: 12px; text-transform: uppercase; }
|
||||||
|
main { max-width: 1060px; margin: 0 auto; padding: 18px; }
|
||||||
|
footer { text-align: center; color: var(--dim); font-size: 11px; padding: 24px; letter-spacing: 1px; }
|
||||||
|
|
||||||
|
#form-card, #results {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.row { margin-bottom: 14px; position: relative; }
|
||||||
|
.row.triple { display: flex; gap: 14px; align-items: end; flex-wrap: wrap; }
|
||||||
|
label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--dim); text-transform: uppercase; letter-spacing: 1px; flex: 1; min-width: 130px; }
|
||||||
|
label.check { flex-direction: row; align-items: center; text-transform: none; letter-spacing: 0; font-size: 13px; min-width: auto; }
|
||||||
|
input, select, button {
|
||||||
|
font: inherit; color: var(--ink);
|
||||||
|
background: #0e0e1a; border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { outline: 1px solid var(--violet); }
|
||||||
|
input[type="checkbox"] { width: auto; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
#go {
|
||||||
|
background: linear-gradient(135deg, #6a55c2, #9b6ad8);
|
||||||
|
border: none; font-weight: 600; letter-spacing: 1px; padding: 11px 26px;
|
||||||
|
}
|
||||||
|
#go:hover { filter: brightness(1.15); }
|
||||||
|
details { margin-bottom: 14px; color: var(--dim); font-size: 13px; }
|
||||||
|
summary { cursor: pointer; margin-bottom: 8px; }
|
||||||
|
#form-error { color: var(--red); }
|
||||||
|
|
||||||
|
#city-results {
|
||||||
|
position: absolute; z-index: 10; left: 0; right: 0; top: 100%;
|
||||||
|
background: var(--panel-solid); border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
max-height: 260px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
#city-results div { padding: 8px 12px; cursor: pointer; }
|
||||||
|
#city-results div:hover, #city-results div.sel { background: #262640; }
|
||||||
|
#city-results small { color: var(--dim); margin-left: 6px; }
|
||||||
|
|
||||||
|
#chart-header { margin-bottom: 14px; }
|
||||||
|
#chart-header h2 { margin: 0 0 2px; font-weight: 400; letter-spacing: 1px; }
|
||||||
|
#chart-header p { margin: 0; color: var(--dim); font-size: 13px; }
|
||||||
|
|
||||||
|
#tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--line); margin-bottom: 18px; }
|
||||||
|
#tabs button {
|
||||||
|
background: none; border: none; border-bottom: 2px solid transparent; border-radius: 0;
|
||||||
|
color: var(--dim); padding: 8px 18px; letter-spacing: 1px; text-transform: uppercase; font-size: 12px;
|
||||||
|
}
|
||||||
|
#tabs button.active { color: var(--gold); border-bottom-color: var(--gold); }
|
||||||
|
.tab { display: none; }
|
||||||
|
.tab.active { display: block; }
|
||||||
|
#tab-wheel.active { display: flex; gap: 24px; flex-wrap: wrap; align-items: flex-start; }
|
||||||
|
#wheel-box { flex: 1 1 560px; min-width: 320px; }
|
||||||
|
#wheel-box svg { width: 100%; height: auto; display: block; }
|
||||||
|
#wheel-side { flex: 1 1 240px; min-width: 240px; }
|
||||||
|
|
||||||
|
h3 { color: var(--gold); font-weight: 400; letter-spacing: 2px; text-transform: uppercase; font-size: 13px; margin: 22px 0 10px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 14px; }
|
||||||
|
th, td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--line); }
|
||||||
|
th { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; font-weight: 500; }
|
||||||
|
td.glyph { font-size: 18px; width: 30px; }
|
||||||
|
td.rx { color: var(--red); }
|
||||||
|
td.dign { color: var(--gold); font-size: 12px; }
|
||||||
|
.scrollx { overflow-x: auto; }
|
||||||
|
|
||||||
|
#aspect-grid td, #aspect-grid th { text-align: center; padding: 4px 6px; min-width: 30px; }
|
||||||
|
#aspect-grid td.asp { font-size: 16px; cursor: default; }
|
||||||
|
.asp-red { color: var(--red); }
|
||||||
|
.asp-blue { color: var(--blue); }
|
||||||
|
.asp-green { color: var(--green); }
|
||||||
|
.asp-gold { color: var(--gold); }
|
||||||
|
.asp-dim { color: var(--dim); }
|
||||||
|
|
||||||
|
.bal-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; font-size: 13px; }
|
||||||
|
.bal-row span:first-child { width: 74px; color: var(--dim); }
|
||||||
|
.bal-bar { height: 10px; border-radius: 5px; }
|
||||||
|
#moonphase { margin-top: 22px; color: var(--dim); font-size: 14px; }
|
||||||
|
#moonphase strong { color: var(--ink); }
|
||||||
|
|
||||||
|
#report section { margin-bottom: 30px; }
|
||||||
|
#report h4 {
|
||||||
|
color: var(--gold); letter-spacing: 3px; text-transform: uppercase; font-weight: 400;
|
||||||
|
border-bottom: 1px solid var(--line); padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
#report .item { margin-bottom: 16px; }
|
||||||
|
#report .item b { color: var(--violet); display: block; margin-bottom: 2px; font-size: 15px; }
|
||||||
|
#report .item p { margin: 0; color: var(--ink); }
|
||||||
|
#print-btn { margin-top: 10px; }
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body { background: #fff; color: #000; font-size: 12px; }
|
||||||
|
header, #form-card, #tabs, footer, #print-btn { display: none !important; }
|
||||||
|
#results { border: none; background: none; }
|
||||||
|
.tab { display: block !important; }
|
||||||
|
#report .item b, #report h4, h3 { color: #000; }
|
||||||
|
th, td { border-color: #ccc; }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user