PICK SKY: natal wheel polish — house/sign fill fixes, button layout, localStorage FT
- 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:
@@ -10,7 +10,7 @@
|
|||||||
* {
|
* {
|
||||||
* planets: { Sun: { sign, degree, retrograde }, … },
|
* planets: { Sun: { sign, degree, retrograde }, … },
|
||||||
* houses: { cusps: [f×12], asc: f, mc: f },
|
* 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 }, …],
|
* aspects: [{ planet1, planet2, type, angle, orb }, …],
|
||||||
* distinctions: { "1": n, …, "12": n },
|
* distinctions: { "1": n, …, "12": n },
|
||||||
* house_system: "O",
|
* house_system: "O",
|
||||||
@@ -28,15 +28,15 @@ const NatusWheel = (() => {
|
|||||||
|
|
||||||
const SIGNS = [
|
const SIGNS = [
|
||||||
{ name: 'Aries', symbol: '♈', element: 'Fire' },
|
{ name: 'Aries', symbol: '♈', element: 'Fire' },
|
||||||
{ name: 'Taurus', symbol: '♉', element: 'Earth' },
|
{ name: 'Taurus', symbol: '♉', element: 'Stone' },
|
||||||
{ name: 'Gemini', symbol: '♊', element: 'Air' },
|
{ name: 'Gemini', symbol: '♊', element: 'Air' },
|
||||||
{ name: 'Cancer', symbol: '♋', element: 'Water' },
|
{ name: 'Cancer', symbol: '♋', element: 'Water' },
|
||||||
{ name: 'Leo', symbol: '♌', element: 'Fire' },
|
{ name: 'Leo', symbol: '♌', element: 'Fire' },
|
||||||
{ name: 'Virgo', symbol: '♍', element: 'Earth' },
|
{ name: 'Virgo', symbol: '♍', element: 'Stone' },
|
||||||
{ name: 'Libra', symbol: '♎', element: 'Air' },
|
{ name: 'Libra', symbol: '♎', element: 'Air' },
|
||||||
{ name: 'Scorpio', symbol: '♏', element: 'Water' },
|
{ name: 'Scorpio', symbol: '♏', element: 'Water' },
|
||||||
{ name: 'Sagittarius', symbol: '♐', element: 'Fire' },
|
{ name: 'Sagittarius', symbol: '♐', element: 'Fire' },
|
||||||
{ name: 'Capricorn', symbol: '♑', element: 'Earth' },
|
{ name: 'Capricorn', symbol: '♑', element: 'Stone' },
|
||||||
{ name: 'Aquarius', symbol: '♒', element: 'Air' },
|
{ name: 'Aquarius', symbol: '♒', element: 'Air' },
|
||||||
{ name: 'Pisces', symbol: '♓', element: 'Water' },
|
{ name: 'Pisces', symbol: '♓', element: 'Water' },
|
||||||
];
|
];
|
||||||
@@ -46,22 +46,15 @@ const NatusWheel = (() => {
|
|||||||
Jupiter: '♃', Saturn: '♄', Uranus: '♅', Neptune: '♆', Pluto: '♇',
|
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)',
|
Conjunction: 'var(--priYl, #f0e060)',
|
||||||
Sextile: 'var(--priGn, #60c080)',
|
Sextile: 'var(--priGn, #60c080)',
|
||||||
Square: 'var(--priRd, #c04040)',
|
Square: 'var(--priRd, #c04040)',
|
||||||
Trine: 'var(--priGn, #60c080)',
|
Trine: 'var(--priGn, #60c080)',
|
||||||
Opposition: 'var(--priRd, #c04040)',
|
Opposition: 'var(--priRd, #c04040)',
|
||||||
};
|
};
|
||||||
|
// Element fill colors live in _natus.scss (.nw-sign--* / .nw-element--*).
|
||||||
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 = [
|
const HOUSE_LABELS = [
|
||||||
'', 'Self', 'Worth', 'Education', 'Family', 'Creation', 'Ritual',
|
'', 'Self', 'Worth', 'Education', 'Family', 'Creation', 'Ritual',
|
||||||
@@ -80,13 +73,14 @@ const NatusWheel = (() => {
|
|||||||
|
|
||||||
/** Convert ecliptic longitude to SVG angle.
|
/** Convert ecliptic longitude to SVG angle.
|
||||||
*
|
*
|
||||||
* Ecliptic 0° (Aries) sits at the Ascendant position (left, 9 o'clock in
|
* American convention: ASC sits at 9 o'clock (left). SVG 0° is 3 o'clock
|
||||||
* standard chart convention). SVG angles are clockwise from 12 o'clock, so:
|
* and increases clockwise, so ecliptic (counter-clockwise, ASC-relative)
|
||||||
* svg_angle = -(ecliptic - asc) - 90° (in radians)
|
* maps to SVG via:
|
||||||
* We subtract 90° because D3 arcs start at 12 o'clock.
|
* svg_angle = -(ecliptic - asc) - 180° (in radians)
|
||||||
|
* The −180° offset places ASC exactly at the left (9 o'clock) position.
|
||||||
*/
|
*/
|
||||||
function _toAngle(degree, asc) {
|
function _toAngle(degree, asc) {
|
||||||
return (-(degree - asc) - 90) * Math.PI / 180;
|
return (-(degree - asc) - 180) * Math.PI / 180;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _css(varName, fallback) {
|
function _css(varName, fallback) {
|
||||||
@@ -140,16 +134,14 @@ const NatusWheel = (() => {
|
|||||||
axisGroup.append('line')
|
axisGroup.append('line')
|
||||||
.attr('x1', x1).attr('y1', y1)
|
.attr('x1', x1).attr('y1', y1)
|
||||||
.attr('x2', x2).attr('y2', y2)
|
.attr('x2', x2).attr('y2', y2)
|
||||||
.attr('stroke', _css('--secUser', '#c0a060'))
|
.attr('class', 'nw-axis-line');
|
||||||
.attr('stroke-width', 1.5)
|
|
||||||
.attr('opacity', 0.7);
|
|
||||||
axisGroup.append('text')
|
axisGroup.append('text')
|
||||||
.attr('x', _cx + (R.ascMcR + 12) * Math.cos(a))
|
.attr('x', _cx + (R.ascMcR + 12) * Math.cos(a))
|
||||||
.attr('y', _cy + (R.ascMcR + 12) * Math.sin(a))
|
.attr('y', _cy + (R.ascMcR + 12) * Math.sin(a))
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dominant-baseline', 'middle')
|
.attr('dominant-baseline', 'middle')
|
||||||
.attr('font-size', `${_r * 0.055}px`)
|
.attr('font-size', `${_r * 0.055}px`)
|
||||||
.attr('fill', _css('--secUser', '#c0a060'))
|
.attr('class', 'nw-axis-label')
|
||||||
.text(label);
|
.text(label);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -167,26 +159,16 @@ const NatusWheel = (() => {
|
|||||||
// D3 arc expects startAngle < endAngle in its own convention; we swap
|
// D3 arc expects startAngle < endAngle in its own convention; we swap
|
||||||
// because our _toAngle goes counter-clockwise
|
// because our _toAngle goes counter-clockwise
|
||||||
const [sa, ea] = startA > endA ? [endA, startA] : [startA, endA];
|
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')
|
sigGroup.append('path')
|
||||||
.attr('transform', `translate(${_cx},${_cy})`)
|
.attr('transform', `translate(${_cx},${_cy})`)
|
||||||
.attr('d', arc({
|
.attr('d', arc({
|
||||||
innerRadius: R.signInner,
|
innerRadius: R.signInner,
|
||||||
outerRadius: R.signOuter,
|
outerRadius: R.signOuter,
|
||||||
startAngle: sa,
|
startAngle: sa + Math.PI / 2,
|
||||||
endAngle: ea,
|
endAngle: ea + Math.PI / 2,
|
||||||
}))
|
}))
|
||||||
.attr('fill', fill)
|
.attr('class', `nw-sign--${sign.element.toLowerCase()}`);
|
||||||
.attr('opacity', 0.35)
|
|
||||||
.attr('stroke', _css('--quaUser', '#444'))
|
|
||||||
.attr('stroke-width', 0.5);
|
|
||||||
|
|
||||||
// Symbol at midpoint
|
// Symbol at midpoint
|
||||||
const midA = (sa + ea) / 2;
|
const midA = (sa + ea) / 2;
|
||||||
@@ -195,8 +177,8 @@ const NatusWheel = (() => {
|
|||||||
.attr('y', _cy + R.labelR * Math.sin(midA))
|
.attr('y', _cy + R.labelR * Math.sin(midA))
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dominant-baseline', 'middle')
|
.attr('dominant-baseline', 'middle')
|
||||||
.attr('font-size', `${_r * 0.072}px`)
|
.attr('font-size', `${_r * 0.095}px`)
|
||||||
.attr('fill', _css('--secUser', '#c8b060'))
|
.attr('class', 'nw-sign-label')
|
||||||
.text(sign.symbol);
|
.text(sign.symbol);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -206,46 +188,48 @@ const NatusWheel = (() => {
|
|||||||
const arc = d3.arc();
|
const arc = d3.arc();
|
||||||
const houseGroup = g.append('g').attr('class', 'nw-houses');
|
const houseGroup = g.append('g').attr('class', 'nw-houses');
|
||||||
|
|
||||||
cusps.forEach((cusp, i) => {
|
// Pre-compute angles; normalise the last house's nextCusp across 360° wrap.
|
||||||
const nextCusp = cusps[(i + 1) % 12];
|
const houses = cusps.map((cusp, i) => {
|
||||||
const startA = _toAngle(cusp, asc);
|
let nextCusp = cusps[(i + 1) % 12];
|
||||||
const endA = _toAngle(nextCusp, asc);
|
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')
|
houseGroup.append('line')
|
||||||
.attr('x1', _cx + R.houseInner * Math.cos(startA))
|
.attr('x1', _cx + R.houseInner * Math.cos(startA))
|
||||||
.attr('y1', _cy + R.houseInner * Math.sin(startA))
|
.attr('y1', _cy + R.houseInner * Math.sin(startA))
|
||||||
.attr('x2', _cx + R.signInner * Math.cos(startA))
|
.attr('x2', _cx + R.signInner * Math.cos(startA))
|
||||||
.attr('y2', _cy + R.signInner * Math.sin(startA))
|
.attr('y2', _cy + R.signInner * Math.sin(startA))
|
||||||
.attr('stroke', _css('--quaUser', '#555'))
|
.attr('class', 'nw-house-cusp');
|
||||||
.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')
|
houseGroup.append('text')
|
||||||
.attr('x', _cx + R.houseNumR * Math.cos(midA))
|
.attr('x', _cx + R.houseNumR * Math.cos(midA))
|
||||||
.attr('y', _cy + R.houseNumR * Math.sin(midA))
|
.attr('y', _cy + R.houseNumR * Math.sin(midA))
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dominant-baseline', 'middle')
|
.attr('dominant-baseline', 'middle')
|
||||||
.attr('font-size', `${_r * 0.05}px`)
|
.attr('font-size', `${_r * 0.05}px`)
|
||||||
.attr('fill', _css('--quiUser', '#888'))
|
.attr('class', 'nw-house-num')
|
||||||
.attr('opacity', 0.8)
|
|
||||||
.text(i + 1);
|
.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')
|
const circle = planetGroup.append('circle')
|
||||||
.attr('cx', _cx + R.planetR * Math.cos(ascAngle))
|
.attr('cx', _cx + R.planetR * Math.cos(ascAngle))
|
||||||
.attr('cy', _cy + R.planetR * Math.sin(ascAngle))
|
.attr('cy', _cy + R.planetR * Math.sin(ascAngle))
|
||||||
.attr('r', _r * 0.038)
|
.attr('r', _r * 0.05)
|
||||||
.attr('fill', pdata.retrograde
|
.attr('class', pdata.retrograde ? 'nw-planet-circle--rx' : 'nw-planet-circle');
|
||||||
? _css('--terUser', '#7a3030')
|
|
||||||
: _css('--priUser', '#304070'))
|
|
||||||
.attr('opacity', 0.6);
|
|
||||||
|
|
||||||
// Symbol
|
// Symbol
|
||||||
const label = planetGroup.append('text')
|
const label = planetGroup.append('text')
|
||||||
@@ -273,8 +254,9 @@ const NatusWheel = (() => {
|
|||||||
.attr('y', _cy + R.planetR * Math.sin(ascAngle))
|
.attr('y', _cy + R.planetR * Math.sin(ascAngle))
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dominant-baseline', 'middle')
|
.attr('dominant-baseline', 'middle')
|
||||||
.attr('font-size', `${_r * 0.068}px`)
|
.attr('dy', '0.1em')
|
||||||
.attr('fill', _css('--ninUser', '#e0d0a0'))
|
.attr('font-size', `${_r * 0.09}px`)
|
||||||
|
.attr('class', 'nw-planet-label')
|
||||||
.text(PLANET_SYMBOLS[name] || name[0]);
|
.text(PLANET_SYMBOLS[name] || name[0]);
|
||||||
|
|
||||||
// Retrograde indicator
|
// Retrograde indicator
|
||||||
@@ -285,29 +267,30 @@ const NatusWheel = (() => {
|
|||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dominant-baseline', 'middle')
|
.attr('dominant-baseline', 'middle')
|
||||||
.attr('font-size', `${_r * 0.040}px`)
|
.attr('font-size', `${_r * 0.040}px`)
|
||||||
.attr('fill', _css('--terUser', '#c04040'))
|
|
||||||
.attr('class', 'nw-rx')
|
.attr('class', 'nw-rx')
|
||||||
.text('℞');
|
.text('℞');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate from ASC → final position (staggered)
|
// Animate from ASC → final position (staggered)
|
||||||
|
// circle uses cx/cy; text uses x/y — must be separate transitions.
|
||||||
const interpAngle = d3.interpolate(ascAngle, finalA);
|
const interpAngle = d3.interpolate(ascAngle, finalA);
|
||||||
[circle, label].forEach(el => {
|
const transition = () => d3.transition()
|
||||||
el.transition()
|
.delay(idx * 40)
|
||||||
.delay(idx * 40)
|
.duration(600)
|
||||||
.duration(600)
|
.ease(d3.easeQuadOut);
|
||||||
.ease(d3.easeQuadOut)
|
|
||||||
.attrTween('cx', () => t => _cx + R.planetR * Math.cos(interpAngle(t)))
|
circle.transition(transition())
|
||||||
.attrTween('cy', () => t => _cy + R.planetR * Math.sin(interpAngle(t)));
|
.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
|
// Retrograde ℞ — move together with planet
|
||||||
if (pdata.retrograde) {
|
if (pdata.retrograde) {
|
||||||
planetGroup.select('.nw-rx:last-child')
|
planetGroup.select('.nw-rx:last-child')
|
||||||
.transition()
|
.transition(transition())
|
||||||
.delay(idx * 40)
|
|
||||||
.duration(600)
|
|
||||||
.ease(d3.easeQuadOut)
|
|
||||||
.attrTween('x', () => t => _cx + (R.planetR + _r * 0.055) * Math.cos(interpAngle(t)))
|
.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)));
|
.attrTween('y', () => t => _cy + (R.planetR + _r * 0.055) * Math.sin(interpAngle(t)));
|
||||||
}
|
}
|
||||||
@@ -316,7 +299,7 @@ const NatusWheel = (() => {
|
|||||||
|
|
||||||
function _drawAspects(g, data) {
|
function _drawAspects(g, data) {
|
||||||
const asc = data.houses.asc;
|
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
|
// Build degree lookup
|
||||||
const degrees = {};
|
const degrees = {};
|
||||||
@@ -331,17 +314,17 @@ const NatusWheel = (() => {
|
|||||||
.attr('y1', _cy + R.aspectR * Math.sin(a1))
|
.attr('y1', _cy + R.aspectR * Math.sin(a1))
|
||||||
.attr('x2', _cx + R.aspectR * Math.cos(a2))
|
.attr('x2', _cx + R.aspectR * Math.cos(a2))
|
||||||
.attr('y2', _cy + R.aspectR * Math.sin(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);
|
.attr('stroke-width', type === 'Opposition' || type === 'Square' ? 1.2 : 0.8);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _drawElements(g, data) {
|
function _drawElements(g, data) {
|
||||||
const el = data.elements;
|
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;
|
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,
|
key: k, value: el[k] || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -356,10 +339,7 @@ const NatusWheel = (() => {
|
|||||||
.data(pie)
|
.data(pie)
|
||||||
.join('path')
|
.join('path')
|
||||||
.attr('d', arc)
|
.attr('d', arc)
|
||||||
.attr('fill', d => ELEMENT_COLOURS[d.data.key])
|
.attr('class', d => `nw-element--${d.data.key.toLowerCase()}`);
|
||||||
.attr('opacity', 0.7)
|
|
||||||
.attr('stroke', _css('--quaUser', '#444'))
|
|
||||||
.attr('stroke-width', 0.5);
|
|
||||||
|
|
||||||
// Time + Space emergent counts as text
|
// Time + Space emergent counts as text
|
||||||
['Time', 'Space'].forEach((key, i) => {
|
['Time', 'Space'].forEach((key, i) => {
|
||||||
@@ -371,8 +351,7 @@ const NatusWheel = (() => {
|
|||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dominant-baseline', 'middle')
|
.attr('dominant-baseline', 'middle')
|
||||||
.attr('font-size', `${_r * 0.045}px`)
|
.attr('font-size', `${_r * 0.045}px`)
|
||||||
.attr('fill', ELEMENT_COLOURS[key])
|
.attr('class', `nw-element-label--${key.toLowerCase()}`)
|
||||||
.attr('opacity', 0.8)
|
|
||||||
.text(`${key[0]}${count}`);
|
.text(`${key[0]}${count}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -389,15 +368,12 @@ const NatusWheel = (() => {
|
|||||||
// Outer circle border
|
// Outer circle border
|
||||||
g.append('circle')
|
g.append('circle')
|
||||||
.attr('cx', _cx).attr('cy', _cy).attr('r', R.signOuter)
|
.attr('cx', _cx).attr('cy', _cy).attr('r', R.signOuter)
|
||||||
.attr('fill', 'none')
|
.attr('class', 'nw-outer-ring');
|
||||||
.attr('stroke', _css('--quaUser', '#555'))
|
|
||||||
.attr('stroke-width', 1);
|
|
||||||
|
|
||||||
// Inner filled disc (aspect area background)
|
// Inner filled disc (aspect area background)
|
||||||
g.append('circle')
|
g.append('circle')
|
||||||
.attr('cx', _cx).attr('cy', _cy).attr('r', R.elementOuter)
|
.attr('cx', _cx).attr('cy', _cy).attr('r', R.elementOuter)
|
||||||
.attr('fill', _css('--quaUser', '#252525'))
|
.attr('class', 'nw-inner-disc');
|
||||||
.attr('opacity', 0.4);
|
|
||||||
|
|
||||||
_drawAspects(g, data);
|
_drawAspects(g, data);
|
||||||
_drawElements(g, data);
|
_drawElements(g, data);
|
||||||
|
|||||||
125
src/functional_tests/test_pick_sky.py
Normal file
125
src/functional_tests/test_pick_sky.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Functional tests for the PICK SKY overlay — natal chart entry."""
|
||||||
|
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from apps.applets.models import Applet
|
||||||
|
from apps.epic.models import GateSlot, Room
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
from .base import FunctionalTest
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sky_select_room():
|
||||||
|
"""Minimal SKY_SELECT room — just enough for the overlay to render."""
|
||||||
|
email = "founder@test.io"
|
||||||
|
gamer, _ = User.objects.get_or_create(email=email)
|
||||||
|
room = Room.objects.create(name="Sky Test Room", table_status=Room.SKY_SELECT, owner=gamer)
|
||||||
|
# Put the founder in slot 1 so the view recognises them as a participant
|
||||||
|
slot = room.gate_slots.get(slot_number=1)
|
||||||
|
slot.gamer = gamer
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
room.gate_status = Room.OPEN
|
||||||
|
room.save()
|
||||||
|
return room, gamer, email
|
||||||
|
|
||||||
|
|
||||||
|
class PickSkyLocalStorageTest(FunctionalTest):
|
||||||
|
"""PICK SKY form fields persist to localStorage."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
self.room, self.founder, self.founder_email = _make_sky_select_room()
|
||||||
|
self.room_url = (
|
||||||
|
self.live_server_url + f"/gameboard/room/{self.room.id}/"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _open_overlay(self):
|
||||||
|
btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")
|
||||||
|
)
|
||||||
|
self.browser.execute_script("arguments[0].click()", btn)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_natus_overlay")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fill_form(self):
|
||||||
|
"""Set date, lat, lon directly (bypasses Nominatim network call)."""
|
||||||
|
self.browser.execute_script(
|
||||||
|
"document.getElementById('id_nf_date').value = '1990-02-28';"
|
||||||
|
"document.getElementById('id_nf_lat').value = '39.8244';"
|
||||||
|
"document.getElementById('id_nf_lon').value = '-74.9970';"
|
||||||
|
"document.getElementById('id_nf_place').value = 'Lindenwold, NJ';"
|
||||||
|
"document.getElementById('id_nf_tz').value = 'America/New_York';"
|
||||||
|
)
|
||||||
|
# Fire input events so the save listener triggers
|
||||||
|
self.browser.execute_script("""
|
||||||
|
['id_nf_date','id_nf_lat','id_nf_lon','id_nf_place','id_nf_tz'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||||
|
});
|
||||||
|
""")
|
||||||
|
|
||||||
|
def _field_values(self):
|
||||||
|
return self.browser.execute_script("""
|
||||||
|
return {
|
||||||
|
date: document.getElementById('id_nf_date').value,
|
||||||
|
lat: document.getElementById('id_nf_lat').value,
|
||||||
|
lon: document.getElementById('id_nf_lon').value,
|
||||||
|
place: document.getElementById('id_nf_place').value,
|
||||||
|
tz: document.getElementById('id_nf_tz').value,
|
||||||
|
};
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T1 — fields survive NVM (close + reopen, same page load) #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_form_fields_repopulated_after_nvm(self):
|
||||||
|
self.create_pre_authenticated_session(self.founder_email)
|
||||||
|
self.browser.get(self.room_url)
|
||||||
|
|
||||||
|
self._open_overlay()
|
||||||
|
self._fill_form()
|
||||||
|
|
||||||
|
# Close via NVM
|
||||||
|
self.browser.find_element(By.ID, "id_natus_cancel").click()
|
||||||
|
|
||||||
|
# Reopen
|
||||||
|
self._open_overlay()
|
||||||
|
|
||||||
|
values = self._field_values()
|
||||||
|
self.assertEqual(values["date"], "1990-02-28")
|
||||||
|
self.assertEqual(values["lat"], "39.8244")
|
||||||
|
self.assertEqual(values["lon"], "-74.9970")
|
||||||
|
self.assertEqual(values["place"], "Lindenwold, NJ")
|
||||||
|
self.assertEqual(values["tz"], "America/New_York")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T2 — fields survive a page refresh #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_form_fields_repopulated_after_page_refresh(self):
|
||||||
|
self.create_pre_authenticated_session(self.founder_email)
|
||||||
|
self.browser.get(self.room_url)
|
||||||
|
|
||||||
|
self._open_overlay()
|
||||||
|
self._fill_form()
|
||||||
|
|
||||||
|
# Refresh the page
|
||||||
|
self.browser.refresh()
|
||||||
|
|
||||||
|
self._open_overlay()
|
||||||
|
|
||||||
|
values = self._field_values()
|
||||||
|
self.assertEqual(values["date"], "1990-02-28")
|
||||||
|
self.assertEqual(values["lat"], "39.8244")
|
||||||
|
self.assertEqual(values["lon"], "-74.9970")
|
||||||
|
self.assertEqual(values["place"], "Lindenwold, NJ")
|
||||||
|
self.assertEqual(values["tz"], "America/New_York")
|
||||||
@@ -62,34 +62,42 @@ html.natus-open .natus-overlay {
|
|||||||
|
|
||||||
// ── Modal panel ───────────────────────────────────────────────────────────────
|
// ── Modal panel ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
.natus-modal {
|
// Thin wrapper: position:relative so the NVM circle can sit on the corner
|
||||||
pointer-events: auto;
|
// without being clipped by the modal's overflow:hidden.
|
||||||
display: flex;
|
.natus-modal-wrap {
|
||||||
flex-direction: column;
|
position: relative;
|
||||||
|
pointer-events: none; // overlay handles pointer-events; children re-enable
|
||||||
width: 92vw;
|
width: 92vw;
|
||||||
max-width: 840px;
|
max-width: 920px;
|
||||||
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
|
// Fade + slide in — wraps modal AND NVM btn so both animate together
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(1rem);
|
transform: translateY(1rem);
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.natus-open .natus-modal {
|
html.natus-open .natus-modal-wrap {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.natus-modal {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%; // fills .natus-modal-wrap
|
||||||
|
max-height: 96vh;
|
||||||
|
border: 0.1rem solid rgba(var(--terUser), 0.25);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Header ────────────────────────────────────────────────────────────────────
|
// ── Header ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
.natus-modal-header {
|
.natus-modal-header {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
|
background: rgba(var(--priUser), 1);
|
||||||
border-bottom: 0.1rem solid rgba(var(--terUser), 0.15);
|
border-bottom: 0.1rem solid rgba(var(--terUser), 0.15);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -121,17 +129,33 @@ html.natus-open .natus-modal {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form column — fixed width, scrollable
|
// Form column — fixed width; form-main scrolls, confirm btn pinned at bottom
|
||||||
.natus-form-col {
|
.natus-form-col {
|
||||||
flex: 0 0 240px;
|
flex: 0 0 240px;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.9rem 1rem;
|
||||||
|
background: rgba(var(--priUser), 1);
|
||||||
border-right: 0.1rem solid rgba(var(--terUser), 0.12);
|
border-right: 0.1rem solid rgba(var(--terUser), 0.12);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scrollable inner container (form fields + status)
|
||||||
|
.natus-form-main {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm btn inside form-col — full width, pinned at column bottom
|
||||||
|
.natus-form-col > #id_natus_confirm {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Wheel column — fills remaining space
|
// Wheel column — fills remaining space
|
||||||
.natus-wheel-col {
|
.natus-wheel-col {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -140,7 +164,7 @@ html.natus-open .natus-modal {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: rgba(var(--priUser), 0.5);
|
// background: rgba(var(--duoUser), 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,8 +173,8 @@ html.natus-open .natus-modal {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
max-width: 400px;
|
max-width: 480px;
|
||||||
max-height: 400px;
|
max-height: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Form fields ───────────────────────────────────────────────────────────────
|
// ── Form fields ───────────────────────────────────────────────────────────────
|
||||||
@@ -270,22 +294,29 @@ html.natus-open .natus-modal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Footer ────────────────────────────────────────────────────────────────────
|
// ── NVM corner btn ────────────────────────────────────────────────────────────
|
||||||
|
// Absolutely pinned to top-right corner of .natus-modal-wrap.
|
||||||
|
// transform: translate(50%,-50%) centres the circle on the corner point.
|
||||||
|
// Lives outside .natus-modal so overflow:hidden doesn't clip it.
|
||||||
|
|
||||||
.natus-modal-footer {
|
#id_natus_cancel {
|
||||||
flex-shrink: 0;
|
position: absolute;
|
||||||
padding: 0.6rem 1rem;
|
top: 0;
|
||||||
border-top: 0.1rem solid rgba(var(--terUser), 0.15);
|
right: 0;
|
||||||
display: flex;
|
transform: translate(50%, -50%);
|
||||||
justify-content: flex-end;
|
z-index: 10;
|
||||||
gap: 0.5rem;
|
margin: 0;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Narrow / portrait ─────────────────────────────────────────────────────────
|
// ── Narrow / portrait ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
.natus-modal-wrap {
|
||||||
|
width: 92vw;
|
||||||
|
}
|
||||||
|
|
||||||
.natus-modal {
|
.natus-modal {
|
||||||
width: 96vw;
|
|
||||||
max-height: 96vh;
|
max-height: 96vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,10 +325,28 @@ html.natus-open .natus-modal {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Form col stacks above wheel; internally becomes a flex-row so
|
||||||
|
// form-main gets most of the width and confirm btn sits to its right.
|
||||||
.natus-form-col {
|
.natus-form-col {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-end;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 0.1rem solid rgba(var(--terUser), 0.12);
|
border-bottom: 0.1rem solid rgba(var(--terUser), 0.12);
|
||||||
|
overflow: visible; // form-main handles its own scroll
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.natus-form-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.natus-form-col > #id_natus_confirm {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.natus-wheel-col {
|
.natus-wheel-col {
|
||||||
@@ -305,6 +354,55 @@ html.natus-open .natus-modal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SVG wheel element styles ──────────────────────────────────────────────────
|
||||||
|
// Colors and opacity live here; geometry (cx/cy/r/font-size) stays in JS.
|
||||||
|
|
||||||
|
.nw-outer-ring {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(var(--quaUser), 0.6);
|
||||||
|
stroke-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nw-inner-disc {
|
||||||
|
fill: rgba(var(--quaUser), 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axes (ASC / DSC / MC / IC)
|
||||||
|
.nw-axis-line { stroke: rgba(var(--secUser), 1); stroke-width: 1.5px; }
|
||||||
|
.nw-axis-label { fill: rgba(var(--secUser), 1); }
|
||||||
|
|
||||||
|
// Sign ring — vars are RGB tuples ("R, G, B"), must be wrapped in rgba()
|
||||||
|
.nw-sign--fire { fill: rgba(var(--priRd, 192, 64, 64), 0.75); stroke: rgba(var(--quaUser), 1); stroke-width: 0.5px; }
|
||||||
|
.nw-sign--stone { fill: rgba(var(--priFs, 122, 96, 64), 0.75); stroke: rgba(var(--quaUser), 1); stroke-width: 0.5px; }
|
||||||
|
.nw-sign--air { fill: rgba(var(--priCy, 64, 144, 176), 0.75); stroke: rgba(var(--quaUser), 1); stroke-width: 0.5px; }
|
||||||
|
.nw-sign--water { fill: rgba(var(--priId, 80, 80, 160), 0.75); stroke: rgba(var(--quaUser), 1); stroke-width: 0.5px; }
|
||||||
|
.nw-sign-label { fill: rgba(var(--secUser), 1); }
|
||||||
|
|
||||||
|
// House ring
|
||||||
|
.nw-house-cusp { stroke: rgba(var(--quaUser), 0.8); stroke-width: 0.8px; }
|
||||||
|
.nw-house-num { fill: rgba(var(--quiUser), 1); }
|
||||||
|
.nw-house-fill--even { fill: rgba(var(--quaUser), 0.45); }
|
||||||
|
.nw-house-fill--odd { fill: rgba(var(--quiUser), 0.35); }
|
||||||
|
|
||||||
|
// Planets
|
||||||
|
.nw-planet-circle { fill: rgba(var(--quiUser), 1); }
|
||||||
|
.nw-planet-circle--rx { fill: rgba(var(--quiUser), 1); }
|
||||||
|
.nw-planet-label { fill: rgba(var(--quaUser), 1); stroke: rgba(var(--quaUser), 0.6); stroke-width: 0.4px; paint-order: stroke fill; }
|
||||||
|
.nw-rx { fill: rgba(var(--terUser), 1); }
|
||||||
|
|
||||||
|
// Aspects
|
||||||
|
.nw-aspects { opacity: 0.8; }
|
||||||
|
|
||||||
|
// Element pie
|
||||||
|
.nw-element--fire { fill: rgba(var(--priRd, 192, 64, 64), 0.92); stroke: rgba(var(--quaUser), 1); stroke-width: 0.5px; }
|
||||||
|
.nw-element--stone { fill: rgba(var(--priFs, 122, 96, 64), 0.92); stroke: rgba(var(--quaUser), 1); stroke-width: 0.5px; }
|
||||||
|
.nw-element--air { fill: rgba(var(--priCy, 64, 144, 176), 0.92); stroke: rgba(var(--quaUser), 1); stroke-width: 0.5px; }
|
||||||
|
.nw-element--water { fill: rgba(var(--priId, 80, 80, 160), 0.92); stroke: rgba(var(--quaUser), 1); stroke-width: 0.5px; }
|
||||||
|
|
||||||
|
// Time / Space emergent labels
|
||||||
|
.nw-element-label--time { fill: rgba(var(--priYl, 192, 160, 48), 1); }
|
||||||
|
.nw-element-label--space { fill: rgba(var(--priGn, 64, 96, 64), 1); }
|
||||||
|
|
||||||
// ── Sidebar z-index sink (landscape sidebars must go below backdrop) ───────────
|
// ── Sidebar z-index sink (landscape sidebars must go below backdrop) ───────────
|
||||||
|
|
||||||
@media (orientation: landscape) {
|
@media (orientation: landscape) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
data-preview-url="{% url 'epic:natus_preview' room.id %}"
|
data-preview-url="{% url 'epic:natus_preview' room.id %}"
|
||||||
data-save-url="{% url 'epic:natus_save' room.id %}">
|
data-save-url="{% url 'epic:natus_save' room.id %}">
|
||||||
|
|
||||||
|
<div class="natus-modal-wrap">
|
||||||
<div class="natus-modal">
|
<div class="natus-modal">
|
||||||
|
|
||||||
<header class="natus-modal-header">
|
<header class="natus-modal-header">
|
||||||
@@ -21,57 +22,66 @@
|
|||||||
|
|
||||||
{# ── Form column ──────────────────────────────────────── #}
|
{# ── Form column ──────────────────────────────────────── #}
|
||||||
<div class="natus-form-col">
|
<div class="natus-form-col">
|
||||||
<form id="id_natus_form" autocomplete="off">
|
|
||||||
|
|
||||||
<div class="natus-field">
|
{# form-main scrolls independently; confirm btn stays pinned below it #}
|
||||||
<label for="id_nf_date">Birth date</label>
|
<div class="natus-form-main">
|
||||||
<input id="id_nf_date" name="date" type="date" required>
|
<form id="id_natus_form" autocomplete="off">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="natus-field">
|
<div class="natus-field">
|
||||||
<label for="id_nf_time">Birth time</label>
|
<label for="id_nf_date">Birth date</label>
|
||||||
<input id="id_nf_time" name="time" type="time" value="12:00">
|
<input id="id_nf_date" name="date" type="date" required>
|
||||||
<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>
|
||||||
<div id="id_nf_suggestions" class="natus-suggestions" hidden></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="natus-field natus-coords">
|
<div class="natus-field">
|
||||||
<div>
|
<label for="id_nf_time">Birth time</label>
|
||||||
<label>Latitude</label>
|
<input id="id_nf_time" name="time" type="time" value="12:00">
|
||||||
<input id="id_nf_lat" name="lat" type="text"
|
<small>Local time at birth place. Use 12:00 if unknown.</small>
|
||||||
placeholder="—" readonly tabindex="-1">
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label>Longitude</label>
|
<div class="natus-field natus-place-field">
|
||||||
<input id="id_nf_lon" name="lon" type="text"
|
<label for="id_nf_place">Birth place</label>
|
||||||
placeholder="—" readonly tabindex="-1">
|
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="natus-field">
|
<div class="natus-field natus-coords">
|
||||||
<label for="id_nf_tz">Timezone</label>
|
<div>
|
||||||
<input id="id_nf_tz" name="tz" type="text"
|
<label>Latitude</label>
|
||||||
placeholder="auto-detected from location">
|
<input id="id_nf_lat" name="lat" type="text"
|
||||||
<small id="id_nf_tz_hint"></small>
|
placeholder="—" readonly tabindex="-1">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Longitude</label>
|
||||||
|
<input id="id_nf_lon" name="lon" type="text"
|
||||||
|
placeholder="—" readonly tabindex="-1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</form>
|
<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>{# /.natus-form-main #}
|
||||||
|
|
||||||
|
<button type="button" id="id_natus_confirm" class="btn btn-primary" disabled>
|
||||||
|
Save Sky
|
||||||
|
</button>
|
||||||
|
|
||||||
<div id="id_natus_status" class="natus-status"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Wheel column ─────────────────────────────────────── #}
|
{# ── Wheel column ─────────────────────────────────────── #}
|
||||||
@@ -81,14 +91,12 @@
|
|||||||
|
|
||||||
</div>{# /.natus-modal-body #}
|
</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-modal #}
|
||||||
|
|
||||||
|
{# NVM: circle btn centered on the top-right corner of the modal #}
|
||||||
|
<button type="button" id="id_natus_cancel" class="btn btn-cancel btn-sm">NVM</button>
|
||||||
|
|
||||||
|
</div>{# /.natus-modal-wrap #}
|
||||||
</div>{# /.natus-overlay #}
|
</div>{# /.natus-overlay #}
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||||
@@ -122,10 +130,44 @@
|
|||||||
const PLACE_DELAY = 400; // ms — Nominatim polite rate
|
const PLACE_DELAY = 400; // ms — Nominatim polite rate
|
||||||
const CHART_DELAY = 300; // ms — chart preview debounce
|
const CHART_DELAY = 300; // ms — chart preview debounce
|
||||||
|
|
||||||
|
// ── localStorage persistence ──────────────────────────────────────────────
|
||||||
|
// Key scoped to room so multiple rooms don't clobber each other.
|
||||||
|
|
||||||
|
const LS_KEY = 'natus-form:' + SAVE_URL;
|
||||||
|
|
||||||
|
function _saveForm() {
|
||||||
|
const data = {
|
||||||
|
date: document.getElementById('id_nf_date').value,
|
||||||
|
time: document.getElementById('id_nf_time').value,
|
||||||
|
place: placeInput.value,
|
||||||
|
lat: latInput.value,
|
||||||
|
lon: lonInput.value,
|
||||||
|
tz: tzInput.value,
|
||||||
|
};
|
||||||
|
try { localStorage.setItem(LS_KEY, JSON.stringify(data)); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _restoreForm() {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(localStorage.getItem(LS_KEY) || 'null'); } catch (_) {}
|
||||||
|
if (!data) return;
|
||||||
|
if (data.date) document.getElementById('id_nf_date').value = data.date;
|
||||||
|
if (data.time) document.getElementById('id_nf_time').value = data.time;
|
||||||
|
if (data.place) placeInput.value = data.place;
|
||||||
|
if (data.lat) latInput.value = data.lat;
|
||||||
|
if (data.lon) lonInput.value = data.lon;
|
||||||
|
if (data.tz) { tzInput.value = data.tz; tzHint.textContent = 'Auto-detected from coordinates.'; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Open / Close ──────────────────────────────────────────────────────────
|
// ── Open / Close ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function openNatus() {
|
function openNatus() {
|
||||||
document.documentElement.classList.add('natus-open');
|
document.documentElement.classList.add('natus-open');
|
||||||
|
// If the wheel is empty but the form has enough data (restored from
|
||||||
|
// localStorage), kick off a fresh preview so the animation plays.
|
||||||
|
if (!svgEl.querySelector('*') && _formReady()) {
|
||||||
|
schedulePreview();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeNatus() {
|
function closeNatus() {
|
||||||
@@ -199,6 +241,7 @@
|
|||||||
latInput.value = parseFloat(place.lat).toFixed(4);
|
latInput.value = parseFloat(place.lat).toFixed(4);
|
||||||
lonInput.value = parseFloat(place.lon).toFixed(4);
|
lonInput.value = parseFloat(place.lon).toFixed(4);
|
||||||
hideSuggestions();
|
hideSuggestions();
|
||||||
|
_saveForm();
|
||||||
schedulePreview();
|
schedulePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +262,8 @@
|
|||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => { placeInput.value = _cityName(data.address) || data.display_name || ''; })
|
.then(data => { placeInput.value = _cityName(data.address) || data.display_name || ''; })
|
||||||
.catch(() => {});
|
.catch(() => {})
|
||||||
|
.finally(() => _saveForm());
|
||||||
setStatus('');
|
setStatus('');
|
||||||
schedulePreview();
|
schedulePreview();
|
||||||
},
|
},
|
||||||
@@ -242,6 +286,7 @@
|
|||||||
// Trigger on date / time / tz changes (coords come via selectPlace / geolocation)
|
// Trigger on date / time / tz changes (coords come via selectPlace / geolocation)
|
||||||
form.addEventListener('input', (e) => {
|
form.addEventListener('input', (e) => {
|
||||||
if (e.target === placeInput) return; // place triggers via selectPlace
|
if (e.target === placeInput) return; // place triggers via selectPlace
|
||||||
|
_saveForm();
|
||||||
clearTimeout(_chartDebounce);
|
clearTimeout(_chartDebounce);
|
||||||
_chartDebounce = setTimeout(schedulePreview, CHART_DELAY);
|
_chartDebounce = setTimeout(schedulePreview, CHART_DELAY);
|
||||||
});
|
});
|
||||||
@@ -336,5 +381,12 @@
|
|||||||
const m = document.cookie.match(/csrftoken=([^;]+)/);
|
const m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||||
return m ? m[1] : '';
|
return m ? m[1] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Restore persisted form data ────────────────────────────────────────────
|
||||||
|
// Called after all functions are defined. Wheel draw is deferred to
|
||||||
|
// openNatus() so the animation plays when the modal opens, not silently
|
||||||
|
// in the background on page load.
|
||||||
|
|
||||||
|
_restoreForm();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user