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) created_at = models.DateTimeField(auto_now_add=True)
board_state = models.JSONField(default=dict) board_state = models.JSONField(default=dict)
seed_count = models.IntegerField(default=12) 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): def get_thread_post(self):
"""Get-or-create this room's single game-table thread Post (the POST """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.client.post(url) # second call must be a no-op
self.assertEqual(TableSeat.objects.filter(room=self.room).count(), 6) 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): class SelectRoleViewTest(TestCase):
def setUp(self): def setUp(self):
@@ -2909,6 +2918,104 @@ class SkyPreviewViewTest(TestCase):
self.assertNotIn("Earth", data["elements"]) 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 ──────────────────────────────────────────────────────────────── # ── tarot_deal ────────────────────────────────────────────────────────────────
class TarotDealViewTest(TestCase): class TarotDealViewTest(TestCase):
@@ -4634,6 +4741,34 @@ class PickSeaUnifiedFeltTest(TestCase):
content = self.client.get(self.url).content.decode() content = self.client.get(self.url).content.decode()
self.assertGreaterEqual(content.count("closeSeedMapFelt"), 3) 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): 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 """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 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/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/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/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/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/deck', views.sea_deck, name='sea_deck'),
path('room/<uuid:room_id>/sea/save', views.sea_save, name='sea_save'), 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) if (sky_confirmed and confirmed_char.chart_data)
else "null" 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: if sky_confirmed:
# Fall back to seat.significator for Characters created before the sync was added # Fall back to seat.significator for Characters created before the sync was added
ctx["my_tray_sig"] = confirmed_char.significator or _sky_seat.significator 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) room = Room.objects.get(id=room_id)
if room.gate_status == Room.OPEN and room.table_status is None: if room.gate_status == Room.OPEN and room.table_status is None:
room.table_status = Room.ROLE_SELECT 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() room.save()
for slot in room.gate_slots.filter(status=GateSlot.FILLED).order_by("slot_number"): for slot in room.gate_slots.filter(status=GateSlot.FILLED).order_by("slot_number"):
TableSeat.objects.create( TableSeat.objects.create(
@@ -1823,6 +1832,47 @@ def sky_preview(request, room_id):
return JsonResponse(data) 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 @login_required
def sky_save(request, room_id): def sky_save(request, room_id):
"""Create or update the draft Character for the requesting gamer's seat. """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 ──────────────────────────────────────────────────────────── // ── Public API ────────────────────────────────────────────────────────────
/** /**
@@ -1519,5 +1649,5 @@ const SkyWheel = (() => {
_currentData = null; _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 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 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), layer (adjacency). STEP 1 is placeholder-seeded (card-driven seeding is Step 2),
so we assert only that the dual graph renders both layers. 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. 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 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. 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"] _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): class SeedMapFeltTest(FunctionalTest):
def setUp(self): def setUp(self):
@@ -53,6 +71,10 @@ class SeedMapFeltTest(FunctionalTest):
celtic_cross holds `hand_len` placed cards. hand_len>=6 => hand_complete celtic_cross holds `hand_len` placed cards. hand_len>=6 => hand_complete
=> SEED MAP is the live phase btn + the felt renders.""" => SEED MAP is the live phase btn + the felt renders."""
room, seat = _make_sky_confirmed_room(self.live_server_url, self.gamer, self.earthman) 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 = [ hand = [
{"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"} {"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"}
for p in _HAND_POSITIONS[:hand_len] for p in _HAND_POSITIONS[:hand_len]
@@ -67,8 +89,8 @@ class SeedMapFeltTest(FunctionalTest):
def _room_url(self, room): def _room_url(self, room):
return self.live_server_url + reverse("epic:room", kwargs={"room_id": room.id}) return self.live_server_url + reverse("epic:room", kwargs={"room_id": room.id})
def test_seed_map_felt_opens_and_paints_dual_graph(self): def _open_seed_felt(self, room):
room = self._seed_room(hand_len=6) """Load the room, wait for SEED MAP to go live, click it open."""
self.create_pre_authenticated_session("founder@test.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room)) self.browser.get(self._room_url(room))
@@ -88,6 +110,10 @@ class SeedMapFeltTest(FunctionalTest):
)) ))
self.wait_for(_click_and_assert_open) 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.assertTrue(
self.browser.find_element(By.ID, "id_seed_map_overlay").is_displayed() self.browser.find_element(By.ID, "id_seed_map_overlay").is_displayed()
) )
@@ -105,6 +131,58 @@ class SeedMapFeltTest(FunctionalTest):
), 1, ), 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): def test_seed_map_felt_absent_until_hand_complete(self):
room = self._seed_room(hand_len=5) # only 5 placed → NOT hand_complete room = self._seed_room(hand_len=5) # only 5 placed → NOT hand_complete
self.create_pre_authenticated_session("founder@test.io") 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); 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%; height: 100%;
} }
// The dual-graph canvas fills the felt. Layers are styled by class so STEP 2 // The col anchors the two stacked centred svgs (map + the wheel rim above it).
// (card-driven territoriality) can recolour cells without touching layout. .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 { .seed-page--room svg.voronoi-map {
display: block; display: block;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 100%; width: 100%;
height: 100%; height: 100%;
&.voronoi-map--rimmed {
clip-path: circle(50%); // round the hub — flush under the rim
}
.voronoi-cell { // territory base — filled cells .voronoi-cell { // territory base — filled cells
fill: rgba(var(--priUser), 0.18); fill: rgba(var(--priUser), 0.18);
stroke: rgba(var(--secUser), 0.55); 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). // Hide the z-130 position strip while the felt is up (mirrors sky/sea).
html.seed-open .position-strip { html.seed-open .position-strip {
visibility: hidden; visibility: hidden;

View File

@@ -414,9 +414,9 @@
--terUser: var(--priBld); --terUser: var(--priBld);
--quaUser: var(--priIce); --quaUser: var(--priIce);
--quiUser: var(--quaIce); --quiUser: var(--quaIce);
--sixUser: var(--terTrs); --sixUser: var(--quaTrs);
--sepUser: var(--terBld); --sepUser: var(--terBld);
--octUser: var(--quiTrs); --octUser: var(--sixTrs);
--ninUser: var(--priMst); --ninUser: var(--priMst);
--decUser: var(--terMst); --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); 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 #} {# SEED MAP felt — d3-delaunay dual graph (Voronoi cells = territory + Delaunay #}
{# edges = adjacency) on a my_sea-style --duoUser felt filling .room-hex-pane. #} {# 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 #} {# 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 #} {# #id_seed_map_btn (html.seed-open). STEP 1 = placeholder seeds; Step 2's first #}
{# seeding (the 6 Celtic-Cross cards) is STEP 2. See project_voronoi_spec. #} {# piece is live: the SHARED wheel rim — the ROOM's own sky (planets only; no #}
<div class="seed-page seed-page--room" id="id_seed_map_overlay"> {# 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-body">
<div class="seed-map-col"> <div class="seed-map-col">
<svg id="id_seed_map_svg" class="voronoi-map"></svg> <svg id="id_seed_map_svg" class="voronoi-map"></svg>
<svg id="id_seed_wheel_svg" class="seed-wheel"></svg>
</div> </div>
</div> </div>
</div> </div>
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script> <script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
<script src="{% static 'apps/gameboard/voronoi-map.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> <script>
(function () { (function () {
'use strict'; 'use strict';
var html = document.documentElement; var html = document.documentElement;
var overlay = document.getElementById('id_seed_map_overlay'); var overlay = document.getElementById('id_seed_map_overlay');
var svgEl = document.getElementById('id_seed_map_svg'); var svgEl = document.getElementById('id_seed_map_svg');
var wheelSvg = document.getElementById('id_seed_wheel_svg');
if (!overlay || !svgEl) return; 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); // 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. // restore on close. The completed CAST SKY / DRAW SEA reopen btns stay lit.
var _disabled = []; var _disabled = [];
@@ -36,15 +52,62 @@
_disabled = []; _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() { 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), // drawRim reads SkyWheel's _signPaths cache, which SkyWheel.preload()
// so the map stays edge-to-edge instead of a stale fixed-size block. The graph // populates via 12 async SVG fetches fired at page parse by _sky_overlay.html.
// is painted with absolute px coords sized to the box at draw time, so a box // Every other draw site (sky.html, the SkyDrive applet, _sky_overlay) awaits
// change needs a redraw — NOT a viewBox (we want it to RE-FILL, not letterbox). // 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; var _ro = null, _raf = 0;
function _observeResize() { function _observeResize() {
if (_ro || typeof ResizeObserver === 'undefined') return; if (_ro || typeof ResizeObserver === 'undefined') return;
@@ -53,7 +116,7 @@
if (_raf) cancelAnimationFrame(_raf); if (_raf) cancelAnimationFrame(_raf);
_raf = requestAnimationFrame(_paint); // one repaint per frame _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 // 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 // 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. // box is already the full pane), then keep it responsive to resize.
_paint(); _paint();
_fetchTableSky();
_observeResize(); _observeResize();
if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear(); if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear();
} }