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

PySwiss:
- calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs)
- /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone)
- aspects included in /api/chart/ response
- timezonefinder==8.2.2 added to requirements
- 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields)

Main app:
- Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033)
- Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross,
  confirmed_at/retired_at lifecycle (migration 0034)
- natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution,
  computes planet-in-house distinctions, returns enriched JSON
- natus_save view: find-or-create draft Character, confirmed_at on action='confirm'
- natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects,
  ASC/MC axes); NatusWheel.draw() / redraw() / clear()
- _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button
  with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill,
  NVM / SAVE SKY footer; html.natus-open class toggle pattern
- _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown,
  portrait collapse at 600px, landscape sidebar z-index sink
- room.html: include overlay when table_status == SKY_SELECT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-14 02:09:26 -04:00
parent 44cf399352
commit 6248d95bf3
17 changed files with 1909 additions and 3 deletions

View File

@@ -0,0 +1,65 @@
# Generated by Django 6.0 on 2026-04-14 05:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0031_sig_ready_sky_select'),
]
operations = [
migrations.CreateModel(
name='AspectType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, unique=True)),
('symbol', models.CharField(max_length=5)),
('angle', models.PositiveSmallIntegerField()),
('orb', models.FloatField()),
],
options={
'ordering': ['angle'],
},
),
migrations.CreateModel(
name='HouseLabel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('number', models.PositiveSmallIntegerField(unique=True)),
('name', models.CharField(max_length=30)),
('keywords', models.CharField(blank=True, max_length=100)),
],
options={
'ordering': ['number'],
},
),
migrations.CreateModel(
name='Planet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, unique=True)),
('symbol', models.CharField(max_length=5)),
('order', models.PositiveSmallIntegerField(unique=True)),
],
options={
'ordering': ['order'],
},
),
migrations.CreateModel(
name='Sign',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, unique=True)),
('symbol', models.CharField(max_length=5)),
('element', models.CharField(choices=[('Fire', 'Fire'), ('Earth', 'Earth'), ('Air', 'Air'), ('Water', 'Water')], max_length=5)),
('modality', models.CharField(choices=[('Cardinal', 'Cardinal'), ('Fixed', 'Fixed'), ('Mutable', 'Mutable')], max_length=8)),
('order', models.PositiveSmallIntegerField(unique=True)),
('start_degree', models.FloatField()),
],
options={
'ordering': ['order'],
},
),
]

View File

