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