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:
@@ -1,11 +1,14 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
import zoneinfo
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests as http_requests
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -13,6 +16,7 @@ from apps.drama.models import GameEvent, record
|
||||
from django.db.models import Case, IntegerField, Value, When
|
||||
|
||||
from apps.epic.models import (
|
||||
Character,
|
||||
GateSlot, Room, RoomInvite, SIG_SEAT_ORDER, SigReservation, TableSeat,
|
||||
TarotCard, TarotDeck,
|
||||
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
|
||||
@@ -916,3 +920,167 @@ def tarot_deal(request, room_id):
|
||||
"positions": positions,
|
||||
})
|
||||
|
||||
|
||||
# ── Natus (natal chart) ───────────────────────────────────────────────────────
|
||||
|
||||
def _planet_house(degree, cusps):
|
||||
"""Return 1-based house number for a planet at ecliptic degree.
|
||||
|
||||
cusps is the 12-element list from PySwiss where cusps[i] is the start of
|
||||
house i+1. Handles the wrap-around case where a cusp crosses 0°/360°.
|
||||
"""
|
||||
degree = degree % 360
|
||||
for i in range(12):
|
||||
start = cusps[i] % 360
|
||||
end = cusps[(i + 1) % 12] % 360
|
||||
if start < end:
|
||||
if start <= degree < end:
|
||||
return i + 1
|
||||
else: # wrap-around: e.g. cusp at 350° → next at 10°
|
||||
if degree >= start or degree < end:
|
||||
return i + 1
|
||||
return 1
|
||||
|
||||
|
||||
def _compute_distinctions(planets, houses):
|
||||
"""Return dict {house_number_str: planet_count} for all 12 houses."""
|
||||
cusps = houses['cusps']
|
||||
counts = {str(i): 0 for i in range(1, 13)}
|
||||
for planet_data in planets.values():
|
||||
h = _planet_house(planet_data['degree'], cusps)
|
||||
counts[str(h)] += 1
|
||||
return counts
|
||||
|
||||
|
||||
@login_required
|
||||
def natus_preview(request, room_id):
|
||||
"""Proxy GET to PySwiss /api/chart/ and augment with distinction counts.
|
||||
|
||||
Query params:
|
||||
date — YYYY-MM-DD (local birth date)
|
||||
time — HH:MM (local birth time, default 12:00)
|
||||
tz — IANA timezone string (optional; auto-resolved from lat/lon if absent)
|
||||
lat — float
|
||||
lon — float
|
||||
|
||||
If tz is absent or blank, calls PySwiss /api/tz/ to resolve it from the
|
||||
coordinates before converting the local datetime to UTC.
|
||||
|
||||
Response includes a 'timezone' key (resolved or supplied) so the client
|
||||
can back-fill the timezone field after the first wheel render.
|
||||
|
||||
No database writes — safe for debounced real-time calls.
|
||||
"""
|
||||
seat = _canonical_user_seat(Room.objects.get(id=room_id), request.user)
|
||||
if seat is None:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
date_str = request.GET.get('date')
|
||||
time_str = request.GET.get('time', '12:00')
|
||||
tz_str = request.GET.get('tz', '').strip()
|
||||
lat_str = request.GET.get('lat')
|
||||
lon_str = request.GET.get('lon')
|
||||
|
||||
if not date_str or lat_str is None or lon_str is None:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
try:
|
||||
lat = float(lat_str)
|
||||
lon = float(lon_str)
|
||||
except ValueError:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||
return HttpResponse(status=400)
|
||||
|
||||
# Resolve timezone from coordinates if not supplied
|
||||
if not tz_str:
|
||||
try:
|
||||
tz_resp = http_requests.get(
|
||||
settings.PYSWISS_URL + '/api/tz/',
|
||||
params={'lat': lat_str, 'lon': lon_str},
|
||||
timeout=5,
|
||||
)
|
||||
tz_resp.raise_for_status()
|
||||
tz_str = tz_resp.json().get('timezone') or 'UTC'
|
||||
except Exception:
|
||||
tz_str = 'UTC'
|
||||
|
||||
try:
|
||||
tz = zoneinfo.ZoneInfo(tz_str)
|
||||
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
||||
return HttpResponse(status=400)
|
||||
|
||||
try:
|
||||
local_dt = datetime.strptime(f'{date_str} {time_str}', '%Y-%m-%d %H:%M')
|
||||
local_dt = local_dt.replace(tzinfo=tz)
|
||||
utc_dt = local_dt.astimezone(zoneinfo.ZoneInfo('UTC'))
|
||||
dt_iso = utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
except ValueError:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
settings.PYSWISS_URL + '/api/chart/',
|
||||
params={'dt': dt_iso, 'lat': lat_str, 'lon': lon_str},
|
||||
timeout=5,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except Exception:
|
||||
return HttpResponse(status=502)
|
||||
|
||||
data = resp.json()
|
||||
data['distinctions'] = _compute_distinctions(data['planets'], data['houses'])
|
||||
data['timezone'] = tz_str
|
||||
return JsonResponse(data)
|
||||
|
||||
|
||||
@login_required
|
||||
def natus_save(request, room_id):
|
||||
"""Create or update the draft Character for the requesting gamer's seat.
|
||||
|
||||
POST body (JSON):
|
||||
birth_dt — ISO 8601 UTC datetime
|
||||
birth_lat — float
|
||||
birth_lon — float
|
||||
birth_place — display string (optional)
|
||||
house_system — single char, default 'O'
|
||||
chart_data — full PySwiss response dict (incl. distinctions)
|
||||
action — 'save' (default) or 'confirm'
|
||||
|
||||
On 'confirm': sets confirmed_at, locking the Character.
|
||||
Returns: {id, confirmed}
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return HttpResponse(status=405)
|
||||
|
||||
room = Room.objects.get(id=room_id)
|
||||
seat = _canonical_user_seat(room, request.user)
|
||||
if seat is None:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
# Find or create the active draft (unconfirmed, unretired) for this seat
|
||||
char = Character.objects.filter(
|
||||
seat=seat, confirmed_at__isnull=True, retired_at__isnull=True,
|
||||
).first()
|
||||
if char is None:
|
||||
char = Character(seat=seat)
|
||||
|
||||
char.birth_dt = body.get('birth_dt')
|
||||
char.birth_lat = body.get('birth_lat')
|
||||
char.birth_lon = body.get('birth_lon')
|
||||
char.birth_place = body.get('birth_place', '')
|
||||
char.house_system = body.get('house_system', Character.PORPHYRY)
|
||||
char.chart_data = body.get('chart_data')
|
||||
|
||||
if body.get('action') == 'confirm':
|
||||
char.confirmed_at = timezone.now()
|
||||
|
||||
char.save()
|
||||
return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user