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);
|
||||
});
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user