SEED MAP shared wheel rim: the table's OWN sky (planets-only, canonical signs) rings the tessellation — one frame for all six gamers — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

The Voronoi felt gains a stripped sky-wheel rim drawn from the ROOM's own sky
(Room.sky_chart) — identical for every gamer, updating toward the shared map —
with the tessellation sized into the wheel's freed hub. Roadmap step 21, Step
2's coordinate frame.

- Room.convened_at + Room.sky_chart (migration 0019); pick_roles stamps
  convened_at at gate-close (stamp only — no HTTP on the transition)
- epic.table_sky: lazy planets-only chart via PySwiss at the null location
  (geocentric longitudes need only the convened TIME; houses/ASC/MC need a
  birth LOCATION a virtual table lacks → omitted), cached on Room.sky_chart;
  legacy rooms key off created_at; seated-gamer gated, 502 on PySwiss down
- SkyWheel.drawRim(svg, data): pure static renderer — canonical asc=0 frame,
  signs ring + planet glyphs only, NO element ring / centre disc / houses /
  axes / aspects / tooltips; never writes the interactive wheel's singleton
  state; returns {size, cx, cy, r, hubR} so the felt sizes the map into the hub
- _seed_map_overlay.html: rim draws on open; map svg shrinks to 2×hubR +
  .voronoi-map--rimmed clip; lazy table-sky fetch on open; preload-then-repaint
  so a cold-cache open doesn't strand the zodiac glyphs; ResizeObserver on the
  col (not the self-sized map svg)
- _sky.scss: stacked centred svgs in .seed-map-col; .seed-wheel pointer-events
  none; circle clip on the rimmed map
- room_sky_json ctx in _role_select_context; rootvars: --sixUser/--octUser
  nudged within the Trs ramp (parallel palette tune)
- drawRim Jasmine suite (R1–R6: signs+planets, strip, hub geometry, static
  placement, singleton untouched, signs-only fallback) in both spec copies;
  epic TableSkyViewTest + convened_at stamp + seed-overlay rim ITs; FT rim
  assertions (12 signs, 3 planets, no stripped/located layers, hub sizing)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-10 00:33:23 -04:00
parent cde556b178
commit 9ed877168e
12 changed files with 764 additions and 21 deletions

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0 on 2026-06-10 03:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0018_remove_sigreservation_one_sig_reservation_per_gamer_per_room_and_more'),
]
operations = [
migrations.AddField(
model_name='room',
name='convened_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='room',
name='sky_chart',
field=models.JSONField(blank=True, null=True),
),
]

View File

@@ -57,6 +57,13 @@ class Room(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
board_state = models.JSONField(default=dict)
seed_count = models.IntegerField(default=12)
# The table's OWN sky — the shared SEED MAP rim frame ([[project-voronoi-spec]]).
# convened_at = the gate-close moment (table_status first set in pick_roles);
# sky_chart = the planets-only chart cast for that moment at the null location
# (houses/ASC/MC need a birth LOCATION a virtual table doesn't have), computed
# LAZILY by the table_sky view — legacy rooms key off created_at.
convened_at = models.DateTimeField(null=True, blank=True)
sky_chart = models.JSONField(null=True, blank=True)
def get_thread_post(self):
"""Get-or-create this room's single game-table thread Post (the POST

View File

@@ -1102,6 +1102,15 @@ class PickRolesViewTest(TestCase):
self.client.post(url) # second call must be a no-op
self.assertEqual(TableSeat.objects.filter(room=self.room).count(), 6)
def test_pick_roles_stamps_convened_at(self):
"""The table's convene moment — the timestamp its OWN sky is cast from
(the shared SEED MAP rim frame; planets-only, no location). Stamped at
the gate-close transition; the chart itself computes LAZILY elsewhere
so this transition never fires HTTP."""
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
self.room.refresh_from_db()
self.assertIsNotNone(self.room.convened_at)
class SelectRoleViewTest(TestCase):
def setUp(self):
@@ -2909,6 +2918,104 @@ class SkyPreviewViewTest(TestCase):
self.assertNotIn("Earth", data["elements"])
class TableSkyViewTest(TestCase):
"""The table's OWN sky — the shared SEED MAP rim frame ([[project-voronoi-spec]]).
Planets-only: geocentric longitudes need only the convened TIME, while
houses/ASC/MC need a birth LOCATION a virtual table doesn't have. Computed
LAZILY on first request via PySwiss at the null location and cached on
Room.sky_chart — never at the pick_roles transition, so gate-close tests
stay HTTP-free."""
_PLANETS = {"Sun": {"sign": "Pisces", "degree": 338.4, "retrograde": False}}
def setUp(self):
self.user = User.objects.create(email="pc@tablesky.io")
self.client.force_login(self.user)
self.room, _ = _make_sig_room(self.user)
self.room.table_status = Room.SKY_SELECT
self.room.convened_at = timezone.now()
self.room.save()
self.url = reverse("epic:table_sky", kwargs={"room_id": self.room.id})
@patch("apps.epic.views.http_requests")
def test_returns_stored_chart_without_calling_pyswiss(self, mock_requests):
self.room.sky_chart = {"planets": self._PLANETS, "aspects": []}
self.room.save(update_fields=["sky_chart"])
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["planets"], self._PLANETS)
mock_requests.get.assert_not_called()
@patch("apps.epic.views.http_requests")
def test_computes_planets_only_at_null_location_and_caches(self, mock_requests):
from unittest.mock import MagicMock
ch_r = MagicMock()
ch_r.json.return_value = {
"planets": self._PLANETS,
"houses": {"cusps": [0] * 12, "asc": 0, "mc": 270},
"elements": {"Earth": 1},
"aspects": [{"planet1": "Sun", "planet2": "Moon", "type": "Trine",
"orb": 1.2, "applying_planet": "Moon"}],
}
ch_r.raise_for_status = MagicMock()
mock_requests.get.return_value = ch_r
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
data = response.json()
# Location-independent keys survive; location-bound houses NEVER do.
self.assertEqual(data["planets"], self._PLANETS)
self.assertEqual(len(data["aspects"]), 1)
self.assertNotIn("houses", data)
# Cast at the convened moment, at the null location.
_, kwargs = mock_requests.get.call_args
self.assertEqual(kwargs["params"]["lat"], "0")
self.assertEqual(kwargs["params"]["lon"], "0")
self.assertEqual(
kwargs["params"]["dt"],
self.room.convened_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
)
# ...and cached: the second GET serves the store, no second call.
self.room.refresh_from_db()
self.assertEqual(self.room.sky_chart["planets"], self._PLANETS)
self.client.get(self.url)
self.assertEqual(mock_requests.get.call_count, 1)
@patch("apps.epic.views.http_requests")
def test_falls_back_to_created_at_for_legacy_rooms(self, mock_requests):
"""Rooms that convened before the stamp existed key off created_at."""
from unittest.mock import MagicMock
self.room.convened_at = None
self.room.save(update_fields=["convened_at"])
ch_r = MagicMock()
ch_r.json.return_value = {"planets": self._PLANETS}
ch_r.raise_for_status = MagicMock()
mock_requests.get.return_value = ch_r
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
_, kwargs = mock_requests.get.call_args
self.assertEqual(
kwargs["params"]["dt"],
self.room.created_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
)
@patch("apps.epic.views.http_requests")
def test_pyswiss_failure_returns_502_and_caches_nothing(self, mock_requests):
from unittest.mock import MagicMock
ch_r = MagicMock()
ch_r.raise_for_status.side_effect = Exception("down")
mock_requests.get.return_value = ch_r
response = self.client.get(self.url)
self.assertEqual(response.status_code, 502)
self.room.refresh_from_db()
self.assertIsNone(self.room.sky_chart)
def test_unseated_gamer_gets_403(self):
outsider = User.objects.create(email="outsider@tablesky.io")
self.client.force_login(outsider)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 403)
# ── tarot_deal ────────────────────────────────────────────────────────────────
class TarotDealViewTest(TestCase):
@@ -4634,6 +4741,34 @@ class PickSeaUnifiedFeltTest(TestCase):
content = self.client.get(self.url).content.decode()
self.assertGreaterEqual(content.count("closeSeedMapFelt"), 3)
def test_seed_map_overlay_renders_shared_room_sky_rim(self):
"""Step 2's shared frame: the felt carries the wheel-rim svg + the
ROOM's own sky (one canonical frame for all six gamers — NOT the
viewing seat's natal chart), drawn via SkyWheel.drawRim. sky-wheel.js
must NOT be re-included — the sky overlay already loads it, and a
second top-level `const SkyWheel` declaration throws."""
self._complete_hand()
self.room.sky_chart = {
"planets": {"Sun": {"sign": "Pisces", "degree": 338.4, "retrograde": False}},
"aspects": [],
}
self.room.save(update_fields=["sky_chart"])
content = self.client.get(self.url).content.decode()
self.assertIn('id="id_seed_wheel_svg"', content)
self.assertIn("SkyWheel.drawRim", content)
self.assertIn("338.4", content) # the room sky, embedded
self.assertIn("data-table-sky-url", content) # lazy compute endpoint
self.assertEqual(content.count("apps/gameboard/sky-wheel.js"), 1)
def test_seed_map_overlay_rim_survives_missing_room_sky(self):
"""No stored room sky yet → the rim svg + lazy fetch URL still render:
the felt fetches table_sky on open (which computes + caches), and the
canonical signs-only frame draws while the planets are absent."""
self._complete_hand()
content = self.client.get(self.url).content.decode()
self.assertIn('id="id_seed_wheel_svg"', content)
self.assertIn("data-table-sky-url", content)
def test_sea_felt_leaves_sky_btn_lit_and_swaps_cleanly(self):
"""Symmetric with Sky Select (which leaves the sea btn lit while the sky
felt is up): opening the Sea Select felt must NOT grey the burger Sky

View File

@@ -32,6 +32,7 @@ urlpatterns = [
path('room/<uuid:room_id>/sky/preview', views.sky_preview, name='sky_preview'),
path('room/<uuid:room_id>/sky/save', views.sky_save, name='sky_save'),
path('room/<uuid:room_id>/sky/delete', views.sky_delete, name='sky_delete'),
path('room/<uuid:room_id>/sky/table', views.table_sky, name='table_sky'),
path('room/<uuid:room_id>/sea/partial', views.sea_partial, name='sea_partial'),
path('room/<uuid:room_id>/sea/deck', views.sea_deck, name='sea_deck'),
path('room/<uuid:room_id>/sea/save', views.sea_save, name='sea_save'),

View File

@@ -696,6 +696,11 @@ def _role_select_context(room, user, seat_param=None):
if (sky_confirmed and confirmed_char.chart_data)
else "null"
)
# The table's OWN sky — the shared SEED MAP rim frame (one canonical
# frame for all six gamers, NOT this seat's natal chart). None until
# the lazy table_sky endpoint computes + caches it; the felt then
# fetches it on open.
ctx["room_sky_json"] = json.dumps(room.sky_chart) if room.sky_chart else None
if sky_confirmed:
# Fall back to seat.significator for Characters created before the sync was added
ctx["my_tray_sig"] = confirmed_char.significator or _sky_seat.significator
@@ -1282,6 +1287,10 @@ def pick_roles(request, room_id):
room = Room.objects.get(id=room_id)
if room.gate_status == Room.OPEN and room.table_status is None:
room.table_status = Room.ROLE_SELECT
# The table's convene moment — the timestamp its OWN sky is cast
# from (the shared SEED MAP rim). Stamp only; the chart computes
# LAZILY in table_sky so this transition never fires HTTP.
room.convened_at = timezone.now()
room.save()
for slot in room.gate_slots.filter(status=GateSlot.FILLED).order_by("slot_number"):
TableSeat.objects.create(
@@ -1823,6 +1832,47 @@ def sky_preview(request, room_id):
return JsonResponse(data)
@login_required
def table_sky(request, room_id):
"""The table's OWN sky — the shared SEED MAP rim frame ([[project-voronoi-spec]]).
Planets-only: geocentric longitudes need only the convened TIME (gate
close, Room.convened_at; legacy rooms key off created_at), while houses/
ASC/MC need a birth LOCATION a virtual table doesn't have — so PySwiss is
queried at the null location and only the location-independent keys
(planets, aspects) are kept. Computed LAZILY here on first request and
cached on Room.sky_chart — never at the pick_roles transition, which must
stay HTTP-free.
Returns {planets, aspects} 200 · 403 not seated · 502 PySwiss unreachable.
"""
room = Room.objects.get(id=room_id)
if _canonical_user_seat(room, request.user) is None:
return HttpResponse(status=403)
if room.sky_chart:
return JsonResponse(room.sky_chart)
convened = room.convened_at or room.created_at
dt_iso = convened.astimezone(zoneinfo.ZoneInfo('UTC')).strftime('%Y-%m-%dT%H:%M:%SZ')
try:
resp = http_requests.get(
settings.PYSWISS_URL + '/api/chart/',
params={'dt': dt_iso, 'lat': '0', 'lon': '0'},
timeout=5,
)
resp.raise_for_status()
except Exception:
return HttpResponse(status=502)
data = resp.json()
room.sky_chart = {
'planets': data.get('planets') or {},
'aspects': data.get('aspects') or [],
}
room.save(update_fields=['sky_chart'])
return JsonResponse(room.sky_chart)
@login_required
def sky_save(request, room_id):
"""Create or update the draft Character for the requesting gamer's seat.

View File

@@ -1443,6 +1443,136 @@ const SkyWheel = (() => {
});
}
// ── SEED MAP rim ──────────────────────────────────────────────────────────
/**
* drawRim — the SEED MAP's shared wheel rim (roadmap step 21, Step 2; see
* project_voronoi_spec): the ROOM's own sky, planets only — a virtual table
* has a convened TIME but no birth LOCATION, so houses/ASC/MC never exist
* here — around the canonical signs ring (asc=0 frame: 0° Aries at 9
* o'clock, identical for all six gamers). Strips the element ring + centre
* disc (the freed hub hosts the tessellation) and the aspect web.
*
* A PURE renderer, deliberately apart from draw(): no tooltips, no cycle,
* no click handlers, no entry transitions (the felt's geometry asserts read
* final positions immediately) and NO singleton writes — the interactive
* saved wheel on the same page (_svg/_currentData/#id_sky_tooltip) must
* survive a rim draw untouched. Reads the module's _signPaths cache
* (preload()ed by the sky overlay, which renders on every SKY_SELECT page).
*
* Returns {size, cx, cy, r, hubR} — hubR (just inside the planet band) is
* the radius the felt sizes the tessellation svg into (2 × hubR square +
* circle clip), or null without an svg/d3.
*/
function drawRim(svgEl, data) {
if (!svgEl || !window.d3) return null;
const sel = d3.select(svgEl);
sel.selectAll('*').remove();
const rect = svgEl.getBoundingClientRect();
const size = Math.min(rect.width || 400, rect.height || 400);
const cx = size / 2, cy = size / 2;
svgEl.setAttribute('viewBox', `0 0 ${size} ${size}`);
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
const r = size * 0.46;
const signInner = r * 0.70, signOuter = r * 0.90;
const labelR = r * 0.80, planetR = r * 0.59, tickOuter = r * 0.96;
const asc = 0; // canonical frame — no location, no Ascendant
const g = sel.append('g').attr('class', 'nw-root nw-root--rim');
g.append('circle')
.attr('cx', cx).attr('cy', cy).attr('r', signOuter)
.attr('class', 'nw-outer-ring');
// Signs — canonical orientation, non-interactive.
const arc = d3.arc();
const sigGroup = g.append('g').attr('class', 'nw-signs');
SIGNS.forEach((sign, i) => {
const startA = _toAngle(i * 30, asc);
const endA = _toAngle(i * 30 + 30, asc);
const [sa, ea] = startA > endA ? [endA, startA] : [startA, endA];
const slice = sigGroup.append('g')
.attr('class', 'nw-sign-group')
.attr('data-sign-name', sign.name);
slice.append('path')
.attr('transform', `translate(${cx},${cy})`)
.attr('d', arc({
innerRadius: signInner,
outerRadius: 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 + labelR * Math.cos(midA);
const ly = cy + labelR * Math.sin(midA);
const cr = r * 0.065;
const sf = (cr * 2 * 0.85) / 640;
slice.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]) {
slice.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()}`);
}
});
// Planets — static at their final canonical angles.
const planetGroup = g.append('g').attr('class', 'nw-planets');
Object.entries((data && data.planets) || {}).forEach(([name, pdata]) => {
const a = _toAngle(pdata.degree, asc);
const el = PLANET_ELEMENTS[name] || '';
const px = cx + planetR * Math.cos(a);
const py = cy + planetR * Math.sin(a);
const grp = 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');
grp.append('line')
.attr('class', el ? `nw-planet-tick nw-planet-tick--${el}` : 'nw-planet-tick')
.attr('x1', px).attr('y1', py)
.attr('x2', cx + tickOuter * Math.cos(a))
.attr('y2', cy + tickOuter * Math.sin(a));
const circleBase = pdata.retrograde ? 'nw-planet-circle--rx' : 'nw-planet-circle';
grp.append('circle')
.attr('cx', px).attr('cy', py).attr('r', r * 0.05)
.attr('class', el ? `${circleBase} nw-planet--${el}` : circleBase);
grp.append('text')
.attr('x', px).attr('y', py)
.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]);
if (pdata.retrograde) {
const rxR = planetR + r * 0.07;
grp.append('circle')
.attr('cx', cx + rxR * Math.cos(a)).attr('cy', cy + rxR * Math.sin(a))
.attr('r', r * 0.035)
.attr('class', 'nw-rx-badge')
.attr('pointer-events', 'none');
grp.append('text')
.attr('x', cx + rxR * Math.cos(a)).attr('y', cy + rxR * Math.sin(a))
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', `${r * 0.045}px`)
.attr('class', 'nw-rx')
.attr('pointer-events', 'none')
.text('℞');
}
});
// The freed hub — just inside the planet band (planetR 0.59 glyph 0.05).
return { size, cx, cy, r, hubR: r * 0.52 };
}
// ── Public API ────────────────────────────────────────────────────────────
/**
@@ -1519,5 +1649,5 @@ const SkyWheel = (() => {
_currentData = null;
}
return { preload, draw, redraw, clear };
return { preload, draw, drawRim, redraw, clear };
})();

View File

@@ -1,4 +1,4 @@
"""Functional test for the SEED MAP felt — Voronoi map, roadmap step 21, Step 1.
"""Functional test for the SEED MAP felt — Voronoi map, roadmap step 21.
The SEED MAP felt is the inline --duoUser sibling of the CAST SKY / DRAW SEA
felts: once the seat's Celtic-Cross hand is complete (hand_complete), SEED MAP
@@ -7,6 +7,13 @@ d3-delaunay DUAL GRAPH — a Voronoi cell layer (territory) + a Delaunay edge
layer (adjacency). STEP 1 is placeholder-seeded (card-driven seeding is Step 2),
so we assert only that the dual graph renders both layers.
Step 2's first piece — the SHARED WHEEL RIM: the stripped sky wheel (signs ring
at canonical orientation + planet glyphs; NO element ring, centre disc, houses
or axes — a virtual table has a convened TIME but no birth LOCATION) rings the
tessellation, drawn from the ROOM'S OWN sky (Room.sky_chart, identical for all
six gamers). The map svg shrinks into the wheel's freed hub. The fixture
pre-stores Room.sky_chart so the lazy PySwiss compute path never fires HTTP.
Plain FunctionalTest (NOT @tag("channels")): the felt-open is pure client-side.
We seed a CONFIRMED Character with a complete hand directly in the DB, so there
is no WebSocket sky-confirm flow to drive — unlike the sky/sea FTs.
@@ -25,6 +32,17 @@ from .test_game_room_select_sea import _make_sky_confirmed_room
_HAND_POSITIONS = ["cover", "cross", "crown", "lay", "leave", "loom"]
# The room's own sky — planets only (location-independent), mirroring the
# Jasmine ROOM_SKY fixture. Mercury retrograde exercises the ℞ badge.
_ROOM_SKY = {
"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},
},
"aspects": [],
}
class SeedMapFeltTest(FunctionalTest):
def setUp(self):
@@ -53,6 +71,10 @@ class SeedMapFeltTest(FunctionalTest):
celtic_cross holds `hand_len` placed cards. hand_len>=6 => hand_complete
=> SEED MAP is the live phase btn + the felt renders."""
room, seat = _make_sky_confirmed_room(self.live_server_url, self.gamer, self.earthman)
# Pre-store the table's own sky so the rim renders without the lazy
# PySwiss compute (no live HTTP from FTs).
room.sky_chart = _ROOM_SKY
room.save(update_fields=["sky_chart"])
hand = [
{"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"}
for p in _HAND_POSITIONS[:hand_len]
@@ -67,8 +89,8 @@ class SeedMapFeltTest(FunctionalTest):
def _room_url(self, room):
return self.live_server_url + reverse("epic:room", kwargs={"room_id": room.id})
def test_seed_map_felt_opens_and_paints_dual_graph(self):
room = self._seed_room(hand_len=6)
def _open_seed_felt(self, room):
"""Load the room, wait for SEED MAP to go live, click it open."""
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
@@ -88,6 +110,10 @@ class SeedMapFeltTest(FunctionalTest):
))
self.wait_for(_click_and_assert_open)
def test_seed_map_felt_opens_and_paints_dual_graph(self):
room = self._seed_room(hand_len=6)
self._open_seed_felt(room)
self.assertTrue(
self.browser.find_element(By.ID, "id_seed_map_overlay").is_displayed()
)
@@ -105,6 +131,58 @@ class SeedMapFeltTest(FunctionalTest):
), 1,
)
def test_seed_map_felt_rings_tessellation_with_room_sky_rim(self):
"""Step 2's shared frame: the stripped sky wheel — canonical signs ring +
the ROOM's own planets, NOTHING location-bound (no houses/axes) and none
of the stripped chrome (element ring / centre disc / aspect web) — rings
the tessellation, which shrinks into the wheel's freed hub."""
room = self._seed_room(hand_len=6)
self._open_seed_felt(room)
# The rim paints: 12 canonical sign slices + the room sky's 3 planets.
self.wait_for(lambda: self.assertEqual(
self.browser.execute_script(
"return document.querySelectorAll('#id_seed_wheel_svg .nw-sign-group').length"
), 12,
))
self.assertEqual(
self.browser.execute_script(
"return document.querySelectorAll('#id_seed_wheel_svg .nw-planet-group').length"
), 3,
)
# The strip: no element ring / centre disc / aspect web, and no
# location-bound layers (houses, ASC/MC axes) on a shared rim.
for cls in ["nw-elements", "nw-inner-disc", "nw-aspects", "nw-houses", "nw-axes"]:
self.assertEqual(
self.browser.execute_script(
f"return document.querySelectorAll('#id_seed_wheel_svg .{cls}').length"
), 0, f"expected no .{cls} on the shared rim",
)
# The tessellation sits INSIDE the rim: the map svg is shrunk square
# into the wheel's hub (2 × hubR = 2 × 0.52 × 0.46 × wheel span) and
# carries the rimmed clip class.
map_w, map_h, col_w, col_h = self.browser.execute_script(
"const m = document.getElementById('id_seed_map_svg'),"
" c = m.closest('.seed-map-col');"
"return [m.clientWidth, m.clientHeight, c.clientWidth, c.clientHeight];"
)
self.assertEqual(map_w, map_h) # square
self.assertLess(map_w, min(col_w, col_h)) # inside the wheel
self.assertAlmostEqual( # = the freed hub
map_w, 2 * 0.52 * 0.46 * min(col_w, col_h), delta=2,
)
self.assertIn("voronoi-map--rimmed",
self.browser.find_element(By.ID, "id_seed_map_svg").get_attribute("class"))
# And the dual graph still paints inside the hub.
self.assertGreaterEqual(
self.browser.execute_script(
"return document.querySelectorAll('#id_seed_map_svg .voronoi-cell').length"
), 1,
)
def test_seed_map_felt_absent_until_hand_complete(self):
room = self._seed_room(hand_len=5) # only 5 placed → NOT hand_complete
self.create_pre_authenticated_session("founder@test.io")

View File

@@ -1033,3 +1033,116 @@ describe("SkyWheel — angle (ASC/MC) click tooltips", () => {
expect(marsGroup.classList.contains("nw-planet--active")).toBe(true);
});
});
// ── SkyWheel.drawRim — the SEED MAP's shared wheel rim ──────────────────────
//
// The STRIPPED wheel ringing the Voronoi tessellation (roadmap step 21,
// Step 2): the ROOM's own sky — planets only, since a virtual table has a
// convened TIME but no birth LOCATION (so houses/ASC/MC never exist on the
// shared rim) — around the canonical signs ring (asc=0 frame, identical for
// all six gamers). drawRim is a PURE renderer: no tooltips, no cycle, no
// transitions, and CRUCIALLY no singleton writes — the interactive saved
// wheel on the same page (_svg/_currentData/#id_sky_tooltip) must survive a
// rim draw untouched.
describe("SkyWheel — drawRim (SEED MAP shared rim)", () => {
const ROOM_SKY = {
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 },
},
aspects: [],
};
let rimSvg, skySvg, tooltipEl;
beforeEach(() => {
rimSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
rimSvg.setAttribute("id", "id_seed_wheel_svg");
rimSvg.setAttribute("width", "400");
rimSvg.setAttribute("height", "400");
rimSvg.style.width = "400px";
rimSvg.style.height = "400px";
document.body.appendChild(rimSvg);
});
afterEach(() => {
rimSvg.remove();
if (skySvg) { SkyWheel.clear(); skySvg.remove(); skySvg = null; }
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
});
it("R1: renders the canonical sign ring + the room sky's planet glyphs", () => {
SkyWheel.drawRim(rimSvg, ROOM_SKY);
expect(rimSvg.querySelectorAll(".nw-sign-group").length).toBe(12);
expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(3);
expect(rimSvg.querySelector(".nw-outer-ring")).not.toBeNull();
// Mercury's retrograde badge carries over to the rim.
expect(rimSvg.querySelector("[data-planet='Mercury'] .nw-rx")).not.toBeNull();
});
it("R2: strips the element ring, centre disc, aspect web, houses and axes", () => {
SkyWheel.drawRim(rimSvg, ROOM_SKY);
["nw-elements", "nw-inner-disc", "nw-aspects", "nw-houses", "nw-axes"]
.forEach((cls) => {
expect(rimSvg.querySelectorAll("." + cls).length)
.toBe(0, `expected no .${cls} on the shared rim`);
});
});
it("R3: returns the hub geometry the tessellation slots into", () => {
const geo = SkyWheel.drawRim(rimSvg, ROOM_SKY);
expect(geo.size).toBe(400);
expect(geo.cx).toBe(200);
expect(geo.cy).toBe(200);
expect(geo.r).toBeCloseTo(400 * 0.46, 6);
// The freed hub: just inside the planet band (planetR 0.59 glyph 0.05).
expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6);
});
it("R4: places planets statically at their canonical (asc=0) angles — no entry transition", () => {
SkyWheel.drawRim(rimSvg, ROOM_SKY);
const sun = rimSvg.querySelector("[data-planet='Sun'] circle");
const a = (-(338.4) - 180) * Math.PI / 180; // _toAngle(338.4, asc=0)
const r = 400 * 0.46;
expect(parseFloat(sun.getAttribute("cx"))).toBeCloseTo(200 + r * 0.59 * Math.cos(a), 1);
expect(parseFloat(sun.getAttribute("cy"))).toBeCloseTo(200 + r * 0.59 * Math.sin(a), 1);
});
it("R5: leaves the interactive singleton untouched — the sky felt's wheel keeps its svg + tooltip", () => {
skySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
skySvg.setAttribute("id", "id_sky_svg");
skySvg.style.width = "400px";
skySvg.style.height = "400px";
document.body.appendChild(skySvg);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_sky_tooltip";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
SkyWheel.draw(skySvg, CONJUNCTION_CHART);
SkyWheel.drawRim(rimSvg, ROOM_SKY);
// The tooltip controls draw() injected survive the rim draw.
expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull();
// The singleton still points at the INTERACTIVE svg: clear() empties
// it, not the rim.
SkyWheel.clear();
expect(skySvg.children.length).toBe(0);
expect(rimSvg.querySelectorAll(".nw-sign-group").length).toBe(12);
});
it("R6: renders the signs-only canonical frame when the room sky is absent", () => {
const geo = SkyWheel.drawRim(rimSvg, null);
expect(rimSvg.querySelectorAll(".nw-sign-group").length).toBe(12);
expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(0);
expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6);
});
});

View File

@@ -242,13 +242,29 @@ html.seed-open .seed-page.seed-page--room {
height: 100%;
}
// The dual-graph canvas fills the felt. Layers are styled by class so STEP 2
// (card-driven territoriality) can recolour cells without touching layout.
// The col anchors the two stacked centred svgs (map + the wheel rim above it).
.seed-page--room .seed-map-col {
position: relative;
}
// The dual-graph canvas. Step 2's rim: _paint() sizes this svg into the
// wheel's freed hub (inline 2×hubR px) — centred, so it stays concentric with
// the rim; the base 100%/100% is the no-rim fallback (Step-1 behaviour).
// Layers are styled by class so card-driven territoriality can recolour cells
// without touching layout.
.seed-page--room svg.voronoi-map {
display: block;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
&.voronoi-map--rimmed {
clip-path: circle(50%); // round the hub — flush under the rim
}
.voronoi-cell { // territory base — filled cells
fill: rgba(var(--priUser), 0.18);
stroke: rgba(var(--secUser), 0.55);
@@ -265,6 +281,19 @@ html.seed-open .seed-page.seed-page--room {
}
}
// The shared wheel rim — the stripped sky wheel (the ROOM's planets-only sky,
// canonical signs frame) drawn by SkyWheel.drawRim. Sits ABOVE the map svg in
// source order; pointer-events none so territory interaction falls through.
// The .nw-* ring/planet styling rules below apply to it unchanged.
.seed-page--room svg.seed-wheel {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
// Hide the z-130 position strip while the felt is up (mirrors sky/sea).
html.seed-open .position-strip {
visibility: hidden;

View File

@@ -414,9 +414,9 @@
--terUser: var(--priBld);
--quaUser: var(--priIce);
--quiUser: var(--quaIce);
--sixUser: var(--terTrs);
--sixUser: var(--quaTrs);
--sepUser: var(--terBld);
--octUser: var(--quiTrs);
--octUser: var(--sixTrs);
--ninUser: var(--priMst);
--decUser: var(--terMst);
}

View File

@@ -1033,3 +1033,116 @@ describe("SkyWheel — angle (ASC/MC) click tooltips", () => {
expect(marsGroup.classList.contains("nw-planet--active")).toBe(true);
});
});
// ── SkyWheel.drawRim — the SEED MAP's shared wheel rim ──────────────────────
//
// The STRIPPED wheel ringing the Voronoi tessellation (roadmap step 21,
// Step 2): the ROOM's own sky — planets only, since a virtual table has a
// convened TIME but no birth LOCATION (so houses/ASC/MC never exist on the
// shared rim) — around the canonical signs ring (asc=0 frame, identical for
// all six gamers). drawRim is a PURE renderer: no tooltips, no cycle, no
// transitions, and CRUCIALLY no singleton writes — the interactive saved
// wheel on the same page (_svg/_currentData/#id_sky_tooltip) must survive a
// rim draw untouched.
describe("SkyWheel — drawRim (SEED MAP shared rim)", () => {
const ROOM_SKY = {
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 },
},
aspects: [],
};
let rimSvg, skySvg, tooltipEl;
beforeEach(() => {
rimSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
rimSvg.setAttribute("id", "id_seed_wheel_svg");
rimSvg.setAttribute("width", "400");
rimSvg.setAttribute("height", "400");
rimSvg.style.width = "400px";
rimSvg.style.height = "400px";
document.body.appendChild(rimSvg);
});
afterEach(() => {
rimSvg.remove();
if (skySvg) { SkyWheel.clear(); skySvg.remove(); skySvg = null; }
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
});
it("R1: renders the canonical sign ring + the room sky's planet glyphs", () => {
SkyWheel.drawRim(rimSvg, ROOM_SKY);
expect(rimSvg.querySelectorAll(".nw-sign-group").length).toBe(12);
expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(3);
expect(rimSvg.querySelector(".nw-outer-ring")).not.toBeNull();
// Mercury's retrograde badge carries over to the rim.
expect(rimSvg.querySelector("[data-planet='Mercury'] .nw-rx")).not.toBeNull();
});
it("R2: strips the element ring, centre disc, aspect web, houses and axes", () => {
SkyWheel.drawRim(rimSvg, ROOM_SKY);
["nw-elements", "nw-inner-disc", "nw-aspects", "nw-houses", "nw-axes"]
.forEach((cls) => {
expect(rimSvg.querySelectorAll("." + cls).length)
.toBe(0, `expected no .${cls} on the shared rim`);
});
});
it("R3: returns the hub geometry the tessellation slots into", () => {
const geo = SkyWheel.drawRim(rimSvg, ROOM_SKY);
expect(geo.size).toBe(400);
expect(geo.cx).toBe(200);
expect(geo.cy).toBe(200);
expect(geo.r).toBeCloseTo(400 * 0.46, 6);
// The freed hub: just inside the planet band (planetR 0.59 glyph 0.05).
expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6);
});
it("R4: places planets statically at their canonical (asc=0) angles — no entry transition", () => {
SkyWheel.drawRim(rimSvg, ROOM_SKY);
const sun = rimSvg.querySelector("[data-planet='Sun'] circle");
const a = (-(338.4) - 180) * Math.PI / 180; // _toAngle(338.4, asc=0)
const r = 400 * 0.46;
expect(parseFloat(sun.getAttribute("cx"))).toBeCloseTo(200 + r * 0.59 * Math.cos(a), 1);
expect(parseFloat(sun.getAttribute("cy"))).toBeCloseTo(200 + r * 0.59 * Math.sin(a), 1);
});
it("R5: leaves the interactive singleton untouched — the sky felt's wheel keeps its svg + tooltip", () => {
skySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
skySvg.setAttribute("id", "id_sky_svg");
skySvg.style.width = "400px";
skySvg.style.height = "400px";
document.body.appendChild(skySvg);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_sky_tooltip";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
SkyWheel.draw(skySvg, CONJUNCTION_CHART);
SkyWheel.drawRim(rimSvg, ROOM_SKY);
// The tooltip controls draw() injected survive the rim draw.
expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull();
// The singleton still points at the INTERACTIVE svg: clear() empties
// it, not the rim.
SkyWheel.clear();
expect(skySvg.children.length).toBe(0);
expect(rimSvg.querySelectorAll(".nw-sign-group").length).toBe(12);
});
it("R6: renders the signs-only canonical frame when the room sky is absent", () => {
const geo = SkyWheel.drawRim(rimSvg, null);
expect(rimSvg.querySelectorAll(".nw-sign-group").length).toBe(12);
expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(0);
expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6);
});
});

