PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz
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:
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
148
pyswiss/apps/charts/tests/unit/test_calc.py
Normal file
148
pyswiss/apps/charts/tests/unit/test_calc.py
Normal 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)
|
||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
65
src/apps/epic/migrations/0032_astro_reference_tables.py
Normal file
65
src/apps/epic/migrations/0032_astro_reference_tables.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
106
src/apps/epic/migrations/0033_seed_astro_reference_tables.py
Normal file
106
src/apps/epic/migrations/0033_seed_astro_reference_tables.py
Normal 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),
|
||||||
|
]
|
||||||
35
src/apps/epic/migrations/0034_character_model.py
Normal file
35
src/apps/epic/migrations/0034_character_model.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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) # 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
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|||||||
421
src/apps/gameboard/static/apps/gameboard/natus-wheel.js
Normal file
421
src/apps/gameboard/static/apps/gameboard/natus-wheel.js
Normal 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 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 };
|
||||||
|
})();
|
||||||
315
src/static_src/scss/_natus.scss
Normal file
315
src/static_src/scss/_natus.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
340
src/templates/apps/gameboard/_partials/_natus_overlay.html
Normal file
340
src/templates/apps/gameboard/_partials/_natus_overlay.html
Normal 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>
|
||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user