+ let _aspectIndex = {}; // planetName → [{partner, type, orb, applying_planet}]
+ let _aspectPlanet = null; // name of planet whose lines are currently drawn
+
+ // ── Static asset base path ────────────────────────────────────────────────
+
+ let _staticBase = null;
+
+ function _getStaticBase() {
+ if (_staticBase) return _staticBase;
+ const scripts = document.querySelectorAll('script[src]');
+ for (const s of scripts) {
+ if (s.src.includes('natus-wheel')) {
+ _staticBase = s.src.replace(/natus-wheel\.js.*$/, '');
+ return _staticBase;
+ }
+ }
+ _staticBase = '/static/apps/gameboard/';
+ return _staticBase;
+ }
+
// ── Helpers ───────────────────────────────────────────────────────────────
- /** Convert ecliptic longitude to SVG angle.
- *
- * American convention: ASC sits at 9 o'clock (left). SVG 0° is 3 o'clock
- * and increases clockwise, so ecliptic (counter-clockwise, ASC-relative)
- * maps to SVG via:
- * svg_angle = -(ecliptic - asc) - 180° (in radians)
- * The −180° offset places ASC exactly at the left (9 o'clock) position.
+ /**
+ * Normalise an element value that may be either a legacy flat integer
+ * (old stored chart_data) or the new enriched object {count, contributors?, …}.
+ * Always returns an object with at least a `count` key.
*/
+ function _elNorm(v) {
+ if (!v) return { count: 0 };
+ if (typeof v === 'number') return { count: v };
+ return v;
+ }
+
+ /** Convert ecliptic longitude to SVG angle (ASC at 9 o'clock). */
function _toAngle(degree, asc) {
return (-(degree - asc) - 180) * Math.PI / 180;
}
@@ -133,13 +184,46 @@ const NatusWheel = (() => {
return ((ecliptic % 360) + 360) % 360 % 30;
}
- /** Inline SVG for a zodiac sign, sized to 1em, using current text colour. */
+ /** Inline SVG for a zodiac sign icon (preloaded path). */
function _signIconSvg(signName) {
const d = _signPaths[signName];
if (!d) return '';
return ``;
}
+ /**
for an element-square badge (Ardor.svg etc.). */
+ function _elementSquareImg(elementKey) {
+ const info = ELEMENT_INFO[elementKey];
+ if (!info) return '';
+ const src = _getStaticBase() + 'icons/element-squares/' + info.name + '.svg';
+ return `
`;
+ }
+
+ /**
for an energy-vector icon (fire.svg, stone.svg etc.) — inline in text. */
+ function _elementVectorImg(elementKey) {
+ const info = ELEMENT_INFO[elementKey];
+ if (!info) return '';
+ const src = _getStaticBase() + 'icons/energy-vectors/' + info.classical + '.svg';
+ return `
`;
+ }
+
+ /** CSS color string for an aspect line, keyed to the applying planet. */
+ function _aspectColor(applying_planet) {
+ const code = PLANET_ELEMENTS[applying_planet];
+ if (!code) return '#888';
+ return `rgba(var(--asp-${code[0].toUpperCase() + code.slice(1)}), 0.85)`;
+ }
+
+ /** Small inline SVG showing the aspect line pattern — used in tooltip legend. */
+ function _aspectLineSvg(type, applying_planet) {
+ const style = ASPECT_STYLES[type] || { dash: 'none', width: 0.8 };
+ const color = _aspectColor(applying_planet);
+ const dash = style.dash === 'none' ? '' : ` stroke-dasharray="${style.dash}"`;
+ return ``;
+ }
+
// ── Cycle helpers ─────────────────────────────────────────────────────────
@@ -161,25 +245,61 @@ const NatusWheel = (() => {
_activeIdx = null;
}
- /** Dismiss tooltip and reset all active state. */
+ function _clearAspectLines() {
+ if (_aspectGroup) _aspectGroup.selectAll('*').remove();
+ if (_svg) _svg.selectAll('.nw-planet-group').classed('nw-planet--asp-active', false);
+ }
+
+ /** Dismiss tooltip and reset all active state. Aspect lines persist. */
function _closeTooltip() {
_clearActive();
if (_tooltipEl) _tooltipEl.style.display = 'none';
}
+ /**
+ * Draw aspect lines for `planetName` into the persistent nw-aspects group.
+ * Only runs when _aspectsVisible is true.
+ */
+ function _showPlanetAspects(planetName) {
+ if (!_aspectsVisible || !_aspectGroup || !_currentData) return;
+ _clearAspectLines();
+ _aspectPlanet = null;
+
+ const asc = _currentData.houses.asc;
+ const degrees = {};
+ Object.entries(_currentData.planets).forEach(([n, p]) => { degrees[n] = p.degree; });
+
+ const myDeg = degrees[planetName];
+ if (myDeg === undefined) return;
+ const a1 = _toAngle(myDeg, asc);
+
+ (_aspectIndex[planetName] || []).forEach(({ partner, type, applying_planet }) => {
+ const partnerDeg = degrees[partner];
+ if (partnerDeg === undefined) return;
+ const a2 = _toAngle(partnerDeg, asc);
+ const style = ASPECT_STYLES[type] || { dash: 'none', width: 0.8 };
+ const color = _aspectColor(applying_planet);
+
+ const line = _aspectGroup.append('line')
+ .attr('x1', _cx + R.planetR * Math.cos(a1))
+ .attr('y1', _cy + R.planetR * Math.sin(a1))
+ .attr('x2', _cx + R.planetR * Math.cos(a2))
+ .attr('y2', _cy + R.planetR * Math.sin(a2))
+ .attr('stroke', color)
+ .attr('stroke-width', style.width * 2)
+ .attr('stroke-opacity', 0.9);
+
+ if (style.dash !== 'none') line.attr('stroke-dasharray', style.dash);
+ });
+ _aspectPlanet = planetName;
+ if (_svg) {
+ _svg.select(`[data-planet="${planetName}"]`).classed('nw-planet--asp-active', true);
+ }
+ }
+
/**
* 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.
+ * clicked planet/element.
*/
function _positionTooltipAtItem(ring, idx) {
const svgNode = _svg ? _svg.node() : null;
@@ -194,7 +314,6 @@ const NatusWheel = (() => {
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;
@@ -211,13 +330,9 @@ const NatusWheel = (() => {
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
));
@@ -229,6 +344,12 @@ const NatusWheel = (() => {
/** Lock-activate a planet by cycle index. */
function _activatePlanet(idx) {
_clearActive();
+ // Aspect lines persist across planet switches — cleared only by DON or DOFF.
+ // Re-opening the same planet restores _aspectsVisible so DON shows as ×.
+ const item0 = _planetItems[idx];
+ if (item0.name !== _aspectPlanet) {
+ _aspectsVisible = false;
+ }
_activeRing = 'planets';
_activeIdx = idx;
const item = _planetItems[idx];
@@ -244,12 +365,41 @@ const NatusWheel = (() => {
const rx = pdata.retrograde ? ' ℞' : '';
const icon = _signIconSvg(pdata.sign) || signData.symbol || '';
+ // Aspect list — always shown in small font; lines on SVG toggled separately.
+ let aspectHtml = '';
+ const myAspects = _aspectIndex[item.name] || [];
+ if (myAspects.length) {
+ aspectHtml = '';
+ myAspects.forEach(({ partner, type, orb, applying_planet }) => {
+ const psym = PLANET_SYMBOLS[partner] || partner[0];
+ const ppdata = _currentData.planets[partner] || {};
+ const sicon = _signIconSvg(ppdata.sign) || (SIGNS.find(s => s.name === ppdata.sign) || {}).symbol || '';
+ const asym = ASPECT_SYMBOLS[type] || type;
+ const dirsym = applying_planet === item.name ? APPLY_SYM : SEP_SYM;
+ const lineSvg = _aspectLineSvg(type, applying_planet);
+ aspectHtml +=
+ `` +
+ `${lineSvg} ${asym} ${psym} in ${sicon}` +
+ ` (${dirsym} ${orb}°)` +
+ `
`;
+ });
+ aspectHtml += '';
+ }
+
if (_ttBody) {
_ttBody.innerHTML =
`${item.name} (${sym})
` +
- `@${inDeg}° ${pdata.sign} (${icon})${rx}
`;
+ `@${inDeg}° ${pdata.sign} (${icon})${rx}
` +
+ aspectHtml;
}
+
+ _updateAspectToggleUI();
+ _showPlanetAspects(item.name);
_positionTooltipAtItem('planets', idx);
+ if (_tooltipEl) {
+ _tooltipEl.querySelector('.nw-asp-don')?.style.removeProperty('display');
+ _tooltipEl.querySelector('.nw-asp-doff')?.style.removeProperty('display');
+ }
}
/** Lock-activate an element slice by cycle index. */
@@ -261,19 +411,100 @@ const NatusWheel = (() => {
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();
+ const info = ELEMENT_INFO[item.key] || {};
+ const elData = _elNorm((_currentData.elements || {})[item.key]);
+ const count = elData.count || 0;
+ const elKey = item.key.toLowerCase();
+ const squareImg = _elementSquareImg(item.key);
+ const vecImg = _elementVectorImg(item.key);
+
+ let bodyHtml = '';
+
+ const pct = Math.round(count / 10 * 100);
+
+ if (CLASSIC_ELEMENTS.has(item.key)) {
+ const contribs = elData.contributors || [];
+ bodyHtml = `${vecImg} +${count} (${pct}%)
`;
+ if (contribs.length) {
+ bodyHtml += '';
+ contribs.forEach(c => {
+ const psym = PLANET_SYMBOLS[c.planet] || c.planet[0];
+ const pdata = (_currentData.planets || {})[c.planet] || {};
+ const inDeg = pdata.degree !== undefined ? _inSignDeg(pdata.degree).toFixed(1) : '?';
+ const sicon = _signIconSvg(c.sign) || (SIGNS.find(s => s.name === c.sign) || {}).symbol || '';
+ bodyHtml += `
${psym} @ ${inDeg}° ${sicon} +1
`;
+ });
+ bodyHtml += '
';
+ }
+
+ } else if (item.key === 'Time') {
+ const stellia = elData.stellia || [];
+ bodyHtml = `${vecImg} +${count} (${pct}%)
`;
+ if (stellia.length) {
+ bodyHtml += '';
+ stellia.forEach(st => {
+ const bonus = st.planets.length - 1;
+ bodyHtml += ``;
+ st.planets.forEach(p => {
+ const psym = PLANET_SYMBOLS[p.planet] || p.planet[0];
+ const pdata = (_currentData.planets || {})[p.planet] || {};
+ const inDeg = pdata.degree !== undefined ? _inSignDeg(pdata.degree).toFixed(1) : '?';
+ const sicon = _signIconSvg(p.sign) || (SIGNS.find(s => s.name === p.sign) || {}).symbol || '';
+ bodyHtml += `
${psym} @ ${inDeg}° ${sicon}
`;
+ });
+ });
+ bodyHtml += '
';
+ } else {
+ bodyHtml += `—
`;
+ }
+
+ } else if (item.key === 'Space') {
+ const parades = elData.parades || [];
+ bodyHtml = `${vecImg} +${count} (${pct}%)
`;
+ if (parades.length) {
+ bodyHtml += '';
+ parades.forEach(pd => {
+ const bonus = pd.signs.length - 1;
+ bodyHtml += ``;
+ // Group planets by sign, sorted by ecliptic degree (counterclockwise = ascending)
+ const bySign = {};
+ pd.planets.forEach(p => {
+ if (!bySign[p.sign]) bySign[p.sign] = [];
+ bySign[p.sign].push(p);
+ });
+ Object.keys(bySign).forEach(sign => {
+ bySign[sign].sort((a, b) => {
+ const da = ((_currentData.planets || {})[a.planet] || {}).degree || 0;
+ const db = ((_currentData.planets || {})[b.planet] || {}).degree || 0;
+ return da - db;
+ });
+ });
+ pd.signs.forEach(sign => {
+ const planets = bySign[sign] || [];
+ const sicon = _signIconSvg(sign) || (SIGNS.find(s => s.name === sign) || {}).symbol || sign;
+ const psyms = planets.map(p => PLANET_SYMBOLS[p.planet] || p.planet[0]).join(' ');
+ bodyHtml += `
${sicon} (${psyms})
`;
+ });
+ });
+ bodyHtml += '
';
+ } else {
+ bodyHtml += `—
`;
+ }
+ }
if (_ttBody) {
_ttBody.innerHTML =
- `[${info.abbr}] ${info.name}
` +
- `${info.classical} · ${count} (${pct}%)
`;
+ `` +
+ bodyHtml;
}
_positionTooltipAtItem('elements', idx);
+ if (_tooltipEl) {
+ _tooltipEl.querySelector('.nw-asp-don')?.style.setProperty('display', 'none');
+ _tooltipEl.querySelector('.nw-asp-doff')?.style.setProperty('display', 'none');
+ }
}
/** Advance the active ring by +1 (NXT) or -1 (PRV). */
@@ -287,37 +518,75 @@ const NatusWheel = (() => {
}
}
+ function _updateAspectToggleUI() {
+ if (!_tooltipEl) return;
+ const don = _tooltipEl.querySelector('.nw-asp-don');
+ const doff = _tooltipEl.querySelector('.nw-asp-doff');
+ if (!don || !doff) return;
+ don.classList.toggle('btn-disabled', _aspectsVisible);
+ doff.classList.toggle('btn-disabled', !_aspectsVisible);
+ don.textContent = _aspectsVisible ? '×' : 'DON';
+ doff.textContent = !_aspectsVisible ? '×' : 'DOFF';
+ }
+
+ function _toggleAspects() {
+ _aspectsVisible = !_aspectsVisible;
+ _updateAspectToggleUI();
+ if (_aspectsVisible) {
+ if (_activeRing === 'planets' && _activeIdx !== null) {
+ _showPlanetAspects(_planetItems[_activeIdx].name);
+ }
+ } else {
+ _clearAspectLines();
+ _aspectPlanet = null;
+ }
+ }
+
/**
- * Inject PRV/idx/NXT controls into #id_natus_tooltip and wire their events.
+ * Inject PRV/NXT + DON|DOFF controls into the tooltip.
+ * DON|DOFF lives in the top-left corner; PRV/NXT float left/right.
* Called on every draw() so a fresh innerHTML replaces any stale state.
*/
function _injectTooltipControls() {
_tooltipEl = document.getElementById('id_natus_tooltip');
if (!_tooltipEl) return;
_tooltipEl.innerHTML =
+ `` +
+ `` +
'' +
'' +
'';
_ttBody = _tooltipEl.querySelector('.nw-tt-body');
_tooltipEl.querySelector('.nw-tt-prv').addEventListener('click', (e) => {
- e.stopPropagation();
- _stepCycle(-1);
+ e.stopPropagation(); _stepCycle(-1);
});
_tooltipEl.querySelector('.nw-tt-nxt').addEventListener('click', (e) => {
- e.stopPropagation();
- _stepCycle(1);
+ e.stopPropagation(); _stepCycle(1);
});
+ _tooltipEl.querySelector('.nw-asp-don')
+ .addEventListener('click', (e) => { e.stopPropagation(); _toggleAspects(); });
+ _tooltipEl.querySelector('.nw-asp-doff')
+ .addEventListener('click', (e) => { e.stopPropagation(); _toggleAspects(); });
+ // Sync button to current state in case of redraw mid-session.
+ _updateAspectToggleUI();
}
- /** 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. */
+ /** Document-level click listener that closes tooltip when clicking outside. */
function _attachOutsideClick() {
if (_outsideClickController) _outsideClickController.abort();
_outsideClickController = new AbortController();
document.addEventListener('click', (e) => {
if (_activeRing === null) return;
if (_tooltipEl && _tooltipEl.contains(e.target)) return;
+ // DON/DOFF have pointer-events:none when disabled — check bounding rect directly
+ if (_tooltipEl) {
+ for (const cls of ['.nw-asp-don', '.nw-asp-doff', '.nw-tt-prv', '.nw-tt-nxt']) {
+ const btn = _tooltipEl.querySelector(cls);
+ if (!btn) continue;
+ const r = btn.getBoundingClientRect();
+ if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) return;
+ }
+ }
_closeTooltip();
}, { signal: _outsideClickController.signal });
}
@@ -327,26 +596,24 @@ const NatusWheel = (() => {
const size = Math.min(rect.width || 400, rect.height || 400);
_cx = size / 2;
_cy = size / 2;
- // viewBox pins the coordinate system to size×size; preserveAspectRatio
- // centres it inside the SVG element regardless of its aspect ratio.
svgEl.setAttribute('viewBox', `0 0 ${size} ${size}`);
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
- _r = size * 0.46; // leave a small margin
+ _r = size * 0.46;
R = {
elementInner: _r * 0.20,
elementOuter: _r * 0.28,
- planetInner: _r * 0.32,
- planetOuter: _r * 0.48,
- houseInner: _r * 0.50,
- houseOuter: _r * 0.68,
+ planetInner: _r * 0.50,
+ planetOuter: _r * 0.68,
+ houseInner: _r * 0.32,
+ houseOuter: _r * 0.48,
signInner: _r * 0.70,
signOuter: _r * 0.90,
- labelR: _r * 0.80, // sign symbol placement
- houseNumR: _r * 0.59, // house number placement
- planetR: _r * 0.40, // planet symbol placement
- aspectR: _r * 0.29, // aspect lines end here (inner circle)
- ascMcR: _r * 0.92, // ASC/MC tick outer
+ labelR: _r * 0.80,
+ houseNumR: _r * 0.40,
+ planetR: _r * 0.59,
+ aspectR: _r * 0.29,
+ ascMcR: _r * 0.92,
};
}
@@ -389,12 +656,10 @@ const NatusWheel = (() => {
const sigGroup = g.append('g').attr('class', 'nw-signs');
SIGNS.forEach((sign, i) => {
- const startDeg = i * 30; // ecliptic 0–360
+ const startDeg = i * 30;
const endDeg = startDeg + 30;
const startA = _toAngle(startDeg, asc);
const endA = _toAngle(endDeg, asc);
- // D3 arc expects startAngle < endAngle in its own convention; we swap
- // because our _toAngle goes counter-clockwise
const [sa, ea] = startA > endA ? [endA, startA] : [startA, endA];
sigGroup.append('path')
@@ -407,22 +672,18 @@ const NatusWheel = (() => {
}))
.attr('class', `nw-sign--${sign.element.toLowerCase()}`);
- // Icon at midpoint
const midA = (sa + ea) / 2;
const lx = _cx + R.labelR * Math.cos(midA);
const ly = _cy + R.labelR * Math.sin(midA);
- const cr = _r * 0.065; // slightly larger than planet circles so icons breathe
- // scale the 640×640 icon viewBox down to 85% of the circle diameter
+ const cr = _r * 0.065;
const sf = (cr * 2 * 0.85) / 640;
- // Colored circle behind icon
sigGroup.append('circle')
.attr('cx', lx)
.attr('cy', ly)
.attr('r', cr)
.attr('class', `nw-sign-icon-bg--${sign.element.toLowerCase()}`);
- // Inline SVG path — translate origin to label centre, scale, re-centre icon
if (_signPaths[sign.name]) {
sigGroup.append('path')
.attr('d', _signPaths[sign.name])
@@ -438,19 +699,15 @@ const NatusWheel = (() => {
const arc = d3.arc();
const houseGroup = g.append('g').attr('class', 'nw-houses');
- // Pre-compute angles; normalise the last house's nextCusp across 360° wrap.
const houses = cusps.map((cusp, i) => {
let nextCusp = cusps[(i + 1) % 12];
- if (nextCusp <= cusp) nextCusp += 360; // close the circle for house 12
+ if (nextCusp <= cusp) nextCusp += 360;
const startA = _toAngle(cusp, asc);
const endA = _toAngle(nextCusp, asc);
- // _toAngle is strictly decreasing with degree after normalisation,
- // so startA > endA always — D3 arc needs sa < ea.
const sa = endA, ea = startA;
return { i, startA, sa, ea, midA: (sa + ea) / 2 };
});
- // 1. Fills first so cusp lines + numbers are never buried beneath them.
houses.forEach(({ i, sa, ea }) => {
houseGroup.append('path')
.attr('transform', `translate(${_cx},${_cy})`)
@@ -463,14 +720,15 @@ const NatusWheel = (() => {
.attr('class', i % 2 === 0 ? 'nw-house-fill--even' : 'nw-house-fill--odd');
});
- // 2. Cusp lines + house numbers on top.
houses.forEach(({ i, startA, midA }) => {
- houseGroup.append('line')
- .attr('x1', _cx + R.houseInner * Math.cos(startA))
- .attr('y1', _cy + R.houseInner * Math.sin(startA))
- .attr('x2', _cx + R.signInner * Math.cos(startA))
- .attr('y2', _cy + R.signInner * Math.sin(startA))
- .attr('class', 'nw-house-cusp');
+ if (i % 3 === 0) {
+ houseGroup.append('line')
+ .attr('x1', _cx + R.houseInner * Math.cos(startA))
+ .attr('y1', _cy + R.houseInner * Math.sin(startA))
+ .attr('x2', _cx + R.signInner * Math.cos(startA))
+ .attr('y2', _cy + R.signInner * Math.sin(startA))
+ .attr('class', 'nw-house-cusp');
+ }
houseGroup.append('text')
.attr('x', _cx + R.houseNumR * Math.cos(midA))
@@ -486,7 +744,7 @@ const NatusWheel = (() => {
function _drawPlanets(g, data) {
const asc = data.houses.asc;
const planetGroup = g.append('g').attr('class', 'nw-planets');
- const ascAngle = _toAngle(asc, asc); // start position for animation
+ const ascAngle = _toAngle(asc, asc);
const TICK_OUTER = _r * 0.96;
@@ -494,8 +752,6 @@ const NatusWheel = (() => {
const finalA = _toAngle(pdata.degree, asc);
const el = PLANET_ELEMENTS[name] || '';
- // 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)
@@ -512,7 +768,6 @@ const NatusWheel = (() => {
}
});
- // 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))
@@ -520,7 +775,6 @@ const NatusWheel = (() => {
.attr('x2', _cx + TICK_OUTER * Math.cos(ascAngle))
.attr('y2', _cy + TICK_OUTER * Math.sin(ascAngle));
- // Circle behind symbol
const circleBase = pdata.retrograde ? 'nw-planet-circle--rx' : 'nw-planet-circle';
const circle = planetEl.append('circle')
.attr('cx', _cx + R.planetR * Math.cos(ascAngle))
@@ -528,7 +782,6 @@ const NatusWheel = (() => {
.attr('r', _r * 0.05)
.attr('class', el ? `${circleBase} nw-planet--${el}` : circleBase);
- // 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))
@@ -540,7 +793,6 @@ const NatusWheel = (() => {
.attr('pointer-events', 'none')
.text(PLANET_SYMBOLS[name] || name[0]);
- // Retrograde indicator — also pointer-events:none
let rxLabel = null;
if (pdata.retrograde) {
rxLabel = planetEl.append('text')
@@ -554,8 +806,6 @@ const NatusWheel = (() => {
.text('℞');
}
- // Animate from ASC → final position (staggered)
- // circle uses cx/cy; text uses x/y — must be separate transitions.
const interpAngle = d3.interpolate(ascAngle, finalA);
const transition = () => d3.transition()
.delay(idx * 40)
@@ -584,34 +834,33 @@ const NatusWheel = (() => {
});
}
+ /**
+ * Build the aspect index (planet → aspects list) and create the persistent
+ * nw-aspects group. Lines are drawn per-planet on click, not here.
+ */
function _drawAspects(g, data) {
- const asc = data.houses.asc;
- const aspectGroup = g.append('g').attr('class', 'nw-aspects');
+ _aspectIndex = {};
+ Object.entries(data.planets).forEach(([name]) => { _aspectIndex[name] = []; });
- // Build degree lookup
- const degrees = {};
- Object.entries(data.planets).forEach(([name, p]) => { degrees[name] = p.degree; });
-
- data.aspects.forEach(({ planet1, planet2, type }) => {
- if (degrees[planet1] === undefined || degrees[planet2] === undefined) return;
- const a1 = _toAngle(degrees[planet1], asc);
- const a2 = _toAngle(degrees[planet2], asc);
- aspectGroup.append('line')
- .attr('x1', _cx + R.aspectR * Math.cos(a1))
- .attr('y1', _cy + R.aspectR * Math.sin(a1))
- .attr('x2', _cx + R.aspectR * Math.cos(a2))
- .attr('y2', _cy + R.aspectR * Math.sin(a2))
- .attr('stroke', ASPECT_COLORS[type] || '#888')
- .attr('stroke-width', type === 'Opposition' || type === 'Square' ? 1.2 : 0.8);
+ data.aspects.forEach(({ planet1, planet2, type, orb, applying_planet }) => {
+ if (!(planet1 in _aspectIndex) || !(planet2 in _aspectIndex)) return;
+ const shared = { type, orb, applying_planet };
+ _aspectIndex[planet1].push({ partner: planet2, ...shared });
+ _aspectIndex[planet2].push({ partner: planet1, ...shared });
});
+
+ _aspectGroup = g.append('g').attr('class', 'nw-aspects');
}
function _drawElements(g, data) {
- const el = data.elements;
- const total = ELEMENT_ORDER.reduce((s, k) => s + (el[k] || 0), 0);
+ const el = data.elements;
+ const total = ELEMENT_ORDER.reduce((s, k) => s + _elNorm(el[k]).count, 0);
if (total === 0) return;
- const pieData = ELEMENT_ORDER.map(k => ({ key: k, value: el[k] || 0 }));
+ const pieData = ELEMENT_ORDER.map(k => ({
+ key: k,
+ value: _elNorm(el[k]).count,
+ }));
const pie = d3.pie().value(d => d.value).sort(null)(pieData);
const arc = d3.arc().innerRadius(R.elementInner).outerRadius(R.elementOuter);
@@ -643,12 +892,8 @@ const NatusWheel = (() => {
// ── Public API ────────────────────────────────────────────────────────────
/**
- * Preload zodiac sign SVGs from `basePath` (default: same dir as this script).
- * Returns a Promise that resolves when all 12 are cached in _signPaths.
- * Safe to call multiple times; re-fetches every call so swapped files are picked up.
- *
- * To swap a sign icon: replace the corresponding .svg file in zodiac-signs/
- * and call NatusWheel.preload() before the next draw().
+ * Preload zodiac sign SVGs from `basePath`.
+ * Returns a Promise that resolves when all 12 are cached.
*/
async function preload(basePath) {
const base = basePath ||
@@ -686,18 +931,16 @@ const NatusWheel = (() => {
const g = _svg.append('g').attr('class', 'nw-root');
- // Outer circle border
g.append('circle')
.attr('cx', _cx).attr('cy', _cy).attr('r', R.signOuter)
.attr('class', 'nw-outer-ring');
- // Inner filled disc (aspect area background)
g.append('circle')
.attr('cx', _cx).attr('cy', _cy).attr('r', R.elementOuter)
.attr('class', 'nw-inner-disc');
- _drawAspects(g, data);
_drawElements(g, data);
+ _drawAspects(g, data); // above element ring, below houses/signs/planets
_drawHouses(g, data);
_drawSigns(g, data);
_drawAscMc(g, data);
@@ -706,8 +949,7 @@ const NatusWheel = (() => {
function redraw(data) {
if (!_svg) return;
- const svgNode = _svg.node();
- draw(svgNode, data);
+ draw(_svg.node(), data);
}
function clear() {
@@ -717,6 +959,10 @@ const NatusWheel = (() => {
_outsideClickController.abort();
_outsideClickController = null;
}
+ _aspectsVisible = false;
+ _aspectPlanet = null;
+ _aspectGroup = null;
+ _currentData = null;
}
return { preload, draw, redraw, clear };
diff --git a/src/static/tests/NatusWheelSpec.js b/src/static/tests/NatusWheelSpec.js
index a3ba5f6..04a2f68 100644
--- a/src/static/tests/NatusWheelSpec.js
+++ b/src/static/tests/NatusWheelSpec.js
@@ -282,6 +282,155 @@ describe("NatusWheel — tick lines, raise, and cycle navigation", () => {
// x ≥ 200 (right side): left = iRect.right - ttW = x + 10 - 0 = x + 10
// ─────────────────────────────────────────────────────────────────────────────
+// ── DON / DOFF aspect line persistence ───────────────────────────────────────
+//
+// Aspect lines belong to the page session, not the tooltip:
+// - DON draws lines into .nw-aspects and disables DON btn (shows ×)
+// - closing the tooltip does NOT clear lines
+// - re-opening the SAME planet preserves _aspectsVisible → DON still disabled
+// - opening a DIFFERENT planet resets state: lines cleared, DON active
+// - DOFF clears lines; re-opening same planet finds DON active
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe("NatusWheel — DON/DOFF aspect line persistence", () => {
+
+ const ASPECT_CHART = {
+ planets: {
+ Sun: { sign: "Capricorn", degree: 280.4, retrograde: false },
+ Moon: { sign: "Scorpio", degree: 220.1, retrograde: false },
+ Mars: { sign: "Taurus", degree: 40.7, retrograde: false },
+ },
+ houses: {
+ cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
+ asc: 0, mc: 270,
+ },
+ elements: { Fire: 0, Stone: 0, Air: 0, Water: 1, Time: 0, Space: 0 },
+ aspects: [
+ { planet1: "Sun", planet2: "Mars", type: "Trine",
+ orb: 0.3, angle: 120, applying_planet: "Sun" },
+ { planet1: "Sun", planet2: "Moon", type: "Sextile",
+ orb: 2.9, angle: 60, applying_planet: "Moon" },
+ ],
+ distinctions: {
+ "1": 0, "2": 0, "3": 0, "4": 0,
+ "5": 0, "6": 0, "7": 0, "8": 0,
+ "9": 0, "10": 0, "11": 0, "12": 0,
+ },
+ house_system: "O",
+ };
+
+ let svgEl, tooltipEl;
+
+ beforeEach(() => {
+ svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svgEl.setAttribute("id", "id_natus_svg");
+ svgEl.setAttribute("width", "400");
+ svgEl.setAttribute("height", "400");
+ svgEl.style.width = "400px";
+ svgEl.style.height = "400px";
+ document.body.appendChild(svgEl);
+
+ tooltipEl = document.createElement("div");
+ tooltipEl.id = "id_natus_tooltip";
+ tooltipEl.className = "tt";
+ tooltipEl.style.display = "none";
+ document.body.appendChild(tooltipEl);
+
+ NatusWheel.draw(svgEl, ASPECT_CHART);
+ });
+
+ afterEach(() => {
+ NatusWheel.clear();
+ svgEl.remove();
+ tooltipEl.remove();
+ });
+
+ function clickPlanet(name) {
+ svgEl.querySelector(`[data-planet="${name}"]`)
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ }
+ function clickDon() { tooltipEl.querySelector(".nw-asp-don") .dispatchEvent(new MouseEvent("click", { bubbles: true })); }
+ function clickDoff() { tooltipEl.querySelector(".nw-asp-doff").dispatchEvent(new MouseEvent("click", { bubbles: true })); }
+ function aspectLines() { return svgEl.querySelectorAll(".nw-aspects line").length; }
+ function donDisabled() { return tooltipEl.querySelector(".nw-asp-don").classList.contains("btn-disabled"); }
+
+ // T11a — DON draws lines
+ it("T11a: clicking DON draws aspect lines into .nw-aspects", () => {
+ clickPlanet("Sun");
+ expect(aspectLines()).toBe(0);
+ clickDon();
+ expect(aspectLines()).toBeGreaterThan(0);
+ });
+
+ // T11b — closing tooltip must not clear aspect lines
+ it("T11b: closing the tooltip (outside click) does not clear aspect lines", () => {
+ clickPlanet("Sun");
+ clickDon();
+ const lineCount = aspectLines();
+ expect(lineCount).toBeGreaterThan(0);
+
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ expect(tooltipEl.style.display).toBe("none");
+ expect(aspectLines()).toBe(lineCount);
+ });
+
+ // T11c — re-opening same planet preserves DON-disabled state
+ it("T11c: re-opening the same planet after DON keeps DON disabled (lines still active)", () => {
+ clickPlanet("Sun");
+ clickDon();
+ expect(donDisabled()).toBe(true);
+
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ clickPlanet("Sun");
+
+ expect(donDisabled()).toBe(true);
+ expect(aspectLines()).toBeGreaterThan(0);
+ });
+
+ // T11d — switching planet leaves previous DONned lines intact; DON active for new planet
+ it("T11d: opening a different planet leaves DONned lines intact — DON active for new planet", () => {
+ clickPlanet("Sun");
+ clickDon();
+ const lineCount = aspectLines();
+ expect(lineCount).toBeGreaterThan(0);
+
+ clickPlanet("Moon");
+
+ expect(donDisabled()).toBe(false); // Moon's DON is fresh/active
+ expect(aspectLines()).toBe(lineCount); // Sun's lines still there
+ });
+
+ // T11f — DONning a second planet replaces the first planet's lines + tick
+ it("T11f: clicking DON on a second planet clears the first planet's lines", () => {
+ clickPlanet("Sun");
+ clickDon();
+ expect(aspectLines()).toBeGreaterThan(0);
+
+ clickPlanet("Moon");
+ clickDon();
+
+ expect(donDisabled()).toBe(true); // Moon's DON now disabled
+ // Moon aspects — Sun's lines replaced (lines may be 0 if Moon has no aspects)
+ const sunGrp = svgEl.querySelector('[data-planet="Sun"]');
+ expect(sunGrp.classList.contains('nw-planet--asp-active')).toBe(false);
+ });
+
+ // T11e — DOFF clears lines; re-opening same planet starts fresh
+ it("T11e: DOFF clears lines; re-opening same planet finds DON active again", () => {
+ clickPlanet("Sun");
+ clickDon();
+ clickDoff();
+ expect(aspectLines()).toBe(0);
+
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ clickPlanet("Sun");
+
+ expect(donDisabled()).toBe(false);
+ expect(aspectLines()).toBe(0);
+ });
+});
+
xdescribe("NatusWheel — half-wheel tooltip positioning", () => {
const HALF_CHART = {
diff --git a/src/static_src/scss/_natus.scss b/src/static_src/scss/_natus.scss
index 8449f58..9ae330d 100644
--- a/src/static_src/scss/_natus.scss
+++ b/src/static_src/scss/_natus.scss
@@ -450,7 +450,8 @@ html.natus-open .natus-modal-wrap {
stroke-linecap: round;
transition: stroke-opacity 0.15s ease;
}
-.nw-planet-group.nw-planet--active .nw-planet-tick {
+.nw-planet-group.nw-planet--active .nw-planet-tick,
+.nw-planet-group.nw-planet--asp-active .nw-planet-tick {
stroke: rgba(var(--terUser), 1);
stroke-opacity: 0.7;
filter: drop-shadow(0 0 3px rgba(var(--terUser), 0.8))
@@ -467,7 +468,21 @@ html.natus-open .natus-modal-wrap {
.nw-planet-tick--np { stroke: rgba(var(--priNp), 1); }
.nw-planet-tick--pu { stroke: rgba(var(--priPu), 1); }
-// Aspects
+// Aspects — per-planet color tokens (light shades on dark palettes; mid on light)
+:root {
+ --asp-Au: var(--sixAu); --asp-Ag: var(--sixAg);
+ --asp-Hg: var(--sixHg); --asp-Cu: var(--sixCu);
+ --asp-Fe: var(--sixFe); --asp-Sn: var(--sixSn);
+ --asp-Pb: var(--sixPb); --asp-U: var(--sixU);
+ --asp-Np: var(--sixNp); --asp-Pu: var(--sixPu);
+}
+body[class*="-light"] {
+ --asp-Au: var(--terAu); --asp-Ag: var(--terAg);
+ --asp-Hg: var(--terHg); --asp-Cu: var(--terCu);
+ --asp-Fe: var(--terFe); --asp-Sn: var(--terSn);
+ --asp-Pb: var(--terPb); --asp-U: var(--terU);
+ --asp-Np: var(--terNp); --asp-Pu: var(--terPu);
+}
.nw-aspects { opacity: 0.8; }
// Element pie — deasil order: Fire → Stone → Time → Space → Air → Water
@@ -487,12 +502,24 @@ html.natus-open .natus-modal-wrap {
position: fixed;
z-index: 200;
pointer-events: auto;
- padding: 0.75rem 1.5rem;
+ padding: 0.75rem 0.75rem 0.75rem 1.5rem;
+ min-width: 14rem;
- .tt-title { font-size: 1rem; font-weight: 700; }
+ .tt-title { font-size: 1.25rem; font-weight: 700; margin-bottom: 0.3rem; }
.tt-description { font-size: 0.75rem; }
.tt-sign-icon { fill: currentColor; vertical-align: middle; margin-bottom: 0.1em; }
+ // DON|DOFF aspect line toggle — stacked at top-left outside the tooltip box,
+ // matching the PRV/NXT pattern at the bottom corners.
+ .nw-asp-don,
+ .nw-asp-doff {
+ position: absolute;
+ left: -1rem;
+ margin: 0;
+ }
+ .nw-asp-don { top: -1rem; }
+ .nw-asp-doff { top: 1.2rem; }
+
.nw-tt-prv,
.nw-tt-nxt {
position: absolute;
@@ -502,6 +529,82 @@ html.natus-open .natus-modal-wrap {
.nw-tt-prv { left: -1rem; }
.nw-tt-nxt { right: -1rem; }
+ // Aspect list — always visible below planet description
+ .tt-aspects {
+ display: block;
+ margin-top: 0.6rem;
+ font-size: 0.94rem;
+ font-weight: 600;
+ opacity: 0.85;
+ line-height: 1.3;
+ }
+
+ .tt-asp-row {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+ white-space: nowrap;
+ }
+
+ .tt-asp-line { flex-shrink: 0; vertical-align: middle; }
+
+ .tt-asp-orb,
+ .tt-asp-in {
+ opacity: 0.6;
+ font-size: 0.65rem;
+ padding-left: 0.25rem;
+ }
+
+ // Element tooltip — title + square badge
+ .tt-el-header {
+ margin-bottom: 0.3rem;
+ }
+
+ .tt-el-square {
+ float: right;
+ display: block;
+ margin-left: 0.5rem;
+ }
+
+ .tt-el-vec {
+ display: inline;
+ vertical-align: middle;
+ margin: 0 0.1em;
+ }
+
+ .tt-el-body-line {
+ font-size: 0.75rem;
+ opacity: 0.9;
+ margin-bottom: 0.25rem;
+ }
+
+ .tt-el-contribs {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ margin-top: 0.3rem;
+ }
+
+ .tt-el-planet-row {
+ opacity: 0.75;
+ margin-left: 0.5rem;
+ margin-top: -0.1rem;
+ }
+
+ .tt-el-formation-header {
+ font-size: 0.85rem;
+ font-weight: 600;
+ text-decoration: underline;
+ margin-top: 0.35rem;
+ }
+
+ .tt-el-formation {
+ font-size: 0.75rem;
+ opacity: 0.7;
+ margin-top: 0.2rem;
+ font-style: italic;
+ }
+
// Planet title colors — senary (brightest) tier on dark palettes
.tt-title--au { color: rgba(var(--sixAu), 1); } // Sun
.tt-title--ag { color: rgba(var(--sixAg), 1); } // Moon
diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss
index f5a24b4..f94bb1a 100644
--- a/src/static_src/scss/rootvars.scss
+++ b/src/static_src/scss/rootvars.scss
@@ -126,49 +126,49 @@
--priOr: 225, 133, 40;
--secOr: 187, 111, 30;
--terOr: 150, 88, 17;
- // yellow (
+ // yellow (A-Time)
--priYl: 255, 207, 52;
--secYl: 211, 172, 44;
--terYl: 168, 138, 33;
- // lime
+ // lime (B-Time)
--priLm: 151, 174, 60;
--secLm: 124, 145, 48;
--terLm: 97, 117, 36;
- // green
+ // green (A-Space)
--priGn: 0, 160, 75;
--secGn: 0, 135, 62;
--terGn: 0, 109, 48;
- // teal
+ // teal (B-Space)
--priTk: 0, 184, 162;
--secTk: 0, 154, 136;
--terTk: 0, 125, 110;
- // cyan
+ // cyan (A-Air)
--priCy: 13, 179, 200;
--secCy: 12, 150, 168;
--terCy: 0, 121, 136;
- // blue
+ // blue (B-Air)
--priBl: 20, 141, 205;
--secBl: 18, 119, 173;
--terBl: 8, 95, 140;
- // indigo
+ // indigo (A-Water)
--priId: 79, 102, 212;
--secId: 66, 88, 184;
--terId: 53, 74, 156;
--quaId: 44, 60, 131;
--quiId: 32, 44, 106;
--sixId: 21, 29, 71;
- // violet
+ // violet (B-Water)
--priVt: 120, 72, 183;
--secVt: 108, 65, 165;
--terVt: 96, 58, 147;
--quaVt: 80, 45, 124;
--quiVt: 64, 30, 100;
--sixVt: 43, 20, 66;
- // fuschia
+ // fuschia (A-Stone)
--priFs: 158, 61, 150;
--secFs: 133, 47, 126;
--terFs: 107, 31, 101;
- // magenta
+ // magenta (B-Stone)
--priMe: 237, 30, 129;
--secMe: 196, 18, 108;
--terMe: 158, 1, 86;
diff --git a/src/static_src/tests/NatusWheelSpec.js b/src/static_src/tests/NatusWheelSpec.js
index a3ba5f6..04a2f68 100644
--- a/src/static_src/tests/NatusWheelSpec.js
+++ b/src/static_src/tests/NatusWheelSpec.js
@@ -282,6 +282,155 @@ describe("NatusWheel — tick lines, raise, and cycle navigation", () => {
// x ≥ 200 (right side): left = iRect.right - ttW = x + 10 - 0 = x + 10
// ─────────────────────────────────────────────────────────────────────────────
+// ── DON / DOFF aspect line persistence ───────────────────────────────────────
+//
+// Aspect lines belong to the page session, not the tooltip:
+// - DON draws lines into .nw-aspects and disables DON btn (shows ×)
+// - closing the tooltip does NOT clear lines
+// - re-opening the SAME planet preserves _aspectsVisible → DON still disabled
+// - opening a DIFFERENT planet resets state: lines cleared, DON active
+// - DOFF clears lines; re-opening same planet finds DON active
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe("NatusWheel — DON/DOFF aspect line persistence", () => {
+
+ const ASPECT_CHART = {
+ planets: {
+ Sun: { sign: "Capricorn", degree: 280.4, retrograde: false },
+ Moon: { sign: "Scorpio", degree: 220.1, retrograde: false },
+ Mars: { sign: "Taurus", degree: 40.7, retrograde: false },
+ },
+ houses: {
+ cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
+ asc: 0, mc: 270,
+ },
+ elements: { Fire: 0, Stone: 0, Air: 0, Water: 1, Time: 0, Space: 0 },
+ aspects: [
+ { planet1: "Sun", planet2: "Mars", type: "Trine",
+ orb: 0.3, angle: 120, applying_planet: "Sun" },
+ { planet1: "Sun", planet2: "Moon", type: "Sextile",
+ orb: 2.9, angle: 60, applying_planet: "Moon" },
+ ],
+ distinctions: {
+ "1": 0, "2": 0, "3": 0, "4": 0,
+ "5": 0, "6": 0, "7": 0, "8": 0,
+ "9": 0, "10": 0, "11": 0, "12": 0,
+ },
+ house_system: "O",
+ };
+
+ let svgEl, tooltipEl;
+
+ beforeEach(() => {
+ svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svgEl.setAttribute("id", "id_natus_svg");
+ svgEl.setAttribute("width", "400");
+ svgEl.setAttribute("height", "400");
+ svgEl.style.width = "400px";
+ svgEl.style.height = "400px";
+ document.body.appendChild(svgEl);
+
+ tooltipEl = document.createElement("div");
+ tooltipEl.id = "id_natus_tooltip";
+ tooltipEl.className = "tt";
+ tooltipEl.style.display = "none";
+ document.body.appendChild(tooltipEl);
+
+ NatusWheel.draw(svgEl, ASPECT_CHART);
+ });
+
+ afterEach(() => {
+ NatusWheel.clear();
+ svgEl.remove();
+ tooltipEl.remove();
+ });
+
+ function clickPlanet(name) {
+ svgEl.querySelector(`[data-planet="${name}"]`)
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ }
+ function clickDon() { tooltipEl.querySelector(".nw-asp-don") .dispatchEvent(new MouseEvent("click", { bubbles: true })); }
+ function clickDoff() { tooltipEl.querySelector(".nw-asp-doff").dispatchEvent(new MouseEvent("click", { bubbles: true })); }
+ function aspectLines() { return svgEl.querySelectorAll(".nw-aspects line").length; }
+ function donDisabled() { return tooltipEl.querySelector(".nw-asp-don").classList.contains("btn-disabled"); }
+
+ // T11a — DON draws lines
+ it("T11a: clicking DON draws aspect lines into .nw-aspects", () => {
+ clickPlanet("Sun");
+ expect(aspectLines()).toBe(0);
+ clickDon();
+ expect(aspectLines()).toBeGreaterThan(0);
+ });
+
+ // T11b — closing tooltip must not clear aspect lines
+ it("T11b: closing the tooltip (outside click) does not clear aspect lines", () => {
+ clickPlanet("Sun");
+ clickDon();
+ const lineCount = aspectLines();
+ expect(lineCount).toBeGreaterThan(0);
+
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ expect(tooltipEl.style.display).toBe("none");
+ expect(aspectLines()).toBe(lineCount);
+ });
+
+ // T11c — re-opening same planet preserves DON-disabled state
+ it("T11c: re-opening the same planet after DON keeps DON disabled (lines still active)", () => {
+ clickPlanet("Sun");
+ clickDon();
+ expect(donDisabled()).toBe(true);
+
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ clickPlanet("Sun");
+
+ expect(donDisabled()).toBe(true);
+ expect(aspectLines()).toBeGreaterThan(0);
+ });
+
+ // T11d — switching planet leaves previous DONned lines intact; DON active for new planet
+ it("T11d: opening a different planet leaves DONned lines intact — DON active for new planet", () => {
+ clickPlanet("Sun");
+ clickDon();
+ const lineCount = aspectLines();
+ expect(lineCount).toBeGreaterThan(0);
+
+ clickPlanet("Moon");
+
+ expect(donDisabled()).toBe(false); // Moon's DON is fresh/active
+ expect(aspectLines()).toBe(lineCount); // Sun's lines still there
+ });
+
+ // T11f — DONning a second planet replaces the first planet's lines + tick
+ it("T11f: clicking DON on a second planet clears the first planet's lines", () => {
+ clickPlanet("Sun");
+ clickDon();
+ expect(aspectLines()).toBeGreaterThan(0);
+
+ clickPlanet("Moon");
+ clickDon();
+
+ expect(donDisabled()).toBe(true); // Moon's DON now disabled
+ // Moon aspects — Sun's lines replaced (lines may be 0 if Moon has no aspects)
+ const sunGrp = svgEl.querySelector('[data-planet="Sun"]');
+ expect(sunGrp.classList.contains('nw-planet--asp-active')).toBe(false);
+ });
+
+ // T11e — DOFF clears lines; re-opening same planet starts fresh
+ it("T11e: DOFF clears lines; re-opening same planet finds DON active again", () => {
+ clickPlanet("Sun");
+ clickDon();
+ clickDoff();
+ expect(aspectLines()).toBe(0);
+
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ clickPlanet("Sun");
+
+ expect(donDisabled()).toBe(false);
+ expect(aspectLines()).toBe(0);
+ });
+});
+
xdescribe("NatusWheel — half-wheel tooltip positioning", () => {
const HALF_CHART = {