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);
});
return result;
_tooltipEl.querySelector('.nw-tt-nxt').addEventListener('click', (e) => {
e.stopPropagation();
_stepCycle(1);
});
}
/** Attach a document-level click listener that closes the tooltip when the
* user clicks outside the tooltip (including on empty wheel areas).
* Planet/element groups stop propagation so their own clicks are not caught. */
function _attachOutsideClick() {
if (_outsideClickController) _outsideClickController.abort();
_outsideClickController = new AbortController();
document.addEventListener('click', (e) => {
if (_activeRing === null) return;
if (_tooltipEl && _tooltipEl.contains(e.target)) return;
_closeTooltip();
}, { signal: _outsideClickController.signal });
}
function _layout(svgEl) {
@@ -317,75 +488,31 @@ const NatusWheel = (() => {
const planetGroup = g.append('g').attr('class', 'nw-planets');
const ascAngle = _toAngle(asc, asc); // start position for animation
const conjuncts = _computeConjunctions(data.planets);
const TICK_OUTER = _r * 0.96;
Object.entries(data.planets).forEach(([name, pdata], idx) => {
const finalA = _toAngle(pdata.degree, asc);
const el = PLANET_ELEMENTS[name] || '';
// Per-planet group — data attrs + hover events live here so the
// symbol text and ℞ indicator don't block mouse events on the circle.
// Per-planet group — click event lives here so the symbol text and ℞
// indicator don't block mouse events on the circle.
const planetEl = planetGroup.append('g')
.attr('class', 'nw-planet-group')
.attr('data-planet', name)
.attr('data-sign', pdata.sign)
.attr('data-degree', pdata.degree.toFixed(1))
.attr('data-retrograde', pdata.retrograde ? 'true' : 'false')
.on('mouseover', function (event) {
planetEl.raise();
d3.select(this).classed('nw-planet--hover', true);
const tooltip = document.getElementById('id_natus_tooltip');
if (!tooltip) return;
const sym = PLANET_SYMBOLS[name] || name[0];
const signData = SIGNS.find(s => s.name === pdata.sign) || {};
const inDeg = _inSignDeg(pdata.degree).toFixed(1);
const rx = pdata.retrograde ? ' ℞' : '';
const icon = _signIconSvg(pdata.sign) || signData.symbol || '';
tooltip.innerHTML =
`<div class="tt-title tt-title--${el}">${name} (${sym})</div>` +
`<div class="tt-description">@${inDeg}° ${pdata.sign} (${icon})${rx}</div>`;
_positionTooltip(tooltip, event);
const tt2 = document.getElementById('id_natus_tooltip_2');
if (tt2) {
const partners = conjuncts[name];
if (partners && partners.length) {
const pname = partners[0];
const pp = data.planets[pname];
const pel = PLANET_ELEMENTS[pname] || '';
const psym = PLANET_SYMBOLS[pname] || pname[0];
const psd = SIGNS.find(s => s.name === pp.sign) || {};
const picon = _signIconSvg(pp.sign) || psd.symbol || '';
const prx = pp.retrograde ? ' ℞' : '';
const pDeg = _inSignDeg(pp.degree).toFixed(1);
tt2.innerHTML =
`<div class="tt-title tt-title--${pel}">${pname} (${psym})</div>` +
`<div class="tt-description">@${pDeg}° ${pp.sign} (${picon})${prx}</div>`;
tt2.style.display = 'block';
const gap = 8;
const tt1W = tooltip.offsetWidth;
const tt2W = tt2.offsetWidth;
let left2 = parseFloat(tooltip.style.left) + tt1W + gap;
if (left2 + tt2W + gap > window.innerWidth)
left2 = parseFloat(tooltip.style.left) - tt2W - gap;
tt2.style.left = Math.max(gap, left2) + 'px';
tt2.style.top = tooltip.style.top;
} else {
tt2.style.display = 'none';
}
.on('click', function (event) {
event.stopPropagation();
const clickIdx = _planetItems.findIndex(p => p.name === name);
if (_activeRing === 'planets' && _activeIdx === clickIdx) {
_closeTooltip();
} else {
_activatePlanet(clickIdx);
}
})
.on('mouseout', function (event) {
if (planetEl.node().contains(event.relatedTarget)) return;
d3.select(this).classed('nw-planet--hover', false);
const tooltip = document.getElementById('id_natus_tooltip');
if (tooltip) tooltip.style.display = 'none';
const tt2 = document.getElementById('id_natus_tooltip_2');
if (tt2) tt2.style.display = 'none';
});
// Tick line — from planet circle outward past the zodiac ring; part of hover group
// Tick line — from planet circle outward past the zodiac ring
const tick = planetEl.append('line')
.attr('class', el ? `nw-planet-tick nw-planet-tick--${el}` : 'nw-planet-tick')
.attr('x1', _cx + R.planetR * Math.cos(ascAngle))
@@ -401,7 +528,7 @@ const NatusWheel = (() => {
.attr('r', _r * 0.05)
.attr('class', el ? `${circleBase} nw-planet--${el}` : circleBase);
// Symbol — pointer-events:none so hover is handled by the group
// Symbol — pointer-events:none so click is handled by the group
const label = planetEl.append('text')
.attr('x', _cx + R.planetR * Math.cos(ascAngle))
.attr('y', _cy + R.planetR * Math.sin(ascAngle))
@@ -481,8 +608,6 @@ const NatusWheel = (() => {
function _drawElements(g, data) {
const el = data.elements;
// Deasil order: Fire → Stone → Time → Space → Air → Water
const ELEMENT_ORDER = ['Fire', 'Stone', 'Time', 'Space', 'Air', 'Water'];
const total = ELEMENT_ORDER.reduce((s, k) => s + (el[k] || 0), 0);
if (total === 0) return;
@@ -495,29 +620,18 @@ const NatusWheel = (() => {
.attr('class', 'nw-elements')
.attr('transform', `translate(${_cx},${_cy})`);
// Per-slice group: carries hover events + glow, arc path inside
pie.forEach(slice => {
const info = ELEMENT_INFO[slice.data.key] || {};
const sliceGroup = elGroup.append('g')
.attr('class', 'nw-element-group')
.on('mouseover', function (event) {
d3.select(this).classed('nw-element--hover', true);
const tooltip = document.getElementById('id_natus_tooltip');
if (!tooltip) return;
const count = slice.data.value;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
const elKey = slice.data.key.toLowerCase();
tooltip.innerHTML =
`<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 };