Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/game/card/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
}

.card svg {
margin: 20px;
margin: 8px;
max-height: 10vh;
}

Expand Down
41 changes: 15 additions & 26 deletions src/deckBuilder/CardSvg.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,15 @@
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";
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"],
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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;
Expand All @@ -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(
<svg x={x} y={y} width={SYMBOL_SIZE} height={SYMBOL_SIZE} viewBox={viewBox} key={`${x}-${y}`}>
<svg x={x} y={y} width={cellSize} height={cellSize} viewBox={viewBox} key={`${x}-${y}`}>
<g
filter={filterDef ? `url(#${filterId})` : undefined}
transform={
Expand Down
26 changes: 23 additions & 3 deletions src/deckBuilder/GeometricDeckGenerator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import {
DEFAULT_CARD,
FeatureName,
GeneratedDeckMetaData,
getEnabledOptions,
isFeatureName,
} from "./features";
import { Deck, FeatureDeck } from "./types";
import { CardSizeMode, Deck, FeatureDeck } from "./types";

export { MAIN_VIEWPORT_SIZE } from "./CardSvg";

Expand All @@ -25,23 +26,36 @@ export default class GeometricDeckGenerator implements Deck {
features: string[];
numOptions: number;
cards: FeatureDeck;
cardSizeMode: CardSizeMode;
private defaults: CardData;
private idPrefix: string;
private fullSizeCapacity: number;

constructor(
metaData: GeneratedDeckMetaData,
defaultCardData?: Partial<CardData>,
options?: { idPrefix?: string }
options?: { idPrefix?: string; cardSizeMode?: CardSizeMode }
) {
this.metaData = metaData;
this.features = Object.keys(metaData);
const optionLists = Object.values(metaData);
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<F extends FeatureName>(
card: CardData,
feature: F,
Expand Down Expand Up @@ -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] = (
<CardSvg key={id} cardId={`${this.idPrefix}-${id}`} card={this.resolveCard(indexes)} />
<CardSvg
key={id}
cardId={`${this.idPrefix}-${id}`}
card={this.resolveCard(indexes)}
capacity={capacity}
/>
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/deckBuilder/__tests__/__snapshots__/deck.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`card markup applies rotation, filter, and pattern 1`] = `"<svg height=\\"100%\\" width=\\"100%\\" viewBox=\\"0 0 120 120\\" xmlns=\\"http://www.w3.org/2000/svg\\"><defs><filter id=\\"flt-test-0_0_0_0_0\\"><feDropShadow dx=\\"1\\" dy=\\"1\\" stdDeviation=\\"0.5\\"></feDropShadow></filter><pattern id=\\"pat-test-0_0_0_0_0\\" width=\\"8\\" height=\\"10\\" patternUnits=\\"userSpaceOnUse\\" patternTransform=\\"rotate(90)\\"><rect width=\\"8\\" height=\\"10\\" fill=\\"#98a9ea\\"></rect><line stroke=\\"#4363d8\\" stroke-width=\\"5px\\" y2=\\"15\\"></line></pattern></defs><svg x=\\"42.5\\" y=\\"42.5\\" width=\\"35\\" height=\\"35\\" viewBox=\\"0 0 120 120\\"><g filter=\\"url(#flt-test-0_0_0_0_0)\\" transform=\\"rotate(90, 60, 60)\\"><path d=\\"M0.5,119.5 L79.5,119.5 L79.5,80.5 L39.5,80.5 L39.5,0.5 L0.5,0.5 L0.5,119.5 Z\\" fill=\\"url(#pat-test-0_0_0_0_0)\\" stroke=\\"#4363d8\\" stroke-width=\\"1\\" fill-rule=\\"evenodd\\"></path></g></svg></svg>"`;
exports[`card markup applies rotation, filter, and pattern 1`] = `"<svg height=\\"100%\\" width=\\"100%\\" viewBox=\\"0 0 120 120\\" xmlns=\\"http://www.w3.org/2000/svg\\"><defs><filter id=\\"flt-test-0_0_0_0_0_0\\"><feDropShadow dx=\\"1\\" dy=\\"1\\" stdDeviation=\\"0.5\\"></feDropShadow></filter><pattern id=\\"pat-test-0_0_0_0_0_0\\" width=\\"8\\" height=\\"10\\" patternUnits=\\"userSpaceOnUse\\" patternTransform=\\"rotate(90)\\"><rect width=\\"8\\" height=\\"10\\" fill=\\"#98a9ea\\"></rect><line stroke=\\"#4363d8\\" stroke-width=\\"5px\\" y2=\\"15\\"></line></pattern></defs><svg x=\\"42.5\\" y=\\"42.5\\" width=\\"35\\" height=\\"35\\" viewBox=\\"0 0 120 120\\"><g filter=\\"url(#flt-test-0_0_0_0_0_0)\\" transform=\\"rotate(90, 60, 60)\\"><path d=\\"M0.5,119.5 L79.5,119.5 L79.5,80.5 L39.5,80.5 L39.5,0.5 L0.5,0.5 L0.5,119.5 Z\\" fill=\\"url(#pat-test-0_0_0_0_0_0)\\" stroke=\\"#4363d8\\" stroke-width=\\"1\\" fill-rule=\\"evenodd\\"></path></g></svg><svg x=\\"0\\" y=\\"85\\" width=\\"35\\" height=\\"35\\" viewBox=\\"0 0 120 120\\"><g filter=\\"url(#flt-test-0_0_0_0_0_0)\\" transform=\\"rotate(90, 60, 60)\\"><path d=\\"M0.5,119.5 L79.5,119.5 L79.5,80.5 L39.5,80.5 L39.5,0.5 L0.5,0.5 L0.5,119.5 Z\\" fill=\\"url(#pat-test-0_0_0_0_0_0)\\" stroke=\\"#4363d8\\" stroke-width=\\"1\\" fill-rule=\\"evenodd\\"></path></g></svg><svg x=\\"85\\" y=\\"0\\" width=\\"35\\" height=\\"35\\" viewBox=\\"0 0 120 120\\"><g filter=\\"url(#flt-test-0_0_0_0_0_0)\\" transform=\\"rotate(90, 60, 60)\\"><path d=\\"M0.5,119.5 L79.5,119.5 L79.5,80.5 L39.5,80.5 L39.5,0.5 L0.5,0.5 L0.5,119.5 Z\\" fill=\\"url(#pat-test-0_0_0_0_0_0)\\" stroke=\\"#4363d8\\" stroke-width=\\"1\\" fill-rule=\\"evenodd\\"></path></g></svg><svg x=\\"85\\" y=\\"85\\" width=\\"35\\" height=\\"35\\" viewBox=\\"0 0 120 120\\"><g filter=\\"url(#flt-test-0_0_0_0_0_0)\\" transform=\\"rotate(90, 60, 60)\\"><path d=\\"M0.5,119.5 L79.5,119.5 L79.5,80.5 L39.5,80.5 L39.5,0.5 L0.5,0.5 L0.5,119.5 Z\\" fill=\\"url(#pat-test-0_0_0_0_0_0)\\" stroke=\\"#4363d8\\" stroke-width=\\"1\\" fill-rule=\\"evenodd\\"></path></g></svg><svg x=\\"0\\" y=\\"0\\" width=\\"35\\" height=\\"35\\" viewBox=\\"0 0 120 120\\"><g filter=\\"url(#flt-test-0_0_0_0_0_0)\\" transform=\\"rotate(90, 60, 60)\\"><path d=\\"M0.5,119.5 L79.5,119.5 L79.5,80.5 L39.5,80.5 L39.5,0.5 L0.5,0.5 L0.5,119.5 Z\\" fill=\\"url(#pat-test-0_0_0_0_0_0)\\" stroke=\\"#4363d8\\" stroke-width=\\"1\\" fill-rule=\\"evenodd\\"></path></g></svg><svg x=\\"85\\" y=\\"42.5\\" width=\\"35\\" height=\\"35\\" viewBox=\\"0 0 120 120\\"><g filter=\\"url(#flt-test-0_0_0_0_0_0)\\" transform=\\"rotate(90, 60, 60)\\"><path d=\\"M0.5,119.5 L79.5,119.5 L79.5,80.5 L39.5,80.5 L39.5,0.5 L0.5,0.5 L0.5,119.5 Z\\" fill=\\"url(#pat-test-0_0_0_0_0_0)\\" stroke=\\"#4363d8\\" stroke-width=\\"1\\" fill-rule=\\"evenodd\\"></path></g></svg><svg x=\\"0\\" y=\\"42.5\\" width=\\"35\\" height=\\"35\\" viewBox=\\"0 0 120 120\\"><g filter=\\"url(#flt-test-0_0_0_0_0_0)\\" transform=\\"rotate(90, 60, 60)\\"><path d=\\"M0.5,119.5 L79.5,119.5 L79.5,80.5 L39.5,80.5 L39.5,0.5 L0.5,0.5 L0.5,119.5 Z\\" fill=\\"url(#pat-test-0_0_0_0_0_0)\\" stroke=\\"#4363d8\\" stroke-width=\\"1\\" fill-rule=\\"evenodd\\"></path></g></svg><svg x=\\"42.5\\" y=\\"0\\" width=\\"35\\" height=\\"35\\" viewBox=\\"0 0 120 120\\"><g filter=\\"url(#flt-test-0_0_0_0_0_0)\\" transform=\\"rotate(90, 60, 60)\\"><path d=\\"M0.5,119.5 L79.5,119.5 L79.5,80.5 L39.5,80.5 L39.5,0.5 L0.5,0.5 L0.5,119.5 Z\\" fill=\\"url(#pat-test-0_0_0_0_0_0)\\" stroke=\\"#4363d8\\" stroke-width=\\"1\\" fill-rule=\\"evenodd\\"></path></g></svg><svg x=\\"42.5\\" y=\\"85\\" width=\\"35\\" height=\\"35\\" viewBox=\\"0 0 120 120\\"><g filter=\\"url(#flt-test-0_0_0_0_0_0)\\" transform=\\"rotate(90, 60, 60)\\"><path d=\\"M0.5,119.5 L79.5,119.5 L79.5,80.5 L39.5,80.5 L39.5,0.5 L0.5,0.5 L0.5,119.5 Z\\" fill=\\"url(#pat-test-0_0_0_0_0_0)\\" stroke=\\"#4363d8\\" stroke-width=\\"1\\" fill-rule=\\"evenodd\\"></path></g></svg></svg>"`;
61 changes: 61 additions & 0 deletions src/deckBuilder/__tests__/cardLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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("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++) {
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);
}
}
}
}
});
19 changes: 19 additions & 0 deletions src/deckBuilder/__tests__/cardSvg.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<CardSvg card={{ ...CARD, numbers: 1 }} cardId="test-0" />
);
expect(markup).toContain('width="115"');
});

test("a capacity prop sizes the card off that shared grid (Full Size)", () => {
const markup = ReactDOMServer.renderToStaticMarkup(
<CardSvg card={{ ...CARD, numbers: 3 }} cardId="test-0" capacity={9} />
);
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"');
});
7 changes: 4 additions & 3 deletions src/deckBuilder/__tests__/deck.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,18 @@ test("card markup applies rotation, filter, and pattern", () => {
{
shapes: ["Tetris - L Block"],
colors: ["Blue"],
numbers: [9],
rotations: [90],
filters: ["shadow"],
patterns: ["striped"],
},
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();
});

Expand Down
129 changes: 129 additions & 0 deletions src/deckBuilder/cardLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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. 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
* 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);
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, 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, cellSize) > 1e-6)
.sort((a, b) => distanceFromCenter(a, cellSize) - distanceFromCenter(b, cellSize));
return { cellSize, slots: ordered.slice(0, count) };
}
Loading
Loading