<p class="hint">Two vectors <b>A</b> (blue) and <b>B</b> (orange) are hashed by random
projections. Similar vectors collide (same bucket); dissimilar ones scatter.
Drag the angle sliders or click <b>New random hashes</b> to see the effect.</p>
<canvas id="canvas" width="320" height="180"></canvas>
<div class="controls">
<label>Angle of A: <span id="aVal">30°</span>
<input type="range" id="angleA" min="0" max="359" value="30"></label>
<label>Angle of B: <span id="bVal">50°</span>
<input type="range" id="angleB" min="0" max="359" value="50"></label>
</div>
<div class="stats" id="stats"></div>
<div class="btns">
<button id="rehash" type="button">New random hashes</button>
<button id="align" type="button" class="ghost">Make A ≈ B</button>
<button id="spread" type="button" class="ghost">Spread A and B</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; display: block;
background: #f5f8fb; width: 100%; max-width: 320px; height: auto; }
.controls { margin: .6rem 0; display: flex; flex-direction: column; gap: .3rem; }
label { font-size: .85rem; display: flex; align-items: center; gap: .5rem; }
input[type=range] { flex: 1; }
.stats { font-size: .9rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .3rem; }
button { font: 600 13px system-ui; padding: .4rem .8rem; 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] ?? ''));
// LSH demo: random hyperplane projection hashing.
// A hash function h picks a random unit vector r and computes h(v) = sign(r·v).
// Collision probability = 1 - angle(A,B)/180 = cosine similarity (for unit vectors).
// We use NUM_HASHES independent hash functions and display each bucket outcome.
const NUM_HASHES = 8;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Random projections (unit vectors): each is an angle in degrees.
let projAngles = [];
function randAngle() { return Math.random() * 360; }
function initHashes() {
projAngles = Array.from({ length: NUM_HASHES }, randAngle);
}
// Convert polar angle (degrees) to unit vector
function toVec(deg) {
const r = deg * Math.PI / 180;
return [Math.cos(r), Math.sin(r)];
}
// dot product
function dot(a, b) { return a[0] * b[0] + a[1] * b[1]; }
// LSH bucket for a vector and a projection: sign of dot product
function bucket(vecDeg, projDeg) {
return dot(toVec(vecDeg), toVec(projDeg)) >= 0 ? 1 : 0;
}
function getAngles() {
return {
a: parseInt(document.getElementById('angleA').value),
b: parseInt(document.getElementById('angleB').value)
};
}
function draw() {
const { a, b } = getAngles();
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const cx = W / 2, cy = H / 2;
const R = Math.min(W, H) * 0.38;
// Draw projection lines (faint)
projAngles.forEach(pa => {
const [px, py] = toVec(pa);
ctx.save();
ctx.strokeStyle = 'rgba(0,0,0,0.10)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(cx - px * R, cy - py * R);
ctx.lineTo(cx + px * R, cy + py * R);
ctx.stroke();
ctx.restore();
});
// Draw axes
ctx.strokeStyle = '#c5cdd6';
ctx.lineWidth = 0.8;
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R); ctx.stroke();
// Draw vector B (orange)
const [bx, by] = toVec(b);
ctx.strokeStyle = '#e07b39';
ctx.lineWidth = 2.5;
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + bx * R, cy - by * R); ctx.stroke();
// arrowhead B
drawArrow(ctx, cx + bx * R, cy - by * R, b, '#e07b39');
// Draw vector A (blue)
const [ax, ay] = toVec(a);
ctx.strokeStyle = '#1d6fa5';
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + ax * R, cy - ay * R); ctx.stroke();
drawArrow(ctx, cx + ax * R, cy - ay * R, a, '#1d6fa5');
// Labels
ctx.font = 'bold 13px system-ui';
ctx.fillStyle = '#1d6fa5';
ctx.fillText('A', cx + ax * (R + 10) - 4, cy - ay * (R + 10) + 5);
ctx.fillStyle = '#e07b39';
ctx.fillText('B', cx + bx * (R + 10) - 4, cy - by * (R + 10) + 5);
// Compute collisions
const collisions = projAngles.filter(pa => bucket(a, pa) === bucket(b, pa)).length;
const pct = Math.round(collisions / NUM_HASHES * 100);
// Draw hash results as colored squares
const sqSize = 18, sqGap = 4;
const totalW = NUM_HASHES * (sqSize + sqGap) - sqGap;
let sx = cx - totalW / 2;
const sy = H - sqSize - 6;
projAngles.forEach((pa, i) => {
const same = bucket(a, pa) === bucket(b, pa);
ctx.fillStyle = same ? '#2dc653' : '#e63946';
roundRect(ctx, sx + i * (sqSize + sqGap), sy, sqSize, sqSize, 3);
ctx.fill();
});
// Legend for squares
ctx.font = '10px system-ui';
ctx.fillStyle = '#555';
ctx.fillText('each square = 1 hash function ■ collide ■ scatter', cx - totalW / 2, sy - 4);
// Stats text
const cosAngle = Math.round(Math.abs(((a - b + 360) % 360)));
const angleDiff = cosAngle > 180 ? 360 - cosAngle : cosAngle;
const expected = Math.round((1 - angleDiff / 180) * 100);
document.getElementById('stats').textContent =
fmt('Collisions: %collisions%/%NUM_HASHES% (%pct%%) — angle between A and B: %angleDiff%° — expected collision rate: %expected%%', { collisions: collisions, NUM_HASHES: NUM_HASHES, pct: pct, angleDiff: angleDiff, expected: expected });
}
function drawArrow(ctx, x, y, angleDeg, color) {
const r = (angleDeg + 180) * Math.PI / 180;
const len = 9;
const spread = 0.4;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(r - spread) * len, y - Math.sin(r - spread) * len);
ctx.lineTo(x + Math.cos(r + spread) * len, y - Math.sin(r + spread) * len);
ctx.closePath();
ctx.fill();
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function updateLabels() {
const a = document.getElementById('angleA').value;
const b = document.getElementById('angleB').value;
document.getElementById('aVal').textContent = a + '°';
document.getElementById('bVal').textContent = b + '°';
}
document.getElementById('angleA').addEventListener('input', () => { updateLabels(); draw(); });
document.getElementById('angleB').addEventListener('input', () => { updateLabels(); draw(); });
document.getElementById('rehash').addEventListener('click', () => { initHashes(); draw(); });
document.getElementById('align').addEventListener('click', () => {
const a = parseInt(document.getElementById('angleA').value);
document.getElementById('angleB').value = (a + 10) % 360;
updateLabels(); draw();
});
document.getElementById('spread').addEventListener('click', () => {
document.getElementById('angleA').value = 30;
document.getElementById('angleB').value = 150;
updateLabels(); draw();
});
initHashes();
draw();
// Verify in console: collision probability ≈ 1 - angleDiff/180
(function verify() {
// For two vectors 60° apart, expected collision rate per hash = 1 - 60/180 ≈ 0.667
let hits = 0;
const trials = 10000;
for (let i = 0; i < trials; i++) {
const pa = Math.random() * 360;
if (bucket(0, pa) === bucket(60, pa)) hits++;
}
const rate = hits / trials;
const expected = 1 - 60 / 180;
const ok = Math.abs(rate - expected) < 0.05;
console.log('LSH verify: 60° apart, collision rate =', rate.toFixed(3),
'expected', expected.toFixed(3), ok ? '✓ PASS' : '✗ FAIL');
})();
<p class="hint">Two vectors <b>A</b> (blue) and <b>B</b> (orange) are hashed by random
projections. Similar vectors collide (same bucket); dissimilar ones scatter.
Drag the angle sliders or click <b>New random hashes</b> to see the effect.</p>
<canvas id="canvas" width="320" height="180"></canvas>
<div class="controls">
<label>Angle of A: <span id="aVal">30°</span>
<input type="range" id="angleA" min="0" max="359" value="30"></label>
<label>Angle of B: <span id="bVal">50°</span>
<input type="range" id="angleB" min="0" max="359" value="50"></label>
</div>
<div class="stats" id="stats"></div>
<div class="btns">
<button id="rehash" type="button">New random hashes</button>
<button id="align" type="button" class="ghost">Make A ≈ B</button>
<button id="spread" type="button" class="ghost">Spread A and B</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; display: block;
background: #f5f8fb; width: 100%; max-width: 320px; height: auto; }
.controls { margin: .6rem 0; display: flex; flex-direction: column; gap: .3rem; }
label { font-size: .85rem; display: flex; align-items: center; gap: .5rem; }
input[type=range] { flex: 1; }
.stats { font-size: .9rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .3rem; }
button { font: 600 13px system-ui; padding: .4rem .8rem; 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] ?? ''));
// LSH demo: random hyperplane projection hashing.
// A hash function h picks a random unit vector r and computes h(v) = sign(r·v).
// Collision probability = 1 - angle(A,B)/180 = cosine similarity (for unit vectors).
// We use NUM_HASHES independent hash functions and display each bucket outcome.
const NUM_HASHES = 8;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Random projections (unit vectors): each is an angle in degrees.
let projAngles = [];
function randAngle() { return Math.random() * 360; }
function initHashes() {
projAngles = Array.from({ length: NUM_HASHES }, randAngle);
}
// Convert polar angle (degrees) to unit vector
function toVec(deg) {
const r = deg * Math.PI / 180;
return [Math.cos(r), Math.sin(r)];
}
// dot product
function dot(a, b) { return a[0] * b[0] + a[1] * b[1]; }
// LSH bucket for a vector and a projection: sign of dot product
function bucket(vecDeg, projDeg) {
return dot(toVec(vecDeg), toVec(projDeg)) >= 0 ? 1 : 0;
}
function getAngles() {
return {
a: parseInt(document.getElementById('angleA').value),
b: parseInt(document.getElementById('angleB').value)
};
}
function draw() {
const { a, b } = getAngles();
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const cx = W / 2, cy = H / 2;
const R = Math.min(W, H) * 0.38;
// Draw projection lines (faint)
projAngles.forEach(pa => {
const [px, py] = toVec(pa);
ctx.save();
ctx.strokeStyle = 'rgba(0,0,0,0.10)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(cx - px * R, cy - py * R);
ctx.lineTo(cx + px * R, cy + py * R);
ctx.stroke();
ctx.restore();
});
// Draw axes
ctx.strokeStyle = '#c5cdd6';
ctx.lineWidth = 0.8;
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R); ctx.stroke();
// Draw vector B (orange)
const [bx, by] = toVec(b);
ctx.strokeStyle = '#e07b39';
ctx.lineWidth = 2.5;
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + bx * R, cy - by * R); ctx.stroke();
// arrowhead B
drawArrow(ctx, cx + bx * R, cy - by * R, b, '#e07b39');
// Draw vector A (blue)
const [ax, ay] = toVec(a);
ctx.strokeStyle = '#1d6fa5';
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + ax * R, cy - ay * R); ctx.stroke();
drawArrow(ctx, cx + ax * R, cy - ay * R, a, '#1d6fa5');
// Labels
ctx.font = 'bold 13px system-ui';
ctx.fillStyle = '#1d6fa5';
ctx.fillText('A', cx + ax * (R + 10) - 4, cy - ay * (R + 10) + 5);
ctx.fillStyle = '#e07b39';
ctx.fillText('B', cx + bx * (R + 10) - 4, cy - by * (R + 10) + 5);
// Compute collisions
const collisions = projAngles.filter(pa => bucket(a, pa) === bucket(b, pa)).length;
const pct = Math.round(collisions / NUM_HASHES * 100);
// Draw hash results as colored squares
const sqSize = 18, sqGap = 4;
const totalW = NUM_HASHES * (sqSize + sqGap) - sqGap;
let sx = cx - totalW / 2;
const sy = H - sqSize - 6;
projAngles.forEach((pa, i) => {
const same = bucket(a, pa) === bucket(b, pa);
ctx.fillStyle = same ? '#2dc653' : '#e63946';
roundRect(ctx, sx + i * (sqSize + sqGap), sy, sqSize, sqSize, 3);
ctx.fill();
});
// Legend for squares
ctx.font = '10px system-ui';
ctx.fillStyle = '#555';
ctx.fillText('each square = 1 hash function ■ collide ■ scatter', cx - totalW / 2, sy - 4);
// Stats text
const cosAngle = Math.round(Math.abs(((a - b + 360) % 360)));
const angleDiff = cosAngle > 180 ? 360 - cosAngle : cosAngle;
const expected = Math.round((1 - angleDiff / 180) * 100);
document.getElementById('stats').textContent =
fmt('Collisions: %collisions%/%NUM_HASHES% (%pct%%) — angle between A and B: %angleDiff%° — expected collision rate: %expected%%', { collisions: collisions, NUM_HASHES: NUM_HASHES, pct: pct, angleDiff: angleDiff, expected: expected });
}
function drawArrow(ctx, x, y, angleDeg, color) {
const r = (angleDeg + 180) * Math.PI / 180;
const len = 9;
const spread = 0.4;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(r - spread) * len, y - Math.sin(r - spread) * len);
ctx.lineTo(x + Math.cos(r + spread) * len, y - Math.sin(r + spread) * len);
ctx.closePath();
ctx.fill();
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function updateLabels() {
const a = document.getElementById('angleA').value;
const b = document.getElementById('angleB').value;
document.getElementById('aVal').textContent = a + '°';
document.getElementById('bVal').textContent = b + '°';
}
document.getElementById('angleA').addEventListener('input', () => { updateLabels(); draw(); });
document.getElementById('angleB').addEventListener('input', () => { updateLabels(); draw(); });
document.getElementById('rehash').addEventListener('click', () => { initHashes(); draw(); });
document.getElementById('align').addEventListener('click', () => {
const a = parseInt(document.getElementById('angleA').value);
document.getElementById('angleB').value = (a + 10) % 360;
updateLabels(); draw();
});
document.getElementById('spread').addEventListener('click', () => {
document.getElementById('angleA').value = 30;
document.getElementById('angleB').value = 150;
updateLabels(); draw();
});
initHashes();
draw();
// Verify in console: collision probability ≈ 1 - angleDiff/180
(function verify() {
// For two vectors 60° apart, expected collision rate per hash = 1 - 60/180 ≈ 0.667
let hits = 0;
const trials = 10000;
for (let i = 0; i < trials; i++) {
const pa = Math.random() * 360;
if (bucket(0, pa) === bucket(60, pa)) hits++;
}
const rate = hits / trials;
const expected = 1 - 60 / 180;
const ok = Math.abs(rate - expected) < 0.05;
console.log('LSH verify: 60° apart, collision rate =', rate.toFixed(3),
'expected', expected.toFixed(3), ok ? '✓ PASS' : '✗ FAIL');
})();
Comments
Loading comments...