Imagine a 12×12 grid of locations (144 cells). Each sensor you place covers every cell within radius 2 — a 5×5 square of up to 25 cells. You want to cover as many distinct cells as possible with a fixed budget of sensors. Coverage is a classic submodular function: the first sensor covers a full square; every additional sensor overlaps with previous ones, so its marginal gain is strictly smaller.
<div class="controls">
<label>Budget: <strong id="budgetVal">6</strong> sensors
<input type="range" id="budgetSlider" min="1" max="12" value="6">
</label>
<div class="btns">
<button id="stepBtn" type="button">Step (Greedy)</button>
<button id="runBtn" type="button">Run to budget</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
</div>
<div class="info-row">
<span>Placed: <b id="placed">0</b> / <span id="budgetSpan">6</span></span>
<span>Coverage: <b id="coverage">0</b> / 144 cells (<b id="pct">0</b>%)</span>
</div>
<div class="panel">
<canvas id="grid" width="228" height="228"></canvas>
<div class="sidebar">
<div class="chart-title">Marginal gain per step</div>
<canvas id="chart" width="160" height="180"></canvas>
<div class="note" id="note">No sensors yet.</div>
</div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #1d2a38; margin: 0; font-size: 14px; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem 1.2rem; margin-bottom: .6rem; }
label { display: flex; align-items: center; gap: .4rem; font-size: .9rem; }
input[type=range] { width: 90px; accent-color: #2a6496; }
.btns { display: flex; gap: .4rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border-radius: 7px; cursor: pointer;
border: 1px solid #2a6496; background: #2a6496; color: #fff; }
button.ghost { background: #fff; color: #2a6496; }
button:disabled { opacity: .45; cursor: default; }
.info-row { display: flex; gap: 1.4rem; margin-bottom: .5rem; font-size: .9rem; flex-wrap: wrap; }
.panel { display: flex; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
canvas#grid { border: 1px solid #c8d4de; border-radius: 6px; display: block; }
.sidebar { flex: 1; min-width: 160px; }
.chart-title { font-size: .82rem; font-weight: 600; color: #4a6070; margin-bottom: .3rem; }
canvas#chart { border: 1px solid #e0e8ef; border-radius: 5px; background: #f7fafc; display: block; }
.note { font-size: .82rem; color: #55707e; margin-top: .5rem; line-height: 1.4; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Greedy submodular maximization: sensor coverage on a 12x12 grid.
// f(S) = number of distinct cells within radius 2 of at least one sensor in S.
// Submodularity: marginal gain of each new sensor <= that of the previous one.
// Greedy guarantee: f(S_greedy) >= (1 - 1/e) * OPT for monotone submodular functions.
// Nemhauser, Wolsey & Fisher (1978).
const ROWS = 12, COLS = 12, RADIUS = 2;
const CELL = 19; // px per cell on canvas
const N = ROWS * COLS;
function idx(r, c) { return r * COLS + c; }
// Precompute which cells each sensor position covers (L-inf ball of radius 2).
function buildCover(sr, sc) {
const s = new Set();
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++)
if (Math.abs(r - sr) <= RADIUS && Math.abs(c - sc) <= RADIUS) s.add(idx(r, c));
return s;
}
const COVER = [];
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) COVER.push(buildCover(r, c));
// State
let budget = 6;
let sensors = [];
let coveredSet = new Set();
let marginals = [];
function marginalGain(sensorIdx) {
let gain = 0;
for (const cell of COVER[sensorIdx])
if (!coveredSet.has(cell)) gain++;
return gain;
}
function greedyStep() {
if (sensors.length >= budget) return false;
let best = -1, bestGain = -1;
for (let i = 0; i < N; i++) {
if (sensors.includes(i)) continue;
const g = marginalGain(i);
if (g > bestGain) { bestGain = g; best = i; }
}
if (best === -1 || bestGain === 0) return false;
sensors.push(best);
for (const cell of COVER[best]) coveredSet.add(cell);
marginals.push(bestGain);
return true;
}
function reset() {
sensors = []; coveredSet = new Set(); marginals = [];
render();
}
const gridCanvas = document.getElementById('grid');
const gctx = gridCanvas.getContext('2d');
const chartCanvas = document.getElementById('chart');
const cctx = chartCanvas.getContext('2d');
function drawGrid() {
gctx.clearRect(0, 0, gridCanvas.width, gridCanvas.height);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const ci = idx(r, c);
const x = c * CELL + 1, y = r * CELL + 1;
const isSensor = sensors.includes(ci);
const isCovered = coveredSet.has(ci);
gctx.fillStyle = isSensor ? '#2a6496' : isCovered ? '#aecde3' : '#e8f0f7';
gctx.strokeStyle = '#b0c4d8';
gctx.lineWidth = 0.5;
gctx.fillRect(x, y, CELL - 1, CELL - 1);
gctx.strokeRect(x, y, CELL - 1, CELL - 1);
if (isSensor) {
gctx.fillStyle = '#fff';
gctx.beginPath();
gctx.arc(x + (CELL - 1) / 2, y + (CELL - 1) / 2, 3, 0, Math.PI * 2);
gctx.fill();
}
}
}
}
function drawChart() {
const W = chartCanvas.width, H = chartCanvas.height;
cctx.clearRect(0, 0, W, H);
if (marginals.length === 0) return;
const maxG = Math.max(...marginals, 1);
const pad = { l: 28, r: 8, t: 10, b: 24 };
const innerW = W - pad.l - pad.r;
const innerH = H - pad.t - pad.b;
const bw = Math.max(4, Math.floor(innerW / budget) - 3);
// axes
cctx.strokeStyle = '#b0c4d8'; cctx.lineWidth = 1;
cctx.beginPath();
cctx.moveTo(pad.l, pad.t); cctx.lineTo(pad.l, H - pad.b);
cctx.lineTo(W - pad.r, H - pad.b);
cctx.stroke();
// y gridlines and labels
cctx.fillStyle = '#6a8090'; cctx.font = '9px system-ui'; cctx.textAlign = 'right';
for (const v of [0, Math.round(maxG / 2), maxG]) {
const y = H - pad.b - (v / maxG) * innerH;
cctx.fillText(v, pad.l - 3, y + 3);
cctx.strokeStyle = '#e0e8ef'; cctx.lineWidth = 0.5;
cctx.beginPath(); cctx.moveTo(pad.l, y); cctx.lineTo(W - pad.r, y); cctx.stroke();
}
// bars with color gradient (early=blue, late=orange)
marginals.forEach((g, i) => {
const t = i / Math.max(budget - 1, 1);
const R = Math.round(42 + t * (210 - 42));
const G = Math.round(100 + t * (130 - 100));
const B = Math.round(150 + t * (40 - 150));
cctx.fillStyle = `rgb(${R},${G},${B})`;
const x = pad.l + i * (innerW / budget) + 2;
const barH = (g / maxG) * innerH;
cctx.fillRect(x, H - pad.b - barH, bw, barH);
cctx.fillStyle = '#4a6070'; cctx.font = '9px system-ui'; cctx.textAlign = 'center';
cctx.fillText(i + 1, x + bw / 2, H - pad.b + 12);
});
}
function render() {
budget = parseInt(document.getElementById('budgetSlider').value);
document.getElementById('budgetVal').textContent = budget;
document.getElementById('budgetSpan').textContent = budget;
document.getElementById('placed').textContent = sensors.length;
document.getElementById('coverage').textContent = coveredSet.size;
document.getElementById('pct').textContent = Math.round(coveredSet.size / N * 100);
const note = document.getElementById('note');
if (sensors.length === 0) {
note.textContent = 'No sensors placed yet. Click Step (Greedy) to begin.';
} else if (sensors.length < budget) {
const last = marginals[marginals.length - 1];
note.textContent = fmt('Step %length%: +%last% new cells covered. Each bar shows the shrinking marginal gain.', { length: sensors.length, last: last });
} else {
const pct = Math.round(coveredSet.size / N * 100);
note.textContent = fmt('Budget used. Coverage: %size%/%N% (%pct%%). Greedy guarantees ≥ 63.2 % of OPT.', { size: coveredSet.size, N: N, pct: pct });
}
document.getElementById('stepBtn').disabled = sensors.length >= budget;
document.getElementById('runBtn').disabled = sensors.length >= budget;
drawGrid();
drawChart();
}
document.getElementById('budgetSlider').addEventListener('input', () => {
budget = parseInt(document.getElementById('budgetSlider').value);
if (sensors.length > budget) {
sensors = sensors.slice(0, budget);
marginals = marginals.slice(0, budget);
coveredSet = new Set();
for (const si of sensors)
for (const c of COVER[si]) coveredSet.add(c);
}
render();
});
document.getElementById('stepBtn').addEventListener('click', () => { greedyStep(); render(); });
document.getElementById('runBtn').addEventListener('click', () => {
while (sensors.length < budget) if (!greedyStep()) break;
render();
});
document.getElementById('resetBtn').addEventListener('click', reset);
render();
<div class="controls">
<label>Budget: <strong id="budgetVal">6</strong> sensors
<input type="range" id="budgetSlider" min="1" max="12" value="6">
</label>
<div class="btns">
<button id="stepBtn" type="button">Step (Greedy)</button>
<button id="runBtn" type="button">Run to budget</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
</div>
<div class="info-row">
<span>Placed: <b id="placed">0</b> / <span id="budgetSpan">6</span></span>
<span>Coverage: <b id="coverage">0</b> / 144 cells (<b id="pct">0</b>%)</span>
</div>
<div class="panel">
<canvas id="grid" width="228" height="228"></canvas>
<div class="sidebar">
<div class="chart-title">Marginal gain per step</div>
<canvas id="chart" width="160" height="180"></canvas>
<div class="note" id="note">No sensors yet.</div>
</div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #1d2a38; margin: 0; font-size: 14px; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem 1.2rem; margin-bottom: .6rem; }
label { display: flex; align-items: center; gap: .4rem; font-size: .9rem; }
input[type=range] { width: 90px; accent-color: #2a6496; }
.btns { display: flex; gap: .4rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border-radius: 7px; cursor: pointer;
border: 1px solid #2a6496; background: #2a6496; color: #fff; }
button.ghost { background: #fff; color: #2a6496; }
button:disabled { opacity: .45; cursor: default; }
.info-row { display: flex; gap: 1.4rem; margin-bottom: .5rem; font-size: .9rem; flex-wrap: wrap; }
.panel { display: flex; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
canvas#grid { border: 1px solid #c8d4de; border-radius: 6px; display: block; }
.sidebar { flex: 1; min-width: 160px; }
.chart-title { font-size: .82rem; font-weight: 600; color: #4a6070; margin-bottom: .3rem; }
canvas#chart { border: 1px solid #e0e8ef; border-radius: 5px; background: #f7fafc; display: block; }
.note { font-size: .82rem; color: #55707e; margin-top: .5rem; line-height: 1.4; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Greedy submodular maximization: sensor coverage on a 12x12 grid.
// f(S) = number of distinct cells within radius 2 of at least one sensor in S.
// Submodularity: marginal gain of each new sensor <= that of the previous one.
// Greedy guarantee: f(S_greedy) >= (1 - 1/e) * OPT for monotone submodular functions.
// Nemhauser, Wolsey & Fisher (1978).
const ROWS = 12, COLS = 12, RADIUS = 2;
const CELL = 19; // px per cell on canvas
const N = ROWS * COLS;
function idx(r, c) { return r * COLS + c; }
// Precompute which cells each sensor position covers (L-inf ball of radius 2).
function buildCover(sr, sc) {
const s = new Set();
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++)
if (Math.abs(r - sr) <= RADIUS && Math.abs(c - sc) <= RADIUS) s.add(idx(r, c));
return s;
}
const COVER = [];
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) COVER.push(buildCover(r, c));
// State
let budget = 6;
let sensors = [];
let coveredSet = new Set();
let marginals = [];
function marginalGain(sensorIdx) {
let gain = 0;
for (const cell of COVER[sensorIdx])
if (!coveredSet.has(cell)) gain++;
return gain;
}
function greedyStep() {
if (sensors.length >= budget) return false;
let best = -1, bestGain = -1;
for (let i = 0; i < N; i++) {
if (sensors.includes(i)) continue;
const g = marginalGain(i);
if (g > bestGain) { bestGain = g; best = i; }
}
if (best === -1 || bestGain === 0) return false;
sensors.push(best);
for (const cell of COVER[best]) coveredSet.add(cell);
marginals.push(bestGain);
return true;
}
function reset() {
sensors = []; coveredSet = new Set(); marginals = [];
render();
}
const gridCanvas = document.getElementById('grid');
const gctx = gridCanvas.getContext('2d');
const chartCanvas = document.getElementById('chart');
const cctx = chartCanvas.getContext('2d');
function drawGrid() {
gctx.clearRect(0, 0, gridCanvas.width, gridCanvas.height);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const ci = idx(r, c);
const x = c * CELL + 1, y = r * CELL + 1;
const isSensor = sensors.includes(ci);
const isCovered = coveredSet.has(ci);
gctx.fillStyle = isSensor ? '#2a6496' : isCovered ? '#aecde3' : '#e8f0f7';
gctx.strokeStyle = '#b0c4d8';
gctx.lineWidth = 0.5;
gctx.fillRect(x, y, CELL - 1, CELL - 1);
gctx.strokeRect(x, y, CELL - 1, CELL - 1);
if (isSensor) {
gctx.fillStyle = '#fff';
gctx.beginPath();
gctx.arc(x + (CELL - 1) / 2, y + (CELL - 1) / 2, 3, 0, Math.PI * 2);
gctx.fill();
}
}
}
}
function drawChart() {
const W = chartCanvas.width, H = chartCanvas.height;
cctx.clearRect(0, 0, W, H);
if (marginals.length === 0) return;
const maxG = Math.max(...marginals, 1);
const pad = { l: 28, r: 8, t: 10, b: 24 };
const innerW = W - pad.l - pad.r;
const innerH = H - pad.t - pad.b;
const bw = Math.max(4, Math.floor(innerW / budget) - 3);
// axes
cctx.strokeStyle = '#b0c4d8'; cctx.lineWidth = 1;
cctx.beginPath();
cctx.moveTo(pad.l, pad.t); cctx.lineTo(pad.l, H - pad.b);
cctx.lineTo(W - pad.r, H - pad.b);
cctx.stroke();
// y gridlines and labels
cctx.fillStyle = '#6a8090'; cctx.font = '9px system-ui'; cctx.textAlign = 'right';
for (const v of [0, Math.round(maxG / 2), maxG]) {
const y = H - pad.b - (v / maxG) * innerH;
cctx.fillText(v, pad.l - 3, y + 3);
cctx.strokeStyle = '#e0e8ef'; cctx.lineWidth = 0.5;
cctx.beginPath(); cctx.moveTo(pad.l, y); cctx.lineTo(W - pad.r, y); cctx.stroke();
}
// bars with color gradient (early=blue, late=orange)
marginals.forEach((g, i) => {
const t = i / Math.max(budget - 1, 1);
const R = Math.round(42 + t * (210 - 42));
const G = Math.round(100 + t * (130 - 100));
const B = Math.round(150 + t * (40 - 150));
cctx.fillStyle = `rgb(${R},${G},${B})`;
const x = pad.l + i * (innerW / budget) + 2;
const barH = (g / maxG) * innerH;
cctx.fillRect(x, H - pad.b - barH, bw, barH);
cctx.fillStyle = '#4a6070'; cctx.font = '9px system-ui'; cctx.textAlign = 'center';
cctx.fillText(i + 1, x + bw / 2, H - pad.b + 12);
});
}
function render() {
budget = parseInt(document.getElementById('budgetSlider').value);
document.getElementById('budgetVal').textContent = budget;
document.getElementById('budgetSpan').textContent = budget;
document.getElementById('placed').textContent = sensors.length;
document.getElementById('coverage').textContent = coveredSet.size;
document.getElementById('pct').textContent = Math.round(coveredSet.size / N * 100);
const note = document.getElementById('note');
if (sensors.length === 0) {
note.textContent = 'No sensors placed yet. Click Step (Greedy) to begin.';
} else if (sensors.length < budget) {
const last = marginals[marginals.length - 1];
note.textContent = fmt('Step %length%: +%last% new cells covered. Each bar shows the shrinking marginal gain.', { length: sensors.length, last: last });
} else {
const pct = Math.round(coveredSet.size / N * 100);
note.textContent = fmt('Budget used. Coverage: %size%/%N% (%pct%%). Greedy guarantees ≥ 63.2 % of OPT.', { size: coveredSet.size, N: N, pct: pct });
}
document.getElementById('stepBtn').disabled = sensors.length >= budget;
document.getElementById('runBtn').disabled = sensors.length >= budget;
drawGrid();
drawChart();
}
document.getElementById('budgetSlider').addEventListener('input', () => {
budget = parseInt(document.getElementById('budgetSlider').value);
if (sensors.length > budget) {
sensors = sensors.slice(0, budget);
marginals = marginals.slice(0, budget);
coveredSet = new Set();
for (const si of sensors)
for (const c of COVER[si]) coveredSet.add(c);
}
render();
});
document.getElementById('stepBtn').addEventListener('click', () => { greedyStep(); render(); });
document.getElementById('runBtn').addEventListener('click', () => {
while (sensors.length < budget) if (!greedyStep()) break;
render();
});
document.getElementById('resetBtn').addEventListener('click', reset);
render();
Comments
Loading comments...