Compare commits

...

5 Commits

Author SHA1 Message Date
Disco DeDisco
a44727c559 rootvars: add --qua/--qui/--six depth shades to all element color groups
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Rd, Or, Yl, Lm, Gn, Tk, Cy, Bl, Fs, Me now have full six-shade series
matching the existing Id and Vt groups; whitespace alignment pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:59:11 -04:00
Disco DeDisco
0b2320e39b natus wheel: ASC/MC angles — tooltips, aspect lines, section headers, tooltip polish
- ASC/MC clickable w. DON/DOFF aspect lines (fixed: open w.o. lines; DON/DOFF
  both work; angles ring handled in _toggleAspects; lines origin at R.planetR)
- btn-disabled click-through fix: pointer-events:auto on DON/DOFF; bounding-rect
  workaround removed
- planet tooltip: applying ⇥ left, separating ↦ right; sign shown for angle partners
- sign tooltip: Planets + Cusps section headers; ordinal house + domain; em-dash fallback
- house tooltip: Planets header; Angular/Succedent/Cadent + phase labels; em-dash fallback
- element tooltips: Planets header for Fire/Stone/Air/Water; Stellium/Parade as
  section-header labels; compact single-stellium Tempo; Parade sign : planets format
- tt-ord: no negative margin in .tt-angle-house context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:58:19 -04:00
Disco DeDisco
5c05bd6552 sky: store birth_tz, prefill form from User model, drop localStorage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:54:34 -04:00
Disco DeDisco
b5a92ddf77 natus wheel: house tooltip polish — br, pointer-events, @deg° muting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:54:06 -04:00
Disco DeDisco
bb1cda9c9c natus wheel: planet/sign/house tooltip layout overhaul — TDD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:20:15 -04:00
9 changed files with 998 additions and 179 deletions

View File

@@ -300,12 +300,28 @@ def _sky_natus_preview(request):
@login_required(login_url="/") @login_required(login_url="/")
def sky_view(request): def sky_view(request):
chart_data = request.user.sky_chart_data
birth_dt = request.user.sky_birth_dt
saved_birth_date = ''
saved_birth_time = ''
if birth_dt:
if request.user.sky_birth_tz:
try:
birth_dt = birth_dt.astimezone(zoneinfo.ZoneInfo(request.user.sky_birth_tz))
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
pass
saved_birth_date = birth_dt.strftime('%Y-%m-%d')
saved_birth_time = birth_dt.strftime('%H:%M')
return render(request, "apps/dashboard/sky.html", { return render(request, "apps/dashboard/sky.html", {
"preview_url": request.build_absolute_uri("/dashboard/sky/preview"), "preview_url": request.build_absolute_uri("/dashboard/sky/preview"),
"save_url": request.build_absolute_uri("/dashboard/sky/save"), "save_url": request.build_absolute_uri("/dashboard/sky/save"),
"saved_sky": request.user.sky_chart_data, "saved_sky_json": json.dumps(chart_data) if chart_data else 'null',
"saved_birth_dt": request.user.sky_birth_dt, "saved_birth_date": saved_birth_date,
"saved_birth_time": saved_birth_time,
"saved_birth_place": request.user.sky_birth_place, "saved_birth_place": request.user.sky_birth_place,
"saved_birth_lat": request.user.sky_birth_lat,
"saved_birth_lon": request.user.sky_birth_lon,
"saved_birth_tz": request.user.sky_birth_tz,
"page_class": "page-sky", "page_class": "page-sky",
}) })
@@ -326,24 +342,35 @@ def sky_save(request):
return HttpResponse(status=400) return HttpResponse(status=400)
user = request.user user = request.user
birth_tz_str = body.get('birth_tz', '').strip()
birth_dt_str = body.get('birth_dt', '') birth_dt_str = body.get('birth_dt', '')
if birth_dt_str: if birth_dt_str:
try: try:
naive = datetime.fromisoformat(birth_dt_str.replace('Z', '+00:00')) naive = datetime.fromisoformat(birth_dt_str.replace('Z', '').replace('+00:00', ''))
user.sky_birth_dt = naive if naive.tzinfo else naive.replace( if naive.tzinfo is None and birth_tz_str:
tzinfo=zoneinfo.ZoneInfo('UTC') try:
local_tz = zoneinfo.ZoneInfo(birth_tz_str)
user.sky_birth_dt = naive.replace(tzinfo=local_tz).astimezone(
zoneinfo.ZoneInfo('UTC')
) )
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
user.sky_birth_dt = naive.replace(tzinfo=zoneinfo.ZoneInfo('UTC'))
elif naive.tzinfo is None:
user.sky_birth_dt = naive.replace(tzinfo=zoneinfo.ZoneInfo('UTC'))
else:
user.sky_birth_dt = naive.astimezone(zoneinfo.ZoneInfo('UTC'))
except ValueError: except ValueError:
user.sky_birth_dt = None user.sky_birth_dt = None
user.sky_birth_lat = body.get('birth_lat') user.sky_birth_lat = body.get('birth_lat')
user.sky_birth_lon = body.get('birth_lon') user.sky_birth_lon = body.get('birth_lon')
user.sky_birth_place = body.get('birth_place', '') user.sky_birth_place = body.get('birth_place', '')
user.sky_birth_tz = body.get('birth_tz', '')
user.sky_house_system = body.get('house_system', 'O') user.sky_house_system = body.get('house_system', 'O')
user.sky_chart_data = body.get('chart_data') user.sky_chart_data = body.get('chart_data')
user.save(update_fields=[ user.save(update_fields=[
'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon', 'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon',
'sky_birth_place', 'sky_house_system', 'sky_chart_data', 'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
]) ])
return JsonResponse({"saved": True}) return JsonResponse({"saved": True})

View File

