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' },
};
// 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 `<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) {
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 =
`<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) {
@@ -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 =
`<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;
.on('click', function (event) {
event.stopPropagation();
const clickIdx = _planetItems.findIndex(p => p.name === name);
if (_activeRing === 'planets' && _activeIdx === clickIdx) {
_closeTooltip();
} 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')
.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 =
`<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';
.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 };

View File

@@ -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.)

View File

@@ -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:
// <svg id="id_natus_svg"> — target for NatusWheel.draw()
// <div id="id_natus_tooltip"> — 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
// 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
// and ℞ if retrograde
// mouseout on [data-planet] group → removes .nw-planet--hover
// hides #id_natus_tooltip
// 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);
});
});

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-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

View File

@@ -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;

View File

@@ -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:
// <svg id="id_natus_svg"> — target for NatusWheel.draw()
// <div id="id_natus_tooltip"> — 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
// 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
// and ℞ if retrograde
// mouseout on [data-planet] group → removes .nw-planet--hover
// hides #id_natus_tooltip
// 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);
});
});