diff --git a/src/apps/gameboard/static/apps/gameboard/natus-wheel.js b/src/apps/gameboard/static/apps/gameboard/natus-wheel.js index eb6266a..be77167 100644 --- a/src/apps/gameboard/static/apps/gameboard/natus-wheel.js +++ b/src/apps/gameboard/static/apps/gameboard/natus-wheel.js @@ -62,6 +62,9 @@ const NatusWheel = (() => { 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. const ASPECT_COLORS = { Conjunction: 'var(--priYl, #f0e060)', @@ -88,6 +91,23 @@ const NatusWheel = (() => { // Ring radii (fractions of _r, set in _layout) 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 ─────────────────────────────────────────────────────────────── /** Convert ecliptic longitude to SVG angle. @@ -120,35 +140,186 @@ const NatusWheel = (() => { return ``; } - /** Position tooltip near cursor, clamped so it never overflows the viewport. */ - function _positionTooltip(tooltip, event) { - const margin = 8; - tooltip.style.display = 'block'; - const ttW = tooltip.offsetWidth; - const ttH = tooltip.offsetHeight; - let left = event.clientX + 14; - let top = event.clientY - 10; - if (left + ttW + margin > window.innerWidth) left = event.clientX - ttW - 14; - 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'; + + // ── Cycle helpers ───────────────────────────────────────────────────────── + + /** Build sorted planet list (ascending ecliptic degree) and element list. */ + function _buildCycleLists(data) { + _planetItems = Object.entries(data.planets) + .map(([name, p]) => ({ name, degree: p.degree })) + .sort((a, b) => b.degree - a.degree); // descending = clockwise on wheel + _elementItems = ELEMENT_ORDER.map(key => ({ key })); } - function _computeConjunctions(planets, threshold) { - threshold = threshold === undefined ? 8 : threshold; - const entries = Object.entries(planets); - const result = {}; - entries.forEach(([a, pa]) => { - 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); - } - }); + /** Clear all active-lock classes and reset cycle state. */ + function _clearActive() { + if (_svg) { + _svg.selectAll('.nw-planet-group').classed('nw-planet--active', false); + _svg.selectAll('.nw-element-group').classed('nw-element--active', false); + } + _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 = + `
${item.name} (${sym})
` + + `
@${inDeg}° ${pdata.sign} (${icon})${rx}
`; + } + _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 = + `
[${info.abbr}] ${info.name}
` + + `
${info.classical} · ${count} (${pct}%)
`; + } + _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 = + '
' + + '' + + ''; + _ttBody = _tooltipEl.querySelector('.nw-tt-body'); + _tooltipEl.querySelector('.nw-tt-prv').addEventListener('click', (e) => { + e.stopPropagation(); + _stepCycle(-1); }); - return result; + _tooltipEl.querySelector('.nw-tt-nxt').addEventListener('click', (e) => { + e.stopPropagation(); + _stepCycle(1); + }); + } + + /** 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) { @@ -317,75 +488,31 @@ const NatusWheel = (() => { const planetGroup = g.append('g').attr('class', 'nw-planets'); const ascAngle = _toAngle(asc, asc); // start position for animation - const conjuncts = _computeConjunctions(data.planets); const TICK_OUTER = _r * 0.96; Object.entries(data.planets).forEach(([name, pdata], idx) => { const finalA = _toAngle(pdata.degree, asc); const el = PLANET_ELEMENTS[name] || ''; - // Per-planet group — data attrs + hover events live here so the - // symbol text and ℞ indicator don't block mouse events on the circle. + // Per-planet group — click event lives here so the symbol text and ℞ + // indicator don't block mouse events on the circle. const planetEl = planetGroup.append('g') .attr('class', 'nw-planet-group') .attr('data-planet', name) .attr('data-sign', pdata.sign) .attr('data-degree', pdata.degree.toFixed(1)) .attr('data-retrograde', pdata.retrograde ? 'true' : 'false') - .on('mouseover', function (event) { - planetEl.raise(); - d3.select(this).classed('nw-planet--hover', true); - const tooltip = document.getElementById('id_natus_tooltip'); - if (!tooltip) return; - 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 = - `
${name} (${sym})
` + - `
@${inDeg}° ${pdata.sign} (${icon})${rx}
`; - _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 = - `
${pname} (${psym})
` + - `
@${pDeg}° ${pp.sign} (${picon})${prx}
`; - 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 { - tt2.style.display = 'none'; - } + .on('click', function (event) { + event.stopPropagation(); + const clickIdx = _planetItems.findIndex(p => p.name === name); + if (_activeRing === 'planets' && _activeIdx === clickIdx) { + _closeTooltip(); + } else { + _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') .attr('class', el ? `nw-planet-tick nw-planet-tick--${el}` : 'nw-planet-tick') .attr('x1', _cx + R.planetR * Math.cos(ascAngle)) @@ -401,7 +528,7 @@ const NatusWheel = (() => { .attr('r', _r * 0.05) .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') .attr('x', _cx + R.planetR * Math.cos(ascAngle)) .attr('y', _cy + R.planetR * Math.sin(ascAngle)) @@ -481,8 +608,6 @@ const NatusWheel = (() => { function _drawElements(g, data) { 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); if (total === 0) return; @@ -495,29 +620,18 @@ const NatusWheel = (() => { .attr('class', 'nw-elements') .attr('transform', `translate(${_cx},${_cy})`); - // Per-slice group: carries hover events + glow, arc path inside pie.forEach(slice => { - const info = ELEMENT_INFO[slice.data.key] || {}; - const sliceGroup = elGroup.append('g') .attr('class', 'nw-element-group') - .on('mouseover', function (event) { - d3.select(this).classed('nw-element--hover', true); - const tooltip = document.getElementById('id_natus_tooltip'); - if (!tooltip) return; - const count = slice.data.value; - const pct = total > 0 ? Math.round((count / total) * 100) : 0; - const elKey = slice.data.key.toLowerCase(); - tooltip.innerHTML = - `
[${info.abbr}] ${info.name}
` + - `
${info.classical} · ${count} (${pct}%)
`; - _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'; + .attr('data-element', slice.data.key) + .on('click', function (event) { + event.stopPropagation(); + const clickIdx = _elementItems.findIndex(e => e.key === slice.data.key); + if (_activeRing === 'elements' && _activeIdx === clickIdx) { + _closeTooltip(); + } else { + _activateElement(clickIdx); + } }); sliceGroup.append('path') @@ -564,6 +678,12 @@ const NatusWheel = (() => { _svg.selectAll('*').remove(); _layout(svgEl); + _currentData = data; + _closeTooltip(); + _buildCycleLists(data); + _injectTooltipControls(); + _attachOutsideClick(); + const g = _svg.append('g').attr('class', 'nw-root'); // Outer circle border @@ -592,6 +712,11 @@ const NatusWheel = (() => { function clear() { if (_svg) _svg.selectAll('*').remove(); + _closeTooltip(); + if (_outsideClickController) { + _outsideClickController.abort(); + _outsideClickController = null; + } } return { preload, draw, redraw, clear }; diff --git a/src/functional_tests/test_applet_my_sky.py b/src/functional_tests/test_applet_my_sky.py index 00a5e84..fe2c3a3 100644 --- a/src/functional_tests/test_applet_my_sky.py +++ b/src/functional_tests/test_applet_my_sky.py @@ -7,7 +7,6 @@ to their account (stored on the User model, independent of any game room). import json as _json -from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By 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): """When the user has saved sky data, the natal wheel appears in the My Sky - applet and the element-ring tooltip fires on hover. - (Planet hover tooltip is covered by NatusWheelSpec.js T3/T4/T5.)""" + applet and clicking an element-ring slice shows the tooltip. + (Planet click tooltip is covered by NatusWheelSpec.js T3/T4/T5.)""" self.create_pre_authenticated_session("stargazer@test.io") 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( 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.browser.find_element(By.ID, "id_natus_tooltip") .value_of_css_property("display"), @@ -325,6 +325,5 @@ class MySkyWheelConjunctionTest(FunctionalTest): 10, )) - # (T7 tick-extends-past-zodiac, T8 hover-raises-to-front, and T9 conjunction - # dual-tooltip are covered by NatusWheelSpec.js T7/T8/T9j — ActionChains - # planet-circle hover is unreliable in headless Firefox.) + # (T7 tick-extends-past-zodiac, T8 click-raises-to-front, and T9c/T9n/T9w + # cycle navigation are covered by NatusWheelSpec.js.) diff --git a/src/static/tests/NatusWheelSpec.js b/src/static/tests/NatusWheelSpec.js index bb75400..a3ba5f6 100644 --- a/src/static/tests/NatusWheelSpec.js +++ b/src/static/tests/NatusWheelSpec.js @@ -1,28 +1,26 @@ // ── 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: // — target for NatusWheel.draw() //
— tooltip portal (position:fixed on page) // -// Public API under test: -// NatusWheel.draw(svgEl, data) — renders wheel; attaches hover listeners -// NatusWheel.clear() — empties the SVG (used in afterEach) -// -// Hover contract: -// mouseover on [data-planet] group → adds .nw-planet--hover class -// shows #id_natus_tooltip with -// planet name, in-sign degree, sign name -// and ℞ if retrograde -// mouseout on [data-planet] group → removes .nw-planet--hover -// hides #id_natus_tooltip +// Click-lock contract: +// click on [data-planet] group → adds .nw-planet--active class +// raises group to DOM front +// shows #id_natus_tooltip with +// planet name, in-sign degree, sign name, +// ℞ if retrograde, and "n / total" index +// click same planet again → removes .nw-planet--active; hides 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) // // ───────────────────────────────────────────────────────────────────────────── -// 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 = { planets: { Sun: { sign: "Gemini", degree: 66.7, retrograde: false }, @@ -42,7 +40,7 @@ const CONJUNCTION_CHART = { house_system: "O", }; -describe("NatusWheel — planet tooltips", () => { +describe("NatusWheel — planet click tooltips", () => { const SYNTHETIC_CHART = { planets: { @@ -68,7 +66,6 @@ describe("NatusWheel — planet tooltips", () => { let svgEl, tooltipEl; beforeEach(() => { - // SVG element — D3 draws into this svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svgEl.setAttribute("id", "id_natus_svg"); svgEl.setAttribute("width", "400"); @@ -77,7 +74,6 @@ describe("NatusWheel — planet tooltips", () => { svgEl.style.height = "400px"; document.body.appendChild(svgEl); - // Tooltip portal — same markup as _natus_overlay.html tooltipEl = document.createElement("div"); tooltipEl.id = "id_natus_tooltip"; tooltipEl.className = "tt"; @@ -93,15 +89,15 @@ describe("NatusWheel — planet tooltips", () => { 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']"); 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"); const text = tooltipEl.textContent; @@ -113,39 +109,45 @@ describe("NatusWheel — planet tooltips", () => { // ── 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']"); 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.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']"); 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"); - // relatedTarget is document.body — outside the planet group - sun.dispatchEvent(new MouseEvent("mouseout", { - bubbles: true, - relatedTarget: document.body, - })); + sun.dispatchEvent(new MouseEvent("click", { bubbles: true })); 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(() => { svgEl2 = document.createElementNS("http://www.w3.org/2000/svg", "svg"); @@ -163,13 +165,6 @@ describe("NatusWheel — conjunction features", () => { tooltipEl.style.position = "fixed"; 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); }); @@ -177,10 +172,10 @@ describe("NatusWheel — conjunction features", () => { NatusWheel.clear(); svgEl2.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", () => { const tick = svgEl2.querySelector(".nw-planet-tick"); @@ -195,31 +190,195 @@ describe("NatusWheel — conjunction features", () => { 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 venus = svgEl2.querySelector("[data-planet='Venus']"); expect(sun).not.toBeNull("expected [data-planet='Sun']"); expect(venus).not.toBeNull("expected [data-planet='Venus']"); - sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body })); - venus.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body })); + sun.dispatchEvent(new MouseEvent("click", { bubbles: true })); + venus.dispatchEvent(new MouseEvent("click", { bubbles: true })); const groups = Array.from(svgEl2.querySelectorAll(".nw-planet-group")); 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']"); 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(tooltip2El.style.display).toBe("block"); - expect(tooltip2El.textContent).toContain("Venus"); + expect(tooltipEl.textContent).toContain("Sun"); + + 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); }); }); diff --git a/src/static_src/scss/_natus.scss b/src/static_src/scss/_natus.scss index 3f63983..8449f58 100644 --- a/src/static_src/scss/_natus.scss +++ b/src/static_src/scss/_natus.scss @@ -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-rx { fill: rgba(var(--terUser), 1); } -// Hover glow (--ninUser) — shared by planet groups and element slice groups -.nw-planet--hover, -.nw-element--hover { +// Hover and active-lock glow — planet groups and element slice groups +.nw-planet-group, +.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)); - cursor: pointer; } -// ── Planet tick lines ───────────────────────────────────────────────────────── +// ── Planet tick lines — hidden until parent group is active ────────────────── .nw-planet-tick { fill: none; - stroke-width: 3px; - stroke-opacity: 0.5; + stroke-width: 1px; + stroke-opacity: 0; 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--ag { stroke: rgba(var(--priAg), 1); } @@ -475,13 +486,22 @@ html.natus-open .natus-modal-wrap { #id_natus_tooltip_2 { position: fixed; z-index: 200; - pointer-events: none; + pointer-events: auto; padding: 0.75rem 1.5rem; .tt-title { font-size: 1rem; font-weight: 700; } .tt-description { font-size: 0.75rem; } .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 .tt-title--au { color: rgba(var(--sixAu), 1); } // Sun .tt-title--ag { color: rgba(var(--sixAg), 1); } // Moon diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss index d940b9a..f5a24b4 100644 --- a/src/static_src/scss/rootvars.scss +++ b/src/static_src/scss/rootvars.scss @@ -118,15 +118,15 @@ --sixPu: 235, 211, 217; /* Chroma Palette */ - // red + // red (A-Fire) --priRd: 233, 53, 37; --secRd: 193, 43, 28; --terRd: 155, 31, 15; - // orange + // orange (B-Fire) --priOr: 225, 133, 40; --secOr: 187, 111, 30; --terOr: 150, 88, 17; - // yellow + // yellow ( --priYl: 255, 207, 52; --secYl: 211, 172, 44; --terYl: 168, 138, 33; diff --git a/src/static_src/tests/NatusWheelSpec.js b/src/static_src/tests/NatusWheelSpec.js index bb75400..a3ba5f6 100644 --- a/src/static_src/tests/NatusWheelSpec.js +++ b/src/static_src/tests/NatusWheelSpec.js @@ -1,28 +1,26 @@ // ── 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: // — target for NatusWheel.draw() //
— tooltip portal (position:fixed on page) // -// Public API under test: -// NatusWheel.draw(svgEl, data) — renders wheel; attaches hover listeners -// NatusWheel.clear() — empties the SVG (used in afterEach) -// -// Hover contract: -// mouseover on [data-planet] group → adds .nw-planet--hover class -// shows #id_natus_tooltip with -// planet name, in-sign degree, sign name -// and ℞ if retrograde -// mouseout on [data-planet] group → removes .nw-planet--hover -// hides #id_natus_tooltip +// Click-lock contract: +// click on [data-planet] group → adds .nw-planet--active class +// raises group to DOM front +// shows #id_natus_tooltip with +// planet name, in-sign degree, sign name, +// ℞ if retrograde, and "n / total" index +// click same planet again → removes .nw-planet--active; hides 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) // // ───────────────────────────────────────────────────────────────────────────── -// 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 = { planets: { Sun: { sign: "Gemini", degree: 66.7, retrograde: false }, @@ -42,7 +40,7 @@ const CONJUNCTION_CHART = { house_system: "O", }; -describe("NatusWheel — planet tooltips", () => { +describe("NatusWheel — planet click tooltips", () => { const SYNTHETIC_CHART = { planets: { @@ -68,7 +66,6 @@ describe("NatusWheel — planet tooltips", () => { let svgEl, tooltipEl; beforeEach(() => { - // SVG element — D3 draws into this svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svgEl.setAttribute("id", "id_natus_svg"); svgEl.setAttribute("width", "400"); @@ -77,7 +74,6 @@ describe("NatusWheel — planet tooltips", () => { svgEl.style.height = "400px"; document.body.appendChild(svgEl); - // Tooltip portal — same markup as _natus_overlay.html tooltipEl = document.createElement("div"); tooltipEl.id = "id_natus_tooltip"; tooltipEl.className = "tt"; @@ -93,15 +89,15 @@ describe("NatusWheel — planet tooltips", () => { 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']"); 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"); const text = tooltipEl.textContent; @@ -113,39 +109,45 @@ describe("NatusWheel — planet tooltips", () => { // ── 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']"); 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.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']"); 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"); - // relatedTarget is document.body — outside the planet group - sun.dispatchEvent(new MouseEvent("mouseout", { - bubbles: true, - relatedTarget: document.body, - })); + sun.dispatchEvent(new MouseEvent("click", { bubbles: true })); 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(() => { svgEl2 = document.createElementNS("http://www.w3.org/2000/svg", "svg"); @@ -163,13 +165,6 @@ describe("NatusWheel — conjunction features", () => { tooltipEl.style.position = "fixed"; 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); }); @@ -177,10 +172,10 @@ describe("NatusWheel — conjunction features", () => { NatusWheel.clear(); svgEl2.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", () => { const tick = svgEl2.querySelector(".nw-planet-tick"); @@ -195,31 +190,195 @@ describe("NatusWheel — conjunction features", () => { 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 venus = svgEl2.querySelector("[data-planet='Venus']"); expect(sun).not.toBeNull("expected [data-planet='Sun']"); expect(venus).not.toBeNull("expected [data-planet='Venus']"); - sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body })); - venus.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body })); + sun.dispatchEvent(new MouseEvent("click", { bubbles: true })); + venus.dispatchEvent(new MouseEvent("click", { bubbles: true })); const groups = Array.from(svgEl2.querySelectorAll(".nw-planet-group")); 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']"); 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(tooltip2El.style.display).toBe("block"); - expect(tooltip2El.textContent).toContain("Venus"); + expect(tooltipEl.textContent).toContain("Sun"); + + 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); }); });