@@ -103,6 +103,44 @@ const NatusWheel = (() => {
'Cooperation', 'Regeneration', 'Enterprise', 'Career', 'Reward', 'Reprisal', 'Cooperation', 'Regeneration', 'Enterprise', 'Career', 'Reward', 'Reprisal',
]; ];
const ANGLE_DEFS = {
ASC: { label: 'Ascendant', sym: 'ASC', house: 1 },
MC: { label: 'Midheaven', sym: 'MC', house: 10 },
};
// Major aspect angles and their exact expected separations
const MAJOR_ASPECTS = [
{ type: 'Conjunction', angle: 0 },
{ type: 'Sextile', angle: 60 },
{ type: 'Square', angle: 90 },
{ type: 'Trine', angle: 120 },
{ type: 'Opposition', angle: 180 },
];
const ANGLE_ORB = 10;
// Cardinal / Fixed / Mutable — parallel index to SIGNS
const SIGN_MODALITIES = [
'Cardinal', 'Fixed', 'Mutable', 'Cardinal', 'Fixed', 'Mutable',
'Cardinal', 'Fixed', 'Mutable', 'Cardinal', 'Fixed', 'Mutable',
];
// Angular / Succedent / Cadent — 1-indexed (index 0 unused)
const HOUSE_QUADRANT = [
'', 'Angular', 'Succedent', 'Cadent',
'Angular', 'Succedent', 'Cadent',
'Angular', 'Succedent', 'Cadent',
'Angular', 'Succedent', 'Cadent',
];
// Houses 13: Juvenescence; 46: Adolescence; 79: Maturescence; 1012: Senescence
const HOUSE_PHASE = [
'', 'Juvenescence', 'Juvenescence', 'Juvenescence',
'Adolescence', 'Adolescence', 'Adolescence',
'Maturescence', 'Maturescence', 'Maturescence',
'Senescence', 'Senescence', 'Senescence',
];
// ── State ───────────────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────────────
let _svg = null; let _svg = null;
@@ -119,12 +157,13 @@ const NatusWheel = (() => {
// ── Cycle state ──────────────────────────────────────────────────────────── // ── Cycle state ────────────────────────────────────────────────────────────
let _activeRing = null; // 'planets' | 'elements' | 'signs' | 'houses' | null let _activeRing = null; // 'planets' | 'elements' | 'signs' | 'houses' | 'angles' | null
let _activeIdx = null; // index within the active ring's sorted list let _activeIdx = null; // index within the active ring's sorted list
let _planetItems = []; // [{name, degree}] sorted by ecliptic degree ascending let _planetItems = []; // [{name, degree}] sorted by ecliptic degree ascending
let _elementItems = []; // [{key}] in ELEMENT_ORDER let _elementItems = []; // [{key}] in ELEMENT_ORDER
let _signItems = []; // [{name, symbol, element}] in SIGNS order let _signItems = []; // [{name, symbol, element}] in SIGNS order
let _houseItems = []; // [{num, label}] houses 112 let _houseItems = []; // [{num, label}] houses 112
let _angleItems = []; // [{name, label, house}] — ASC and MC
// Tooltip DOM refs — set by _injectTooltipControls() on each draw(). // Tooltip DOM refs — set by _injectTooltipControls() on each draw().
let _tooltipEl = null; let _tooltipEl = null;
@@ -186,6 +225,21 @@ const NatusWheel = (() => {
return ((ecliptic % 360) + 360) % 360 % 30; return ((ecliptic % 360) + 360) % 360 % 30;
} }
/** Return 1-based house number for an ecliptic degree given 12 cusps. */
function _planetHouse(degree, cusps) {
degree = ((degree % 360) + 360) % 360;
for (let i = 0; i < 12; i++) {
const start = ((cusps[i] % 360) + 360) % 360;
const end = ((cusps[(i + 1) % 12] % 360) + 360) % 360;
if (start < end) {
if (start <= degree && degree < end) return i + 1;
} else {
if (degree >= start || degree < end) return i + 1;
}
}
return 1;
}
/** Inline SVG for a zodiac sign icon (preloaded path). */ /** Inline SVG for a zodiac sign icon (preloaded path). */
function _signIconSvg(signName) { function _signIconSvg(signName) {
const d = _signPaths[signName]; const d = _signPaths[signName];
@@ -193,6 +247,29 @@ const NatusWheel = (() => {
return `<svg viewBox="0 0 640 640" width="1em" height="1em" class="tt-sign-icon" aria-hidden="true"><path d="${d}"/></svg>`; return `<svg viewBox="0 0 640 640" width="1em" height="1em" class="tt-sign-icon" aria-hidden="true"><path d="${d}"/></svg>`;
} }
/** Planet symbol wrapped in its metal-color span. data-planet allows test queries. */
function _pSym(planetName) {
const el = PLANET_ELEMENTS[planetName] || '';
const sym = PLANET_SYMBOLS[planetName] || planetName[0];
return el
? `<span class="tt-title--${el}" data-planet="${planetName}">${sym}</span>`
: `<span data-planet="${planetName}">${sym}</span>`;
}
/** Sign icon SVG wrapped in its element-color span. */
function _signIcon(signName) {
const sign = SIGNS.find(s => s.name === signName) || {};
const el = (sign.element || '').toLowerCase();
const svg = _signIconSvg(signName) || sign.symbol || '';
return el ? `<span class="tt-sign-icon-wrap--${el}">${svg}</span>` : svg;
}
/** Ordinal suffix HTML for a house number, e.g. 2 → "2<span class=tt-ord>nd</span>". */
function _houseOrdinal(n) {
const sfx = n === 1 ? 'st' : n === 2 ? 'nd' : n === 3 ? 'rd' : 'th';
return `${n}<span class="tt-ord">${sfx}</span>`;
}
/** <img> for an element-square badge (Ardor.svg etc.). */ /** <img> for an element-square badge (Ardor.svg etc.). */
function _elementSquareImg(elementKey) { function _elementSquareImg(elementKey) {
const info = ELEMENT_INFO[elementKey]; const info = ELEMENT_INFO[elementKey];
@@ -240,6 +317,10 @@ const NatusWheel = (() => {
num: i + 1, num: i + 1,
label: HOUSE_LABELS[i + 1], label: HOUSE_LABELS[i + 1],
})); }));
_angleItems = [
{ name: 'ASC', deg: data.houses.asc },
{ name: 'MC', deg: data.houses.mc },
];
} }
/** Clear all active-lock classes and reset cycle state. */ /** Clear all active-lock classes and reset cycle state. */
@@ -247,6 +328,9 @@ const NatusWheel = (() => {
if (_svg) { if (_svg) {
_svg.selectAll('.nw-planet-group').classed('nw-planet--active', false); _svg.selectAll('.nw-planet-group').classed('nw-planet--active', false);
_svg.selectAll('.nw-element-group').classed('nw-element--active', false); _svg.selectAll('.nw-element-group').classed('nw-element--active', false);
_svg.selectAll('.nw-sign-group').classed('nw-sign--active', false);
_svg.selectAll('[data-house]').classed('nw-house--active', false);
_svg.selectAll('[data-angle]').classed('nw-angle--active', false);
} }
_activeRing = null; _activeRing = null;
_activeIdx = null; _activeIdx = null;
@@ -304,6 +388,97 @@ const NatusWheel = (() => {
} }
} }
/** Draw aspect lines from an angle (ASC/MC) to all planets in its aspect index. */
function _showAngleAspects(angleName) {
if (!_aspectGroup || !_currentData) return;
_clearAspectLines();
const angleDeg = angleName === 'ASC' ? _currentData.houses.asc : _currentData.houses.mc;
const asc = _currentData.houses.asc;
const a1 = _toAngle(angleDeg, asc);
const r1 = R.planetR;
(_aspectIndex[angleName] || []).forEach(({ partner, type, applying_planet }) => {
const pdata = _currentData.planets[partner];
if (!pdata) return;
const a2 = _toAngle(pdata.degree, asc);
const style = ASPECT_STYLES[type] || { dash: 'none', width: 0.8 };
const color = _aspectColor(applying_planet);
const line = _aspectGroup.append('line')
.attr('x1', _cx + r1 * Math.cos(a1))
.attr('y1', _cy + r1 * Math.sin(a1))
.attr('x2', _cx + R.planetR * Math.cos(a2))
.attr('y2', _cy + R.planetR * Math.sin(a2))
.attr('stroke', color)
.attr('stroke-width', style.width * 2)
.attr('stroke-opacity', 0.9);
if (style.dash !== 'none') line.attr('stroke-dasharray', style.dash);
});
}
/** Lock-activate an angle (ASC or MC) — show tooltip + draw aspect lines. */
function _activateAngle(angleName) {
if (_activeRing === 'angles' && _activeIdx === angleName) {
_closeTooltip();
return;
}
_clearActive();
_activeRing = 'angles';
_activeIdx = angleName;
if (_svg) _svg.select(`[data-angle="${angleName}"]`).classed('nw-angle--active', true);
const def = ANGLE_DEFS[angleName] || {};
const angleDeg = angleName === 'ASC' ? _currentData.houses.asc : _currentData.houses.mc;
const signIdx = Math.floor(angleDeg / 30) % 12;
const sign = SIGNS[signIdx] || {};
const inDeg = (angleDeg % 30).toFixed(1);
const icon = _signIconSvg(sign.name) || sign.symbol || '';
const elKey = (sign.element || '').toLowerCase();
let aspectHtml = '';
const myAspects = _aspectIndex[angleName] || [];
if (myAspects.length) {
aspectHtml = '<small class="tt-aspects">';
myAspects.forEach(({ partner, type, orb, applying_planet }) => {
const ppdata = _currentData.planets[partner] || {};
const asym = ASPECT_SYMBOLS[type] || type;
const lineSvg = _aspectLineSvg(type, applying_planet);
aspectHtml +=
`<div class="tt-asp-row">` +
`${lineSvg} ${asym} ${_pSym(partner)} <span class="tt-asp-in">in</span> ${_signIcon(ppdata.sign)}` +
` <span class="tt-asp-orb">${APPLY_SYM} ${orb}°</span>` +
`</div>`;
});
aspectHtml += '</small>';
}
if (_ttBody) {
_ttBody.innerHTML =
`<div class="tt-planet-header">` +
`<span class="tt-title">${def.label || angleName}</span>` +
`<span class="tt-planet-sym tt-angle-sym">${def.sym || angleName}</span>` +
`</div>` +
`<div class="tt-planet-loc">` +
`<span>@${inDeg}° ${sign.name || ''}</span>` +
`<span class="tt-planet-sign-icon tt-planet-sign-icon--${elKey}">${icon}</span>` +
`</div>` +
`<div class="tt-angle-house">${_houseOrdinal(def.house)} House <span class="tt-dim">(${HOUSE_LABELS[def.house] || ''})</span></div>` +
aspectHtml;
}
_aspectsVisible = false;
_positionTooltipAtItem('angles', angleName);
if (_tooltipEl) {
_tooltipEl.style.display = 'block';
_tooltipEl.querySelector('.nw-asp-don')?.style.removeProperty('display');
_tooltipEl.querySelector('.nw-asp-doff')?.style.removeProperty('display');
}
_updateAspectToggleUI();
}
/** /**
* Position the tooltip in the vertical half of the wheel opposite to the * Position the tooltip in the vertical half of the wheel opposite to the
* clicked planet/element. * clicked planet/element.
@@ -336,6 +511,9 @@ const NatusWheel = (() => {
} else if (ring === 'houses') { } else if (ring === 'houses') {
const grp = svgNode.querySelector(`[data-house="${_houseItems[idx].num}"]`); const grp = svgNode.querySelector(`[data-house="${_houseItems[idx].num}"]`);
el = grp && (grp.querySelector('path') || grp); el = grp && (grp.querySelector('path') || grp);
} else if (ring === 'angles') {
const grp = svgNode.querySelector(`[data-angle="${idx}"]`);
el = grp && (grp.querySelector('text') || grp);
} }
if (el) iRect = el.getBoundingClientRect(); if (el) iRect = el.getBoundingClientRect();
} }
@@ -383,16 +561,29 @@ const NatusWheel = (() => {
if (myAspects.length) { if (myAspects.length) {
aspectHtml = '<small class="tt-aspects">'; aspectHtml = '<small class="tt-aspects">';
myAspects.forEach(({ partner, type, orb, applying_planet }) => { myAspects.forEach(({ partner, type, orb, applying_planet }) => {
const psym = PLANET_SYMBOLS[partner] || partner[0]; const isAngle = partner === 'ASC' || partner === 'MC';
const ppdata = _currentData.planets[partner] || {}; const ppdata = isAngle ? {} : (_currentData.planets[partner] || {});
const sicon = _signIconSvg(ppdata.sign) || (SIGNS.find(s => s.name === ppdata.sign) || {}).symbol || '';
const asym = ASPECT_SYMBOLS[type] || type; const asym = ASPECT_SYMBOLS[type] || type;
const dirsym = applying_planet === item.name ? APPLY_SYM : SEP_SYM; const applying = applying_planet === item.name;
const lineSvg = _aspectLineSvg(type, applying_planet); const lineSvg = _aspectLineSvg(type, applying_planet);
const partnerSym = isAngle
? `<span class="tt-title tt-angle-sym">${(ANGLE_DEFS[partner] || {}).sym || partner}</span>`
: _pSym(partner);
let signPart;
if (isAngle) {
const aDeg = partner === 'ASC' ? _currentData.houses.asc : _currentData.houses.mc;
const aSign = SIGNS[Math.floor(aDeg / 30) % 12];
signPart = aSign ? ` <span class="tt-asp-in">in</span> ${_signIcon(aSign.name)}` : '';
} else {
signPart = ` <span class="tt-asp-in">in</span> ${_signIcon(ppdata.sign)}`;
}
const orbHtml = applying
? `${APPLY_SYM} ${orb}°`
: `${orb}° ${SEP_SYM}`;
aspectHtml += aspectHtml +=
`<div class="tt-asp-row">` + `<div class="tt-asp-row">` +
`${lineSvg} ${asym} ${psym} <span class="tt-asp-in">in</span> ${sicon}` + `${lineSvg} ${asym} ${partnerSym}${signPart}` +
` <span class="tt-asp-orb">(${dirsym} ${orb}°)</span>` + ` <span class="tt-asp-orb">${orbHtml}</span>` +
`</div>`; `</div>`;
}); });
aspectHtml += '</small>'; aspectHtml += '</small>';
@@ -400,8 +591,14 @@ const NatusWheel = (() => {
if (_ttBody) { if (_ttBody) {
_ttBody.innerHTML = _ttBody.innerHTML =
`<div class="tt-title tt-title--${el}">${item.name} (${sym})</div>` + `<div class="tt-planet-header">` +
`<div class="tt-description">@${inDeg}° ${pdata.sign} (${icon})${rx}</div>` + `<span class="tt-title tt-title--${el}">${item.name}</span>` +
`<span class="tt-planet-sym tt-title--${el}">${sym}</span>` +
`</div>` +
`<div class="tt-planet-loc">` +
`<span>@${inDeg}° ${pdata.sign}${rx}</span>` +
`<span class="tt-planet-sign-icon tt-planet-sign-icon--${(signData.element || '').toLowerCase()}">${icon}</span>` +
`</div>` +
aspectHtml; aspectHtml;
} }
@@ -437,14 +634,13 @@ const NatusWheel = (() => {
if (CLASSIC_ELEMENTS.has(item.key)) { if (CLASSIC_ELEMENTS.has(item.key)) {
const contribs = elData.contributors || []; const contribs = elData.contributors || [];
bodyHtml = `<div class="tt-el-body-line">${vecImg} +${count} (${pct}%)</div>`; bodyHtml = `<div class="tt-el-body-line">${vecImg} +${count} (${pct}%)</div>`;
bodyHtml += `<div class="tt-sign-section-header">Planets</div>`;
if (contribs.length) { if (contribs.length) {
bodyHtml += '<div class="tt-el-contribs">'; bodyHtml += '<div class="tt-el-contribs">';
contribs.forEach(c => { contribs.forEach(c => {
const psym = PLANET_SYMBOLS[c.planet] || c.planet[0];
const pdata = (_currentData.planets || {})[c.planet] || {}; const pdata = (_currentData.planets || {})[c.planet] || {};
const inDeg = pdata.degree !== undefined ? _inSignDeg(pdata.degree).toFixed(1) : '?'; const inDeg = pdata.degree !== undefined ? _inSignDeg(pdata.degree).toFixed(1) : '?';
const sicon = _signIconSvg(c.sign) || (SIGNS.find(s => s.name === c.sign) || {}).symbol || ''; bodyHtml += `<div class="tt-asp-row">${_pSym(c.planet)} @ ${inDeg}° ${_signIcon(c.sign)} +1</div>`;
bodyHtml += `<div class="tt-asp-row">${psym} @ ${inDeg}° ${sicon} +1</div>`;
}); });
bodyHtml += '</div>'; bodyHtml += '</div>';
} else { } else {
@@ -455,17 +651,21 @@ const NatusWheel = (() => {
const stellia = elData.stellia || []; const stellia = elData.stellia || [];
bodyHtml = `<div class="tt-el-body-line">${vecImg} +${count} (${pct}%)</div>`; bodyHtml = `<div class="tt-el-body-line">${vecImg} +${count} (${pct}%)</div>`;
if (stellia.length) { if (stellia.length) {
const isTie = stellia.length > 1;
bodyHtml += '<div class="tt-el-contribs">'; bodyHtml += '<div class="tt-el-contribs">';
stellia.forEach(st => { stellia.forEach(st => {
const bonus = st.planets.length - 1; const bonus = st.planets.length - 1;
bodyHtml += `<div class="tt-el-formation-header">Stellium +${bonus}</div>`; bodyHtml += `<div class="tt-el-formation-header"><span class="tt-el-formation-label">Stellium</span> +${bonus}</div>`;
if (isTie) {
st.planets.forEach(p => { st.planets.forEach(p => {
const psym = PLANET_SYMBOLS[p.planet] || p.planet[0];
const pdata = (_currentData.planets || {})[p.planet] || {}; const pdata = (_currentData.planets || {})[p.planet] || {};
const inDeg = pdata.degree !== undefined ? _inSignDeg(pdata.degree).toFixed(1) : '?'; const inDeg = pdata.degree !== undefined ? _inSignDeg(pdata.degree).toFixed(1) : '?';
const sicon = _signIconSvg(p.sign) || (SIGNS.find(s => s.name === p.sign) || {}).symbol || ''; bodyHtml += `<div class="tt-asp-row tt-el-planet-row">${_pSym(p.planet)} @ ${inDeg}° ${_signIcon(p.sign)}</div>`;
bodyHtml += `<div class="tt-asp-row tt-el-planet-row">${psym} @ ${inDeg}° ${sicon}</div>`;
}); });
} else {
const psyms = st.planets.map(p => _pSym(p.planet)).join(' ');
bodyHtml += `<div class="tt-asp-row tt-el-planet-row">${_signIcon(st.sign)} : ${psyms}</div>`;
}
}); });
bodyHtml += '</div>'; bodyHtml += '</div>';
} else { } else {
@@ -479,8 +679,7 @@ const NatusWheel = (() => {
bodyHtml += '<div class="tt-el-contribs">'; bodyHtml += '<div class="tt-el-contribs">';
parades.forEach(pd => { parades.forEach(pd => {
const bonus = pd.signs.length - 1; const bonus = pd.signs.length - 1;
bodyHtml += `<div class="tt-el-formation-header">Parade +${bonus}</div>`; bodyHtml += `<div class="tt-el-formation-header"><span class="tt-el-formation-label">Parade</span> +${bonus}</div>`;
// Group planets by sign, sorted by ecliptic degree (counterclockwise = ascending)
const bySign = {}; const bySign = {};
pd.planets.forEach(p => { pd.planets.forEach(p => {
if (!bySign[p.sign]) bySign[p.sign] = []; if (!bySign[p.sign]) bySign[p.sign] = [];
@@ -495,9 +694,8 @@ const NatusWheel = (() => {
}); });
pd.signs.forEach(sign => { pd.signs.forEach(sign => {
const planets = bySign[sign] || []; const planets = bySign[sign] || [];
const sicon = _signIconSvg(sign) || (SIGNS.find(s => s.name === sign) || {}).symbol || sign; const psyms = planets.map(p => _pSym(p.planet)).join(' ');
const psyms = planets.map(p => PLANET_SYMBOLS[p.planet] || p.planet[0]).join(' '); bodyHtml += `<div class="tt-asp-row tt-el-planet-row">${_signIcon(sign)} : ${psyms}</div>`;
bodyHtml += `<div class="tt-asp-row tt-el-planet-row">${sicon} (${psyms})</div>`;
}); });
}); });
bodyHtml += '</div>'; bodyHtml += '</div>';
@@ -522,16 +720,63 @@ const NatusWheel = (() => {
} }
function _activateSign(idx) { function _activateSign(idx) {
_clearActive();
_activeRing = 'signs'; _activeRing = 'signs';
_activeIdx = idx; _activeIdx = idx;
const sign = _signItems[idx]; const sign = _signItems[idx];
_svg.select(`[data-sign-name="${sign.name}"]`).classed('nw-sign--active', true);
const elKey = sign.element.toLowerCase();
const modality = SIGN_MODALITIES[idx];
const vecImg = _elementVectorImg(sign.element);
const iconSvg = _signIconSvg(sign.name) ||
`<span class="tt-sign-sym-fallback">${sign.symbol}</span>`;
let planetsHtml = '';
let cuspsHtml = '';
if (_currentData) {
const cusps = (_currentData.houses || {}).cusps || [];
const inSign = Object.entries(_currentData.planets || {})
.filter(([, p]) => p.sign === sign.name)
.sort((a, b) => a[1].degree - b[1].degree);
inSign.forEach(([pname, pdata]) => {
const inDeg = _inSignDeg(pdata.degree).toFixed(1);
const house = cusps.length ? _planetHouse(pdata.degree, cusps) : null;
const domain = house ? HOUSE_LABELS[house] : '';
const houseHtml = house
? ` ${_houseOrdinal(house)} House <span class="tt-dim">(${domain})</span>`
: '';
planetsHtml +=
`<div class="tt-house-planet-row">${_pSym(pname)} <span class="tt-dim">@${inDeg}°</span>${houseHtml}</div>`;
});
if (cusps.length) {
cusps.forEach((cuspDeg, i) => {
const norm = ((cuspDeg % 360) + 360) % 360;
if (Math.floor(norm / 30) === idx) {
const houseNum = i + 1;
const inDeg = _inSignDeg(cuspDeg).toFixed(1);
cuspsHtml +=
`<div class="tt-house-planet-row">${_houseOrdinal(houseNum)} House <span class="tt-dim">@${inDeg}°</span> ${_signIcon(sign.name)}</div>`;
}
});
}
}
if (_ttBody) { if (_ttBody) {
_ttBody.innerHTML = _ttBody.innerHTML =
`<div class="tt-sign-header">` + `<div class="tt-sign-header">` +
`<span class="tt-sign-symbol">${sign.symbol}</span>` + `<span class="tt-title tt-title--sign-${elKey}">${sign.name}</span>` +
`<span class="tt-title">${sign.name}</span>` + `<span class="tt-sign-icon-wrap tt-sign-icon-wrap--${elKey}">${iconSvg}</span>` +
`<span class="tt-sign-element"> · ${sign.element}</span>` + `</div>` +
`</div>`; `<div class="tt-sign-meta">` +
`<span>${modality} ${sign.element}</span>` +
vecImg +
`</div>` +
`<div class="tt-sign-section-header">Planets</div>` +
(planetsHtml ? `<div class="tt-sign-planets">${planetsHtml}</div>` : `<div class="tt-el-formation">—</div>`) +
`<br>` +
`<div class="tt-sign-section-header">Cusps</div>` +
(cuspsHtml ? `<div class="tt-sign-cusps">${cuspsHtml}</div>` : `<div class="tt-el-formation">—</div>`);
} }
_positionTooltipAtItem('signs', idx); _positionTooltipAtItem('signs', idx);
if (_tooltipEl) { if (_tooltipEl) {
@@ -541,15 +786,39 @@ const NatusWheel = (() => {
} }
function _activateHouse(idx) { function _activateHouse(idx) {
_clearActive();
_activeRing = 'houses'; _activeRing = 'houses';
_activeIdx = idx; _activeIdx = idx;
const house = _houseItems[idx]; const house = _houseItems[idx];
_svg.select(`[data-house="${house.num}"]`).classed('nw-house--active', true);
const cusps = (_currentData && (_currentData.houses || {}).cusps) || [];
let planetsHtml = '';
if (cusps.length && _currentData) {
const inHouse = Object.entries(_currentData.planets || {})
.filter(([, p]) => _planetHouse(p.degree, cusps) === house.num)
.sort((a, b) => a[1].degree - b[1].degree);
inHouse.forEach(([pname, pdata]) => {
const inDeg = _inSignDeg(pdata.degree).toFixed(1);
planetsHtml += `<div class="tt-house-planet-row">${_pSym(pname)} <span class="tt-dim">@${inDeg}°</span> ${_signIcon(pdata.sign)}</div>`;
});
}
const quadrant = HOUSE_QUADRANT[house.num] || '';
const phase = HOUSE_PHASE[house.num] || '';
if (_ttBody) { if (_ttBody) {
_ttBody.innerHTML = _ttBody.innerHTML =
`<div class="tt-house-header">` + `<div class="tt-house-header">` +
`<span class="tt-title">${house.num}</span>` + `<span class="tt-title">` +
`<span class="tt-house-label"> · ${house.label}</span>` + `<span class="tt-house-of">House of</span><br>` +
`</div>`; `${house.label}<br>` +
`<span class="tt-house-type">${quadrant} ${phase}</span>` +
`</span>` +
`<span class="tt-house-num">${house.num}</span>` +
`</div>` +
`<div class="tt-sign-section-header">Planets</div>` +
(planetsHtml ? `<div class="tt-house-planets">${planetsHtml}</div>` : `<div class="tt-el-formation">—</div>`);
} }
_positionTooltipAtItem('houses', idx); _positionTooltipAtItem('houses', idx);
if (_tooltipEl) { if (_tooltipEl) {
@@ -592,6 +861,8 @@ const NatusWheel = (() => {
if (_aspectsVisible) { if (_aspectsVisible) {
if (_activeRing === 'planets' && _activeIdx !== null) { if (_activeRing === 'planets' && _activeIdx !== null) {
_showPlanetAspects(_planetItems[_activeIdx].name); _showPlanetAspects(_planetItems[_activeIdx].name);
} else if (_activeRing === 'angles' && _activeIdx !== null) {
_showAngleAspects(_activeIdx);
} }
} else { } else {
_clearAspectLines(); _clearAspectLines();
@@ -621,9 +892,9 @@ const NatusWheel = (() => {
e.stopPropagation(); _stepCycle(1); e.stopPropagation(); _stepCycle(1);
}); });
_tooltipEl.querySelector('.nw-asp-don') _tooltipEl.querySelector('.nw-asp-don')
.addEventListener('click', (e) => { e.stopPropagation(); _toggleAspects(); }); .addEventListener('click', (e) => { e.stopPropagation(); if (!e.currentTarget.classList.contains('btn-disabled')) _toggleAspects(); });
_tooltipEl.querySelector('.nw-asp-doff') _tooltipEl.querySelector('.nw-asp-doff')
.addEventListener('click', (e) => { e.stopPropagation(); _toggleAspects(); }); .addEventListener('click', (e) => { e.stopPropagation(); if (!e.currentTarget.classList.contains('btn-disabled')) _toggleAspects(); });
// Sync button to current state in case of redraw mid-session. // Sync button to current state in case of redraw mid-session.
_updateAspectToggleUI(); _updateAspectToggleUI();
} }
@@ -635,15 +906,6 @@ const NatusWheel = (() => {
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (_activeRing === null) return; if (_activeRing === null) return;
if (_tooltipEl && _tooltipEl.contains(e.target)) return; if (_tooltipEl && _tooltipEl.contains(e.target)) return;
// DON/DOFF have pointer-events:none when disabled — check bounding rect directly
if (_tooltipEl) {
for (const cls of ['.nw-asp-don', '.nw-asp-doff', '.nw-tt-prv', '.nw-tt-nxt']) {
const btn = _tooltipEl.querySelector(cls);
if (!btn) continue;
const r = btn.getBoundingClientRect();
if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) return;
}
}
_closeTooltip(); _closeTooltip();
}, { signal: _outsideClickController.signal }); }, { signal: _outsideClickController.signal });
} }
@@ -680,13 +942,13 @@ const NatusWheel = (() => {
const asc = data.houses.asc; const asc = data.houses.asc;
const mc = data.houses.mc; const mc = data.houses.mc;
const points = [ const points = [
{ deg: asc, label: 'ASC' }, { deg: asc, label: 'ASC', clickable: true },
{ deg: asc + 180, label: 'DSC' }, { deg: asc + 180, label: 'DSC', clickable: false },
{ deg: mc, label: 'MC' }, { deg: mc, label: 'MC', clickable: true },
{ deg: mc + 180, label: 'IC' }, { deg: mc + 180, label: 'IC', clickable: false },
]; ];
const axisGroup = g.append('g').attr('class', 'nw-axes'); const axisGroup = g.append('g').attr('class', 'nw-axes');
points.forEach(({ deg, label }) => { points.forEach(({ deg, label, clickable }) => {
const a = _toAngle(deg, asc); const a = _toAngle(deg, asc);
const x1 = _cx + R.houseInner * Math.cos(a); const x1 = _cx + R.houseInner * Math.cos(a);
const y1 = _cy + R.houseInner * Math.sin(a); const y1 = _cy + R.houseInner * Math.sin(a);
@@ -696,7 +958,8 @@ const NatusWheel = (() => {
.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('class', 'nw-axis-line'); .attr('class', 'nw-axis-line');
axisGroup.append('text')
const textEl = 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')
@@ -704,6 +967,37 @@ const NatusWheel = (() => {
.attr('font-size', `${_r * 0.055}px`) .attr('font-size', `${_r * 0.055}px`)
.attr('class', 'nw-axis-label') .attr('class', 'nw-axis-label')
.text(label); .text(label);
if (clickable) {
const hitR = _r * 0.055 + 6;
const hitGroup = axisGroup.append('g')
.attr('class', 'nw-angle-group')
.attr('data-angle', label)
.style('cursor', 'pointer');
// Invisible hit circle over the label
hitGroup.append('circle')
.attr('cx', _cx + (R.ascMcR + 12) * Math.cos(a))
.attr('cy', _cy + (R.ascMcR + 12) * Math.sin(a))
.attr('r', hitR)
.attr('fill', 'transparent');
// Move the text into the clickable group
textEl.remove();
hitGroup.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('class', 'nw-axis-label')
.text(label);
hitGroup.on('click', function (event) {
event.stopPropagation();
_activateAngle(label);
});
}
}); });
} }
@@ -752,7 +1046,7 @@ const NatusWheel = (() => {
.attr('cx', lx) .attr('cx', lx)
.attr('cy', ly) .attr('cy', ly)
.attr('r', cr) .attr('r', cr)
.attr('class', `nw-sign-icon-bg--${sign.element.toLowerCase()}`); .attr('class', `nw-sign-icon-bg nw-sign-icon-bg--${sign.element.toLowerCase()}`);
if (_signPaths[sign.name]) { if (_signPaths[sign.name]) {
signSlice.append('path') signSlice.append('path')
@@ -778,8 +1072,22 @@ const NatusWheel = (() => {
return { i, startA, sa, ea, midA: (sa + ea) / 2 }; return { i, startA, sa, ea, midA: (sa + ea) / 2 };
}); });
houses.forEach(({ i, sa, ea }) => { houses.forEach(({ i, startA, sa, ea, midA }) => {
houseGroup.append('path') // Per-house group — wraps fill + number so CSS can target both on hover/active
const grp = houseGroup.append('g')
.attr('class', 'nw-house-group')
.attr('data-house', i + 1)
.on('click', function (event) {
event.stopPropagation();
const clickIdx = i;
if (_activeRing === 'houses' && _activeIdx === clickIdx) {
_closeTooltip();
} else {
_activateHouse(clickIdx);
}
});
grp.append('path')
.attr('transform', `translate(${_cx},${_cy})`) .attr('transform', `translate(${_cx},${_cy})`)
.attr('d', arc({ .attr('d', arc({
innerRadius: R.houseInner, innerRadius: R.houseInner,
@@ -787,20 +1095,19 @@ const NatusWheel = (() => {
startAngle: sa + Math.PI / 2, startAngle: sa + Math.PI / 2,
endAngle: ea + Math.PI / 2, endAngle: ea + Math.PI / 2,
})) }))
.attr('class', i % 2 === 0 ? 'nw-house-fill--even' : 'nw-house-fill--odd') .attr('class', i % 2 === 0 ? 'nw-house-fill--even' : 'nw-house-fill--odd');
.attr('data-house', i + 1)
.on('click', function (event) {
event.stopPropagation();
const clickIdx = i; // _houseItems[i].num === i+1
if (_activeRing === 'houses' && _activeIdx === clickIdx) {
_closeTooltip();
} else {
_activateHouse(clickIdx);
}
});
});
houses.forEach(({ i, startA, midA }) => { grp.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('class', 'nw-house-num')
.attr('pointer-events', 'none')
.text(i + 1);
// Cusp lines at quadrant boundaries stay in houseGroup (not clickable)
if (i % 3 === 0) { if (i % 3 === 0) {
houseGroup.append('line') houseGroup.append('line')
.attr('x1', _cx + R.houseInner * Math.cos(startA)) .attr('x1', _cx + R.houseInner * Math.cos(startA))
@@ -809,15 +1116,6 @@ const NatusWheel = (() => {
.attr('y2', _cy + R.signInner * Math.sin(startA)) .attr('y2', _cy + R.signInner * Math.sin(startA))
.attr('class', 'nw-house-cusp'); .attr('class', 'nw-house-cusp');
} }
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('class', 'nw-house-num')
.text(i + 1);
}); });
} }
@@ -918,6 +1216,17 @@ const NatusWheel = (() => {
* Build the aspect index (planet → aspects list) and create the persistent * Build the aspect index (planet → aspects list) and create the persistent
* nw-aspects group. Lines are drawn per-planet on click, not here. * nw-aspects group. Lines are drawn per-planet on click, not here.
*/ */
/** Compute aspect type + orb between an angle (deg) and a planet (deg), or null. */
function _angleAspectWith(angleDeg, planetDeg) {
let diff = Math.abs(planetDeg - angleDeg) % 360;
if (diff > 180) diff = 360 - diff;
for (const { type, angle } of MAJOR_ASPECTS) {
const orb = Math.abs(diff - angle);
if (orb <= ANGLE_ORB) return { type, orb: +orb.toFixed(1) };
}
return null;
}
function _drawAspects(g, data) { function _drawAspects(g, data) {
_aspectIndex = {}; _aspectIndex = {};
Object.entries(data.planets).forEach(([name]) => { _aspectIndex[name] = []; }); Object.entries(data.planets).forEach(([name]) => { _aspectIndex[name] = []; });
@@ -929,6 +1238,19 @@ const NatusWheel = (() => {
_aspectIndex[planet2].push({ partner: planet1, ...shared }); _aspectIndex[planet2].push({ partner: planet1, ...shared });
}); });
// Compute planet-angle aspects client-side (planet always applies to angle).
['ASC', 'MC'].forEach(angleName => {
const angleDeg = angleName === 'ASC' ? data.houses.asc : data.houses.mc;
_aspectIndex[angleName] = [];
Object.entries(data.planets).forEach(([pname, pdata]) => {
const asp = _angleAspectWith(angleDeg, pdata.degree);
if (!asp) return;
const shared = { type: asp.type, orb: asp.orb, applying_planet: pname };
_aspectIndex[angleName].push({ partner: pname, ...shared });
_aspectIndex[pname].push({ partner: angleName, ...shared });
});
});
_aspectGroup = g.append('g').attr('class', 'nw-aspects'); _aspectGroup = g.append('g').attr('class', 'nw-aspects');
} }

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-04-22 01:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0018_user_sky_fields'),
]
operations = [
migrations.AddField(
model_name='user',
name='sky_birth_tz',
field=models.CharField(blank=True, max_length=64),
),
]

