PICK SKY: natal wheel polish — house/sign fill fixes, button layout, localStorage FT
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

- Fix D3 arc coordinate offset (add π/2 to all arc angles — D3 subtracts it
  internally, causing fills to render 90° CW from label midpoints)
- Fix house-12 wrap-around: normalise nextCusp += 360 when it crosses 0°,
  eliminating the 330° ghost arc that buried house fill/number layers
- Draw all house fills before cusp lines + numbers (z-order fix)
- SCSS: sign/element fills corrected to rgba(var(--priXx, R, G, B), α) —
  CSS vars are raw RGB tuples so bare var() in fill was invalid
- brighten Stone/Air/Water fallback colours; raise house fill opacities
- Button layout: SAVE SKY moves into form column (full-width, pinned bottom);
  NVM becomes a btn-sm circle anchored on the modal's top-right corner via
  .natus-modal-wrap (position:relative, outside overflow:hidden modal);
  entrance animation moved to wrapper so NVM rides the fade+slide
- Form fields wrapped in .natus-form-main (scrollable); portrait layout
  switches form-col to flex-row so form spans most width, SAVE SKY on right
- Modal max-height 92→96vh, max-width 840→920px, SVG cap 400→480px
- FT: PickSkyLocalStorageTest (2 tests) — form fields restored after NVM
  and after page refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-15 00:49:14 -04:00
parent 6248d95bf3
commit 9beb21bffe
4 changed files with 426 additions and 175 deletions

View File

