Give a phone on its side the table shape it needs
The last two rounds left one thing failing and said so: a landscape phone with six or twelve seats stacked two of them 25px apart at each end of the felt, with pucks 64px tall. It has been there since seats were first spaced by distance rather than by angle. Three things were wrong at once, and it needed all three. The rim is the wrong shape when the table is very flat. An ellipse 616px wide and 96px tall has nowhere to put the seats near its ends: the curve doubles back on itself, so two seats a long walk apart come out one above the other. Past two and a half times wider than tall the rim is now a stadium — two straight rails with a half-round cap at each end, which is how a real poker table is built and exactly why it is built that way. A cap is a half-circle of the felt's own height, so an end takes one seat and the rest is rail. Only past that ratio: measured, the cut-off lands between a landscape phone at 4.4 and every other layout the tool has, 1.45 at 1280x900 and 1.0 to 1.4 upright, so no working table changes shape. The ring had less height than the felt did. The seat layer is inset by half a puck to keep the outermost seats on screen, and 26px off the top and bottom of a 96px felt left 44px to work with — less than half the table, which is why no rim shape could have fixed it alone. The inset is a percentage where the felt is short. The landscape branch never had that rule at all, because it is matched on height while the inset lives in a width query, so an 844px-wide phone on its side drew its ring against the felt's whole box; with a flat-topped stadium rail that put a dealer button over Resolve Pot in the header. And the puck was a line too tall. In the tight state the hole cards give up their row: of the four things on a puck, the cards are the one the dealer is holding in their other hand, and the seat editor still shows them. That took the puck from 64px to 55px, which is what finally cleared the ends. Measured across 36 combinations of nine viewports and 2, 6, 9 and 12 seats: the closest two seats at 640x360 go from 25px apart to 55px, and every combination is clean. Then 216 audits across thirteen viewports, three seat counts and both a mouse and a thumb: no failures, no console errors. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -848,8 +848,13 @@ button,
|
|||||||
font-size: 0.55rem;
|
font-size: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The hole cards give up their line here. A puck this close to its neighbours is 64px
|
||||||
|
tall and the seats at the ends of a landscape felt are 55px apart, so the line has to
|
||||||
|
come from somewhere — and of the four things on a puck, the cards are the one the
|
||||||
|
dealer is holding in their other hand. Name, bet and stack stay. The seat editor
|
||||||
|
still shows the cards in full. */
|
||||||
.seatsTight .seatPuckCards {
|
.seatsTight .seatPuckCards {
|
||||||
font-size: 0.58rem;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
|
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
|
||||||
@@ -3038,10 +3043,18 @@ body.hasActionBar #toastHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Keeps every puck inside the viewport: the ring is drawn within this box,
|
/* Keeps every puck inside the viewport: the ring is drawn within this box,
|
||||||
so half a puck of padding is exactly enough to stop the overhang. */
|
so half a puck of padding is exactly enough to stop the overhang.
|
||||||
|
|
||||||
|
Half a puck is the right answer when the felt is tall enough to spare it. On a
|
||||||
|
phone held sideways it isn't: the felt is 96px tall there once the action bar has
|
||||||
|
grown, and 26px off the top and bottom left the ring 44px of height to work with
|
||||||
|
— less than half the table. That is what kept the seats at the ends stacked no
|
||||||
|
matter what shape the rim was.
|
||||||
|
|
||||||
|
Above about 216px of felt the flat 26px still wins, so nothing upright changes. */
|
||||||
#seatsLayer,
|
#seatsLayer,
|
||||||
#betsLayer {
|
#betsLayer {
|
||||||
inset: 26px 44px;
|
inset: min(26px, 12%) 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Narrower than the desktop puck: at a full ten-handed table the ring is
|
/* Narrower than the desktop puck: at a full ten-handed table the ring is
|
||||||
@@ -3410,6 +3423,16 @@ body.hasActionBar #toastHost {
|
|||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The same inset the phone layout uses, which this branch never had because it is
|
||||||
|
matched on height rather than width — so an 844px-wide phone on its side drew its
|
||||||
|
ring against the felt's full box. With the rim a stadium, the top rail is flat,
|
||||||
|
which put the button seat's dealer chip over the Resolve Pot button in the header
|
||||||
|
above. Percentages of a short felt, so the ring keeps the height it needs. */
|
||||||
|
#seatsLayer,
|
||||||
|
#betsLayer {
|
||||||
|
inset: min(26px, 12%) 44px;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Short phones, upright ----------
|
/* ---------- Short phones, upright ----------
|
||||||
|
|||||||
@@ -117,8 +117,11 @@ const Render = {
|
|||||||
// anything visible — it is just the honest way to say "on that player's spoke",
|
// anything visible — it is just the honest way to say "on that player's spoke",
|
||||||
// and it saves the second walk.
|
// and it saves the second walk.
|
||||||
betPosition(index, count, aspect = this.feltAspect()) {
|
betPosition(index, count, aspect = this.feltAspect()) {
|
||||||
const angle = this.ringAngles(count, 44, 42, aspect)[index];
|
const seat = this.seatPosition(index, count, aspect);
|
||||||
return { left: 50 + 27 * Math.cos(angle), top: 50 + 25 * Math.sin(angle) };
|
return {
|
||||||
|
left: 50 + (seat.left - 50) * (27 / 44),
|
||||||
|
top: 50 + (seat.top - 50) * (25 / 42),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Seats spaced evenly around the rim — by the distance between them, not by the
|
// Seats spaced evenly around the rim — by the distance between them, not by the
|
||||||
@@ -136,11 +139,71 @@ const Render = {
|
|||||||
// an ellipse's has no closed form — 720 steps is far more than enough for a rim a
|
// an ellipse's has no closed form — 720 steps is far more than enough for a rim a
|
||||||
// few hundred pixels long, and it runs once per render.
|
// few hundred pixels long, and it runs once per render.
|
||||||
positionOnRing(index, count, rx, ry, aspect = 1) {
|
positionOnRing(index, count, rx, ry, aspect = 1) {
|
||||||
const target = this.ringAngles(count, rx, ry, aspect)[index];
|
return this.ringPositions(count, rx, ry, aspect)[index];
|
||||||
return {
|
},
|
||||||
left: 50 + rx * Math.cos(target),
|
|
||||||
top: 50 + ry * Math.sin(target),
|
// Past this much wider than tall, the rim stops being an ellipse and becomes a
|
||||||
};
|
// stadium — two straight rails with a semicircular cap at each end.
|
||||||
|
//
|
||||||
|
// An ellipse that flat has nowhere to put the seats near its ends. At 640x360 with
|
||||||
|
// the bar grown by a side pot the felt is 616x96, and six seats came out with two
|
||||||
|
// of them stacked 25px apart at each end, pucks 64px tall. No puck size fixes 25px:
|
||||||
|
// the shape is wrong. A stadium's cap is a half-circle of the felt's own height, so
|
||||||
|
// an end holds one seat and the rest of the table is rail — which is how a real
|
||||||
|
// poker table is built, and why it is shaped that way.
|
||||||
|
//
|
||||||
|
// Only where it is needed. Anything rounder than this keeps the ellipse it has
|
||||||
|
// always had: measured, the cut-off lands between a landscape phone (4.4 and up)
|
||||||
|
// and every other layout the tool has (1.45 at 1280x900, 1.0 to 1.4 on a phone
|
||||||
|
// upright), so no currently-working table changes shape.
|
||||||
|
STADIUM_RATIO: 2.5,
|
||||||
|
|
||||||
|
// Seats spaced evenly around whichever rim this table has, as percentages.
|
||||||
|
ringPositions(count, rx, ry, aspect = 1) {
|
||||||
|
if (count <= 0) return [];
|
||||||
|
const halfWidth = rx * aspect;
|
||||||
|
const halfHeight = ry;
|
||||||
|
if (halfWidth <= halfHeight * this.STADIUM_RATIO) {
|
||||||
|
return this.ringAngles(count, rx, ry, aspect).map(angle => ({
|
||||||
|
left: 50 + rx * Math.cos(angle),
|
||||||
|
top: 50 + ry * Math.sin(angle),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return this.stadiumPositions(count, halfWidth, halfHeight, aspect);
|
||||||
|
},
|
||||||
|
|
||||||
|
// The same walk, around a stadium instead. Its perimeter has a closed form, so
|
||||||
|
// this needs no sampling: two rails of 2(A-B) and two caps of piB.
|
||||||
|
stadiumPositions(count, halfWidth, halfHeight, aspect) {
|
||||||
|
const rail = halfWidth - halfHeight;
|
||||||
|
const cap = Math.PI * halfHeight;
|
||||||
|
const perimeter = 4 * rail + 2 * cap;
|
||||||
|
|
||||||
|
return Array.from({ length: count }, (_, seat) => {
|
||||||
|
// Clockwise from the top centre, so seat one sits where it always has.
|
||||||
|
let walked = (perimeter * seat) / count;
|
||||||
|
let x;
|
||||||
|
let y;
|
||||||
|
if (walked <= rail) { // top rail, going right
|
||||||
|
x = walked;
|
||||||
|
y = -halfHeight;
|
||||||
|
} else if (walked <= rail + cap) { // right cap
|
||||||
|
const turned = (walked - rail) / halfHeight;
|
||||||
|
x = rail + halfHeight * Math.sin(turned);
|
||||||
|
y = -halfHeight * Math.cos(turned);
|
||||||
|
} else if (walked <= 3 * rail + cap) { // bottom rail, going left
|
||||||
|
x = rail - (walked - rail - cap);
|
||||||
|
y = halfHeight;
|
||||||
|
} else if (walked <= 3 * rail + 2 * cap) { // left cap
|
||||||
|
const turned = (walked - 3 * rail - cap) / halfHeight;
|
||||||
|
x = -rail - halfHeight * Math.sin(turned);
|
||||||
|
y = halfHeight * Math.cos(turned);
|
||||||
|
} else { // top rail, back to the start
|
||||||
|
x = -rail + (walked - 3 * rail - 2 * cap);
|
||||||
|
y = -halfHeight;
|
||||||
|
}
|
||||||
|
return { left: 50 + x / aspect, top: 50 + y };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
RING_STEPS: 720,
|
RING_STEPS: 720,
|
||||||
@@ -156,9 +219,9 @@ const Render = {
|
|||||||
closestSeatGapPx(count, box = this.ringBox()) {
|
closestSeatGapPx(count, box = this.ringBox()) {
|
||||||
if (count < 2 || !box || !box.width || !box.height) return Infinity;
|
if (count < 2 || !box || !box.width || !box.height) return Infinity;
|
||||||
|
|
||||||
const points = this.ringAngles(count, 44, 42, box.width / box.height).map(angle => ({
|
const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({
|
||||||
x: ((50 + 44 * Math.cos(angle)) / 100) * box.width,
|
x: (seat.left / 100) * box.width,
|
||||||
y: ((50 + 42 * Math.sin(angle)) / 100) * box.height,
|
y: (seat.top / 100) * box.height,
|
||||||
}));
|
}));
|
||||||
return points.reduce((closest, point, i) => {
|
return points.reduce((closest, point, i) => {
|
||||||
const next = points[(i + 1) % points.length];
|
const next = points[(i + 1) % points.length];
|
||||||
|
|||||||
@@ -99,10 +99,27 @@ describe("how close the closest two seats come", () => {
|
|||||||
// apart end up stacked one above the other.
|
// apart end up stacked one above the other.
|
||||||
const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height });
|
const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height });
|
||||||
|
|
||||||
test("a flat table's ends are far closer than its rim suggests", () => {
|
test("a flat table no longer stacks two seats at each end", () => {
|
||||||
// 616x96: the landscape felt on a 640x360 phone once the bar has grown.
|
// 616x96: the landscape felt on a 640x360 phone once the bar has grown. On an
|
||||||
const rim = Math.PI * (3 * (271 + 40) - Math.sqrt((3 * 271 + 40) * (271 + 3 * 40))) / 6;
|
// ellipse that flat the end pair came out 25px apart with 64px pucks. The rim
|
||||||
assert.ok(gap(6, 616, 96) < rim / 3, `${gap(6, 616, 96).toFixed(0)}px apart, ${rim.toFixed(0)}px of rim each`);
|
// is a stadium at this shape, so an end holds one seat.
|
||||||
|
assert.ok(gap(6, 616, 96) > 70, `${gap(6, 616, 96).toFixed(0)}px apart`);
|
||||||
|
assert.ok(gap(12, 616, 96) > 40, `${gap(12, 616, 96).toFixed(0)}px apart at twelve seats`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ellipse's ends are still closer than its rim suggests, which is why the shape changes", () => {
|
||||||
|
// Same felt, forced through the ellipse the tool uses for rounder tables.
|
||||||
|
const asEllipse = Render.ringAngles(6, 44, 42, 616 / 96).map(angle => ({
|
||||||
|
x: ((50 + 44 * Math.cos(angle)) / 100) * 616,
|
||||||
|
y: ((50 + 42 * Math.sin(angle)) / 100) * 96,
|
||||||
|
}));
|
||||||
|
const closest = asEllipse.reduce((least, point, i) => {
|
||||||
|
const next = asEllipse[(i + 1) % asEllipse.length];
|
||||||
|
return Math.min(least, Math.hypot(point.x - next.x, point.y - next.y));
|
||||||
|
}, Infinity);
|
||||||
|
assert.ok(closest < 70, `${closest.toFixed(0)}px`);
|
||||||
|
assert.ok(gap(6, 616, 96) > closest * 1.3,
|
||||||
|
`stadium ${gap(6, 616, 96).toFixed(0)}px against ellipse ${closest.toFixed(0)}px`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a round table's neighbours are about as far apart as the rim says", () => {
|
test("a round table's neighbours are about as far apart as the rim says", () => {
|
||||||
@@ -122,6 +139,53 @@ describe("how close the closest two seats come", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("which rim a table gets", () => {
|
||||||
|
// An ellipse until the table is much wider than tall, then a stadium: two rails
|
||||||
|
// and a half-round cap at each end, which is how a real poker table is built.
|
||||||
|
const shape = (width, height) => {
|
||||||
|
const aspect = width / height;
|
||||||
|
return 44 * aspect > 42 * Render.STADIUM_RATIO ? "stadium" : "ellipse";
|
||||||
|
};
|
||||||
|
|
||||||
|
test("every layout the tool had keeps the ellipse", () => {
|
||||||
|
assert.equal(shape(844, 608), "ellipse", "desktop");
|
||||||
|
assert.equal(shape(600, 600), "ellipse", "tablet");
|
||||||
|
assert.equal(shape(278, 314), "ellipse", "phone upright");
|
||||||
|
assert.equal(shape(336, 240), "ellipse", "short phone upright");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a phone on its side gets the stadium", () => {
|
||||||
|
assert.equal(shape(528, 73), "stadium");
|
||||||
|
assert.equal(shape(780, 113), "stadium");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the cap holds one seat instead of stacking two", () => {
|
||||||
|
// Six seats on a 616x96 rim: the walk between neighbours has to be longer than
|
||||||
|
// the cap, or two of them end up on the same half-circle.
|
||||||
|
const halfWidth = 44 * (616 / 96), halfHeight = 42;
|
||||||
|
const perimeter = 4 * (halfWidth - halfHeight) + 2 * Math.PI * halfHeight;
|
||||||
|
assert.ok(perimeter / 6 > Math.PI * halfHeight, "one seat per cap at six-handed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("seat one is still at the top centre, and the ring still mirrors", () => {
|
||||||
|
const seats = Array.from({ length: 8 }, (_, i) => Render.positionOnRing(i, 8, 44, 42, 616 / 96));
|
||||||
|
assert.ok(Math.abs(seats[0].left - 50) < 0.01);
|
||||||
|
assert.ok(seats[0].top < 50);
|
||||||
|
for (let seat = 1; seat < 4; seat++) {
|
||||||
|
const mirrored = seats[8 - seat];
|
||||||
|
assert.ok(Math.abs((seats[seat].left - 50) + (mirrored.left - 50)) < 0.01, `seat ${seat} across`);
|
||||||
|
assert.ok(Math.abs(seats[seat].top - mirrored.top) < 0.01, `seat ${seat} down`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nobody is drawn off the felt", () => {
|
||||||
|
Array.from({ length: 12 }, (_, i) => Render.positionOnRing(i, 12, 44, 42, 616 / 96)).forEach((seat, i) => {
|
||||||
|
assert.ok(seat.left >= 5 && seat.left <= 95, `seat ${i + 1} across at ${seat.left.toFixed(1)}%`);
|
||||||
|
assert.ok(seat.top >= 7 && seat.top <= 93, `seat ${i + 1} down at ${seat.top.toFixed(1)}%`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("the shape of the ring", () => {
|
describe("the shape of the ring", () => {
|
||||||
test("is a mirror image left to right", () => {
|
test("is a mirror image left to right", () => {
|
||||||
const seats = ringPixels(10, 601, 126);
|
const seats = ringPixels(10, 601, 126);
|
||||||
|
|||||||
Reference in New Issue
Block a user