View File

@@ -52,6 +52,7 @@ class User(AbstractBaseUser):
sky_birth_lat = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True) sky_birth_lat = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True)
sky_birth_lon = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True) sky_birth_lon = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True)
sky_birth_place = models.CharField(max_length=255, blank=True) sky_birth_place = models.CharField(max_length=255, blank=True)
sky_birth_tz = models.CharField(max_length=64, blank=True)
sky_house_system = models.CharField(max_length=1, blank=True, default="O") sky_house_system = models.CharField(max_length=1, blank=True, default="O")
sky_chart_data = models.JSONField(null=True, blank=True) sky_chart_data = models.JSONField(null=True, blank=True)

View File

@@ -819,3 +819,137 @@ describe("NatusWheel — house ring click tooltips", () => {
expect(tooltipEl.style.display).toBe("none"); expect(tooltipEl.style.display).toBe("none");
}); });
}); });
// ── T15 — ASC / MC angle click tooltips + aspect lines ────────────────────────
//
// ASC and MC labels are clickable groups ([data-angle='ASC'] / [data-angle='MC']).
// Clicking shows a tooltip similar to a planet tooltip:
// Title: "Ascendant" (ASC) or "Midheaven" (MC)
// Degree in sign + sign name, plus the house number the angle defines.
// Aspect list for planets within 10° orb of the angle (client-side computed).
// Aspect lines drawn to those planets.
// Clicking same angle again closes the tooltip.
// Planet tooltips include angle aspects in their own aspect lists.
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — angle (ASC/MC) click tooltips", () => {
// ASC=0°(Aries): Sun@8° → Conjunction orb 8° ✓; Mars@188° → Opposition orb 8° ✓
// MC=90°(Cancer): Moon@97° → Conjunction orb 7° ✓
const ANGLE_CHART = {
planets: {
Sun: { sign: "Aries", degree: 8.0, retrograde: false },
Moon: { sign: "Cancer", degree: 97.0, retrograde: false },
Mars: { sign: "Libra", degree: 188.0, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0.0,
mc: 90.0,
},
elements: { Fire: 2, Stone: 0, Air: 1, Water: 1, Time: 0, Space: 0 },
aspects: [],
distinctions: {
"1":1,"2":0,"3":0,"4":1,"5":0,"6":0,
"7":1,"8":0,"9":0,"10":1,"11":0,"12":0,
},
house_system: "O",
};
let svgEl, tooltipEl;
beforeEach(() => {
svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl.setAttribute("id", "id_natus_svg");
svgEl.setAttribute("width", "400");
svgEl.setAttribute("height", "400");
svgEl.style.width = "400px";
svgEl.style.height = "400px";
document.body.appendChild(svgEl);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
NatusWheel.draw(svgEl, ANGLE_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// T15a — ASC label is a clickable group with data-angle='ASC'
it("T15a: clicking [data-angle='ASC'] shows tooltip with 'Ascendant' title and house 1", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
expect(ascGroup).not.toBeNull("expected [data-angle='ASC'] to exist in the SVG");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
const text = tooltipEl.textContent;
expect(text).toContain("Ascendant");
// ASC=0° is the cusp of House 1
expect(text).toContain("1");
});
// T15b — MC label is a clickable group with data-angle='MC'
it("T15b: clicking [data-angle='MC'] shows tooltip with 'Midheaven' title and house 10", () => {
const mcGroup = svgEl.querySelector("[data-angle='MC']");
expect(mcGroup).not.toBeNull("expected [data-angle='MC'] to exist in the SVG");
mcGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
const text = tooltipEl.textContent;
expect(text).toContain("Midheaven");
expect(text).toContain("10");
});
// T15c — ASC tooltip shows degree-in-sign and sign name
it("T15c: ASC tooltip shows the in-sign degree and sign of the Ascendant", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const text = tooltipEl.textContent;
// ASC=0° → 0° Aries
expect(text).toContain("Aries");
expect(text).toContain("0.0");
});
// T15d — clicking same angle a second time hides the tooltip
it("T15d: clicking the same angle again hides the tooltip", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("none");
});
// T15e — ASC tooltip lists planets within 10° orb (client-side computed)
it("T15e: ASC tooltip includes aspect rows for planets within 10° orb", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const bodyHtml = tooltipEl.querySelector(".nw-tt-body").innerHTML;
// Sun at 8° → Conjunction (orb 8°) ✓ — _pSym emits data-planet attr
expect(bodyHtml).toContain('data-planet="Sun"');
// Mars at 188° → Opposition to ASC (0°), angular distance 172° → orb 8° ✓
expect(bodyHtml).toContain('data-planet="Mars"');
});
// T15f — planet tooltip includes ASC in its aspect list when within orb
it("T15f: planet tooltip for Sun lists ASC as an aspect partner", () => {
const sunGroup = svgEl.querySelector("[data-planet='Sun']");
expect(sunGroup).not.toBeNull();
sunGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const bodyHtml = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(bodyHtml).toContain("ASC");
});
});