@@ -0,0 +1,106 @@
"""
Data migration: seed Sign, Planet, AspectType, and HouseLabel tables.
These are stable astrological reference rows — never user-edited.
The data matches the constants in pyswiss/apps/charts/calc.py so that
the proxy view and D3 wheel share a single source of truth.
"""
from django.db import migrations
# ── Signs ────────────────────────────────────────────────────────────────────
# (order, name, symbol, element, modality, start_degree)
SIGNS = [
(0, 'Aries', '', 'Fire', 'Cardinal', 0.0),
(1, 'Taurus', '', 'Earth', 'Fixed', 30.0),
(2, 'Gemini', '', 'Air', 'Mutable', 60.0),
(3, 'Cancer', '', 'Water', 'Cardinal', 90.0),
(4, 'Leo', '', 'Fire', 'Fixed', 120.0),
(5, 'Virgo', '', 'Earth', 'Mutable', 150.0),
(6, 'Libra', '', 'Air', 'Cardinal', 180.0),
(7, 'Scorpio', '', 'Water', 'Fixed', 210.0),
(8, 'Sagittarius', '', 'Fire', 'Mutable', 240.0),
(9, 'Capricorn', '', 'Earth', 'Cardinal', 270.0),
(10, 'Aquarius', '', 'Air', 'Fixed', 300.0),
(11, 'Pisces', '', 'Water', 'Mutable', 330.0),
]
# ── Planets ───────────────────────────────────────────────────────────────────
# (order, name, symbol)
PLANETS = [
(0, 'Sun', ''),
(1, 'Moon', ''),
(2, 'Mercury', ''),
(3, 'Venus', ''),
(4, 'Mars', ''),
(5, 'Jupiter', ''),
(6, 'Saturn', ''),
(7, 'Uranus', ''),
(8, 'Neptune', ''),
(9, 'Pluto', ''),
]
# ── Aspect types ──────────────────────────────────────────────────────────────
# (name, symbol, angle, orb) — mirrors ASPECTS constant in pyswiss calc.py
ASPECT_TYPES = [
('Conjunction', '', 0, 8.0),
('Sextile', '', 60, 6.0),
('Square', '', 90, 8.0),
('Trine', '', 120, 8.0),
('Opposition', '', 180, 10.0),
]
# ── House labels (distinctions) ───────────────────────────────────────────────
# (number, name, keywords)
HOUSE_LABELS = [
(1, 'Self', 'identity, appearance, first impressions'),
(2, 'Worth', 'possessions, values, finances'),
(3, 'Education', 'communication, siblings, short journeys'),
(4, 'Family', 'home, roots, ancestry'),
(5, 'Creation', 'creativity, romance, children, pleasure'),
(6, 'Ritual', 'service, health, daily routines'),
(7, 'Cooperation', 'partnerships, marriage, open enemies'),
(8, 'Regeneration', 'transformation, shared resources, death'),
(9, 'Enterprise', 'philosophy, travel, higher learning'),
(10, 'Career', 'public life, reputation, authority'),
(11, 'Reward', 'friends, groups, aspirations'),
(12, 'Reprisal', 'hidden matters, karma, self-undoing'),
]
def forward(apps, schema_editor):
Sign = apps.get_model('epic', 'Sign')
Planet = apps.get_model('epic', 'Planet')
AspectType = apps.get_model('epic', 'AspectType')
HouseLabel = apps.get_model('epic', 'HouseLabel')
for order, name, symbol, element, modality, start_degree in SIGNS:
Sign.objects.create(
order=order, name=name, symbol=symbol,
element=element, modality=modality, start_degree=start_degree,
)
for order, name, symbol in PLANETS:
Planet.objects.create(order=order, name=name, symbol=symbol)
for name, symbol, angle, orb in ASPECT_TYPES:
AspectType.objects.create(name=name, symbol=symbol, angle=angle, orb=orb)
for number, name, keywords in HOUSE_LABELS:
HouseLabel.objects.create(number=number, name=name, keywords=keywords)
def reverse(apps, schema_editor):
for model_name in ('Sign', 'Planet', 'AspectType', 'HouseLabel'):
apps.get_model('epic', model_name).objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('epic', '0032_astro_reference_tables'),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 6.0 on 2026-04-14 05:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0033_seed_astro_reference_tables'),
]
operations = [
migrations.CreateModel(
name='Character',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('birth_dt', models.DateTimeField(blank=True, null=True)),
('birth_lat', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('birth_lon', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('birth_place', models.CharField(blank=True, max_length=200)),
('house_system', models.CharField(choices=[('O', 'Porphyry'), ('P', 'Placidus'), ('K', 'Koch'), ('W', 'Whole Sign')], default='O', max_length=1)),
('chart_data', models.JSONField(blank=True, null=True)),
('celtic_cross', models.JSONField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('confirmed_at', models.DateTimeField(blank=True, null=True)),
('retired_at', models.DateTimeField(blank=True, null=True)),
('seat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='characters', to='epic.tableseat')),
('significator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='character_significators', to='epic.tarotcard')),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -470,3 +470,141 @@ def active_sig_seat(room):
if seat.significator_id is None:
return seat
return None
# ── Astrological reference tables (seeded, never user-edited) ─────────────────
class Sign(models.Model):
FIRE = 'Fire'
EARTH = 'Earth'
AIR = 'Air'
WATER = 'Water'
ELEMENT_CHOICES = [(e, e) for e in (FIRE, EARTH, AIR, WATER)]
CARDINAL = 'Cardinal'
FIXED = 'Fixed'
MUTABLE = 'Mutable'
MODALITY_CHOICES = [(m, m) for m in (CARDINAL, FIXED, MUTABLE)]
name = models.CharField(max_length=20, unique=True)
symbol = models.CharField(max_length=5) # ♈ ♉ … ♓
element = models.CharField(max_length=5, choices=ELEMENT_CHOICES)
modality = models.CharField(max_length=8, choices=MODALITY_CHOICES)
order = models.PositiveSmallIntegerField(unique=True) # 011, Aries first
start_degree = models.FloatField() # 0, 30, 60 … 330
class Meta:
ordering = ['order']
def __str__(self):
return self.name
class Planet(models.Model):
name = models.CharField(max_length=20, unique=True)
symbol = models.CharField(max_length=5) # ☉ ☽ ☿ ♀ ♂ ♃ ♄ ♅ ♆ ♇
order = models.PositiveSmallIntegerField(unique=True) # 09, Sun first
class Meta:
ordering = ['order']
def __str__(self):
return self.name
class AspectType(models.Model):
name = models.CharField(max_length=20, unique=True)
symbol = models.CharField(max_length=5) # ☌ ⚹ □ △ ☍
angle = models.PositiveSmallIntegerField() # 0, 60, 90, 120, 180
orb = models.FloatField() # max allowed orb in degrees
class Meta:
ordering = ['angle']
def __str__(self):
return self.name
class HouseLabel(models.Model):
"""Life-area label for each of the 12 astrological houses (distinctions)."""
number = models.PositiveSmallIntegerField(unique=True) # 112
name = models.CharField(max_length=30)
keywords = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['number']
def __str__(self):
return f"{self.number}: {self.name}"
# ── Character ─────────────────────────────────────────────────────────────────
class Character(models.Model):
"""A gamer's player-character for one seat in one game session.
Lifecycle:
- Created (draft) when gamer opens PICK SKY overlay.
- confirmed_at set on confirm → locked.
- retired_at set on retirement → archived (seat may hold a new Character).
Active character for a seat: confirmed_at__isnull=False, retired_at__isnull=True.
"""
PORPHYRY = 'O'
PLACIDUS = 'P'
KOCH = 'K'
WHOLE = 'W'
HOUSE_SYSTEM_CHOICES = [
(PORPHYRY, 'Porphyry'),
(PLACIDUS, 'Placidus'),
(KOCH, 'Koch'),
(WHOLE, 'Whole Sign'),
]
# ── seat relationship ─────────────────────────────────────────────────
seat = models.ForeignKey(
TableSeat, on_delete=models.CASCADE, related_name='characters',
)
# ── significator (set at PICK SKY) ────────────────────────────────────
significator = models.ForeignKey(
TarotCard, null=True, blank=True,
on_delete=models.SET_NULL, related_name='character_significators',
)
# ── natus input (what the gamer entered) ─────────────────────────────
birth_dt = models.DateTimeField(null=True, blank=True) # UTC
birth_lat = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
birth_lon = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
birth_place = models.CharField(max_length=200, blank=True) # display string only
house_system = models.CharField(
max_length=1, choices=HOUSE_SYSTEM_CHOICES, default=PORPHYRY,
)
# ── computed natus snapshot (full PySwiss response) ───────────────────
chart_data = models.JSONField(null=True, blank=True)
# ── celtic cross spread (added at PICK SEA) ───────────────────────────
celtic_cross = models.JSONField(null=True, blank=True)
# ── lifecycle ─────────────────────────────────────────────────────────
created_at = models.DateTimeField(auto_now_add=True)
confirmed_at = models.DateTimeField(null=True, blank=True)
retired_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
status = 'confirmed' if self.confirmed_at else 'draft'
return f"Character(seat={self.seat_id}, {status})"
@property
def is_confirmed(self):
return self.confirmed_at is not None
@property
def is_active(self):
return self.confirmed_at is not None and self.retired_at is None

View File

@@ -25,4 +25,6 @@ urlpatterns = [
path('room/<uuid:room_id>/abandon', views.abandon_room, name='abandon_room'),
path('room/<uuid:room_id>/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>/natus/preview', views.natus_preview, name='natus_preview'),
path('room/<uuid:room_id>/natus/save', views.natus_save, name='natus_save'),
]

View File

@@ -1,11 +1,14 @@
import json
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})

View File

@@ -0,0 +1,421 @@
/**
* natus-wheel.js — Self-contained D3 natal-chart module.
*
* Public API:
* NatusWheel.draw(svgEl, data) — first render
* NatusWheel.redraw(data) — live update (same SVG)
* NatusWheel.clear() — empty the SVG
*
* `data` shape — matches the /epic/natus/preview/ proxy response:
* {
* planets: { Sun: { sign, degree, retrograde }, … },
* houses: { cusps: [f×12], asc: f, mc: f },
* elements: { Fire: n, Water: n, Earth: n, Air: n, Time: n, Space: n },
* aspects: [{ planet1, planet2, type, angle, orb }, …],
* distinctions: { "1": n, …, "12": n },
* house_system: "O",
* }
*
* Requires D3 v7 to be available as `window.d3` (loaded before this file).
* Uses CSS variables from the project palette (--priUser, --secUser, etc.)
* already defined in the page; falls back to neutral colours if absent.
*/
const NatusWheel = (() => {
'use strict';
// ── Constants ──────────────────────────────────────────────────────────────
const SIGNS = [
{ name: 'Aries', symbol: '♈', element: 'Fire' },
{ name: 'Taurus', symbol: '♉', element: 'Earth' },
{ name: 'Gemini', symbol: '♊', element: 'Air' },
{ name: 'Cancer', symbol: '♋', element: 'Water' },
{ name: 'Leo', symbol: '♌', element: 'Fire' },
{ name: 'Virgo', symbol: '♍', element: 'Earth' },
{ name: 'Libra', symbol: '♎', element: 'Air' },
{ name: 'Scorpio', symbol: '♏', element: 'Water' },
{ name: 'Sagittarius', symbol: '♐', element: 'Fire' },
{ name: 'Capricorn', symbol: '♑', element: 'Earth' },
{ name: 'Aquarius', symbol: '♒', element: 'Air' },
{ name: 'Pisces', symbol: '♓', element: 'Water' },
];
const PLANET_SYMBOLS = {
Sun: '☉', Moon: '☽', Mercury: '☿', Venus: '♀', Mars: '♂',
Jupiter: '♃', Saturn: '♄', Uranus: '♅', Neptune: '♆', Pluto: '♇',
};
const ASPECT_COLOURS = {
Conjunction: 'var(--priYl, #f0e060)',
Sextile: 'var(--priGn, #60c080)',
Square: 'var(--priRd, #c04040)',
Trine: 'var(--priGn, #60c080)',
Opposition: 'var(--priRd, #c04040)',
};
const ELEMENT_COLOURS = {
Fire: 'var(--terUser, #c04040)',
Earth: 'var(--priGn, #60c080)',
Air: 'var(--priYl, #f0e060)',
Water: 'var(--priBl, #4080c0)',
Time: 'var(--quaUser, #808080)',
Space: 'var(--quiUser, #a0a0a0)',
};
const HOUSE_LABELS = [
'', 'Self', 'Worth', 'Education', 'Family', 'Creation', 'Ritual',
'Cooperation', 'Regeneration', 'Enterprise', 'Career', 'Reward', 'Reprisal',
];
// ── State ─────────────────────────────────────────────────────────────────
let _svg = null;
let _cx, _cy, _r; // centre + outer radius
// Ring radii (fractions of _r, set in _layout)
let R = {};
// ── Helpers ───────────────────────────────────────────────────────────────
/** Convert ecliptic longitude to SVG angle.
*
* Ecliptic 0° (Aries) sits at the Ascendant position (left, 9 o'clock in
* standard chart convention). SVG angles are clockwise from 12 o'clock, so:
* svg_angle = -(ecliptic - asc) - 90° (in radians)
* We subtract 90° because D3 arcs start at 12 o'clock.
*/
function _toAngle(degree, asc) {
return (-(degree - asc) - 90) * Math.PI / 180;
}
function _css(varName, fallback) {
const v = getComputedStyle(document.documentElement)
.getPropertyValue(varName).trim();
return v || fallback;
}
function _layout(svgEl) {
const rect = svgEl.getBoundingClientRect();
const size = Math.min(rect.width || 400, rect.height || 400);
_cx = size / 2;
_cy = size / 2;
_r = size * 0.46; // leave a small margin
R = {
elementInner: _r * 0.20,
elementOuter: _r * 0.28,
planetInner: _r * 0.32,
planetOuter: _r * 0.48,
houseInner: _r * 0.50,
houseOuter: _r * 0.68,
signInner: _r * 0.70,
signOuter: _r * 0.90,
labelR: _r * 0.80, // sign symbol placement
houseNumR: _r * 0.59, // house number placement
planetR: _r * 0.40, // planet symbol placement
aspectR: _r * 0.29, // aspect lines end here (inner circle)
ascMcR: _r * 0.92, // ASC/MC tick outer
};
}
// ── Drawing sub-routines ──────────────────────────────────────────────────
function _drawAscMc(g, data) {
const asc = data.houses.asc;
const mc = data.houses.mc;
const points = [
{ deg: asc, label: 'ASC' },
{ deg: asc + 180, label: 'DSC' },
{ deg: mc, label: 'MC' },
{ deg: mc + 180, label: 'IC' },
];
const axisGroup = g.append('g').attr('class', 'nw-axes');
points.forEach(({ deg, label }) => {
const a = _toAngle(deg, asc);
const x1 = _cx + R.houseInner * Math.cos(a);
const y1 = _cy + R.houseInner * Math.sin(a);
const x2 = _cx + R.ascMcR * Math.cos(a);
const y2 = _cy + R.ascMcR * Math.sin(a);
axisGroup.append('line')
.attr('x1', x1).attr('y1', y1)
.attr('x2', x2).attr('y2', y2)
.attr('stroke', _css('--secUser', '#c0a060'))
.attr('stroke-width', 1.5)
.attr('opacity', 0.7);
axisGroup.append('text')
.attr('x', _cx + (R.ascMcR + 12) * Math.cos(a))
.attr('y', _cy + (R.ascMcR + 12) * Math.sin(a))
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', `${_r * 0.055}px`)
.attr('fill', _css('--secUser', '#c0a060'))
.text(label);
});
}
function _drawSigns(g, data) {
const asc = data.houses.asc;
const arc = d3.arc();
const sigGroup = g.append('g').attr('class', 'nw-signs');
SIGNS.forEach((sign, i) => {
const startDeg = i * 30; // ecliptic 0360
const endDeg = startDeg + 30;
const startA = _toAngle(startDeg, asc);
const endA = _toAngle(endDeg, asc);
// D3 arc expects startAngle < endAngle in its own convention; we swap
// because our _toAngle goes counter-clockwise
const [sa, ea] = startA > endA ? [endA, startA] : [startA, endA];
// Fill wedge
const fill = {
Fire: _css('--terUser', '#7a3030'),
Earth: _css('--priGn', '#306030'),
Air: _css('--quaUser', '#606030'),
Water: _css('--priUser', '#304070'),
}[sign.element];
sigGroup.append('path')
.attr('transform', `translate(${_cx},${_cy})`)
.attr('d', arc({
innerRadius: R.signInner,
outerRadius: R.signOuter,
startAngle: sa,
endAngle: ea,
}))
.attr('fill', fill)
.attr('opacity', 0.35)
.attr('stroke', _css('--quaUser', '#444'))
.attr('stroke-width', 0.5);
// Symbol at midpoint
const midA = (sa + ea) / 2;
sigGroup.append('text')
.attr('x', _cx + R.labelR * Math.cos(midA))
.attr('y', _cy + R.labelR * Math.sin(midA))
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', `${_r * 0.072}px`)
.attr('fill', _css('--secUser', '#c8b060'))
.text(sign.symbol);
});
}
function _drawHouses(g, data) {
const { cusps, asc } = data.houses;
const arc = d3.arc();
const houseGroup = g.append('g').attr('class', 'nw-houses');
cusps.forEach((cusp, i) => {
const nextCusp = cusps[(i + 1) % 12];
const startA = _toAngle(cusp, asc);
const endA = _toAngle(nextCusp, asc);
// Cusp radial line
houseGroup.append('line')
.attr('x1', _cx + R.houseInner * Math.cos(startA))
.attr('y1', _cy + R.houseInner * Math.sin(startA))
.attr('x2', _cx + R.signInner * Math.cos(startA))
.attr('y2', _cy + R.signInner * Math.sin(startA))
.attr('stroke', _css('--quaUser', '#555'))
.attr('stroke-width', 0.8);
// House number at midpoint of house arc
const [sa, ea] = startA > endA ? [endA, startA] : [startA, endA];
const midA = (sa + ea) / 2;
houseGroup.append('text')
.attr('x', _cx + R.houseNumR * Math.cos(midA))
.attr('y', _cy + R.houseNumR * Math.sin(midA))
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', `${_r * 0.05}px`)
.attr('fill', _css('--quiUser', '#888'))
.attr('opacity', 0.8)
.text(i + 1);
// Faint fill strip
houseGroup.append('path')
.attr('transform', `translate(${_cx},${_cy})`)
.attr('d', arc({
innerRadius: R.houseInner,
outerRadius: R.houseOuter,
startAngle: sa,
endAngle: ea,
}))
.attr('fill', (i % 2 === 0)
? _css('--quaUser', '#3a3a3a')
: _css('--quiUser', '#2e2e2e'))
.attr('opacity', 0.15);
});
}
function _drawPlanets(g, data) {
const asc = data.houses.asc;
const planetGroup = g.append('g').attr('class', 'nw-planets');
const ascAngle = _toAngle(asc, asc); // start position for animation
Object.entries(data.planets).forEach(([name, pdata], idx) => {
const finalA = _toAngle(pdata.degree, asc);
// Circle behind symbol
const circle = planetGroup.append('circle')
.attr('cx', _cx + R.planetR * Math.cos(ascAngle))
.attr('cy', _cy + R.planetR * Math.sin(ascAngle))
.attr('r', _r * 0.038)
.attr('fill', pdata.retrograde
? _css('--terUser', '#7a3030')
: _css('--priUser', '#304070'))
.attr('opacity', 0.6);
// Symbol
const label = planetGroup.append('text')
.attr('x', _cx + R.planetR * Math.cos(ascAngle))
.attr('y', _cy + R.planetR * Math.sin(ascAngle))
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', `${_r * 0.068}px`)
.attr('fill', _css('--ninUser', '#e0d0a0'))
.text(PLANET_SYMBOLS[name] || name[0]);
// Retrograde indicator
if (pdata.retrograde) {
planetGroup.append('text')
.attr('x', _cx + (R.planetR + _r * 0.055) * Math.cos(ascAngle))
.attr('y', _cy + (R.planetR + _r * 0.055) * Math.sin(ascAngle))
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', `${_r * 0.040}px`)
.attr('fill', _css('--terUser', '#c04040'))
.attr('class', 'nw-rx')
.text('℞');
}
// Animate from ASC → final position (staggered)
const interpAngle = d3.interpolate(ascAngle, finalA);
[circle, label].forEach(el => {
el.transition()
.delay(idx * 40)
.duration(600)
.ease(d3.easeQuadOut)
.attrTween('cx', () => t => _cx + R.planetR * Math.cos(interpAngle(t)))
.attrTween('cy', () => t => _cy + R.planetR * Math.sin(interpAngle(t)));
});
// Retrograde ℞ — move together with planet
if (pdata.retrograde) {
planetGroup.select('.nw-rx:last-child')
.transition()
.delay(idx * 40)
.duration(600)
.ease(d3.easeQuadOut)
.attrTween('x', () => t => _cx + (R.planetR + _r * 0.055) * Math.cos(interpAngle(t)))
.attrTween('y', () => t => _cy + (R.planetR + _r * 0.055) * Math.sin(interpAngle(t)));
}
});
}
function _drawAspects(g, data) {
const asc = data.houses.asc;
const aspectGroup = g.append('g').attr('class', 'nw-aspects').attr('opacity', 0.45);
// Build degree lookup
const degrees = {};
Object.entries(data.planets).forEach(([name, p]) => { degrees[name] = p.degree; });
data.aspects.forEach(({ planet1, planet2, type }) => {
if (degrees[planet1] === undefined || degrees[planet2] === undefined) return;
const a1 = _toAngle(degrees[planet1], asc);
const a2 = _toAngle(degrees[planet2], asc);
aspectGroup.append('line')
.attr('x1', _cx + R.aspectR * Math.cos(a1))
.attr('y1', _cy + R.aspectR * Math.sin(a1))
.attr('x2', _cx + R.aspectR * Math.cos(a2))
.attr('y2', _cy + R.aspectR * Math.sin(a2))
.attr('stroke', ASPECT_COLOURS[type] || '#888')
.attr('stroke-width', type === 'Opposition' || type === 'Square' ? 1.2 : 0.8);
});
}
function _drawElements(g, data) {
const el = data.elements;
const total = (el.Fire || 0) + (el.Earth || 0) + (el.Air || 0) + (el.Water || 0);
if (total === 0) return;
const pieData = ['Fire', 'Earth', 'Air', 'Water'].map(k => ({
key: k, value: el[k] || 0,
}));
const pie = d3.pie().value(d => d.value).sort(null)(pieData);
const arc = d3.arc().innerRadius(R.elementInner).outerRadius(R.elementOuter);
const elGroup = g.append('g')
.attr('class', 'nw-elements')
.attr('transform', `translate(${_cx},${_cy})`);
elGroup.selectAll('path')
.data(pie)
.join('path')
.attr('d', arc)
.attr('fill', d => ELEMENT_COLOURS[d.data.key])
.attr('opacity', 0.7)
.attr('stroke', _css('--quaUser', '#444'))
.attr('stroke-width', 0.5);
// Time + Space emergent counts as text
['Time', 'Space'].forEach((key, i) => {
const count = el[key] || 0;
if (count === 0) return;
g.append('text')
.attr('x', _cx + (i === 0 ? -1 : 1) * R.elementInner * 0.6)
.attr('y', _cy)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', `${_r * 0.045}px`)
.attr('fill', ELEMENT_COLOURS[key])
.attr('opacity', 0.8)
.text(`${key[0]}${count}`);
});
}
// ── Public API ────────────────────────────────────────────────────────────
function draw(svgEl, data) {
_svg = d3.select(svgEl);
_svg.selectAll('*').remove();
_layout(svgEl);
const g = _svg.append('g').attr('class', 'nw-root');
// Outer circle border
g.append('circle')
.attr('cx', _cx).attr('cy', _cy).attr('r', R.signOuter)
.attr('fill', 'none')
.attr('stroke', _css('--quaUser', '#555'))
.attr('stroke-width', 1);
// Inner filled disc (aspect area background)
g.append('circle')
.attr('cx', _cx).attr('cy', _cy).attr('r', R.elementOuter)
.attr('fill', _css('--quaUser', '#252525'))
.attr('opacity', 0.4);
_drawAspects(g, data);
_drawElements(g, data);
_drawHouses(g, data);
_drawSigns(g, data);
_drawAscMc(g, data);
_drawPlanets(g, data);
}
function redraw(data) {
if (!_svg) return;
const svgNode = _svg.node();
draw(svgNode, data);
}
function clear() {
if (_svg) _svg.selectAll('*').remove();
}
return { draw, redraw, clear };
})();

View File

@@ -0,0 +1,315 @@
// ─── Natus (Pick Sky) overlay ────────────────────────────────────────────────
// Gaussian backdrop + centred modal, matching the gate/sig overlay pattern.
// Open state: html.natus-open (added by JS on PICK SKY click).
//
// Layout: header / two-column body (form | wheel) / footer
// Collapses to stacked single-column below 600 px.
// ── Scroll-lock ───────────────────────────────────────────────────────────────
html.natus-open {
overflow: hidden;
#id_aperture_fill { opacity: 1; }
}
// ── Backdrop ──────────────────────────────────────────────────────────────────
.natus-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(5px);
z-index: 100;
pointer-events: none;
// Hidden until html.natus-open
opacity: 0;
transition: opacity 0.15s ease;
}
html.natus-open .natus-backdrop {
opacity: 1;
}
// ── Overlay shell (positions + scrolls the modal) ─────────────────────────────
.natus-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 120;
overflow-y: auto;
overscroll-behavior: contain;
pointer-events: none;
// Hidden until html.natus-open
visibility: hidden;
@media (orientation: landscape) {
$sidebar-w: 4rem;
left: $sidebar-w;
right: $sidebar-w;
}
}
html.natus-open .natus-overlay {
visibility: visible;
pointer-events: none; // modal itself is pointer-events: auto
}
// ── Modal panel ───────────────────────────────────────────────────────────────
.natus-modal {
pointer-events: auto;
display: flex;
flex-direction: column;
width: 92vw;
max-width: 840px;
max-height: 92vh;
border: 0.1rem solid rgba(var(--terUser), 0.25);
border-radius: 0.5rem;
background: rgba(var(--priUser), 1);
overflow: hidden;
// Fade + slide in
opacity: 0;
transform: translateY(1rem);
transition: opacity 0.2s ease, transform 0.2s ease;
}
html.natus-open .natus-modal {
opacity: 1;
transform: translateY(0);
}
// ── Header ────────────────────────────────────────────────────────────────────
.natus-modal-header {
flex-shrink: 0;
padding: 0.6rem 1rem;
border-bottom: 0.1rem solid rgba(var(--terUser), 0.15);
display: flex;
flex-direction: row;
align-items: baseline;
gap: 0.75rem;
h2 {
margin: 0;
font-size: 1.1rem;
letter-spacing: 0.06em;
span { color: rgba(var(--secUser), 1); }
}
p {
margin: 0;
font-size: 0.7rem;
opacity: 0.55;
}
}
// ── Body: two columns ─────────────────────────────────────────────────────────
.natus-modal-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
overflow: hidden;
}
// Form column — fixed width, scrollable
.natus-form-col {
flex: 0 0 240px;
overflow-y: auto;
padding: 0.9rem 1rem;
border-right: 0.1rem solid rgba(var(--terUser), 0.12);
display: flex;
flex-direction: column;
gap: 0.65rem;
}
// Wheel column — fills remaining space
.natus-wheel-col {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem;
background: rgba(var(--priUser), 0.5);
position: relative;
}
.natus-svg {
display: block;
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
max-width: 400px;
max-height: 400px;
}
// ── Form fields ───────────────────────────────────────────────────────────────
.natus-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
label {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(var(--quaUser), 0.8);
}
input {
width: 100%;
// inherits global input styles
}
small {
font-size: 0.58rem;
opacity: 0.45;
line-height: 1.3;
}
}
// Place search field wrapper: text input + geo button inline
.natus-place-field { position: relative; }
.natus-place-wrap {
display: flex;
gap: 0.4rem;
align-items: center;
input { flex: 1; min-width: 0; }
.btn-sm { flex-shrink: 0; }
}
// Nominatim suggestion dropdown
.natus-suggestions {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 2px);
z-index: 10;
background: rgba(var(--priUser), 1);
border: 0.1rem solid rgba(var(--terUser), 0.3);
border-radius: 0.3rem;
overflow-y: auto;
max-height: 10rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.natus-suggestion-item {
display: block;
width: 100%;
padding: 0.4rem 0.6rem;
text-align: left;
background: none;
border: none;
border-bottom: 0.05rem solid rgba(var(--terUser), 0.1);
font-size: 0.65rem;
color: rgba(var(--ninUser), 0.85);
cursor: pointer;
line-height: 1.35;
&:last-child { border-bottom: none; }
&:hover, &:focus {
background: rgba(var(--terUser), 0.12);
color: rgba(var(--ninUser), 1);
outline: none;
}
}
// Coords row: lat | lon (read-only, populated by place selection)
.natus-coords {
flex-direction: row;
align-items: flex-end;
gap: 0.4rem;
> div {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
label {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(var(--quaUser), 0.8);
}
input {
width: 100%;
opacity: 0.6;
cursor: default;
}
}
}
// ── Status line ───────────────────────────────────────────────────────────────
.natus-status {
font-size: 0.65rem;
opacity: 0.6;
min-height: 1rem;
text-align: center;
&--error {
opacity: 1;
color: rgba(var(--priRd), 1);
}
}
// ── Footer ────────────────────────────────────────────────────────────────────
.natus-modal-footer {
flex-shrink: 0;
padding: 0.6rem 1rem;
border-top: 0.1rem solid rgba(var(--terUser), 0.15);
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
// ── Narrow / portrait ─────────────────────────────────────────────────────────
@media (max-width: 600px) {
.natus-modal {
width: 96vw;
max-height: 96vh;
}
.natus-modal-body {
flex-direction: column;
overflow-y: auto;
}
.natus-form-col {
flex: 0 0 auto;
border-right: none;
border-bottom: 0.1rem solid rgba(var(--terUser), 0.12);
}
.natus-wheel-col {
flex: 0 0 280px;
}
}
// ── Sidebar z-index sink (landscape sidebars must go below backdrop) ───────────
@media (orientation: landscape) {
html.natus-open body .container .navbar,
html.natus-open body #id_footer {
z-index: 90;
}
}

View File

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

View File

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

View File

@@ -67,6 +67,11 @@
{% include "apps/gameboard/_partials/_sig_select_overlay.html" %}
{% endif %}
{# Natus (Pick Sky) overlay — natal chart entry #}
{% if room.table_status == "SKY_SELECT" %}
{% include "apps/gameboard/_partials/_natus_overlay.html" %}
{% endif %}
{% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %}
{% include "apps/gameboard/_partials/_table_positions.html" %}
{% endif %}