/**
 * sky-wheel.js — Self-contained D3 natal-chart module.
 *
 * Public API:
 *   SkyWheel.draw(svgEl, data)    — first render
 *   SkyWheel.redraw(data)         — live update (same SVG)
 *   SkyWheel.clear()              — empty the SVG
 *
 * `data` shape — matches the /epic/sky/preview/ proxy response:
 * {
 *   planets:      { Sun: { sign, degree, speed, retrograde }, … },
 *   houses:       { cusps: [f×12], asc: f, mc: f },
 *   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",
 * }
 *
 * Requires D3 v7 to be available as `window.d3` (loaded before this file).
 * Uses CSS variables from the project palette (--priUser, --secUser, etc.)
 * already defined in the page; falls back to neutral colours if absent.
 */

const SkyWheel = (() => {
  'use strict';

  // ── Constants ──────────────────────────────────────────────────────────────

  const SIGNS = [
    { name: 'Aries',       symbol: '♈', element: 'Fire',  fa: 'aries'       },
    { name: 'Taurus',      symbol: '♉', element: 'Stone', fa: 'taurus'      },
    { name: 'Gemini',      symbol: '♊', element: 'Air',   fa: 'gemini'      },
    { name: 'Cancer',      symbol: '♋', element: 'Water', fa: 'cancer'      },
    { name: 'Leo',         symbol: '♌', element: 'Fire',  fa: 'leo'         },
    { name: 'Virgo',       symbol: '♍', element: 'Stone', fa: 'virgo'       },
    { name: 'Libra',       symbol: '♎', element: 'Air',   fa: 'libra'       },
    { name: 'Scorpio',     symbol: '♏', element: 'Water', fa: 'scorpio'     },
    { name: 'Sagittarius', symbol: '♐', element: 'Fire',  fa: 'sagittarius' },
    { name: 'Capricorn',   symbol: '♑', element: 'Stone', fa: 'capricorn'   },
    { name: 'Aquarius',    symbol: '♒', element: 'Air',   fa: 'aquarius'    },
    { name: 'Pisces',      symbol: '♓', element: 'Water', fa: 'pisces'      },
  ];

  const PLANET_SYMBOLS = {
    Sun: '☉', Moon: '☽', Mercury: '☿', Venus: '♀', Mars: '♂',
    Jupiter: '♃', Saturn: '♄', Uranus: '♅', Neptune: '♆', Pluto: '♇',
  };

  // 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',
  };

  // EarthmanRPG element names + classical equivalents + ring segment color var
  const ELEMENT_INFO = {
    Fire:  { abbr: 'Ar', name: 'Ardor',  classical: 'fire',  titleVar: '--priRd' },
    Stone: { abbr: 'Om', name: 'Ossum',  classical: 'stone', titleVar: '--priFs' },
    Time:  { abbr: 'Tp', name: 'Tempo',  classical: 'time',  titleVar: '--priYl' },
    Space: { abbr: 'Nx', name: 'Nexus',  classical: 'space', titleVar: '--priGn' },
    Air:   { abbr: 'Pn', name: 'Pneuma', classical: 'air',   titleVar: '--priCy' },
    Water: { abbr: 'Hm', name: 'Humor',  classical: 'water', titleVar: '--priId' },
  };

  // Clockwise ring order for element cycling
  const ELEMENT_ORDER = ['Fire', 'Stone', 'Time', 'Space', 'Air', 'Water'];

  const CLASSIC_ELEMENTS = new Set(['Fire', 'Stone', 'Air', 'Water']);

  const ASPECT_SYMBOLS = {
    Conjunction:    '☌',
    Semisextile:    '⚺',
    Semisquare:     '∠',
    Sextile:        '⚹',
    Square:         '□',
    Trine:          '△',
    Sesquiquadrate: '⊼',
    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 },
    Semisquare:     { dash: '3 4',               width: 0.7 },
    Sextile:        { dash: '6 4',               width: 0.8 },
    Square:         { dash: '2 4',               width: 1.2 },
    Trine:          { dash: '12 4',              width: 0.8 },
    Sesquiquadrate: { dash: '8 4 3 4',           width: 0.7 },
    Quincunx:       { dash: '12 4 2 4',          width: 0.8 },
    Opposition:     { dash: '10 4 2 4 2 4',      width: 1.2 },
  };

  const HOUSE_LABELS = [
    '', 'Self', 'Worth', 'Education', 'Family', 'Creation', 'Ritual',
    'Cooperation', 'Regeneration', 'Enterprise', 'Career', 'Reward', 'Reprisal',
  ];

  const ANGLE_DEFS = {
    ASC: { label: 'Ascendant', sym: 'ASC', house: 1  },
    MC:  { label: 'Midheaven', sym: 'MC',  house: 10 },
  };

  // All aspect angles — used for client-side angle (ASC/MC) aspect detection.
  const ALL_ASPECTS = [
    { type: 'Conjunction',    angle: 0   },
    { type: 'Semisextile',    angle: 30  },
    { type: 'Semisquare',     angle: 45  },
    { type: 'Sextile',        angle: 60  },
    { type: 'Square',         angle: 90  },
    { type: 'Trine',          angle: 120 },
    { type: 'Sesquiquadrate', angle: 135 },
    { type: 'Quincunx',       angle: 150 },
    { type: 'Opposition',     angle: 180 },
  ];

  // Ott's Orb: per-body allowed orb; angle points (ASC/MC) use ANGLE_BODY_ORB.
  // Allowed orb for a pair = (orbOtt_body1 + orbOtt_body2) / 2.
  const PLANET_ORB = {
    Sun: 10, Moon: 10,
    Mercury: 8, Venus: 8, Mars: 8,
    Jupiter: 6, Saturn: 6,
    Uranus: 4, Neptune: 4, Pluto: 4,
  };
  const ANGLE_BODY_ORB = 10;

  // Aspect Values for intensity scoring: A.V. × (1 − actual_orb / ott_orb)
  const ASPECT_AV = {
    Conjunction: 12, Opposition: 6, Trine: 4, Square: 3, Sextile: 2,
    Sesquiquadrate: 1.5, Semisquare: 1.5, Quincunx: 1, Semisextile: 1,
  };

  // Tie-break 2: partner body importance (lower = more important)
  const BODY_IMPORTANCE = {
    Sun: 0, Moon: 1, ASC: 2, MC: 3,
    Mercury: 4, Venus: 5, Mars: 6,
    Jupiter: 7, Saturn: 8, Uranus: 9, Neptune: 10, Pluto: 11,
  };

  // Tie-break 3: aspect importance (lower = more important)
  const ASPECT_IMPORTANCE = {
    Conjunction: 0, Opposition: 1, Trine: 2, Square: 3, Sextile: 4,
    Sesquiquadrate: 5, Semisquare: 6, Quincunx: 7, Semisextile: 8,
  };

  // Cardinal / Fixed / Mutable — parallel index to SIGNS
  const SIGN_MODALITIES = [
    'Cardinal', 'Fixed', 'Mutable', 'Cardinal', 'Fixed', 'Mutable',
    'Cardinal', 'Fixed', 'Mutable', 'Cardinal', 'Fixed', 'Mutable',
  ];

  // Angular / Succedent / Cadent — 1-indexed (index 0 unused)
  const HOUSE_QUADRANT = [
    '', 'Angular', 'Succedent', 'Cadent',
    'Angular', 'Succedent', 'Cadent',
    'Angular', 'Succedent', 'Cadent',
    'Angular', 'Succedent', 'Cadent',
  ];

  // Houses 1–3: Juvenescence; 4–6: Adolescence; 7–9: Maturescence; 10–12: Senescence
  const HOUSE_PHASE = [
    '', 'Juvenescence', 'Juvenescence', 'Juvenescence',
    'Adolescence',    'Adolescence',    'Adolescence',
    'Maturescence',   'Maturescence',   'Maturescence',
    'Senescence',     'Senescence',     'Senescence',
  ];

  // ── State ─────────────────────────────────────────────────────────────────

  let _svg = null;
  let _cx, _cy, _r;   // centre + outer radius

  // Cached SVG path strings keyed by sign name, populated by preload().
  const _signPaths = {};

  // Ring radii (fractions of _r, set in _layout)
  let R = {};

  // Chart data — cached so cycle navigation can re-render without a data arg.
  let _currentData = null;

  // ── Cycle state ────────────────────────────────────────────────────────────

  let _activeRing  = null;  // 'planets' | 'elements' | 'signs' | 'houses' | 'angles' | null
  let _activeIdx   = null;  // index within the active ring's sorted list
  let _planetItems = [];    // [{name, degree}] sorted by ecliptic degree descending
  let _elementItems = [];   // [{key}] in ELEMENT_ORDER
  let _signItems   = [];    // [{name, symbol, element}] in SIGNS order
  let _houseItems  = [];    // [{num, label}] houses 1–12
  let _angleItems  = [];    // [{name, deg}] — ASC and MC
  let _chartItems  = [];    // [{type, name, deg}] planets+angles merged, degree desc

  // Tooltip DOM refs — set by _injectTooltipControls() on each draw().
  let _tooltipEl = null;
  let _ttBody    = null;

  // AbortController for the outside-click dismiss listener.
  let _outsideClickController = null;

  // ── Aspect overlay state ──────────────────────────────────────────────────

  let _aspectsVisible   = false;
  let _aspectGroup      = null;   // D3 selection of the nw-aspects <g>
  let _aspectIndex      = {};     // planetName → [{partner, type, orb, applying_planet}]
  let _aspectPlanet     = null;   // name of planet whose lines are currently drawn

  // Polish-8 — aspect mini-portal element ref (Aspected / Unaspected swap
  // alongside the DON|DOFF apparatus on the planet + angle tooltips).
  let _miniPortalEl     = null;

  // ── 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('sky-wheel')) {
        _staticBase = s.src.replace(/sky-wheel\.js.*$/, '');
        return _staticBase;
      }
    }
    _staticBase = '/static/apps/gameboard/';
    return _staticBase;
  }

  // ── Helpers ───────────────────────────────────────────────────────────────

  /**
   * 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;
  }

  function _css(varName, fallback) {
    const v = getComputedStyle(document.documentElement)
      .getPropertyValue(varName).trim();
    return v || fallback;
  }

  /** Ecliptic longitude → degrees within the sign (0–29.999…). */
  function _inSignDeg(ecliptic) {
    return ((ecliptic % 360) + 360) % 360 % 30;
  }

  /** Return 1-based house number for an ecliptic degree given 12 cusps. */
  function _planetHouse(degree, cusps) {
    degree = ((degree % 360) + 360) % 360;
    for (let i = 0; i < 12; i++) {
      const start = ((cusps[i]           % 360) + 360) % 360;
      const end   = ((cusps[(i + 1) % 12] % 360) + 360) % 360;
      if (start < end) {
        if (start <= degree && degree < end) return i + 1;
      } else {
        if (degree >= start || degree < end) return i + 1;
      }
    }
    return 1;
  }

  /** Inline SVG for a zodiac sign icon (preloaded path). */
  function _signIconSvg(signName) {
    const d = _signPaths[signName];
    if (!d) return '';
    return `<svg viewBox="0 0 640 640" width="1em" height="1em" class="tt-sign-icon" aria-hidden="true"><path d="${d}"/></svg>`;
  }

  /** Planet symbol wrapped in its metal-color span. data-planet allows test queries. */
  function _pSym(planetName) {
    const el  = PLANET_ELEMENTS[planetName] || '';
    const sym = PLANET_SYMBOLS[planetName]  || planetName[0];
    return el
      ? `<span class="tt-title--${el}" data-planet="${planetName}">${sym}</span>`
      : `<span data-planet="${planetName}">${sym}</span>`;
  }

  /** Sign icon SVG wrapped in its element-color span. */
  function _signIcon(signName) {
    const sign = SIGNS.find(s => s.name === signName) || {};
    const el   = (sign.element || '').toLowerCase();
    const svg  = _signIconSvg(signName) || sign.symbol || '';
    return el ? `<span class="tt-sign-icon-wrap--${el}">${svg}</span>` : svg;
  }

  /** Ordinal suffix HTML for a house number, e.g. 2 → "2<span class=tt-ord>nd</span>". */
  function _houseOrdinal(n) {
    const sfx = n === 1 ? 'st' : n === 2 ? 'nd' : n === 3 ? 'rd' : 'th';
    return `${n}<span class="tt-ord">${sfx}</span>`;
  }

  /** <img> 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 `<img src="${src}" class="tt-el-square" width="80" height="80" alt="${info.name}" aria-hidden="true">`;
  }

  /** <img> 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 `<img src="${src}" class="tt-el-vec" width="1em" height="1em" alt="${info.classical}" aria-hidden="true">`;
  }

  /** Average of two bodies' Ott orbs — the allowed orb for their aspects. */
  function _ottOrb(body1, body2) {
    const o1 = PLANET_ORB[body1] ?? ANGLE_BODY_ORB;
    const o2 = PLANET_ORB[body2] ?? ANGLE_BODY_ORB;
    return (o1 + o2) / 2;
  }

  /**
   * Sort an aspect list for display: descending intensity = A.V. × (1 − orb/ottOrb).
   * Ties resolved by: separating before applying → partner importance → aspect importance.
   */
  function _sortAspects(aspects, thisBody) {
    return aspects.filter(a => a.orb <= _ottOrb(thisBody, a.partner)).sort((a, b) => {
      const ottA = _ottOrb(thisBody, a.partner);
      const ottB = _ottOrb(thisBody, b.partner);
      const intA = (ASPECT_AV[a.type] ?? 1) * (1 - a.orb / ottA);
      const intB = (ASPECT_AV[b.type] ?? 1) * (1 - b.orb / ottB);
      if (Math.abs(intA - intB) > 1e-9) return intB - intA;
      // tie-break 1: separating (applying_planet !== thisBody) before applying
      const aApplying = a.applying_planet === thisBody ? 1 : 0;
      const bApplying = b.applying_planet === thisBody ? 1 : 0;
      if (aApplying !== bApplying) return aApplying - bApplying;
      // tie-break 2: partner body importance
      const impA = BODY_IMPORTANCE[a.partner] ?? 99;
      const impB = BODY_IMPORTANCE[b.partner] ?? 99;
      if (impA !== impB) return impA - impB;
      // tie-break 3: aspect importance
      return (ASPECT_IMPORTANCE[a.type] ?? 99) - (ASPECT_IMPORTANCE[b.type] ?? 99);
    });
  }

  /** 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 `<svg class="tt-asp-line" width="28" height="10" aria-hidden="true">` +
      `<line x1="2" y1="5" x2="26" y2="5" stroke="${color}" stroke-width="${style.width * 2}"${dash}/>` +
      `</svg>`;
  }


  // ── Cycle helpers ─────────────────────────────────────────────────────────

  /** Build sorted planet list (ascending ecliptic degree) and element list. */
  function _buildCycleLists(data) {
    _planetItems = Object.entries(data.planets)
      .map(([name, p]) => ({ name, degree: p.degree }))
      .sort((a, b) => b.degree - a.degree);  // descending = clockwise on wheel
    _elementItems = ELEMENT_ORDER.map(key => ({ key }));
    _signItems = SIGNS.map(s => ({ name: s.name, symbol: s.symbol, element: s.element }));
    _houseItems = Array.from({ length: 12 }, (_, i) => ({
      num: i + 1,
      label: HOUSE_LABELS[i + 1],
    }));
    _angleItems = [
      { name: 'ASC', deg: data.houses.asc },
      { name: 'MC',  deg: data.houses.mc  },
    ];
    _chartItems = [
      ..._planetItems.map(p => ({ type: 'planet', name: p.name, deg: p.degree })),
      ..._angleItems.map(a => ({ type: 'angle',  name: a.name, deg: a.deg   })),
    ].sort((a, b) => b.deg - a.deg);
  }

  /** Clear all active-lock classes and reset cycle state. */
  function _clearActive() {
    if (_svg) {
      _svg.selectAll('.nw-planet-group').classed('nw-planet--active', false);
      _svg.selectAll('.nw-element-group').classed('nw-element--active', false);
      _svg.selectAll('.nw-sign-group').classed('nw-sign--active', false);
      _svg.selectAll('[data-house]').classed('nw-house--active', false);
      _svg.selectAll('[data-angle]').classed('nw-angle--active', false);
    }
    _activeRing = null;
    _activeIdx  = null;
  }

  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';
    _hideAspectMiniPortal();
  }

  /**
   * 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; });
    degrees['ASC'] = _currentData.houses.asc;
    degrees['MC']  = _currentData.houses.mc;

    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);
    }
  }

  /** Draw aspect lines from an angle (ASC/MC) to all planets in its aspect index. */
  function _showAngleAspects(angleName) {
    if (!_aspectGroup || !_currentData) return;
    _clearAspectLines();

    const angleDeg = angleName === 'ASC' ? _currentData.houses.asc : _currentData.houses.mc;
    const asc      = _currentData.houses.asc;
    const a1       = _toAngle(angleDeg, asc);
    const r1       = R.planetR;

    (_aspectIndex[angleName] || []).forEach(({ partner, type, applying_planet }) => {
      const pdata = _currentData.planets[partner];
      if (!pdata) return;
      const a2    = _toAngle(pdata.degree, asc);
      const style = ASPECT_STYLES[type] || { dash: 'none', width: 0.8 };
      const color = _aspectColor(applying_planet);

      const line = _aspectGroup.append('line')
        .attr('x1', _cx + r1 * Math.cos(a1))
        .attr('y1', _cy + r1 * 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);
    });
  }

  /** Lock-activate an angle (ASC or MC) — show tooltip + draw aspect lines. */
  function _activateAngle(angleName) {
    if (_activeRing === 'angles' && _activeIdx === angleName) {
      _closeTooltip();
      return;
    }
    _clearActive();
    _activeRing = 'angles';
    _activeIdx  = angleName;

    if (_svg) _svg.select(`[data-angle="${angleName}"]`).classed('nw-angle--active', true);

    const def       = ANGLE_DEFS[angleName] || {};
    const angleDeg  = angleName === 'ASC' ? _currentData.houses.asc : _currentData.houses.mc;
    const signIdx   = Math.floor(angleDeg / 30) % 12;
    const sign      = SIGNS[signIdx] || {};
    const inDeg     = (angleDeg % 30).toFixed(1);
    const icon      = _signIconSvg(sign.name) || sign.symbol || '';
    const elKey     = (sign.element || '').toLowerCase();

    let aspectHtml = '';
    const myAspects = _sortAspects(_aspectIndex[angleName] || [], angleName);
    if (myAspects.length) {
      aspectHtml = '<small class="tt-aspects">';
      myAspects.forEach(({ partner, type, orb, applying_planet }) => {
        const ppdata  = _currentData.planets[partner] || {};
        const asym    = ASPECT_SYMBOLS[type] || type;
        const lineSvg = _aspectLineSvg(type, applying_planet);
        aspectHtml +=
          `<div class="tt-asp-row">` +
          `${lineSvg} ${asym} ${_pSym(partner)} <span class="tt-asp-in">in</span> ${_signIcon(ppdata.sign)}` +
          ` <span class="tt-asp-orb">${APPLY_SYM} ${orb}°</span>` +
          `</div>`;
      });
      aspectHtml += '</small>';
    }

    if (_ttBody) {
      _ttBody.innerHTML =
        `<div class="tt-planet-header">` +
          `<span class="tt-title">${def.label || angleName}</span>` +
          `<span class="tt-planet-sym tt-angle-sym">${def.sym || angleName}</span>` +
        `</div>` +
        `<div class="tt-planet-loc">` +
          `<span>@${inDeg}° ${sign.name || ''}</span>` +
          `<span class="tt-planet-sign-icon tt-planet-sign-icon--${elKey}">${icon}</span>` +
        `</div>` +
        `<div class="tt-angle-house">${_houseOrdinal(def.house)} House <span class="tt-dim">(${HOUSE_LABELS[def.house] || ''})</span></div>` +
        aspectHtml;
    }

    _aspectsVisible = false;
    _positionTooltipAtItem('angles', angleName);
    if (_tooltipEl) {
      _tooltipEl.style.display = 'block';
      _tooltipEl.querySelector('.nw-asp-don')?.style.removeProperty('display');
      _tooltipEl.querySelector('.nw-asp-doff')?.style.removeProperty('display');
    }
    _updateAspectToggleUI();
    _showAspectMiniPortal();
  }

  /**
   * Position the tooltip in the vertical half of the wheel opposite to the
   * clicked planet/element.
   */
  function _positionTooltipAtItem(ring, idx) {
    const svgNode = _svg ? _svg.node() : null;
    if (!svgNode || !_tooltipEl) return;

    _tooltipEl.style.display = 'block';
    const ttW = _tooltipEl.offsetWidth  || 0;
    const ttH = _tooltipEl.offsetHeight || 0;
    const REM = 16;

    const svgRect = svgNode.getBoundingClientRect();
    const svgCX   = svgRect.left + svgRect.width  / 2;
    const svgCY   = svgRect.top  + svgRect.height / 2;

    let iRect = { left: svgCX, top: svgCY, width: 0, height: 0, right: svgCX, bottom: svgCY };
    {
      let el = null;
      if (ring === 'planets') {
        const grp = svgNode.querySelector(`[data-planet="${_planetItems[idx].name}"]`);
        el = grp && (grp.querySelector('circle') || grp);
      } else if (ring === 'elements') {
        const grp = svgNode.querySelector(`[data-element="${_elementItems[idx].key}"]`);
        el = grp && (grp.querySelector('path') || grp);
      } else if (ring === 'signs') {
        const grp = svgNode.querySelector(`[data-sign-name="${_signItems[idx].name}"]`);
        el = grp && (grp.querySelector('path') || grp);
      } else if (ring === 'houses') {
        const grp = svgNode.querySelector(`[data-house="${_houseItems[idx].num}"]`);
        el = grp && (grp.querySelector('path') || grp);
      } else if (ring === 'angles') {
        const grp = svgNode.querySelector(`[data-angle="${idx}"]`);
        el = grp && (grp.querySelector('text') || grp);
      }
      if (el) iRect = el.getBoundingClientRect();
    }

    const itemX = iRect.left + iRect.width  / 2;
    const itemY = iRect.top  + iRect.height / 2;

    const left = Math.max(svgRect.left + REM, Math.min(svgRect.right  - ttW - REM,
      itemX < svgCX ? iRect.left : iRect.right - ttW
    ));
    const top = Math.max(svgRect.top  + REM, Math.min(svgRect.bottom - ttH - REM,
      itemY >= svgCY ? svgCY - REM - ttH : svgCY + REM
    ));

    _tooltipEl.style.left = left + 'px';
    _tooltipEl.style.top  = top  + 'px';
  }

  /** Lock-activate a planet by cycle index. */
  function _activatePlanet(idx) {
    _clearActive();
    // _aspectsVisible tracks whether the currently-active planet's aspects are drawn.
    // Derive it from _aspectPlanet so navigating away and back via PRV/NXT correctly
    // restores the DOFF state instead of resetting to DON.
    const item0 = _planetItems[idx];
    _aspectsVisible = (item0.name === _aspectPlanet);
    _activeRing = 'planets';
    _activeIdx  = idx;
    const item  = _planetItems[idx];
    const grp   = _svg.select(`[data-planet="${item.name}"]`);
    grp.classed('nw-planet--active', true);
    grp.raise();

    const pdata    = _currentData.planets[item.name];
    const el       = PLANET_ELEMENTS[item.name] || '';
    const sym      = PLANET_SYMBOLS[item.name]  || item.name[0];
    const signData = SIGNS.find(s => s.name === pdata.sign) || {};
    const inDeg    = _inSignDeg(pdata.degree).toFixed(1);
    const rx       = pdata.retrograde ? ' ℞' : '';
    const icon     = _signIconSvg(pdata.sign) || signData.symbol || '';

    // Aspect list — always shown in small font; lines on SVG toggled separately.
    let aspectHtml = '';
    const myAspects = _sortAspects(_aspectIndex[item.name] || [], item.name);
    if (myAspects.length) {
      aspectHtml = '<small class="tt-aspects">';
      myAspects.forEach(({ partner, type, orb, applying_planet }) => {
        const isAngle   = partner === 'ASC' || partner === 'MC';
        const ppdata    = isAngle ? {} : (_currentData.planets[partner] || {});
        const asym      = ASPECT_SYMBOLS[type] || type;
        const applying   = applying_planet === item.name;
        const lineSvg    = _aspectLineSvg(type, applying_planet);
        const partnerSym = isAngle
          ? `<span class="tt-title tt-angle-sym">${(ANGLE_DEFS[partner] || {}).sym || partner}</span>`
          : _pSym(partner);
        let signPart;
        if (isAngle) {
          const aDeg   = partner === 'ASC' ? _currentData.houses.asc : _currentData.houses.mc;
          const aSign  = SIGNS[Math.floor(aDeg / 30) % 12];
          signPart = aSign ? ` <span class="tt-asp-in">in</span> ${_signIcon(aSign.name)}` : '';
        } else {
          signPart = ` <span class="tt-asp-in">in</span> ${_signIcon(ppdata.sign)}`;
        }
        const orbHtml = applying
          ? `${APPLY_SYM} ${orb}°`
          : `${orb}° ${SEP_SYM}`;
        aspectHtml +=
          `<div class="tt-asp-row">` +
          `${lineSvg} ${asym} ${partnerSym}${signPart}` +
          ` <span class="tt-asp-orb">${orbHtml}</span>` +
          `</div>`;
      });
      aspectHtml += '</small>';
    }

    if (_ttBody) {
      _ttBody.innerHTML =
        `<div class="tt-planet-header">` +
          `<span class="tt-title tt-title--${el}">${item.name}</span>` +
          `<span class="tt-planet-sym tt-title--${el}">${sym}</span>` +
        `</div>` +
        `<div class="tt-planet-loc">` +
          `<span>@${inDeg}° ${pdata.sign}${rx}</span>` +
          `<span class="tt-planet-sign-icon tt-planet-sign-icon--${(signData.element || '').toLowerCase()}">${icon}</span>` +
        `</div>` +
        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');
    }
    _showAspectMiniPortal();
  }

  /** Lock-activate an element slice by cycle index. */
  function _activateElement(idx) {
    _clearActive();
    _activeRing = 'elements';
    _activeIdx  = idx;
    const item  = _elementItems[idx];
    const grp   = _svg.select(`[data-element="${item.key}"]`);
    grp.classed('nw-element--active', true);

    const info   = ELEMENT_INFO[item.key] || {};
    const 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 = `<div class="tt-el-formation-header"><span class="tt-el-formation-label">Planets</span> +${contribs.length}</div>`;
      if (contribs.length) {
        bodyHtml += '<div class="tt-el-contribs">';
        contribs.forEach(c => {
          bodyHtml += `<div class="tt-asp-row">${_pSym(c.planet)} <span class="tt-dim">in</span> ${_signIcon(c.sign)} +1</div>`;
        });
        bodyHtml += '</div>';
      } else {
        bodyHtml += `<div class="tt-el-formation">—</div>`;
      }

    } else if (item.key === 'Time') {
      const stellia = elData.stellia || [];
      if (stellia.length) {
        const isTie = stellia.length > 1;
        bodyHtml += '<div class="tt-el-contribs">';
        stellia.forEach(st => {
          const bonus = st.planets.length - 1;
          bodyHtml += `<div class="tt-el-formation-header"><span class="tt-el-formation-label">Stellium</span> +${bonus}</div>`;
          if (isTie) {
            st.planets.forEach(p => {
              const pdata = (_currentData.planets || {})[p.planet] || {};
              const inDeg = pdata.degree !== undefined ? _inSignDeg(pdata.degree).toFixed(1) : '?';
              bodyHtml += `<div class="tt-asp-row tt-el-planet-row">${_pSym(p.planet)} @ ${inDeg}° ${_signIcon(p.sign)}</div>`;
            });
          } else {
            const psyms = st.planets.map(p => _pSym(p.planet)).join(' ');
            bodyHtml += `<div class="tt-asp-row tt-el-planet-row"><span class="tt-dim">in</span> ${_signIcon(st.sign)} : ${psyms}</div>`;
          }
        });
        bodyHtml += '</div>';
      } else {
        bodyHtml += `<div class="tt-el-formation">—</div>`;
      }

    } else if (item.key === 'Space') {
      const parades = elData.parades || [];
      if (parades.length) {
        bodyHtml += '<div class="tt-el-contribs">';
        parades.forEach(pd => {
          const bonus = pd.signs.length - 1;
          bodyHtml += `<div class="tt-el-formation-header"><span class="tt-el-formation-label">Parade</span> +${bonus}</div>`;
          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 psyms = planets.map(p => _pSym(p.planet)).join(' ');
            bodyHtml += `<div class="tt-asp-row tt-el-planet-row"><span class="tt-dim">in</span> ${_signIcon(sign)} : ${psyms}</div>`;
          });
        });
        bodyHtml += '</div>';
      } else {
        bodyHtml += `<div class="tt-el-formation">—</div>`;
      }
    }

    if (_ttBody) {
      _ttBody.innerHTML =
        `<div class="tt-el-header">` +
          `<div class="tt-el-badge-col">` +
            squareImg +
            `<div class="tt-el-score">+${count} (${pct}%)</div>` +
          `</div>` +
          `<span class="tt-title tt-title--el-${elKey}">${info.name}</span>` +
        `</div>` +
        `<span class="tt-element-type">${item.key}-Capacitor</span>` +
        bodyHtml;
    }
    _positionTooltipAtItem('elements', idx);
    if (_tooltipEl) {
      _tooltipEl.querySelector('.nw-asp-don')?.style.setProperty('display', 'none');
      _tooltipEl.querySelector('.nw-asp-doff')?.style.setProperty('display', 'none');
    }
    _hideAspectMiniPortal();
  }

  function _activateSign(idx) {
    _clearActive();
    _activeRing = 'signs';
    _activeIdx  = idx;
    const sign     = _signItems[idx];
    _svg.select(`[data-sign-name="${sign.name}"]`).classed('nw-sign--active', true);
    const elKey    = sign.element.toLowerCase();
    const modality = SIGN_MODALITIES[idx];
    const vecImg   = _elementVectorImg(sign.element);
    const iconSvg  = _signIconSvg(sign.name) ||
      `<span class="tt-sign-sym-fallback">${sign.symbol}</span>`;

    let planetsHtml = '';
    let cuspsHtml   = '';
    if (_currentData) {
      const cusps = (_currentData.houses || {}).cusps || [];
      const inSign = Object.entries(_currentData.planets || {})
        .filter(([, p]) => p.sign === sign.name)
        .sort((a, b) => a[1].degree - b[1].degree);
      inSign.forEach(([pname, pdata]) => {
        const inDeg  = _inSignDeg(pdata.degree).toFixed(1);
        const house  = cusps.length ? _planetHouse(pdata.degree, cusps) : null;
        const domain = house ? HOUSE_LABELS[house] : '';
        const houseHtml = house
          ? ` ${_houseOrdinal(house)} House <span class="tt-dim">(${domain})</span>`
          : '';
        planetsHtml +=
          `<div class="tt-house-planet-row">${_pSym(pname)} <span class="tt-dim">@${inDeg}°</span>${houseHtml}</div>`;
      });

      if (cusps.length) {
        cusps.forEach((cuspDeg, i) => {
          const norm = ((cuspDeg % 360) + 360) % 360;
          if (Math.floor(norm / 30) === idx) {
            const houseNum = i + 1;
            const inDeg    = _inSignDeg(cuspDeg).toFixed(1);
            cuspsHtml +=
              `<div class="tt-house-planet-row">${_houseOrdinal(houseNum)} House <span class="tt-dim">@${inDeg}°</span> ${_signIcon(sign.name)}</div>`;
          }
        });
      }
    }

    if (_ttBody) {
      _ttBody.innerHTML =
        `<div class="tt-sign-header">` +
          `<span class="tt-title tt-title--sign-${elKey}">${sign.name}</span>` +
          `<span class="tt-sign-icon-wrap tt-sign-icon-wrap--${elKey}">${iconSvg}</span>` +
        `</div>` +
        `<div class="tt-sign-meta">` +
          `<span class="tt-sign-type">${modality} ${sign.element}</span>` +
          vecImg +
        `</div>` +
        `<div class="tt-sign-section-header">Planets</div>` +
        (planetsHtml ? `<div class="tt-sign-planets">${planetsHtml}</div>` : `<div class="tt-el-formation">—</div>`) +
        `<br>` +
        `<div class="tt-sign-section-header">Cusps</div>` +
        (cuspsHtml ? `<div class="tt-sign-cusps">${cuspsHtml}</div>` : `<div class="tt-el-formation">—</div>`);
    }
    _positionTooltipAtItem('signs', idx);
    if (_tooltipEl) {
      _tooltipEl.querySelector('.nw-asp-don')?.style.setProperty('display', 'none');
      _tooltipEl.querySelector('.nw-asp-doff')?.style.setProperty('display', 'none');
    }
    _hideAspectMiniPortal();
  }

  function _activateHouse(idx) {
    _clearActive();
    _activeRing = 'houses';
    _activeIdx  = idx;
    const house = _houseItems[idx];
    _svg.select(`[data-house="${house.num}"]`).classed('nw-house--active', true);
    const cusps = (_currentData && (_currentData.houses || {}).cusps) || [];

    let planetsHtml = '';
    if (cusps.length && _currentData) {
      const inHouse = Object.entries(_currentData.planets || {})
        .filter(([, p]) => _planetHouse(p.degree, cusps) === house.num)
        .sort((a, b) => a[1].degree - b[1].degree);
      inHouse.forEach(([pname, pdata]) => {
        const inDeg = _inSignDeg(pdata.degree).toFixed(1);
        planetsHtml += `<div class="tt-house-planet-row">${_pSym(pname)} <span class="tt-dim">@${inDeg}°</span> ${_signIcon(pdata.sign)}</div>`;
      });
    }

    const quadrant = HOUSE_QUADRANT[house.num] || '';
    const phase    = HOUSE_PHASE[house.num]    || '';

    if (_ttBody) {
      _ttBody.innerHTML =
        `<div class="tt-house-header">` +
          `<span class="tt-title">` +
            `<span class="tt-house-of">House of</span><br>` +
            `${house.label}<br>` +
            `<span class="tt-house-type">${quadrant} ${phase}</span>` +
          `</span>` +
          `<span class="tt-house-num">${house.num}</span>` +
        `</div>` +
        `<div class="tt-sign-section-header">Planets</div>` +
        (planetsHtml ? `<div class="tt-house-planets">${planetsHtml}</div>` : `<div class="tt-el-formation">—</div>`);
    }
    _positionTooltipAtItem('houses', idx);
    if (_tooltipEl) {
      _tooltipEl.querySelector('.nw-asp-don')?.style.setProperty('display', 'none');
      _tooltipEl.querySelector('.nw-asp-doff')?.style.setProperty('display', 'none');
    }
    _hideAspectMiniPortal();
  }

  /** Advance the active ring by +1 (NXT) or -1 (PRV). */
  function _stepCycle(dir) {
    if (_activeRing === 'planets' || _activeRing === 'angles') {
      const currentName = _activeRing === 'planets'
        ? _planetItems[_activeIdx].name
        : _activeIdx;  // angles use name string as _activeIdx
      const pos  = _chartItems.findIndex(c => c.name === currentName);
      if (pos === -1) return;
      const next = _chartItems[(pos + dir + _chartItems.length) % _chartItems.length];
      if (next.type === 'planet') {
        _activatePlanet(_planetItems.findIndex(p => p.name === next.name));
      } else {
        _activateAngle(next.name);
      }
    } else if (_activeRing === 'elements') {
      _activeIdx = (_activeIdx + dir + _elementItems.length) % _elementItems.length;
      _activateElement(_activeIdx);
    } else if (_activeRing === 'signs') {
      _activeIdx = (_activeIdx + dir + _signItems.length) % _signItems.length;
      _activateSign(_activeIdx);
    } else if (_activeRing === 'houses') {
      _activeIdx = (_activeIdx + dir + _houseItems.length) % _houseItems.length;
      _activateHouse(_activeIdx);
    }
  }

  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';
    _updateAspectMiniPortal();
  }

  // Polish-8 — aspect mini-portal: text-swaps "Aspected" / "Unaspected"
  // alongside the main tooltip, mirroring the game-kit Equipped / Unequipped
  // micro-tooltip pattern. Only relevant for planets + angles (rings w.
  // DON|DOFF apparatus). State derives from `_aspectsVisible` global +
  // whether the currently-active item is the one whose lines are drawn.
  function _updateAspectMiniPortal() {
    if (!_miniPortalEl) return;
    _miniPortalEl.textContent = _aspectsVisible ? 'Aspected' : 'Unaspected';
  }

  function _showAspectMiniPortal() {
    if (!_miniPortalEl || !_tooltipEl) return;
    _updateAspectMiniPortal();
    _positionAspectMiniPortal();
    _miniPortalEl.classList.add('active');
  }

  function _hideAspectMiniPortal() {
    if (!_miniPortalEl) return;
    _miniPortalEl.classList.remove('active');
  }

  // Mirror the gameboard's mini-portal positioning (gameboard.js:285-287):
  // pin the mini-portal's RIGHT edge to the main tooltip's right edge,
  // 4px below the tooltip's bottom. Text width changes grow/shrink
  // leftward instead of overflowing the right column. z-index lowered
  // to 150 so it falls behind the tooltip's PRV/NXT buttons (which
  // inherit the tooltip's z-index 200 stacking context) per user-spec
  // 2026-05-25 PM "make sure its z-index falls behind the NXT button".
  function _positionAspectMiniPortal() {
    if (!_miniPortalEl || !_tooltipEl) return;
    const r = _tooltipEl.getBoundingClientRect();
    _miniPortalEl.style.left = '';
    _miniPortalEl.style.right = Math.round(window.innerWidth - r.right) + 'px';
    _miniPortalEl.style.top  = (r.bottom + 4) + 'px';
    _miniPortalEl.style.zIndex = '150';
  }

  function _toggleAspects() {
    _aspectsVisible = !_aspectsVisible;
    _updateAspectToggleUI();
    if (_aspectsVisible) {
      if (_activeRing === 'planets' && _activeIdx !== null) {
        _showPlanetAspects(_planetItems[_activeIdx].name);
      } else if (_activeRing === 'angles' && _activeIdx !== null) {
        _showAngleAspects(_activeIdx);
      }
    } else {
      _clearAspectLines();
      _aspectPlanet = null;
    }
  }

  /**
   * 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_sky_tooltip');
    if (!_tooltipEl) return;
    _miniPortalEl = document.getElementById('id_mini_tooltip_portal');
    _tooltipEl.innerHTML =
      `<button type="button" class="btn btn-equip nw-asp-don">DON</button>` +
      `<button type="button" class="btn btn-unequip btn-disabled nw-asp-doff">DOFF</button>` +
      '<div class="nw-tt-body"></div>' +
      '<button type="button" class="btn btn-nav-left nw-tt-prv">PRV</button>' +
      '<button type="button" class="btn btn-nav-right nw-tt-nxt">NXT</button>';
    _ttBody = _tooltipEl.querySelector('.nw-tt-body');
    _tooltipEl.querySelector('.nw-tt-prv').addEventListener('click', (e) => {
      e.stopPropagation(); _stepCycle(-1);
    });
    _tooltipEl.querySelector('.nw-tt-nxt').addEventListener('click', (e) => {
      e.stopPropagation(); _stepCycle(1);
    });
    _tooltipEl.querySelector('.nw-asp-don')
      .addEventListener('click', (e) => { e.stopPropagation(); if (!e.currentTarget.classList.contains('btn-disabled')) _toggleAspects(); });
    _tooltipEl.querySelector('.nw-asp-doff')
      .addEventListener('click', (e) => { e.stopPropagation(); if (!e.currentTarget.classList.contains('btn-disabled')) _toggleAspects(); });
    // Sync button to current state in case of redraw mid-session.
    _updateAspectToggleUI();
  }

  /** 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;
      _closeTooltip();
    }, { signal: _outsideClickController.signal });
  }

  function _layout(svgEl) {
    const rect = svgEl.getBoundingClientRect();
    const size = Math.min(rect.width || 400, rect.height || 400);
    _cx = size / 2;
    _cy = size / 2;
    svgEl.setAttribute('viewBox', `0 0 ${size} ${size}`);
    svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
    _r  = size * 0.46;

    R = {
      elementInner:  _r * 0.20,
      elementOuter:  _r * 0.28,
      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,
      houseNumR:     _r * 0.40,
      planetR:       _r * 0.59,
      aspectR:       _r * 0.29,
      ascMcR:        _r * 0.92,
    };
  }

  // ── Drawing sub-routines ──────────────────────────────────────────────────

  function _drawAscMc(g, data) {
    const asc = data.houses.asc;
    const mc  = data.houses.mc;
    const points = [
      { deg: asc,       label: 'ASC', clickable: true  },
      { deg: asc + 180, label: 'DSC', clickable: false },
      { deg: mc,        label: 'MC',  clickable: true  },
      { deg: mc  + 180, label: 'IC',  clickable: false },
    ];
    const axisGroup = g.append('g').attr('class', 'nw-axes');
    points.forEach(({ deg, label, clickable }) => {
      const a = _toAngle(deg, asc);
      const x1 = _cx + R.houseInner * Math.cos(a);
      const y1 = _cy + R.houseInner * Math.sin(a);
      const x2 = _cx + R.ascMcR     * Math.cos(a);
      const y2 = _cy + R.ascMcR     * Math.sin(a);
      axisGroup.append('line')
        .attr('x1', x1).attr('y1', y1)
        .attr('x2', x2).attr('y2', y2)
        .attr('class', 'nw-axis-line');

      const textEl = axisGroup.append('text')
        .attr('x', _cx + (R.ascMcR + 12) * Math.cos(a))
        .attr('y', _cy + (R.ascMcR + 12) * Math.sin(a))
        .attr('text-anchor', 'middle')
        .attr('dominant-baseline', 'middle')
        .attr('font-size', `${_r * 0.055}px`)
        .attr('class', 'nw-axis-label')
        .text(label);

      if (clickable) {
        const hitR = _r * 0.055 + 6;
        const hitGroup = axisGroup.append('g')
          .attr('class', 'nw-angle-group')
          .attr('data-angle', label)
          .style('cursor', 'pointer');

        // Invisible hit circle over the label
        hitGroup.append('circle')
          .attr('cx', _cx + (R.ascMcR + 12) * Math.cos(a))
          .attr('cy', _cy + (R.ascMcR + 12) * Math.sin(a))
          .attr('r', hitR)
          .attr('fill', 'transparent');

        // Move the text into the clickable group
        textEl.remove();
        hitGroup.append('text')
          .attr('x', _cx + (R.ascMcR + 12) * Math.cos(a))
          .attr('y', _cy + (R.ascMcR + 12) * Math.sin(a))
          .attr('text-anchor', 'middle')
          .attr('dominant-baseline', 'middle')
          .attr('font-size', `${_r * 0.055}px`)
          .attr('class', 'nw-axis-label')
          .text(label);

        hitGroup.on('click', function (event) {
          event.stopPropagation();
          _activateAngle(label);
        });
      }
    });
  }

  function _drawSigns(g, data) {
    const asc = data.houses.asc;
    const arc = d3.arc();
    const sigGroup = g.append('g').attr('class', 'nw-signs');

    SIGNS.forEach((sign, i) => {
      const startDeg = i * 30;
      const endDeg   = startDeg + 30;
      const startA   = _toAngle(startDeg, asc);
      const endA     = _toAngle(endDeg,   asc);
      const [sa, ea] = startA > endA ? [endA, startA] : [startA, endA];

      const signSlice = sigGroup.append('g')
        .attr('class', `nw-sign-group`)
        .attr('data-sign-name', sign.name)
        .on('click', function (event) {
          event.stopPropagation();
          const clickIdx = _signItems.findIndex(s => s.name === sign.name);
          if (_activeRing === 'signs' && _activeIdx === clickIdx) {
            _closeTooltip();
          } else {
            _activateSign(clickIdx);
          }
        });

      signSlice.append('path')
        .attr('transform', `translate(${_cx},${_cy})`)
        .attr('d', arc({
          innerRadius: R.signInner,
          outerRadius: R.signOuter,
          startAngle:  sa + Math.PI / 2,
          endAngle:    ea + Math.PI / 2,
        }))
        .attr('class', `nw-sign--${sign.element.toLowerCase()}`);

      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;
      const sf   = (cr * 2 * 0.85) / 640;

      signSlice.append('circle')
        .attr('cx', lx)
        .attr('cy', ly)
        .attr('r', cr)
        .attr('class', `nw-sign-icon-bg nw-sign-icon-bg--${sign.element.toLowerCase()}`);

      if (_signPaths[sign.name]) {
        signSlice.append('path')
          .attr('d', _signPaths[sign.name])
          .attr('transform',
            `translate(${lx},${ly}) scale(${sf}) translate(-320,-320)`)
          .attr('class', `nw-sign-icon nw-sign-icon--${sign.element.toLowerCase()}`);
      }
    });
  }

  function _drawHouses(g, data) {
    const { cusps, asc } = data.houses;
    const arc = d3.arc();
    const houseGroup = g.append('g').attr('class', 'nw-houses');

    const houses = cusps.map((cusp, i) => {
      let nextCusp = cusps[(i + 1) % 12];
      if (nextCusp <= cusp) nextCusp += 360;
      const startA = _toAngle(cusp,     asc);
      const endA   = _toAngle(nextCusp, asc);
      const sa = endA, ea = startA;
      return { i, startA, sa, ea, midA: (sa + ea) / 2 };
    });

    houses.forEach(({ i, startA, sa, ea, midA }) => {
      // Per-house group — wraps fill + number so CSS can target both on hover/active
      const grp = houseGroup.append('g')
        .attr('class', 'nw-house-group')
        .attr('data-house', i + 1)
        .on('click', function (event) {
          event.stopPropagation();
          const clickIdx = i;
          if (_activeRing === 'houses' && _activeIdx === clickIdx) {
            _closeTooltip();
          } else {
            _activateHouse(clickIdx);
          }
        });

      grp.append('path')
        .attr('transform', `translate(${_cx},${_cy})`)
        .attr('d', arc({
          innerRadius: R.houseInner,
          outerRadius: R.houseOuter,
          startAngle:  sa + Math.PI / 2,
          endAngle:    ea + Math.PI / 2,
        }))
        .attr('class', i % 2 === 0 ? 'nw-house-fill--even' : 'nw-house-fill--odd');

      grp.append('text')
        .attr('x', _cx + R.houseNumR * Math.cos(midA))
        .attr('y', _cy + R.houseNumR * Math.sin(midA))
        .attr('text-anchor', 'middle')
        .attr('dominant-baseline', 'middle')
        .attr('font-size', `${_r * 0.05}px`)
        .attr('class', 'nw-house-num')
        .attr('pointer-events', 'none')
        .text(i + 1);

      // Cusp lines at quadrant boundaries stay in houseGroup (not clickable)
      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');
      }
    });
  }

  function _drawPlanets(g, data) {
    const asc = data.houses.asc;
    const planetGroup = g.append('g').attr('class', 'nw-planets');
    const ascAngle = _toAngle(asc, asc);

    const TICK_OUTER = _r * 0.96;

    Object.entries(data.planets).forEach(([name, pdata], idx) => {
      const finalA = _toAngle(pdata.degree, asc);
      const el     = PLANET_ELEMENTS[name] || '';

      const planetEl = planetGroup.append('g')
        .attr('class', 'nw-planet-group')
        .attr('data-planet', name)
        .attr('data-sign', pdata.sign)
        .attr('data-degree', pdata.degree.toFixed(1))
        .attr('data-retrograde', pdata.retrograde ? 'true' : 'false')
        .on('click', function (event) {
          event.stopPropagation();
          const clickIdx = _planetItems.findIndex(p => p.name === name);
          if (_activeRing === 'planets' && _activeIdx === clickIdx) {
            _closeTooltip();
          } else {
            _activatePlanet(clickIdx);
          }
        });

      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))
        .attr('y1', _cy + R.planetR * Math.sin(ascAngle))
        .attr('x2', _cx + TICK_OUTER * Math.cos(ascAngle))
        .attr('y2', _cy + TICK_OUTER * Math.sin(ascAngle));

      const circleBase = pdata.retrograde ? 'nw-planet-circle--rx' : 'nw-planet-circle';
      const circle = planetEl.append('circle')
        .attr('cx', _cx + R.planetR * Math.cos(ascAngle))
        .attr('cy', _cy + R.planetR * Math.sin(ascAngle))
        .attr('r', _r * 0.05)
        .attr('class', el ? `${circleBase} nw-planet--${el}` : circleBase);

      const label = planetEl.append('text')
        .attr('x', _cx + R.planetR * Math.cos(ascAngle))
        .attr('y', _cy + R.planetR * Math.sin(ascAngle))
        .attr('text-anchor', 'middle')
        .attr('dominant-baseline', 'middle')
        .attr('dy', '0.1em')
        .attr('font-size', `${_r * 0.09}px`)
        .attr('class', el ? `nw-planet-label nw-planet-label--${el}` : 'nw-planet-label')
        .attr('pointer-events', 'none')
        .text(PLANET_SYMBOLS[name] || name[0]);

      let rxLabel = null;
      let rxBadge = null;
      // Rx-retrograde badge — mirrors the wallet Shop applet's `.shop-badge`
      // ×N pattern: --secUser disc + --priUser glyph + bold. Sized at ~70%
      // of the planet circle radius + placed slightly outside the planet
      // along the same angle so the badge edge overlaps the planet edge.
      // User-spec 2026-05-25 PM.
      if (pdata.retrograde) {
        const RX_OFFSET = R.planetR + _r * 0.07;
        rxBadge = planetEl.append('circle')
          .attr('cx', _cx + RX_OFFSET * Math.cos(ascAngle))
          .attr('cy', _cy + RX_OFFSET * Math.sin(ascAngle))
          .attr('r', _r * 0.035)
          .attr('class', 'nw-rx-badge')
          .attr('pointer-events', 'none');
        rxLabel = planetEl.append('text')
          .attr('x', _cx + RX_OFFSET * Math.cos(ascAngle))
          .attr('y', _cy + RX_OFFSET * Math.sin(ascAngle))
          .attr('text-anchor', 'middle')
          .attr('dominant-baseline', 'middle')
          .attr('font-size', `${_r * 0.045}px`)
          .attr('class', 'nw-rx')
          .attr('pointer-events', 'none')
          .text('℞');
      }

      const interpAngle = d3.interpolate(ascAngle, finalA);
      const transition = () => d3.transition()
        .delay(idx * 40)
        .duration(600)
        .ease(d3.easeQuadOut);

      circle.transition(transition())
        .attrTween('cx', () => t => _cx + R.planetR * Math.cos(interpAngle(t)))
        .attrTween('cy', () => t => _cy + R.planetR * Math.sin(interpAngle(t)));

      label.transition(transition())
        .attrTween('x', () => t => _cx + R.planetR * Math.cos(interpAngle(t)))
        .attrTween('y', () => t => _cy + R.planetR * Math.sin(interpAngle(t)));

      if (rxLabel) {
        const RX_OFFSET = R.planetR + _r * 0.07;
        rxBadge.transition(transition())
          .attrTween('cx', () => t => _cx + RX_OFFSET * Math.cos(interpAngle(t)))
          .attrTween('cy', () => t => _cy + RX_OFFSET * Math.sin(interpAngle(t)));
        rxLabel.transition(transition())
          .attrTween('x', () => t => _cx + RX_OFFSET * Math.cos(interpAngle(t)))
          .attrTween('y', () => t => _cy + RX_OFFSET * Math.sin(interpAngle(t)));
      }

      tick.transition(transition())
        .attrTween('x1', () => t => _cx + R.planetR  * Math.cos(interpAngle(t)))
        .attrTween('y1', () => t => _cy + R.planetR  * Math.sin(interpAngle(t)))
        .attrTween('x2', () => t => _cx + TICK_OUTER * Math.cos(interpAngle(t)))
        .attrTween('y2', () => t => _cy + TICK_OUTER * Math.sin(interpAngle(t)));
    });
  }

  /**
   * Build the aspect index (planet → aspects list) and create the persistent
   * nw-aspects group. Lines are drawn per-planet on click, not here.
   */
  /** Compute aspect type + orb between an angle (ASC/MC) and a planet, or null. */
  function _angleAspectWith(angleDeg, planetDeg, planetName) {
    let diff = Math.abs(planetDeg - angleDeg) % 360;
    if (diff > 180) diff = 360 - diff;
    const maxOrb = _ottOrb(planetName, 'ASC');
    for (const { type, angle } of ALL_ASPECTS) {
      const orb = Math.abs(diff - angle);
      if (orb <= maxOrb) return { type, orb: +orb.toFixed(1) };
    }
    return null;
  }

  function _drawAspects(g, data) {
    _aspectIndex = {};
    Object.entries(data.planets).forEach(([name]) => { _aspectIndex[name] = []; });

    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 });
    });

    // Compute planet-angle aspects client-side (planet always applies to angle).
    ['ASC', 'MC'].forEach(angleName => {
      const angleDeg = angleName === 'ASC' ? data.houses.asc : data.houses.mc;
      _aspectIndex[angleName] = [];
      Object.entries(data.planets).forEach(([pname, pdata]) => {
        const asp = _angleAspectWith(angleDeg, pdata.degree, pname);
        if (!asp) return;
        const shared = { type: asp.type, orb: asp.orb, applying_planet: pname };
        _aspectIndex[angleName].push({ partner: pname, ...shared });
        _aspectIndex[pname].push({ partner: angleName, ...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 + _elNorm(el[k]).count, 0);
    if (total === 0) return;

    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);

    const elGroup = g.append('g')
      .attr('class', 'nw-elements')
      .attr('transform', `translate(${_cx},${_cy})`);

    pie.forEach(slice => {
      const sliceGroup = elGroup.append('g')
        .attr('class', 'nw-element-group')
        .attr('data-element', slice.data.key)
        .on('click', function (event) {
          event.stopPropagation();
          const clickIdx = _elementItems.findIndex(e => e.key === slice.data.key);
          if (_activeRing === 'elements' && _activeIdx === clickIdx) {
            _closeTooltip();
          } else {
            _activateElement(clickIdx);
          }
        });

      sliceGroup.append('path')
        .attr('d', arc(slice))
        .attr('class', `nw-element--${slice.data.key.toLowerCase()}`);
    });
  }

  // ── Public API ────────────────────────────────────────────────────────────

  /**
   * Preload zodiac sign SVGs from `basePath`.
   * Returns a Promise that resolves when all 12 are cached.
   */
  async function preload(basePath) {
    const base = basePath ||
      (() => {
        const scripts = document.querySelectorAll('script[src]');
        for (const s of scripts) {
          if (s.src.includes('sky-wheel')) {
            return s.src.replace(/sky-wheel\.js.*$/, 'icons/zodiac-signs/');
          }
        }
        return '/static/apps/gameboard/icons/zodiac-signs/';
      })();

    await Promise.all(SIGNS.map(async sign => {
      const url  = base + sign.name.toLowerCase() + '.svg';
      const resp = await window.fetch(url);
      if (!resp.ok) { console.warn(`SkyWheel: failed to load ${url}`); return; }
      const text = await resp.text();
      const doc  = new DOMParser().parseFromString(text, 'image/svg+xml');
      const path = doc.querySelector('path');
      if (path) _signPaths[sign.name] = path.getAttribute('d');
    }));
  }

  function draw(svgEl, data) {
    _svg = d3.select(svgEl);
    _svg.selectAll('*').remove();
    _layout(svgEl);

    _currentData = data;
    _closeTooltip();
    _buildCycleLists(data);
    _injectTooltipControls();
    _attachOutsideClick();

    const g = _svg.append('g').attr('class', 'nw-root');

    g.append('circle')
      .attr('cx', _cx).attr('cy', _cy).attr('r', R.signOuter)
      .attr('class', 'nw-outer-ring');

    g.append('circle')
      .attr('cx', _cx).attr('cy', _cy).attr('r', R.elementOuter)
      .attr('class', 'nw-inner-disc');

    _drawElements(g, data);
    _drawAspects(g, data);   // above element ring, below houses/signs/planets
    _drawHouses(g, data);
    _drawSigns(g, data);
    _drawAscMc(g, data);
    _drawPlanets(g, data);
  }

  function redraw(data) {
    if (!_svg) return;
    draw(_svg.node(), data);
  }

  function clear() {
    if (_svg) _svg.selectAll('*').remove();
    _closeTooltip();
    if (_outsideClickController) {
      _outsideClickController.abort();
      _outsideClickController = null;
    }
    _aspectsVisible = false;
    _aspectPlanet   = null;
    _aspectGroup    = null;
    _currentData    = null;
  }

  return { preload, draw, redraw, clear };
})();
