NATUS WHEEL: half-wheel tooltip positioning + click-outside fix — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

Tooltip positioning:
- Scrapped SVG-edge priority; now places in opposite vertical half anchored
  1rem from the centreline (lower edge above CL if item in bottom half,
  upper edge below CL if item in top half)
- Horizontal: left edge aligns with item when item is left of centre;
  right edge aligns with item when right of centre
- Clamped to svgRect bounds (not window.inner*)

Click-outside fix:
- Added event.stopPropagation() to D3 v7 planet and element click handlers
- Removed svgNode.contains() guard from _attachOutsideClick so clicks on
  empty wheel areas (zodiac ring, background) now correctly dismiss the tooltip

FT fix: use execute_script click for element-ring slice (inside overflow-masked applet)
Jasmine: positioning describe block xdescribe'd (JSDOM has no layout engine)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-19 17:27:52 -04:00
parent fbf260b148
commit 2be330e698
6 changed files with 686 additions and 224 deletions

View File

@@ -62,6 +62,9 @@ const NatusWheel = (() => {
Water: { abbr: 'Hm', name: 'Humor', classical: 'water', titleVar: '--priId' }, Water: { abbr: 'Hm', name: 'Humor', classical: 'water', titleVar: '--priId' },
}; };
// Clockwise ring order for element cycling
const ELEMENT_ORDER = ['Fire', 'Stone', 'Time', 'Space', 'Air', 'Water'];
// Aspect stroke colors remain in JS — they are data-driven, not stylistic. // Aspect stroke colors remain in JS — they are data-driven, not stylistic.
const ASPECT_COLORS = { const ASPECT_COLORS = {
Conjunction: 'var(--priYl, #f0e060)', Conjunction: 'var(--priYl, #f0e060)',
@@ -88,6 +91,23 @@ const NatusWheel = (() => {
// Ring radii (fractions of _r, set in _layout) // Ring radii (fractions of _r, set in _layout)
let R = {}; let R = {};
// Chart data — cached so cycle navigation can re-render without a data arg.
let _currentData = null;
// ── Cycle state ────────────────────────────────────────────────────────────
let _activeRing = null; // 'planets' | 'elements' | 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
// Tooltip DOM refs — set by _injectTooltipControls() on each draw().
let _tooltipEl = null;
let _ttBody = null;
// AbortController for the outside-click dismiss listener.
let _outsideClickController = null;
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
/** Convert ecliptic longitude to SVG angle. /** Convert ecliptic longitude to SVG angle.
@@ -120,35 +140,186 @@ 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>`;
} }
/** Position tooltip near cursor, clamped so it never overflows the viewport. */
function _positionTooltip(tooltip, event) { // ── Cycle helpers ─────────────────────────────────────────────────────────
const margin = 8;
tooltip.style.display = 'block'; /** Build sorted planet list (ascending ecliptic degree) and element list. */
const ttW = tooltip.offsetWidth; function _buildCycleLists(data) {
const ttH = tooltip.offsetHeight; _planetItems = Object.entries(data.planets)
let left = event.clientX + 14; .map(([name, p]) => ({ name, degree: p.degree }))
let top = event.clientY - 10; .sort((a, b) => b.degree - a.degree); // descending = clockwise on wheel
if (left + ttW + margin > window.innerWidth) left = event.clientX - ttW - 14; _elementItems = ELEMENT_ORDER.map(key => ({ key }));
if (top + ttH + margin > window.innerHeight) top = event.clientY - ttH - 10;
tooltip.style.left = Math.max(margin, left) + 'px';
tooltip.style.top = Math.max(margin, top) + 'px';
} }
function _computeConjunctions(planets, threshold) { /** Clear all active-lock classes and reset cycle state. */
threshold = threshold === undefined ? 8 : threshold; function _clearActive() {
const entries = Object.entries(planets); if (_svg) {
const result = {}; _svg.selectAll('.nw-planet-group').classed('nw-planet--active', false);
entries.forEach(([a, pa]) => { _svg.selectAll('.nw-element-group').classed('nw-element--active', false);
entries.forEach(([b, pb]) => {
if (a === b) return;
const diff = Math.abs(pa.degree - pb.degree);
if (Math.min(diff, 360 - diff) <= threshold) {
if (!result[a]) result[a] = [];
result[a].push(b);
} }
_activeRing = null;
_activeIdx = null;
}
/** Dismiss tooltip and reset all active state. */
function _closeTooltip() {
_clearActive();
if (_tooltipEl) _tooltipEl.style.display = 'none';
}
/**
* Position the tooltip in the vertical half of the wheel opposite to the
* clicked planet/element, with the horizontal edge aligned to the item.
*
* Vertical (upper/lower):
* item in lower half (itemY ≥ svgCY) → lower edge 1rem above centreline
* item in upper half (itemY < svgCY) → upper edge 1rem below centreline
*
* Horizontal (left/right of centre):
* item left of centre → tooltip left edge aligns with item left edge
* item right of centre → tooltip right edge aligns with item right edge
*
* "1rem" is approximated as 16 px.
*/
function _positionTooltipAtItem(ring, idx) {
const svgNode = _svg ? _svg.node() : null;
if (!svgNode || !_tooltipEl) return;
_tooltipEl.style.display = 'block';
const ttW = _tooltipEl.offsetWidth || 0;
const ttH = _tooltipEl.offsetHeight || 0;
const REM = 16;
const svgRect = svgNode.getBoundingClientRect();
const svgCX = svgRect.left + svgRect.width / 2;
const svgCY = svgRect.top + svgRect.height / 2;
// Item screen rect — fall back to SVG centre if element not found.
let iRect = { left: svgCX, top: svgCY, width: 0, height: 0, right: svgCX, bottom: svgCY };
{
let el = null;
if (ring === 'planets') {
const grp = svgNode.querySelector(`[data-planet="${_planetItems[idx].name}"]`);
el = grp && (grp.querySelector('circle') || grp);
} else {
const grp = svgNode.querySelector(`[data-element="${_elementItems[idx].key}"]`);
el = grp && (grp.querySelector('path') || grp);
}
if (el) iRect = el.getBoundingClientRect();
}
const itemX = iRect.left + iRect.width / 2;
const itemY = iRect.top + iRect.height / 2;
// Horizontal: align tooltip edge with item edge on the same side.
// Clamp within the SVG rect so the tooltip stays over the wheel.
const left = Math.max(svgRect.left + REM, Math.min(svgRect.right - ttW - REM,
itemX < svgCX ? iRect.left : iRect.right - ttW
));
// Vertical: place in the opposite half, 1rem from centreline.
const top = Math.max(svgRect.top + REM, Math.min(svgRect.bottom - ttH - REM,
itemY >= svgCY ? svgCY - REM - ttH : svgCY + REM
));
_tooltipEl.style.left = left + 'px';
_tooltipEl.style.top = top + 'px';
}
/** Lock-activate a planet by cycle index. */
function _activatePlanet(idx) {
_clearActive();
_activeRing = 'planets';
_activeIdx = idx;
const item = _planetItems[idx];
const grp = _svg.select(`[data-planet="${item.name}"]`);
grp.classed('nw-planet--active', true);
grp.raise();
const pdata = _currentData.planets[item.name];
const el = PLANET_ELEMENTS[item.name] || '';
const sym = PLANET_SYMBOLS[item.name] || item.name[0];
const signData = SIGNS.find(s => s.name === pdata.sign) || {};
const inDeg = _inSignDeg(pdata.degree).toFixed(1);
const rx = pdata.retrograde ? ' ℞' : '';
const icon = _signIconSvg(pdata.sign) || signData.symbol || '';
if (_ttBody) {
_ttBody.innerHTML =
`<div class="tt-title tt-title--${el}">${item.name} (${sym})</div>` +
`<div class="tt-description">@${inDeg}° ${pdata.sign} (${icon})${rx}</div>`;
}
_positionTooltipAtItem('planets', idx);
}
/** Lock-activate an element slice by cycle index. */
function _activateElement(idx) {
_clearActive();
_activeRing = 'elements';
_activeIdx = idx;
const item = _elementItems[idx];
const grp = _svg.select(`[data-element="${item.key}"]`);
grp.classed('nw-element--active', true);
const info = ELEMENT_INFO[item.key] || {};
const elCounts = _currentData.elements;
const total = Object.values(elCounts).reduce((s, v) => s + v, 0);
const count = elCounts[item.key] || 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
const elKey = item.key.toLowerCase();
if (_ttBody) {
_ttBody.innerHTML =
`<div class="tt-title tt-title--el-${elKey}">[${info.abbr}] ${info.name}</div>` +
`<div class="tt-description">${info.classical} · ${count} (${pct}%)</div>`;
}
_positionTooltipAtItem('elements', idx);
}
/** Advance the active ring by +1 (NXT) or -1 (PRV). */
function _stepCycle(dir) {
if (_activeRing === 'planets') {
_activeIdx = (_activeIdx + dir + _planetItems.length) % _planetItems.length;
_activatePlanet(_activeIdx);
} else if (_activeRing === 'elements') {
_activeIdx = (_activeIdx + dir + _elementItems.length) % _elementItems.length;
_activateElement(_activeIdx);
}
}
/**
* Inject PRV/idx/NXT controls into #id_natus_tooltip and wire their events.
* Called on every draw() so a fresh innerHTML replaces any stale state.
*/
function _injectTooltipControls() {
_tooltipEl = document.getElementById('id_natus_tooltip');
if (!_tooltipEl) return;
_tooltipEl.innerHTML =
'<div class="nw-tt-body"></div>' +
'<button type="button" class="btn btn-nav-left nw-tt-prv">PRV</button>' +
'<button type="button" class="btn btn-nav-right nw-tt-nxt">NXT</button>';
_ttBody = _tooltipEl.querySelector('.nw-tt-body');
_tooltipEl.querySelector('.nw-tt-prv').addEventListener('click', (e) => {
e.stopPropagation();
_stepCycle(-1);
}); });
_tooltipEl.querySelector('.nw-tt-nxt').addEventListener('click', (e) => {
e.stopPropagation();
_stepCycle(1);
}); });
return result; }
/** Attach a document-level click listener that closes the tooltip when the
* user clicks outside the tooltip (including on empty wheel areas).
* Planet/element groups stop propagation so their own clicks are not caught. */
function _attachOutsideClick() {
if (_outsideClickController) _outsideClickController.abort();
_outsideClickController = new AbortController();
document.addEventListener('click', (e) => {
if (_activeRing === null) return;
if (_tooltipEl && _tooltipEl.contains(e.target)) return;
_closeTooltip();
}, { signal: _outsideClickController.signal });
} }
function _layout(svgEl) { function _layout(svgEl) {
@@ -317,75 +488,31 @@ const NatusWheel = (() => {
const planetGroup = g.append('g').attr('class', 'nw-planets'); const planetGroup = g.append('g').attr('class', 'nw-planets');
const ascAngle = _toAngle(asc, asc); // start position for animation const ascAngle = _toAngle(asc, asc); // start position for animation
const conjuncts = _computeConjunctions(data.planets);
const TICK_OUTER = _r * 0.96; const TICK_OUTER = _r * 0.96;
Object.entries(data.planets).forEach(([name, pdata], idx) => { Object.entries(data.planets).forEach(([name, pdata], idx) => {
const finalA = _toAngle(pdata.degree, asc); const finalA = _toAngle(pdata.degree, asc);
const el = PLANET_ELEMENTS[name] || ''; const el = PLANET_ELEMENTS[name] || '';
// Per-planet group — data attrs + hover events live here so the // Per-planet group — click event lives here so the symbol text and ℞
// symbol text and ℞ indicator don't block mouse events on the circle. // indicator don't block mouse events on the circle.
const planetEl = planetGroup.append('g') const planetEl = planetGroup.append('g')
.attr('class', 'nw-planet-group') .attr('class', 'nw-planet-group')
.attr('data-planet', name) .attr('data-planet', name)
.attr('data-sign', pdata.sign) .attr('data-sign', pdata.sign)
.attr('data-degree', pdata.degree.toFixed(1)) .attr('data-degree', pdata.degree.toFixed(1))
.attr('data-retrograde', pdata.retrograde ? 'true' : 'false') .attr('data-retrograde', pdata.retrograde ? 'true' : 'false')
.on('mouseover', function (event) { .on('click', function (event) {
planetEl.raise(); event.stopPropagation();
d3.select(this).classed('nw-planet--hover', true); const clickIdx = _planetItems.findIndex(p => p.name === name);
const tooltip = document.getElementById('id_natus_tooltip'); if (_activeRing === 'planets' && _activeIdx === clickIdx) {
if (!tooltip) return; _closeTooltip();
const sym = PLANET_SYMBOLS[name] || name[0];
const signData = SIGNS.find(s => s.name === pdata.sign) || {};
const inDeg = _inSignDeg(pdata.degree).toFixed(1);
const rx = pdata.retrograde ? ' ℞' : '';
const icon = _signIconSvg(pdata.sign) || signData.symbol || '';
tooltip.innerHTML =
`<div class="tt-title tt-title--${el}">${name} (${sym})</div>` +
`<div class="tt-description">@${inDeg}° ${pdata.sign} (${icon})${rx}</div>`;
_positionTooltip(tooltip, event);
const tt2 = document.getElementById('id_natus_tooltip_2');
if (tt2) {
const partners = conjuncts[name];
if (partners && partners.length) {
const pname = partners[0];
const pp = data.planets[pname];
const pel = PLANET_ELEMENTS[pname] || '';
const psym = PLANET_SYMBOLS[pname] || pname[0];
const psd = SIGNS.find(s => s.name === pp.sign) || {};
const picon = _signIconSvg(pp.sign) || psd.symbol || '';
const prx = pp.retrograde ? ' ℞' : '';
const pDeg = _inSignDeg(pp.degree).toFixed(1);
tt2.innerHTML =
`<div class="tt-title tt-title--${pel}">${pname} (${psym})</div>` +
`<div class="tt-description">@${pDeg}° ${pp.sign} (${picon})${prx}</div>`;
tt2.style.display = 'block';
const gap = 8;
const tt1W = tooltip.offsetWidth;
const tt2W = tt2.offsetWidth;
let left2 = parseFloat(tooltip.style.left) + tt1W + gap;
if (left2 + tt2W + gap > window.innerWidth)
left2 = parseFloat(tooltip.style.left) - tt2W - gap;
tt2.style.left = Math.max(gap, left2) + 'px';
tt2.style.top = tooltip.style.top;
} else { } else {
tt2.style.display = 'none'; _activatePlanet(clickIdx);
} }
}
})
.on('mouseout', function (event) {
if (planetEl.node().contains(event.relatedTarget)) return;
d3.select(this).classed('nw-planet--hover', false);
const tooltip = document.getElementById('id_natus_tooltip');
if (tooltip) tooltip.style.display = 'none';
const tt2 = document.getElementById('id_natus_tooltip_2');
if (tt2) tt2.style.display = 'none';
}); });
// Tick line — from planet circle outward past the zodiac ring; part of hover group // Tick line — from planet circle outward past the zodiac ring
const tick = planetEl.append('line') const tick = planetEl.append('line')
.attr('class', el ? `nw-planet-tick nw-planet-tick--${el}` : 'nw-planet-tick') .attr('class', el ? `nw-planet-tick nw-planet-tick--${el}` : 'nw-planet-tick')
.attr('x1', _cx + R.planetR * Math.cos(ascAngle)) .attr('x1', _cx + R.planetR * Math.cos(ascAngle))
@@ -401,7 +528,7 @@ const NatusWheel = (() => {
.attr('r', _r * 0.05) .attr('r', _r * 0.05)
.attr('class', el ? `${circleBase} nw-planet--${el}` : circleBase); .attr('class', el ? `${circleBase} nw-planet--${el}` : circleBase);
// Symbol — pointer-events:none so hover is handled by the group // Symbol — pointer-events:none so click is handled by the group
const label = planetEl.append('text') const label = planetEl.append('text')
.attr('x', _cx + R.planetR * Math.cos(ascAngle)) .attr('x', _cx + R.planetR * Math.cos(ascAngle))
.attr('y', _cy + R.planetR * Math.sin(ascAngle)) .attr('y', _cy + R.planetR * Math.sin(ascAngle))
@@ -481,8 +608,6 @@ const NatusWheel = (() => {
function _drawElements(g, data) { function _drawElements(g, data) {
const el = data.elements; const el = data.elements;
// Deasil order: Fire → Stone → Time → Space → Air → Water
const ELEMENT_ORDER = ['Fire', 'Stone', 'Time', 'Space', 'Air', 'Water'];
const total = ELEMENT_ORDER.reduce((s, k) => s + (el[k] || 0), 0); const total = ELEMENT_ORDER.reduce((s, k) => s + (el[k] || 0), 0);
if (total === 0) return; if (total === 0) return;
@@ -495,29 +620,18 @@ const NatusWheel = (() => {
.attr('class', 'nw-elements') .attr('class', 'nw-elements')
.attr('transform', `translate(${_cx},${_cy})`); .attr('transform', `translate(${_cx},${_cy})`);
// Per-slice group: carries hover events + glow, arc path inside
pie.forEach(slice => { pie.forEach(slice => {
const info = ELEMENT_INFO[slice.data.key] || {};
const sliceGroup = elGroup.append('g') const sliceGroup = elGroup.append('g')
.attr('class', 'nw-element-group') .attr('class', 'nw-element-group')
.on('mouseover', function (event) { .attr('data-element', slice.data.key)
d3.select(this).classed('nw-element--hover', true); .on('click', function (event) {
const tooltip = document.getElementById('id_natus_tooltip'); event.stopPropagation();
if (!tooltip) return; const clickIdx = _elementItems.findIndex(e => e.key === slice.data.key);
const count = slice.data.value; if (_activeRing === 'elements' && _activeIdx === clickIdx) {
const pct = total > 0 ? Math.round((count / total) * 100) : 0; _closeTooltip();
const elKey = slice.data.key.toLowerCase(); } else {
tooltip.innerHTML = _activateElement(clickIdx);
`<div class="tt-title tt-title--el-${elKey}">[${info.abbr}] ${info.name}</div>` + }
`<div class="tt-description">${info.classical} · ${count} (${pct}%)</div>`;
_positionTooltip(tooltip, event);
})
.on('mouseout', function (event) {
if (sliceGroup.node().contains(event.relatedTarget)) return;
d3.select(this).classed('nw-element--hover', false);
const tooltip = document.getElementById('id_natus_tooltip');
if (tooltip) tooltip.style.display = 'none';
}); });
sliceGroup.append('path') sliceGroup.append('path')
@@ -564,6 +678,12 @@ const NatusWheel = (() => {
_svg.selectAll('*').remove(); _svg.selectAll('*').remove();
_layout(svgEl); _layout(svgEl);
_currentData = data;
_closeTooltip();
_buildCycleLists(data);
_injectTooltipControls();
_attachOutsideClick();
const g = _svg.append('g').attr('class', 'nw-root'); const g = _svg.append('g').attr('class', 'nw-root');
// Outer circle border // Outer circle border
@@ -592,6 +712,11 @@ const NatusWheel = (() => {
function clear() { function clear() {
if (_svg) _svg.selectAll('*').remove(); if (_svg) _svg.selectAll('*').remove();
_closeTooltip();
if (_outsideClickController) {
_outsideClickController.abort();
_outsideClickController = null;
}
} }
return { preload, draw, redraw, clear }; return { preload, draw, redraw, clear };

View File

@@ -7,7 +7,6 @@ to their account (stored on the User model, independent of any game room).
import json as _json import json as _json
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from apps.applets.models import Applet from apps.applets.models import Applet
@@ -169,8 +168,8 @@ class MySkyAppletWheelTest(FunctionalTest):
def test_saved_sky_wheel_renders_with_element_tooltip_in_applet(self): def test_saved_sky_wheel_renders_with_element_tooltip_in_applet(self):
"""When the user has saved sky data, the natal wheel appears in the My Sky """When the user has saved sky data, the natal wheel appears in the My Sky
applet and the element-ring tooltip fires on hover. applet and clicking an element-ring slice shows the tooltip.
(Planet hover tooltip is covered by NatusWheelSpec.js T3/T4/T5.)""" (Planet click tooltip is covered by NatusWheelSpec.js T3/T4/T5.)"""
self.create_pre_authenticated_session("stargazer@test.io") self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
@@ -181,11 +180,12 @@ class MySkyAppletWheelTest(FunctionalTest):
) )
)) ))
# 2. Hovering an element-ring slice shows the tooltip # 2. Clicking an element-ring slice shows the tooltip (JS click bypasses
# scroll-into-view restriction inside the overflow-masked applet).
slice_el = self.browser.find_element( slice_el = self.browser.find_element(
By.CSS_SELECTOR, "#id_applet_my_sky .nw-element-group" By.CSS_SELECTOR, "#id_applet_my_sky .nw-element-group"
) )
ActionChains(self.browser).move_to_element(slice_el).perform() self.browser.execute_script("arguments[0].click();", slice_el)
self.wait_for(lambda: self.assertEqual( self.wait_for(lambda: self.assertEqual(
self.browser.find_element(By.ID, "id_natus_tooltip") self.browser.find_element(By.ID, "id_natus_tooltip")
.value_of_css_property("display"), .value_of_css_property("display"),
@@ -325,6 +325,5 @@ class MySkyWheelConjunctionTest(FunctionalTest):
10, 10,
)) ))
# (T7 tick-extends-past-zodiac, T8 hover-raises-to-front, and T9 conjunction # (T7 tick-extends-past-zodiac, T8 click-raises-to-front, and T9c/T9n/T9w
# dual-tooltip are covered by NatusWheelSpec.js T7/T8/T9j — ActionChains # cycle navigation are covered by NatusWheelSpec.js.)
# planet-circle hover is unreliable in headless Firefox.)

View File

@@ -1,28 +1,26 @@
// ── NatusWheelSpec.js ───────────────────────────────────────────────────────── // ── NatusWheelSpec.js ─────────────────────────────────────────────────────────
// //
// Unit specs for natus-wheel.js — planet hover tooltips. // Unit specs for natus-wheel.js — planet/element click-to-lock tooltips.
// //
// DOM contract assumed: // DOM contract assumed:
// <svg id="id_natus_svg"> — target for NatusWheel.draw() // <svg id="id_natus_svg"> — target for NatusWheel.draw()
// <div id="id_natus_tooltip"> — tooltip portal (position:fixed on page) // <div id="id_natus_tooltip"> — tooltip portal (position:fixed on page)
// //
// Public API under test: // Click-lock contract:
// NatusWheel.draw(svgEl, data) — renders wheel; attaches hover listeners // click on [data-planet] group → adds .nw-planet--active class
// NatusWheel.clear() — empties the SVG (used in afterEach) // raises group to DOM front
//
// Hover contract:
// mouseover on [data-planet] group → adds .nw-planet--hover class
// shows #id_natus_tooltip with // shows #id_natus_tooltip with
// planet name, in-sign degree, sign name // planet name, in-sign degree, sign name,
// and ℞ if retrograde // ℞ if retrograde, and "n / total" index
// mouseout on [data-planet] group → removes .nw-planet--hover // click same planet again → removes .nw-planet--active; hides tooltip
// hides #id_natus_tooltip // PRV / NXT buttons in tooltip → cycle to adjacent planet by ecliptic degree
// //
// In-sign degree: ecliptic_longitude % 30 (e.g. 338.4° → 8.4° Pisces) // In-sign degree: ecliptic_longitude % 30 (e.g. 338.4° → 8.4° Pisces)
// //
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Shared conjunction chart — Sun and Venus 3.4° apart in Gemini // Shared chart — Sun (66.7°), Venus (63.3°), Mars (132.0°)
// Descending-degree (clockwise) order: Mars (132.0) → Sun (66.7) → Venus (63.3)
const CONJUNCTION_CHART = { const CONJUNCTION_CHART = {
planets: { planets: {
Sun: { sign: "Gemini", degree: 66.7, retrograde: false }, Sun: { sign: "Gemini", degree: 66.7, retrograde: false },
@@ -42,7 +40,7 @@ const CONJUNCTION_CHART = {
house_system: "O", house_system: "O",
}; };
describe("NatusWheel — planet tooltips", () => { describe("NatusWheel — planet click tooltips", () => {
const SYNTHETIC_CHART = { const SYNTHETIC_CHART = {
planets: { planets: {
@@ -68,7 +66,6 @@ describe("NatusWheel — planet tooltips", () => {
let svgEl, tooltipEl; let svgEl, tooltipEl;
beforeEach(() => { beforeEach(() => {
// SVG element — D3 draws into this
svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl.setAttribute("id", "id_natus_svg"); svgEl.setAttribute("id", "id_natus_svg");
svgEl.setAttribute("width", "400"); svgEl.setAttribute("width", "400");
@@ -77,7 +74,6 @@ describe("NatusWheel — planet tooltips", () => {
svgEl.style.height = "400px"; svgEl.style.height = "400px";
document.body.appendChild(svgEl); document.body.appendChild(svgEl);
// Tooltip portal — same markup as _natus_overlay.html
tooltipEl = document.createElement("div"); tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip"; tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt"; tooltipEl.className = "tt";
@@ -93,15 +89,15 @@ describe("NatusWheel — planet tooltips", () => {
tooltipEl.remove(); tooltipEl.remove();
}); });
// ── T3 ── hover planet shows name / sign / in-sign degree + glow ───────── // ── T3 ── click planet shows name / sign / in-sign degree + glow ─────────
it("T3: hovering a planet group adds the glow class and shows the tooltip with name, sign, and in-sign degree", () => { it("T3: clicking a planet group adds the active class and shows the tooltip with name, sign, and in-sign degree", () => {
const sun = svgEl.querySelector("[data-planet='Sun']"); const sun = svgEl.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG"); expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG");
sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(sun.classList.contains("nw-planet--hover")).toBe(true); expect(sun.classList.contains("nw-planet--active")).toBe(true);
expect(tooltipEl.style.display).toBe("block"); expect(tooltipEl.style.display).toBe("block");
const text = tooltipEl.textContent; const text = tooltipEl.textContent;
@@ -113,39 +109,45 @@ describe("NatusWheel — planet tooltips", () => {
// ── T4 ── retrograde planet shows ℞ ────────────────────────────────────── // ── T4 ── retrograde planet shows ℞ ──────────────────────────────────────
it("T4: hovering a retrograde planet shows ℞ in the tooltip", () => { it("T4: clicking a retrograde planet shows ℞ in the tooltip", () => {
const mercury = svgEl.querySelector("[data-planet='Mercury']"); const mercury = svgEl.querySelector("[data-planet='Mercury']");
expect(mercury).not.toBeNull("expected [data-planet='Mercury'] to exist in the SVG"); expect(mercury).not.toBeNull("expected [data-planet='Mercury'] to exist in the SVG");
mercury.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); mercury.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block"); expect(tooltipEl.style.display).toBe("block");
expect(tooltipEl.textContent).toContain("℞"); expect(tooltipEl.textContent).toContain("℞");
}); });
// ── T5 ── mouseout hides tooltip and removes glow ───────────────────────── // ── T5 ── clicking same planet again hides tooltip and removes active ──────
it("T5: mouseout hides the tooltip and removes the glow class", () => { it("T5: clicking the same planet again hides the tooltip and removes the active class", () => {
const sun = svgEl.querySelector("[data-planet='Sun']"); const sun = svgEl.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG"); expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG");
sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block"); expect(tooltipEl.style.display).toBe("block");
// relatedTarget is document.body — outside the planet group sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
sun.dispatchEvent(new MouseEvent("mouseout", {
bubbles: true,
relatedTarget: document.body,
}));
expect(tooltipEl.style.display).toBe("none"); expect(tooltipEl.style.display).toBe("none");
expect(sun.classList.contains("nw-planet--hover")).toBe(false); expect(sun.classList.contains("nw-planet--active")).toBe(false);
});
// ── T6 ── tooltip shows PRV / NXT buttons ─────────────────────────────────
it("T6: tooltip contains PRV and NXT buttons after a planet click", () => {
const sun = svgEl.querySelector("[data-planet='Sun']");
sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull("expected .nw-tt-prv button");
expect(tooltipEl.querySelector(".nw-tt-nxt")).not.toBeNull("expected .nw-tt-nxt button");
}); });
}); });
describe("NatusWheel — conjunction features", () => { describe("NatusWheel — tick lines, raise, and cycle navigation", () => {
let svgEl2, tooltipEl, tooltip2El; let svgEl2, tooltipEl;
beforeEach(() => { beforeEach(() => {
svgEl2 = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svgEl2 = document.createElementNS("http://www.w3.org/2000/svg", "svg");
@@ -163,13 +165,6 @@ describe("NatusWheel — conjunction features", () => {
tooltipEl.style.position = "fixed"; tooltipEl.style.position = "fixed";
document.body.appendChild(tooltipEl); document.body.appendChild(tooltipEl);
tooltip2El = document.createElement("div");
tooltip2El.id = "id_natus_tooltip_2";
tooltip2El.className = "tt";
tooltip2El.style.display = "none";
tooltip2El.style.position = "fixed";
document.body.appendChild(tooltip2El);
NatusWheel.draw(svgEl2, CONJUNCTION_CHART); NatusWheel.draw(svgEl2, CONJUNCTION_CHART);
}); });
@@ -177,10 +172,10 @@ describe("NatusWheel — conjunction features", () => {
NatusWheel.clear(); NatusWheel.clear();
svgEl2.remove(); svgEl2.remove();
tooltipEl.remove(); tooltipEl.remove();
tooltip2El.remove();
}); });
// ── T7 ── tick extends past zodiac ring ─────────────────────────────────── // ── T7 ── tick present in DOM and extends past the zodiac ring ───────────
// Visibility is CSS-controlled (opacity-0 by default, revealed on --active).
it("T7: each planet has a tick line whose outer endpoint extends past the sign ring", () => { it("T7: each planet has a tick line whose outer endpoint extends past the sign ring", () => {
const tick = svgEl2.querySelector(".nw-planet-tick"); const tick = svgEl2.querySelector(".nw-planet-tick");
@@ -195,31 +190,195 @@ describe("NatusWheel — conjunction features", () => {
expect(rOuter).toBeGreaterThan(signOuter); expect(rOuter).toBeGreaterThan(signOuter);
}); });
// ── T8 ── hover raises planet to front ──────────────────────────────────── // ── T8 ── click raises planet to front ────────────────────────────────────
it("T8: hovering a planet raises it to the last DOM position (visually on top)", () => { it("T8: clicking a planet raises it to the last DOM position (visually on top)", () => {
const sun = svgEl2.querySelector("[data-planet='Sun']"); const sun = svgEl2.querySelector("[data-planet='Sun']");
const venus = svgEl2.querySelector("[data-planet='Venus']"); const venus = svgEl2.querySelector("[data-planet='Venus']");
expect(sun).not.toBeNull("expected [data-planet='Sun']"); expect(sun).not.toBeNull("expected [data-planet='Sun']");
expect(venus).not.toBeNull("expected [data-planet='Venus']"); expect(venus).not.toBeNull("expected [data-planet='Venus']");
sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body })); sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
venus.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body })); venus.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const groups = Array.from(svgEl2.querySelectorAll(".nw-planet-group")); const groups = Array.from(svgEl2.querySelectorAll(".nw-planet-group"));
expect(groups[groups.length - 1].getAttribute("data-planet")).toBe("Venus"); expect(groups[groups.length - 1].getAttribute("data-planet")).toBe("Venus");
}); });
// ── T9j ── dual tooltip fires for conjunct planet ───────────────────────── // ── T9c ── NXT cycles clockwise (to lower ecliptic degree) ──────────────
// Descending order: Mars [idx 0] → Sun [idx 1] → Venus [idx 2]
// Clicking Sun (idx 1) then NXT should activate Venus (idx 2, lower degree = clockwise).
it("T9j: hovering a conjunct planet shows a second tooltip for its partner", () => { it("T9c: clicking NXT from Sun shows Venus (next planet clockwise = lower degree)", () => {
const sun = svgEl2.querySelector("[data-planet='Sun']"); const sun = svgEl2.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun']"); expect(sun).not.toBeNull("expected [data-planet='Sun']");
sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body })); sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block"); expect(tooltipEl.style.display).toBe("block");
expect(tooltip2El.style.display).toBe("block"); expect(tooltipEl.textContent).toContain("Sun");
expect(tooltip2El.textContent).toContain("Venus");
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
expect(nxtBtn).not.toBeNull("expected .nw-tt-nxt button in tooltip");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
expect(tooltipEl.textContent).toContain("Venus");
const venus = svgEl2.querySelector("[data-planet='Venus']");
expect(venus.classList.contains("nw-planet--active")).toBe(true);
expect(sun.classList.contains("nw-planet--active")).toBe(false);
});
// ── T9n ── PRV cycles counterclockwise (to higher ecliptic degree) ────────
it("T9n: clicking PRV from Sun shows Mars (previous planet counterclockwise = higher degree)", () => {
const sun = svgEl2.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun']");
sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const prvBtn = tooltipEl.querySelector(".nw-tt-prv");
prvBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Mars");
const mars = svgEl2.querySelector("[data-planet='Mars']");
expect(mars.classList.contains("nw-planet--active")).toBe(true);
});
// ── T9w ── NXT wraps clockwise from the last (lowest-degree) planet ───────
it("T9w: cycling NXT from Venus (lowest degree) wraps clockwise to Mars (highest degree)", () => {
// Venus is idx 2 (lowest degree = furthest clockwise); NXT wraps to idx 0 = Mars
const venus = svgEl2.querySelector("[data-planet='Venus']");
venus.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Mars");
const mars = svgEl2.querySelector("[data-planet='Mars']");
expect(mars.classList.contains("nw-planet--active")).toBe(true);
});
});
// ── Half-wheel tooltip positioning ───────────────────────────────────────────
//
// Tooltip lands in the opposite vertical half, with horizontal edge anchored
// to the item's screen edge on the same L/R side.
//
// SVG: 400×400 at viewport origin → centre = (200, 200).
// Tooltip offsetWidth/Height: 0 in JSDOM (no layout engine) → ttW=ttH=0.
// Clamping uses svgRect bounds (not window.inner*), so no viewport mock needed.
// REM = 16 px. Item circle: 20×20 px around mock centre.
//
// Vertical results (item circle centre at y):
// y ≥ 200 (lower half): top = svgCY - REM - ttH = 200 - 16 - 0 = 184
// y < 200 (upper half): top = svgCY + REM = 200 + 16 = 216
//
// Horizontal results (item circle centre at x, radius=10):
// x < 200 (left side): left = iRect.left = x - 10
// x ≥ 200 (right side): left = iRect.right - ttW = x + 10 - 0 = x + 10
// ─────────────────────────────────────────────────────────────────────────────
xdescribe("NatusWheel — half-wheel tooltip positioning", () => {
const HALF_CHART = {
planets: {
// Vesta 90° → SVG (200, 274) — BELOW centre
// Ceres 270° → SVG (200, 126) — ABOVE centre
Vesta: { sign: "Cancer", degree: 90, retrograde: false },
Ceres: { sign: "Capricorn", degree: 270, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0, mc: 270,
},
elements: { Fire: 0, Stone: 1, Air: 0, Water: 1, Time: 0, Space: 0 },
aspects: [],
distinctions: {
"1": 0, "2": 0, "3": 0, "4": 1,
"5": 0, "6": 0, "7": 0, "8": 0,
"9": 0, "10": 1, "11": 0, "12": 0,
},
house_system: "P",
};
let svgEl3, tooltipEl;
beforeEach(() => {
svgEl3 = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl3.setAttribute("id", "id_natus_svg_half");
svgEl3.setAttribute("width", "400");
svgEl3.setAttribute("height", "400");
svgEl3.style.width = "400px";
svgEl3.style.height = "400px";
document.body.appendChild(svgEl3);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
tooltipEl.style.position = "fixed";
document.body.appendChild(tooltipEl);
// Simulate SVG occupying [0,400]×[0,400] in the viewport.
// Clamping uses svgRect bounds, so no need to mock window.inner*.
spyOn(svgEl3, "getBoundingClientRect").and.returnValue(
{ left: 0, top: 0, width: 400, height: 400, right: 400, bottom: 400 }
);
NatusWheel.draw(svgEl3, HALF_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl3.remove();
tooltipEl.remove();
});
function mockPlanetAt(name, screenX, screenY) {
const grp = svgEl3.querySelector(`[data-planet="${name}"]`);
const circle = grp && (grp.querySelector("circle") || grp);
if (circle) {
spyOn(circle, "getBoundingClientRect").and.returnValue({
left: screenX - 10, top: screenY - 10,
width: 20, height: 20,
right: screenX + 10, bottom: screenY + 10,
});
}
}
// T10a — lower half: lower edge of tooltip sits 1rem above centreline
it("T10a: planet in lower half places tooltip lower-edge 1rem above the centreline (top=184)", () => {
mockPlanetAt("Vesta", 200, 274);
svgEl3.querySelector("[data-planet='Vesta']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(parseFloat(tooltipEl.style.top)).toBe(184);
});
// T10b — upper half: upper edge of tooltip sits 1rem below centreline
it("T10b: planet in upper half places tooltip upper-edge 1rem below the centreline (top=216)", () => {
mockPlanetAt("Ceres", 200, 126);
svgEl3.querySelector("[data-planet='Ceres']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(parseFloat(tooltipEl.style.top)).toBe(216);
});
// T10c — left side: tooltip left edge aligns with item left edge (x=140 → left=130)
it("T10c: planet on left side of wheel aligns tooltip left edge with item left edge", () => {
mockPlanetAt("Vesta", 140, 274); // itemRect.left = 130
svgEl3.querySelector("[data-planet='Vesta']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(parseFloat(tooltipEl.style.left)).toBe(130);
});
// T10d — right side: tooltip right edge aligns with item right edge (x=260 → left=270-ttW=270)
it("T10d: planet on right side of wheel aligns tooltip right edge with item right edge", () => {
mockPlanetAt("Vesta", 260, 274); // iRect.right=270, ttW=0 → left=270
svgEl3.querySelector("[data-planet='Vesta']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(parseFloat(tooltipEl.style.left)).toBe(270);
}); });
}); });

View File

@@ -431,19 +431,30 @@ 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 glow (--ninUser) — shared by planet groups and element slice groups // Hover and active-lock glow — planet groups and element slice groups
.nw-planet--hover, .nw-planet-group,
.nw-element--hover { .nw-element-group { cursor: pointer; }
.nw-planet-group:hover,
.nw-planet-group.nw-planet--active,
.nw-element-group:hover,
.nw-element-group.nw-element--active {
filter: drop-shadow(0 0 5px rgba(var(--ninUser), 0.9)); filter: drop-shadow(0 0 5px rgba(var(--ninUser), 0.9));
cursor: pointer;
} }
// ── Planet tick lines ───────────────────────────────────────────────────────── // ── Planet tick lines — hidden until parent group is active ──────────────────
.nw-planet-tick { .nw-planet-tick {
fill: none; fill: none;
stroke-width: 3px; stroke-width: 1px;
stroke-opacity: 0.5; stroke-opacity: 0;
stroke-linecap: round; stroke-linecap: round;
transition: stroke-opacity 0.15s ease;
}
.nw-planet-group.nw-planet--active .nw-planet-tick {
stroke: rgba(var(--terUser), 1);
stroke-opacity: 0.7;
filter: drop-shadow(0 0 3px rgba(var(--terUser), 0.8))
drop-shadow(0 0 6px rgba(var(--terUser), 0.4));
} }
.nw-planet-tick--au { stroke: rgba(var(--priAu), 1); } .nw-planet-tick--au { stroke: rgba(var(--priAu), 1); }
.nw-planet-tick--ag { stroke: rgba(var(--priAg), 1); } .nw-planet-tick--ag { stroke: rgba(var(--priAg), 1); }
@@ -475,13 +486,22 @@ html.natus-open .natus-modal-wrap {
#id_natus_tooltip_2 { #id_natus_tooltip_2 {
position: fixed; position: fixed;
z-index: 200; z-index: 200;
pointer-events: none; pointer-events: auto;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
.tt-title { font-size: 1rem; font-weight: 700; } .tt-title { font-size: 1rem; font-weight: 700; }
.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; }
.nw-tt-prv,
.nw-tt-nxt {
position: absolute;
bottom: -1rem;
margin: 0;
}
.nw-tt-prv { left: -1rem; }
.nw-tt-nxt { right: -1rem; }
// Planet title colors — senary (brightest) tier on dark palettes // Planet title colors — senary (brightest) tier on dark palettes
.tt-title--au { color: rgba(var(--sixAu), 1); } // Sun .tt-title--au { color: rgba(var(--sixAu), 1); } // Sun
.tt-title--ag { color: rgba(var(--sixAg), 1); } // Moon .tt-title--ag { color: rgba(var(--sixAg), 1); } // Moon

View File

@@ -118,15 +118,15 @@
--sixPu: 235, 211, 217; --sixPu: 235, 211, 217;
/* Chroma Palette */ /* Chroma Palette */
// red // red (A-Fire)
--priRd: 233, 53, 37; --priRd: 233, 53, 37;
--secRd: 193, 43, 28; --secRd: 193, 43, 28;
--terRd: 155, 31, 15; --terRd: 155, 31, 15;
// orange // 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;
// yellow // yellow (
--priYl: 255, 207, 52; --priYl: 255, 207, 52;
--secYl: 211, 172, 44; --secYl: 211, 172, 44;
--terYl: 168, 138, 33; --terYl: 168, 138, 33;

View File

@@ -1,28 +1,26 @@
// ── NatusWheelSpec.js ───────────────────────────────────────────────────────── // ── NatusWheelSpec.js ─────────────────────────────────────────────────────────
// //
// Unit specs for natus-wheel.js — planet hover tooltips. // Unit specs for natus-wheel.js — planet/element click-to-lock tooltips.
// //
// DOM contract assumed: // DOM contract assumed:
// <svg id="id_natus_svg"> — target for NatusWheel.draw() // <svg id="id_natus_svg"> — target for NatusWheel.draw()
// <div id="id_natus_tooltip"> — tooltip portal (position:fixed on page) // <div id="id_natus_tooltip"> — tooltip portal (position:fixed on page)
// //
// Public API under test: // Click-lock contract:
// NatusWheel.draw(svgEl, data) — renders wheel; attaches hover listeners // click on [data-planet] group → adds .nw-planet--active class
// NatusWheel.clear() — empties the SVG (used in afterEach) // raises group to DOM front
//
// Hover contract:
// mouseover on [data-planet] group → adds .nw-planet--hover class
// shows #id_natus_tooltip with // shows #id_natus_tooltip with
// planet name, in-sign degree, sign name // planet name, in-sign degree, sign name,
// and ℞ if retrograde // ℞ if retrograde, and "n / total" index
// mouseout on [data-planet] group → removes .nw-planet--hover // click same planet again → removes .nw-planet--active; hides tooltip
// hides #id_natus_tooltip // PRV / NXT buttons in tooltip → cycle to adjacent planet by ecliptic degree
// //
// In-sign degree: ecliptic_longitude % 30 (e.g. 338.4° → 8.4° Pisces) // In-sign degree: ecliptic_longitude % 30 (e.g. 338.4° → 8.4° Pisces)
// //
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Shared conjunction chart — Sun and Venus 3.4° apart in Gemini // Shared chart — Sun (66.7°), Venus (63.3°), Mars (132.0°)
// Descending-degree (clockwise) order: Mars (132.0) → Sun (66.7) → Venus (63.3)
const CONJUNCTION_CHART = { const CONJUNCTION_CHART = {
planets: { planets: {
Sun: { sign: "Gemini", degree: 66.7, retrograde: false }, Sun: { sign: "Gemini", degree: 66.7, retrograde: false },
@@ -42,7 +40,7 @@ const CONJUNCTION_CHART = {
house_system: "O", house_system: "O",
}; };
describe("NatusWheel — planet tooltips", () => { describe("NatusWheel — planet click tooltips", () => {
const SYNTHETIC_CHART = { const SYNTHETIC_CHART = {
planets: { planets: {
@@ -68,7 +66,6 @@ describe("NatusWheel — planet tooltips", () => {
let svgEl, tooltipEl; let svgEl, tooltipEl;
beforeEach(() => { beforeEach(() => {
// SVG element — D3 draws into this
svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl.setAttribute("id", "id_natus_svg"); svgEl.setAttribute("id", "id_natus_svg");
svgEl.setAttribute("width", "400"); svgEl.setAttribute("width", "400");
@@ -77,7 +74,6 @@ describe("NatusWheel — planet tooltips", () => {
svgEl.style.height = "400px"; svgEl.style.height = "400px";
document.body.appendChild(svgEl); document.body.appendChild(svgEl);
// Tooltip portal — same markup as _natus_overlay.html
tooltipEl = document.createElement("div"); tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip"; tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt"; tooltipEl.className = "tt";
@@ -93,15 +89,15 @@ describe("NatusWheel — planet tooltips", () => {
tooltipEl.remove(); tooltipEl.remove();
}); });
// ── T3 ── hover planet shows name / sign / in-sign degree + glow ───────── // ── T3 ── click planet shows name / sign / in-sign degree + glow ─────────
it("T3: hovering a planet group adds the glow class and shows the tooltip with name, sign, and in-sign degree", () => { it("T3: clicking a planet group adds the active class and shows the tooltip with name, sign, and in-sign degree", () => {
const sun = svgEl.querySelector("[data-planet='Sun']"); const sun = svgEl.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG"); expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG");
sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(sun.classList.contains("nw-planet--hover")).toBe(true); expect(sun.classList.contains("nw-planet--active")).toBe(true);
expect(tooltipEl.style.display).toBe("block"); expect(tooltipEl.style.display).toBe("block");
const text = tooltipEl.textContent; const text = tooltipEl.textContent;
@@ -113,39 +109,45 @@ describe("NatusWheel — planet tooltips", () => {
// ── T4 ── retrograde planet shows ℞ ────────────────────────────────────── // ── T4 ── retrograde planet shows ℞ ──────────────────────────────────────
it("T4: hovering a retrograde planet shows ℞ in the tooltip", () => { it("T4: clicking a retrograde planet shows ℞ in the tooltip", () => {
const mercury = svgEl.querySelector("[data-planet='Mercury']"); const mercury = svgEl.querySelector("[data-planet='Mercury']");
expect(mercury).not.toBeNull("expected [data-planet='Mercury'] to exist in the SVG"); expect(mercury).not.toBeNull("expected [data-planet='Mercury'] to exist in the SVG");
mercury.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); mercury.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block"); expect(tooltipEl.style.display).toBe("block");
expect(tooltipEl.textContent).toContain("℞"); expect(tooltipEl.textContent).toContain("℞");
}); });
// ── T5 ── mouseout hides tooltip and removes glow ───────────────────────── // ── T5 ── clicking same planet again hides tooltip and removes active ──────
it("T5: mouseout hides the tooltip and removes the glow class", () => { it("T5: clicking the same planet again hides the tooltip and removes the active class", () => {
const sun = svgEl.querySelector("[data-planet='Sun']"); const sun = svgEl.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG"); expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG");
sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block"); expect(tooltipEl.style.display).toBe("block");
// relatedTarget is document.body — outside the planet group sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
sun.dispatchEvent(new MouseEvent("mouseout", {
bubbles: true,
relatedTarget: document.body,
}));
expect(tooltipEl.style.display).toBe("none"); expect(tooltipEl.style.display).toBe("none");
expect(sun.classList.contains("nw-planet--hover")).toBe(false); expect(sun.classList.contains("nw-planet--active")).toBe(false);
});
// ── T6 ── tooltip shows PRV / NXT buttons ─────────────────────────────────
it("T6: tooltip contains PRV and NXT buttons after a planet click", () => {
const sun = svgEl.querySelector("[data-planet='Sun']");
sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull("expected .nw-tt-prv button");
expect(tooltipEl.querySelector(".nw-tt-nxt")).not.toBeNull("expected .nw-tt-nxt button");
}); });
}); });
describe("NatusWheel — conjunction features", () => { describe("NatusWheel — tick lines, raise, and cycle navigation", () => {
let svgEl2, tooltipEl, tooltip2El; let svgEl2, tooltipEl;
beforeEach(() => { beforeEach(() => {
svgEl2 = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svgEl2 = document.createElementNS("http://www.w3.org/2000/svg", "svg");
@@ -163,13 +165,6 @@ describe("NatusWheel — conjunction features", () => {
tooltipEl.style.position = "fixed"; tooltipEl.style.position = "fixed";
document.body.appendChild(tooltipEl); document.body.appendChild(tooltipEl);
tooltip2El = document.createElement("div");
tooltip2El.id = "id_natus_tooltip_2";
tooltip2El.className = "tt";
tooltip2El.style.display = "none";
tooltip2El.style.position = "fixed";
document.body.appendChild(tooltip2El);
NatusWheel.draw(svgEl2, CONJUNCTION_CHART); NatusWheel.draw(svgEl2, CONJUNCTION_CHART);
}); });
@@ -177,10 +172,10 @@ describe("NatusWheel — conjunction features", () => {
NatusWheel.clear(); NatusWheel.clear();
svgEl2.remove(); svgEl2.remove();
tooltipEl.remove(); tooltipEl.remove();
tooltip2El.remove();
}); });
// ── T7 ── tick extends past zodiac ring ─────────────────────────────────── // ── T7 ── tick present in DOM and extends past the zodiac ring ───────────
// Visibility is CSS-controlled (opacity-0 by default, revealed on --active).
it("T7: each planet has a tick line whose outer endpoint extends past the sign ring", () => { it("T7: each planet has a tick line whose outer endpoint extends past the sign ring", () => {
const tick = svgEl2.querySelector(".nw-planet-tick"); const tick = svgEl2.querySelector(".nw-planet-tick");
@@ -195,31 +190,195 @@ describe("NatusWheel — conjunction features", () => {
expect(rOuter).toBeGreaterThan(signOuter); expect(rOuter).toBeGreaterThan(signOuter);
}); });
// ── T8 ── hover raises planet to front ──────────────────────────────────── // ── T8 ── click raises planet to front ────────────────────────────────────
it("T8: hovering a planet raises it to the last DOM position (visually on top)", () => { it("T8: clicking a planet raises it to the last DOM position (visually on top)", () => {
const sun = svgEl2.querySelector("[data-planet='Sun']"); const sun = svgEl2.querySelector("[data-planet='Sun']");
const venus = svgEl2.querySelector("[data-planet='Venus']"); const venus = svgEl2.querySelector("[data-planet='Venus']");
expect(sun).not.toBeNull("expected [data-planet='Sun']"); expect(sun).not.toBeNull("expected [data-planet='Sun']");
expect(venus).not.toBeNull("expected [data-planet='Venus']"); expect(venus).not.toBeNull("expected [data-planet='Venus']");
sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body })); sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
venus.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body })); venus.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const groups = Array.from(svgEl2.querySelectorAll(".nw-planet-group")); const groups = Array.from(svgEl2.querySelectorAll(".nw-planet-group"));
expect(groups[groups.length - 1].getAttribute("data-planet")).toBe("Venus"); expect(groups[groups.length - 1].getAttribute("data-planet")).toBe("Venus");
}); });
// ── T9j ── dual tooltip fires for conjunct planet ───────────────────────── // ── T9c ── NXT cycles clockwise (to lower ecliptic degree) ──────────────
// Descending order: Mars [idx 0] → Sun [idx 1] → Venus [idx 2]
// Clicking Sun (idx 1) then NXT should activate Venus (idx 2, lower degree = clockwise).
it("T9j: hovering a conjunct planet shows a second tooltip for its partner", () => { it("T9c: clicking NXT from Sun shows Venus (next planet clockwise = lower degree)", () => {
const sun = svgEl2.querySelector("[data-planet='Sun']"); const sun = svgEl2.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun']"); expect(sun).not.toBeNull("expected [data-planet='Sun']");
sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body })); sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block"); expect(tooltipEl.style.display).toBe("block");
expect(tooltip2El.style.display).toBe("block"); expect(tooltipEl.textContent).toContain("Sun");
expect(tooltip2El.textContent).toContain("Venus");
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
expect(nxtBtn).not.toBeNull("expected .nw-tt-nxt button in tooltip");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
expect(tooltipEl.textContent).toContain("Venus");
const venus = svgEl2.querySelector("[data-planet='Venus']");
expect(venus.classList.contains("nw-planet--active")).toBe(true);
expect(sun.classList.contains("nw-planet--active")).toBe(false);
});
// ── T9n ── PRV cycles counterclockwise (to higher ecliptic degree) ────────
it("T9n: clicking PRV from Sun shows Mars (previous planet counterclockwise = higher degree)", () => {
const sun = svgEl2.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun']");
sun.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const prvBtn = tooltipEl.querySelector(".nw-tt-prv");
prvBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Mars");
const mars = svgEl2.querySelector("[data-planet='Mars']");
expect(mars.classList.contains("nw-planet--active")).toBe(true);
});
// ── T9w ── NXT wraps clockwise from the last (lowest-degree) planet ───────
it("T9w: cycling NXT from Venus (lowest degree) wraps clockwise to Mars (highest degree)", () => {
// Venus is idx 2 (lowest degree = furthest clockwise); NXT wraps to idx 0 = Mars
const venus = svgEl2.querySelector("[data-planet='Venus']");
venus.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Mars");
const mars = svgEl2.querySelector("[data-planet='Mars']");
expect(mars.classList.contains("nw-planet--active")).toBe(true);
});
});
// ── Half-wheel tooltip positioning ───────────────────────────────────────────
//
// Tooltip lands in the opposite vertical half, with horizontal edge anchored
// to the item's screen edge on the same L/R side.
//
// SVG: 400×400 at viewport origin → centre = (200, 200).
// Tooltip offsetWidth/Height: 0 in JSDOM (no layout engine) → ttW=ttH=0.
// Clamping uses svgRect bounds (not window.inner*), so no viewport mock needed.
// REM = 16 px. Item circle: 20×20 px around mock centre.
//
// Vertical results (item circle centre at y):
// y ≥ 200 (lower half): top = svgCY - REM - ttH = 200 - 16 - 0 = 184
// y < 200 (upper half): top = svgCY + REM = 200 + 16 = 216
//
// Horizontal results (item circle centre at x, radius=10):
// x < 200 (left side): left = iRect.left = x - 10
// x ≥ 200 (right side): left = iRect.right - ttW = x + 10 - 0 = x + 10
// ─────────────────────────────────────────────────────────────────────────────
xdescribe("NatusWheel — half-wheel tooltip positioning", () => {
const HALF_CHART = {
planets: {
// Vesta 90° → SVG (200, 274) — BELOW centre
// Ceres 270° → SVG (200, 126) — ABOVE centre
Vesta: { sign: "Cancer", degree: 90, retrograde: false },
Ceres: { sign: "Capricorn", degree: 270, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0, mc: 270,
},
elements: { Fire: 0, Stone: 1, Air: 0, Water: 1, Time: 0, Space: 0 },
aspects: [],
distinctions: {
"1": 0, "2": 0, "3": 0, "4": 1,
"5": 0, "6": 0, "7": 0, "8": 0,
"9": 0, "10": 1, "11": 0, "12": 0,
},
house_system: "P",
};
let svgEl3, tooltipEl;
beforeEach(() => {
svgEl3 = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl3.setAttribute("id", "id_natus_svg_half");
svgEl3.setAttribute("width", "400");
svgEl3.setAttribute("height", "400");
svgEl3.style.width = "400px";
svgEl3.style.height = "400px";
document.body.appendChild(svgEl3);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
tooltipEl.style.position = "fixed";
document.body.appendChild(tooltipEl);
// Simulate SVG occupying [0,400]×[0,400] in the viewport.
// Clamping uses svgRect bounds, so no need to mock window.inner*.
spyOn(svgEl3, "getBoundingClientRect").and.returnValue(
{ left: 0, top: 0, width: 400, height: 400, right: 400, bottom: 400 }
);
NatusWheel.draw(svgEl3, HALF_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl3.remove();
tooltipEl.remove();
});
function mockPlanetAt(name, screenX, screenY) {
const grp = svgEl3.querySelector(`[data-planet="${name}"]`);
const circle = grp && (grp.querySelector("circle") || grp);
if (circle) {
spyOn(circle, "getBoundingClientRect").and.returnValue({
left: screenX - 10, top: screenY - 10,
width: 20, height: 20,
right: screenX + 10, bottom: screenY + 10,
});
}
}
// T10a — lower half: lower edge of tooltip sits 1rem above centreline
it("T10a: planet in lower half places tooltip lower-edge 1rem above the centreline (top=184)", () => {
mockPlanetAt("Vesta", 200, 274);
svgEl3.querySelector("[data-planet='Vesta']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(parseFloat(tooltipEl.style.top)).toBe(184);
});
// T10b — upper half: upper edge of tooltip sits 1rem below centreline
it("T10b: planet in upper half places tooltip upper-edge 1rem below the centreline (top=216)", () => {
mockPlanetAt("Ceres", 200, 126);
svgEl3.querySelector("[data-planet='Ceres']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(parseFloat(tooltipEl.style.top)).toBe(216);
});
// T10c — left side: tooltip left edge aligns with item left edge (x=140 → left=130)
it("T10c: planet on left side of wheel aligns tooltip left edge with item left edge", () => {
mockPlanetAt("Vesta", 140, 274); // itemRect.left = 130
svgEl3.querySelector("[data-planet='Vesta']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(parseFloat(tooltipEl.style.left)).toBe(130);
});
// T10d — right side: tooltip right edge aligns with item right edge (x=260 → left=270-ttW=270)
it("T10d: planet on right side of wheel aligns tooltip right edge with item right edge", () => {
mockPlanetAt("Vesta", 260, 274); // iRect.right=270, ttW=0 → left=270
svgEl3.querySelector("[data-planet='Vesta']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(parseFloat(tooltipEl.style.left)).toBe(270);
}); });
}); });