PICK SKY: natal wheel planet tooltips + FT modernisation

- natus-wheel.js: per-planet <g> group with data-planet/sign/degree/retrograde
  attrs; mouseover/mouseout on group (pointer-events:none on child text/℞ so
  the whole apparatus triggers hover); tooltip uses .tt-title/.tt-description;
  in-sign degree via _inSignDeg() (ecliptic % 30); D3 switched from CDN to
  local d3.min.js
- _natus.scss: .nw-planet--hover glow; #id_natus_tooltip position:fixed z-200
- _natus_overlay.html: tooltip div uses .tt; local d3.min.js script tag
- T3/T4/T5 converted from Selenium execute_script to Jasmine unit tests
  (NatusWheelSpec.js) — NatusWheel was never defined in headless GeckoDriver;
  SpecRunner.html updated to load D3 + natus-wheel.js
- test_pick_sky.py: NatusWheelTooltipTest removed (replaced by Jasmine)
- test_component_cards_tarot / test_trinket_carte_blanche: equip assertions
  updated from legacy .equip-deck-btn/.equip-trinket-btn mini-tooltip pattern
  to current DON|DOFF (.btn-equip in main portal); mini-portal text assertions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-16 01:57:02 -04:00
parent db9ac9cb24
commit 2910012b67
12 changed files with 370 additions and 59 deletions

View File

@@ -1030,6 +1030,9 @@ def natus_preview(request, room_id):
return HttpResponse(status=502)
data = resp.json()
# PySwiss uses "Earth"; the wheel and SCSS use "Stone".
if 'elements' in data and 'Earth' in data['elements']:
data['elements']['Stone'] = data['elements'].pop('Earth')
data['distinctions'] = _compute_distinctions(data['planets'], data['houses'])
data['timezone'] = tz_str
return JsonResponse(data)

File diff suppressed because one or more lines are too long

View File

