Skip to content

Instantly share code, notes, and snippets.

@kbouw
Created February 16, 2026 05:18
Show Gist options
  • Select an option

  • Save kbouw/3c29a4726bc33a5f6542179d9b6b7a9f to your computer and use it in GitHub Desktop.

Select an option

Save kbouw/3c29a4726bc33a5f6542179d9b6b7a9f to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Understanding Embeddings – Interactive Explainer</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #111216;
--surface: rgba(255,255,255,0.015);
--surfaceHover: rgba(255,255,255,0.04);
--border: rgba(255,255,255,0.04);
--text: #e8e6e3;
--textMuted: #6a6e76;
--textDim: #4a4e56;
--textFaint: #3a3e46;
--green: #4ade80;
--blue: #8cb4ff;
--yellow: #f0c674;
--red: #f07474;
--purple: #a78bfa;
--font-body: 'IBM Plex Sans', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.container { max-width: 720px; margin: 0 auto; padding: 48px 24px 64px; }
/* Header */
.header { margin-bottom: 32px; max-width: 640px; }
.header-label {
font-family: var(--font-mono); font-size: 11px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.12em; color: var(--green); margin-bottom: 8px;
}
.header-title {
font-family: var(--font-body); font-size: 28px; font-weight: 300;
color: var(--text); letter-spacing: -0.01em; margin-bottom: 8px; line-height: 1.2;
}
.header-subtitle {
font-family: var(--font-body); font-size: 15px;
color: var(--textMuted); line-height: 1.5; max-width: 560px;
}
/* Tabs */
.tab-bar {
display: flex; gap: 4px; margin-bottom: 24px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; padding: 4px; overflow-x: auto;
}
.tab-btn {
font-family: var(--font-mono); font-size: 11px; font-weight: 400;
letter-spacing: 0.02em; color: var(--textDim); background: transparent;
border: 1px solid transparent; border-radius: 6px; padding: 8px 14px;
cursor: pointer; white-space: nowrap; transition: all 0.2s ease-out;
}
.tab-btn:hover { color: var(--textMuted); background: var(--surfaceHover); }
.tab-btn.active { color: var(--green); background: rgba(74,222,128,0.08); border-color: rgba(74,222,128,0.2); }
/* Panels */
.panel {
display: none; background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 28px 24px; animation: panelIn 0.3s ease-out;
}
.panel.active { display: block; }
@keyframes panelIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.panel-title { font-size: 18px; font-weight: 400; color: var(--text); margin-bottom: 6px; }
.panel-desc { font-size: 13px; color: var(--textMuted); margin-bottom: 24px; line-height: 1.5; }
/* Canvas */
.canvas-wrap { position: relative; width: 100%; border-radius: 8px; overflow: hidden; margin-bottom: 16px; }
.canvas-wrap canvas { display: block; width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; }
/* Insight Card */
.insight { background: rgba(255,255,255,0.02); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-top: 16px; }
.insight-label { font-family: var(--font-mono); font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.1em; color: var(--textDim); margin-bottom: 6px; }
.insight-text { font-family: var(--font-mono); font-size: 12px; color: var(--textMuted); line-height: 1.6; }
.hl-green { color: var(--green); } .hl-blue { color: var(--blue); }
.hl-yellow { color: var(--yellow); } .hl-red { color: var(--red); } .hl-purple { color: var(--purple); }
/* Controls */
.controls { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 20px; align-items: flex-end; }
.control-group { display: flex; flex-direction: column; gap: 4px; }
.control-label { font-family: var(--font-mono); font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.08em; color: var(--textDim); }
.btn {
font-family: var(--font-mono); font-size: 11px; font-weight: 400;
color: var(--textMuted); background: rgba(255,255,255,0.02);
border: 1px solid var(--border); border-radius: 6px; padding: 7px 14px;
cursor: pointer; transition: all 0.2s ease-out;
}
.btn:hover { color: var(--text); background: var(--surfaceHover); border-color: rgba(255,255,255,0.08); }
.btn.active { color: var(--green); border-color: rgba(74,222,128,0.2); background: rgba(74,222,128,0.06); }
/* Prompt display */
.prompt-box {
background: rgba(255,255,255,0.02); border: 1px solid var(--border); border-radius: 8px;
padding: 14px 18px; margin-bottom: 20px; font-family: var(--font-body); font-size: 14px;
color: var(--textMuted); line-height: 1.5;
}
.prompt-box .pw {
color: var(--text); font-weight: 500; cursor: pointer; padding: 1px 5px;
border-radius: 3px; transition: all 0.2s ease-out; position: relative;
}
.prompt-box .pw:hover { background: rgba(255,255,255,0.06); }
.prompt-box .pw.selected { color: var(--green); background: rgba(74,222,128,0.1); }
/* Similarity bars */
.sim-row {
display: flex; align-items: center; gap: 12px; margin-bottom: 10px;
padding: 8px 12px; border-radius: 6px; transition: all 0.3s ease-out;
cursor: default;
}
.sim-row:hover { background: rgba(255,255,255,0.02); }
.sim-label { font-family: var(--font-mono); font-size: 12px; font-weight: 500; min-width: 130px; text-align: right; }
.sim-bar-track { flex: 1; height: 6px; background: rgba(255,255,255,0.03); border-radius: 3px; overflow: hidden; position: relative; }
.sim-bar-fill { height: 100%; border-radius: 3px; transition: width 0.8s cubic-bezier(0.34, 1.56, 0.64, 1); }
.sim-score { font-family: var(--font-mono); font-size: 11px; min-width: 40px; text-align: right; }
/* Relationship arrows panel */
.rel-grid { display: flex; flex-direction: column; gap: 16px; margin-bottom: 16px; }
.rel-pair {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 10px 14px; border-radius: 8px; background: rgba(255,255,255,0.015);
border: 1px solid var(--border);
}
.rel-word {
font-family: var(--font-mono); font-size: 13px; font-weight: 500;
padding: 3px 10px; border-radius: 4px;
}
.rel-arrow { font-family: var(--font-mono); font-size: 14px; color: var(--textFaint); }
.rel-eq { font-family: var(--font-mono); font-size: 11px; color: var(--textDim); padding: 0 4px; }
.rel-dir-label {
font-family: var(--font-mono); font-size: 10px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.08em; margin-left: auto; padding: 3px 8px;
border-radius: 4px;
}
@media (max-width: 480px) {
.container { padding: 32px 16px 48px; }
.header-title { font-size: 22px; }
.panel { padding: 20px 16px; }
.tab-btn { font-size: 10px; padding: 6px 10px; }
.sim-label { min-width: 100px; font-size: 11px; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-label">Interactive Explainer</div>
<div class="header-title">Embeddings</div>
<div class="header-subtitle">You asked for a chicken dinner and the model suggested coq au vin. Learn why that match works even when the words are completely different.</div>
</div>
<div class="tab-bar">
<button class="tab-btn active" data-tab="neighbors">Your Words → Neighbors</button>
<button class="tab-btn" data-tab="match">Finding the Match</button>
<button class="tab-btn" data-tab="directions">Directions in Space</button>
</div>
<!-- Panel 1: Your Words → Neighbors -->
<div class="panel active" id="panel-neighbors">
<div class="panel-title">Your Words Expand into Meaning</div>
<div class="panel-desc">Click a word in the prompt to see what's nearby in embedding space. What you see are not synonyms from a dictionary but words that appeared in similar contexts across billions of sentences.</div>
<div class="prompt-box" id="prompt-box">
Give me a
<span class="pw" data-word="dinner">dinner</span>
I can make with
<span class="pw" data-word="chicken">chicken</span>
and
<span class="pw" data-word="mushrooms">mushrooms</span>
</div>
<div class="canvas-wrap">
<canvas id="neighborCanvas" width="672" height="400"></canvas>
</div>
<div class="insight">
<div class="insight-label">Insight</div>
<div class="insight-text" id="insight-neighbors">
Click a word above. Each word in your prompt has a position in embedding space, surrounded by <span class="hl-green">semantically related concepts</span> the model learned from training. Your prompt lives in a neighborhood, and so does every possible response.
</div>
</div>
</div>
<!-- Panel 2: Finding the Match -->
<div class="panel" id="panel-match">
<div class="panel-title">Why "Coq au Vin" Matches Your Prompt</div>
<div class="panel-desc">The model converts your entire prompt into one vector, then compares it against candidate responses. Cosine similarity measures how much two vectors point the same direction: 1.0 = identical meaning, 0.0 = unrelated.</div>
<div class="prompt-box" style="text-align:center;">
<span style="font-family:var(--font-mono);font-size:10px;color:var(--textDim);text-transform:uppercase;letter-spacing:0.08em;">Your prompt vector</span><br>
<span style="color:var(--blue);font-weight:500;">"give me a dinner I can make with chicken and mushrooms"</span>
</div>
<div id="sim-bars"></div>
<div class="controls" style="justify-content:center;margin-top:16px;">
<div class="control-group">
<span class="control-label">Sort by</span>
<div style="display:flex;gap:6px;">
<button class="btn active" id="sort-sim">Similarity</button>
<button class="btn" id="sort-alpha">Alphabetical</button>
</div>
</div>
</div>
<div class="insight">
<div class="insight-label">Insight</div>
<div class="insight-text" id="insight-match">
<span class="hl-green">"Coq au vin"</span> and <span class="hl-green">"chicken marsala"</span> score highest because their embeddings point in nearly the same direction as your prompt, despite sharing few or no words. <span class="hl-red">"Calculus homework"</span> points in a completely different direction. The model matched <span class="hl-green">meaning</span>, not keywords.
</div>
</div>
</div>
<!-- Panel 3: Directions in Space -->
<div class="panel" id="panel-directions">
<div class="panel-title">Relationships as Directions</div>
<div class="panel-desc">Embedding space encodes more than similarity. The same relationship between two words shows up as the same direction between other word pairs. These consistent geometric patterns are how the model generalizes.</div>
<div class="controls">
<div class="control-group">
<span class="control-label">Relationship</span>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="btn active" data-rel="0">Ingredient → Dish</button>
<button class="btn" data-rel="1">Raw → Cooked</button>
<button class="btn" data-rel="2">Ingredient → Category</button>
</div>
</div>
</div>
<div class="canvas-wrap">
<canvas id="dirCanvas" width="672" height="380"></canvas>
</div>
<div class="insight">
<div class="insight-label">Insight</div>
<div class="insight-text" id="insight-directions">
The direction from <span class="hl-blue">"chicken"</span> to <span class="hl-green">"coq au vin"</span> is similar to the direction from <span class="hl-blue">"beef"</span> to <span class="hl-green">"beef bourguignon."</span> Both capture "ingredient → French braise." The model reuses these directional patterns to connect your prompt to recipes it was never explicitly taught to pair.
</div>
</div>
</div>
</div>
<script>
// ─── Tab switching ───
const tabs = document.querySelectorAll('.tab-btn');
const panels = document.querySelectorAll('.panel');
tabs.forEach(t => t.addEventListener('click', () => {
tabs.forEach(b => b.classList.remove('active'));
panels.forEach(p => p.classList.remove('active'));
t.classList.add('active');
document.getElementById('panel-' + t.dataset.tab).classList.add('active');
}));
// ═══════════════════════════════════════════
// Panel 1: Neighbor Constellation
// ═══════════════════════════════════════════
const neighborData = {
chicken: {
center: { word: 'chicken', x: 0.5, y: 0.5 },
neighbors: [
{ word: 'poultry', x: 0.42, y: 0.38, sim: 0.92 },
{ word: 'roast chicken', x: 0.58, y: 0.36, sim: 0.89 },
{ word: 'thigh', x: 0.62, y: 0.48, sim: 0.86 },
{ word: 'turkey', x: 0.35, y: 0.52, sim: 0.84 },
{ word: 'broth', x: 0.55, y: 0.62, sim: 0.81 },
{ word: 'grilled', x: 0.68, y: 0.40, sim: 0.78 },
{ word: 'duck', x: 0.32, y: 0.42, sim: 0.76 },
{ word: 'marinade', x: 0.65, y: 0.58, sim: 0.73 },
{ word: 'coq au vin', x: 0.44, y: 0.64, sim: 0.71 },
{ word: 'protein', x: 0.72, y: 0.52, sim: 0.68 },
]
},
mushrooms: {
center: { word: 'mushrooms', x: 0.5, y: 0.5 },
neighbors: [
{ word: 'fungi', x: 0.42, y: 0.38, sim: 0.91 },
{ word: 'shiitake', x: 0.58, y: 0.36, sim: 0.88 },
{ word: 'cremini', x: 0.36, y: 0.48, sim: 0.86 },
{ word: 'truffle', x: 0.62, y: 0.44, sim: 0.83 },
{ word: 'sautéed', x: 0.55, y: 0.62, sim: 0.79 },
{ word: 'risotto', x: 0.44, y: 0.64, sim: 0.77 },
{ word: 'umami', x: 0.68, y: 0.52, sim: 0.75 },
{ word: 'portobello', x: 0.34, y: 0.56, sim: 0.74 },
{ word: 'earthy', x: 0.64, y: 0.60, sim: 0.70 },
{ word: 'foraging', x: 0.72, y: 0.42, sim: 0.65 },
]
},
dinner: {
center: { word: 'dinner', x: 0.5, y: 0.5 },
neighbors: [
{ word: 'supper', x: 0.42, y: 0.38, sim: 0.93 },
{ word: 'meal', x: 0.58, y: 0.36, sim: 0.90 },
{ word: 'entrée', x: 0.36, y: 0.50, sim: 0.87 },
{ word: 'weeknight', x: 0.62, y: 0.46, sim: 0.82 },
{ word: 'recipe', x: 0.48, y: 0.64, sim: 0.79 },
{ word: 'cooking', x: 0.56, y: 0.62, sim: 0.77 },
{ word: 'feast', x: 0.34, y: 0.42, sim: 0.73 },
{ word: 'homemade', x: 0.66, y: 0.56, sim: 0.71 },
{ word: 'comfort food', x: 0.44, y: 0.66, sim: 0.69 },
{ word: 'restaurant', x: 0.70, y: 0.40, sim: 0.64 },
]
}
};
let activeWord = null;
let neighborAnim = 0;
let neighborAnimId = null;
const nCanvas = document.getElementById('neighborCanvas');
const nCtx = nCanvas.getContext('2d');
let nDpr, nW, nH;
function initCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx, w: rect.width, h: rect.height, dpr };
}
function drawNeighbors() {
const { ctx, w, h } = initCanvas(nCanvas);
nW = w; nH = h;
ctx.clearRect(0, 0, w, h);
if (!activeWord) {
// Empty state
ctx.font = `400 13px 'IBM Plex Sans', sans-serif`;
ctx.fillStyle = '#4a4e56';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Click a highlighted word in the prompt above', w/2, h/2);
return;
}
const data = neighborData[activeWord];
const pad = 50;
const plotW = w - pad * 2;
const plotH = h - pad * 2;
const t = neighborAnim;
// Connections from center to neighbors (lines first, behind dots)
data.neighbors.forEach((n, i) => {
const delay = i * 0.06;
const localT = Math.max(0, Math.min(1, (t - delay) / 0.5));
if (localT <= 0) return;
const eased = 1 - Math.pow(1 - localT, 3); // ease-out cubic
const cx = pad + data.center.x * plotW;
const cy = pad + data.center.y * plotH;
const nx = pad + n.x * plotW;
const ny = pad + n.y * plotH;
const alpha = 0.04 + n.sim * 0.06;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + (nx - cx) * eased, cy + (ny - cy) * eased);
ctx.strokeStyle = `rgba(74,222,128,${alpha * eased})`;
ctx.lineWidth = 1;
ctx.stroke();
});
// Center word
const cx = pad + data.center.x * plotW;
const cy = pad + data.center.y * plotH;
// Center glow
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, 60);
grad.addColorStop(0, 'rgba(140,180,255,0.12)');
grad.addColorStop(1, 'rgba(140,180,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(cx - 60, cy - 60, 120, 120);
ctx.beginPath();
ctx.arc(cx, cy, 6, 0, Math.PI * 2);
ctx.fillStyle = '#8cb4ff';
ctx.fill();
ctx.font = `600 13px 'JetBrains Mono', monospace`;
ctx.fillStyle = '#8cb4ff';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(data.center.word, cx, cy - 14);
// Neighbor words
data.neighbors.forEach((n, i) => {
const delay = i * 0.06;
const localT = Math.max(0, Math.min(1, (t - delay) / 0.5));
if (localT <= 0) return;
const eased = 1 - Math.pow(1 - localT, 3);
const nx = pad + n.x * plotW;
const ny = pad + n.y * plotH;
// Dot
const r = 3 + n.sim * 2;
ctx.globalAlpha = eased;
ctx.beginPath();
ctx.arc(nx, ny, r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(74,222,128,${0.4 + n.sim * 0.6})`;
ctx.fill();
// Label
ctx.font = `${n.sim > 0.85 ? 500 : 400} ${n.sim > 0.85 ? 12 : 11}px 'JetBrains Mono', monospace`;
ctx.fillStyle = `rgba(74,222,128,${0.5 + n.sim * 0.5})`;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(n.word, nx, ny - 10);
// Similarity score
ctx.font = `300 9px 'JetBrains Mono', monospace`;
ctx.fillStyle = 'rgba(106,110,118,0.7)';
ctx.textBaseline = 'top';
ctx.fillText(n.sim.toFixed(2), nx, ny + 8);
ctx.globalAlpha = 1;
});
}
function animNeighbors() {
neighborAnim += 0.025;
drawNeighbors();
if (neighborAnim < 2) {
neighborAnimId = requestAnimationFrame(animNeighbors);
}
}
document.querySelectorAll('.pw').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.pw').forEach(p => p.classList.remove('selected'));
el.classList.add('selected');
activeWord = el.dataset.word;
neighborAnim = 0;
if (neighborAnimId) cancelAnimationFrame(neighborAnimId);
animNeighbors();
const insightMap = {
chicken: '<span class="hl-blue">"Chicken"</span> sits near poultry, turkey, duck: words that share cooking contexts. <span class="hl-green">"Coq au vin"</span> appears in this neighborhood because recipes link them in training data. The model learned this proximity from context, not from a food database.',
mushrooms: '<span class="hl-blue">"Mushrooms"</span> neighbors include varieties (shiitake, cremini), preparation methods (sautéed), and dishes (risotto). The model grouped them because they co-occur in the same kinds of sentences. <span class="hl-green">Proximity here means shared context.</span>',
dinner: '<span class="hl-blue">"Dinner"</span> is close to supper, meal, entrée: words that appear in similar sentence positions. <span class="hl-green">"Weeknight"</span> and <span class="hl-green">"recipe"</span> are nearby because they frequently co-occur with dinner in training text.'
};
document.getElementById('insight-neighbors').innerHTML = insightMap[activeWord];
});
});
// ═══════════════════════════════════════════
// Panel 2: Similarity Bars
// ═══════════════════════════════════════════
const candidates = [
{ name: 'coq au vin', sim: 0.91, color: 'var(--green)' },
{ name: 'chicken marsala', sim: 0.89, color: 'var(--green)' },
{ name: 'mushroom risotto', sim: 0.86, color: 'var(--green)' },
{ name: 'chicken pot pie', sim: 0.83, color: 'var(--green)' },
{ name: 'beef stroganoff', sim: 0.62, color: 'var(--yellow)' },
{ name: 'pasta primavera', sim: 0.55, color: 'var(--yellow)' },
{ name: 'chocolate cake', sim: 0.28, color: 'var(--red)' },
{ name: 'oil change guide', sim: 0.09, color: 'var(--red)' },
{ name: 'calculus homework', sim: 0.04, color: 'var(--red)' },
];
let sortMode = 'sim';
let barsAnimated = false;
function renderBars() {
const wrap = document.getElementById('sim-bars');
const sorted = [...candidates];
if (sortMode === 'sim') sorted.sort((a, b) => b.sim - a.sim);
else sorted.sort((a, b) => a.name.localeCompare(b.name));
wrap.innerHTML = sorted.map((c, i) => `
<div class="sim-row">
<span class="sim-label" style="color:${c.color}">${c.name}</span>
<div class="sim-bar-track">
<div class="sim-bar-fill" style="width:${barsAnimated ? c.sim * 100 : 0}%;background:${c.color};opacity:${0.4 + c.sim * 0.6};transition-delay:${i * 60}ms;"></div>
</div>
<span class="sim-score" style="color:${c.color}">${c.sim.toFixed(2)}</span>
</div>
`).join('');
if (!barsAnimated) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
barsAnimated = true;
renderBars();
});
});
}
}
document.getElementById('sort-sim').addEventListener('click', () => {
sortMode = 'sim';
document.getElementById('sort-sim').classList.add('active');
document.getElementById('sort-alpha').classList.remove('active');
renderBars();
});
document.getElementById('sort-alpha').addEventListener('click', () => {
sortMode = 'alpha';
document.getElementById('sort-alpha').classList.add('active');
document.getElementById('sort-sim').classList.remove('active');
renderBars();
});
// Trigger bar animation when panel becomes visible
const matchObserver = new MutationObserver(() => {
if (document.getElementById('panel-match').classList.contains('active')) {
barsAnimated = false;
renderBars();
}
});
matchObserver.observe(document.getElementById('panel-match'), { attributes: true, attributeFilter: ['class'] });
// ═══════════════════════════════════════════
// Panel 3: Directions in Space
// ═══════════════════════════════════════════
const relationships = [
{
label: 'Ingredient → Dish',
pairs: [
{ a: { word: 'chicken', x: 0.15, y: 0.25 }, b: { word: 'coq au vin', x: 0.65, y: 0.25 } },
{ a: { word: 'beef', x: 0.15, y: 0.50 }, b: { word: 'beef bourguignon', x: 0.65, y: 0.50 } },
{ a: { word: 'lamb', x: 0.15, y: 0.75 }, b: { word: 'shepherd\'s pie', x: 0.65, y: 0.75 } },
],
dirColor: 'var(--green)',
dirLabel: 'same direction: "becomes a dish"',
insight: 'The direction from <span class="hl-blue">"chicken"</span> to <span class="hl-green">"coq au vin"</span> is similar to the direction from <span class="hl-blue">"beef"</span> to <span class="hl-green">"beef bourguignon."</span> Both encode "ingredient → braised French dish." The model reuses this directional pattern to connect your prompt to dishes it was never explicitly taught to pair.',
},
{
label: 'Raw → Cooked',
pairs: [
{ a: { word: 'chicken', x: 0.15, y: 0.25 }, b: { word: 'roasted', x: 0.65, y: 0.25 } },
{ a: { word: 'mushrooms', x: 0.15, y: 0.50 }, b: { word: 'sautéed', x: 0.65, y: 0.50 } },
{ a: { word: 'onions', x: 0.15, y: 0.75 }, b: { word: 'caramelized', x: 0.65, y: 0.75 } },
],
dirColor: 'var(--yellow)',
dirLabel: 'same direction: "typical preparation"',
insight: 'The direction from <span class="hl-blue">"chicken"</span> to <span class="hl-yellow">"roasted"</span> parallels <span class="hl-blue">"mushrooms"</span> to <span class="hl-yellow">"sautéed."</span> Each pair captures an ingredient\'s default cooking method. The model learned these preparation associations from recipe text without explicit instruction.',
},
{
label: 'Ingredient → Category',
pairs: [
{ a: { word: 'chicken', x: 0.15, y: 0.25 }, b: { word: 'poultry', x: 0.65, y: 0.25 } },
{ a: { word: 'beef', x: 0.15, y: 0.50 }, b: { word: 'red meat', x: 0.65, y: 0.50 } },
{ a: { word: 'salmon', x: 0.15, y: 0.75 }, b: { word: 'seafood', x: 0.65, y: 0.75 } },
],
dirColor: 'var(--purple)',
dirLabel: 'same direction: "belongs to category"',
insight: 'The direction from <span class="hl-blue">"chicken"</span> to <span class="hl-purple">"poultry"</span> parallels <span class="hl-blue">"salmon"</span> to <span class="hl-purple">"seafood."</span> Both encode "specific item → general category." This consistent geometry lets the model reason about food groups even for ingredients it encounters in new combinations.',
}
];
let activeRel = 0;
let dirAnim = 0;
let dirAnimId = null;
const dirCanvas = document.getElementById('dirCanvas');
function drawDirections() {
const { ctx, w, h } = initCanvas(dirCanvas);
ctx.clearRect(0, 0, w, h);
const rel = relationships[activeRel];
const pad = 60;
const plotW = w - pad * 2;
const plotH = h - pad * 2;
const t = dirAnim;
rel.pairs.forEach((pair, i) => {
const delay = i * 0.15;
const localT = Math.max(0, Math.min(1, (t - delay) / 0.6));
const eased = 1 - Math.pow(1 - localT, 3);
if (eased <= 0) return;
const ax = pad + pair.a.x * plotW;
const ay = pad + pair.a.y * plotH;
const bx = pad + pair.b.x * plotW;
const by = pad + pair.b.y * plotH;
// Arrow shaft
const endX = ax + (bx - ax) * eased;
const endY = ay + (by - ay) * eased;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(endX, endY);
// Parse color to use in rgba
let arrowColor;
if (activeRel === 0) arrowColor = 'rgba(74,222,128,';
else if (activeRel === 1) arrowColor = 'rgba(240,198,116,';
else arrowColor = 'rgba(167,139,250,';
ctx.strokeStyle = arrowColor + (0.4 * eased) + ')';
ctx.lineWidth = 2;
ctx.stroke();
// Arrowhead
if (eased > 0.8) {
const headAlpha = (eased - 0.8) / 0.2;
const angle = Math.atan2(by - ay, bx - ax);
const headLen = 10;
ctx.beginPath();
ctx.moveTo(endX, endY);
ctx.lineTo(endX - headLen * Math.cos(angle - 0.3), endY - headLen * Math.sin(angle - 0.3));
ctx.lineTo(endX - headLen * Math.cos(angle + 0.3), endY - headLen * Math.sin(angle + 0.3));
ctx.closePath();
ctx.fillStyle = arrowColor + (0.5 * headAlpha) + ')';
ctx.fill();
}
// A point (ingredient)
ctx.beginPath();
ctx.arc(ax, ay, 5, 0, Math.PI * 2);
ctx.fillStyle = '#8cb4ff';
ctx.globalAlpha = eased;
ctx.fill();
ctx.globalAlpha = 1;
ctx.font = `500 12px 'JetBrains Mono', monospace`;
ctx.fillStyle = `rgba(140,180,255,${eased})`;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(pair.a.word, ax - 14, ay);
// B point (result)
if (eased > 0.5) {
const bAlpha = (eased - 0.5) / 0.5;
ctx.beginPath();
ctx.arc(bx, by, 5, 0, Math.PI * 2);
if (activeRel === 0) ctx.fillStyle = `rgba(74,222,128,${bAlpha})`;
else if (activeRel === 1) ctx.fillStyle = `rgba(240,198,116,${bAlpha})`;
else ctx.fillStyle = `rgba(167,139,250,${bAlpha})`;
ctx.fill();
ctx.font = `500 12px 'JetBrains Mono', monospace`;
ctx.fillStyle = arrowColor + bAlpha + ')';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(pair.b.word, bx + 14, by);
}
});
// Direction label
if (t > 0.5) {
const labelAlpha = Math.min(1, (t - 0.5) / 0.5);
ctx.font = `400 10px 'JetBrains Mono', monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
let labelColor;
if (activeRel === 0) labelColor = `rgba(74,222,128,${0.6 * labelAlpha})`;
else if (activeRel === 1) labelColor = `rgba(240,198,116,${0.6 * labelAlpha})`;
else labelColor = `rgba(167,139,250,${0.6 * labelAlpha})`;
ctx.fillStyle = labelColor;
ctx.fillText(rel.dirLabel, w / 2, h - 30);
}
}
function animDir() {
dirAnim += 0.02;
drawDirections();
if (dirAnim < 2.5) {
dirAnimId = requestAnimationFrame(animDir);
}
}
function setRelationship(idx) {
activeRel = idx;
document.querySelectorAll('[data-rel]').forEach(b => b.classList.toggle('active', parseInt(b.dataset.rel) === idx));
document.getElementById('insight-directions').innerHTML = relationships[idx].insight;
dirAnim = 0;
if (dirAnimId) cancelAnimationFrame(dirAnimId);
animDir();
}
document.querySelectorAll('[data-rel]').forEach(b => {
b.addEventListener('click', () => setRelationship(parseInt(b.dataset.rel)));
});
// Observe tab switch to trigger animations
const dirObserver = new MutationObserver(() => {
if (document.getElementById('panel-directions').classList.contains('active')) {
setTimeout(() => setRelationship(activeRel), 50);
}
});
dirObserver.observe(document.getElementById('panel-directions'), { attributes: true, attributeFilter: ['class'] });
// ─── Init ───
function init() {
drawNeighbors();
renderBars();
}
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
drawNeighbors();
drawDirections();
}, 150);
});
window.addEventListener('load', init);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment