PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

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 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-14 02:09:26 -04:00
parent 44cf399352
commit 6248d95bf3
17 changed files with 1909 additions and 3 deletions

View File

@@ -19,6 +19,14 @@ SIGN_ELEMENT = {
'Cancer': 'Water', 'Scorpio': 'Water', 'Pisces': 'Water', '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 = { PLANET_CODES = {
'Sun': swe.SUN, 'Sun': swe.SUN,
'Moon': swe.MOON, 'Moon': swe.MOON,
@@ -90,3 +98,33 @@ def get_element_counts(planets):
counts['Space'] = max_seq - 1 counts['Space'] = max_seq - 1
return counts 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

View File

@@ -1,7 +1,7 @@
""" """
Integration tests for the PySwiss chart calculation API. 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. They verify the HTTP contract using Django's test client.
Run: Run:
@@ -18,6 +18,11 @@ from django.test import TestCase
J2000 = '2000-01-01T12:00:00Z' J2000 = '2000-01-01T12:00:00Z'
LONDON = {'lat': 51.5074, 'lon': -0.1278} 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): class ChartApiTest(TestCase):
"""GET /api/chart/ — calculate a natal chart from datetime + coordinates.""" """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.""" """House system override is superuser-only; plain requests get 403."""
response = self._get({'dt': J2000, **LONDON, 'house_system': 'P'}) response = self._get({'dt': J2000, **LONDON, 'house_system': 'P'})
self.assertEqual(response.status_code, 403) 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')

View File

@@ -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)

View File

@@ -4,4 +4,5 @@ from . import views
urlpatterns = [ urlpatterns = [
path('chart/', views.chart, name='chart'), path('chart/', views.chart, name='chart'),
path('charts/', views.charts_list, name='charts_list'), path('charts/', views.charts_list, name='charts_list'),
path('tz/', views.timezone_lookup, name='timezone_lookup'),
] ]

View File

@@ -1,11 +1,13 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from timezonefinder import TimezoneFinder
import swisseph as swe import swisseph as swe
from .calc import ( from .calc import (
DEFAULT_HOUSE_SYSTEM, DEFAULT_HOUSE_SYSTEM,
calculate_aspects,
get_element_counts, get_element_counts,
get_julian_day, get_julian_day,
get_planet_positions, get_planet_positions,
@@ -61,10 +63,41 @@ def chart(request):
'planets': planets, 'planets': planets,
'houses': houses, 'houses': houses,
'elements': get_element_counts(planets), 'elements': get_element_counts(planets),
'aspects': calculate_aspects(planets),
'house_system': house_system, '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): def charts_list(request):
date_from_str = request.GET.get('date_from') date_from_str = request.GET.get('date_from')
date_to_str = request.GET.get('date_to') date_to_str = request.GET.get('date_to')

View File

@@ -2,3 +2,4 @@ django==6.0.4
django-cors-headers==4.3.1 django-cors-headers==4.3.1
gunicorn==23.0.0 gunicorn==23.0.0
pyswisseph==2.10.3.2 pyswisseph==2.10.3.2
timezonefinder==8.2.2

View File

@@ -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'],
},
),
]

View File

@@ -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),
]

View File

@@ -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'],
},
),
]

View File

@@ -470,3 +470,141 @@ def active_sig_seat(room):
if seat.significator_id is None: if seat.significator_id is None:
return seat return seat
return None 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) # 011, 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) # 09, 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) # 112
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

View File

@@ -25,4 +25,6 @@ urlpatterns = [
path('room/<uuid:room_id>/abandon', views.abandon_room, name='abandon_room'), path('room/<uuid:room_id>/abandon', views.abandon_room, name='abandon_room'),
path('room/<uuid:room_id>/tarot/', views.tarot_deck, name='tarot_deck'), path('room/<uuid:room_id>/tarot/', views.tarot_deck, name='tarot_deck'),
path('room/<uuid:room_id>/tarot/deal', views.tarot_deal, name='tarot_deal'), path('room/<uuid:room_id>/tarot/deal', views.tarot_deal, name='tarot_deal'),
path('room/<uuid:room_id>/natus/preview', views.natus_preview, name='natus_preview'),
path('room/<uuid:room_id>/natus/save', views.natus_save, name='natus_save'),
] ]

