From 0b2320e39ba30df78473d9f6752f5b6b06c6f1c0 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 22 Apr 2026 00:58:19 -0400 Subject: [PATCH] =?UTF-8?q?natus=20wheel:=20ASC/MC=20angles=20=E2=80=94=20?= =?UTF-8?q?tooltips,=20aspect=20lines,=20section=20headers,=20tooltip=20po?= =?UTF-8?q?lish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/apps/dashboard/views.py | 35 +- .../static/apps/gameboard/natus-wheel.js | 409 ++++++++++++++---- src/static/tests/NatusWheelSpec.js | 134 ++++++ src/static_src/scss/_natus.scss | 130 +++++- src/static_src/tests/NatusWheelSpec.js | 134 ++++++ src/templates/apps/dashboard/sky.html | 19 +- 6 files changed, 752 insertions(+), 109 deletions(-) diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 06c829a..02e5f09 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -300,11 +300,24 @@ def _sky_natus_preview(request): @login_required(login_url="/") 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", { "preview_url": request.build_absolute_uri("/dashboard/sky/preview"), "save_url": request.build_absolute_uri("/dashboard/sky/save"), - "saved_sky": request.user.sky_chart_data, - "saved_birth_dt": request.user.sky_birth_dt, + "saved_sky_json": json.dumps(chart_data) if chart_data else 'null', + "saved_birth_date": saved_birth_date, + "saved_birth_time": saved_birth_time, "saved_birth_place": request.user.sky_birth_place, "saved_birth_lat": request.user.sky_birth_lat, "saved_birth_lon": request.user.sky_birth_lon, @@ -329,13 +342,23 @@ def sky_save(request): return HttpResponse(status=400) user = request.user + birth_tz_str = body.get('birth_tz', '').strip() birth_dt_str = body.get('birth_dt', '') if birth_dt_str: try: - naive = datetime.fromisoformat(birth_dt_str.replace('Z', '+00:00')) - user.sky_birth_dt = naive if naive.tzinfo else naive.replace( - tzinfo=zoneinfo.ZoneInfo('UTC') - ) + naive = datetime.fromisoformat(birth_dt_str.replace('Z', '').replace('+00:00', '')) + if naive.tzinfo is None and birth_tz_str: + 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: user.sky_birth_dt = None user.sky_birth_lat = body.get('birth_lat') diff --git a/src/apps/gameboard/static/apps/gameboard/natus-wheel.js b/src/apps/gameboard/static/apps/gameboard/natus-wheel.js index 223b067..7d60657 100644 --- a/src/apps/gameboard/static/apps/gameboard/natus-wheel.js +++ b/src/apps/gameboard/static/apps/gameboard/natus-wheel.js @@ -103,12 +103,44 @@ const NatusWheel = (() => { '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 1–3: Juvenescence; 4–6: Adolescence; 7–9: Maturescence; 10–12: Senescence + const HOUSE_PHASE = [ + '', 'Juvenescence', 'Juvenescence', 'Juvenescence', + 'Adolescence', 'Adolescence', 'Adolescence', + 'Maturescence', 'Maturescence', 'Maturescence', + 'Senescence', 'Senescence', 'Senescence', + ]; + // ── State ───────────────────────────────────────────────────────────────── let _svg = null; @@ -125,12 +157,13 @@ const NatusWheel = (() => { // ── 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 _planetItems = []; // [{name, degree}] sorted by ecliptic degree ascending let _elementItems = []; // [{key}] in ELEMENT_ORDER let _signItems = []; // [{name, symbol, element}] in SIGNS order let _houseItems = []; // [{num, label}] houses 1–12 + let _angleItems = []; // [{name, label, house}] — ASC and MC // Tooltip DOM refs — set by _injectTooltipControls() on each draw(). let _tooltipEl = null; @@ -214,6 +247,29 @@ const NatusWheel = (() => { return ``; } + /** 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 + ? `${sym}` + : `${sym}`; + } + + /** 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 ? `${svg}` : svg; + } + + /** Ordinal suffix HTML for a house number, e.g. 2 → "2nd". */ + function _houseOrdinal(n) { + const sfx = n === 1 ? 'st' : n === 2 ? 'nd' : n === 3 ? 'rd' : 'th'; + return `${n}${sfx}`; + } + /** for an element-square badge (Ardor.svg etc.). */ function _elementSquareImg(elementKey) { const info = ELEMENT_INFO[elementKey]; @@ -261,6 +317,10 @@ const NatusWheel = (() => { num: 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. */ @@ -268,6 +328,9 @@ const NatusWheel = (() => { if (_svg) { _svg.selectAll('.nw-planet-group').classed('nw-planet--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; _activeIdx = null; @@ -325,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 = ''; + 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 += + `
` + + `${lineSvg} ${asym} ${_pSym(partner)} in ${_signIcon(ppdata.sign)}` + + ` ${APPLY_SYM} ${orb}°` + + `
`; + }); + aspectHtml += '
'; + } + + if (_ttBody) { + _ttBody.innerHTML = + `
` + + `${def.label || angleName}` + + `${def.sym || angleName}` + + `
` + + `
` + + `@${inDeg}° ${sign.name || ''}` + + `${icon}` + + `
` + + `
${_houseOrdinal(def.house)} House (${HOUSE_LABELS[def.house] || ''})
` + + 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 * clicked planet/element. @@ -357,6 +511,9 @@ const NatusWheel = (() => { } else if (ring === 'houses') { const grp = svgNode.querySelector(`[data-house="${_houseItems[idx].num}"]`); 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(); } @@ -404,16 +561,29 @@ const NatusWheel = (() => { if (myAspects.length) { aspectHtml = ''; myAspects.forEach(({ partner, type, orb, applying_planet }) => { - const psym = PLANET_SYMBOLS[partner] || partner[0]; - const ppdata = _currentData.planets[partner] || {}; - const sicon = _signIconSvg(ppdata.sign) || (SIGNS.find(s => s.name === ppdata.sign) || {}).symbol || ''; + const isAngle = partner === 'ASC' || partner === 'MC'; + const ppdata = isAngle ? {} : (_currentData.planets[partner] || {}); const asym = ASPECT_SYMBOLS[type] || type; - const dirsym = applying_planet === item.name ? APPLY_SYM : SEP_SYM; - const lineSvg = _aspectLineSvg(type, applying_planet); + const applying = applying_planet === item.name; + const lineSvg = _aspectLineSvg(type, applying_planet); + const partnerSym = isAngle + ? `${(ANGLE_DEFS[partner] || {}).sym || partner}` + : _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 ? ` in ${_signIcon(aSign.name)}` : ''; + } else { + signPart = ` in ${_signIcon(ppdata.sign)}`; + } + const orbHtml = applying + ? `${APPLY_SYM} ${orb}°` + : `${orb}° ${SEP_SYM}`; aspectHtml += `
` + - `${lineSvg} ${asym} ${psym} in ${sicon}` + - ` (${dirsym} ${orb}°)` + + `${lineSvg} ${asym} ${partnerSym}${signPart}` + + ` ${orbHtml}` + `
`; }); aspectHtml += '
'; @@ -427,7 +597,7 @@ const NatusWheel = (() => { `` + `
` + `@${inDeg}° ${pdata.sign}${rx}` + - `${icon}` + + `${icon}` + `
` + aspectHtml; } @@ -464,14 +634,13 @@ const NatusWheel = (() => { if (CLASSIC_ELEMENTS.has(item.key)) { const contribs = elData.contributors || []; bodyHtml = `
${vecImg} +${count} (${pct}%)
`; + bodyHtml += `
Planets
`; if (contribs.length) { bodyHtml += '
'; contribs.forEach(c => { - const psym = PLANET_SYMBOLS[c.planet] || c.planet[0]; const pdata = (_currentData.planets || {})[c.planet] || {}; const inDeg = pdata.degree !== undefined ? _inSignDeg(pdata.degree).toFixed(1) : '?'; - const sicon = _signIconSvg(c.sign) || (SIGNS.find(s => s.name === c.sign) || {}).symbol || ''; - bodyHtml += `
${psym} @ ${inDeg}° ${sicon} +1
`; + bodyHtml += `
${_pSym(c.planet)} @ ${inDeg}° ${_signIcon(c.sign)} +1
`; }); bodyHtml += '
'; } else { @@ -482,17 +651,21 @@ const NatusWheel = (() => { const stellia = elData.stellia || []; bodyHtml = `
${vecImg} +${count} (${pct}%)
`; if (stellia.length) { + const isTie = stellia.length > 1; bodyHtml += '
'; stellia.forEach(st => { const bonus = st.planets.length - 1; - bodyHtml += `
Stellium +${bonus}
`; - st.planets.forEach(p => { - const psym = PLANET_SYMBOLS[p.planet] || p.planet[0]; - const pdata = (_currentData.planets || {})[p.planet] || {}; - const inDeg = pdata.degree !== undefined ? _inSignDeg(pdata.degree).toFixed(1) : '?'; - const sicon = _signIconSvg(p.sign) || (SIGNS.find(s => s.name === p.sign) || {}).symbol || ''; - bodyHtml += `
${psym} @ ${inDeg}° ${sicon}
`; - }); + bodyHtml += `
Stellium +${bonus}
`; + if (isTie) { + st.planets.forEach(p => { + const pdata = (_currentData.planets || {})[p.planet] || {}; + const inDeg = pdata.degree !== undefined ? _inSignDeg(pdata.degree).toFixed(1) : '?'; + bodyHtml += `
${_pSym(p.planet)} @ ${inDeg}° ${_signIcon(p.sign)}
`; + }); + } else { + const psyms = st.planets.map(p => _pSym(p.planet)).join(' '); + bodyHtml += `
${_signIcon(st.sign)} : ${psyms}
`; + } }); bodyHtml += '
'; } else { @@ -506,8 +679,7 @@ const NatusWheel = (() => { bodyHtml += '
'; parades.forEach(pd => { const bonus = pd.signs.length - 1; - bodyHtml += `
Parade +${bonus}
`; - // Group planets by sign, sorted by ecliptic degree (counterclockwise = ascending) + bodyHtml += `
Parade +${bonus}
`; const bySign = {}; pd.planets.forEach(p => { if (!bySign[p.sign]) bySign[p.sign] = []; @@ -522,9 +694,8 @@ const NatusWheel = (() => { }); pd.signs.forEach(sign => { const planets = bySign[sign] || []; - const sicon = _signIconSvg(sign) || (SIGNS.find(s => s.name === sign) || {}).symbol || sign; - const psyms = planets.map(p => PLANET_SYMBOLS[p.planet] || p.planet[0]).join(' '); - bodyHtml += `
${sicon} (${psyms})
`; + const psyms = planets.map(p => _pSym(p.planet)).join(' '); + bodyHtml += `
${_signIcon(sign)} : ${psyms}
`; }); }); bodyHtml += '
'; @@ -549,9 +720,11 @@ const NatusWheel = (() => { } function _activateSign(idx) { + _clearActive(); _activeRing = 'signs'; _activeIdx = 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); @@ -559,33 +732,51 @@ const NatusWheel = (() => { `${sign.symbol}`; 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 psym = PLANET_SYMBOLS[pname] || pname[0]; 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 (${domain})` + : ''; planetsHtml += - `
${psym} @${inDeg}°` + - (domain ? `, House of ${domain}` : '') + `
`; + `
${_pSym(pname)} @${inDeg}°${houseHtml}
`; }); + + 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 += + `
${_houseOrdinal(houseNum)} House @${inDeg}° ${_signIcon(sign.name)}
`; + } + }); + } } if (_ttBody) { _ttBody.innerHTML = `
` + - `${sign.name}` + - `${iconSvg}` + + `${sign.name}` + + `${iconSvg}` + `
` + `
` + `${modality} ${sign.element}` + vecImg + `
` + - (planetsHtml ? `
${planetsHtml}
` : ''); + `
Planets
` + + (planetsHtml ? `
${planetsHtml}
` : `
`) + + `
` + + `
Cusps
` + + (cuspsHtml ? `
${cuspsHtml}
` : `
`); } _positionTooltipAtItem('signs', idx); if (_tooltipEl) { @@ -595,9 +786,11 @@ const NatusWheel = (() => { } function _activateHouse(idx) { + _clearActive(); _activeRing = 'houses'; _activeIdx = idx; const house = _houseItems[idx]; + _svg.select(`[data-house="${house.num}"]`).classed('nw-house--active', true); const cusps = (_currentData && (_currentData.houses || {}).cusps) || []; let planetsHtml = ''; @@ -606,20 +799,26 @@ const NatusWheel = (() => { .filter(([, p]) => _planetHouse(p.degree, cusps) === house.num) .sort((a, b) => a[1].degree - b[1].degree); inHouse.forEach(([pname, pdata]) => { - const psym = PLANET_SYMBOLS[pname] || pname[0]; const inDeg = _inSignDeg(pdata.degree).toFixed(1); - const sicon = _signIconSvg(pdata.sign) || (SIGNS.find(s => s.name === pdata.sign) || {}).symbol || ''; - planetsHtml += `
${psym} @${inDeg}° ${sicon}
`; + planetsHtml += `
${_pSym(pname)} @${inDeg}° ${_signIcon(pdata.sign)}
`; }); } + const quadrant = HOUSE_QUADRANT[house.num] || ''; + const phase = HOUSE_PHASE[house.num] || ''; + if (_ttBody) { _ttBody.innerHTML = `
` + - `House of
${house.label}
` + + `` + + `House of
` + + `${house.label}
` + + `${quadrant} ${phase}` + + `
` + `${house.num}` + `
` + - (planetsHtml ? `
${planetsHtml}
` : ''); + `
Planets
` + + (planetsHtml ? `
${planetsHtml}
` : `
`); } _positionTooltipAtItem('houses', idx); if (_tooltipEl) { @@ -662,6 +861,8 @@ const NatusWheel = (() => { if (_aspectsVisible) { if (_activeRing === 'planets' && _activeIdx !== null) { _showPlanetAspects(_planetItems[_activeIdx].name); + } else if (_activeRing === 'angles' && _activeIdx !== null) { + _showAngleAspects(_activeIdx); } } else { _clearAspectLines(); @@ -691,9 +892,9 @@ const NatusWheel = (() => { e.stopPropagation(); _stepCycle(1); }); _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') - .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. _updateAspectToggleUI(); } @@ -705,15 +906,6 @@ const NatusWheel = (() => { document.addEventListener('click', (e) => { if (_activeRing === null) 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(); }, { signal: _outsideClickController.signal }); } @@ -750,13 +942,13 @@ const NatusWheel = (() => { const asc = data.houses.asc; const mc = data.houses.mc; const points = [ - { deg: asc, label: 'ASC' }, - { deg: asc + 180, label: 'DSC' }, - { deg: mc, label: 'MC' }, - { deg: mc + 180, label: 'IC' }, + { deg: asc, label: 'ASC', clickable: true }, + { deg: asc + 180, label: 'DSC', clickable: false }, + { deg: mc, label: 'MC', clickable: true }, + { deg: mc + 180, label: 'IC', clickable: false }, ]; const axisGroup = g.append('g').attr('class', 'nw-axes'); - points.forEach(({ deg, label }) => { + points.forEach(({ deg, label, clickable }) => { const a = _toAngle(deg, asc); const x1 = _cx + R.houseInner * Math.cos(a); const y1 = _cy + R.houseInner * Math.sin(a); @@ -766,7 +958,8 @@ const NatusWheel = (() => { .attr('x1', x1).attr('y1', y1) .attr('x2', x2).attr('y2', y2) .attr('class', 'nw-axis-line'); - axisGroup.append('text') + + const textEl = axisGroup.append('text') .attr('x', _cx + (R.ascMcR + 12) * Math.cos(a)) .attr('y', _cy + (R.ascMcR + 12) * Math.sin(a)) .attr('text-anchor', 'middle') @@ -774,6 +967,37 @@ const NatusWheel = (() => { .attr('font-size', `${_r * 0.055}px`) .attr('class', 'nw-axis-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); + }); + } }); } @@ -822,7 +1046,7 @@ const NatusWheel = (() => { .attr('cx', lx) .attr('cy', ly) .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]) { signSlice.append('path') @@ -848,8 +1072,22 @@ const NatusWheel = (() => { return { i, startA, sa, ea, midA: (sa + ea) / 2 }; }); - houses.forEach(({ i, sa, ea }) => { - houseGroup.append('path') + houses.forEach(({ i, startA, sa, ea, midA }) => { + // 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('d', arc({ innerRadius: R.houseInner, @@ -857,30 +1095,9 @@ const NatusWheel = (() => { startAngle: sa + Math.PI / 2, endAngle: ea + Math.PI / 2, })) - .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); - } - }); - }); + .attr('class', i % 2 === 0 ? 'nw-house-fill--even' : 'nw-house-fill--odd'); - houses.forEach(({ i, startA, midA }) => { - if (i % 3 === 0) { - houseGroup.append('line') - .attr('x1', _cx + R.houseInner * Math.cos(startA)) - .attr('y1', _cy + R.houseInner * Math.sin(startA)) - .attr('x2', _cx + R.signInner * Math.cos(startA)) - .attr('y2', _cy + R.signInner * Math.sin(startA)) - .attr('class', 'nw-house-cusp'); - } - - houseGroup.append('text') + grp.append('text') .attr('x', _cx + R.houseNumR * Math.cos(midA)) .attr('y', _cy + R.houseNumR * Math.sin(midA)) .attr('text-anchor', 'middle') @@ -889,6 +1106,16 @@ const NatusWheel = (() => { .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) { + houseGroup.append('line') + .attr('x1', _cx + R.houseInner * Math.cos(startA)) + .attr('y1', _cy + R.houseInner * Math.sin(startA)) + .attr('x2', _cx + R.signInner * Math.cos(startA)) + .attr('y2', _cy + R.signInner * Math.sin(startA)) + .attr('class', 'nw-house-cusp'); + } }); } @@ -989,6 +1216,17 @@ const NatusWheel = (() => { * Build the aspect index (planet → aspects list) and create the persistent * 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) { _aspectIndex = {}; Object.entries(data.planets).forEach(([name]) => { _aspectIndex[name] = []; }); @@ -1000,6 +1238,19 @@ const NatusWheel = (() => { _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'); } diff --git a/src/static/tests/NatusWheelSpec.js b/src/static/tests/NatusWheelSpec.js index 8a9ebca..259862c 100644 --- a/src/static/tests/NatusWheelSpec.js +++ b/src/static/tests/NatusWheelSpec.js @@ -819,3 +819,137 @@ describe("NatusWheel — house ring click tooltips", () => { 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"); + }); +}); diff --git a/src/static_src/scss/_natus.scss b/src/static_src/scss/_natus.scss index 00d99c1..65c4900 100644 --- a/src/static_src/scss/_natus.scss +++ b/src/static_src/scss/_natus.scss @@ -373,33 +373,33 @@ html.natus-open .natus-modal-wrap { .nw-axis-line { stroke: rgba(var(--secUser), 1); stroke-width: 1.5px; } .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--stone, .nw-sign--air, .nw-sign--water { - fill: rgba(var(--priMe), 0.25); + fill: rgba(var(--priYl), 0.25); stroke: rgba(var(--terUser), 1); stroke-width: 0.75px; } // 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--stone { fill: rgba(var(--priYl), 0.92); stroke: rgba(var(--priLm), 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--water { fill: rgba(var(--priCy), 0.92); stroke: rgba(var(--priBl), 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(--quaFs), 0.92); stroke: rgba(var(--priMe), 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(--sixId), 0.92); stroke: rgba(var(--priVt), 1); stroke-width: 1px; } // 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--stone { fill: rgba(var(--terLm), 1); text-shadow: 2px 2px 2px rgba(0, 0, 0, 1); } -.nw-sign-icon--air { fill: rgba(var(--priTk), 1); text-shadow: 2px 2px 2px rgba(0, 0, 0, 1); } -.nw-sign-icon--water { fill: rgba(var(--terBl), 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(--priMe), 1); } +.nw-sign-icon--air { fill: rgba(var(--priBl), 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-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--odd { 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(--quiGn), 0.25); stroke: rgba(var(--terUser), 1); stroke-width: 0.75px; } // Planets — base geometry .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-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-element-group { cursor: pointer; } +.nw-element-group, +.nw-sign-group, +.nw-house-group { cursor: pointer; } .nw-planet-group:hover, .nw-planet-group.nw-planet--active, +.nw-planet-group.nw-planet--asp-active, .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)); } +// 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 ────────────────── .nw-planet-tick { fill: none; @@ -521,6 +538,14 @@ body[class*="-light"] { 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; @@ -560,6 +585,19 @@ body[class*="-light"] { 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 { @@ -581,12 +619,24 @@ body[class*="-light"] { 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, // matching the PRV/NXT pattern at the bottom corners. @@ -595,6 +645,7 @@ body[class*="-light"] { position: absolute; left: -1rem; margin: 0; + pointer-events: auto; // override btn-disabled; click must land here, not pass through to SVG } .nw-asp-don { top: -1rem; } .nw-asp-doff { top: 1.2rem; } @@ -627,12 +678,30 @@ body[class*="-light"] { .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 { opacity: 0.6; font-size: 0.65rem; 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 .tt-el-header { @@ -672,9 +741,14 @@ body[class*="-light"] { .tt-el-formation-header { font-size: 0.85rem; - font-weight: 600; - text-decoration: underline; + font-weight: 700; 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 { @@ -708,6 +782,24 @@ body[class*="-light"] { .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 body[class*="-light"] #id_natus_tooltip, body[class*="-light"] #id_natus_tooltip_2 { diff --git a/src/static_src/tests/NatusWheelSpec.js b/src/static_src/tests/NatusWheelSpec.js index 8a9ebca..259862c 100644 --- a/src/static_src/tests/NatusWheelSpec.js +++ b/src/static_src/tests/NatusWheelSpec.js @@ -819,3 +819,137 @@ describe("NatusWheel — house ring click tooltips", () => { 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"); + }); +}); diff --git a/src/templates/apps/dashboard/sky.html b/src/templates/apps/dashboard/sky.html index 85d0d6a..d877c0c 100644 --- a/src/templates/apps/dashboard/sky.html +++ b/src/templates/apps/dashboard/sky.html @@ -20,13 +20,13 @@
+ {% if saved_birth_date %}value="{{ saved_birth_date }}"{% endif %}>
+ value="{{ saved_birth_time|default:'12:00' }}"> Local time at birth place. Use 12:00 if unknown.
@@ -121,7 +121,7 @@ const PLACE_DELAY = 400; const CHART_DELAY = 300; - NatusWheel.preload(); + const _preloadReady = NatusWheel.preload(); // ── Status helper ─────────────────────────────────────────────────────── @@ -313,9 +313,18 @@ return m ? m[1] : ''; } - // ── Auto-preview on load if form is pre-filled from saved sky ─────────── + // ── Draw saved sky on load; only call PySwiss if no saved chart yet ───── - if (_formReady()) schedulePreview(); + const _savedSky = {{ saved_sky_json|safe }}; + _preloadReady.then(() => { + if (_savedSky) { + _lastChartData = _savedSky; + confirmBtn.disabled = false; + NatusWheel.draw(svgEl, _savedSky); + } else if (_formReady()) { + schedulePreview(); + } + }); })(); {% endblock %}