From 8a7a71b28e0f02bad0e2c66835c933735e74bdfe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:31:27 +0000 Subject: [PATCH 1/2] Add Full Size / Max Size shape-sizing options to deck builder CardSvg previously sized every shape with a fixed 35-unit box on an implicit 3x3 grid, regardless of how many shapes a card had, wasting space on cards with fewer shapes. Adds a "Card Size" control to the Game Editor: "Full Size" sizes every card's shapes off the deck's largest shape count so sizing stays uniform across the deck (reduces to the original fixed layout when that max is 9, so existing decks render unchanged), and "Max Size" sizes each card independently off its own shape count. Both compute a non-overlapping grid via the new cardLayout module and persist with the saved deck. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YPLPk2pADJj4uStZuvu4Dy --- src/deckBuilder/CardSvg.tsx | 41 +++--- src/deckBuilder/GeometricDeckGenerator.tsx | 26 +++- .../__snapshots__/deck.test.tsx.snap | 2 +- src/deckBuilder/__tests__/cardLayout.test.ts | 54 ++++++++ src/deckBuilder/__tests__/cardSvg.test.tsx | 19 +++ src/deckBuilder/__tests__/deck.test.tsx | 7 +- src/deckBuilder/cardLayout.ts | 118 ++++++++++++++++++ src/deckBuilder/types.ts | 12 ++ src/views/gameEditor/GameEditor.tsx | 16 ++- src/views/gameEditor/cardSizeSelect.tsx | 39 ++++++ 10 files changed, 298 insertions(+), 36 deletions(-) create mode 100644 src/deckBuilder/__tests__/cardLayout.test.ts create mode 100644 src/deckBuilder/cardLayout.ts create mode 100644 src/views/gameEditor/cardSizeSelect.tsx diff --git a/src/deckBuilder/CardSvg.tsx b/src/deckBuilder/CardSvg.tsx index d2af6a5..32af3db 100644 --- a/src/deckBuilder/CardSvg.tsx +++ b/src/deckBuilder/CardSvg.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { layoutCard, MAIN_VIEWPORT_SIZE } from "./cardLayout"; import { CardData } from "./features"; import { COLOR_SETS, clampColorSet } from "./features/colors"; import { FILTER_DEFS, FilterName } from "./features/filters"; @@ -6,29 +7,9 @@ import { PATTERN_DEFS, PatternName, createPattern } from "./features/patterns"; import { SHAPE_REGISTRY } from "./shapes"; import { Rotation, ShapeFeatureSupport } from "./types"; -export const MAIN_VIEWPORT_SIZE = 120; +export { MAIN_VIEWPORT_SIZE } from "./cardLayout"; const DEFAULT_SHAPE_VIEW_BOX = "0 0 120 120"; -const SYMBOL_MARGIN = 5; -const SYMBOL_SIZE = MAIN_VIEWPORT_SIZE / 3 - SYMBOL_MARGIN; - -/** The nine symbol slots on a card, center first so odd counts stay centered. */ -const POSITIONS = (() => { - const start = 0; - const middle = MAIN_VIEWPORT_SIZE / 2 - SYMBOL_SIZE / 2; - const end = MAIN_VIEWPORT_SIZE - SYMBOL_SIZE; - return [ - { x: middle, y: middle }, - { x: start, y: end }, - { x: end, y: start }, - { x: end, y: end }, - { x: start, y: start }, - { x: end, y: middle }, - { x: start, y: middle }, - { x: middle, y: start }, - { x: middle, y: end }, - ]; -})(); const resolveRotation = ( supported: ShapeFeatureSupport["rotations"], @@ -56,6 +37,15 @@ interface Props { card: CardData; /** Document-unique id, used to namespace this card's SVG defs. */ cardId: string; + /** + * Grid capacity (1-9) used to size every shape cell on this card. Defaults + * to this card's own `card.numbers` — sizing it as large as its own shape + * count allows, independent of any other card ("Max Size"). Pass the + * deck's largest `numbers` value instead to size every card off that one + * shared capacity, so shapes stay one uniform size across the deck + * ("Full Size"). + */ + capacity?: number; } /** @@ -64,7 +54,7 @@ interface Props { * (rotation to 0, filter to none, pattern to solid — including patterns that * use more colors than the shape declares). */ -export const CardSvg = ({ card, cardId }: Props) => { +export const CardSvg = ({ card, cardId, capacity = card.numbers }: Props) => { const shape = SHAPE_REGISTRY[card.shapes]; const supports = shape.supports || {}; const colorCount = supports.colors === undefined ? 3 : supports.colors; @@ -86,12 +76,11 @@ export const CardSvg = ({ card, cardId }: Props) => { const [minX, minY, width, height] = viewBox.split(" ").map(Number); const rotationCenter = { x: minX + width / 2, y: minY + height / 2 }; + const { cellSize, slots } = layoutCard(capacity, card.numbers); const symbols: JSX.Element[] = []; - const offset = card.numbers % 2 ? 0 : 1; - for (let i = 0; i < card.numbers; i++) { - const { x, y } = POSITIONS[i + offset]; + for (const { x, y } of slots) { symbols.push( - + , - options?: { idPrefix?: string } + options?: { idPrefix?: string; cardSizeMode?: CardSizeMode } ) { this.metaData = metaData; this.features = Object.keys(metaData); @@ -39,9 +42,20 @@ export default class GeometricDeckGenerator implements Deck { this.numOptions = optionLists[0] ? optionLists[0].length : 0; this.defaults = { ...DEFAULT_CARD, ...defaultCardData }; this.idPrefix = (options && options.idPrefix) || `d${instanceCounter++}`; + this.cardSizeMode = (options && options.cardSizeMode) || "full"; + this.fullSizeCapacity = this.computeFullSizeCapacity(); this.cards = this.createDeck(); } + /** Largest `numbers` value actually in play, capped to `numOptions` the + * same defensive way the editor's `shapesInPlay` is — the shared grid + * capacity every card sizes off of in "full" mode. */ + private computeFullSizeCapacity(): number { + const numbersInPlay = getEnabledOptions(this.metaData, "numbers"); + const values = numbersInPlay ? numbersInPlay.slice(0, this.numOptions) : []; + return values.length > 0 ? Math.max(...values) : this.defaults.numbers; + } + private setFeature( card: CardData, feature: F, @@ -73,8 +87,14 @@ export default class GeometricDeckGenerator implements Deck { looper(loopNumber + 1); } else { const id = indexes.join("_"); + const capacity = this.cardSizeMode === "full" ? this.fullSizeCapacity : undefined; deck[id] = ( - + ); } } diff --git a/src/deckBuilder/__tests__/__snapshots__/deck.test.tsx.snap b/src/deckBuilder/__tests__/__snapshots__/deck.test.tsx.snap index 34ba392..e2edb31 100644 --- a/src/deckBuilder/__tests__/__snapshots__/deck.test.tsx.snap +++ b/src/deckBuilder/__tests__/__snapshots__/deck.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`card markup applies rotation, filter, and pattern 1`] = `""`; +exports[`card markup applies rotation, filter, and pattern 1`] = `""`; diff --git a/src/deckBuilder/__tests__/cardLayout.test.ts b/src/deckBuilder/__tests__/cardLayout.test.ts new file mode 100644 index 0000000..5b6d677 --- /dev/null +++ b/src/deckBuilder/__tests__/cardLayout.test.ts @@ -0,0 +1,54 @@ +import { computeGrid, layoutCard } from "../cardLayout"; + +// The deck builder's original fixed layout: 9 slots on a 3x3 grid, center +// first, then corners, then edge-midpoints, cell size 35. +const LEGACY_POSITIONS = [ + { x: 42.5, y: 42.5 }, + { x: 0, y: 85 }, + { x: 85, y: 0 }, + { x: 85, y: 85 }, + { x: 0, y: 0 }, + { x: 85, y: 42.5 }, + { x: 0, y: 42.5 }, + { x: 42.5, y: 0 }, + { x: 42.5, y: 85 }, +]; + +test("capacities 7-9 all resolve to the legacy 3x3 grid", () => { + expect(computeGrid(9)).toEqual({ cols: 3, rows: 3, cellSize: 35 }); + expect(computeGrid(8)).toEqual({ cols: 3, rows: 3, cellSize: 35 }); + expect(computeGrid(7)).toEqual({ cols: 3, rows: 3, cellSize: 35 }); +}); + +test("capacity 1 fills nearly the whole card", () => { + expect(computeGrid(1)).toEqual({ cols: 1, rows: 1, cellSize: 115 }); +}); + +test("capacity 6 ties between 2x3 and 3x2, resolved toward fewer columns", () => { + expect(computeGrid(6)).toEqual({ cols: 2, rows: 3, cellSize: 35 }); +}); + +test("layoutCard(9, n) reproduces the legacy position table exactly", () => { + for (let n = 1; n <= 9; n++) { + const { cellSize, slots } = layoutCard(9, n); + expect(cellSize).toBe(35); + const offset = n % 2 ? 0 : 1; + expect(slots).toEqual(LEGACY_POSITIONS.slice(offset, offset + n)); + } +}); + +test("no two slots ever overlap, for any capacity/count combination", () => { + for (let capacity = 1; capacity <= 9; capacity++) { + for (let count = 1; count <= capacity; count++) { + const { cellSize, slots } = layoutCard(capacity, count); + expect(slots).toHaveLength(count); + for (let i = 0; i < slots.length; i++) { + for (let j = i + 1; j < slots.length; j++) { + const dx = Math.abs(slots[i].x - slots[j].x); + const dy = Math.abs(slots[i].y - slots[j].y); + expect(dx >= cellSize || dy >= cellSize).toBe(true); + } + } + } + } +}); diff --git a/src/deckBuilder/__tests__/cardSvg.test.tsx b/src/deckBuilder/__tests__/cardSvg.test.tsx index 38965bb..0a25bca 100644 --- a/src/deckBuilder/__tests__/cardSvg.test.tsx +++ b/src/deckBuilder/__tests__/cardSvg.test.tsx @@ -68,3 +68,22 @@ test("color support clamps the set and downgrades color-hungry patterns", () => const triangles = renderCard({ ...CARD, shapes: "Circle - Semi", patterns: "triangles" }); expect(triangles).toContain('fill="#e6194B"'); }); + +test("without a capacity prop, a card sizes off its own shape count (Max Size)", () => { + const markup = ReactDOMServer.renderToStaticMarkup( + + ); + expect(markup).toContain('width="115"'); +}); + +test("a capacity prop sizes the card off that shared grid (Full Size)", () => { + const markup = ReactDOMServer.renderToStaticMarkup( + + ); + expect(markup).toContain('width="35"'); + expect(markup).not.toContain('width="115"'); + // legacy center + first two corner slots for a 3-shape card + expect(markup).toContain('x="42.5" y="42.5"'); + expect(markup).toContain('x="0" y="85"'); + expect(markup).toContain('x="85" y="0"'); +}); diff --git a/src/deckBuilder/__tests__/deck.test.tsx b/src/deckBuilder/__tests__/deck.test.tsx index 7269efa..f812412 100644 --- a/src/deckBuilder/__tests__/deck.test.tsx +++ b/src/deckBuilder/__tests__/deck.test.tsx @@ -24,6 +24,7 @@ test("card markup applies rotation, filter, and pattern", () => { { shapes: ["Tetris - L Block"], colors: ["Blue"], + numbers: [9], rotations: [90], filters: ["shadow"], patterns: ["striped"], @@ -31,10 +32,10 @@ test("card markup applies rotation, filter, and pattern", () => { undefined, { idPrefix: "test" } ); - const markup = ReactDOMServer.renderToStaticMarkup(deck.cards["0_0_0_0_0"]); + const markup = ReactDOMServer.renderToStaticMarkup(deck.cards["0_0_0_0_0_0"]); expect(markup).toContain("rotate(90, 60, 60)"); - expect(markup).toContain('filter="url(#flt-test-0_0_0_0_0)"'); - expect(markup).toContain('fill="url(#pat-test-0_0_0_0_0)"'); + expect(markup).toContain('filter="url(#flt-test-0_0_0_0_0_0)"'); + expect(markup).toContain('fill="url(#pat-test-0_0_0_0_0_0)"'); expect(markup).toMatchSnapshot(); }); diff --git a/src/deckBuilder/cardLayout.ts b/src/deckBuilder/cardLayout.ts new file mode 100644 index 0000000..8fc44d9 --- /dev/null +++ b/src/deckBuilder/cardLayout.ts @@ -0,0 +1,118 @@ +export const MAIN_VIEWPORT_SIZE = 120; +export const SYMBOL_MARGIN = 5; + +export interface GridSlot { + x: number; + y: number; +} + +export interface CardGrid { + cols: number; + rows: number; + cellSize: number; +} + +// The deck builder's original fixed 3x3 layout, in its hand-picked +// center-first, then corners, then edge-midpoints order. Kept as a literal +// constant (rather than derived) so every capacity that resolves to this +// same grid (7, 8, and 9) renders pixel-identical to that original layout. +const LEGACY_CELL_SIZE = MAIN_VIEWPORT_SIZE / 3 - SYMBOL_MARGIN; +const LEGACY_START = 0; +const LEGACY_MIDDLE = MAIN_VIEWPORT_SIZE / 2 - LEGACY_CELL_SIZE / 2; +const LEGACY_END = MAIN_VIEWPORT_SIZE - LEGACY_CELL_SIZE; +const LEGACY_SLOTS: GridSlot[] = [ + { x: LEGACY_MIDDLE, y: LEGACY_MIDDLE }, + { x: LEGACY_START, y: LEGACY_END }, + { x: LEGACY_END, y: LEGACY_START }, + { x: LEGACY_END, y: LEGACY_END }, + { x: LEGACY_START, y: LEGACY_START }, + { x: LEGACY_END, y: LEGACY_MIDDLE }, + { x: LEGACY_START, y: LEGACY_MIDDLE }, + { x: LEGACY_MIDDLE, y: LEGACY_START }, + { x: LEGACY_MIDDLE, y: LEGACY_END }, +]; + +/** + * The cols x rows split (rows = ceil(capacity / cols)) that maximizes each + * cell's size — min(120/cols, 120/rows) minus the fixed symbol margin — for + * `capacity` shape slots. Ties favor the more square split, then fewer + * columns, so results are deterministic. + */ +export function computeGrid(capacity: number): CardGrid { + let best: CardGrid | undefined; + for (let cols = 1; cols <= capacity; cols++) { + const rows = Math.ceil(capacity / cols); + const cellSize = Math.min(MAIN_VIEWPORT_SIZE / cols, MAIN_VIEWPORT_SIZE / rows) - SYMBOL_MARGIN; + const squareness = Math.abs(cols - rows); + if ( + !best || + cellSize > best.cellSize || + (cellSize === best.cellSize && + (squareness < Math.abs(best.cols - best.rows) || + (squareness === Math.abs(best.cols - best.rows) && cols < best.cols))) + ) { + best = { cols, rows, cellSize }; + } + } + return best as CardGrid; +} + +/** Evenly spaced positions along one 120-unit axis for `count` cells of + * `cellSize`: a single cell centers, more than one runs edge to edge. */ +function axisPositions(count: number, cellSize: number): number[] { + if (count === 1) { + return [(MAIN_VIEWPORT_SIZE - cellSize) / 2]; + } + const pitch = (MAIN_VIEWPORT_SIZE - cellSize) / (count - 1); + return Array.from({ length: count }, (_, i) => i * pitch); +} + +function gridSlots(grid: CardGrid): GridSlot[] { + const xs = axisPositions(grid.cols, grid.cellSize); + const ys = axisPositions(grid.rows, grid.cellSize); + const slots: GridSlot[] = []; + ys.forEach((y) => xs.forEach((x) => slots.push({ x, y }))); + return slots; +} + +/** True when the grid has a single, unambiguous center cell (odd columns + * and odd rows) — the case the legacy layout special-cases by skipping the + * center slot for an even count. */ +function hasCenterCell(grid: CardGrid): boolean { + return grid.cols % 2 === 1 && grid.rows % 2 === 1; +} + +const CENTER = MAIN_VIEWPORT_SIZE / 2; + +function distanceFromCenter(slot: GridSlot, cellSize: number): number { + const cx = slot.x + cellSize / 2; + const cy = slot.y + cellSize / 2; + return Math.hypot(cx - CENTER, cy - CENTER); +} + +/** + * The cell size and slot positions to render `count` shapes at `capacity` + * grid resolution (`count` must be <= `capacity`). Reproduces the deck + * builder's original fixed 3x3 layout exactly whenever the capacity + * resolves to that grid (capacities 7-9); every other capacity uses an + * evenly spaced grid, filled from the center outward, skipping the true + * center cell for an even count the same way the legacy layout does. + * + * Cells are disjoint axis-aligned boxes by construction — computeGrid only + * ever picks a cellSize small enough that the per-axis pitch between + * adjacent cells exceeds the cell size — so slots never overlap. + */ +export function layoutCard(capacity: number, count: number): { cellSize: number; slots: GridSlot[] } { + const grid = computeGrid(capacity); + if (grid.cols === 3 && grid.rows === 3) { + const offset = count % 2 ? 0 : 1; + return { cellSize: LEGACY_CELL_SIZE, slots: LEGACY_SLOTS.slice(offset, offset + count) }; + } + + const slots = gridSlots(grid); + const skipCenter = hasCenterCell(grid) && count % 2 === 0; + const ordered = slots + .filter((slot) => !skipCenter || distanceFromCenter(slot, grid.cellSize) > 1e-6) + .sort((a, b) => distanceFromCenter(a, grid.cellSize) - distanceFromCenter(b, grid.cellSize)); + return { cellSize: grid.cellSize, slots: ordered.slice(0, count) }; +} diff --git a/src/deckBuilder/types.ts b/src/deckBuilder/types.ts index 7141253..0a6751c 100644 --- a/src/deckBuilder/types.ts +++ b/src/deckBuilder/types.ts @@ -82,10 +82,22 @@ export interface DeckMetaData { [feature: string]: (string | number)[]; } +/** + * How CardSvg sizes shapes within a card. "full" sizes every card in the + * deck off the deck's largest `numbers` value, so shapes stay one uniform + * size across the whole deck. "max" sizes each card off its own count, + * independent of siblings, always filling as much of that card as its + * count allows. + */ +export type CardSizeMode = "full" | "max"; + /** The deck contract consumed by redux, the board, and the menu. */ export interface Deck { metaData: { readonly [feature: string]: readonly (string | number)[] | undefined }; features: string[]; numOptions: number; cards: FeatureDeck; + /** Present on GeometricDeckGenerator decks; undefined on PresetDecks, + * which render pre-baked images and have no notion of shape sizing. */ + cardSizeMode?: CardSizeMode; } diff --git a/src/views/gameEditor/GameEditor.tsx b/src/views/gameEditor/GameEditor.tsx index df03dd2..dd77443 100644 --- a/src/views/gameEditor/GameEditor.tsx +++ b/src/views/gameEditor/GameEditor.tsx @@ -25,9 +25,11 @@ import { getSupportedFeatureValues, } from "deckBuilder/deckRules"; import { ShapeName } from "deckBuilder/shapes"; +import { CardSizeMode } from "deckBuilder/types"; import { actions } from "views/actions"; import { FeatureSelect } from "./featureSelect"; import { CardSelector } from "./cardSelector"; +import { CardSizeSelect } from "./cardSizeSelect"; import { EnableFeature } from "./enableFeature"; import { DECK_DATA } from "views/reducers"; @@ -40,9 +42,12 @@ export const GameEditor = () => { const [deckData, setDeckData] = useState(() => isGeneratedMetaData(globalDeck.metaData) ? globalDeck.metaData : DECK_DATA ); + const [cardSizeMode, setCardSizeMode] = useState( + () => globalDeck.cardSizeMode || "full" + ); const localDeck = useMemo( - () => new GeometricDeckGenerator(deckData, deckDefaults), - [deckData, deckDefaults] + () => new GeometricDeckGenerator(deckData, deckDefaults, { cardSizeMode }), + [deckData, deckDefaults, cardSizeMode] ); const deck = localDeck.cards; const numberOfCards = localDeck.numOptions; @@ -133,7 +138,11 @@ export const GameEditor = () => { const handleShow = () => setShow(true); const handleClose = () => setShow(false); const handleSave = () => { - dispatch(actions.updateDeck({ deck: new GeometricDeckGenerator(deckData, deckDefaults) })); + dispatch( + actions.updateDeck({ + deck: new GeometricDeckGenerator(deckData, deckDefaults, { cardSizeMode }), + }) + ); setShow(false); }; @@ -156,6 +165,7 @@ export const GameEditor = () => { card={card} setCard={setCard} /> + {FEATURE_NAMES.filter((feature) => !isFeatureLocked(feature)).map((feature) => { const values = deckData[feature]; return ( diff --git a/src/views/gameEditor/cardSizeSelect.tsx b/src/views/gameEditor/cardSizeSelect.tsx new file mode 100644 index 0000000..f66ecd4 --- /dev/null +++ b/src/views/gameEditor/cardSizeSelect.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import Form from "react-bootstrap/Form"; +import { CardSizeMode } from "deckBuilder/types"; + +interface Props { + value: CardSizeMode; + onChange: (value: CardSizeMode) => void; +} + +/** + * "Full Size" keeps every card's shapes one uniform size across the deck, + * sized off the card with the most shapes. "Max Size" sizes each card's + * shapes as large as that card alone allows, independent of its siblings. + */ +export const CardSizeSelect = ({ value, onChange }: Props) => ( + + Card Size +
+ onChange("full")} + /> + onChange("max")} + /> +
+ +); From aedc015059d18f26936f60b4aa4010b5b0e32cc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 23:39:26 +0000 Subject: [PATCH 2/2] Tighten card padding and always center a lone shape Reduce the outer whitespace around each card's shapes (.card svg margin 20px -> 8px). Shape size itself is driven by max-height, so this only shrinks the surrounding padding, not the shapes. Also fix a Full Size edge case: a card with exactly one shape now always renders dead center, even when the deck's shared capacity grid (e.g. 5, a 2x3 grid) has no single cell centered on both axes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YPLPk2pADJj4uStZuvu4Dy --- src/components/game/card/index.css | 2 +- src/deckBuilder/__tests__/cardLayout.test.ts | 7 ++++++ src/deckBuilder/cardLayout.ts | 23 +++++++++++++++----- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/components/game/card/index.css b/src/components/game/card/index.css index 1d20323..67d7827 100644 --- a/src/components/game/card/index.css +++ b/src/components/game/card/index.css @@ -30,7 +30,7 @@ } .card svg { - margin: 20px; + margin: 8px; max-height: 10vh; } diff --git a/src/deckBuilder/__tests__/cardLayout.test.ts b/src/deckBuilder/__tests__/cardLayout.test.ts index 5b6d677..9a02ee4 100644 --- a/src/deckBuilder/__tests__/cardLayout.test.ts +++ b/src/deckBuilder/__tests__/cardLayout.test.ts @@ -37,6 +37,13 @@ test("layoutCard(9, n) reproduces the legacy position table exactly", () => { } }); +test("a single shape is always dead center, even on a capacity grid with no true center cell", () => { + // capacity 5 resolves to a 2x3 grid, which has no cell centered on both + // axes — the nearest-cell selection alone would put a lone shape off-center. + expect(computeGrid(5)).toEqual({ cols: 2, rows: 3, cellSize: 35 }); + expect(layoutCard(5, 1)).toEqual({ cellSize: 35, slots: [{ x: 42.5, y: 42.5 }] }); +}); + test("no two slots ever overlap, for any capacity/count combination", () => { for (let capacity = 1; capacity <= 9; capacity++) { for (let count = 1; count <= capacity; count++) { diff --git a/src/deckBuilder/cardLayout.ts b/src/deckBuilder/cardLayout.ts index 8fc44d9..1326e3a 100644 --- a/src/deckBuilder/cardLayout.ts +++ b/src/deckBuilder/cardLayout.ts @@ -96,7 +96,10 @@ function distanceFromCenter(slot: GridSlot, cellSize: number): number { * builder's original fixed 3x3 layout exactly whenever the capacity * resolves to that grid (capacities 7-9); every other capacity uses an * evenly spaced grid, filled from the center outward, skipping the true - * center cell for an even count the same way the legacy layout does. + * center cell for an even count the same way the legacy layout does. A + * single shape always sits dead center, regardless of the capacity grid's + * own shape (e.g. an even-columned grid has no cell that's exactly + * centered on both axes). * * Cells are disjoint axis-aligned boxes by construction — computeGrid only * ever picks a cellSize small enough that the per-axis pitch between @@ -104,15 +107,23 @@ function distanceFromCenter(slot: GridSlot, cellSize: number): number { */ export function layoutCard(capacity: number, count: number): { cellSize: number; slots: GridSlot[] } { const grid = computeGrid(capacity); - if (grid.cols === 3 && grid.rows === 3) { + const isLegacyGrid = grid.cols === 3 && grid.rows === 3; + const cellSize = isLegacyGrid ? LEGACY_CELL_SIZE : grid.cellSize; + + if (count === 1) { + const center = (MAIN_VIEWPORT_SIZE - cellSize) / 2; + return { cellSize, slots: [{ x: center, y: center }] }; + } + + if (isLegacyGrid) { const offset = count % 2 ? 0 : 1; - return { cellSize: LEGACY_CELL_SIZE, slots: LEGACY_SLOTS.slice(offset, offset + count) }; + return { cellSize, slots: LEGACY_SLOTS.slice(offset, offset + count) }; } const slots = gridSlots(grid); const skipCenter = hasCenterCell(grid) && count % 2 === 0; const ordered = slots - .filter((slot) => !skipCenter || distanceFromCenter(slot, grid.cellSize) > 1e-6) - .sort((a, b) => distanceFromCenter(a, grid.cellSize) - distanceFromCenter(b, grid.cellSize)); - return { cellSize: grid.cellSize, slots: ordered.slice(0, count) }; + .filter((slot) => !skipCenter || distanceFromCenter(slot, cellSize) > 1e-6) + .sort((a, b) => distanceFromCenter(a, cellSize) - distanceFromCenter(b, cellSize)); + return { cellSize, slots: ordered.slice(0, count) }; }