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