NATUS WHEEL: half-wheel tooltip positioning + click-outside fix — TDD
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:
@@ -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 };
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user