From ea2bfa6ce10770a5f88e20510f4074254a4d7810 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 21 Apr 2026 03:17:20 -0400 Subject: [PATCH] =?UTF-8?q?natus=20wheel:=20aspect=20line=20system,=20elem?= =?UTF-8?q?ent=20tooltip=20overhaul,=20ring/spoke=20changes=20=E2=80=94=20?= =?UTF-8?q?TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DON/DOFF toggle: aspect lines persist across planet switches & outside clicks; cleared only by DON (new planet) or DOFF; planet-keyed --asp-* colors (--sixU/--terU on light palettes) - planet tooltip: aspect rows w. 2× thick line legend, planet symbol + .tt-asp-in 'in' + sign icon + orb; applying/separating direction symbols - element tooltip: 80px square badge (float right); DON/DOFF hidden; symbol-based contributor rows (☉ @ 15.3° ♈ +1); Stellium/Parade +N underlined headers; parade sign-grouped w. parenthetical planet symbols, counterclockwise order - ring swap: planets outer (0.50–0.68r), houses inner (0.32–0.48r) to reduce stellia crowding - house spokes: angle cusps only (ASC/IC/DSC/MC); non-angle spokes removed - outside-click guard: bounding-rect check for DON/DOFF/PRV/NXT so pointer-events:none buttons don't trigger close - add element-square & energy-vector icon dirs (Ardor/Ossum/Tempo/Nexus/Pneuma/Humor SVGs) - T11a–f Jasmine specs for DON/DOFF line persistence Co-Authored-By: Claude Sonnet 4.6 --- .../gameboard/icons/dice-polyhedra/d4.svg | 22 + .../gameboard/icons/dice-polyhedra/d6.svg | 22 + .../gameboard/icons/dice-polyhedra/d8.svg | 25 + .../gameboard/icons/element-squares/Ardor.svg | 75 +++ .../gameboard/icons/element-squares/Humor.svg | 71 +++ .../gameboard/icons/element-squares/Nexus.svg | 73 +++ .../gameboard/icons/element-squares/Ossum.svg | 85 +++ .../icons/element-squares/Pneuma.svg | 67 +++ .../gameboard/icons/element-squares/Tempo.svg | 88 ++++ .../gameboard/icons/energy-vectors/air.svg | 28 + .../gameboard/icons/energy-vectors/fire.svg | 20 + .../gameboard/icons/energy-vectors/space.svg | 25 + .../gameboard/icons/energy-vectors/stone.svg | 45 ++ .../gameboard/icons/energy-vectors/time.svg | 33 ++ .../gameboard/icons/energy-vectors/water.svg | 32 ++ .../static/apps/gameboard/natus-wheel.js | 498 +++++++++++++----- src/static/tests/NatusWheelSpec.js | 149 ++++++ src/static_src/scss/_natus.scss | 111 +++- src/static_src/scss/rootvars.scss | 20 +- src/static_src/tests/NatusWheelSpec.js | 149 ++++++ 20 files changed, 1498 insertions(+), 140 deletions(-) create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/dice-polyhedra/d4.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/dice-polyhedra/d6.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/dice-polyhedra/d8.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/element-squares/Ardor.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/element-squares/Humor.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/element-squares/Nexus.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/element-squares/Ossum.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/element-squares/Pneuma.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/element-squares/Tempo.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/air.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/fire.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/space.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/stone.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/time.svg create mode 100644 src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/water.svg diff --git a/src/apps/gameboard/static/apps/gameboard/icons/dice-polyhedra/d4.svg b/src/apps/gameboard/static/apps/gameboard/icons/dice-polyhedra/d4.svg new file mode 100644 index 0000000..66459c7 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/dice-polyhedra/d4.svg @@ -0,0 +1,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/dice-polyhedra/d6.svg b/src/apps/gameboard/static/apps/gameboard/icons/dice-polyhedra/d6.svg new file mode 100644 index 0000000..7798b1c --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/dice-polyhedra/d6.svg @@ -0,0 +1,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/dice-polyhedra/d8.svg b/src/apps/gameboard/static/apps/gameboard/icons/dice-polyhedra/d8.svg new file mode 100644 index 0000000..68141dc --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/dice-polyhedra/d8.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Ardor.svg b/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Ardor.svg new file mode 100644 index 0000000..0cbc6b1 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Ardor.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + Ar + Ardor + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Humor.svg b/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Humor.svg new file mode 100644 index 0000000..0c6838f --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Humor.svg @@ -0,0 +1,71 @@ + + + + + + + + Hm + Humor + + + + + + + + + + + 20 + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Nexus.svg b/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Nexus.svg new file mode 100644 index 0000000..7b9cd9f --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Nexus.svg @@ -0,0 +1,73 @@ + + + + + + + + Nx + Nexus + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Ossum.svg b/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Ossum.svg new file mode 100644 index 0000000..a5afd17 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Ossum.svg @@ -0,0 +1,85 @@ + + + + + + + + Om + Ossum + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Pneuma.svg b/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Pneuma.svg new file mode 100644 index 0000000..201b9ac --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Pneuma.svg @@ -0,0 +1,67 @@ + + + + + + + + Pn + Pneuma + + + + + + + + + + + 12 + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Tempo.svg b/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Tempo.svg new file mode 100644 index 0000000..bae4757 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/element-squares/Tempo.svg @@ -0,0 +1,88 @@ + + + + + + + + Tp + Tempo + + + + + + + + + + + + 2 + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/air.svg b/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/air.svg new file mode 100644 index 0000000..eac8f61 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/air.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/fire.svg b/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/fire.svg new file mode 100644 index 0000000..652ea60 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/fire.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/space.svg b/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/space.svg new file mode 100644 index 0000000..23a8ec3 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/space.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/stone.svg b/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/stone.svg new file mode 100644 index 0000000..74e782c --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/stone.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/time.svg b/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/time.svg new file mode 100644 index 0000000..5af1f47 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/time.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/water.svg b/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/water.svg new file mode 100644 index 0000000..5ade568 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/icons/energy-vectors/water.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/gameboard/static/apps/gameboard/natus-wheel.js b/src/apps/gameboard/static/apps/gameboard/natus-wheel.js index be77167..c478138 100644 --- a/src/apps/gameboard/static/apps/gameboard/natus-wheel.js +++ b/src/apps/gameboard/static/apps/gameboard/natus-wheel.js @@ -8,10 +8,17 @@ * * `data` shape — matches the /epic/natus/preview/ proxy response: * { - * planets: { Sun: { sign, degree, retrograde }, … }, + * planets: { Sun: { sign, degree, speed, retrograde }, … }, * houses: { cusps: [f×12], asc: f, mc: f }, - * elements: { Fire: n, Water: n, Stone: n, Air: n, Time: n, Space: n }, - * aspects: [{ planet1, planet2, type, angle, orb }, …], + * elements: { + * Fire: { count: n, contributors: [{planet, sign}, …] }, + * Stone: { count: n, contributors: […] }, + * Air: { count: n, contributors: […] }, + * Water: { count: n, contributors: […] }, + * Time: { count: n, stellia: [{ sign, planets: [{planet, sign}] }] }, + * Space: { count: n, parades: [{ signs: […], planets: [{planet, sign}] }] }, + * }, + * aspects: [{ planet1, planet2, type, angle, orb, applying_planet }, …], * distinctions: { "1": n, …, "12": n }, * house_system: "O", * } @@ -46,7 +53,7 @@ const NatusWheel = (() => { Jupiter: '♃', Saturn: '♄', Uranus: '♅', Neptune: '♆', Pluto: '♇', }; - // Alchemical element symbol → CSS modifier class suffix (matches rootvars palette) + // Alchemical element code → CSS var suffix (--pri{Cap}) e.g. 'au' → --priAu const PLANET_ELEMENTS = { Sun: 'au', Moon: 'ag', Mercury: 'hg', Venus: 'cu', Mars: 'fe', Jupiter: 'sn', Saturn: 'pb', Uranus: 'u', Neptune: 'np', Pluto: 'pu', @@ -65,15 +72,31 @@ const NatusWheel = (() => { // 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)', - Sextile: 'var(--priGn, #60c080)', - Square: 'var(--priRd, #c04040)', - Trine: 'var(--priGn, #60c080)', - Opposition: 'var(--priRd, #c04040)', + const CLASSIC_ELEMENTS = new Set(['Fire', 'Stone', 'Air', 'Water']); + + const ASPECT_SYMBOLS = { + Conjunction: '☌', + Semisextile: '⚺', + Sextile: '⚹', + Square: '□', + Trine: '△', + Quincunx: '⚻', + Opposition: '☍', + }; + + const APPLY_SYM = '⇥'; // →| applying (converging toward exact) + const SEP_SYM = '↦'; // |→ separating (diverging from exact) + + // SVG stroke-dasharray and width per aspect type — color comes from applying planet. + const ASPECT_STYLES = { + Conjunction: { dash: 'none', width: 1.2 }, + Semisextile: { dash: '1 4', width: 0.6 }, + Sextile: { dash: '6 4', width: 0.8 }, + Square: { dash: '2 4', width: 1.2 }, + Trine: { dash: '12 4', width: 0.8 }, + Quincunx: { dash: '12 4 2 4', width: 0.8 }, + Opposition: { dash: '10 4 2 4 2 4', width: 1.2 }, }; - // Element fill colors live in _natus.scss (.nw-sign--* / .nw-element--*). const HOUSE_LABELS = [ '', 'Self', 'Worth', 'Education', 'Family', 'Creation', 'Ritual', @@ -108,16 +131,44 @@ const NatusWheel = (() => { // AbortController for the outside-click dismiss listener. let _outsideClickController = null; + // ── Aspect overlay state ────────────────────────────────────────────────── + + let _aspectsVisible = false; + let _aspectGroup = null; // D3 selection of the nw-aspects + 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 += `
Stellium +${bonus}
`; + 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 += `
Parade +${bonus}
`; + // 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}%)
`; + `
` + + squareImg + + `${info.name}` + + `
` + + 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 = {