sky wheel: element contributor display; sign + house tooltips — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

sky_save now re-fetches from PySwiss server-side on save so stored
chart_data always carries enriched element format (contributors/stellia/
parades). New sky/data endpoint serves fresh PySwiss data to the My Sky
applet on load, replacing the stale inline json_script approach.

natus-wheel.js: sign ring slices (data-sign-name) and house ring slices
(data-house) now have click handlers with _activateSign/_activateHouse;
em-dash fallback added for classic elements with empty contributor lists.
Action URLs sky/preview, sky/save, sky/data lose trailing slashes.

Jasmine: T12 sign tooltip, T13 house tooltip, T14 enriched element
contributor display (symbols, Stellium/Parade formations, em-dash fallback).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-21 20:07:40 -04:00
parent 02975d79d3
commit b8ac004fb6
10 changed files with 868 additions and 95 deletions

View File

@@ -1,8 +1,8 @@
"""Integration tests for the My Sky dashboard views.
sky_view — GET /dashboard/sky/ → renders sky template
sky_preview — GET /dashboard/sky/preview/ → proxies to PySwiss (no DB write)
sky_save — POST /dashboard/sky/save/ → saves natal data to User model
sky_preview — GET /dashboard/sky/preview → proxies to PySwiss (no DB write)
sky_save — POST /dashboard/sky/save → saves natal data to User model
"""
import json
@@ -113,14 +113,13 @@ class SkySaveTest(TestCase):
self.assertEqual(response.status_code, 405)
def test_saves_sky_fields_to_user(self):
chart = {"planets": {}, "houses": {}, "elements": {}}
payload = {
"birth_dt": "1990-06-15T08:30:00",
"birth_lat": 51.5074,
"birth_lon": -0.1278,
"birth_place": "London, UK",
"house_system": "O",
"chart_data": chart,
"chart_data": {},
}
response = self._post(payload)
self.assertEqual(response.status_code, 200)
@@ -131,7 +130,6 @@ class SkySaveTest(TestCase):
self.assertAlmostEqual(float(self.user.sky_birth_lon), -0.1278, places=3)
self.assertEqual(self.user.sky_birth_place, "London, UK")
self.assertEqual(self.user.sky_house_system, "O")
self.assertEqual(self.user.sky_chart_data, chart)
def test_invalid_json_returns_400(self):
response = self.client.post(

View File

@@ -1,4 +1,6 @@
import json
import lxml.html
from unittest.mock import patch, MagicMock
from django.contrib.messages import get_messages
from django.test import override_settings, TestCase
@@ -482,3 +484,117 @@ class WalletAppletTest(TestCase):
def test_wallet_applet_has_manage_link(self):
[link] = self.parsed.cssselect("#id_applet_wallet a.wallet-manage-link")
self.assertEqual(link.get("href"), "/dashboard/wallet/")
ENRICHED_CHART = {
"planets": {"Sun": {"lon": 10.0, "sign": "Aries", "house": 1, "degree": 10.0}},
"houses": {"cusps": [float(i * 30) for i in range(12)]},
"elements": {
"Fire": {"count": 3, "contributors": ["Sun", "Mars", "Jupiter"]},
"Stone": {"count": 1, "contributors": ["Venus"]},
"Air": {"count": 2, "contributors": ["Mercury", "Uranus"]},
"Water": {"count": 0, "contributors": []},
"Time": {"count": 1, "stellia": ["Saturn"]},
"Space": {"count": 1, "parades": ["Neptune"]},
},
"distinctions": [],
"timezone": "UTC",
}
BIRTH_PAYLOAD = {
"birth_dt": "1990-06-15T12:00:00Z",
"birth_lat": 51.5,
"birth_lon": -0.1,
"birth_place": "London",
"house_system": "O",
"chart_data": {"stale": True},
}
@override_settings(PYSWISS_URL="http://pyswiss-test")
class SkySaveViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(
email="disco@test.io",
sky_birth_lat=51.5,
sky_birth_lon=-0.1,
sky_birth_place="London",
)
self.client.force_login(self.user)
def _post(self, payload=None):
return self.client.post(
"/dashboard/sky/save",
data=json.dumps(payload or BIRTH_PAYLOAD),
content_type="application/json",
)
@patch("apps.dashboard.views.http_requests.get")
def test_save_fetches_enriched_data_from_pyswiss(self, mock_get):
mock_resp = MagicMock()
mock_resp.raise_for_status.return_value = None
mock_resp.json.return_value = dict(ENRICHED_CHART)
mock_get.return_value = mock_resp
response = self._post()
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
saved = self.user.sky_chart_data
self.assertIsNotNone(saved)
fire = saved["elements"]["Fire"]
self.assertIn("contributors", fire)
self.assertIn("Sun", fire["contributors"])
@patch("apps.dashboard.views.http_requests.get")
def test_save_falls_back_to_client_data_if_pyswiss_unreachable(self, mock_get):
mock_get.side_effect = Exception("connection refused")
client_chart = {"planets": {}, "elements": {"Fire": 2}}
payload = dict(BIRTH_PAYLOAD, chart_data=client_chart)
response = self._post(payload)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertEqual(self.user.sky_chart_data, client_chart)
@override_settings(PYSWISS_URL="http://pyswiss-test")
class SkyNatusDataViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(
email="disco@test.io",
sky_birth_lat=51.5,
sky_birth_lon=-0.1,
sky_birth_place="London",
)
self.client.force_login(self.user)
@patch("apps.dashboard.views.http_requests.get")
def test_returns_enriched_chart_data(self, mock_get):
from datetime import datetime, timezone as dt_timezone
self.user.sky_birth_dt = datetime(1990, 6, 15, 12, 0, tzinfo=dt_timezone.utc)
self.user.sky_house_system = "O"
self.user.save(update_fields=["sky_birth_dt", "sky_house_system"])
mock_resp = MagicMock()
mock_resp.raise_for_status.return_value = None
mock_resp.json.return_value = dict(ENRICHED_CHART)
mock_get.return_value = mock_resp
response = self.client.get("/dashboard/sky/data")
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertIn("elements", data)
fire = data["elements"]["Fire"]
self.assertIn("contributors", fire)
def test_returns_404_if_no_birth_data_saved(self):
response = self.client.get("/dashboard/sky/data")
self.assertEqual(response.status_code, 404)
def test_requires_login(self):
self.client.logout()
response = self.client.get("/dashboard/sky/data")
self.assertRedirects(response, "/?next=/dashboard/sky/data", fetch_redirect_response=False)

View File

@@ -15,6 +15,7 @@ urlpatterns = [
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
path('kit-bag/', views.kit_bag, name='kit_bag'),
path('sky/', views.sky_view, name='sky'),
path('sky/preview/', views.sky_preview, name='sky_preview'),
path('sky/save/', views.sky_save, name='sky_save'),
path('sky/preview', views.sky_preview, name='sky_preview'),
path('sky/save', views.sky_save, name='sky_save'),
path('sky/data', views.sky_natus_data, name='sky_natus_data'),
]

View File

@@ -301,8 +301,8 @@ def _sky_natus_preview(request):
@login_required(login_url="/")
def sky_view(request):
return render(request, "apps/dashboard/sky.html", {
"preview_url": request.build_absolute_uri("/dashboard/sky/preview/"),
"save_url": request.build_absolute_uri("/dashboard/sky/save/"),
"preview_url": request.build_absolute_uri("/dashboard/sky/preview"),
"save_url": request.build_absolute_uri("/dashboard/sky/save"),
"saved_sky": request.user.sky_chart_data,
"saved_birth_dt": request.user.sky_birth_dt,
"saved_birth_place": request.user.sky_birth_place,
@@ -327,21 +327,72 @@ def sky_save(request):
user = request.user
birth_dt_str = body.get('birth_dt', '')
birth_dt_utc = None
if birth_dt_str:
try:
naive = datetime.fromisoformat(birth_dt_str.replace('Z', '+00:00'))
user.sky_birth_dt = naive if naive.tzinfo else naive.replace(
tzinfo=zoneinfo.ZoneInfo('UTC')
)
birth_dt_utc = user.sky_birth_dt.astimezone(zoneinfo.ZoneInfo('UTC'))
except ValueError:
user.sky_birth_dt = None
user.sky_birth_lat = body.get('birth_lat')
user.sky_birth_lon = body.get('birth_lon')
user.sky_birth_place = body.get('birth_place', '')
user.sky_house_system = body.get('house_system', 'O')
user.sky_chart_data = body.get('chart_data')
lat_str = body.get('birth_lat')
lon_str = body.get('birth_lon')
if birth_dt_utc and lat_str is not None and lon_str is not None:
try:
dt_iso = birth_dt_utc.strftime('%Y-%m-%dT%H:%M:%SZ')
resp = http_requests.get(
settings.PYSWISS_URL + '/api/chart/',
params={'dt': dt_iso, 'lat': str(lat_str), 'lon': str(lon_str)},
timeout=5,
)
resp.raise_for_status()
enriched = resp.json()
if 'elements' in enriched and 'Earth' in enriched['elements']:
enriched['elements']['Stone'] = enriched['elements'].pop('Earth')
user.sky_chart_data = enriched
except Exception:
user.sky_chart_data = body.get('chart_data')
else:
user.sky_chart_data = body.get('chart_data')
user.save(update_fields=[
'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon',
'sky_birth_place', 'sky_house_system', 'sky_chart_data',
])
return JsonResponse({"saved": True})
@login_required(login_url="/")
def sky_natus_data(request):
user = request.user
if not user.sky_birth_lat or not user.sky_birth_lon or not user.sky_birth_dt:
return HttpResponse(status=404)
try:
utc_dt = user.sky_birth_dt.astimezone(zoneinfo.ZoneInfo('UTC'))
dt_iso = utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
resp = http_requests.get(
settings.PYSWISS_URL + '/api/chart/',
params={
'dt': dt_iso,
'lat': str(user.sky_birth_lat),
'lon': str(user.sky_birth_lon),
},
timeout=5,
)
resp.raise_for_status()
except Exception:
return HttpResponse(status=502)
data = resp.json()
if 'elements' in data and 'Earth' in data['elements']:
data['elements']['Stone'] = data['elements'].pop('Earth')
data['distinctions'] = _compute_distinctions(data['planets'], data['houses'])
return JsonResponse(data)

View File

@@ -119,10 +119,12 @@ const NatusWheel = (() => {
// ── Cycle state ────────────────────────────────────────────────────────────
let _activeRing = null; // 'planets' | 'elements' | null
let _activeRing = null; // 'planets' | 'elements' | 'signs' | 'houses' | null
let _activeIdx = null; // index within the active ring's sorted list
let _planetItems = []; // [{name, degree}] sorted by ecliptic degree ascending
let _elementItems = []; // [{key}] in ELEMENT_ORDER
let _signItems = []; // [{name, symbol, element}] in SIGNS order
let _houseItems = []; // [{num, label}] houses 112
// Tooltip DOM refs — set by _injectTooltipControls() on each draw().
let _tooltipEl = null;
@@ -233,6 +235,11 @@ const NatusWheel = (() => {
.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],
}));
}
/** Clear all active-lock classes and reset cycle state. */
@@ -320,9 +327,15 @@ const NatusWheel = (() => {
if (ring === 'planets') {
const grp = svgNode.querySelector(`[data-planet="${_planetItems[idx].name}"]`);
el = grp && (grp.querySelector('circle') || grp);
} else {
} 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);
}
if (el) iRect = el.getBoundingClientRect();
}
@@ -434,6 +447,8 @@ const NatusWheel = (() => {
bodyHtml += `<div class="tt-asp-row">${psym} @ ${inDeg}° ${sicon} +1</div>`;
});
bodyHtml += '</div>';
} else {
bodyHtml += `<div class="tt-el-formation">—</div>`;
}
} else if (item.key === 'Time') {
@@ -506,6 +521,43 @@ const NatusWheel = (() => {
}
}
function _activateSign(idx) {
_activeRing = 'signs';
_activeIdx = idx;
const sign = _signItems[idx];
if (_ttBody) {
_ttBody.innerHTML =
`<div class="tt-sign-header">` +
`<span class="tt-sign-symbol">${sign.symbol}</span>` +
`<span class="tt-title">${sign.name}</span>` +
`<span class="tt-sign-element"> · ${sign.element}</span>` +
`</div>`;
}
_positionTooltipAtItem('signs', idx);
if (_tooltipEl) {
_tooltipEl.querySelector('.nw-asp-don')?.style.setProperty('display', 'none');
_tooltipEl.querySelector('.nw-asp-doff')?.style.setProperty('display', 'none');
}
}
function _activateHouse(idx) {
_activeRing = 'houses';
_activeIdx = idx;
const house = _houseItems[idx];
if (_ttBody) {
_ttBody.innerHTML =
`<div class="tt-house-header">` +
`<span class="tt-title">${house.num}</span>` +
`<span class="tt-house-label"> · ${house.label}</span>` +
`</div>`;
}
_positionTooltipAtItem('houses', 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). */
function _stepCycle(dir) {
if (_activeRing === 'planets') {
@@ -514,6 +566,12 @@ const NatusWheel = (() => {
} 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);
}
}
@@ -661,7 +719,20 @@ const NatusWheel = (() => {
const endA = _toAngle(endDeg, asc);
const [sa, ea] = startA > endA ? [endA, startA] : [startA, endA];
sigGroup.append('path')
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,
@@ -677,14 +748,14 @@ const NatusWheel = (() => {
const cr = _r * 0.065;
const sf = (cr * 2 * 0.85) / 640;
sigGroup.append('circle')
signSlice.append('circle')
.attr('cx', lx)
.attr('cy', ly)
.attr('r', cr)
.attr('class', `nw-sign-icon-bg--${sign.element.toLowerCase()}`);
if (_signPaths[sign.name]) {
sigGroup.append('path')
signSlice.append('path')
.attr('d', _signPaths[sign.name])
.attr('transform',
`translate(${lx},${ly}) scale(${sf}) translate(-320,-320)`)
@@ -716,7 +787,17 @@ const NatusWheel = (() => {
startAngle: sa + Math.PI / 2,
endAngle: ea + Math.PI / 2,
}))
.attr('class', i % 2 === 0 ? 'nw-house-fill--even' : 'nw-house-fill--odd');
.attr('class', i % 2 === 0 ? 'nw-house-fill--even' : 'nw-house-fill--odd')
.attr('data-house', i + 1)
.on('click', function (event) {
event.stopPropagation();
const clickIdx = i; // _houseItems[i].num === i+1
if (_activeRing === 'houses' && _activeIdx === clickIdx) {
_closeTooltip();
} else {
_activateHouse(clickIdx);
}
});
});
houses.forEach(({ i, startA, midA }) => {

View File

@@ -246,13 +246,13 @@ class MySkyAppletFormTest(FunctionalTest):
const FIXTURE = """ + _json.dumps(_CHART_FIXTURE) + """;
window._origFetch = window.fetch;
window.fetch = function(url, opts) {
if (url.includes('/sky/preview/')) {
if (url.includes('/sky/preview')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(FIXTURE),
});
}
if (url.includes('/sky/save/')) {
if (url.includes('/sky/save')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({saved: true}),

View File

@@ -551,3 +551,271 @@ xdescribe("NatusWheel — half-wheel tooltip positioning", () => {
expect(parseFloat(tooltipEl.style.left)).toBe(270);
});
});
// ── T14 — element tooltip shows contributor planets (enriched data) ───────────
//
// When element data arrives in enriched format {count, contributors/stellia/parades},
// clicking a classic-element slice lists contributor planet names in the tooltip.
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — element tooltip contributor display", () => {
const ENRICHED_CHART = {
planets: {
Sun: { sign: "Gemini", degree: 66.7, retrograde: false },
Venus: { sign: "Gemini", degree: 63.3, retrograde: false },
Mars: { sign: "Leo", degree: 132.0, retrograde: false },
Saturn: { sign: "Virgo", degree: 153.0, retrograde: false },
},
houses: {
cusps: [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150],
asc: 180.0, mc: 90.0,
},
elements: {
Fire: { count: 2, contributors: [
{ planet: "Sun", sign: "Gemini" },
{ planet: "Mars", sign: "Leo" },
]},
Stone: { count: 1, contributors: [
{ planet: "Saturn", sign: "Virgo" },
]},
Air: { count: 0, contributors: [] },
Water: { count: 0, contributors: [] },
Time: { count: 1, stellia: [
{ sign: "Gemini", planets: [
{ planet: "Sun", sign: "Gemini" },
{ planet: "Venus", sign: "Gemini" },
]},
]},
Space: { count: 1, parades: [
{ signs: ["Gemini", "Leo", "Virgo"],
planets: [
{ planet: "Sun", sign: "Gemini" },
{ planet: "Mars", sign: "Leo" },
{ planet: "Saturn", sign: "Virgo" },
]},
]},
},
aspects: [],
distinctions: { "1":0,"2":0,"3":2,"4":0,"5":0,"6":0,"7":0,"8":0,"9":1,"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, ENRICHED_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// T14a — Fire slice lists contributor planet symbols ☉ (Sun) and ♂ (Mars)
it("T14a: clicking Fire element slice shows contributor planet symbols", () => {
const fireSlice = svgEl.querySelector("[data-element='Fire']");
expect(fireSlice).not.toBeNull();
fireSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
const body = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(body).toContain("☉"); // Sun
expect(body).toContain("♂"); // Mars
});
// T14b — Space slice shows parade formation with planet symbols
it("T14b: clicking Space element slice shows parade formation block", () => {
const spaceSlice = svgEl.querySelector("[data-element='Space']");
spaceSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const body = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(body).toContain("Parade");
// Planet symbols for Sun ☉, Mars ♂, Saturn ♄ appear in the parade
expect(body).toContain("☉");
});
// T14c — Time slice shows stellium formation with planet symbols
it("T14c: clicking Time element slice shows stellium formation block", () => {
const timeSlice = svgEl.querySelector("[data-element='Time']");
timeSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const body = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(body).toContain("Stellium");
// Sun ☉ and Venus ♀ are in the Gemini stellium
expect(body).toContain("☉");
expect(body).toContain("♀");
});
// T14d — Air slice (count 0) shows em dash fallback, not an empty list
it("T14d: empty element slice shows em dash fallback", () => {
const airSlice = svgEl.querySelector("[data-element='Air']");
airSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const body = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(body).toContain("—");
});
});
// ── T12 — sign ring click tooltips ────────────────────────────────────────────
//
// Clicking a sign slice shows:
// ♉ Taurus · Stone
// Clicking the same sign again closes the tooltip.
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — sign ring click tooltips", () => {
const SIGN_CHART = {
planets: {
Sun: { sign: "Taurus", degree: 40.0, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0,
mc: 270,
},
elements: { Fire: 0, Stone: 1, Air: 0, Water: 0, Time: 0, Space: 0 },
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: "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, SIGN_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// T12a — clicking a sign shows symbol + name + element
it("T12a: clicking a sign slice shows sign symbol, name, and element", () => {
const taurusSlice = svgEl.querySelector("[data-sign-name='Taurus']");
expect(taurusSlice).not.toBeNull();
taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
const bodyText = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(bodyText).toContain("Taurus");
expect(bodyText).toContain("Stone");
expect(bodyText).toContain("♉");
});
// T12b — clicking same sign again closes the tooltip
it("T12b: clicking the same sign again hides the tooltip", () => {
const taurusSlice = svgEl.querySelector("[data-sign-name='Taurus']");
taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("none");
});
});
// ── T13 — house ring click tooltips ───────────────────────────────────────────
//
// Clicking a house slice shows:
// 3 · Education
// Clicking the same house again closes the tooltip.
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — house ring click tooltips", () => {
const HOUSE_CHART = {
planets: {
Sun: { sign: "Gemini", degree: 66.7, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0,
mc: 270,
},
elements: { Fire: 1, Stone: 0, Air: 0, Water: 0, Time: 0, Space: 0 },
aspects: [],
distinctions: { "1":0,"2":0,"3":1,"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, HOUSE_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// T13a — clicking a house slice shows house number + label
it("T13a: clicking a house slice shows house number and house label", () => {
// House 3 is at index 2 (zero-based), cusps[2]=60° span
const house3 = svgEl.querySelector("[data-house='3']");
expect(house3).not.toBeNull();
house3.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
const bodyText = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(bodyText).toContain("3");
expect(bodyText).toContain("Education");
});
// T13b — clicking same house again closes the tooltip
it("T13b: clicking the same house again hides the tooltip", () => {
const house3 = svgEl.querySelector("[data-house='3']");
house3.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
house3.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("none");
});
});

View File

@@ -551,3 +551,271 @@ xdescribe("NatusWheel — half-wheel tooltip positioning", () => {
expect(parseFloat(tooltipEl.style.left)).toBe(270);
});
});
// ── T14 — element tooltip shows contributor planets (enriched data) ───────────
//
// When element data arrives in enriched format {count, contributors/stellia/parades},
// clicking a classic-element slice lists contributor planet names in the tooltip.
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — element tooltip contributor display", () => {
const ENRICHED_CHART = {
planets: {
Sun: { sign: "Gemini", degree: 66.7, retrograde: false },
Venus: { sign: "Gemini", degree: 63.3, retrograde: false },
Mars: { sign: "Leo", degree: 132.0, retrograde: false },
Saturn: { sign: "Virgo", degree: 153.0, retrograde: false },
},
houses: {
cusps: [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150],
asc: 180.0, mc: 90.0,
},
elements: {
Fire: { count: 2, contributors: [
{ planet: "Sun", sign: "Gemini" },
{ planet: "Mars", sign: "Leo" },
]},
Stone: { count: 1, contributors: [
{ planet: "Saturn", sign: "Virgo" },
]},
Air: { count: 0, contributors: [] },
Water: { count: 0, contributors: [] },
Time: { count: 1, stellia: [
{ sign: "Gemini", planets: [
{ planet: "Sun", sign: "Gemini" },
{ planet: "Venus", sign: "Gemini" },
]},
]},
Space: { count: 1, parades: [
{ signs: ["Gemini", "Leo", "Virgo"],
planets: [
{ planet: "Sun", sign: "Gemini" },
{ planet: "Mars", sign: "Leo" },
{ planet: "Saturn", sign: "Virgo" },
]},
]},
},
aspects: [],
distinctions: { "1":0,"2":0,"3":2,"4":0,"5":0,"6":0,"7":0,"8":0,"9":1,"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, ENRICHED_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// T14a — Fire slice lists contributor planet symbols ☉ (Sun) and ♂ (Mars)
it("T14a: clicking Fire element slice shows contributor planet symbols", () => {
const fireSlice = svgEl.querySelector("[data-element='Fire']");
expect(fireSlice).not.toBeNull();
fireSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
const body = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(body).toContain("☉"); // Sun
expect(body).toContain("♂"); // Mars
});
// T14b — Space slice shows parade formation with planet symbols
it("T14b: clicking Space element slice shows parade formation block", () => {
const spaceSlice = svgEl.querySelector("[data-element='Space']");
spaceSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const body = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(body).toContain("Parade");
// Planet symbols for Sun ☉, Mars ♂, Saturn ♄ appear in the parade
expect(body).toContain("☉");
});
// T14c — Time slice shows stellium formation with planet symbols
it("T14c: clicking Time element slice shows stellium formation block", () => {
const timeSlice = svgEl.querySelector("[data-element='Time']");
timeSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const body = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(body).toContain("Stellium");
// Sun ☉ and Venus ♀ are in the Gemini stellium
expect(body).toContain("☉");
expect(body).toContain("♀");
});
// T14d — Air slice (count 0) shows em dash fallback, not an empty list
it("T14d: empty element slice shows em dash fallback", () => {
const airSlice = svgEl.querySelector("[data-element='Air']");
airSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const body = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(body).toContain("—");
});
});
// ── T12 — sign ring click tooltips ────────────────────────────────────────────
//
// Clicking a sign slice shows:
// ♉ Taurus · Stone
// Clicking the same sign again closes the tooltip.
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — sign ring click tooltips", () => {
const SIGN_CHART = {
planets: {
Sun: { sign: "Taurus", degree: 40.0, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0,
mc: 270,
},
elements: { Fire: 0, Stone: 1, Air: 0, Water: 0, Time: 0, Space: 0 },
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: "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, SIGN_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// T12a — clicking a sign shows symbol + name + element
it("T12a: clicking a sign slice shows sign symbol, name, and element", () => {
const taurusSlice = svgEl.querySelector("[data-sign-name='Taurus']");
expect(taurusSlice).not.toBeNull();
taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
const bodyText = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(bodyText).toContain("Taurus");
expect(bodyText).toContain("Stone");
expect(bodyText).toContain("♉");
});
// T12b — clicking same sign again closes the tooltip
it("T12b: clicking the same sign again hides the tooltip", () => {
const taurusSlice = svgEl.querySelector("[data-sign-name='Taurus']");
taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
taurusSlice.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("none");
});
});
// ── T13 — house ring click tooltips ───────────────────────────────────────────
//
// Clicking a house slice shows:
// 3 · Education
// Clicking the same house again closes the tooltip.
// ─────────────────────────────────────────────────────────────────────────────
describe("NatusWheel — house ring click tooltips", () => {
const HOUSE_CHART = {
planets: {
Sun: { sign: "Gemini", degree: 66.7, retrograde: false },
},
houses: {
cusps: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
asc: 0,
mc: 270,
},
elements: { Fire: 1, Stone: 0, Air: 0, Water: 0, Time: 0, Space: 0 },
aspects: [],
distinctions: { "1":0,"2":0,"3":1,"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, HOUSE_CHART);
});
afterEach(() => {
NatusWheel.clear();
svgEl.remove();
tooltipEl.remove();
});
// T13a — clicking a house slice shows house number + label
it("T13a: clicking a house slice shows house number and house label", () => {
// House 3 is at index 2 (zero-based), cusps[2]=60° span
const house3 = svgEl.querySelector("[data-house='3']");
expect(house3).not.toBeNull();
house3.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
const bodyText = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(bodyText).toContain("3");
expect(bodyText).toContain("Education");
});
// T13b — clicking same house again closes the tooltip
it("T13b: clicking the same house again hides the tooltip", () => {
const house3 = svgEl.querySelector("[data-house='3']");
house3.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).not.toBe("none");
house3.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.style.display).toBe("none");
});
});

View File

@@ -85,9 +85,17 @@
{% if request.user.sky_chart_data %}
// Sky already saved — draw the stored wheel immediately.
const data = JSON.parse(document.getElementById('id_my_sky_data').textContent);
NatusWheel.preload().then(function () { NatusWheel.draw(svgEl, data); });
// Sky already saved — fetch fresh enriched data from server then draw.
fetch('{% url "sky_natus_data" %}')
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) {
NatusWheel.preload().then(function () { NatusWheel.draw(svgEl, data); });
})
.catch(function () {
// Fallback: draw from inline stale data if endpoint fails.
var stale = JSON.parse(document.getElementById('id_my_sky_data').textContent);
NatusWheel.preload().then(function () { NatusWheel.draw(svgEl, stale); });
});
{% else %}