View File

@@ -373,33 +373,33 @@ html.natus-open .natus-modal-wrap {
.nw-axis-line { stroke: rgba(var(--secUser), 1); stroke-width: 1.5px; } .nw-axis-line { stroke: rgba(var(--secUser), 1); stroke-width: 1.5px; }
.nw-axis-label { fill: rgba(var(--secUser), 1); } .nw-axis-label { fill: rgba(var(--secUser), 1); }
// Sign ring — uniform --priMe bg at half opacity // Sign ring — uniform --priYl bg at half opacity
.nw-sign--fire, .nw-sign--fire,
.nw-sign--stone, .nw-sign--stone,
.nw-sign--air, .nw-sign--air,
.nw-sign--water { .nw-sign--water {
fill: rgba(var(--priMe), 0.25); fill: rgba(var(--priYl), 0.25);
stroke: rgba(var(--terUser), 1); stroke: rgba(var(--terUser), 1);
stroke-width: 0.75px; stroke-width: 0.75px;
} }
// Icon bg circles — element fill + matching border // Icon bg circles — element fill + matching border
.nw-sign-icon-bg--fire { fill: rgba(var(--terRd), 0.92); stroke: rgba(var(--priOr), 1); stroke-width: 1px; } .nw-sign-icon-bg--fire { fill: rgba(var(--quaRd), 0.92); stroke: rgba(var(--priOr), 1); stroke-width: 1px; }
.nw-sign-icon-bg--stone { fill: rgba(var(--priYl), 0.92); stroke: rgba(var(--priLm), 1); stroke-width: 1px; } .nw-sign-icon-bg--stone { fill: rgba(var(--quaFs), 0.92); stroke: rgba(var(--priMe), 1); stroke-width: 1px; }
.nw-sign-icon-bg--air { fill: rgba(var(--terGn), 0.92); stroke: rgba(var(--priTk), 1); stroke-width: 1px; } .nw-sign-icon-bg--air { fill: rgba(var(--quiCy), 0.92); stroke: rgba(var(--priBl), 1); stroke-width: 1px; }
.nw-sign-icon-bg--water { fill: rgba(var(--priCy), 0.92); stroke: rgba(var(--priBl), 1); stroke-width: 1px; } .nw-sign-icon-bg--water { fill: rgba(var(--sixId), 0.92); stroke: rgba(var(--priVt), 1); stroke-width: 1px; }
// Inline SVG path icons — per-element colors // Inline SVG path icons — per-element colors
.nw-sign-icon--fire { fill: rgba(var(--priOr), 1); text-shadow: 2px 2px 2px rgba(0, 0, 0, 1); } .nw-sign-icon--fire { fill: rgba(var(--priOr), 1); }
.nw-sign-icon--stone { fill: rgba(var(--terLm), 1); text-shadow: 2px 2px 2px rgba(0, 0, 0, 1); } .nw-sign-icon--stone { fill: rgba(var(--priMe), 1); }
.nw-sign-icon--air { fill: rgba(var(--priTk), 1); text-shadow: 2px 2px 2px rgba(0, 0, 0, 1); } .nw-sign-icon--air { fill: rgba(var(--priBl), 1); }
.nw-sign-icon--water { fill: rgba(var(--terBl), 1); text-shadow: 2px 2px 2px rgba(0, 0, 0, 1); } .nw-sign-icon--water { fill: rgba(var(--priVt), 1); }
// House ring — uniform --priFs bg // House ring — uniform --priGn bg
.nw-house-cusp { stroke: rgba(var(--terUser), 1); stroke-width: 1.2px; } .nw-house-cusp { stroke: rgba(var(--terUser), 1); stroke-width: 1.2px; }
.nw-house-num { fill: rgba(var(--ninUser), 1); } .nw-house-num { fill: rgba(var(--ninUser), 1); }
.nw-house-fill--even { fill: rgba(var(--priFs), 0.25); stroke: rgba(var(--terUser), 1); stroke-width: 0.75px; } .nw-house-fill--even { fill: rgba(var(--secGn), 0.25); stroke: rgba(var(--terUser), 1); stroke-width: 0.75px; }
.nw-house-fill--odd { fill: rgba(var(--priFs), 0.25); stroke: rgba(var(--terUser), 1); stroke-width: 0.75px; } .nw-house-fill--odd { fill: rgba(var(--quiGn), 0.25); stroke: rgba(var(--terUser), 1); stroke-width: 0.75px; }
// Planets — base geometry // Planets — base geometry
.nw-planet-circle, .nw-planet-circle,
@@ -431,17 +431,34 @@ html.natus-open .natus-modal-wrap {
.nw-planet-label--pu { fill: rgba(var(--sixPu), 1); stroke: rgba(var(--sixPu), 0.6); } .nw-planet-label--pu { fill: rgba(var(--sixPu), 1); stroke: rgba(var(--sixPu), 0.6); }
.nw-rx { fill: rgba(var(--terUser), 1); } .nw-rx { fill: rgba(var(--terUser), 1); }
// Hover and active-lock glow — planet groups and element slice groups // Hover and active-lock glow — planet, element, sign, house groups
.nw-planet-group, .nw-planet-group,
.nw-element-group { cursor: pointer; } .nw-element-group,
.nw-sign-group,
.nw-house-group { cursor: pointer; }
.nw-planet-group:hover, .nw-planet-group:hover,
.nw-planet-group.nw-planet--active, .nw-planet-group.nw-planet--active,
.nw-planet-group.nw-planet--asp-active,
.nw-element-group:hover, .nw-element-group:hover,
.nw-element-group.nw-element--active { .nw-element-group.nw-element--active,
.nw-sign-group:hover,
.nw-sign-group.nw-sign--active,
.nw-house-group:hover,
.nw-house-group.nw-house--active {
filter: drop-shadow(0 0 5px rgba(var(--ninUser), 0.9)); filter: drop-shadow(0 0 5px rgba(var(--ninUser), 0.9));
} }
// Zodiac icon circles — muted by default, full opacity on hover/active
.nw-sign-icon-bg { opacity: 0.5; }
.nw-sign-group:hover .nw-sign-icon-bg,
.nw-sign-group.nw-sign--active .nw-sign-icon-bg { opacity: 1; }
// House numbers — muted by default, full opacity on hover/active
.nw-house-num { opacity: 0.75; }
.nw-house-group:hover .nw-house-num,
.nw-house-group.nw-house--active .nw-house-num { opacity: 1; }
// ── Planet tick lines — hidden until parent group is active ────────────────── // ── Planet tick lines — hidden until parent group is active ──────────────────
.nw-planet-tick { .nw-planet-tick {
fill: none; fill: none;
@@ -505,10 +522,122 @@ body[class*="-light"] {
padding: 0.75rem 0.75rem 0.75rem 1.5rem; padding: 0.75rem 0.75rem 0.75rem 1.5rem;
min-width: 14rem; min-width: 14rem;
.tt-title { font-size: 1.25rem; font-weight: 700; margin-bottom: 0.3rem; } .tt-title { font-size: 1.25rem; font-weight: 700; margin-bottom: 0; }
.tt-description { font-size: 0.75rem; } .tt-description { font-size: 0.75rem; }
.tt-sign-icon { fill: currentColor; vertical-align: middle; margin-bottom: 0.1em; } .tt-sign-icon { fill: currentColor; vertical-align: middle; margin-bottom: 0.1em; }
// Planet tooltip — flex row: name | symbol; location row: @deg° Sign | sign icon
.tt-planet-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.2rem;
}
.tt-planet-sym {
font-size: 1.2rem;
opacity: 0.85;
}
.tt-angle-sym {
font-variant-caps: all-small-caps;
font-size: 1.1rem;
opacity: 0.85;
}
.tt-angle-house .tt-ord {
margin-left: 0;
}
.tt-planet-loc {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.8rem;
margin-bottom: 0.3rem;
}
.tt-planet-sign-icon { font-size: 1.2rem; line-height: 1; }
// Sign tooltip — name in element color | SVG icon; modality | vector; planets
.tt-sign-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.2rem;
}
.tt-sign-icon-wrap {
font-size: 1.5rem;
line-height: 1;
flex-shrink: 0;
.tt-sign-icon { fill: currentColor; }
}
.tt-sign-meta {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.75rem;
opacity: 0.85;
margin-bottom: 0.3rem;
}
.tt-sign-planets {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin-top: 0.1rem;
font-size: 0.85rem;
}
.tt-sign-section-header {
font-size: 0.65rem;
font-weight: 600;
opacity: 0.55;
letter-spacing: 0.04em;
margin-bottom: 0.15rem;
}
.tt-sign-cusps {
display: flex;
flex-direction: column;
gap: 0.15rem;
font-size: 0.85rem;
}
// House tooltip — "House of X" | number; planets in house
.tt-house-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.3rem;
}
.tt-house-of {
font-size: 0.6em;
font-weight: 700;
margin-right: 0.15em;
opacity: 0.9;
}
.tt-house-num {
font-size: 1.4rem;
font-weight: 700;
opacity: 1;
flex-shrink: 0;
}
.tt-house-type {
display: block;
font-size: 0.6em;
font-weight: 400;
opacity: 0.7;
margin-top: 0.1em;
}
.tt-house-planets {
display: flex;
flex-direction: column;
gap: 0.15rem;
font-size: 0.85rem;
}
.tt-house-planet-row {
display: flex;
align-items: center;
gap: 0.3rem;
}
// DON|DOFF aspect line toggle — stacked at top-left outside the tooltip box, // DON|DOFF aspect line toggle — stacked at top-left outside the tooltip box,
// matching the PRV/NXT pattern at the bottom corners. // matching the PRV/NXT pattern at the bottom corners.
.nw-asp-don, .nw-asp-don,
@@ -516,6 +645,7 @@ body[class*="-light"] {
position: absolute; position: absolute;
left: -1rem; left: -1rem;
margin: 0; margin: 0;
pointer-events: auto; // override btn-disabled; click must land here, not pass through to SVG
} }
.nw-asp-don { top: -1rem; } .nw-asp-don { top: -1rem; }
.nw-asp-doff { top: 1.2rem; } .nw-asp-doff { top: 1.2rem; }
@@ -548,12 +678,30 @@ body[class*="-light"] {
.tt-asp-line { flex-shrink: 0; vertical-align: middle; } .tt-asp-line { flex-shrink: 0; vertical-align: middle; }
.tt-asp-orb, .tt-asp-orb {
margin-left: auto;
opacity: 0.6;
font-size: 0.65rem;
padding-left: 0.5rem;
white-space: nowrap;
}
.tt-asp-in { .tt-asp-in {
opacity: 0.6; opacity: 0.6;
font-size: 0.65rem; font-size: 0.65rem;
padding-left: 0.25rem; padding-left: 0.25rem;
} }
.tt-dim {
opacity: 0.6;
font-size: 0.65rem;
}
.tt-ord {
font-size: 0.6rem;
vertical-align: 0.25rem;
line-height: 0;
opacity: 1;
margin-left: -0.25rem;
letter-spacing: 0;
}
// Element tooltip — title + square badge // Element tooltip — title + square badge
.tt-el-header { .tt-el-header {
@@ -593,9 +741,14 @@ body[class*="-light"] {
.tt-el-formation-header { .tt-el-formation-header {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 700;
text-decoration: underline;
margin-top: 0.35rem; margin-top: 0.35rem;
.tt-el-formation-label {
font-size: 0.65rem;
font-weight: 600;
opacity: 0.55;
letter-spacing: 0.04em;
}
} }
.tt-el-formation { .tt-el-formation {
@@ -629,6 +782,24 @@ body[class*="-light"] {
.tt-title--el-water { color: rgba(var(--priId), 1); } .tt-title--el-water { color: rgba(var(--priId), 1); }
} }
// Sign tooltip title + sign icon SVG — element border colors (Stone/Air/Fire/Water schema)
#id_natus_tooltip,
#id_natus_tooltip_2 {
.tt-title--sign-fire { color: rgba(var(--priOr), 1); }
.tt-title--sign-stone { color: rgba(var(--priMe), 1); }
.tt-title--sign-air { color: rgba(var(--priBl), 1); }
.tt-title--sign-water { color: rgba(var(--priVt), 1); }
.tt-sign-icon-wrap--fire .tt-sign-icon,
.tt-planet-sign-icon--fire .tt-sign-icon { fill: rgba(var(--priOr), 1); }
.tt-sign-icon-wrap--stone .tt-sign-icon,
.tt-planet-sign-icon--stone .tt-sign-icon { fill: rgba(var(--priMe), 1); }
.tt-sign-icon-wrap--air .tt-sign-icon,
.tt-planet-sign-icon--air .tt-sign-icon { fill: rgba(var(--priBl), 1); }
.tt-sign-icon-wrap--water .tt-sign-icon,
.tt-planet-sign-icon--water .tt-sign-icon { fill: rgba(var(--priVt), 1); }
}
// On light palettes — switch to tertiary tier for legibility // On light palettes — switch to tertiary tier for legibility
body[class*="-light"] #id_natus_tooltip, body[class*="-light"] #id_natus_tooltip,
body[class*="-light"] #id_natus_tooltip_2 { body[class*="-light"] #id_natus_tooltip_2 {

View File

@@ -122,34 +122,58 @@
--priRd: 233, 53, 37; --priRd: 233, 53, 37;
--secRd: 193, 43, 28; --secRd: 193, 43, 28;
--terRd: 155, 31, 15; --terRd: 155, 31, 15;
--quaRd: 119, 20, 4;
--quiRd: 85, 11, 0;
--sixRd: 54, 4, 0;
// orange (B-Fire) // orange (B-Fire)
--priOr: 225, 133, 40; --priOr: 225, 133, 40;
--secOr: 187, 111, 30; --secOr: 187, 111, 30;
--terOr: 150, 88, 17; --terOr: 150, 88, 17;
--quaOr: 115, 67, 6;
--quiOr: 82, 47, 0;
--sixOr: 51, 29, 0;
// yellow (A-Time) // yellow (A-Time)
--priYl: 255, 207, 52; --priYl: 255, 207, 52;
--secYl: 211, 172, 44; --secYl: 211, 172, 44;
--terYl: 168, 138, 33; --terYl: 168, 138, 33;
--quaYl: 128, 106, 24;
--quiYl: 91, 76, 15;
--sixYl: 57, 49, 7;
// lime (B-Time) // lime (B-Time)
--priLm: 151, 174, 60; --priLm: 151, 174, 60;
--secLm: 124, 145, 48; --secLm: 124, 145, 48;
--terLm: 97, 117, 36; --terLm: 97, 117, 36;
--quaLm: 71, 90, 25;
--quiLm: 47, 64, 15;
--sixLm: 25, 40, 6;
// green (A-Space) // green (A-Space)
--priGn: 0, 160, 75; --priGn: 0, 160, 75;
--secGn: 0, 135, 62; --secGn: 0, 135, 62;
--terGn: 0, 109, 48; --terGn: 0, 109, 48;
--quaGn: 0, 85, 35;
--quiGn: 0, 62, 23;
--sixGn: 0, 40, 12;
// teal (B-Space) // teal (B-Space)
--priTk: 0, 184, 162; --priTk: 0, 184, 162;
--secTk: 0, 154, 136; --secTk: 0, 154, 136;
--terTk: 0, 125, 110; --terTk: 0, 125, 110;
--quaTk: 0, 97, 85;
--quiTk: 0, 70, 61;
--sixTk: 0, 45, 39;
// cyan (A-Air) // cyan (A-Air)
--priCy: 13, 179, 200; --priCy: 13, 179, 200;
--secCy: 12, 150, 168; --secCy: 12, 150, 168;
--terCy: 0, 121, 136; --terCy: 0, 121, 136;
--quaCy: 0, 93, 106;
--quiCy: 0, 67, 78;
--sixCy: 0, 43, 52;
// blue (B-Air) // blue (B-Air)
--priBl: 20, 141, 205; --priBl: 20, 141, 205;
--secBl: 18, 119, 173; --secBl: 18, 119, 173;
--terBl: 8, 95, 140; --terBl: 8, 95, 140;
--quaBl: 3, 73, 109;
--quiBl: 0, 52, 79;
--sixBl: 0, 33, 51;
// indigo (A-Water) // indigo (A-Water)
--priId: 79, 102, 212; --priId: 79, 102, 212;
--secId: 66, 88, 184; --secId: 66, 88, 184;
@@ -168,10 +192,16 @@
--priFs: 158, 61, 150; --priFs: 158, 61, 150;
--secFs: 133, 47, 126; --secFs: 133, 47, 126;
--terFs: 107, 31, 101; --terFs: 107, 31, 101;
--quaFs: 83, 17, 78;
--quiFs: 61, 5, 56;
--sixFs: 41, 0, 36;
// magenta (B-Stone) // magenta (B-Stone)
--priMe: 237, 30, 129; --priMe: 237, 30, 129;
--secMe: 196, 18, 108; --secMe: 196, 18, 108;
--terMe: 158, 1, 86; --terMe: 158, 1, 86;
--quaMe: 122, 0, 66;
--quiMe: 89, 0, 48;
--sixMe: 59, 0, 32;
/* Earthman Palette */ /* Earthman Palette */
// bark // bark

View File

@@ -819,3 +819,137 @@ describe("NatusWheel — house ring click tooltips", () => {
expect(tooltipEl.style.display).toBe("none"); expect(tooltipEl.style.display).toBe("none");
}); });
}); });
// ── T15 — ASC / MC angle click tooltips + aspect lines ────────────────────────
//
// ASC and MC labels are clickable groups ([data-angle='ASC'] / [data-angle='MC']).
// Clicking shows a tooltip similar to a planet tooltip:
// Title: "Ascendant" (ASC) or "Midheaven" (MC)
// Degree in sign + sign name, plus the house number the angle defines.
// Aspect list for planets within 10° orb of the angle (client-side computed).
// Aspect lines drawn to those planets.
// Clicking same angle again closes the tooltip.
// Planet tooltips include angle aspects in their own aspect lists.
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — angle (ASC/MC) click tooltips", () => {
// ASC=0°(Aries): Sun@8° → Conjunction orb 8° ✓; Mars@188° → Opposition orb 8° ✓
// MC=90°(Cancer): Moon@97° → Conjunction orb 7° ✓
const ANGLE_CHART = {
planets: {
Sun: { sign: "Aries", degree: 8.0, retrograde: false },
Moon: { sign: "Cancer", degree: 97.0, retrograde: false },
Mars: { sign: "Libra", degree: 188.0, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0.0,
mc: 90.0,
},
elements: { Fire: 2, Stone: 0, Air: 1, Water: 1, Time: 0, Space: 0 },
aspects: [],
distinctions: {
"1":1,"2":0,"3":0,"4":1,"5":0,"6":0,
"7":1,"8":0,"9":0,"10":1,"11":0,"12":0,
},
house_system: "O",
};
let svgEl, tooltipEl;
beforeEach(() => {
svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl.setAttribute("id", "id_natus_svg");
svgEl.setAttribute("width", "400");
svgEl.setAttribute("height", "400");
svgEl.style.width = "400px";
svgEl.style.height = "400px";
document.body.appendChild(svgEl);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
NatusWheel.draw(svgEl, ANGLE_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// T15a — ASC label is a clickable group with data-angle='ASC'
it("T15a: clicking [data-angle='ASC'] shows tooltip with 'Ascendant' title and house 1", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
expect(ascGroup).not.toBeNull("expected [data-angle='ASC'] to exist in the SVG");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
const text = tooltipEl.textContent;
expect(text).toContain("Ascendant");
// ASC=0° is the cusp of House 1
expect(text).toContain("1");
});
// T15b — MC label is a clickable group with data-angle='MC'
it("T15b: clicking [data-angle='MC'] shows tooltip with 'Midheaven' title and house 10", () => {
const mcGroup = svgEl.querySelector("[data-angle='MC']");
expect(mcGroup).not.toBeNull("expected [data-angle='MC'] to exist in the SVG");
mcGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
const text = tooltipEl.textContent;
expect(text).toContain("Midheaven");
expect(text).toContain("10");
});
// T15c — ASC tooltip shows degree-in-sign and sign name
it("T15c: ASC tooltip shows the in-sign degree and sign of the Ascendant", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const text = tooltipEl.textContent;
// ASC=0° → 0° Aries
expect(text).toContain("Aries");
expect(text).toContain("0.0");
});
// T15d — clicking same angle a second time hides the tooltip
it("T15d: clicking the same angle again hides the tooltip", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("none");
});
// T15e — ASC tooltip lists planets within 10° orb (client-side computed)
it("T15e: ASC tooltip includes aspect rows for planets within 10° orb", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const bodyHtml = tooltipEl.querySelector(".nw-tt-body").innerHTML;
// Sun at 8° → Conjunction (orb 8°) ✓ — _pSym emits data-planet attr
expect(bodyHtml).toContain('data-planet="Sun"');
// Mars at 188° → Opposition to ASC (0°), angular distance 172° → orb 8° ✓
expect(bodyHtml).toContain('data-planet="Mars"');
});
// T15f — planet tooltip includes ASC in its aspect list when within orb
it("T15f: planet tooltip for Sun lists ASC as an aspect partner", () => {
const sunGroup = svgEl.querySelector("[data-planet='Sun']");
expect(sunGroup).not.toBeNull();
sunGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const bodyHtml = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(bodyHtml).toContain("ASC");
});
});

