SEED MAP felt: 2D d3-delaunay Voronoi/Delaunay dual graph (roadmap step 21, Step 1) — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

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:
Disco DeDisco
2026-06-09 21:02:21 -04:00
parent 02c4307a95
commit cde556b178
15 changed files with 643 additions and 4 deletions

View File

@@ -91,6 +91,7 @@
var paneAtlas = roomMenu && roomMenu.querySelector('.room-menu-atlas'); var paneAtlas = roomMenu && roomMenu.querySelector('.room-menu-atlas');
var paneSky = roomMenu && roomMenu.querySelector('.room-menu-sky'); var paneSky = roomMenu && roomMenu.querySelector('.room-menu-sky');
var paneSea = roomMenu && roomMenu.querySelector('.room-menu-sea'); 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 GEARLESS = { yarn: true, post: true, pulse: true };
var onReelhouse = false; var onReelhouse = false;
@@ -102,6 +103,7 @@
if (paneAtlas) paneAtlas.style.display = pane === paneAtlas ? '' : 'none'; if (paneAtlas) paneAtlas.style.display = pane === paneAtlas ? '' : 'none';
if (paneSky) paneSky.style.display = pane === paneSky ? '' : 'none'; if (paneSky) paneSky.style.display = pane === paneSky ? '' : 'none';
if (paneSea) paneSea.style.display = pane === paneSea ? '' : 'none'; if (paneSea) paneSea.style.display = pane === paneSea ? '' : 'none';
if (paneSeed) paneSeed.style.display = pane === paneSeed ? '' : 'none';
} }
function closeRoomMenu() { function closeRoomMenu() {
if (roomMenu) roomMenu.style.display = 'none'; if (roomMenu) roomMenu.style.display = 'none';
@@ -125,6 +127,13 @@
showPane(paneSea); showPane(paneSea);
return; 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]; var disabled = onReelhouse && GEARLESS[current];
gearBtn.classList.toggle('gear-disabled', !!disabled); gearBtn.classList.toggle('gear-disabled', !!disabled);
if (disabled) { closeRoomMenu(); return; } if (disabled) { closeRoomMenu(); return; }

View File

@@ -1018,11 +1018,12 @@ class CarteTrayFollowsSelectedSeatTest(TestCase):
self.room.save() self.room.save()
response = self.client.get(self.room_url + "?seat=4") response = self.client.get(self.room_url + "?seat=4")
self.assertEqual(response.context["current_slot"], 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( self.assertContains(
response, response,
f'href="{self.room_url}?seat=4" class="btn btn-cancel">NVM</a>', 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): def test_sky_sea_nvm_default_targets_lowest_owned(self):
@@ -1035,7 +1036,7 @@ class CarteTrayFollowsSelectedSeatTest(TestCase):
self.assertContains( self.assertContains(
response, response,
f'href="{self.room_url}?seat=1" class="btn btn-cancel">NVM</a>', 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") [sea] = parsed.cssselect("#id_pick_sea_btn")
self.assertIn("hex-phase-btn--out", sea.get("class", "")) 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): 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

@@ -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,
};
}());

View 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"), [])

View File

@@ -35,6 +35,7 @@
<script src="VoiceMeshSpec.js"></script> <script src="VoiceMeshSpec.js"></script>
<script src="VoiceGlowSpec.js"></script> <script src="VoiceGlowSpec.js"></script>
<script src="RoomViewsSpec.js"></script> <script src="RoomViewsSpec.js"></script>
<script src="VoronoiMapSpec.js"></script>
<!-- src files --> <!-- src files -->
<script src="/static/apps/applets/row-lock.js"></script> <script src="/static/apps/applets/row-lock.js"></script>
<script src="/static/apps/dashboard/dashboard.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-mesh.js"></script>
<script src="/static/apps/voice/voice-glow.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/d3.min.js"></script>
<script src="/static/apps/gameboard/voronoi-map.js"></script>
<script src="/static/apps/gameboard/sky-wheel.js"></script> <script src="/static/apps/gameboard/sky-wheel.js"></script>
<!-- Jasmine env config (optional) --> <!-- Jasmine env config (optional) -->
<script src="lib/jasmine-6.0.1/boot1.js"></script> <script src="lib/jasmine-6.0.1/boot1.js"></script>

View 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);
});
});

