SEED MAP shared wheel rim: the table's OWN sky (planets-only, canonical signs) rings the tessellation — one frame for all six gamers — TDD
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:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user