View File

@@ -2,25 +2,41 @@
{# SEED MAP felt — d3-delaunay dual graph (Voronoi cells = territory + Delaunay #}
{# edges = adjacency) on a my_sea-style --duoUser felt filling .room-hex-pane. #}
{# Inline sibling of CAST SKY (_sky_overlay) / DRAW SEA (_sea_overlay). Opens on #}
{# #id_seed_map_btn (html.seed-open). STEP 1 = placeholder seeds; card-driven #}
{# seeding (the 6 Celtic-Cross cards) is STEP 2. See project_voronoi_spec. #}
<div class="seed-page seed-page--room" id="id_seed_map_overlay">
{# #id_seed_map_btn (html.seed-open). STEP 1 = placeholder seeds; Step 2's first #}
{# piece is live: the SHARED wheel rim — the ROOM's own sky (planets only; no #}
{# location => no houses/axes), one canonical frame for all six gamers — rings #}
{# the tessellation, which _paint() sizes into the wheel's freed hub. #}
{# Card-driven seeding (the 6 Celtic-Cross cards) is the next piece. #}
{# See project_voronoi_spec. #}
<div class="seed-page seed-page--room" id="id_seed_map_overlay"
data-table-sky-url="{% url 'epic:table_sky' room.id %}">
<div class="seed-map-body">
<div class="seed-map-col">
<svg id="id_seed_map_svg" class="voronoi-map"></svg>
<svg id="id_seed_wheel_svg" class="seed-wheel"></svg>
</div>
</div>
</div>
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
<script src="{% static 'apps/gameboard/voronoi-map.js' %}"></script>
{# NO sky-wheel include here — _sky_overlay.html (always rendered in SKY_SELECT, #}
{# a strictly looser gate than this felt's) already loads it, and a second #}
{# top-level `const SkyWheel` declaration throws. #}
<script>
(function () {
'use strict';
var html = document.documentElement;
var overlay = document.getElementById('id_seed_map_overlay');
var svgEl = document.getElementById('id_seed_map_svg');
var wheelSvg = document.getElementById('id_seed_wheel_svg');
if (!overlay || !svgEl) return;
// The table's OWN sky (the shared rim frame) — embedded when already
// computed, else lazily fetched on first open (the endpoint computes via
// PySwiss at the convened moment + caches on the Room).
var _tableSky = {{ room_sky_json|default:"null"|safe }};
var _skyFetched = false;
// Phase-btn shed: drop #id_text_btn .active while the felt is up (sea-style);
// restore on close. The completed CAST SKY / DRAW SEA reopen btns stay lit.
var _disabled = [];
@@ -36,15 +52,62 @@
_disabled = [];
}
// Paint/repaint the dual graph at the svg's CURRENT box (re-measured each call).
// Paint/repaint the rim + dual graph at the felt's CURRENT box. The rim
// draws first and hands back its hub geometry; the map svg is then sized
// into the freed hub (2×hubR square, circle-clipped) so the wheel RINGS the
// tessellation — both svgs centre on the same point, so they stay
// concentric. No rim (SkyWheel absent) -> the map keeps its full-pane CSS
// size, the Step-1 behaviour (also the Jasmine fixture path).
function _paint() {
if (window.SeedMap) window.SeedMap.drawPlaceholder(svgEl);
if (!window.SeedMap) return;
// NB: sky-wheel.js declares `const SkyWheel` at top-level script scope —
// a global LEXICAL binding, not a window property — so probe it with
// typeof, never window.SkyWheel (always undefined).
var geo = (typeof SkyWheel !== 'undefined' && SkyWheel.drawRim)
? SkyWheel.drawRim(wheelSvg, _tableSky)
: null;
if (geo) {
var side = Math.round(geo.hubR * 2);
svgEl.style.width = side + 'px';
svgEl.style.height = side + 'px';
svgEl.classList.add('voronoi-map--rimmed');
_ensureGlyphs();
} else {
svgEl.style.width = svgEl.style.height = '';
svgEl.classList.remove('voronoi-map--rimmed');
}
SeedMap.drawPlaceholder(svgEl);
}
// Re-tessellate to fill the felt when its box changes (window resize / rotate),
// so the map stays edge-to-edge instead of a stale fixed-size block. The graph
// is painted with absolute px coords sized to the box at draw time, so a box
// change needs a redraw — NOT a viewBox (we want it to RE-FILL, not letterbox).
// drawRim reads SkyWheel's _signPaths cache, which SkyWheel.preload()
// populates via 12 async SVG fetches fired at page parse by _sky_overlay.html.
// Every other draw site (sky.html, the SkyDrive applet, _sky_overlay) awaits
// that preload; the rim must too, else a cold-cache fast felt-open paints the
// sign slices WITHOUT their zodiac glyphs — and in the steady state (the room
// sky embedded server-side, so _fetchTableSky no-ops), no repaint ever heals
// it. preload() re-fetches are browser-cached + idempotent on the cache, so
// kicking it once + repainting on resolve is the cheap established fix.
var _preloadKicked = false;
function _ensureGlyphs() {
if (_preloadKicked || typeof SkyWheel === 'undefined' || !SkyWheel.preload) return;
_preloadKicked = true;
SkyWheel.preload().then(function () {
if (html.classList.contains('seed-open')) _paint();
}).catch(function () {});
}
function _fetchTableSky() {
if (_tableSky || _skyFetched || !overlay.dataset.tableSkyUrl) return;
_skyFetched = true; // one attempt per page — the signs-only frame stands on failure
window.fetch(overlay.dataset.tableSkyUrl)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { if (j) { _tableSky = j; _paint(); } })
.catch(function () {});
}
// Re-tessellate to fill the felt when its box changes (window resize /
// rotate). Observe the COL, not the map svg — _paint() sizes that svg
// itself, and self-observation would re-fire on every paint.
var _ro = null, _raf = 0;
function _observeResize() {
if (_ro || typeof ResizeObserver === 'undefined') return;
@@ -53,7 +116,7 @@
if (_raf) cancelAnimationFrame(_raf);
_raf = requestAnimationFrame(_paint); // one repaint per frame
});
_ro.observe(svgEl);
_ro.observe(overlay.querySelector('.seed-map-col'));
}
// Open/close — trap T3: full-close BOTH sibling felts FIRST (three equal-z
@@ -66,6 +129,7 @@
// Paint at the felt's current box (visibility:hidden retains layout, so the
// box is already the full pane), then keep it responsive to resize.
_paint();
_fetchTableSky();
_observeResize();
if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear();
}