Click anywhere on the canvas to place points. The algorithm instantly recomputes and highlights the largest empty axis-aligned rectangle — the biggest clear region that avoids every point you placed.
<p class="hint">Click on the canvas to place points. The highlighted rectangle is the <b>largest empty axis-aligned rectangle</b> — the biggest clear region that avoids every point. Drag points to move them.</p>
<canvas id="cvs" width="480" height="320"></canvas>
<div class="info" id="info">Add points to see the largest empty rectangle.</div>
<div class="btns">
<button id="btnRandom" type="button">Random points</button>
<button id="btnClear" type="button" class="ghost">Clear</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; border: 1.5px solid #cdd9e3; border-radius: 8px;
cursor: crosshair; max-width: 100%; background: #f7f9fb; }
.info { font-size: .95rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Largest Empty Rectangle (LER) demo.
//
// Key structural fact (Chazelle, Drysdale & Lee 1986): the optimal axis-aligned
// empty rectangle has every side touching either the bounding box or a point.
// So we only need x-coordinates and y-coordinates drawn from the point set plus
//
// Algorithm (O(n^3) for the demo — fast enough for up to ~30 points):
// For each adjacent pair of x-boundaries (lx, rx):
// Mark each y-band [ys[k], ys[k+1]] as blocked if any point lies strictly
// inside (lx, rx) at that y.
// Find the largest contiguous run of unblocked bands; within each run,
// Track the global best.
const W = 480, H = 320, PAD = 20, R = 6;
const cvs = document.getElementById('cvs');
const ctx = cvs.getContext('2d');
let pts = [];
let bestRect = null;
let dragging = -1;
const BX1 = PAD, BY1 = PAD, BX2 = W - PAD, BY2 = H - PAD;
function computeLER(points) {
if (!points.length) return { x1: BX1, y1: BY1, x2: BX2, y2: BY2, area: (BX2-BX1)*(BY2-BY1) };
const xs = [...new Set([BX1, BX2, ...points.map(p => p.x)])].sort((a, b) => a - b);
const ys = [...new Set([BY1, BY2, ...points.map(p => p.y)])].sort((a, b) => a - b);
const ny = ys.length - 1;
let best = { x1: BX1, y1: BY1, x2: BX2, y2: BY1, area: 0 };
for (let i = 0; i < xs.length - 1; i++) {
const lx = xs[i], rx = xs[i + 1];
// Determine which y-bands are blocked by points strictly inside this x-strip.
const blocked = new Array(ny).fill(false);
for (const p of points) {
if (p.x > lx && p.x < rx) {
for (let k = 0; k < ny; k++) {
if (p.y >= ys[k] && p.y <= ys[k + 1]) blocked[k] = true;
}
}
}
// Band heights (0 if blocked).
const bh = blocked.map((b, k) => b ? 0 : ys[k + 1] - ys[k]);
// Scan contiguous runs of free bands; try every sub-range within each run.
// The area is always (rx-lx)*(ys[b+1]-ys[a]) for sub-range [a..b].
let runStart = -1;
for (let k = 0; k <= ny; k++) {
const h = k < ny ? bh[k] : 0;
if (h === 0) {
if (runStart >= 0) {
// Evaluate all sub-ranges [runStart..k-1]
for (let a = runStart; a < k; a++) {
for (let b = a; b < k; b++) {
const area = (rx - lx) * (ys[b + 1] - ys[a]);
if (area > best.area) {
best = { x1: lx, y1: ys[a], x2: rx, y2: ys[b + 1], area };
}
}
}
}
runStart = -1;
} else {
if (runStart < 0) runStart = k;
}
}
// Flush a run that reaches the end of the band array.
if (runStart >= 0) {
const k = ny;
for (let a = runStart; a < k; a++) {
for (let b = a; b < k; b++) {
const area = (rx - lx) * (ys[b + 1] - ys[a]);
if (area > best.area) {
best = { x1: lx, y1: ys[a], x2: rx, y2: ys[b + 1], area };
}
}
}
}
}
return best;
}
function draw() {
ctx.clearRect(0, 0, W, H);
// Bounding box outline
ctx.strokeStyle = '#bcc8d4';
ctx.lineWidth = 1;
ctx.strokeRect(BX1, BY1, BX2 - BX1, BY2 - BY1);
// Largest empty rectangle
if (bestRect) {
const { x1, y1, x2, y2 } = bestRect;
ctx.fillStyle = 'rgba(29,53,87,0.12)';
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
document.getElementById('info').textContent =
fmt('Largest empty rectangle: %round% × %round2% px (area %round3% px²)', { round: Math.round(x2 - x1), round2: Math.round(y2 - y1), round3: Math.round(bestRect.area) });
} else {
document.getElementById('info').textContent =
pts.length ? 'Computing…' : 'Add points to see the largest empty rectangle.';
}
// Points
for (let i = 0; i < pts.length; i++) {
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, R, 0, 2 * Math.PI);
ctx.fillStyle = '#e63946';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
function update() {
bestRect = computeLER(pts);
draw();
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function hitPt(x, y) {
for (let i = pts.length - 1; i >= 0; i--) {
const dx = pts[i].x - x, dy = pts[i].y - y;
if (dx * dx + dy * dy <= R * R * 3) return i;
}
return -1;
}
function canvasPos(e, touch) {
const r = cvs.getBoundingClientRect();
const sx = W / r.width, sy = H / r.height;
const src = touch || e;
return {
x: clamp((src.clientX - r.left) * sx, BX1, BX2),
y: clamp((src.clientY - r.top) * sy, BY1, BY2)
};
}
cvs.addEventListener('mousedown', e => {
const { x, y } = canvasPos(e);
const hit = hitPt(x, y);
if (hit >= 0) { dragging = hit; return; }
pts.push({ x, y });
update();
});
cvs.addEventListener('mousemove', e => {
if (dragging < 0) return;
const { x, y } = canvasPos(e);
pts[dragging].x = x; pts[dragging].y = y;
update();
});
cvs.addEventListener('mouseup', () => { dragging = -1; });
cvs.addEventListener('mouseleave', () => { dragging = -1; });
cvs.addEventListener('touchstart', e => {
e.preventDefault();
const { x, y } = canvasPos(e, e.touches[0]);
const hit = hitPt(x, y);
if (hit >= 0) { dragging = hit; return; }
pts.push({ x, y });
update();
}, { passive: false });
cvs.addEventListener('touchmove', e => {
e.preventDefault();
if (dragging < 0) return;
const { x, y } = canvasPos(e, e.touches[0]);
pts[dragging].x = x; pts[dragging].y = y;
update();
}, { passive: false });
cvs.addEventListener('touchend', () => { dragging = -1; });
document.getElementById('btnRandom').addEventListener('click', () => {
const n = 8 + Math.floor(Math.random() * 7);
pts = Array.from({ length: n }, () => ({
x: BX1 + Math.random() * (BX2 - BX1),
y: BY1 + Math.random() * (BY2 - BY1)
}));
update();
});
document.getElementById('btnClear').addEventListener('click', () => {
pts = []; bestRect = null; draw();
});
// Initial example
pts = [
{ x: 160, y: 100 }, { x: 340, y: 90 }, { x: 260, y: 200 },
{ x: 120, y: 230 }, { x: 400, y: 260 }, { x: 220, y: 140 }
];
update();
// Self-test (runs silently in console)
(function selfTest() {
// Test: center point splits canvas; each half-width strip has full height
const cx = (BX1 + BX2) / 2;
const r = computeLER([{ x: cx, y: (BY1 + BY2) / 2 }]);
const expectedArea = Math.max((cx - BX1) * (BY2 - BY1), (BX2 - cx) * (BY2 - BY1));
console.assert(r.area === expectedArea, 'Self-test failed: area mismatch');
// Test: no points -> whole box
const r2 = computeLER([]);
console.assert(r2.area === (BX2 - BX1) * (BY2 - BY1), 'Self-test failed: empty case');
console.log('LER self-test passed. center-point area:', r.area, 'empty area:', r2.area);
})();
<p class="hint">Click on the canvas to place points. The highlighted rectangle is the <b>largest empty axis-aligned rectangle</b> — the biggest clear region that avoids every point. Drag points to move them.</p>
<canvas id="cvs" width="480" height="320"></canvas>
<div class="info" id="info">Add points to see the largest empty rectangle.</div>
<div class="btns">
<button id="btnRandom" type="button">Random points</button>
<button id="btnClear" type="button" class="ghost">Clear</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; border: 1.5px solid #cdd9e3; border-radius: 8px;
cursor: crosshair; max-width: 100%; background: #f7f9fb; }
.info { font-size: .95rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Largest Empty Rectangle (LER) demo.
//
// Key structural fact (Chazelle, Drysdale & Lee 1986): the optimal axis-aligned
// empty rectangle has every side touching either the bounding box or a point.
// So we only need x-coordinates and y-coordinates drawn from the point set plus
//
// Algorithm (O(n^3) for the demo — fast enough for up to ~30 points):
// For each adjacent pair of x-boundaries (lx, rx):
// Mark each y-band [ys[k], ys[k+1]] as blocked if any point lies strictly
// inside (lx, rx) at that y.
// Find the largest contiguous run of unblocked bands; within each run,
// Track the global best.
const W = 480, H = 320, PAD = 20, R = 6;
const cvs = document.getElementById('cvs');
const ctx = cvs.getContext('2d');
let pts = [];
let bestRect = null;
let dragging = -1;
const BX1 = PAD, BY1 = PAD, BX2 = W - PAD, BY2 = H - PAD;
function computeLER(points) {
if (!points.length) return { x1: BX1, y1: BY1, x2: BX2, y2: BY2, area: (BX2-BX1)*(BY2-BY1) };
const xs = [...new Set([BX1, BX2, ...points.map(p => p.x)])].sort((a, b) => a - b);
const ys = [...new Set([BY1, BY2, ...points.map(p => p.y)])].sort((a, b) => a - b);
const ny = ys.length - 1;
let best = { x1: BX1, y1: BY1, x2: BX2, y2: BY1, area: 0 };
for (let i = 0; i < xs.length - 1; i++) {
const lx = xs[i], rx = xs[i + 1];
// Determine which y-bands are blocked by points strictly inside this x-strip.
const blocked = new Array(ny).fill(false);
for (const p of points) {
if (p.x > lx && p.x < rx) {
for (let k = 0; k < ny; k++) {
if (p.y >= ys[k] && p.y <= ys[k + 1]) blocked[k] = true;
}
}
}
// Band heights (0 if blocked).
const bh = blocked.map((b, k) => b ? 0 : ys[k + 1] - ys[k]);
// Scan contiguous runs of free bands; try every sub-range within each run.
// The area is always (rx-lx)*(ys[b+1]-ys[a]) for sub-range [a..b].
let runStart = -1;
for (let k = 0; k <= ny; k++) {
const h = k < ny ? bh[k] : 0;
if (h === 0) {
if (runStart >= 0) {
// Evaluate all sub-ranges [runStart..k-1]
for (let a = runStart; a < k; a++) {
for (let b = a; b < k; b++) {
const area = (rx - lx) * (ys[b + 1] - ys[a]);
if (area > best.area) {
best = { x1: lx, y1: ys[a], x2: rx, y2: ys[b + 1], area };
}
}
}
}
runStart = -1;
} else {
if (runStart < 0) runStart = k;
}
}
// Flush a run that reaches the end of the band array.
if (runStart >= 0) {
const k = ny;
for (let a = runStart; a < k; a++) {
for (let b = a; b < k; b++) {
const area = (rx - lx) * (ys[b + 1] - ys[a]);
if (area > best.area) {
best = { x1: lx, y1: ys[a], x2: rx, y2: ys[b + 1], area };
}
}
}
}
}
return best;
}
function draw() {
ctx.clearRect(0, 0, W, H);
// Bounding box outline
ctx.strokeStyle = '#bcc8d4';
ctx.lineWidth = 1;
ctx.strokeRect(BX1, BY1, BX2 - BX1, BY2 - BY1);
// Largest empty rectangle
if (bestRect) {
const { x1, y1, x2, y2 } = bestRect;
ctx.fillStyle = 'rgba(29,53,87,0.12)';
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
document.getElementById('info').textContent =
fmt('Largest empty rectangle: %round% × %round2% px (area %round3% px²)', { round: Math.round(x2 - x1), round2: Math.round(y2 - y1), round3: Math.round(bestRect.area) });
} else {
document.getElementById('info').textContent =
pts.length ? 'Computing…' : 'Add points to see the largest empty rectangle.';
}
// Points
for (let i = 0; i < pts.length; i++) {
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, R, 0, 2 * Math.PI);
ctx.fillStyle = '#e63946';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
function update() {
bestRect = computeLER(pts);
draw();
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function hitPt(x, y) {
for (let i = pts.length - 1; i >= 0; i--) {
const dx = pts[i].x - x, dy = pts[i].y - y;
if (dx * dx + dy * dy <= R * R * 3) return i;
}
return -1;
}
function canvasPos(e, touch) {
const r = cvs.getBoundingClientRect();
const sx = W / r.width, sy = H / r.height;
const src = touch || e;
return {
x: clamp((src.clientX - r.left) * sx, BX1, BX2),
y: clamp((src.clientY - r.top) * sy, BY1, BY2)
};
}
cvs.addEventListener('mousedown', e => {
const { x, y } = canvasPos(e);
const hit = hitPt(x, y);
if (hit >= 0) { dragging = hit; return; }
pts.push({ x, y });
update();
});
cvs.addEventListener('mousemove', e => {
if (dragging < 0) return;
const { x, y } = canvasPos(e);
pts[dragging].x = x; pts[dragging].y = y;
update();
});
cvs.addEventListener('mouseup', () => { dragging = -1; });
cvs.addEventListener('mouseleave', () => { dragging = -1; });
cvs.addEventListener('touchstart', e => {
e.preventDefault();
const { x, y } = canvasPos(e, e.touches[0]);
const hit = hitPt(x, y);
if (hit >= 0) { dragging = hit; return; }
pts.push({ x, y });
update();
}, { passive: false });
cvs.addEventListener('touchmove', e => {
e.preventDefault();
if (dragging < 0) return;
const { x, y } = canvasPos(e, e.touches[0]);
pts[dragging].x = x; pts[dragging].y = y;
update();
}, { passive: false });
cvs.addEventListener('touchend', () => { dragging = -1; });
document.getElementById('btnRandom').addEventListener('click', () => {
const n = 8 + Math.floor(Math.random() * 7);
pts = Array.from({ length: n }, () => ({
x: BX1 + Math.random() * (BX2 - BX1),
y: BY1 + Math.random() * (BY2 - BY1)
}));
update();
});
document.getElementById('btnClear').addEventListener('click', () => {
pts = []; bestRect = null; draw();
});
// Initial example
pts = [
{ x: 160, y: 100 }, { x: 340, y: 90 }, { x: 260, y: 200 },
{ x: 120, y: 230 }, { x: 400, y: 260 }, { x: 220, y: 140 }
];
update();
// Self-test (runs silently in console)
(function selfTest() {
// Test: center point splits canvas; each half-width strip has full height
const cx = (BX1 + BX2) / 2;
const r = computeLER([{ x: cx, y: (BY1 + BY2) / 2 }]);
const expectedArea = Math.max((cx - BX1) * (BY2 - BY1), (BX2 - cx) * (BY2 - BY1));
console.assert(r.area === expectedArea, 'Self-test failed: area mismatch');
// Test: no points -> whole box
const r2 = computeLER([]);
console.assert(r2.area === (BX2 - BX1) * (BY2 - BY1), 'Self-test failed: empty case');
console.log('LER self-test passed. center-point area:', r.area, 'empty area:', r2.area);
})();
Notice how the optimal rectangle always has at least one edge flush against a point (or the boundary). Moving a single point can cause the solution to jump to an entirely different region. Add many points and watch the gaps shrink — yet the algorithm always finds the largest one without searching every possible rectangle.
Comments
Loading comments...