<!-- Main layout for the hat monotile demo -->
<div class="controls">
<button id="btn-grow" type="button">Grow patch</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span class="count-label">Tiles placed: <strong id="tile-count">0</strong></span>
</div>
<p class="hint">Each shape is the <b>hat</b> monotile (or its mirror). Press <em>Grow patch</em> to add more tiles. No matter how large the patch grows, the arrangement never locks into a periodic grid.</p>
<canvas id="canvas" width="480" height="360" aria-label="Interactive hat monotile tiling"></canvas>
<div class="status" id="status"></div>
/* Styles for the aperiodic monotile demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #f7f8fa; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.count-label { font-size: .9rem; color: #555; margin-left: .3rem; }
.hint { font-size: .85rem; color: #555; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; border: 1px solid #d0d8e0; border-radius: 10px;
background: #fff; max-width: 100%; }
.status { font-size: .85rem; color: #5a7088; margin-top: .4rem; min-height: 1.2em; }
/* Aperiodic monotile (hat) interactive demo */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* The 'hat' is a 13-sided polygon (polykite) discovered by Smith et al. 2023. */
/* It tiles the plane aperiodically — never periodically — using itself and its mirror. */
/* Vertex coordinates in a unit grid (equilateral-triangle lattice). */
const ROOT3 = Math.sqrt(3);
/* 13 vertices of the hat in local coordinates (equilateral-triangle lattice, unit = 1) */
const HAT_BASE = [
[0, 0], [1, 0], [1.5, ROOT3 / 2], [2, 0], [3, 0],
[3.5, ROOT3 / 2], [3, ROOT3], [2.5, ROOT3 / 2],
[2, ROOT3], [1.5, 3 * ROOT3 / 2], [1, ROOT3],
[0.5, ROOT3 / 2], [0, ROOT3]
];
/* Compute the centroid (average) of a polygon's vertices. */
function centroid(pts) {
let sx = 0, sy = 0;
for (const [x, y] of pts) { sx += x; sy += y; }
return [sx / pts.length, sy / pts.length];
}
/* Apply translation, rotation, uniform scale and optional horizontal flip. */
function transform(pts, tx, ty, angle, scale, flip) {
const cos = Math.cos(angle), sin = Math.sin(angle);
return pts.map(([x, y]) => {
if (flip) x = -x;
const rx = cos * x - sin * y;
const ry = sin * x + cos * y;
return [rx * scale + tx, ry * scale + ty];
});
}
/* Approximate center used for proximity check. */
function approxCenter(pts) { return centroid(pts); }
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const CX = W / 2, CY = H / 2;
/* Five shades of blue-grey for tile coloring. */
const PALETTE = ['#b5c8d8', '#7fa8c2', '#4d7fa8', '#2d5f8a', '#c8dde8'];
/* List of placed tiles and set of occupied center positions. */
const placed = [];
const occupiedCenters = [];
/* Visual size and initial orientation of each tile. */
const BASE_SCALE = 26;
const BASE_ANGLE = 0;
/* Queue of candidate neighbour positions to try next. */
let queue = [];
function tooClose(cx, cy) {
const THRESH = BASE_SCALE * 0.9;
for (const [ox, oy] of occupiedCenters) {
const dx = cx - ox, dy = cy - oy;
if (dx * dx + dy * dy < THRESH * THRESH) return true;
}
return false;
}
function inBounds(pts) {
const margin = -BASE_SCALE * 1.5;
for (const [x, y] of pts) {
if (x < margin || x > W - margin || y < margin || y > H - margin) return false;
}
return true;
}
/* Try to place a hat tile; returns false if position is taken or out of bounds. */
function seedTile(tx, ty, angle, flip, depth) {
const pts = transform(HAT_BASE, tx, ty, angle, BASE_SCALE / 3.5, flip);
const [cx, cy] = centroid(pts);
if (tooClose(cx, cy)) return false;
if (!inBounds(pts)) return false;
placed.push({ pts, flip, color: PALETTE[placed.length % PALETTE.length] });
occupiedCenters.push([cx, cy]);
if (depth > 0) enqueueNeighbours(cx, cy, angle, flip, depth - 1);
return true;
}
/* Push candidate neighbour positions into the queue. */
function enqueueNeighbours(cx, cy, angle, flip, depth) {
const offsets = [
[BASE_SCALE * 1.1, 0, angle + Math.PI / 3, !flip],
[-BASE_SCALE * 1.1, 0, angle - Math.PI / 3, !flip],
[0, BASE_SCALE * 1.2, angle + Math.PI * 2 / 3, flip],
[0, -BASE_SCALE * 1.2, angle - Math.PI * 2 / 3, flip],
[BASE_SCALE * 0.95, BASE_SCALE * 0.95, angle, !flip],
[-BASE_SCALE * 0.95, -BASE_SCALE * 0.95, angle, flip],
];
for (const [dx, dy, a, f] of offsets) {
queue.push({ tx: cx + dx, ty: cy + dy, angle: a, flip: f, depth });
}
}
function drawTile(pts, color) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#6a8fac';
ctx.lineWidth = 1;
ctx.stroke();
}
function render() {
ctx.clearRect(0, 0, W, H);
for (const { pts, color } of placed) drawTile(pts, color);
document.getElementById('tile-count').textContent = placed.length;
}
function init() {
placed.length = 0;
occupiedCenters.length = 0;
queue.length = 0;
/* Place the first tile at canvas center. */
seedTile(CX - BASE_SCALE * 0.5, CY - BASE_SCALE * 0.5, BASE_ANGLE, false, 0);
render();
document.getElementById('status').textContent = '';
}
function grow() {
/* First click seeds around the origin; subsequent clicks expand the frontier. */
if (placed.length === 0) {
seedTile(CX - BASE_SCALE * 0.5, CY - BASE_SCALE * 0.5, BASE_ANGLE, false, 1);
render();
document.getElementById('status').textContent = i18nFmt('Patch has %n% tile(s) — still no repeating pattern.', { n: placed.length });
return;
}
/* Process up to 8 queued candidates per button press. */
let added = 0;
const MAX_TRIES = 120;
let tries = 0;
while (added < 8 && tries < MAX_TRIES && queue.length > 0) {
const item = queue.shift();
const ok = seedTile(item.tx, item.ty, item.angle, item.flip, 0);
if (ok) { added++; enqueueNeighbours(item.tx, item.ty, item.angle, item.flip, 0); }
tries++;
}
if (added === 0) {
/* If the queue is exhausted, try new angles further out. */
const angles = [Math.PI / 6, Math.PI / 4, Math.PI / 3, Math.PI / 2, 2 * Math.PI / 3];
for (const a of angles) {
const r = BASE_SCALE * (2 + placed.length * 0.1);
const tx = CX + Math.cos(a) * r;
const ty = CY + Math.sin(a) * r;
seedTile(tx, ty, a, placed.length % 2 === 0, 0);
}
}
render();
document.getElementById('status').textContent = i18nFmt('Patch has %n% tile(s) — still no repeating pattern.', { n: placed.length });
}
document.getElementById('btn-grow').addEventListener('click', grow);
document.getElementById('btn-reset').addEventListener('click', () => { init(); });
init();
<!-- Main layout for the hat monotile demo -->
<div class="controls">
<button id="btn-grow" type="button">Grow patch</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span class="count-label">Tiles placed: <strong id="tile-count">0</strong></span>
</div>
<p class="hint">Each shape is the <b>hat</b> monotile (or its mirror). Press <em>Grow patch</em> to add more tiles. No matter how large the patch grows, the arrangement never locks into a periodic grid.</p>
<canvas id="canvas" width="480" height="360" aria-label="Interactive hat monotile tiling"></canvas>
<div class="status" id="status"></div>
/* Styles for the aperiodic monotile demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #f7f8fa; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.count-label { font-size: .9rem; color: #555; margin-left: .3rem; }
.hint { font-size: .85rem; color: #555; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; border: 1px solid #d0d8e0; border-radius: 10px;
background: #fff; max-width: 100%; }
.status { font-size: .85rem; color: #5a7088; margin-top: .4rem; min-height: 1.2em; }
/* Aperiodic monotile (hat) interactive demo */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* The 'hat' is a 13-sided polygon (polykite) discovered by Smith et al. 2023. */
/* It tiles the plane aperiodically — never periodically — using itself and its mirror. */
/* Vertex coordinates in a unit grid (equilateral-triangle lattice). */
const ROOT3 = Math.sqrt(3);
/* 13 vertices of the hat in local coordinates (equilateral-triangle lattice, unit = 1) */
const HAT_BASE = [
[0, 0], [1, 0], [1.5, ROOT3 / 2], [2, 0], [3, 0],
[3.5, ROOT3 / 2], [3, ROOT3], [2.5, ROOT3 / 2],
[2, ROOT3], [1.5, 3 * ROOT3 / 2], [1, ROOT3],
[0.5, ROOT3 / 2], [0, ROOT3]
];
/* Compute the centroid (average) of a polygon's vertices. */
function centroid(pts) {
let sx = 0, sy = 0;
for (const [x, y] of pts) { sx += x; sy += y; }
return [sx / pts.length, sy / pts.length];
}
/* Apply translation, rotation, uniform scale and optional horizontal flip. */
function transform(pts, tx, ty, angle, scale, flip) {
const cos = Math.cos(angle), sin = Math.sin(angle);
return pts.map(([x, y]) => {
if (flip) x = -x;
const rx = cos * x - sin * y;
const ry = sin * x + cos * y;
return [rx * scale + tx, ry * scale + ty];
});
}
/* Approximate center used for proximity check. */
function approxCenter(pts) { return centroid(pts); }
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const CX = W / 2, CY = H / 2;
/* Five shades of blue-grey for tile coloring. */
const PALETTE = ['#b5c8d8', '#7fa8c2', '#4d7fa8', '#2d5f8a', '#c8dde8'];
/* List of placed tiles and set of occupied center positions. */
const placed = [];
const occupiedCenters = [];
/* Visual size and initial orientation of each tile. */
const BASE_SCALE = 26;
const BASE_ANGLE = 0;
/* Queue of candidate neighbour positions to try next. */
let queue = [];
function tooClose(cx, cy) {
const THRESH = BASE_SCALE * 0.9;
for (const [ox, oy] of occupiedCenters) {
const dx = cx - ox, dy = cy - oy;
if (dx * dx + dy * dy < THRESH * THRESH) return true;
}
return false;
}
function inBounds(pts) {
const margin = -BASE_SCALE * 1.5;
for (const [x, y] of pts) {
if (x < margin || x > W - margin || y < margin || y > H - margin) return false;
}
return true;
}
/* Try to place a hat tile; returns false if position is taken or out of bounds. */
function seedTile(tx, ty, angle, flip, depth) {
const pts = transform(HAT_BASE, tx, ty, angle, BASE_SCALE / 3.5, flip);
const [cx, cy] = centroid(pts);
if (tooClose(cx, cy)) return false;
if (!inBounds(pts)) return false;
placed.push({ pts, flip, color: PALETTE[placed.length % PALETTE.length] });
occupiedCenters.push([cx, cy]);
if (depth > 0) enqueueNeighbours(cx, cy, angle, flip, depth - 1);
return true;
}
/* Push candidate neighbour positions into the queue. */
function enqueueNeighbours(cx, cy, angle, flip, depth) {
const offsets = [
[BASE_SCALE * 1.1, 0, angle + Math.PI / 3, !flip],
[-BASE_SCALE * 1.1, 0, angle - Math.PI / 3, !flip],
[0, BASE_SCALE * 1.2, angle + Math.PI * 2 / 3, flip],
[0, -BASE_SCALE * 1.2, angle - Math.PI * 2 / 3, flip],
[BASE_SCALE * 0.95, BASE_SCALE * 0.95, angle, !flip],
[-BASE_SCALE * 0.95, -BASE_SCALE * 0.95, angle, flip],
];
for (const [dx, dy, a, f] of offsets) {
queue.push({ tx: cx + dx, ty: cy + dy, angle: a, flip: f, depth });
}
}
function drawTile(pts, color) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#6a8fac';
ctx.lineWidth = 1;
ctx.stroke();
}
function render() {
ctx.clearRect(0, 0, W, H);
for (const { pts, color } of placed) drawTile(pts, color);
document.getElementById('tile-count').textContent = placed.length;
}
function init() {
placed.length = 0;
occupiedCenters.length = 0;
queue.length = 0;
/* Place the first tile at canvas center. */
seedTile(CX - BASE_SCALE * 0.5, CY - BASE_SCALE * 0.5, BASE_ANGLE, false, 0);
render();
document.getElementById('status').textContent = '';
}
function grow() {
/* First click seeds around the origin; subsequent clicks expand the frontier. */
if (placed.length === 0) {
seedTile(CX - BASE_SCALE * 0.5, CY - BASE_SCALE * 0.5, BASE_ANGLE, false, 1);
render();
document.getElementById('status').textContent = i18nFmt('Patch has %n% tile(s) — still no repeating pattern.', { n: placed.length });
return;
}
/* Process up to 8 queued candidates per button press. */
let added = 0;
const MAX_TRIES = 120;
let tries = 0;
while (added < 8 && tries < MAX_TRIES && queue.length > 0) {
const item = queue.shift();
const ok = seedTile(item.tx, item.ty, item.angle, item.flip, 0);
if (ok) { added++; enqueueNeighbours(item.tx, item.ty, item.angle, item.flip, 0); }
tries++;
}
if (added === 0) {
/* If the queue is exhausted, try new angles further out. */
const angles = [Math.PI / 6, Math.PI / 4, Math.PI / 3, Math.PI / 2, 2 * Math.PI / 3];
for (const a of angles) {
const r = BASE_SCALE * (2 + placed.length * 0.1);
const tx = CX + Math.cos(a) * r;
const ty = CY + Math.sin(a) * r;
seedTile(tx, ty, a, placed.length % 2 === 0, 0);
}
}
render();
document.getElementById('status').textContent = i18nFmt('Patch has %n% tile(s) — still no repeating pattern.', { n: placed.length });
}
document.getElementById('btn-grow').addEventListener('click', grow);
document.getElementById('btn-reset').addEventListener('click', () => { init(); });
init();
Comments
Loading comments...