Click Stream items to send a torrent of items through a fixed set of k=5 counters. Popular items (A, B, C) appear far more often than rare ones. Watch which labels survive eviction and which get replaced.
<!-- Space-Saving heavy-hitters demo: simulate a stream of items and watch -->
<p class="hint">Items A, B, C are <strong>heavy hitters</strong> (appear ~12x more often than the rare ones). Watch a fixed set of k counters track them using the Space-Saving rule: new items evict the smallest counter and inherit its count.</p>
<div class="controls">
<label>k (counters): <input id="kInput" type="number" min="2" max="10" value="5"></label>
<label>Batch size: <input id="batchInput" type="number" min="1" max="100" value="20"></label>
</div>
<div class="stream-label">Last items streamed</div>
<div id="streamViz" class="stream-viz"></div>
<div class="counter-label">Counter table (item / count / max-error)</div>
<div id="counters" class="counters"></div>
<div id="status" class="status"></div>
<div class="btns">
<button id="btnStream" type="button">Stream items</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
/* General layout and typography */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.controls { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: .6rem; align-items: center; }
.controls label { font-size: .85rem; display: flex; align-items: center; gap: .3rem; }
.controls input { width: 52px; padding: .2rem .4rem; border: 1px solid #adb1b8; border-radius: 6px; font-size: .85rem; }
.stream-label, .counter-label { font-size: .78rem; font-weight: 600; color: #666; letter-spacing: .04em; text-transform: uppercase; margin-bottom: .25rem; }
/* Stream visualization tokens */
.stream-viz { display: flex; flex-wrap: wrap; gap: 3px; min-height: 28px; margin-bottom: .5rem; }
.token { display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: 5px; font: 700 12px ui-monospace, monospace;
transition: opacity .3s; }
.token.heavy { background: #1d3557; color: #fff; }
.token.rare { background: #c9ccd1; color: #444; }
.token.evict { background: #e63946; color: #fff; opacity: .7; }
/* Counter cards grid */
.counters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: .5rem; }
.counter-card { border: 1.5px solid #cdd9e3; border-radius: 8px; padding: .35rem .55rem;
min-width: 54px; text-align: center; background: #e8eef3; transition: background .2s, border-color .2s; }
.counter-card.bump { background: #d0f0e0; border-color: #0a7d33; }
.counter-card.evicted { background: #fde8ea; border-color: #e63946; }
.counter-card .item { font: 700 16px ui-monospace, monospace; color: #1d3557; }
.counter-card .count { font-size: .75rem; color: #555; }
.counter-card .err { font-size: .65rem; color: #888; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; margin-bottom: .4rem; color: #1d3557; }
.btns { display: flex; gap: .5rem; }
button { font: 600 14px system-ui; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// the k counters track the most frequent ones via the Space-Saving rule.
// Each arriving item either bumps its counter (if tracked) or evicts the minimum counter.
// Items in the simulated stream
const HEAVY = ['A','B','C'];
const RARE = ['D','E','F','G','H','I','J','K'];
// Heavy items appear HEAVY_WEIGHT times more often than rare ones
const HEAVY_WEIGHT = 12;
const RARE_WEIGHT = 1;
function pickItem() {
// Build a weighted pool and draw uniformly
const pool = [];
HEAVY.forEach(x => { for(let i=0;i<HEAVY_WEIGHT;i++) pool.push(x); });
RARE.forEach(x => { for(let i=0;i<RARE_WEIGHT;i++) pool.push(x); });
return pool[Math.floor(Math.random() * pool.length)];
}
// Algorithm state: array of (item, count, maxError) tuples
let counters = []; // [{item, count, maxError}]
let totalSeen = 0;
function getK() { return Math.max(2, Math.min(10, parseInt(document.getElementById('kInput').value) || 5)); }
function getBatch() { return Math.max(1, Math.min(100,parseInt(document.getElementById('batchInput').value) || 20)); }
// Space-Saving insert: O(k) naive; O(log k) with a min-heap
function spaceSavingInsert(item) {
totalSeen++;
const idx = counters.findIndex(c => c.item === item);
if (idx >= 0) {
// Item already tracked — just increment
counters[idx].count++;
return 'bump';
}
if (counters.length < getK()) {
// Spare counter slot available — add with count 1
counters.push({ item, count: 1, maxError: 0 });
return 'new';
}
// No spare slot — evict the minimum, inherit its count + 1
counters.sort((a, b) => a.count - b.count);
const minCount = counters[0].count;
counters[0] = { item, count: minCount + 1, maxError: minCount };
return 'evict';
}
function renderCounters(lastAction, lastItem) {
const div = document.getElementById('counters');
div.innerHTML = '';
const sorted = [...counters].sort((a,b) => b.count - a.count);
sorted.forEach(c => {
const card = document.createElement('div');
const isLast = c.item === lastItem;
card.className = 'counter-card' +
(isLast && lastAction === 'bump' ? ' bump' : '') +
(isLast && lastAction === 'evict' ? ' evicted' : '');
card.innerHTML =
`<div class="item">${c.item}</div>` +
`<div class="count">${c.count}</div>` +
`<div class="err">±${c.maxError}</div>`;
div.appendChild(card);
});
}
function renderStream(items, action) {
const div = document.getElementById('streamViz');
div.innerHTML = '';
items.forEach((x, i) => {
const t = document.createElement('span');
const isLast = (i === items.length - 1);
const isHeavy = HEAVY.includes(x);
t.className = 'token ' + (isLast && action === 'evict' ? 'evict' : isHeavy ? 'heavy' : 'rare');
t.textContent = x;
div.appendChild(t);
});
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
let streamedItems = [];
function streamBatch() {
const batch = getBatch();
let lastAction = 'new', lastItem = '';
for (let i = 0; i < batch; i++) {
const item = pickItem();
streamedItems.push(item);
if (streamedItems.length > 60) streamedItems.shift();
lastAction = spaceSavingInsert(item);
lastItem = item;
}
renderStream(streamedItems, lastAction);
renderCounters(lastAction, lastItem);
setStatus(i18nFmt('Streamed %n% items total into %k% counters.', { n: totalSeen, k: getK() }));
}
function reset() {
counters = [];
totalSeen = 0;
streamedItems = [];
renderStream([], '');
renderCounters('', '');
setStatus('Ready — click Stream items to begin.');
}
document.getElementById('btnStream').addEventListener('click', streamBatch);
document.getElementById('btnReset').addEventListener('click', reset);
reset();
<!-- Space-Saving heavy-hitters demo: simulate a stream of items and watch -->
<p class="hint">Items A, B, C are <strong>heavy hitters</strong> (appear ~12x more often than the rare ones). Watch a fixed set of k counters track them using the Space-Saving rule: new items evict the smallest counter and inherit its count.</p>
<div class="controls">
<label>k (counters): <input id="kInput" type="number" min="2" max="10" value="5"></label>
<label>Batch size: <input id="batchInput" type="number" min="1" max="100" value="20"></label>
</div>
<div class="stream-label">Last items streamed</div>
<div id="streamViz" class="stream-viz"></div>
<div class="counter-label">Counter table (item / count / max-error)</div>
<div id="counters" class="counters"></div>
<div id="status" class="status"></div>
<div class="btns">
<button id="btnStream" type="button">Stream items</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
/* General layout and typography */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.controls { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: .6rem; align-items: center; }
.controls label { font-size: .85rem; display: flex; align-items: center; gap: .3rem; }
.controls input { width: 52px; padding: .2rem .4rem; border: 1px solid #adb1b8; border-radius: 6px; font-size: .85rem; }
.stream-label, .counter-label { font-size: .78rem; font-weight: 600; color: #666; letter-spacing: .04em; text-transform: uppercase; margin-bottom: .25rem; }
/* Stream visualization tokens */
.stream-viz { display: flex; flex-wrap: wrap; gap: 3px; min-height: 28px; margin-bottom: .5rem; }
.token { display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: 5px; font: 700 12px ui-monospace, monospace;
transition: opacity .3s; }
.token.heavy { background: #1d3557; color: #fff; }
.token.rare { background: #c9ccd1; color: #444; }
.token.evict { background: #e63946; color: #fff; opacity: .7; }
/* Counter cards grid */
.counters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: .5rem; }
.counter-card { border: 1.5px solid #cdd9e3; border-radius: 8px; padding: .35rem .55rem;
min-width: 54px; text-align: center; background: #e8eef3; transition: background .2s, border-color .2s; }
.counter-card.bump { background: #d0f0e0; border-color: #0a7d33; }
.counter-card.evicted { background: #fde8ea; border-color: #e63946; }
.counter-card .item { font: 700 16px ui-monospace, monospace; color: #1d3557; }
.counter-card .count { font-size: .75rem; color: #555; }
.counter-card .err { font-size: .65rem; color: #888; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; margin-bottom: .4rem; color: #1d3557; }
.btns { display: flex; gap: .5rem; }
button { font: 600 14px system-ui; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// the k counters track the most frequent ones via the Space-Saving rule.
// Each arriving item either bumps its counter (if tracked) or evicts the minimum counter.
// Items in the simulated stream
const HEAVY = ['A','B','C'];
const RARE = ['D','E','F','G','H','I','J','K'];
// Heavy items appear HEAVY_WEIGHT times more often than rare ones
const HEAVY_WEIGHT = 12;
const RARE_WEIGHT = 1;
function pickItem() {
// Build a weighted pool and draw uniformly
const pool = [];
HEAVY.forEach(x => { for(let i=0;i<HEAVY_WEIGHT;i++) pool.push(x); });
RARE.forEach(x => { for(let i=0;i<RARE_WEIGHT;i++) pool.push(x); });
return pool[Math.floor(Math.random() * pool.length)];
}
// Algorithm state: array of (item, count, maxError) tuples
let counters = []; // [{item, count, maxError}]
let totalSeen = 0;
function getK() { return Math.max(2, Math.min(10, parseInt(document.getElementById('kInput').value) || 5)); }
function getBatch() { return Math.max(1, Math.min(100,parseInt(document.getElementById('batchInput').value) || 20)); }
// Space-Saving insert: O(k) naive; O(log k) with a min-heap
function spaceSavingInsert(item) {
totalSeen++;
const idx = counters.findIndex(c => c.item === item);
if (idx >= 0) {
// Item already tracked — just increment
counters[idx].count++;
return 'bump';
}
if (counters.length < getK()) {
// Spare counter slot available — add with count 1
counters.push({ item, count: 1, maxError: 0 });
return 'new';
}
// No spare slot — evict the minimum, inherit its count + 1
counters.sort((a, b) => a.count - b.count);
const minCount = counters[0].count;
counters[0] = { item, count: minCount + 1, maxError: minCount };
return 'evict';
}
function renderCounters(lastAction, lastItem) {
const div = document.getElementById('counters');
div.innerHTML = '';
const sorted = [...counters].sort((a,b) => b.count - a.count);
sorted.forEach(c => {
const card = document.createElement('div');
const isLast = c.item === lastItem;
card.className = 'counter-card' +
(isLast && lastAction === 'bump' ? ' bump' : '') +
(isLast && lastAction === 'evict' ? ' evicted' : '');
card.innerHTML =
`<div class="item">${c.item}</div>` +
`<div class="count">${c.count}</div>` +
`<div class="err">±${c.maxError}</div>`;
div.appendChild(card);
});
}
function renderStream(items, action) {
const div = document.getElementById('streamViz');
div.innerHTML = '';
items.forEach((x, i) => {
const t = document.createElement('span');
const isLast = (i === items.length - 1);
const isHeavy = HEAVY.includes(x);
t.className = 'token ' + (isLast && action === 'evict' ? 'evict' : isHeavy ? 'heavy' : 'rare');
t.textContent = x;
div.appendChild(t);
});
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
let streamedItems = [];
function streamBatch() {
const batch = getBatch();
let lastAction = 'new', lastItem = '';
for (let i = 0; i < batch; i++) {
const item = pickItem();
streamedItems.push(item);
if (streamedItems.length > 60) streamedItems.shift();
lastAction = spaceSavingInsert(item);
lastItem = item;
}
renderStream(streamedItems, lastAction);
renderCounters(lastAction, lastItem);
setStatus(i18nFmt('Streamed %n% items total into %k% counters.', { n: totalSeen, k: getK() }));
}
function reset() {
counters = [];
totalSeen = 0;
streamedItems = [];
renderStream([], '');
renderCounters('', '');
setStatus('Ready — click Stream items to begin.');
}
document.getElementById('btnStream').addEventListener('click', streamBatch);
document.getElementById('btnReset').addEventListener('click', reset);
reset();
Notice how A, B, and C almost always end up in the top counters even though you never stored the full stream. Each time a new item has no slot, it evicts the minimum counter — but the true heavy hitters keep winning their slots back because they arrive so frequently.
Comments
Loading comments...