@@ -98,6 +98,11 @@ const NatusWheel = (() => {
return v || fallback;
}
/** Ecliptic longitude → degrees within the sign (029.999…). */
function _inSignDeg(ecliptic) {
return ((ecliptic % 360) + 360) % 360 % 30;
}
function _layout(svgEl) {
const rect = svgEl.getBoundingClientRect();
const size = Math.min(rect.width || 400, rect.height || 400);
@@ -264,16 +269,48 @@ const NatusWheel = (() => {
const finalA = _toAngle(pdata.degree, asc);
const el = PLANET_ELEMENTS[name] || '';
// Per-planet group — data attrs + hover events live here so the
// symbol text and ℞ indicator don't block mouse events on the circle.
const planetEl = planetGroup.append('g')
.attr('class', 'nw-planet-group')
.attr('data-planet', name)
.attr('data-sign', pdata.sign)
.attr('data-degree', pdata.degree.toFixed(1))
.attr('data-retrograde', pdata.retrograde ? 'true' : 'false')
.on('mouseover', function (event) {
d3.select(this).classed('nw-planet--hover', true);
const tooltip = document.getElementById('id_natus_tooltip');
if (!tooltip) return;
const sym = PLANET_SYMBOLS[name] || name[0];
const signData = SIGNS.find(s => s.name === pdata.sign) || {};
const signSym = signData.symbol || '';
const inDeg = _inSignDeg(pdata.degree).toFixed(1);
const rx = pdata.retrograde ? ' ℞' : '';
tooltip.innerHTML =
`<div class="tt-title">${name} (${sym})</div>` +
`<div class="tt-description">${inDeg}° ${pdata.sign} ${signSym}${rx}</div>`;
tooltip.style.left = (event.clientX + 14) + 'px';
tooltip.style.top = (event.clientY - 10) + 'px';
tooltip.style.display = 'block';
})
.on('mouseout', function (event) {
// Ignore mouseout when moving between children of this group
if (planetEl.node().contains(event.relatedTarget)) return;
d3.select(this).classed('nw-planet--hover', false);
const tooltip = document.getElementById('id_natus_tooltip');
if (tooltip) tooltip.style.display = 'none';
});
// Circle behind symbol
const circleBase = pdata.retrograde ? 'nw-planet-circle--rx' : 'nw-planet-circle';
const circle = planetGroup.append('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);
// Symbol
const label = planetGroup.append('text')
// Symbol — pointer-events:none so hover 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))
.attr('text-anchor', 'middle')
@@ -281,17 +318,20 @@ const NatusWheel = (() => {
.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]);
// Retrograde indicator
// Retrograde indicator — also pointer-events:none
let rxLabel = null;
if (pdata.retrograde) {
planetGroup.append('text')
rxLabel = planetEl.append('text')
.attr('x', _cx + (R.planetR + _r * 0.055) * Math.cos(ascAngle))
.attr('y', _cy + (R.planetR + _r * 0.055) * Math.sin(ascAngle))
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', `${_r * 0.040}px`)
.attr('class', 'nw-rx')
.attr('pointer-events', 'none')
.text('℞');
}
@@ -311,10 +351,8 @@ const NatusWheel = (() => {
.attrTween('x', () => t => _cx + R.planetR * Math.cos(interpAngle(t)))
.attrTween('y', () => t => _cy + R.planetR * Math.sin(interpAngle(t)));
// Retrograde ℞ — move together with planet
if (pdata.retrograde) {
planetGroup.select('.nw-rx:last-child')
.transition(transition())
if (rxLabel) {
rxLabel.transition(transition())
.attrTween('x', () => t => _cx + (R.planetR + _r * 0.055) * Math.cos(interpAngle(t)))
.attrTween('y', () => t => _cy + (R.planetR + _r * 0.055) * Math.sin(interpAngle(t)));
}

View File

@@ -272,11 +272,12 @@ class GameKitDeckSelectionTest(FunctionalTest):
self.assertIn("Earthman", portal.text)
self.assertIn("108", portal.text)
# Mini tooltip shows Equip button — Earthman is NOT currently equipped
# Mini shows "Not Equipped"; DON button is active in the main portal
mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal")
self.wait_for(lambda: self.assertTrue(mini.is_displayed()))
equip_btn = mini.find_element(By.CSS_SELECTOR, ".equip-deck-btn")
self.assertEqual(equip_btn.text, "Equip Deck?")
self.assertIn("Not Equipped", mini.text)
don = portal.find_element(By.CSS_SELECTOR, ".btn-equip")
self.assertNotIn("btn-disabled", don.get_attribute("class"))
# ── Hover over Fiorentine Minchiate deck ─────────────────────────
fiorentine_el = self.browser.find_element(By.ID, "id_kit_fiorentine_deck")
@@ -299,7 +300,7 @@ class GameKitDeckSelectionTest(FunctionalTest):
self.wait_for(lambda: self.assertTrue(mini.is_displayed()))
self.assertIn("Equipped", mini.text)
# ── Hover back to Earthman and click Equip ────────────────────────
# ── Hover back to Earthman and click DON ─────────────────────────
ActionChains(self.browser).move_to_element(earthman_el).perform()
self.wait_for(
lambda: self.assertIn(
@@ -307,24 +308,21 @@ class GameKitDeckSelectionTest(FunctionalTest):
self.browser.find_element(By.ID, "id_tooltip_portal").text,
)
)
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal")
self.wait_for(lambda: self.assertTrue(mini.is_displayed()))
mini.find_element(By.CSS_SELECTOR, ".equip-deck-btn").click()
portal.find_element(By.CSS_SELECTOR, ".btn-equip").click()
# Both portals close after equip
# DON becomes disabled; mini updates to "Equipped"; data attr set optimistically
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed()
lambda: self.assertIn(
"btn-disabled",
portal.find_element(By.CSS_SELECTOR, ".btn-equip").get_attribute("class"),
)
)
# Game Kit data attribute now reflects Earthman's id
self.assertIn("Equipped", self.browser.find_element(By.ID, "id_mini_tooltip_portal").text)
game_kit = self.browser.find_element(By.ID, "id_game_kit")
self.wait_for(
lambda: self.assertNotEqual(
game_kit.get_attribute("data-equipped-deck-id"), ""
)
)
self.assertNotEqual(game_kit.get_attribute("data-equipped-deck-id"), "")
# ------------------------------------------------------------------ #
# Test 6 — new user's Game Kit shows only the default Earthman deck #

View File

@@ -52,10 +52,10 @@ class PickSkyLocalStorageTest(FunctionalTest):
def _fill_form(self):
"""Set date, lat, lon directly (bypasses Nominatim network call)."""
self.browser.execute_script(
"document.getElementById('id_nf_date').value = '1990-02-28';"
"document.getElementById('id_nf_lat').value = '39.8244';"
"document.getElementById('id_nf_lon').value = '-74.9970';"
"document.getElementById('id_nf_place').value = 'Lindenwold, NJ';"
"document.getElementById('id_nf_date').value = '2008-05-27';"
"document.getElementById('id_nf_lat').value = '38.3754';"
"document.getElementById('id_nf_lon').value = '-76.6955';"
"document.getElementById('id_nf_place').value = 'Morganza, MD';"
"document.getElementById('id_nf_tz').value = 'America/New_York';"
)
# Fire input events so the save listener triggers
@@ -95,10 +95,10 @@ class PickSkyLocalStorageTest(FunctionalTest):
self._open_overlay()
values = self._field_values()
self.assertEqual(values["date"], "1990-02-28")
self.assertEqual(values["lat"], "39.8244")
self.assertEqual(values["lon"], "-74.9970")
self.assertEqual(values["place"], "Lindenwold, NJ")
self.assertEqual(values["date"], "2008-05-27")
self.assertEqual(values["lat"], "38.3754")
self.assertEqual(values["lon"], "-76.6955")
self.assertEqual(values["place"], "Morganza, MD")
self.assertEqual(values["tz"], "America/New_York")
# ------------------------------------------------------------------ #
@@ -118,8 +118,10 @@ class PickSkyLocalStorageTest(FunctionalTest):
self._open_overlay()
values = self._field_values()
self.assertEqual(values["date"], "1990-02-28")
self.assertEqual(values["lat"], "39.8244")
self.assertEqual(values["lon"], "-74.9970")
self.assertEqual(values["place"], "Lindenwold, NJ")
self.assertEqual(values["date"], "2008-05-27")
self.assertEqual(values["lat"], "38.3754")
self.assertEqual(values["lon"], "-76.6955")
self.assertEqual(values["place"], "Morganza, MD")
self.assertEqual(values["tz"], "America/New_York")

View File

@@ -106,7 +106,7 @@ class CarteBlancheTest(FunctionalTest):
)
)
# 3. Hover Carte Blanche — main tooltip present; mini tooltip shows "Equip Trinket?"
# 3. Hover Carte Blanche — main tooltip present; mini shows "Not Equipped"; DON active
carte_el = self.browser.find_element(By.ID, "id_kit_carte_blanche")
self.browser.execute_script(
"arguments[0].scrollIntoView({block: 'center'})", carte_el
@@ -122,14 +122,16 @@ class CarteBlancheTest(FunctionalTest):
self.assertIn("no expiry", portal.text)
mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal")
self.wait_for(lambda: self.assertTrue(mini.is_displayed()))
equip_btn = mini.find_element(By.CSS_SELECTOR, ".equip-trinket-btn")
self.assertEqual(equip_btn.text, "Equip Trinket?")
self.assertIn("Not Equipped", mini.text)
don = portal.find_element(By.CSS_SELECTOR, ".btn-equip")
self.assertNotIn("btn-disabled", don.get_attribute("class"))
# 4. Click "Equip Trinket?" — DB switches; both portals close
equip_btn.click()
# 4. Click DON — DON becomes disabled; data-equipped-id set optimistically
don.click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed()
lambda: self.assertIn(
"btn-disabled",
portal.find_element(By.CSS_SELECTOR, ".btn-equip").get_attribute("class"),
)
)
@@ -141,13 +143,8 @@ class CarteBlancheTest(FunctionalTest):
str(self.carte.pk),
)
)
# NOTE: re-hovering carte_el here to assert "Equipped" in mini is unreliable in
# headless GeckoDriver — move_to_element uses a different scroll-into-view algorithm
# than scrollIntoView({block:'center'}), so the computed element centre can match the
# cursor's current position and no mousemove fires. The equip round-trip is validated
# implicitly by the DB-side check below (step 6: Pass now shows "Equip Trinket?").
# 6. Hover Backstage Pass — mini tooltip shows "Equip Trinket?" (Pass no longer equipped)
# 6. Hover Backstage Pass — mini shows "Not Equipped" (Pass no longer equipped)
pass_el = self.browser.find_element(By.ID, "id_kit_pass")
ActionChains(self.browser).move_to_element(pass_el).perform()
self.wait_for(
@@ -158,7 +155,7 @@ class CarteBlancheTest(FunctionalTest):
)
mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal")
self.wait_for(lambda: self.assertTrue(mini.is_displayed()))
self.assertTrue(mini.find_element(By.CSS_SELECTOR, ".equip-trinket-btn").is_displayed())
self.assertIn("Not Equipped", mini.text)
# ── GATEKEEPER PHASE ─────────────────────────────────────────────────

View File

@@ -0,0 +1,124 @@
// ── NatusWheelSpec.js ─────────────────────────────────────────────────────────
//
// Unit specs for natus-wheel.js — planet hover tooltips.
//
// DOM contract assumed:
// <svg id="id_natus_svg"> — target for NatusWheel.draw()
// <div id="id_natus_tooltip"> — tooltip portal (position:fixed on page)
//
// Public API under test:
// NatusWheel.draw(svgEl, data) — renders wheel; attaches hover listeners
// NatusWheel.clear() — empties the SVG (used in afterEach)
//
// Hover contract:
// mouseover on [data-planet] group → adds .nw-planet--hover class
// shows #id_natus_tooltip with
// planet name, in-sign degree, sign name
// and ℞ if retrograde
// mouseout on [data-planet] group → removes .nw-planet--hover
// hides #id_natus_tooltip
//
// In-sign degree: ecliptic_longitude % 30 (e.g. 338.4° → 8.4° Pisces)
//
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — planet tooltips", () => {
const SYNTHETIC_CHART = {
planets: {
Sun: { sign: "Pisces", degree: 338.4, retrograde: false },
Moon: { sign: "Capricorn", degree: 295.1, retrograde: false },
Mercury: { sign: "Aquarius", degree: 312.8, retrograde: true },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0,
mc: 270,
},
elements: { Fire: 1, Stone: 2, Air: 1, Water: 3, Time: 1, Space: 2 },
aspects: [],
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: "P",
};
let svgEl, tooltipEl;
beforeEach(() => {
// SVG element — D3 draws into this
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);
// Tooltip portal — same markup as _natus_overlay.html
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
NatusWheel.draw(svgEl, SYNTHETIC_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// ── T3 ── hover planet shows name / sign / in-sign degree + glow ─────────
it("T3: hovering a planet group adds the glow class and shows the tooltip with name, sign, and in-sign degree", () => {
const sun = svgEl.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG");
sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
expect(sun.classList.contains("nw-planet--hover")).toBe(true);
expect(tooltipEl.style.display).toBe("block");
const text = tooltipEl.textContent;
expect(text).toContain("Sun");
expect(text).toContain("Pisces");
// in-sign degree: 338.4° ecliptic 330° (Pisces start) = 8.4°
expect(text).toContain("8.4");
});
// ── T4 ── retrograde planet shows ℞ ──────────────────────────────────────
it("T4: hovering a retrograde planet shows ℞ in the tooltip", () => {
const mercury = svgEl.querySelector("[data-planet='Mercury']");
expect(mercury).not.toBeNull("expected [data-planet='Mercury'] to exist in the SVG");
mercury.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
expect(tooltipEl.textContent).toContain("℞");
});
// ── T5 ── mouseout hides tooltip and removes glow ─────────────────────────
it("T5: mouseout hides the tooltip and removes the glow class", () => {
const sun = svgEl.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG");
sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
// relatedTarget is document.body — outside the planet group
sun.dispatchEvent(new MouseEvent("mouseout", {
bubbles: true,
relatedTarget: document.body,
}));
expect(tooltipEl.style.display).toBe("none");
expect(sun.classList.contains("nw-planet--hover")).toBe(false);
});
});

View File

@@ -22,11 +22,14 @@
<script src="RoleSelectSpec.js"></script>
<script src="TraySpec.js"></script>
<script src="SigSelectSpec.js"></script>
<script src="NatusWheelSpec.js"></script>
<!-- src files -->
<script src="/static/apps/dashboard/dashboard.js"></script>
<script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/sig-select.js"></script>
<script src="/static/apps/gameboard/d3.min.js"></script>
<script src="/static/apps/gameboard/natus-wheel.js"></script>
<!-- Jasmine env config (optional) -->
<script src="lib/jasmine-6.0.1/boot1.js"></script>

View File

@@ -384,16 +384,16 @@ html.natus-open .natus-modal-wrap {
}
// Icon bg circles — element fill + matching border
.nw-sign-icon-bg--fire { fill: rgba(var(--terRd), 1); stroke: rgba(var(--priOr), 1); stroke-width: 1px; }
.nw-sign-icon-bg--stone { fill: rgba(var(--priYl), 1); stroke: rgba(var(--priLm), 1); stroke-width: 1px; }
.nw-sign-icon-bg--air { fill: rgba(var(--terGn), 1); stroke: rgba(var(--priTk), 1); stroke-width: 1px; }
.nw-sign-icon-bg--water { fill: rgba(var(--priCy), 1); stroke: rgba(var(--priBl), 1); stroke-width: 1px; }
.nw-sign-icon-bg--fire { fill: rgba(var(--terRd), 0.92); stroke: rgba(var(--priOr), 1); stroke-width: 1px; }
.nw-sign-icon-bg--stone { fill: rgba(var(--priYl), 0.92); stroke: rgba(var(--priLm), 1); stroke-width: 1px; }
.nw-sign-icon-bg--air { fill: rgba(var(--terGn), 0.92); stroke: rgba(var(--priTk), 1); stroke-width: 1px; }
.nw-sign-icon-bg--water { fill: rgba(var(--priCy), 0.92); stroke: rgba(var(--priBl), 1); stroke-width: 1px; }
// Inline SVG path icons — per-element colors
.nw-sign-icon--fire { fill: rgba(var(--priOr), 1); }
.nw-sign-icon--stone { fill: rgba(var(--terLm), 1); }
.nw-sign-icon--air { fill: rgba(var(--priTk), 1); }
.nw-sign-icon--water { fill: rgba(var(--terBl), 1); }
.nw-sign-icon--fire { fill: rgba(var(--priOr), 1); text-shadow: 2px 2px 2px rgba(0, 0, 0, 1); }
.nw-sign-icon--stone { fill: rgba(var(--terLm), 1); text-shadow: 2px 2px 2px rgba(0, 0, 0, 1); }
.nw-sign-icon--air { fill: rgba(var(--priTk), 1); text-shadow: 2px 2px 2px rgba(0, 0, 0, 1); }
.nw-sign-icon--water { fill: rgba(var(--terBl), 1); text-shadow: 2px 2px 2px rgba(0, 0, 0, 1); }
// House ring — uniform --priFs bg
.nw-house-cusp { stroke: rgba(var(--terUser), 1); stroke-width: 1.2px; }
@@ -431,6 +431,12 @@ html.natus-open .natus-modal-wrap {
.nw-planet-label--pu { fill: rgba(var(--sixPu), 1); stroke: rgba(var(--sixPu), 0.6); }
.nw-rx { fill: rgba(var(--terUser), 1); }
// Planet hover glow (--ninUser)
.nw-planet--hover {
filter: drop-shadow(0 0 5px rgba(var(--ninUser), 0.9));
cursor: pointer;
}
// Aspects
.nw-aspects { opacity: 0.8; }
@@ -444,6 +450,14 @@ html.natus-open .natus-modal-wrap {
.nw-element-label--time { fill: rgba(var(--priYl, 192, 160, 48), 1); }
.nw-element-label--space { fill: rgba(var(--priGn, 64, 96, 64), 1); }
// ── Planet hover tooltip — uses .tt base styles; overrides position + z ───────
#id_natus_tooltip {
position: fixed;
z-index: 200;
pointer-events: none;
}
// ── Sidebar z-index sink (landscape sidebars must go below backdrop) ───────────
@media (orientation: landscape) {

View File

@@ -0,0 +1,124 @@
// ── NatusWheelSpec.js ─────────────────────────────────────────────────────────
//
// Unit specs for natus-wheel.js — planet hover tooltips.
//
// DOM contract assumed:
// <svg id="id_natus_svg"> — target for NatusWheel.draw()
// <div id="id_natus_tooltip"> — tooltip portal (position:fixed on page)
//
// Public API under test:
// NatusWheel.draw(svgEl, data) — renders wheel; attaches hover listeners
// NatusWheel.clear() — empties the SVG (used in afterEach)
//
// Hover contract:
// mouseover on [data-planet] group → adds .nw-planet--hover class
// shows #id_natus_tooltip with
// planet name, in-sign degree, sign name
// and ℞ if retrograde
// mouseout on [data-planet] group → removes .nw-planet--hover
// hides #id_natus_tooltip
//
// In-sign degree: ecliptic_longitude % 30 (e.g. 338.4° → 8.4° Pisces)
//
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — planet tooltips", () => {
const SYNTHETIC_CHART = {
planets: {
Sun: { sign: "Pisces", degree: 338.4, retrograde: false },
Moon: { sign: "Capricorn", degree: 295.1, retrograde: false },
Mercury: { sign: "Aquarius", degree: 312.8, retrograde: true },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0,
mc: 270,
},
elements: { Fire: 1, Stone: 2, Air: 1, Water: 3, Time: 1, Space: 2 },
aspects: [],
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: "P",
};
let svgEl, tooltipEl;
beforeEach(() => {
// SVG element — D3 draws into this
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);
// Tooltip portal — same markup as _natus_overlay.html
tooltipEl = document.createElement("div");
tooltipEl.id = "id_natus_tooltip";
tooltipEl.className = "tt";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
NatusWheel.draw(svgEl, SYNTHETIC_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// ── T3 ── hover planet shows name / sign / in-sign degree + glow ─────────
it("T3: hovering a planet group adds the glow class and shows the tooltip with name, sign, and in-sign degree", () => {
const sun = svgEl.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG");
sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
expect(sun.classList.contains("nw-planet--hover")).toBe(true);
expect(tooltipEl.style.display).toBe("block");
const text = tooltipEl.textContent;
expect(text).toContain("Sun");
expect(text).toContain("Pisces");
// in-sign degree: 338.4° ecliptic 330° (Pisces start) = 8.4°
expect(text).toContain("8.4");
});
// ── T4 ── retrograde planet shows ℞ ──────────────────────────────────────
it("T4: hovering a retrograde planet shows ℞ in the tooltip", () => {
const mercury = svgEl.querySelector("[data-planet='Mercury']");
expect(mercury).not.toBeNull("expected [data-planet='Mercury'] to exist in the SVG");
mercury.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
expect(tooltipEl.textContent).toContain("℞");
});
// ── T5 ── mouseout hides tooltip and removes glow ─────────────────────────
it("T5: mouseout hides the tooltip and removes the glow class", () => {
const sun = svgEl.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun'] to exist in the SVG");
sun.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
expect(tooltipEl.style.display).toBe("block");
// relatedTarget is document.body — outside the planet group
sun.dispatchEvent(new MouseEvent("mouseout", {
bubbles: true,
relatedTarget: document.body,
}));
expect(tooltipEl.style.display).toBe("none");
expect(sun.classList.contains("nw-planet--hover")).toBe(false);
});
});

View File

@@ -22,11 +22,14 @@
<script src="RoleSelectSpec.js"></script>
<script src="TraySpec.js"></script>
<script src="SigSelectSpec.js"></script>
<script src="NatusWheelSpec.js"></script>
<!-- src files -->
<script src="/static/apps/dashboard/dashboard.js"></script>
<script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/sig-select.js"></script>
<script src="/static/apps/gameboard/d3.min.js"></script>
<script src="/static/apps/gameboard/natus-wheel.js"></script>
<!-- Jasmine env config (optional) -->
<script src="lib/jasmine-6.0.1/boot1.js"></script>

View File

@@ -99,7 +99,10 @@
</div>{# /.natus-modal-wrap #}
</div>{# /.natus-overlay #}
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
{# Planet hover tooltip — position:fixed so it escapes overflow:hidden on the modal #}
<div id="id_natus_tooltip" class="tt" style="display:none;"></div>
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
<script src="{% static 'apps/gameboard/natus-wheel.js' %}"></script>
<script>
(function () {