View File

@@ -19,12 +19,14 @@
<div class="natus-field"> <div class="natus-field">
<label for="id_nf_date">Birth date</label> <label for="id_nf_date">Birth date</label>
<input id="id_nf_date" name="date" type="date" required> <input id="id_nf_date" name="date" type="date" required
{% if saved_birth_date %}value="{{ saved_birth_date }}"{% endif %}>
</div> </div>
<div class="natus-field"> <div class="natus-field">
<label for="id_nf_time">Birth time</label> <label for="id_nf_time">Birth time</label>
<input id="id_nf_time" name="time" type="time" value="12:00"> <input id="id_nf_time" name="time" type="time"
value="{{ saved_birth_time|default:'12:00' }}">
<small>Local time at birth place. Use 12:00 if unknown.</small> <small>Local time at birth place. Use 12:00 if unknown.</small>
</div> </div>
@@ -33,7 +35,8 @@
<div class="natus-place-wrap"> <div class="natus-place-wrap">
<input id="id_nf_place" name="place" type="text" <input id="id_nf_place" name="place" type="text"
placeholder="Start typing a city…" placeholder="Start typing a city…"
autocomplete="off"> autocomplete="off"
{% if saved_birth_place %}value="{{ saved_birth_place }}"{% endif %}>
<button type="button" id="id_nf_geolocate" <button type="button" id="id_nf_geolocate"
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
title="Use device location"> title="Use device location">
@@ -47,19 +50,22 @@
<div> <div>
<label>Latitude</label> <label>Latitude</label>
<input id="id_nf_lat" name="lat" type="text" <input id="id_nf_lat" name="lat" type="text"
placeholder="—" readonly tabindex="-1"> placeholder="—" readonly tabindex="-1"
{% if saved_birth_lat %}value="{{ saved_birth_lat }}"{% endif %}>
</div> </div>
<div> <div>
<label>Longitude</label> <label>Longitude</label>
<input id="id_nf_lon" name="lon" type="text" <input id="id_nf_lon" name="lon" type="text"
placeholder="—" readonly tabindex="-1"> placeholder="—" readonly tabindex="-1"
{% if saved_birth_lon %}value="{{ saved_birth_lon }}"{% endif %}>
</div> </div>
</div> </div>
<div class="natus-field"> <div class="natus-field">
<label for="id_nf_tz">Timezone</label> <label for="id_nf_tz">Timezone</label>
<input id="id_nf_tz" name="tz" type="text" <input id="id_nf_tz" name="tz" type="text"
placeholder="auto-detected from location"> placeholder="auto-detected from location"
{% if saved_birth_tz %}value="{{ saved_birth_tz }}"{% endif %}>
<small id="id_nf_tz_hint"></small> <small id="id_nf_tz_hint"></small>
</div> </div>
@@ -109,44 +115,13 @@
const NOMINATIM = 'https://nominatim.openstreetmap.org/search'; const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
const USER_AGENT = 'EarthmanRPG/1.0 (https://earthmanrpg.me)'; const USER_AGENT = 'EarthmanRPG/1.0 (https://earthmanrpg.me)';
// localStorage key — fixed for the user's personal sky (not room-scoped)
const LS_KEY = 'natus-form:dashboard:sky';
let _lastChartData = null; let _lastChartData = null;
let _placeDebounce = null; let _placeDebounce = null;
let _chartDebounce = null; let _chartDebounce = null;
const PLACE_DELAY = 400; const PLACE_DELAY = 400;
const CHART_DELAY = 300; const CHART_DELAY = 300;
NatusWheel.preload(); const _preloadReady = NatusWheel.preload();
// ── localStorage persistence ────────────────────────────────────────────
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.'; }
// If we have enough data from localStorage, kick off a wheel draw
if (_formReady()) schedulePreview();
}
// ── Status helper ─────────────────────────────────────────────────────── // ── Status helper ───────────────────────────────────────────────────────
@@ -209,7 +184,6 @@
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();
} }
@@ -230,8 +204,7 @@
}) })
.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();
}, },
@@ -251,7 +224,6 @@
form.addEventListener('input', (e) => { form.addEventListener('input', (e) => {
if (e.target === placeInput) return; if (e.target === placeInput) return;
_saveForm();
clearTimeout(_chartDebounce); clearTimeout(_chartDebounce);
_chartDebounce = setTimeout(schedulePreview, CHART_DELAY); _chartDebounce = setTimeout(schedulePreview, CHART_DELAY);
}); });
@@ -312,6 +284,7 @@
birth_lat: parseFloat(latInput.value), birth_lat: parseFloat(latInput.value),
birth_lon: parseFloat(lonInput.value), birth_lon: parseFloat(lonInput.value),
birth_place: placeInput.value, birth_place: placeInput.value,
birth_tz: tzInput.value.trim(),
house_system: _lastChartData.house_system || 'O', house_system: _lastChartData.house_system || 'O',
chart_data: _lastChartData, chart_data: _lastChartData,
}; };
@@ -340,9 +313,18 @@
return m ? m[1] : ''; return m ? m[1] : '';
} }
// ── Restore persisted form on load ─────────────────────────────────────── // ── Draw saved sky on load; only call PySwiss if no saved chart yet ─────
_restoreForm(); const _savedSky = {{ saved_sky_json|safe }};
_preloadReady.then(() => {
if (_savedSky) {
_lastChartData = _savedSky;
confirmBtn.disabled = false;
NatusWheel.draw(svgEl, _savedSky);
} else if (_formReady()) {
schedulePreview();
}
});
})(); })();
</script> </script>
{% endblock %} {% endblock %}