View File

@@ -1,11 +1,14 @@
import json 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 asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db import transaction from django.db import transaction
from django.http import HttpResponse from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils import timezone 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 django.db.models import Case, IntegerField, Value, When
from apps.epic.models import ( from apps.epic.models import (
Character,
GateSlot, Room, RoomInvite, SIG_SEAT_ORDER, SigReservation, TableSeat, GateSlot, Room, RoomInvite, SIG_SEAT_ORDER, SigReservation, TableSeat,
TarotCard, TarotDeck, TarotCard, TarotDeck,
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards, active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
@@ -916,3 +920,167 @@ def tarot_deal(request, room_id):
"positions": positions, "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})

View File

@@ -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 0360
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 };
})();

View File

@@ -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;
}
}

View File

@@ -7,6 +7,7 @@
@import 'palette-picker'; @import 'palette-picker';
@import 'room'; @import 'room';
@import 'card-deck'; @import 'card-deck';
@import 'natus';
@import 'tray'; @import 'tray';
@import 'billboard'; @import 'billboard';
@import 'game-kit'; @import 'game-kit';

View File

@@ -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. #}
<div class="natus-backdrop"></div>
<div class="natus-overlay"
id="id_natus_overlay"
data-preview-url="{% url 'epic:natus_preview' room.id %}"
data-save-url="{% url 'epic:natus_save' room.id %}">
<div class="natus-modal">
<header class="natus-modal-header">
<h2>PICK <span>SKY</span></h2>
<p>Enter your birth details to generate your natal chart.</p>
</header>
<div class="natus-modal-body">
{# ── Form column ──────────────────────────────────────── #}
<div class="natus-form-col">
<form id="id_natus_form" autocomplete="off">
<div class="natus-field">
<label for="id_nf_date">Birth date</label>
<input id="id_nf_date" name="date" type="date" required>
</div>
<div class="natus-field">
<label for="id_nf_time">Birth time</label>
<input id="id_nf_time" name="time" type="time" value="12:00">
<small>Local time at birth place. Use 12:00 if unknown.</small>
</div>
<div class="natus-field natus-place-field">
<label for="id_nf_place">Birth place</label>
<div class="natus-place-wrap">
<input id="id_nf_place" name="place" type="text"
placeholder="Start typing a city…"
autocomplete="off">
<button type="button" id="id_nf_geolocate"
class="btn btn-secondary btn-sm"
title="Use device location">
<i class="fa-solid fa-location-crosshairs"></i>
</button>
</div>
<div id="id_nf_suggestions" class="natus-suggestions" hidden></div>
</div>
<div class="natus-field natus-coords">
<div>
<label>Latitude</label>
<input id="id_nf_lat" name="lat" type="text"
placeholder="—" readonly tabindex="-1">
</div>
<div>
<label>Longitude</label>
<input id="id_nf_lon" name="lon" type="text"
placeholder="—" readonly tabindex="-1">
</div>
</div>
<div class="natus-field">
<label for="id_nf_tz">Timezone</label>
<input id="id_nf_tz" name="tz" type="text"
placeholder="auto-detected from location">
<small id="id_nf_tz_hint"></small>
</div>
</form>
<div id="id_natus_status" class="natus-status"></div>
</div>
{# ── Wheel column ─────────────────────────────────────── #}
<div class="natus-wheel-col">
<svg id="id_natus_svg" class="natus-svg"></svg>
</div>
</div>{# /.natus-modal-body #}
<footer class="natus-modal-footer">
<button type="button" id="id_natus_cancel" class="btn btn-cancel">NVM</button>
<button type="button" id="id_natus_confirm" class="btn btn-primary" disabled>
Save Sky
</button>
</footer>
</div>{# /.natus-modal #}
</div>{# /.natus-overlay #}
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="{% static 'apps/gameboard/natus-wheel.js' %}"></script>
<script>
(function () {
'use strict';
const overlay = document.getElementById('id_natus_overlay');
const form = document.getElementById('id_natus_form');
const svgEl = document.getElementById('id_natus_svg');
const statusEl = document.getElementById('id_natus_status');
const confirmBtn = document.getElementById('id_natus_confirm');
const cancelBtn = document.getElementById('id_natus_cancel');
const geoBtn = document.getElementById('id_nf_geolocate');
const placeInput = document.getElementById('id_nf_place');
const latInput = document.getElementById('id_nf_lat');
const lonInput = document.getElementById('id_nf_lon');
const tzInput = document.getElementById('id_nf_tz');
const tzHint = document.getElementById('id_nf_tz_hint');
const suggestions = document.getElementById('id_nf_suggestions');
const PREVIEW_URL = overlay.dataset.previewUrl;
const SAVE_URL = overlay.dataset.saveUrl;
const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
const USER_AGENT = 'EarthmanRPG/1.0 (https://earthmanrpg.me)';
let _lastChartData = null;
let _placeDebounce = null;
let _chartDebounce = null;
const PLACE_DELAY = 400; // ms — Nominatim polite rate
const CHART_DELAY = 300; // ms — chart preview debounce
// ── Open / Close ──────────────────────────────────────────────────────────
function openNatus() {
document.documentElement.classList.add('natus-open');
}
function closeNatus() {
document.documentElement.classList.remove('natus-open');
hideSuggestions();
}
const pickSkyBtn = document.getElementById('id_pick_sky_btn');
if (pickSkyBtn) pickSkyBtn.addEventListener('click', openNatus);
cancelBtn.addEventListener('click', closeNatus);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeNatus(); });
// ── Status helper ─────────────────────────────────────────────────────────
function setStatus(msg, type) {
statusEl.textContent = msg;
statusEl.className = 'natus-status' + (type ? ` natus-status--${type}` : '');
}
// ── Nominatim place search ────────────────────────────────────────────────
placeInput.addEventListener('input', () => {
clearTimeout(_placeDebounce);
const q = placeInput.value.trim();
if (q.length < 3) { hideSuggestions(); return; }
_placeDebounce = setTimeout(() => fetchPlaces(q), PLACE_DELAY);
});
placeInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hideSuggestions();
});
document.addEventListener('click', (e) => {
if (!placeInput.contains(e.target) && !suggestions.contains(e.target)) {
hideSuggestions();
}
});
function fetchPlaces(query) {
fetch(`${NOMINATIM}?format=json&q=${encodeURIComponent(query)}&limit=6`, {
headers: { 'User-Agent': USER_AGENT },
})
.then(r => r.json())
.then(results => {
if (!results.length) { hideSuggestions(); return; }
renderSuggestions(results);
})
.catch(() => hideSuggestions());
}
function renderSuggestions(results) {
suggestions.innerHTML = '';
results.forEach(place => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'natus-suggestion-item';
item.textContent = place.display_name;
item.addEventListener('click', () => selectPlace(place));
suggestions.appendChild(item);
});
suggestions.hidden = false;
}
function hideSuggestions() {
suggestions.hidden = true;
suggestions.innerHTML = '';
}
function selectPlace(place) {
placeInput.value = place.display_name;
latInput.value = parseFloat(place.lat).toFixed(4);
lonInput.value = parseFloat(place.lon).toFixed(4);
hideSuggestions();
schedulePreview();
}
// ── Geolocation ───────────────────────────────────────────────────────────
geoBtn.addEventListener('click', () => {
if (!navigator.geolocation) {
setStatus('Geolocation not supported by this browser.', 'error');
return;
}
setStatus('Requesting device location…');
navigator.geolocation.getCurrentPosition(
(pos) => {
latInput.value = pos.coords.latitude.toFixed(4);
lonInput.value = pos.coords.longitude.toFixed(4);
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latInput.value}&lon=${lonInput.value}`, {
headers: { 'User-Agent': USER_AGENT },
})
.then(r => r.json())
.then(data => { placeInput.value = _cityName(data.address) || data.display_name || ''; })
.catch(() => {});
setStatus('');
schedulePreview();
},
() => setStatus('Location access denied.', 'error'),
);
});
// Build a "City, State, Country" string from a Nominatim address object.
// Prefers the most specific incorporated place name available.
function _cityName(addr) {
if (!addr) return '';
const city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || '';
const region = addr.state || addr.county || addr.state_district || '';
const country = addr.country || '';
return [city, region, country].filter(Boolean).join(', ');
}
// ── Debounced chart preview ───────────────────────────────────────────────
// Trigger on date / time / tz changes (coords come via selectPlace / geolocation)
form.addEventListener('input', (e) => {
if (e.target === placeInput) return; // place triggers via selectPlace
clearTimeout(_chartDebounce);
_chartDebounce = setTimeout(schedulePreview, CHART_DELAY);
});
function _formReady() {
return document.getElementById('id_nf_date').value &&
latInput.value && lonInput.value;
}
function schedulePreview() {
if (!_formReady()) return;
const date = document.getElementById('id_nf_date').value;
const time = document.getElementById('id_nf_time').value || '12:00';
const lat = latInput.value;
const lon = lonInput.value;
const tz = tzInput.value.trim(); // optional — proxy resolves if blank
const params = new URLSearchParams({ date, time, lat, lon });
if (tz) params.set('tz', tz);
setStatus('Calculating…');
confirmBtn.disabled = true;
fetch(`${PREVIEW_URL}?${params}`, { credentials: 'same-origin' })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
_lastChartData = data;
// Back-fill timezone field from proxy response (first render)
if (!tzInput.value && data.timezone) {
tzInput.value = data.timezone;
tzHint.textContent = 'Auto-detected from coordinates.';
}
setStatus('');
confirmBtn.disabled = false;
if (svgEl.querySelector('*')) {
NatusWheel.redraw(data);
} else {
NatusWheel.draw(svgEl, data);
}
})
.catch(err => {
setStatus(`Could not fetch chart: ${err.message}`, 'error');
confirmBtn.disabled = true;
});
}
// ── Save ──────────────────────────────────────────────────────────────────
confirmBtn.addEventListener('click', () => {
if (!_lastChartData) return;
confirmBtn.disabled = true;
setStatus('Saving…');
const payload = {
birth_dt: `${document.getElementById('id_nf_date').value}T${document.getElementById('id_nf_time').value || '12:00'}:00`,
birth_lat: parseFloat(latInput.value),
birth_lon: parseFloat(lonInput.value),
birth_place: placeInput.value,
house_system: _lastChartData.house_system || 'O',
chart_data: _lastChartData,
action: 'confirm',
};
fetch(SAVE_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': _getCsrf() },
body: JSON.stringify(payload),
})
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(() => {
setStatus('Sky saved!');
setTimeout(closeNatus, 1200);
})
.catch(err => {
setStatus(`Save failed: ${err.message}`, 'error');
confirmBtn.disabled = false;
});
});
// ── CSRF ──────────────────────────────────────────────────────────────────
function _getCsrf() {
const m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
})();
</script>

View File

@@ -67,6 +67,11 @@
{% include "apps/gameboard/_partials/_sig_select_overlay.html" %} {% include "apps/gameboard/_partials/_sig_select_overlay.html" %}
{% endif %} {% 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" %} {% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %}
{% include "apps/gameboard/_partials/_table_positions.html" %} {% include "apps/gameboard/_partials/_table_positions.html" %}
{% endif %} {% endif %}