Below is the workshop problem drawn as a picture. The shaded polygon is every plan that respects your wood and hours; any point inside is allowed. The dashed line is your profit — all the plans that earn the same amount sit on it.
<p class="hint">The shaded polygon is every plan that fits your wood and hours. The dashed line is your <b>profit</b> — every plan on it earns the same. Slide it out as far as it still touches the region. The best plan is always a <b>corner</b>.</p>
<canvas id="lp" width="360" height="320"></canvas>
<div class="row">
<label for="slider">Profit line</label>
<input id="slider" type="range" min="0" max="100" value="30" />
</div>
<div class="status" id="status">Slide the line, then Solve.</div>
<div class="btns">
<button id="solve" type="button">Solve (jump to best corner)</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
canvas { background: #fff; border: 1px solid #cdd9e3; border-radius: 8px; display: block; max-width: 100%; }
.row { display: flex; align-items: center; gap: .6rem; margin: .7rem 0 .3rem; }
.row label { font-size: .85rem; font-weight: 600; color: #1d3557; }
input[type=range] { flex: 1; accent-color: #1d3557; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.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] ?? ''));
// Linear program (the workshop): maximize P = 3x + 2y
// subject to x + y <= 8 (work hours)
// 2x + y <= 12 (wood)
// x >= 0, y >= 0
// The feasible region is a polygon; the optimum is ALWAYS at one of its corners (vertices). Checking a corner is instant; the simplex method just walks the corners — and the whole problem is in P (Khachiyan 1979).
const C = { x: 3, y: 2 }; // objective coefficients
const CONS = [ // a*x + b*y <= c
{ a: 1, b: 1, c: 8 },
{ a: 2, b: 1, c: 12 },
];
const profit = (p) => C.x * p.x + C.y * p.y;
const feasible = (p) =>
p.x >= -1e-9 && p.y >= -1e-9 &&
CONS.every(k => k.a * p.x + k.b * p.y <= k.c + 1e-9);
// Candidate vertices = intersections of every pair of boundary lines (including the two axes), kept only if feasible.
function vertices() {
const lines = [
{ a: 1, b: 0, c: 0 }, // x = 0
{ a: 0, b: 1, c: 0 }, // y = 0
...CONS,
];
const pts = [];
for (let i = 0; i < lines.length; i++)
for (let j = i + 1; j < lines.length; j++) {
const L = lines[i], M = lines[j];
const det = L.a * M.b - M.a * L.b;
if (Math.abs(det) < 1e-9) continue;
const x = (L.c * M.b - M.c * L.b) / det;
const y = (L.a * M.c - M.a * L.c) / det;
const p = { x, y };
if (feasible(p)) pts.push(p);
}
return pts;
}
function solveLP() {
const v = vertices();
let best = v[0];
for (const p of v) if (profit(p) > profit(best)) best = p;
return best;
}
// ---- drawing ----
const cv = document.getElementById('lp'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height, PAD = 36;
const SX = 7.5, SY = 7.5; // axis range 0..7.5 in data units
const px = (x) => PAD + (x / SX) * (W - 2 * PAD);
const py = (y) => H - PAD - (y / SY) * (H - 2 * PAD);
function hull(pts) { // order vertices around centroid
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length;
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;
return pts.slice().sort((a, b) =>
Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx));
}
function draw(targetProfit, marked) {
ctx.clearRect(0, 0, W, H);
// axes
ctx.strokeStyle = '#aab4c0'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px(0), py(0)); ctx.lineTo(px(SX), py(0));
ctx.moveTo(px(0), py(0)); ctx.lineTo(px(0), py(SY)); ctx.stroke();
// feasible polygon
const poly = hull(vertices());
ctx.beginPath();
poly.forEach((p, i) => i ? ctx.lineTo(px(p.x), py(p.y)) : ctx.moveTo(px(p.x), py(p.y)));
ctx.closePath();
ctx.fillStyle = 'rgba(29,53,87,.12)'; ctx.fill();
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 1.6; ctx.stroke();
// corners
for (const p of poly) {
ctx.beginPath(); ctx.arc(px(p.x), py(p.y), 3.5, 0, 7); ctx.fillStyle = '#1d3557'; ctx.fill();
}
// profit line 3x + 2y = targetProfit
ctx.strokeStyle = '#e63946'; ctx.lineWidth = 2; ctx.setLineDash([6, 5]);
const x0 = 0, y0 = targetProfit / C.y;
const y1 = 0, x1 = targetProfit / C.x;
ctx.beginPath(); ctx.moveTo(px(x0), py(y0)); ctx.lineTo(px(x1), py(y1)); ctx.stroke();
ctx.setLineDash([]);
// marked optimum
if (marked) {
ctx.beginPath(); ctx.arc(px(marked.x), py(marked.y), 6, 0, 7);
ctx.fillStyle = '#0a7d33'; ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke();
}
}
const slider = document.getElementById('slider');
const maxP = profit(solveLP()); // profit at the true optimum
function sliderProfit() { return (slider.value / 100) * (maxP + 6); }
function setStatus(t, cls) { const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + (cls || ''); }
function update() {
const tp = sliderProfit();
const touches = tp <= maxP + 1e-9;
draw(tp, null);
setStatus(fmt('Profit line = %tp%%v%', { tp: tp.toFixed(1), v: ' — ' + (touches
? 'still reachable.'
: 'too high, the line has left the region.') }), touches ? '' : '');
}
slider.addEventListener('input', update);
document.getElementById('solve').addEventListener('click', () => {
const best = solveLP();
slider.value = Math.round(profit(best) / (maxP + 6) * 100);
draw(profit(best), best);
setStatus(fmt('Best plan: %round% tables, %round2% chairs → profit %profit% (a corner).', { round: Math.round(best.x), round2: Math.round(best.y), profit: profit(best).toFixed(0) }), 'ok');
console.log('optimum', best, 'profit', profit(best));
});
document.getElementById('reset').addEventListener('click', () => { slider.value = 30; update(); });
update();
<p class="hint">The shaded polygon is every plan that fits your wood and hours. The dashed line is your <b>profit</b> — every plan on it earns the same. Slide it out as far as it still touches the region. The best plan is always a <b>corner</b>.</p>
<canvas id="lp" width="360" height="320"></canvas>
<div class="row">
<label for="slider">Profit line</label>
<input id="slider" type="range" min="0" max="100" value="30" />
</div>
<div class="status" id="status">Slide the line, then Solve.</div>
<div class="btns">
<button id="solve" type="button">Solve (jump to best corner)</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
canvas { background: #fff; border: 1px solid #cdd9e3; border-radius: 8px; display: block; max-width: 100%; }
.row { display: flex; align-items: center; gap: .6rem; margin: .7rem 0 .3rem; }
.row label { font-size: .85rem; font-weight: 600; color: #1d3557; }
input[type=range] { flex: 1; accent-color: #1d3557; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.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] ?? ''));
// Linear program (the workshop): maximize P = 3x + 2y
// subject to x + y <= 8 (work hours)
// 2x + y <= 12 (wood)
// x >= 0, y >= 0
// The feasible region is a polygon; the optimum is ALWAYS at one of its corners (vertices). Checking a corner is instant; the simplex method just walks the corners — and the whole problem is in P (Khachiyan 1979).
const C = { x: 3, y: 2 }; // objective coefficients
const CONS = [ // a*x + b*y <= c
{ a: 1, b: 1, c: 8 },
{ a: 2, b: 1, c: 12 },
];
const profit = (p) => C.x * p.x + C.y * p.y;
const feasible = (p) =>
p.x >= -1e-9 && p.y >= -1e-9 &&
CONS.every(k => k.a * p.x + k.b * p.y <= k.c + 1e-9);
// Candidate vertices = intersections of every pair of boundary lines (including the two axes), kept only if feasible.
function vertices() {
const lines = [
{ a: 1, b: 0, c: 0 }, // x = 0
{ a: 0, b: 1, c: 0 }, // y = 0
...CONS,
];
const pts = [];
for (let i = 0; i < lines.length; i++)
for (let j = i + 1; j < lines.length; j++) {
const L = lines[i], M = lines[j];
const det = L.a * M.b - M.a * L.b;
if (Math.abs(det) < 1e-9) continue;
const x = (L.c * M.b - M.c * L.b) / det;
const y = (L.a * M.c - M.a * L.c) / det;
const p = { x, y };
if (feasible(p)) pts.push(p);
}
return pts;
}
function solveLP() {
const v = vertices();
let best = v[0];
for (const p of v) if (profit(p) > profit(best)) best = p;
return best;
}
// ---- drawing ----
const cv = document.getElementById('lp'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height, PAD = 36;
const SX = 7.5, SY = 7.5; // axis range 0..7.5 in data units
const px = (x) => PAD + (x / SX) * (W - 2 * PAD);
const py = (y) => H - PAD - (y / SY) * (H - 2 * PAD);
function hull(pts) { // order vertices around centroid
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length;
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;
return pts.slice().sort((a, b) =>
Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx));
}
function draw(targetProfit, marked) {
ctx.clearRect(0, 0, W, H);
// axes
ctx.strokeStyle = '#aab4c0'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px(0), py(0)); ctx.lineTo(px(SX), py(0));
ctx.moveTo(px(0), py(0)); ctx.lineTo(px(0), py(SY)); ctx.stroke();
// feasible polygon
const poly = hull(vertices());
ctx.beginPath();
poly.forEach((p, i) => i ? ctx.lineTo(px(p.x), py(p.y)) : ctx.moveTo(px(p.x), py(p.y)));
ctx.closePath();
ctx.fillStyle = 'rgba(29,53,87,.12)'; ctx.fill();
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 1.6; ctx.stroke();
// corners
for (const p of poly) {
ctx.beginPath(); ctx.arc(px(p.x), py(p.y), 3.5, 0, 7); ctx.fillStyle = '#1d3557'; ctx.fill();
}
// profit line 3x + 2y = targetProfit
ctx.strokeStyle = '#e63946'; ctx.lineWidth = 2; ctx.setLineDash([6, 5]);
const x0 = 0, y0 = targetProfit / C.y;
const y1 = 0, x1 = targetProfit / C.x;
ctx.beginPath(); ctx.moveTo(px(x0), py(y0)); ctx.lineTo(px(x1), py(y1)); ctx.stroke();
ctx.setLineDash([]);
// marked optimum
if (marked) {
ctx.beginPath(); ctx.arc(px(marked.x), py(marked.y), 6, 0, 7);
ctx.fillStyle = '#0a7d33'; ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke();
}
}
const slider = document.getElementById('slider');
const maxP = profit(solveLP()); // profit at the true optimum
function sliderProfit() { return (slider.value / 100) * (maxP + 6); }
function setStatus(t, cls) { const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + (cls || ''); }
function update() {
const tp = sliderProfit();
const touches = tp <= maxP + 1e-9;
draw(tp, null);
setStatus(fmt('Profit line = %tp%%v%', { tp: tp.toFixed(1), v: ' — ' + (touches
? 'still reachable.'
: 'too high, the line has left the region.') }), touches ? '' : '');
}
slider.addEventListener('input', update);
document.getElementById('solve').addEventListener('click', () => {
const best = solveLP();
slider.value = Math.round(profit(best) / (maxP + 6) * 100);
draw(profit(best), best);
setStatus(fmt('Best plan: %round% tables, %round2% chairs → profit %profit% (a corner).', { round: Math.round(best.x), round2: Math.round(best.y), profit: profit(best).toFixed(0) }), 'ok');
console.log('optimum', best, 'profit', profit(best));
});
document.getElementById('reset').addEventListener('click', () => { slider.value = 30; update(); });
update();
Push the profit line outward with the slider. As long as it still touches the polygon you can earn that much; the best plan is the last point it touches before leaving — and that point is always a corner. Press Solve to jump straight there. Notice the asymmetry: once a corner is proposed, checking that it beats its neighbors is instant, and unlike many puzzles on this site, finding it is fast too.
Comments
Loading comments...