View File

@@ -115,6 +115,13 @@ html.sea-open .room-aperture.is-scrollable {
scroll-snap-type: none; 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 { .room-scroll-pane {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -198,6 +198,83 @@ html.sea-open .position-strip {
transition: opacity 0.5s ease; 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 ────────────────────────────────────────────────────────────────── // ── Backdrop ──────────────────────────────────────────────────────────────────
.sky-backdrop { .sky-backdrop {

View File

@@ -35,6 +35,7 @@
<script src="VoiceMeshSpec.js"></script> <script src="VoiceMeshSpec.js"></script>
<script src="VoiceGlowSpec.js"></script> <script src="VoiceGlowSpec.js"></script>
<script src="RoomViewsSpec.js"></script> <script src="RoomViewsSpec.js"></script>
<script src="VoronoiMapSpec.js"></script>
<!-- src files --> <!-- src files -->
<script src="/static/apps/applets/row-lock.js"></script> <script src="/static/apps/applets/row-lock.js"></script>
<script src="/static/apps/dashboard/dashboard.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-mesh.js"></script>
<script src="/static/apps/voice/voice-glow.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/d3.min.js"></script>
<script src="/static/apps/gameboard/voronoi-map.js"></script>
<script src="/static/apps/gameboard/sky-wheel.js"></script> <script src="/static/apps/gameboard/sky-wheel.js"></script>
<!-- Jasmine env config (optional) --> <!-- Jasmine env config (optional) -->
<script src="lib/jasmine-6.0.1/boot1.js"></script> <script src="lib/jasmine-6.0.1/boot1.js"></script>

View 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);
});
});

View File

@@ -39,6 +39,11 @@
<div class="room-menu-sea" style="display:none"> <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> <a href="{% url 'epic:room' room.id %}{% if current_slot %}?seat={{ current_slot }}{% endif %}" class="btn btn-cancel">NVM</a>
</div> </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"> <div class="room-menu-scroll" style="display:none">
<form id="id_scroll_filter_form"> <form id="id_scroll_filter_form">
<label><input type="checkbox" name="labels" value="frame" checked> Frame</label> <label><input type="checkbox" name="labels" value="frame" checked> Frame</label>

View File

@@ -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) { if (document.documentElement.classList.contains('sky-open') && window.closeSkyFelt) {
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-open');
document.documentElement.classList.add('sea-entered'); document.documentElement.classList.add('sea-entered');
_disablePhaseBtns(); _disablePhaseBtns();

View File

@@ -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>

View File

@@ -191,6 +191,10 @@
if (document.documentElement.classList.contains('sea-open') && window.closeSeaFelt) { if (document.documentElement.classList.contains('sea-open') && window.closeSeaFelt) {
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'); document.documentElement.classList.add('sky-open');
_disableTextBtn(); _disableTextBtn();
// Re-sync the room gear to the sky NVM pane (room-views.js owns the gear // Re-sync the room gear to the sky NVM pane (room-views.js owns the gear

View File

@@ -36,7 +36,7 @@
{# so the green-felt _sig_select_overlay fills it (my_sea-style), covering #} {# 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) #} {# the hex/seats behind. Dismissing the overlay (this gamer's sigs done) #}
{# reveals the hex + waiting message underneath. #} {# 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 class="room-shell">
<div id="id_game_table" class="room-table"> <div id="id_game_table" class="room-table">
{# SCAN SIGS advances the whole table past role-select — gated on #} {# 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 %} {% if room.table_status == "SKY_SELECT" and sky_confirmed and viewer_cost_current %}
{% include "apps/gameboard/_partials/_sea_overlay.html" %} {% include "apps/gameboard/_partials/_sea_overlay.html" %}
{% endif %} {% 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 #} {# 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), #} {# the DRAW SEA felt HERE (inside the pane so .sea-page--room fills it), #}
{# executing its inline init. Empty until _injectSeaOverlay runs. #} {# executing its inline init. Empty until _injectSeaOverlay runs. #}