SEED MAP felt: 2D d3-delaunay Voronoi/Delaunay dual graph (roadmap step 21, Step 1) — TDD
New inline --duoUser felt (sibling of CAST SKY / DRAW SEA) that opens on #id_seed_map_btn once the 6-card sea hand completes (hand_complete); paints a Voronoi cell layer (territory) + a Delaunay edge layer (adjacency) from PLACEHOLDER seeds. Card-driven seeding (the 6 Celtic-Cross cards) is Step 2. - voronoi-map.js: window.SeedMap.draw/drawPlaceholder/clear over the bundled d3.min.js (d3-delaunay ships in the v7.9.0 UMD bundle — no new dep); a ResizeObserver re-tessellates to fill the felt on resize; data-seed reads d3's ring.index (survives skipped/degenerate cells in Step 2) - _seed_map_overlay.html felt + room.html include + has-seed-stage (gated on hand_complete) - three-way felt close (T3): openSeed closes sky+sea; openSky/openSea reciprocally close seed - .room-menu-seed gear NVM pane + room-views.js seed-open branch - _sky.scss felt block (T1: no aperture-fill light; T2: chained selector; fills the pane) + _room.scss aperture pin - VoronoiMapSpec Jasmine + game_room_seed_map FT + 5 felt ITs; CarteTray NVM count 2->3 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,7 @@
|
||||
var paneAtlas = roomMenu && roomMenu.querySelector('.room-menu-atlas');
|
||||
var paneSky = roomMenu && roomMenu.querySelector('.room-menu-sky');
|
||||
var paneSea = roomMenu && roomMenu.querySelector('.room-menu-sea');
|
||||
var paneSeed = roomMenu && roomMenu.querySelector('.room-menu-seed');
|
||||
var GEARLESS = { yarn: true, post: true, pulse: true };
|
||||
var onReelhouse = false;
|
||||
|
||||
@@ -102,6 +103,7 @@
|
||||
if (paneAtlas) paneAtlas.style.display = pane === paneAtlas ? '' : 'none';
|
||||
if (paneSky) paneSky.style.display = pane === paneSky ? '' : 'none';
|
||||
if (paneSea) paneSea.style.display = pane === paneSea ? '' : 'none';
|
||||
if (paneSeed) paneSeed.style.display = pane === paneSeed ? '' : 'none';
|
||||
}
|
||||
function closeRoomMenu() {
|
||||
if (roomMenu) roomMenu.style.display = 'none';
|
||||
@@ -125,6 +127,13 @@
|
||||
showPane(paneSea);
|
||||
return;
|
||||
}
|
||||
// SEED MAP felt open → the gear is the seed NVM pane (returns to the
|
||||
// hex), same as the sky/sea felts.
|
||||
if (document.documentElement.classList.contains('seed-open')) {
|
||||
gearBtn.classList.remove('gear-disabled');
|
||||
showPane(paneSeed);
|
||||
return;
|
||||
}
|
||||
var disabled = onReelhouse && GEARLESS[current];
|
||||
gearBtn.classList.toggle('gear-disabled', !!disabled);
|
||||
if (disabled) { closeRoomMenu(); return; }
|
||||
|
||||
@@ -1018,11 +1018,12 @@ class CarteTrayFollowsSelectedSeatTest(TestCase):
|
||||
self.room.save()
|
||||
response = self.client.get(self.room_url + "?seat=4")
|
||||
self.assertEqual(response.context["current_slot"], 4)
|
||||
# The room-menu-sky + room-menu-sea panes both return to the acting seat.
|
||||
# room-menu-sky + room-menu-sea + room-menu-seed all return to the acting
|
||||
# seat (the SEED MAP felt's NVM joined the felt panes — roadmap step 21).
|
||||
self.assertContains(
|
||||
response,
|
||||
f'href="{self.room_url}?seat=4" class="btn btn-cancel">NVM</a>',
|
||||
count=2,
|
||||
count=3,
|
||||
)
|
||||
|
||||
def test_sky_sea_nvm_default_targets_lowest_owned(self):
|
||||
@@ -1035,7 +1036,7 @@ class CarteTrayFollowsSelectedSeatTest(TestCase):
|
||||
self.assertContains(
|
||||
response,
|
||||
f'href="{self.room_url}?seat=1" class="btn btn-cancel">NVM</a>',
|
||||
count=2,
|
||||
count=3,
|
||||
)
|
||||
|
||||
|
||||
@@ -4581,6 +4582,58 @@ class PickSeaUnifiedFeltTest(TestCase):
|
||||
[sea] = parsed.cssselect("#id_pick_sea_btn")
|
||||
self.assertIn("hex-phase-btn--out", sea.get("class", ""))
|
||||
|
||||
# ── SEED MAP felt (Voronoi map, roadmap step 21, Step 1) ──────────────────
|
||||
def _complete_hand(self):
|
||||
"""Place a 6-card hand on the confirmed Character so hand_complete is true
|
||||
(one card_id reused — the gate is purely len(hand) >= 6)."""
|
||||
card_id = TarotCard.objects.filter(deck_variant=self.earthman).first().id
|
||||
char = Character.objects.get(seat=self.pc_seat, confirmed_at__isnull=False)
|
||||
char.celtic_cross = {"spread": "waite-smith", "hand": [
|
||||
{"position": p, "card_id": card_id, "reversed": False, "polarity": "gravity"}
|
||||
for p in ["cover", "cross", "crown", "lay", "loom", "leave"]]}
|
||||
char.save(update_fields=["celtic_cross"])
|
||||
|
||||
def test_seed_map_felt_renders_when_hand_complete(self):
|
||||
"""Once hand_complete, room.html injects the SEED MAP felt — the inline
|
||||
--duoUser sibling of the sky/sea felts — carrying the d3-delaunay dual-graph
|
||||
svg + its renderer include (Step 1: bare integration, placeholder seeds)."""
|
||||
self._complete_hand()
|
||||
content = self.client.get(self.url).content.decode()
|
||||
self.assertIn('id="id_seed_map_overlay"', content)
|
||||
self.assertIn("seed-page--room", content)
|
||||
self.assertIn('id="id_seed_map_svg"', content)
|
||||
self.assertIn("voronoi-map", content)
|
||||
# Renderer + d3 (d3-delaunay ships inside the d3.min.js bundle).
|
||||
self.assertIn("apps/gameboard/voronoi-map.js", content)
|
||||
self.assertIn("apps/gameboard/d3.min.js", content)
|
||||
# Open/close API exposed for the burger reopen + cross-felt swap.
|
||||
self.assertIn("openSeedMapFelt", content)
|
||||
self.assertIn("closeSeedMapFelt", content)
|
||||
|
||||
def test_seed_map_felt_absent_until_hand_complete(self):
|
||||
"""Pre-completion the felt is NOT injected — the include is gated on
|
||||
hand_complete, same as the SEED MAP btn's live-state."""
|
||||
content = self.client.get(self.url).content.decode()
|
||||
self.assertNotIn('id="id_seed_map_overlay"', content)
|
||||
|
||||
def test_room_gear_has_seed_nvm_pane(self):
|
||||
"""The gear carries a SEED NVM pane (sibling of room-menu-sky/-sea), hidden
|
||||
by default and revealed by room-views.js on html.seed-open. Like its
|
||||
siblings it renders for the whole table phase (gated on scroll_filter, NOT
|
||||
hand_complete — the FELT is what's hand_complete-gated)."""
|
||||
content = self.client.get(self.url).content.decode()
|
||||
self.assertIn('class="room-menu-seed" style="display:none"', content)
|
||||
|
||||
def test_sky_and_sea_opens_close_a_live_seed_felt(self):
|
||||
"""T3 (three-way): the SEED MAP felt is a third equal-z sibling, so
|
||||
openSky()/openSea() must each ALSO full-close it. window.closeSeedMapFelt
|
||||
is wired from the seed overlay (definition) AND the sky + sea overlays (the
|
||||
reciprocal closes) → >= 3 references on the page.
|
||||
[[feedback-equal-z-felt-siblings-double-open]]"""
|
||||
self._complete_hand()
|
||||
content = self.client.get(self.url).content.decode()
|
||||
self.assertGreaterEqual(content.count("closeSeedMapFelt"), 3)
|
||||
|
||||
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
|
||||
|
||||
121
src/apps/gameboard/static/apps/gameboard/voronoi-map.js
Normal file
121
src/apps/gameboard/static/apps/gameboard/voronoi-map.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/* voronoi-map.js — SEED MAP dual-graph renderer (Voronoi map, roadmap step 21).
|
||||
*
|
||||
* Paints a 2D d3-delaunay DUAL GRAPH into an inline <svg>: a Voronoi CELL layer
|
||||
* (the "territory" base) + a Delaunay EDGE layer (the "adjacency/movement"
|
||||
* overlay). STEP 1 uses PLACEHOLDER seeds; card-driven seeding (the 6 Celtic-
|
||||
* Cross cards -> Deleuzian territoriality) is STEP 2. See project_voronoi_spec.
|
||||
*
|
||||
* Requires D3 v7 (with d3-delaunay, bundled in d3.min.js) as window.d3, loaded
|
||||
* before this file. API parity with window.SkyWheel / window.SeaDeal:
|
||||
* SeedMap.draw(svgEl, seeds) — render the dual graph for an explicit seed array of [x,y]
|
||||
* SeedMap.drawPlaceholder(svgEl) — STEP-1 placeholder seeds sized to the svg's box
|
||||
* SeedMap.clear(svgEl) — empty the svg
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var SVGNS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function _box(svgEl) {
|
||||
var w = svgEl.clientWidth, h = svgEl.clientHeight;
|
||||
if ((!w || !h) && svgEl.getBoundingClientRect) {
|
||||
var r = svgEl.getBoundingClientRect();
|
||||
w = w || Math.round(r.width);
|
||||
h = h || Math.round(r.height);
|
||||
}
|
||||
return [w, h];
|
||||
}
|
||||
|
||||
function clear(svgEl) {
|
||||
while (svgEl.firstChild) svgEl.removeChild(svgEl.firstChild);
|
||||
}
|
||||
|
||||
function _g(cls) {
|
||||
var g = document.createElementNS(SVGNS, 'g');
|
||||
g.setAttribute('class', cls);
|
||||
return g;
|
||||
}
|
||||
|
||||
// PLACEHOLDER seeds: a centre point + two staggered concentric rings.
|
||||
// Deterministic (no Math.random) so the FT's cell/edge counts are stable.
|
||||
function placeholderSeeds(w, h) {
|
||||
var cx = w / 2, cy = h / 2, span = Math.min(w, h);
|
||||
var pts = [[cx, cy]];
|
||||
var rings = [[6, span * 0.32], [6, span * 0.46]];
|
||||
for (var ri = 0; ri < rings.length; ri++) {
|
||||
var n = rings[ri][0], rad = rings[ri][1], off = (ri * Math.PI) / n;
|
||||
for (var i = 0; i < n; i++) {
|
||||
var a = off + (i / n) * 2 * Math.PI;
|
||||
pts.push([cx + rad * Math.cos(a), cy + rad * Math.sin(a)]);
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function draw(svgEl, seeds) {
|
||||
var d3 = window.d3;
|
||||
if (!d3 || !d3.Delaunay || !svgEl || !seeds || seeds.length < 3) return;
|
||||
var box = _box(svgEl), w = box[0], h = box[1];
|
||||
if (!w || !h) return; // 0-box guard — felt measured before it has layout
|
||||
clear(svgEl);
|
||||
|
||||
var delaunay = d3.Delaunay.from(seeds);
|
||||
var voronoi = delaunay.voronoi([0, 0, w, h]);
|
||||
|
||||
// ── Territory layer: one bounded Voronoi cell <path> per seed.
|
||||
var cells = _g('seed-cells');
|
||||
// d3 stamps the TRUE seed index on each yielded polygon as ring.index;
|
||||
// cellPolygons() SKIPS degenerate/clipped cells, so a loop counter would drift
|
||||
// from the seed id once seeds can coincide (Step 2 card seeds). Use ring.index.
|
||||
Array.from(voronoi.cellPolygons()).forEach(function (ring) {
|
||||
var d = 'M' + ring.map(function (p) { return p[0] + ',' + p[1]; }).join('L') + 'Z';
|
||||
var path = document.createElementNS(SVGNS, 'path');
|
||||
path.setAttribute('class', 'voronoi-cell');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('data-seed', ring.index);
|
||||
cells.appendChild(path);
|
||||
});
|
||||
svgEl.appendChild(cells);
|
||||
|
||||
// ── Adjacency layer: de-duped Delaunay edges (each unordered pair once).
|
||||
var edges = _g('seed-edges');
|
||||
var tri = delaunay.triangles, seen = {};
|
||||
for (var t = 0; t < tri.length; t += 3) {
|
||||
for (var e = 0; e < 3; e++) {
|
||||
var a = tri[t + e], b = tri[t + ((e + 1) % 3)];
|
||||
var key = a < b ? a + '-' + b : b + '-' + a;
|
||||
if (seen[key]) continue;
|
||||
seen[key] = 1;
|
||||
var pa = seeds[a], pb = seeds[b];
|
||||
var line = document.createElementNS(SVGNS, 'line');
|
||||
line.setAttribute('class', 'delaunay-edge');
|
||||
line.setAttribute('x1', pa[0]); line.setAttribute('y1', pa[1]);
|
||||
line.setAttribute('x2', pb[0]); line.setAttribute('y2', pb[1]);
|
||||
edges.appendChild(line);
|
||||
}
|
||||
}
|
||||
svgEl.appendChild(edges);
|
||||
|
||||
// ── Seed dots (placeholder in STEP 1; card-anchored in STEP 2).
|
||||
var dots = _g('seed-points');
|
||||
seeds.forEach(function (p) {
|
||||
var dot = document.createElementNS(SVGNS, 'circle');
|
||||
dot.setAttribute('class', 'voronoi-seed');
|
||||
dot.setAttribute('cx', p[0]); dot.setAttribute('cy', p[1]);
|
||||
dot.setAttribute('r', 2.5);
|
||||
dots.appendChild(dot);
|
||||
});
|
||||
svgEl.appendChild(dots);
|
||||
}
|
||||
|
||||
function drawPlaceholder(svgEl) {
|
||||
var box = _box(svgEl);
|
||||
draw(svgEl, placeholderSeeds(box[0], box[1]));
|
||||
}
|
||||
|
||||
window.SeedMap = {
|
||||
draw: draw,
|
||||
drawPlaceholder: drawPlaceholder,
|
||||
clear: clear,
|
||||
placeholderSeeds: placeholderSeeds,
|
||||
};
|
||||
}());
|
||||
119
src/functional_tests/test_game_room_seed_map.py
Normal file
119
src/functional_tests/test_game_room_seed_map.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Functional test for the SEED MAP felt — Voronoi map, roadmap step 21, Step 1.
|
||||
|
||||
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
|
||||
is the live hex phase btn, and clicking it opens a felt that paints a 2D
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from apps.epic.models import Character, DeckVariant, TarotCard
|
||||
from apps.lyric.models import User
|
||||
|
||||
from .base import FunctionalTest
|
||||
from .test_game_room_select_sea import _make_sky_confirmed_room
|
||||
|
||||
|
||||
_HAND_POSITIONS = ["cover", "cross", "crown", "lay", "leave", "loom"]
|
||||
|
||||
|
||||
class SeedMapFeltTest(FunctionalTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.browser.set_window_size(800, 1200) # portrait — clicking hex-area btns
|
||||
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman", "card_count": 106, "is_default": True,
|
||||
"is_polarized": True, "has_card_images": False},
|
||||
)
|
||||
# One card is enough: hand_complete is purely len(hand) >= 6, and STEP-1
|
||||
# rendering uses PLACEHOLDER seeds, so card identity is irrelevant. Seed
|
||||
# it explicitly — TransactionTestCase flushes migration cards (no
|
||||
# serialized_rollback). [[feedback-transactiontestcase-flush]]
|
||||
self.card, _ = TarotCard.objects.get_or_create(
|
||||
deck_variant=self.earthman, slug="seed-fixture-em",
|
||||
defaults={"arcana": "MAJOR", "suit": None, "number": 0, "name": "Fixture"},
|
||||
)
|
||||
self.gamer, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
self.gamer.unlocked_decks.add(self.earthman)
|
||||
self.gamer.equipped_deck = self.earthman
|
||||
self.gamer.save(update_fields=["equipped_deck"])
|
||||
|
||||
def _seed_room(self, hand_len=6):
|
||||
"""SKY_SELECT room, founder seated PC, with a CONFIRMED Character whose
|
||||
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)
|
||||
hand = [
|
||||
{"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"}
|
||||
for p in _HAND_POSITIONS[:hand_len]
|
||||
]
|
||||
Character.objects.create(
|
||||
seat=seat, significator=seat.significator or self.card,
|
||||
chart_data={"planets": {}}, confirmed_at=timezone.now(),
|
||||
celtic_cross={"spread": "waite-smith", "hand": hand},
|
||||
)
|
||||
return room
|
||||
|
||||
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)
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self._room_url(room))
|
||||
|
||||
# hand_complete → SEED MAP is the live phase btn (loses hex-phase-btn--out).
|
||||
self.wait_for(lambda: self.assertNotIn(
|
||||
"hex-phase-btn--out",
|
||||
self.browser.find_element(By.ID, "id_seed_map_btn").get_attribute("class"),
|
||||
))
|
||||
|
||||
# Click opens the felt. JS-click + retry absorbs the parse-time bind race
|
||||
# (mirrors _click_and_assert_sea_open in test_game_room_select_sea.py).
|
||||
def _click_and_assert_open():
|
||||
btn = self.browser.find_element(By.ID, "id_seed_map_btn")
|
||||
self.browser.execute_script("arguments[0].click()", btn)
|
||||
self.assertTrue(self.browser.execute_script(
|
||||
"return document.documentElement.classList.contains('seed-open')"
|
||||
))
|
||||
self.wait_for(_click_and_assert_open)
|
||||
|
||||
self.assertTrue(
|
||||
self.browser.find_element(By.ID, "id_seed_map_overlay").is_displayed()
|
||||
)
|
||||
|
||||
# The d3-delaunay dual graph paints BOTH layers from placeholder seeds:
|
||||
# Voronoi cells (territory) + Delaunay edges (adjacency).
|
||||
self.wait_for(lambda: self.assertGreaterEqual(
|
||||
self.browser.execute_script(
|
||||
"return document.querySelectorAll('#id_seed_map_svg .voronoi-cell').length"
|
||||
), 1,
|
||||
))
|
||||
self.assertGreaterEqual(
|
||||
self.browser.execute_script(
|
||||
"return document.querySelectorAll('#id_seed_map_svg .delaunay-edge').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")
|
||||
self.browser.get(self._room_url(room))
|
||||
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_seed_map_btn"))
|
||||
# SEED MAP stays --out and the felt is never injected into the DOM.
|
||||
self.assertIn(
|
||||
"hex-phase-btn--out",
|
||||
self.browser.find_element(By.ID, "id_seed_map_btn").get_attribute("class"),
|
||||
)
|
||||
self.assertEqual(self.browser.find_elements(By.ID, "id_seed_map_overlay"), [])
|
||||
@@ -35,6 +35,7 @@
|
||||
<script src="VoiceMeshSpec.js"></script>
|
||||
<script src="VoiceGlowSpec.js"></script>
|
||||
<script src="RoomViewsSpec.js"></script>
|
||||
<script src="VoronoiMapSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/applets/row-lock.js"></script>
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
@@ -54,6 +55,7 @@
|
||||
<script src="/static/apps/voice/voice-mesh.js"></script>
|
||||
<script src="/static/apps/voice/voice-glow.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/sky-wheel.js"></script>
|
||||
<!-- Jasmine env config (optional) -->
|
||||
<script src="lib/jasmine-6.0.1/boot1.js"></script>
|
||||
|
||||
72
src/static/tests/VoronoiMapSpec.js
Normal file
72
src/static/tests/VoronoiMapSpec.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// ── VoronoiMapSpec.js ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for voronoi-map.js — the SEED MAP dual-graph renderer (Voronoi map,
|
||||
// roadmap step 21, Step 1). Verifies the two SVG layers paint from a seed array:
|
||||
// g.seed-cells > path.voronoi-cell — Voronoi territory cells (one per seed)
|
||||
// g.seed-edges > line.delaunay-edge — Delaunay adjacency edges
|
||||
//
|
||||
// DOM contract: <svg id="id_seed_map_svg" class="voronoi-map"> as the draw target.
|
||||
// Requires window.d3 (with d3-delaunay) + window.SeedMap loaded before this spec.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SeedMap — d3-delaunay dual graph", () => {
|
||||
// A clean, non-degenerate triangle — 3 seeds in general position give an
|
||||
// UNAMBIGUOUS triangulation: 3 bounded cells + 3 edges (no cocircularity).
|
||||
const TRIANGLE_SEEDS = [[100, 100], [300, 120], [200, 300]];
|
||||
|
||||
let svgEl;
|
||||
|
||||
beforeEach(() => {
|
||||
svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svgEl.setAttribute("id", "id_seed_map_svg");
|
||||
svgEl.setAttribute("class", "voronoi-map");
|
||||
svgEl.setAttribute("width", "400");
|
||||
svgEl.setAttribute("height", "400");
|
||||
svgEl.style.width = "400px";
|
||||
svgEl.style.height = "400px";
|
||||
document.body.appendChild(svgEl);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SeedMap.clear(svgEl);
|
||||
svgEl.remove();
|
||||
});
|
||||
|
||||
it("draws one Voronoi cell per seed (the territory layer)", () => {
|
||||
SeedMap.draw(svgEl, TRIANGLE_SEEDS);
|
||||
expect(svgEl.querySelectorAll("g.seed-cells path.voronoi-cell").length)
|
||||
.toBe(TRIANGLE_SEEDS.length);
|
||||
});
|
||||
|
||||
it("draws the Delaunay adjacency edges (the movement overlay)", () => {
|
||||
SeedMap.draw(svgEl, TRIANGLE_SEEDS);
|
||||
// One triangle → exactly 3 unique (de-duped) edges.
|
||||
expect(svgEl.querySelectorAll("g.seed-edges line.delaunay-edge").length)
|
||||
.toBe(3);
|
||||
});
|
||||
|
||||
it("renders the two layers as distinct groups", () => {
|
||||
SeedMap.draw(svgEl, TRIANGLE_SEEDS);
|
||||
expect(svgEl.querySelectorAll("g.seed-cells").length).toBe(1);
|
||||
expect(svgEl.querySelectorAll("g.seed-edges").length).toBe(1);
|
||||
});
|
||||
|
||||
it("clear() empties the svg and re-draw is idempotent (no doubling)", () => {
|
||||
SeedMap.draw(svgEl, TRIANGLE_SEEDS);
|
||||
SeedMap.clear(svgEl);
|
||||
expect(svgEl.children.length).toBe(0);
|
||||
SeedMap.draw(svgEl, TRIANGLE_SEEDS);
|
||||
SeedMap.draw(svgEl, TRIANGLE_SEEDS); // second draw clears the first
|
||||
expect(svgEl.querySelectorAll("path.voronoi-cell").length)
|
||||
.toBe(TRIANGLE_SEEDS.length);
|
||||
});
|
||||
|
||||
it("drawPlaceholder seeds the dual graph from the svg box (STEP-1 default)", () => {
|
||||
SeedMap.drawPlaceholder(svgEl);
|
||||
// placeholderSeeds = 1 centre + two rings of 6 = 13 bounded cells.
|
||||
expect(svgEl.querySelectorAll("path.voronoi-cell").length).toBe(13);
|
||||
// A 13-point triangulation has well more than a spanning tree of edges.
|
||||
expect(svgEl.querySelectorAll("line.delaunay-edge").length)
|
||||
.toBeGreaterThanOrEqual(12);
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,13 @@ html.sea-open .room-aperture.is-scrollable {
|
||||
scroll-snap-type: none;
|
||||
}
|
||||
|
||||
// Same pin while the SEED MAP felt is up (html.seed-open) — the felt fills the
|
||||
// hex pane + the reelhouse below must stay out of reach. Mirrors sky/sea.
|
||||
html.seed-open .room-aperture.is-scrollable {
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: none;
|
||||
}
|
||||
|
||||
.room-scroll-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -198,6 +198,83 @@ html.sea-open .position-strip {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
// ── In-room SEED MAP felt (roadmap step 21 — Voronoi/Delaunay dual graph) ──────
|
||||
// Third --duoUser felt sibling of CAST SKY + DRAW SEA, rendered INSIDE
|
||||
// .room-hex-pane (my_sea-style). Holds the d3-delaunay dual graph: a Voronoi CELL
|
||||
// layer (territory) + a Delaunay EDGE overlay (adjacency). Hidden until
|
||||
// #id_seed_map_btn adds html.seed-open; opens via a clean SWAP (openSeed() first
|
||||
// closeSkyFelt()/closeSeaFelt() — three equal-z felts now, trap T3).
|
||||
html.seed-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.room-hex-pane.has-seed-stage {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Chained (0,2,0) to mirror .sky-page--room / .sea-page--room and stay robust
|
||||
// against any future bare `.seed-page` rule; the felt must be absolutely
|
||||
// positioned inside the relative hex-pane. [[feedback-scss-import-order-specificity]]
|
||||
.seed-page.seed-page--room {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: rgba(var(--duoUser), 1); // own --duoUser surface — covers the hex
|
||||
visibility: hidden; // hidden until opened; can't eat the SEED MAP btn click beneath it
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html.seed-open .seed-page.seed-page--room {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// The inner wrappers fill the pane so the svg's 100%/100% resolves against the
|
||||
// full hex-pane box — else the flex-centred wrappers shrink-wrap the svg to its
|
||||
// ~300px intrinsic size and the map floats as a small centred box.
|
||||
.seed-page--room .seed-map-body,
|
||||
.seed-page--room .seed-map-col {
|
||||
width: 100%;
|
||||
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.
|
||||
.seed-page--room svg.voronoi-map {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.voronoi-cell { // territory base — filled cells
|
||||
fill: rgba(var(--priUser), 0.18);
|
||||
stroke: rgba(var(--secUser), 0.55);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.delaunay-edge { // adjacency overlay — drawn on top
|
||||
fill: none;
|
||||
stroke: rgba(var(--secUser), 0.9);
|
||||
stroke-width: 0.75;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.voronoi-seed { // placeholder seed dots (STEP 1)
|
||||
fill: rgba(var(--secUser), 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the z-130 position strip while the felt is up (mirrors sky/sea).
|
||||
html.seed-open .position-strip {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
// NB: NO `html.seed-open #id_aperture_fill { opacity: 1 }` rule — the z-90 fill
|
||||
// must stay transparent over this z-5 felt, else its 0.15s opacity transition
|
||||
// paints an opaque --duoUser sheet OVER the map (flash-then-vanish). The felt
|
||||
// covers the hex with its own --duoUser bg. [[feedback-felt-aperture-fill-covers-felt]]
|
||||
|
||||
// ── Backdrop ──────────────────────────────────────────────────────────────────
|
||||
|
||||
.sky-backdrop {
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<script src="VoiceMeshSpec.js"></script>
|
||||
<script src="VoiceGlowSpec.js"></script>
|
||||
<script src="RoomViewsSpec.js"></script>
|
||||
<script src="VoronoiMapSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/applets/row-lock.js"></script>
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
@@ -54,6 +55,7 @@
|
||||
<script src="/static/apps/voice/voice-mesh.js"></script>
|
||||
<script src="/static/apps/voice/voice-glow.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/sky-wheel.js"></script>
|
||||
<!-- Jasmine env config (optional) -->
|
||||
<script src="lib/jasmine-6.0.1/boot1.js"></script>
|
||||
|
||||
72
src/static_src/tests/VoronoiMapSpec.js
Normal file
72
src/static_src/tests/VoronoiMapSpec.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// ── VoronoiMapSpec.js ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for voronoi-map.js — the SEED MAP dual-graph renderer (Voronoi map,
|
||||
// roadmap step 21, Step 1). Verifies the two SVG layers paint from a seed array:
|
||||
// g.seed-cells > path.voronoi-cell — Voronoi territory cells (one per seed)
|
||||
// g.seed-edges > line.delaunay-edge — Delaunay adjacency edges
|
||||
//
|
||||
// DOM contract: <svg id="id_seed_map_svg" class="voronoi-map"> as the draw target.
|
||||
// Requires window.d3 (with d3-delaunay) + window.SeedMap loaded before this spec.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SeedMap — d3-delaunay dual graph", () => {
|
||||
// A clean, non-degenerate triangle — 3 seeds in general position give an
|
||||
// UNAMBIGUOUS triangulation: 3 bounded cells + 3 edges (no cocircularity).
|
||||
const TRIANGLE_SEEDS = [[100, 100], [300, 120], [200, 300]];
|
||||
|
||||
let svgEl;
|
||||
|
||||
beforeEach(() => {
|
||||
svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svgEl.setAttribute("id", "id_seed_map_svg");
|
||||
svgEl.setAttribute("class", "voronoi-map");
|
||||
svgEl.setAttribute("width", "400");
|
||||
svgEl.setAttribute("height", "400");
|
||||
svgEl.style.width = "400px";
|
||||
svgEl.style.height = "400px";
|
||||
document.body.appendChild(svgEl);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SeedMap.clear(svgEl);
|
||||
svgEl.remove();
|
||||
});
|
||||
|
||||
it("draws one Voronoi cell per seed (the territory layer)", () => {
|
||||
SeedMap.draw(svgEl, TRIANGLE_SEEDS);
|
||||
expect(svgEl.querySelectorAll("g.seed-cells path.voronoi-cell").length)
|
||||
.toBe(TRIANGLE_SEEDS.length);
|
||||
});
|
||||
|
||||
it("draws the Delaunay adjacency edges (the movement overlay)", () => {
|
||||
SeedMap.draw(svgEl, TRIANGLE_SEEDS);
|
||||
// One triangle → exactly 3 unique (de-duped) edges.
|
||||
expect(svgEl.querySelectorAll("g.seed-edges line.delaunay-edge").length)
|
||||
.toBe(3);
|
||||
});
|
||||
|
||||
it("renders the two layers as distinct groups", () => {
|
||||
SeedMap.draw(svgEl, TRIANGLE_SEEDS);
|
||||
expect(svgEl.querySelectorAll("g.seed-cells").length).toBe(1);
|
||||
expect(svgEl.querySelectorAll("g.seed-edges").length).toBe(1);
|
||||
});
|
||||
|
||||
it("clear() empties the svg and re-draw is idempotent (no doubling)", () => {
|
||||
SeedMap.draw(svgEl, TRIANGLE_SEEDS);
|
||||
SeedMap.clear(svgEl);
|
||||
expect(svgEl.children.length).toBe(0);
|
||||
SeedMap.draw(svgEl, TRIANGLE_SEEDS);
|
||||
SeedMap.draw(svgEl, TRIANGLE_SEEDS); // second draw clears the first
|
||||
expect(svgEl.querySelectorAll("path.voronoi-cell").length)
|
||||
.toBe(TRIANGLE_SEEDS.length);
|
||||
});
|
||||
|
||||
it("drawPlaceholder seeds the dual graph from the svg box (STEP-1 default)", () => {
|
||||
SeedMap.drawPlaceholder(svgEl);
|
||||
// placeholderSeeds = 1 centre + two rings of 6 = 13 bounded cells.
|
||||
expect(svgEl.querySelectorAll("path.voronoi-cell").length).toBe(13);
|
||||
// A 13-point triangulation has well more than a spanning tree of edges.
|
||||
expect(svgEl.querySelectorAll("line.delaunay-edge").length)
|
||||
.toBeGreaterThanOrEqual(12);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,11 @@
|
||||
<div class="room-menu-sea" style="display:none">
|
||||
<a href="{% url 'epic:room' room.id %}{% if current_slot %}?seat={{ current_slot }}{% endif %}" class="btn btn-cancel">NVM</a>
|
||||
</div>
|
||||
{# Seed pane — shown by room-views.js (updateGear) while the SEED MAP felt is #}
|
||||
{# open (html.seed-open). NVM returns to the table-hex. ?seat as above. #}
|
||||
<div class="room-menu-seed" style="display:none">
|
||||
<a href="{% url 'epic:room' room.id %}{% if current_slot %}?seat={{ current_slot }}{% endif %}" class="btn btn-cancel">NVM</a>
|
||||
</div>
|
||||
<div class="room-menu-scroll" style="display:none">
|
||||
<form id="id_scroll_filter_form">
|
||||
<label><input type="checkbox" name="labels" value="frame" checked> Frame</label>
|
||||
|
||||
@@ -480,6 +480,10 @@ via epic:sea_save. `?seat` threads the CARTE-selected seat onto the action URLs.
|
||||
if (document.documentElement.classList.contains('sky-open') && window.closeSkyFelt) {
|
||||
window.closeSkyFelt();
|
||||
}
|
||||
// SEED MAP is a third equal-z felt sibling — close it too (trap T3).
|
||||
if (document.documentElement.classList.contains('seed-open') && window.closeSeedMapFelt) {
|
||||
window.closeSeedMapFelt();
|
||||
}
|
||||
document.documentElement.classList.add('sea-open');
|
||||
document.documentElement.classList.add('sea-entered');
|
||||
_disablePhaseBtns();
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
{% load static %}
|
||||
{# 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">
|
||||
<div class="seed-map-body">
|
||||
<div class="seed-map-col">
|
||||
<svg id="id_seed_map_svg" class="voronoi-map"></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
|
||||
<script src="{% static 'apps/gameboard/voronoi-map.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var html = document.documentElement;
|
||||
var overlay = document.getElementById('id_seed_map_overlay');
|
||||
var svgEl = document.getElementById('id_seed_map_svg');
|
||||
if (!overlay || !svgEl) return;
|
||||
|
||||
// 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 = [];
|
||||
function _disablePhaseBtns() {
|
||||
_disabled = [];
|
||||
['id_text_btn'].forEach(function (id) {
|
||||
var b = document.getElementById(id);
|
||||
if (b && b.classList.contains('active')) { b.classList.remove('active'); _disabled.push(b); }
|
||||
});
|
||||
}
|
||||
function _restorePhaseBtns() {
|
||||
_disabled.forEach(function (b) { b.classList.add('active'); });
|
||||
_disabled = [];
|
||||
}
|
||||
|
||||
// Paint/repaint the dual graph at the svg's CURRENT box (re-measured each call).
|
||||
function _paint() {
|
||||
if (window.SeedMap) window.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).
|
||||
var _ro = null, _raf = 0;
|
||||
function _observeResize() {
|
||||
if (_ro || typeof ResizeObserver === 'undefined') return;
|
||||
_ro = new ResizeObserver(function () {
|
||||
if (!html.classList.contains('seed-open')) return; // ignore while hidden
|
||||
if (_raf) cancelAnimationFrame(_raf);
|
||||
_raf = requestAnimationFrame(_paint); // one repaint per frame
|
||||
});
|
||||
_ro.observe(svgEl);
|
||||
}
|
||||
|
||||
// Open/close — trap T3: full-close BOTH sibling felts FIRST (three equal-z
|
||||
// felts now; no open path drops another's open-class on its own).
|
||||
function openSeed() {
|
||||
if (html.classList.contains('sky-open') && window.closeSkyFelt) window.closeSkyFelt();
|
||||
if (html.classList.contains('sea-open') && window.closeSeaFelt) window.closeSeaFelt();
|
||||
html.classList.add('seed-open');
|
||||
_disablePhaseBtns();
|
||||
// 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();
|
||||
_observeResize();
|
||||
if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear();
|
||||
}
|
||||
function closeSeed() {
|
||||
html.classList.remove('seed-open');
|
||||
_restorePhaseBtns();
|
||||
if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear();
|
||||
}
|
||||
window.openSeedMapFelt = openSeed; // burger reopen + cross-felt swap
|
||||
window.closeSeedMapFelt = closeSeed; // so openSky/openSea can swap cleanly (T3)
|
||||
|
||||
// PHASE btn #id_seed_map_btn lives in _room_hex_center.html (parsed BEFORE this
|
||||
// overlay via _table_hex.html) -> bind directly, no defer. Trap T4 only bites
|
||||
// _burger.html elements (included after); STEP 1 has no burger seed sub-btn.
|
||||
var seedBtn = document.getElementById('id_seed_map_btn');
|
||||
if (seedBtn) seedBtn.addEventListener('click', openSeed);
|
||||
}());
|
||||
</script>
|
||||
@@ -191,6 +191,10 @@
|
||||
if (document.documentElement.classList.contains('sea-open') && window.closeSeaFelt) {
|
||||
window.closeSeaFelt();
|
||||
}
|
||||
// SEED MAP is a third equal-z felt sibling — close it too (trap T3).
|
||||
if (document.documentElement.classList.contains('seed-open') && window.closeSeedMapFelt) {
|
||||
window.closeSeedMapFelt();
|
||||
}
|
||||
document.documentElement.classList.add('sky-open');
|
||||
_disableTextBtn();
|
||||
// Re-sync the room gear to the sky NVM pane (room-views.js owns the gear
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
{# so the green-felt _sig_select_overlay fills it (my_sea-style), covering #}
|
||||
{# the hex/seats behind. Dismissing the overlay (this gamer's sigs done) #}
|
||||
{# reveals the hex + waiting message underneath. #}
|
||||
<div class="room-pane room-hex-pane{% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done and viewer_cost_current %} has-sig-stage{% endif %}{% if room.table_status == "SKY_SELECT" and not sky_confirmed and viewer_cost_current %} has-sky-stage{% endif %}{% if room.table_status == "SKY_SELECT" and sky_confirmed and viewer_cost_current %} has-sea-stage{% endif %}">
|
||||
<div class="room-pane room-hex-pane{% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done and viewer_cost_current %} has-sig-stage{% endif %}{% if room.table_status == "SKY_SELECT" and not sky_confirmed and viewer_cost_current %} has-sky-stage{% endif %}{% if room.table_status == "SKY_SELECT" and sky_confirmed and viewer_cost_current %} has-sea-stage{% endif %}{% if room.table_status == "SKY_SELECT" and hand_complete and viewer_cost_current %} has-seed-stage{% endif %}">
|
||||
<div class="room-shell">
|
||||
<div id="id_game_table" class="room-table">
|
||||
{# SCAN SIGS advances the whole table past role-select — gated on #}
|
||||
@@ -76,6 +76,12 @@
|
||||
{% if room.table_status == "SKY_SELECT" and sky_confirmed and viewer_cost_current %}
|
||||
{% include "apps/gameboard/_partials/_sea_overlay.html" %}
|
||||
{% endif %}
|
||||
{# SEED MAP felt — d3-delaunay Voronoi/Delaunay dual graph (roadmap step #}
|
||||
{# 21). Injected once the 6-card sea hand completes (hand_complete); the #}
|
||||
{# inline sibling of the sky/sea felts, opened by #id_seed_map_btn. #}
|
||||
{% if room.table_status == "SKY_SELECT" and hand_complete and viewer_cost_current %}
|
||||
{% include "apps/gameboard/_partials/_seed_map_overlay.html" %}
|
||||
{% endif %}
|
||||
{# No-reload path: the post-save sky cascade fetches sea_partial + injects #}
|
||||
{# the DRAW SEA felt HERE (inside the pane so .sea-page--room fills it), #}
|
||||
{# executing its inline init. Empty until _injectSeaOverlay runs. #}
|
||||
|
||||
Reference in New Issue
Block a user