@@ -10,7 +10,7 @@
* {
* 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 },
* elements: { Fire: n, Water: n, Stone: n, Air: n, Time: n, Space: n },
* aspects: [{ planet1, planet2, type, angle, orb }, …],
* distinctions: { "1": n, …, "12": n },
* house_system: "O",
@@ -28,15 +28,15 @@ const NatusWheel = (() => {
const SIGNS = [
{ name: 'Aries', symbol: '♈', element: 'Fire' },
{ name: 'Taurus', symbol: '♉', element: 'Earth' },
{ name: 'Taurus', symbol: '♉', element: 'Stone' },
{ name: 'Gemini', symbol: '♊', element: 'Air' },
{ name: 'Cancer', symbol: '♋', element: 'Water' },
{ name: 'Leo', symbol: '♌', element: 'Fire' },
{ name: 'Virgo', symbol: '♍', element: 'Earth' },
{ name: 'Virgo', symbol: '♍', element: 'Stone' },
{ name: 'Libra', symbol: '♎', element: 'Air' },
{ name: 'Scorpio', symbol: '♏', element: 'Water' },
{ name: 'Sagittarius', symbol: '♐', element: 'Fire' },
{ name: 'Capricorn', symbol: '♑', element: 'Earth' },
{ name: 'Capricorn', symbol: '♑', element: 'Stone' },
{ name: 'Aquarius', symbol: '♒', element: 'Air' },
{ name: 'Pisces', symbol: '♓', element: 'Water' },
];
@@ -46,22 +46,15 @@ const NatusWheel = (() => {
Jupiter: '♃', Saturn: '♄', Uranus: '♅', Neptune: '♆', Pluto: '♇',
};
const ASPECT_COLOURS = {
// Aspect stroke colors remain in JS — they are data-driven, not stylistic.
const ASPECT_COLORS = {
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)',
};
// Element fill colors live in _natus.scss (.nw-sign--* / .nw-element--*).
const HOUSE_LABELS = [
'', 'Self', 'Worth', 'Education', 'Family', 'Creation', 'Ritual',
@@ -80,13 +73,14 @@ const NatusWheel = (() => {
/** 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.
* American convention: ASC sits at 9 o'clock (left). SVG 0° is 3 o'clock
* and increases clockwise, so ecliptic (counter-clockwise, ASC-relative)
* maps to SVG via:
* svg_angle = -(ecliptic - asc) - 180° (in radians)
* The 180° offset places ASC exactly at the left (9 o'clock) position.
*/
function _toAngle(degree, asc) {
return (-(degree - asc) - 90) * Math.PI / 180;
return (-(degree - asc) - 180) * Math.PI / 180;
}
function _css(varName, fallback) {
@@ -140,16 +134,14 @@ const NatusWheel = (() => {
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);
.attr('class', 'nw-axis-line');
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'))
.attr('class', 'nw-axis-label')
.text(label);
});
}
@@ -167,26 +159,16 @@ const NatusWheel = (() => {
// 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,
startAngle: sa + Math.PI / 2,
endAngle: ea + Math.PI / 2,
}))
.attr('fill', fill)
.attr('opacity', 0.35)
.attr('stroke', _css('--quaUser', '#444'))
.attr('stroke-width', 0.5);
.attr('class', `nw-sign--${sign.element.toLowerCase()}`);
// Symbol at midpoint
const midA = (sa + ea) / 2;
@@ -195,8 +177,8 @@ const NatusWheel = (() => {
.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'))
.attr('font-size', `${_r * 0.095}px`)
.attr('class', 'nw-sign-label')
.text(sign.symbol);
});
}
@@ -206,46 +188,48 @@ const NatusWheel = (() => {
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);
// Pre-compute angles; normalise the last house's nextCusp across 360° wrap.
const houses = cusps.map((cusp, i) => {
let nextCusp = cusps[(i + 1) % 12];
if (nextCusp <= cusp) nextCusp += 360; // close the circle for house 12
const startA = _toAngle(cusp, asc);
const endA = _toAngle(nextCusp, asc);
// _toAngle is strictly decreasing with degree after normalisation,
// so startA > endA always — D3 arc needs sa < ea.
const sa = endA, ea = startA;
return { i, startA, sa, ea, midA: (sa + ea) / 2 };
});
// Cusp radial line
// 1. Fills first so cusp lines + numbers are never buried beneath them.
houses.forEach(({ i, sa, ea }) => {
houseGroup.append('path')
.attr('transform', `translate(${_cx},${_cy})`)
.attr('d', arc({
innerRadius: R.houseInner,
outerRadius: R.houseOuter,
startAngle: sa + Math.PI / 2,
endAngle: ea + Math.PI / 2,
}))
.attr('class', i % 2 === 0 ? 'nw-house-fill--even' : 'nw-house-fill--odd');
});
// 2. Cusp lines + house numbers on top.
houses.forEach(({ i, startA, midA }) => {
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);
.attr('class', 'nw-house-cusp');
// 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)
.attr('class', 'nw-house-num')
.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);
});
}
@@ -261,11 +245,8 @@ const NatusWheel = (() => {
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);
.attr('r', _r * 0.05)
.attr('class', pdata.retrograde ? 'nw-planet-circle--rx' : 'nw-planet-circle');
// Symbol
const label = planetGroup.append('text')
@@ -273,8 +254,9 @@ const NatusWheel = (() => {
.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'))
.attr('dy', '0.1em')
.attr('font-size', `${_r * 0.09}px`)
.attr('class', 'nw-planet-label')
.text(PLANET_SYMBOLS[name] || name[0]);
// Retrograde indicator
@@ -285,29 +267,30 @@ const NatusWheel = (() => {
.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)
// circle uses cx/cy; text uses x/y — must be separate transitions.
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)));
});
const transition = () => d3.transition()
.delay(idx * 40)
.duration(600)
.ease(d3.easeQuadOut);
circle.transition(transition())
.attrTween('cx', () => t => _cx + R.planetR * Math.cos(interpAngle(t)))
.attrTween('cy', () => t => _cy + R.planetR * Math.sin(interpAngle(t)));
label.transition(transition())
.attrTween('x', () => t => _cx + R.planetR * Math.cos(interpAngle(t)))
.attrTween('y', () => 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)
.transition(transition())
.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)));
}
@@ -316,7 +299,7 @@ const NatusWheel = (() => {
function _drawAspects(g, data) {
const asc = data.houses.asc;
const aspectGroup = g.append('g').attr('class', 'nw-aspects').attr('opacity', 0.45);
const aspectGroup = g.append('g').attr('class', 'nw-aspects');
// Build degree lookup
const degrees = {};
@@ -331,17 +314,17 @@ const NatusWheel = (() => {
.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', ASPECT_COLORS[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);
const total = (el.Fire || 0) + (el.Stone || 0) + (el.Air || 0) + (el.Water || 0);
if (total === 0) return;
const pieData = ['Fire', 'Earth', 'Air', 'Water'].map(k => ({
const pieData = ['Fire', 'Stone', 'Air', 'Water'].map(k => ({
key: k, value: el[k] || 0,
}));
@@ -356,10 +339,7 @@ const NatusWheel = (() => {
.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);
.attr('class', d => `nw-element--${d.data.key.toLowerCase()}`);
// Time + Space emergent counts as text
['Time', 'Space'].forEach((key, i) => {
@@ -371,8 +351,7 @@ const NatusWheel = (() => {
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', `${_r * 0.045}px`)
.attr('fill', ELEMENT_COLOURS[key])
.attr('opacity', 0.8)
.attr('class', `nw-element-label--${key.toLowerCase()}`)
.text(`${key[0]}${count}`);
});
}
@@ -389,15 +368,12 @@ const NatusWheel = (() => {
// 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);
.attr('class', 'nw-outer-ring');
// 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);
.attr('class', 'nw-inner-disc');
_drawAspects(g, data);
_drawElements(g, data);