From 6248d95bf374703d8303be42d92807362f341ba3 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 14 Apr 2026 02:09:26 -0400 Subject: [PATCH] PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 --- pyswiss/apps/charts/calc.py | 38 ++ .../charts/tests/integrated/test_views.py | 91 +++- pyswiss/apps/charts/tests/unit/test_calc.py | 148 ++++++ pyswiss/apps/charts/urls.py | 1 + pyswiss/apps/charts/views.py | 33 ++ pyswiss/requirements.txt | 1 + .../migrations/0032_astro_reference_tables.py | 65 +++ .../0033_seed_astro_reference_tables.py | 106 +++++ .../epic/migrations/0034_character_model.py | 35 ++ src/apps/epic/models.py | 138 ++++++ src/apps/epic/urls.py | 2 + src/apps/epic/views.py | 172 ++++++- .../static/apps/gameboard/natus-wheel.js | 421 ++++++++++++++++++ src/static_src/scss/_natus.scss | 315 +++++++++++++ src/static_src/scss/core.scss | 1 + .../gameboard/_partials/_natus_overlay.html | 340 ++++++++++++++ src/templates/apps/gameboard/room.html | 5 + 17 files changed, 1909 insertions(+), 3 deletions(-) create mode 100644 pyswiss/apps/charts/tests/unit/test_calc.py create mode 100644 src/apps/epic/migrations/0032_astro_reference_tables.py create mode 100644 src/apps/epic/migrations/0033_seed_astro_reference_tables.py create mode 100644 src/apps/epic/migrations/0034_character_model.py create mode 100644 src/apps/gameboard/static/apps/gameboard/natus-wheel.js create mode 100644 src/static_src/scss/_natus.scss create mode 100644 src/templates/apps/gameboard/_partials/_natus_overlay.html diff --git a/pyswiss/apps/charts/calc.py b/pyswiss/apps/charts/calc.py index a471f99..8399e29 100644 --- a/pyswiss/apps/charts/calc.py +++ b/pyswiss/apps/charts/calc.py @@ -19,6 +19,14 @@ SIGN_ELEMENT = { 'Cancer': 'Water', 'Scorpio': 'Water', 'Pisces': 'Water', } +ASPECTS = [ + ('Conjunction', 0, 8.0), + ('Sextile', 60, 6.0), + ('Square', 90, 8.0), + ('Trine', 120, 8.0), + ('Opposition', 180, 10.0), +] + PLANET_CODES = { 'Sun': swe.SUN, 'Moon': swe.MOON, @@ -90,3 +98,33 @@ def get_element_counts(planets): counts['Space'] = max_seq - 1 return counts + + +def calculate_aspects(planets): + """Return a list of aspects between all planet pairs. + + Each entry: {planet1, planet2, type, angle (actual, rounded), orb (rounded)}. + Only the first matching aspect type is reported per pair (aspects are + well-separated enough that at most one can apply with standard orbs). + """ + names = list(planets.keys()) + aspects = [] + for i, name1 in enumerate(names): + for name2 in names[i + 1:]: + deg1 = planets[name1]['degree'] + deg2 = planets[name2]['degree'] + angle = abs(deg1 - deg2) + if angle > 180: + angle = 360 - angle + for aspect_name, target, max_orb in ASPECTS: + orb = abs(angle - target) + if orb <= max_orb: + aspects.append({ + 'planet1': name1, + 'planet2': name2, + 'type': aspect_name, + 'angle': round(angle, 2), + 'orb': round(orb, 2), + }) + break + return aspects diff --git a/pyswiss/apps/charts/tests/integrated/test_views.py b/pyswiss/apps/charts/tests/integrated/test_views.py index 5982f01..f52fb87 100644 --- a/pyswiss/apps/charts/tests/integrated/test_views.py +++ b/pyswiss/apps/charts/tests/integrated/test_views.py @@ -1,7 +1,7 @@ """ Integration tests for the PySwiss chart calculation API. -These tests drive the TDD implementation of GET /api/chart/. +These tests drive the TDD implementation of GET /api/chart/ and GET /api/tz/. They verify the HTTP contract using Django's test client. Run: @@ -18,6 +18,11 @@ from django.test import TestCase J2000 = '2000-01-01T12:00:00Z' LONDON = {'lat': 51.5074, 'lon': -0.1278} +# Well-known coordinates with unambiguous timezone results +NEW_YORK = {'lat': 40.7128, 'lon': -74.0060} # America/New_York +TOKYO = {'lat': 35.6762, 'lon': 139.6503} # Asia/Tokyo +REYKJAVIK = {'lat': 64.1355, 'lon': -21.8954} # Atlantic/Reykjavik + class ChartApiTest(TestCase): """GET /api/chart/ — calculate a natal chart from datetime + coordinates.""" @@ -124,3 +129,87 @@ class ChartApiTest(TestCase): """House system override is superuser-only; plain requests get 403.""" response = self._get({'dt': J2000, **LONDON, 'house_system': 'P'}) self.assertEqual(response.status_code, 403) + + # ── aspects ─────────────────────────────────────────────────────────── + + def test_chart_returns_aspects_list(self): + data = self._get({'dt': J2000, **LONDON}).json() + self.assertIn('aspects', data) + self.assertIsInstance(data['aspects'], list) + + def test_each_aspect_has_required_fields(self): + data = self._get({'dt': J2000, **LONDON}).json() + for aspect in data['aspects']: + with self.subTest(aspect=aspect): + self.assertIn('planet1', aspect) + self.assertIn('planet2', aspect) + self.assertIn('type', aspect) + self.assertIn('angle', aspect) + self.assertIn('orb', aspect) + + def test_sun_saturn_trine_present_at_j2000(self): + """Sun ~280.37° (Capricorn) and Saturn ~40.73° (Taurus) are ~120.36° apart — Trine.""" + data = self._get({'dt': J2000, **LONDON}).json() + pairs = {(a['planet1'], a['planet2'], a['type']) for a in data['aspects']} + self.assertIn(('Sun', 'Saturn', 'Trine'), pairs) + + +class TimezoneApiTest(TestCase): + """GET /api/tz/ — resolve IANA timezone from lat/lon coordinates.""" + + def _get(self, params): + return self.client.get('/api/tz/', params) + + # ── guards ──────────────────────────────────────────────────────────── + + def test_returns_400_if_lat_missing(self): + response = self._get({'lon': -74.0060}) + self.assertEqual(response.status_code, 400) + + def test_returns_400_if_lon_missing(self): + response = self._get({'lat': 40.7128}) + self.assertEqual(response.status_code, 400) + + def test_returns_400_for_invalid_lat(self): + response = self._get({'lat': 'abc', 'lon': -74.0060}) + self.assertEqual(response.status_code, 400) + + def test_returns_400_for_out_of_range_lat(self): + response = self._get({'lat': 999, 'lon': -74.0060}) + self.assertEqual(response.status_code, 400) + + def test_returns_400_for_out_of_range_lon(self): + response = self._get({'lat': 40.7128, 'lon': 999}) + self.assertEqual(response.status_code, 400) + + # ── response shape ──────────────────────────────────────────────────── + + def test_returns_200_for_valid_coords(self): + response = self._get(NEW_YORK) + self.assertEqual(response.status_code, 200) + + def test_response_is_json(self): + response = self._get(NEW_YORK) + self.assertIn('application/json', response['Content-Type']) + + def test_response_contains_timezone_key(self): + data = self._get(NEW_YORK).json() + self.assertIn('timezone', data) + + def test_timezone_is_a_string(self): + data = self._get(NEW_YORK).json() + self.assertIsInstance(data['timezone'], str) + + # ── correctness ─────────────────────────────────────────────────────── + + def test_new_york_timezone(self): + data = self._get(NEW_YORK).json() + self.assertEqual(data['timezone'], 'America/New_York') + + def test_tokyo_timezone(self): + data = self._get(TOKYO).json() + self.assertEqual(data['timezone'], 'Asia/Tokyo') + + def test_reykjavik_timezone(self): + data = self._get(REYKJAVIK).json() + self.assertEqual(data['timezone'], 'Atlantic/Reykjavik') diff --git a/pyswiss/apps/charts/tests/unit/test_calc.py b/pyswiss/apps/charts/tests/unit/test_calc.py new file mode 100644 index 0000000..119bd69 --- /dev/null +++ b/pyswiss/apps/charts/tests/unit/test_calc.py @@ -0,0 +1,148 @@ +""" +Unit tests for calc.py helper functions. + +These tests verify pure calculation logic without hitting the database +or the Swiss Ephemeris — all inputs are fixed synthetic data. + +Run: + pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts +""" +from django.test import SimpleTestCase + +from apps.charts.calc import calculate_aspects + + +# --------------------------------------------------------------------------- +# Synthetic planet data — degrees chosen for predictable aspects +# Matches FAKE_PLANETS in test_populate_ephemeris.py +# --------------------------------------------------------------------------- + +FAKE_PLANETS = { + 'Sun': {'degree': 10.0}, # Aries + 'Moon': {'degree': 130.0}, # Leo — 120° from Sun → Trine + 'Mercury': {'degree': 250.0}, # Sagittarius — 120° from Sun → Trine + 'Venus': {'degree': 40.0}, # Taurus — 90° from Moon → Square + 'Mars': {'degree': 160.0}, # Virgo — 60° from Neptune → Sextile + 'Jupiter': {'degree': 280.0}, # Capricorn — 120° from Mars → Trine + 'Saturn': {'degree': 70.0}, # Gemini — 120° from Uranus → Trine + 'Uranus': {'degree': 310.0}, # Aquarius — 60° from Sun (wrap) → Sextile + 'Neptune': {'degree': 100.0}, # Cancer + 'Pluto': {'degree': 340.0}, # Pisces +} + + +def _aspect_pairs(aspects): + """Return a set of (planet1, planet2, type) tuples for easy assertion.""" + return {(a['planet1'], a['planet2'], a['type']) for a in aspects} + + +class CalculateAspectsTest(SimpleTestCase): + + def setUp(self): + self.aspects = calculate_aspects(FAKE_PLANETS) + + # ── return shape ────────────────────────────────────────────────────── + + def test_returns_a_list(self): + self.assertIsInstance(self.aspects, list) + + def test_each_aspect_has_required_keys(self): + for aspect in self.aspects: + with self.subTest(aspect=aspect): + self.assertIn('planet1', aspect) + self.assertIn('planet2', aspect) + self.assertIn('type', aspect) + self.assertIn('angle', aspect) + self.assertIn('orb', aspect) + + def test_each_aspect_type_is_a_known_name(self): + known = {'Conjunction', 'Sextile', 'Square', 'Trine', 'Opposition'} + for aspect in self.aspects: + with self.subTest(aspect=aspect): + self.assertIn(aspect['type'], known) + + def test_angle_and_orb_are_floats(self): + for aspect in self.aspects: + with self.subTest(aspect=aspect): + self.assertIsInstance(aspect['angle'], float) + self.assertIsInstance(aspect['orb'], float) + + def test_no_self_aspects(self): + for aspect in self.aspects: + self.assertNotEqual(aspect['planet1'], aspect['planet2']) + + def test_no_duplicate_pairs(self): + pairs = [(a['planet1'], a['planet2']) for a in self.aspects] + self.assertEqual(len(pairs), len(set(pairs))) + + # ── known aspects in FAKE_PLANETS ──────────────────────────────────── + + def test_sun_moon_trine(self): + """Moon at 130° is exactly 120° from Sun at 10°.""" + pairs = _aspect_pairs(self.aspects) + self.assertIn(('Sun', 'Moon', 'Trine'), pairs) + + def test_sun_mercury_trine(self): + """Mercury at 250° wraps to 120° from Sun at 10° (360-250+10=120).""" + pairs = _aspect_pairs(self.aspects) + self.assertIn(('Sun', 'Mercury', 'Trine'), pairs) + + def test_moon_mercury_trine(self): + """Moon 130° → Mercury 250° = 120°.""" + pairs = _aspect_pairs(self.aspects) + self.assertIn(('Moon', 'Mercury', 'Trine'), pairs) + + def test_moon_venus_square(self): + """Moon 130° → Venus 40° = 90°.""" + pairs = _aspect_pairs(self.aspects) + self.assertIn(('Moon', 'Venus', 'Square'), pairs) + + def test_venus_neptune_sextile(self): + """Venus 40° → Neptune 100° = 60°.""" + pairs = _aspect_pairs(self.aspects) + self.assertIn(('Venus', 'Neptune', 'Sextile'), pairs) + + def test_mars_neptune_sextile(self): + """Mars 160° → Neptune 100° = 60°.""" + pairs = _aspect_pairs(self.aspects) + self.assertIn(('Mars', 'Neptune', 'Sextile'), pairs) + + def test_sun_uranus_sextile(self): + """Sun 10° → Uranus 310° — angle = |10-310| = 300° → 360-300 = 60°.""" + pairs = _aspect_pairs(self.aspects) + self.assertIn(('Sun', 'Uranus', 'Sextile'), pairs) + + def test_mars_jupiter_trine(self): + """Mars 160° → Jupiter 280° = 120°.""" + pairs = _aspect_pairs(self.aspects) + self.assertIn(('Mars', 'Jupiter', 'Trine'), pairs) + + def test_saturn_uranus_trine(self): + """Saturn 70° → Uranus 310° = |70-310| = 240° → 360-240 = 120°.""" + pairs = _aspect_pairs(self.aspects) + self.assertIn(('Saturn', 'Uranus', 'Trine'), pairs) + + # ── orb bounds ──────────────────────────────────────────────────────── + + def test_orb_is_within_allowed_maximum(self): + max_orbs = { + 'Conjunction': 8.0, + 'Sextile': 6.0, + 'Square': 8.0, + 'Trine': 8.0, + 'Opposition': 10.0, + } + for aspect in self.aspects: + with self.subTest(aspect=aspect): + self.assertLessEqual( + aspect['orb'], max_orbs[aspect['type']], + msg=f"{aspect['planet1']}-{aspect['planet2']} orb exceeds maximum", + ) + + def test_exact_trine_has_zero_orb(self): + """Sun-Moon at exactly 120° should report orb of 0.0.""" + sun_moon = next( + a for a in self.aspects + if a['planet1'] == 'Sun' and a['planet2'] == 'Moon' + ) + self.assertAlmostEqual(sun_moon['orb'], 0.0, places=5) diff --git a/pyswiss/apps/charts/urls.py b/pyswiss/apps/charts/urls.py index 78d1960..005a36f 100644 --- a/pyswiss/apps/charts/urls.py +++ b/pyswiss/apps/charts/urls.py @@ -4,4 +4,5 @@ from . import views urlpatterns = [ path('chart/', views.chart, name='chart'), path('charts/', views.charts_list, name='charts_list'), + path('tz/', views.timezone_lookup, name='timezone_lookup'), ] diff --git a/pyswiss/apps/charts/views.py b/pyswiss/apps/charts/views.py index d1d5a90..5b1ef84 100644 --- a/pyswiss/apps/charts/views.py +++ b/pyswiss/apps/charts/views.py @@ -1,11 +1,13 @@ from datetime import datetime, timezone from django.http import HttpResponse, JsonResponse +from timezonefinder import TimezoneFinder import swisseph as swe from .calc import ( DEFAULT_HOUSE_SYSTEM, + calculate_aspects, get_element_counts, get_julian_day, get_planet_positions, @@ -61,10 +63,41 @@ def chart(request): 'planets': planets, 'houses': houses, 'elements': get_element_counts(planets), + 'aspects': calculate_aspects(planets), 'house_system': house_system, }) +_tf = TimezoneFinder() + + +def timezone_lookup(request): + """GET /api/tz/ — resolve IANA timezone string from lat/lon. + + Query params: lat (float), lon (float) + Returns: { "timezone": "America/New_York" } + Returns 404 JSON { "timezone": null } if coordinates fall in international + waters (no timezone found) — not an error, just no result. + """ + lat_str = request.GET.get('lat') + lon_str = request.GET.get('lon') + + if lat_str is None or lon_str is None: + return HttpResponse(status=400) + + try: + lat = float(lat_str) + lon = float(lon_str) + except ValueError: + return HttpResponse(status=400) + + if not (-90 <= lat <= 90) or not (-180 <= lon <= 180): + return HttpResponse(status=400) + + tz = _tf.timezone_at(lat=lat, lng=lon) + return JsonResponse({'timezone': tz}) + + def charts_list(request): date_from_str = request.GET.get('date_from') date_to_str = request.GET.get('date_to') diff --git a/pyswiss/requirements.txt b/pyswiss/requirements.txt index 68a2dc4..d3ee6a7 100644 --- a/pyswiss/requirements.txt +++ b/pyswiss/requirements.txt @@ -2,3 +2,4 @@ django==6.0.4 django-cors-headers==4.3.1 gunicorn==23.0.0 pyswisseph==2.10.3.2 +timezonefinder==8.2.2 diff --git a/src/apps/epic/migrations/0032_astro_reference_tables.py b/src/apps/epic/migrations/0032_astro_reference_tables.py new file mode 100644 index 0000000..2117838 --- /dev/null +++ b/src/apps/epic/migrations/0032_astro_reference_tables.py @@ -0,0 +1,65 @@ +# Generated by Django 6.0 on 2026-04-14 05:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0031_sig_ready_sky_select'), + ] + + operations = [ + migrations.CreateModel( + name='AspectType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=20, unique=True)), + ('symbol', models.CharField(max_length=5)), + ('angle', models.PositiveSmallIntegerField()), + ('orb', models.FloatField()), + ], + options={ + 'ordering': ['angle'], + }, + ), + migrations.CreateModel( + name='HouseLabel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('number', models.PositiveSmallIntegerField(unique=True)), + ('name', models.CharField(max_length=30)), + ('keywords', models.CharField(blank=True, max_length=100)), + ], + options={ + 'ordering': ['number'], + }, + ), + migrations.CreateModel( + name='Planet', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=20, unique=True)), + ('symbol', models.CharField(max_length=5)), + ('order', models.PositiveSmallIntegerField(unique=True)), + ], + options={ + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='Sign', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=20, unique=True)), + ('symbol', models.CharField(max_length=5)), + ('element', models.CharField(choices=[('Fire', 'Fire'), ('Earth', 'Earth'), ('Air', 'Air'), ('Water', 'Water')], max_length=5)), + ('modality', models.CharField(choices=[('Cardinal', 'Cardinal'), ('Fixed', 'Fixed'), ('Mutable', 'Mutable')], max_length=8)), + ('order', models.PositiveSmallIntegerField(unique=True)), + ('start_degree', models.FloatField()), + ], + options={ + 'ordering': ['order'], + }, + ), + ] diff --git a/src/apps/epic/migrations/0033_seed_astro_reference_tables.py b/src/apps/epic/migrations/0033_seed_astro_reference_tables.py new file mode 100644 index 0000000..4113956 --- /dev/null +++ b/src/apps/epic/migrations/0033_seed_astro_reference_tables.py @@ -0,0 +1,106 @@ +""" +Data migration: seed Sign, Planet, AspectType, and HouseLabel tables. + +These are stable astrological reference rows — never user-edited. +The data matches the constants in pyswiss/apps/charts/calc.py so that +the proxy view and D3 wheel share a single source of truth. +""" +from django.db import migrations + + +# ── Signs ──────────────────────────────────────────────────────────────────── +# (order, name, symbol, element, modality, start_degree) +SIGNS = [ + (0, 'Aries', '♈', 'Fire', 'Cardinal', 0.0), + (1, 'Taurus', '♉', 'Earth', 'Fixed', 30.0), + (2, 'Gemini', '♊', 'Air', 'Mutable', 60.0), + (3, 'Cancer', '♋', 'Water', 'Cardinal', 90.0), + (4, 'Leo', '♌', 'Fire', 'Fixed', 120.0), + (5, 'Virgo', '♍', 'Earth', 'Mutable', 150.0), + (6, 'Libra', '♎', 'Air', 'Cardinal', 180.0), + (7, 'Scorpio', '♏', 'Water', 'Fixed', 210.0), + (8, 'Sagittarius', '♐', 'Fire', 'Mutable', 240.0), + (9, 'Capricorn', '♑', 'Earth', 'Cardinal', 270.0), + (10, 'Aquarius', '♒', 'Air', 'Fixed', 300.0), + (11, 'Pisces', '♓', 'Water', 'Mutable', 330.0), +] + +# ── Planets ─────────────────────────────────────────────────────────────────── +# (order, name, symbol) +PLANETS = [ + (0, 'Sun', '☉'), + (1, 'Moon', '☽'), + (2, 'Mercury', '☿'), + (3, 'Venus', '♀'), + (4, 'Mars', '♂'), + (5, 'Jupiter', '♃'), + (6, 'Saturn', '♄'), + (7, 'Uranus', '♅'), + (8, 'Neptune', '♆'), + (9, 'Pluto', '♇'), +] + +# ── Aspect types ────────────────────────────────────────────────────────────── +# (name, symbol, angle, orb) — mirrors ASPECTS constant in pyswiss calc.py +ASPECT_TYPES = [ + ('Conjunction', '☌', 0, 8.0), + ('Sextile', '⚹', 60, 6.0), + ('Square', '□', 90, 8.0), + ('Trine', '△', 120, 8.0), + ('Opposition', '☍', 180, 10.0), +] + +# ── House labels (distinctions) ─────────────────────────────────────────────── +# (number, name, keywords) +HOUSE_LABELS = [ + (1, 'Self', 'identity, appearance, first impressions'), + (2, 'Worth', 'possessions, values, finances'), + (3, 'Education', 'communication, siblings, short journeys'), + (4, 'Family', 'home, roots, ancestry'), + (5, 'Creation', 'creativity, romance, children, pleasure'), + (6, 'Ritual', 'service, health, daily routines'), + (7, 'Cooperation', 'partnerships, marriage, open enemies'), + (8, 'Regeneration', 'transformation, shared resources, death'), + (9, 'Enterprise', 'philosophy, travel, higher learning'), + (10, 'Career', 'public life, reputation, authority'), + (11, 'Reward', 'friends, groups, aspirations'), + (12, 'Reprisal', 'hidden matters, karma, self-undoing'), +] + + +def forward(apps, schema_editor): + Sign = apps.get_model('epic', 'Sign') + Planet = apps.get_model('epic', 'Planet') + AspectType = apps.get_model('epic', 'AspectType') + HouseLabel = apps.get_model('epic', 'HouseLabel') + + for order, name, symbol, element, modality, start_degree in SIGNS: + Sign.objects.create( + order=order, name=name, symbol=symbol, + element=element, modality=modality, start_degree=start_degree, + ) + + for order, name, symbol in PLANETS: + Planet.objects.create(order=order, name=name, symbol=symbol) + + for name, symbol, angle, orb in ASPECT_TYPES: + AspectType.objects.create(name=name, symbol=symbol, angle=angle, orb=orb) + + for number, name, keywords in HOUSE_LABELS: + HouseLabel.objects.create(number=number, name=name, keywords=keywords) + + +def reverse(apps, schema_editor): + for model_name in ('Sign', 'Planet', 'AspectType', 'HouseLabel'): + apps.get_model('epic', model_name).objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0032_astro_reference_tables'), + ] + + operations = [ + migrations.RunPython(forward, reverse_code=reverse), + ] diff --git a/src/apps/epic/migrations/0034_character_model.py b/src/apps/epic/migrations/0034_character_model.py new file mode 100644 index 0000000..38d5e09 --- /dev/null +++ b/src/apps/epic/migrations/0034_character_model.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0 on 2026-04-14 05:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0033_seed_astro_reference_tables'), + ] + + operations = [ + migrations.CreateModel( + name='Character', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('birth_dt', models.DateTimeField(blank=True, null=True)), + ('birth_lat', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('birth_lon', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('birth_place', models.CharField(blank=True, max_length=200)), + ('house_system', models.CharField(choices=[('O', 'Porphyry'), ('P', 'Placidus'), ('K', 'Koch'), ('W', 'Whole Sign')], default='O', max_length=1)), + ('chart_data', models.JSONField(blank=True, null=True)), + ('celtic_cross', models.JSONField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('confirmed_at', models.DateTimeField(blank=True, null=True)), + ('retired_at', models.DateTimeField(blank=True, null=True)), + ('seat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='characters', to='epic.tableseat')), + ('significator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='character_significators', to='epic.tarotcard')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 8b3a32b..7d2482e 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -470,3 +470,141 @@ def active_sig_seat(room): if seat.significator_id is None: return seat return None + + +# ── Astrological reference tables (seeded, never user-edited) ───────────────── + +class Sign(models.Model): + FIRE = 'Fire' + EARTH = 'Earth' + AIR = 'Air' + WATER = 'Water' + ELEMENT_CHOICES = [(e, e) for e in (FIRE, EARTH, AIR, WATER)] + + CARDINAL = 'Cardinal' + FIXED = 'Fixed' + MUTABLE = 'Mutable' + MODALITY_CHOICES = [(m, m) for m in (CARDINAL, FIXED, MUTABLE)] + + name = models.CharField(max_length=20, unique=True) + symbol = models.CharField(max_length=5) # ♈ ♉ … ♓ + element = models.CharField(max_length=5, choices=ELEMENT_CHOICES) + modality = models.CharField(max_length=8, choices=MODALITY_CHOICES) + order = models.PositiveSmallIntegerField(unique=True) # 0–11, Aries first + start_degree = models.FloatField() # 0, 30, 60 … 330 + + class Meta: + ordering = ['order'] + + def __str__(self): + return self.name + + +class Planet(models.Model): + name = models.CharField(max_length=20, unique=True) + symbol = models.CharField(max_length=5) # ☉ ☽ ☿ ♀ ♂ ♃ ♄ ♅ ♆ ♇ + order = models.PositiveSmallIntegerField(unique=True) # 0–9, Sun first + + class Meta: + ordering = ['order'] + + def __str__(self): + return self.name + + +class AspectType(models.Model): + name = models.CharField(max_length=20, unique=True) + symbol = models.CharField(max_length=5) # ☌ ⚹ □ △ ☍ + angle = models.PositiveSmallIntegerField() # 0, 60, 90, 120, 180 + orb = models.FloatField() # max allowed orb in degrees + + class Meta: + ordering = ['angle'] + + def __str__(self): + return self.name + + +class HouseLabel(models.Model): + """Life-area label for each of the 12 astrological houses (distinctions).""" + + number = models.PositiveSmallIntegerField(unique=True) # 1–12 + name = models.CharField(max_length=30) + keywords = models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ['number'] + + def __str__(self): + return f"{self.number}: {self.name}" + + +# ── Character ───────────────────────────────────────────────────────────────── + +class Character(models.Model): + """A gamer's player-character for one seat in one game session. + + Lifecycle: + - Created (draft) when gamer opens PICK SKY overlay. + - confirmed_at set on confirm → locked. + - retired_at set on retirement → archived (seat may hold a new Character). + + Active character for a seat: confirmed_at__isnull=False, retired_at__isnull=True. + """ + + PORPHYRY = 'O' + PLACIDUS = 'P' + KOCH = 'K' + WHOLE = 'W' + HOUSE_SYSTEM_CHOICES = [ + (PORPHYRY, 'Porphyry'), + (PLACIDUS, 'Placidus'), + (KOCH, 'Koch'), + (WHOLE, 'Whole Sign'), + ] + + # ── seat relationship ───────────────────────────────────────────────── + seat = models.ForeignKey( + TableSeat, on_delete=models.CASCADE, related_name='characters', + ) + + # ── significator (set at PICK SKY) ──────────────────────────────────── + significator = models.ForeignKey( + TarotCard, null=True, blank=True, + on_delete=models.SET_NULL, related_name='character_significators', + ) + + # ── natus input (what the gamer entered) ───────────────────────────── + birth_dt = models.DateTimeField(null=True, blank=True) # UTC + birth_lat = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + birth_lon = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + birth_place = models.CharField(max_length=200, blank=True) # display string only + house_system = models.CharField( + max_length=1, choices=HOUSE_SYSTEM_CHOICES, default=PORPHYRY, + ) + + # ── computed natus snapshot (full PySwiss response) ─────────────────── + chart_data = models.JSONField(null=True, blank=True) + + # ── celtic cross spread (added at PICK SEA) ─────────────────────────── + celtic_cross = models.JSONField(null=True, blank=True) + + # ── lifecycle ───────────────────────────────────────────────────────── + created_at = models.DateTimeField(auto_now_add=True) + confirmed_at = models.DateTimeField(null=True, blank=True) + retired_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + status = 'confirmed' if self.confirmed_at else 'draft' + return f"Character(seat={self.seat_id}, {status})" + + @property + def is_confirmed(self): + return self.confirmed_at is not None + + @property + def is_active(self): + return self.confirmed_at is not None and self.retired_at is None diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index dd556e3..a3a047b 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -25,4 +25,6 @@ urlpatterns = [ path('room//abandon', views.abandon_room, name='abandon_room'), path('room//tarot/', views.tarot_deck, name='tarot_deck'), path('room//tarot/deal', views.tarot_deal, name='tarot_deal'), + path('room//natus/preview', views.natus_preview, name='natus_preview'), + path('room//natus/save', views.natus_save, name='natus_save'), ] diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 2c1ba63..18ccaa4 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -1,11 +1,14 @@ import json -from datetime import timedelta +import zoneinfo +from datetime import datetime, timedelta +import requests as http_requests from asgiref.sync import async_to_sync from channels.layers import get_channel_layer +from django.conf import settings from django.contrib.auth.decorators import login_required from django.db import transaction -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from django.shortcuts import redirect, render from django.utils import timezone @@ -13,6 +16,7 @@ from apps.drama.models import GameEvent, record from django.db.models import Case, IntegerField, Value, When from apps.epic.models import ( + Character, GateSlot, Room, RoomInvite, SIG_SEAT_ORDER, SigReservation, TableSeat, TarotCard, TarotDeck, active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards, @@ -916,3 +920,167 @@ def tarot_deal(request, room_id): "positions": positions, }) + +# ── Natus (natal chart) ─────────────────────────────────────────────────────── + +def _planet_house(degree, cusps): + """Return 1-based house number for a planet at ecliptic degree. + + cusps is the 12-element list from PySwiss where cusps[i] is the start of + house i+1. Handles the wrap-around case where a cusp crosses 0°/360°. + """ + degree = degree % 360 + for i in range(12): + start = cusps[i] % 360 + end = cusps[(i + 1) % 12] % 360 + if start < end: + if start <= degree < end: + return i + 1 + else: # wrap-around: e.g. cusp at 350° → next at 10° + if degree >= start or degree < end: + return i + 1 + return 1 + + +def _compute_distinctions(planets, houses): + """Return dict {house_number_str: planet_count} for all 12 houses.""" + cusps = houses['cusps'] + counts = {str(i): 0 for i in range(1, 13)} + for planet_data in planets.values(): + h = _planet_house(planet_data['degree'], cusps) + counts[str(h)] += 1 + return counts + + +@login_required +def natus_preview(request, room_id): + """Proxy GET to PySwiss /api/chart/ and augment with distinction counts. + + Query params: + date — YYYY-MM-DD (local birth date) + time — HH:MM (local birth time, default 12:00) + tz — IANA timezone string (optional; auto-resolved from lat/lon if absent) + lat — float + lon — float + + If tz is absent or blank, calls PySwiss /api/tz/ to resolve it from the + coordinates before converting the local datetime to UTC. + + Response includes a 'timezone' key (resolved or supplied) so the client + can back-fill the timezone field after the first wheel render. + + No database writes — safe for debounced real-time calls. + """ + seat = _canonical_user_seat(Room.objects.get(id=room_id), request.user) + if seat is None: + return HttpResponse(status=403) + + date_str = request.GET.get('date') + time_str = request.GET.get('time', '12:00') + tz_str = request.GET.get('tz', '').strip() + lat_str = request.GET.get('lat') + lon_str = request.GET.get('lon') + + if not date_str or lat_str is None or lon_str is None: + return HttpResponse(status=400) + + try: + lat = float(lat_str) + lon = float(lon_str) + except ValueError: + return HttpResponse(status=400) + + if not (-90 <= lat <= 90) or not (-180 <= lon <= 180): + return HttpResponse(status=400) + + # Resolve timezone from coordinates if not supplied + if not tz_str: + try: + tz_resp = http_requests.get( + settings.PYSWISS_URL + '/api/tz/', + params={'lat': lat_str, 'lon': lon_str}, + timeout=5, + ) + tz_resp.raise_for_status() + tz_str = tz_resp.json().get('timezone') or 'UTC' + except Exception: + tz_str = 'UTC' + + try: + tz = zoneinfo.ZoneInfo(tz_str) + except (zoneinfo.ZoneInfoNotFoundError, KeyError): + return HttpResponse(status=400) + + try: + local_dt = datetime.strptime(f'{date_str} {time_str}', '%Y-%m-%d %H:%M') + local_dt = local_dt.replace(tzinfo=tz) + utc_dt = local_dt.astimezone(zoneinfo.ZoneInfo('UTC')) + dt_iso = utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ') + except ValueError: + return HttpResponse(status=400) + + try: + resp = http_requests.get( + settings.PYSWISS_URL + '/api/chart/', + params={'dt': dt_iso, 'lat': lat_str, 'lon': lon_str}, + timeout=5, + ) + resp.raise_for_status() + except Exception: + return HttpResponse(status=502) + + data = resp.json() + data['distinctions'] = _compute_distinctions(data['planets'], data['houses']) + data['timezone'] = tz_str + return JsonResponse(data) + + +@login_required +def natus_save(request, room_id): + """Create or update the draft Character for the requesting gamer's seat. + + POST body (JSON): + birth_dt — ISO 8601 UTC datetime + birth_lat — float + birth_lon — float + birth_place — display string (optional) + house_system — single char, default 'O' + chart_data — full PySwiss response dict (incl. distinctions) + action — 'save' (default) or 'confirm' + + On 'confirm': sets confirmed_at, locking the Character. + Returns: {id, confirmed} + """ + if request.method != 'POST': + return HttpResponse(status=405) + + room = Room.objects.get(id=room_id) + seat = _canonical_user_seat(room, request.user) + if seat is None: + return HttpResponse(status=403) + + try: + body = json.loads(request.body) + except json.JSONDecodeError: + return HttpResponse(status=400) + + # Find or create the active draft (unconfirmed, unretired) for this seat + char = Character.objects.filter( + seat=seat, confirmed_at__isnull=True, retired_at__isnull=True, + ).first() + if char is None: + char = Character(seat=seat) + + char.birth_dt = body.get('birth_dt') + char.birth_lat = body.get('birth_lat') + char.birth_lon = body.get('birth_lon') + char.birth_place = body.get('birth_place', '') + char.house_system = body.get('house_system', Character.PORPHYRY) + char.chart_data = body.get('chart_data') + + if body.get('action') == 'confirm': + char.confirmed_at = timezone.now() + + char.save() + return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed}) + diff --git a/src/apps/gameboard/static/apps/gameboard/natus-wheel.js b/src/apps/gameboard/static/apps/gameboard/natus-wheel.js new file mode 100644 index 0000000..e95ff92 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/natus-wheel.js @@ -0,0 +1,421 @@ +/** + * natus-wheel.js — Self-contained D3 natal-chart module. + * + * Public API: + * NatusWheel.draw(svgEl, data) — first render + * NatusWheel.redraw(data) — live update (same SVG) + * NatusWheel.clear() — empty the SVG + * + * `data` shape — matches the /epic/natus/preview/ proxy response: + * { + * planets: { Sun: { sign, degree, retrograde }, … }, + * houses: { cusps: [f×12], asc: f, mc: f }, + * elements: { Fire: n, Water: n, Earth: n, Air: n, Time: n, Space: n }, + * aspects: [{ planet1, planet2, type, angle, orb }, …], + * distinctions: { "1": n, …, "12": n }, + * house_system: "O", + * } + * + * Requires D3 v7 to be available as `window.d3` (loaded before this file). + * Uses CSS variables from the project palette (--priUser, --secUser, etc.) + * already defined in the page; falls back to neutral colours if absent. + */ + +const NatusWheel = (() => { + 'use strict'; + + // ── Constants ────────────────────────────────────────────────────────────── + + const SIGNS = [ + { name: 'Aries', symbol: '♈', element: 'Fire' }, + { name: 'Taurus', symbol: '♉', element: 'Earth' }, + { name: 'Gemini', symbol: '♊', element: 'Air' }, + { name: 'Cancer', symbol: '♋', element: 'Water' }, + { name: 'Leo', symbol: '♌', element: 'Fire' }, + { name: 'Virgo', symbol: '♍', element: 'Earth' }, + { name: 'Libra', symbol: '♎', element: 'Air' }, + { name: 'Scorpio', symbol: '♏', element: 'Water' }, + { name: 'Sagittarius', symbol: '♐', element: 'Fire' }, + { name: 'Capricorn', symbol: '♑', element: 'Earth' }, + { name: 'Aquarius', symbol: '♒', element: 'Air' }, + { name: 'Pisces', symbol: '♓', element: 'Water' }, + ]; + + const PLANET_SYMBOLS = { + Sun: '☉', Moon: '☽', Mercury: '☿', Venus: '♀', Mars: '♂', + Jupiter: '♃', Saturn: '♄', Uranus: '♅', Neptune: '♆', Pluto: '♇', + }; + + const ASPECT_COLOURS = { + Conjunction: 'var(--priYl, #f0e060)', + Sextile: 'var(--priGn, #60c080)', + Square: 'var(--priRd, #c04040)', + Trine: 'var(--priGn, #60c080)', + Opposition: 'var(--priRd, #c04040)', + }; + + const ELEMENT_COLOURS = { + Fire: 'var(--terUser, #c04040)', + Earth: 'var(--priGn, #60c080)', + Air: 'var(--priYl, #f0e060)', + Water: 'var(--priBl, #4080c0)', + Time: 'var(--quaUser, #808080)', + Space: 'var(--quiUser, #a0a0a0)', + }; + + const HOUSE_LABELS = [ + '', 'Self', 'Worth', 'Education', 'Family', 'Creation', 'Ritual', + 'Cooperation', 'Regeneration', 'Enterprise', 'Career', 'Reward', 'Reprisal', + ]; + + // ── State ───────────────────────────────────────────────────────────────── + + let _svg = null; + let _cx, _cy, _r; // centre + outer radius + + // Ring radii (fractions of _r, set in _layout) + let R = {}; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** Convert ecliptic longitude to SVG angle. + * + * Ecliptic 0° (Aries) sits at the Ascendant position (left, 9 o'clock in + * standard chart convention). SVG angles are clockwise from 12 o'clock, so: + * svg_angle = -(ecliptic - asc) - 90° (in radians) + * We subtract 90° because D3 arcs start at 12 o'clock. + */ + function _toAngle(degree, asc) { + return (-(degree - asc) - 90) * Math.PI / 180; + } + + function _css(varName, fallback) { + const v = getComputedStyle(document.documentElement) + .getPropertyValue(varName).trim(); + return v || fallback; + } + + function _layout(svgEl) { + const rect = svgEl.getBoundingClientRect(); + const size = Math.min(rect.width || 400, rect.height || 400); + _cx = size / 2; + _cy = size / 2; + _r = size * 0.46; // leave a small margin + + R = { + elementInner: _r * 0.20, + elementOuter: _r * 0.28, + planetInner: _r * 0.32, + planetOuter: _r * 0.48, + houseInner: _r * 0.50, + houseOuter: _r * 0.68, + signInner: _r * 0.70, + signOuter: _r * 0.90, + labelR: _r * 0.80, // sign symbol placement + houseNumR: _r * 0.59, // house number placement + planetR: _r * 0.40, // planet symbol placement + aspectR: _r * 0.29, // aspect lines end here (inner circle) + ascMcR: _r * 0.92, // ASC/MC tick outer + }; + } + + // ── Drawing sub-routines ────────────────────────────────────────────────── + + function _drawAscMc(g, data) { + const asc = data.houses.asc; + const mc = data.houses.mc; + const points = [ + { deg: asc, label: 'ASC' }, + { deg: asc + 180, label: 'DSC' }, + { deg: mc, label: 'MC' }, + { deg: mc + 180, label: 'IC' }, + ]; + const axisGroup = g.append('g').attr('class', 'nw-axes'); + points.forEach(({ deg, label }) => { + const a = _toAngle(deg, asc); + const x1 = _cx + R.houseInner * Math.cos(a); + const y1 = _cy + R.houseInner * Math.sin(a); + const x2 = _cx + R.ascMcR * Math.cos(a); + const y2 = _cy + R.ascMcR * Math.sin(a); + axisGroup.append('line') + .attr('x1', x1).attr('y1', y1) + .attr('x2', x2).attr('y2', y2) + .attr('stroke', _css('--secUser', '#c0a060')) + .attr('stroke-width', 1.5) + .attr('opacity', 0.7); + axisGroup.append('text') + .attr('x', _cx + (R.ascMcR + 12) * Math.cos(a)) + .attr('y', _cy + (R.ascMcR + 12) * Math.sin(a)) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', `${_r * 0.055}px`) + .attr('fill', _css('--secUser', '#c0a060')) + .text(label); + }); + } + + function _drawSigns(g, data) { + const asc = data.houses.asc; + const arc = d3.arc(); + const sigGroup = g.append('g').attr('class', 'nw-signs'); + + SIGNS.forEach((sign, i) => { + const startDeg = i * 30; // ecliptic 0–360 + const endDeg = startDeg + 30; + const startA = _toAngle(startDeg, asc); + const endA = _toAngle(endDeg, asc); + // D3 arc expects startAngle < endAngle in its own convention; we swap + // because our _toAngle goes counter-clockwise + const [sa, ea] = startA > endA ? [endA, startA] : [startA, endA]; + // Fill wedge + const fill = { + Fire: _css('--terUser', '#7a3030'), + Earth: _css('--priGn', '#306030'), + Air: _css('--quaUser', '#606030'), + Water: _css('--priUser', '#304070'), + }[sign.element]; + + sigGroup.append('path') + .attr('transform', `translate(${_cx},${_cy})`) + .attr('d', arc({ + innerRadius: R.signInner, + outerRadius: R.signOuter, + startAngle: sa, + endAngle: ea, + })) + .attr('fill', fill) + .attr('opacity', 0.35) + .attr('stroke', _css('--quaUser', '#444')) + .attr('stroke-width', 0.5); + + // Symbol at midpoint + const midA = (sa + ea) / 2; + sigGroup.append('text') + .attr('x', _cx + R.labelR * Math.cos(midA)) + .attr('y', _cy + R.labelR * Math.sin(midA)) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', `${_r * 0.072}px`) + .attr('fill', _css('--secUser', '#c8b060')) + .text(sign.symbol); + }); + } + + function _drawHouses(g, data) { + const { cusps, asc } = data.houses; + const arc = d3.arc(); + const houseGroup = g.append('g').attr('class', 'nw-houses'); + + cusps.forEach((cusp, i) => { + const nextCusp = cusps[(i + 1) % 12]; + const startA = _toAngle(cusp, asc); + const endA = _toAngle(nextCusp, asc); + + // Cusp radial line + houseGroup.append('line') + .attr('x1', _cx + R.houseInner * Math.cos(startA)) + .attr('y1', _cy + R.houseInner * Math.sin(startA)) + .attr('x2', _cx + R.signInner * Math.cos(startA)) + .attr('y2', _cy + R.signInner * Math.sin(startA)) + .attr('stroke', _css('--quaUser', '#555')) + .attr('stroke-width', 0.8); + + // House number at midpoint of house arc + const [sa, ea] = startA > endA ? [endA, startA] : [startA, endA]; + const midA = (sa + ea) / 2; + houseGroup.append('text') + .attr('x', _cx + R.houseNumR * Math.cos(midA)) + .attr('y', _cy + R.houseNumR * Math.sin(midA)) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', `${_r * 0.05}px`) + .attr('fill', _css('--quiUser', '#888')) + .attr('opacity', 0.8) + .text(i + 1); + + // Faint fill strip + houseGroup.append('path') + .attr('transform', `translate(${_cx},${_cy})`) + .attr('d', arc({ + innerRadius: R.houseInner, + outerRadius: R.houseOuter, + startAngle: sa, + endAngle: ea, + })) + .attr('fill', (i % 2 === 0) + ? _css('--quaUser', '#3a3a3a') + : _css('--quiUser', '#2e2e2e')) + .attr('opacity', 0.15); + }); + } + + function _drawPlanets(g, data) { + const asc = data.houses.asc; + const planetGroup = g.append('g').attr('class', 'nw-planets'); + const ascAngle = _toAngle(asc, asc); // start position for animation + + Object.entries(data.planets).forEach(([name, pdata], idx) => { + const finalA = _toAngle(pdata.degree, asc); + + // Circle behind symbol + const circle = planetGroup.append('circle') + .attr('cx', _cx + R.planetR * Math.cos(ascAngle)) + .attr('cy', _cy + R.planetR * Math.sin(ascAngle)) + .attr('r', _r * 0.038) + .attr('fill', pdata.retrograde + ? _css('--terUser', '#7a3030') + : _css('--priUser', '#304070')) + .attr('opacity', 0.6); + + // Symbol + const label = planetGroup.append('text') + .attr('x', _cx + R.planetR * Math.cos(ascAngle)) + .attr('y', _cy + R.planetR * Math.sin(ascAngle)) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', `${_r * 0.068}px`) + .attr('fill', _css('--ninUser', '#e0d0a0')) + .text(PLANET_SYMBOLS[name] || name[0]); + + // Retrograde indicator + if (pdata.retrograde) { + planetGroup.append('text') + .attr('x', _cx + (R.planetR + _r * 0.055) * Math.cos(ascAngle)) + .attr('y', _cy + (R.planetR + _r * 0.055) * Math.sin(ascAngle)) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', `${_r * 0.040}px`) + .attr('fill', _css('--terUser', '#c04040')) + .attr('class', 'nw-rx') + .text('℞'); + } + + // Animate from ASC → final position (staggered) + const interpAngle = d3.interpolate(ascAngle, finalA); + [circle, label].forEach(el => { + el.transition() + .delay(idx * 40) + .duration(600) + .ease(d3.easeQuadOut) + .attrTween('cx', () => t => _cx + R.planetR * Math.cos(interpAngle(t))) + .attrTween('cy', () => t => _cy + R.planetR * Math.sin(interpAngle(t))); + }); + + // Retrograde ℞ — move together with planet + if (pdata.retrograde) { + planetGroup.select('.nw-rx:last-child') + .transition() + .delay(idx * 40) + .duration(600) + .ease(d3.easeQuadOut) + .attrTween('x', () => t => _cx + (R.planetR + _r * 0.055) * Math.cos(interpAngle(t))) + .attrTween('y', () => t => _cy + (R.planetR + _r * 0.055) * Math.sin(interpAngle(t))); + } + }); + } + + function _drawAspects(g, data) { + const asc = data.houses.asc; + const aspectGroup = g.append('g').attr('class', 'nw-aspects').attr('opacity', 0.45); + + // Build degree lookup + const degrees = {}; + Object.entries(data.planets).forEach(([name, p]) => { degrees[name] = p.degree; }); + + data.aspects.forEach(({ planet1, planet2, type }) => { + if (degrees[planet1] === undefined || degrees[planet2] === undefined) return; + const a1 = _toAngle(degrees[planet1], asc); + const a2 = _toAngle(degrees[planet2], asc); + aspectGroup.append('line') + .attr('x1', _cx + R.aspectR * Math.cos(a1)) + .attr('y1', _cy + R.aspectR * Math.sin(a1)) + .attr('x2', _cx + R.aspectR * Math.cos(a2)) + .attr('y2', _cy + R.aspectR * Math.sin(a2)) + .attr('stroke', ASPECT_COLOURS[type] || '#888') + .attr('stroke-width', type === 'Opposition' || type === 'Square' ? 1.2 : 0.8); + }); + } + + function _drawElements(g, data) { + const el = data.elements; + const total = (el.Fire || 0) + (el.Earth || 0) + (el.Air || 0) + (el.Water || 0); + if (total === 0) return; + + const pieData = ['Fire', 'Earth', 'Air', 'Water'].map(k => ({ + key: k, value: el[k] || 0, + })); + + const pie = d3.pie().value(d => d.value).sort(null)(pieData); + const arc = d3.arc().innerRadius(R.elementInner).outerRadius(R.elementOuter); + + const elGroup = g.append('g') + .attr('class', 'nw-elements') + .attr('transform', `translate(${_cx},${_cy})`); + + elGroup.selectAll('path') + .data(pie) + .join('path') + .attr('d', arc) + .attr('fill', d => ELEMENT_COLOURS[d.data.key]) + .attr('opacity', 0.7) + .attr('stroke', _css('--quaUser', '#444')) + .attr('stroke-width', 0.5); + + // Time + Space emergent counts as text + ['Time', 'Space'].forEach((key, i) => { + const count = el[key] || 0; + if (count === 0) return; + g.append('text') + .attr('x', _cx + (i === 0 ? -1 : 1) * R.elementInner * 0.6) + .attr('y', _cy) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', `${_r * 0.045}px`) + .attr('fill', ELEMENT_COLOURS[key]) + .attr('opacity', 0.8) + .text(`${key[0]}${count}`); + }); + } + + // ── Public API ──────────────────────────────────────────────────────────── + + function draw(svgEl, data) { + _svg = d3.select(svgEl); + _svg.selectAll('*').remove(); + _layout(svgEl); + + const g = _svg.append('g').attr('class', 'nw-root'); + + // Outer circle border + g.append('circle') + .attr('cx', _cx).attr('cy', _cy).attr('r', R.signOuter) + .attr('fill', 'none') + .attr('stroke', _css('--quaUser', '#555')) + .attr('stroke-width', 1); + + // Inner filled disc (aspect area background) + g.append('circle') + .attr('cx', _cx).attr('cy', _cy).attr('r', R.elementOuter) + .attr('fill', _css('--quaUser', '#252525')) + .attr('opacity', 0.4); + + _drawAspects(g, data); + _drawElements(g, data); + _drawHouses(g, data); + _drawSigns(g, data); + _drawAscMc(g, data); + _drawPlanets(g, data); + } + + function redraw(data) { + if (!_svg) return; + const svgNode = _svg.node(); + draw(svgNode, data); + } + + function clear() { + if (_svg) _svg.selectAll('*').remove(); + } + + return { draw, redraw, clear }; +})(); diff --git a/src/static_src/scss/_natus.scss b/src/static_src/scss/_natus.scss new file mode 100644 index 0000000..80d304f --- /dev/null +++ b/src/static_src/scss/_natus.scss @@ -0,0 +1,315 @@ +// ─── Natus (Pick Sky) overlay ──────────────────────────────────────────────── +// Gaussian backdrop + centred modal, matching the gate/sig overlay pattern. +// Open state: html.natus-open (added by JS on PICK SKY click). +// +// Layout: header / two-column body (form | wheel) / footer +// Collapses to stacked single-column below 600 px. + +// ── Scroll-lock ─────────────────────────────────────────────────────────────── + +html.natus-open { + overflow: hidden; + + #id_aperture_fill { opacity: 1; } +} + +// ── Backdrop ────────────────────────────────────────────────────────────────── + +.natus-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(5px); + z-index: 100; + pointer-events: none; + + // Hidden until html.natus-open + opacity: 0; + transition: opacity 0.15s ease; +} + +html.natus-open .natus-backdrop { + opacity: 1; +} + +// ── Overlay shell (positions + scrolls the modal) ───────────────────────────── + +.natus-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 120; + overflow-y: auto; + overscroll-behavior: contain; + pointer-events: none; + + // Hidden until html.natus-open + visibility: hidden; + + @media (orientation: landscape) { + $sidebar-w: 4rem; + left: $sidebar-w; + right: $sidebar-w; + } +} + +html.natus-open .natus-overlay { + visibility: visible; + pointer-events: none; // modal itself is pointer-events: auto +} + +// ── Modal panel ─────────────────────────────────────────────────────────────── + +.natus-modal { + pointer-events: auto; + display: flex; + flex-direction: column; + width: 92vw; + max-width: 840px; + max-height: 92vh; + border: 0.1rem solid rgba(var(--terUser), 0.25); + border-radius: 0.5rem; + background: rgba(var(--priUser), 1); + overflow: hidden; + + // Fade + slide in + opacity: 0; + transform: translateY(1rem); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +html.natus-open .natus-modal { + opacity: 1; + transform: translateY(0); +} + +// ── Header ──────────────────────────────────────────────────────────────────── + +.natus-modal-header { + flex-shrink: 0; + padding: 0.6rem 1rem; + border-bottom: 0.1rem solid rgba(var(--terUser), 0.15); + display: flex; + flex-direction: row; + align-items: baseline; + gap: 0.75rem; + + h2 { + margin: 0; + font-size: 1.1rem; + letter-spacing: 0.06em; + + span { color: rgba(var(--secUser), 1); } + } + + p { + margin: 0; + font-size: 0.7rem; + opacity: 0.55; + } +} + +// ── Body: two columns ───────────────────────────────────────────────────────── + +.natus-modal-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + overflow: hidden; +} + +// Form column — fixed width, scrollable +.natus-form-col { + flex: 0 0 240px; + overflow-y: auto; + padding: 0.9rem 1rem; + border-right: 0.1rem solid rgba(var(--terUser), 0.12); + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +// Wheel column — fills remaining space +.natus-wheel-col { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem; + background: rgba(var(--priUser), 0.5); + position: relative; +} + +.natus-svg { + display: block; + width: 100%; + height: 100%; + aspect-ratio: 1 / 1; + max-width: 400px; + max-height: 400px; +} + +// ── Form fields ─────────────────────────────────────────────────────────────── + +.natus-field { + display: flex; + flex-direction: column; + gap: 0.25rem; + + label { + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: rgba(var(--quaUser), 0.8); + } + + input { + width: 100%; + // inherits global input styles + } + + small { + font-size: 0.58rem; + opacity: 0.45; + line-height: 1.3; + } +} + +// Place search field wrapper: text input + geo button inline +.natus-place-field { position: relative; } + +.natus-place-wrap { + display: flex; + gap: 0.4rem; + align-items: center; + + input { flex: 1; min-width: 0; } + .btn-sm { flex-shrink: 0; } +} + +// Nominatim suggestion dropdown +.natus-suggestions { + position: absolute; + left: 0; + right: 0; + top: calc(100% + 2px); + z-index: 10; + background: rgba(var(--priUser), 1); + border: 0.1rem solid rgba(var(--terUser), 0.3); + border-radius: 0.3rem; + overflow-y: auto; + max-height: 10rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.natus-suggestion-item { + display: block; + width: 100%; + padding: 0.4rem 0.6rem; + text-align: left; + background: none; + border: none; + border-bottom: 0.05rem solid rgba(var(--terUser), 0.1); + font-size: 0.65rem; + color: rgba(var(--ninUser), 0.85); + cursor: pointer; + line-height: 1.35; + + &:last-child { border-bottom: none; } + + &:hover, &:focus { + background: rgba(var(--terUser), 0.12); + color: rgba(var(--ninUser), 1); + outline: none; + } +} + +// Coords row: lat | lon (read-only, populated by place selection) +.natus-coords { + flex-direction: row; + align-items: flex-end; + gap: 0.4rem; + + > div { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + + label { + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: rgba(var(--quaUser), 0.8); + } + + input { + width: 100%; + opacity: 0.6; + cursor: default; + } + } +} + +// ── Status line ─────────────────────────────────────────────────────────────── + +.natus-status { + font-size: 0.65rem; + opacity: 0.6; + min-height: 1rem; + text-align: center; + + &--error { + opacity: 1; + color: rgba(var(--priRd), 1); + } +} + +// ── Footer ──────────────────────────────────────────────────────────────────── + +.natus-modal-footer { + flex-shrink: 0; + padding: 0.6rem 1rem; + border-top: 0.1rem solid rgba(var(--terUser), 0.15); + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +// ── Narrow / portrait ───────────────────────────────────────────────────────── + +@media (max-width: 600px) { + .natus-modal { + width: 96vw; + max-height: 96vh; + } + + .natus-modal-body { + flex-direction: column; + overflow-y: auto; + } + + .natus-form-col { + flex: 0 0 auto; + border-right: none; + border-bottom: 0.1rem solid rgba(var(--terUser), 0.12); + } + + .natus-wheel-col { + flex: 0 0 280px; + } +} + +// ── Sidebar z-index sink (landscape sidebars must go below backdrop) ─────────── + +@media (orientation: landscape) { + html.natus-open body .container .navbar, + html.natus-open body #id_footer { + z-index: 90; + } +} diff --git a/src/static_src/scss/core.scss b/src/static_src/scss/core.scss index 6215a88..cf0f2d4 100644 --- a/src/static_src/scss/core.scss +++ b/src/static_src/scss/core.scss @@ -7,6 +7,7 @@ @import 'palette-picker'; @import 'room'; @import 'card-deck'; +@import 'natus'; @import 'tray'; @import 'billboard'; @import 'game-kit'; diff --git a/src/templates/apps/gameboard/_partials/_natus_overlay.html b/src/templates/apps/gameboard/_partials/_natus_overlay.html new file mode 100644 index 0000000..d83c963 --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_natus_overlay.html @@ -0,0 +1,340 @@ +{% load static %} +{# PICK SKY overlay — natal chart entry + D3 wheel preview #} +{# Included in room.html when table_status == "SKY_SELECT" #} +{# Opens when user clicks #id_pick_sky_btn; html.natus-open controls #} +{# visibility via CSS — backdrop-filter blur + centred modal. #} + +
+
+ +
+ +
+

PICK SKY

+

Enter your birth details to generate your natal chart.

+
+ +
+ + {# ── Form column ──────────────────────────────────────── #} +
+
+ +
+ + +
+ +
+ + + Local time at birth place. Use 12:00 if unknown. +
+ +
+ +
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + +
+ +
+ +
+
+ + {# ── Wheel column ─────────────────────────────────────── #} +
+ +
+ +
{# /.natus-modal-body #} + +
+ + +
+ +
{# /.natus-modal #} +
{# /.natus-overlay #} + + + + diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 895a505..1b449f6 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -67,6 +67,11 @@ {% include "apps/gameboard/_partials/_sig_select_overlay.html" %} {% endif %} + {# Natus (Pick Sky) overlay — natal chart entry #} + {% if room.table_status == "SKY_SELECT" %} + {% include "apps/gameboard/_partials/_natus_overlay.html" %} + {% endif %} + {% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %} {% include "apps/gameboard/_partials/_table_positions.html" %} {% endif %}