The demo below builds a small random network. Each circle is a person; lines are friendships. Click Sample a node to highlight a random person (blue) and their direct friends (orange). The panel shows their degree versus the average degree of their friends.
<!-- Friendship Paradox demo: sample a node and compare its degree to its neighbors’ average degree. -->
<div class="layout">
<div class="canvas-wrap">
<canvas id="graph" width="300" height="280" title="Network graph — click a node to select it"></canvas>
</div>
<div class="panel" id="panel">
<p class="panel-hint" id="panel-hint">Click a node or press <b>Sample a node</b> to see the paradox.</p>
<div id="stats" class="stats hidden">
<div class="stat-row">
<span class="stat-label">Your degree</span>
<span class="stat-val" id="my-deg">—</span>
</div>
<div class="stat-row">
<span class="stat-label">Friends’ avg degree</span>
<span class="stat-val" id="nb-avg">—</span>
</div>
<div class="stat-row verdict" id="verdict-row">
<span id="verdict"></span>
</div>
</div>
<div id="global-stats" class="stats hidden">
<div class="stat-row">
<span class="stat-label">Mean degree (all)</span>
<span class="stat-val" id="g-mean">—</span>
</div>
<div class="stat-row">
<span class="stat-label">Mean neighbors’ avg</span>
<span class="stat-val" id="g-nb-mean">—</span>
</div>
<div class="stat-row verdict" id="global-verdict-row">
<span id="global-verdict"></span>
</div>
</div>
</div>
</div>
<div class="btns">
<button id="btn-sample" type="button">Sample a node</button>
<button id="btn-all" type="button">Show all</button>
<button id="btn-reset" type="button" class="ghost">New graph</button>
</div>
/* Styles for the friendship paradox demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.layout { display: flex; gap: .8rem; align-items: flex-start; flex-wrap: wrap; }
.canvas-wrap { flex: 0 0 auto; }
canvas { border: 1px solid #cdd9e3; border-radius: 10px; background: #f8fafc; display: block; cursor: pointer; }
.panel { flex: 1 1 160px; min-width: 140px; padding: .6rem .8rem; border: 1px solid #cdd9e3; border-radius: 10px; background: #f8fafc; }
.panel-hint { font-size: .88rem; color: #556; margin: 0; line-height: 1.4; }
.stats { display: flex; flex-direction: column; gap: .35rem; }
.stat-row { display: flex; justify-content: space-between; align-items: center; font-size: .88rem; }
.stat-label { color: #556; }
.stat-val { font-weight: 700; color: #1d3557; font-variant-numeric: tabular-nums; }
.verdict { justify-content: center; font-size: .85rem; font-weight: 600; margin-top: .2rem; }
.verdict.higher { color: #c92f3c; }
.verdict.lower { color: #0a7d33; }
.verdict.equal { color: #888; }
.hidden { display: none; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .6rem; }
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 i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Friendship paradox: the expected degree of a random edge-endpoint equals mean_deg + Var(deg)/mean_deg, which is >= mean_deg. */
const canvas = document.getElementById('graph');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
/* Generate a random graph (Erdos-Renyi style, then ensure connectivity) */
const N = 12;
let nodes, edges, adj;
function buildGraph() {
/* Place nodes on a circle */
nodes = [];
const cx = W / 2, cy = H / 2, r = Math.min(W, H) * 0.38;
for (let i = 0; i < N; i++) {
const a = (2 * Math.PI * i) / N - Math.PI / 2;
nodes.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
/* Generate edges randomly */
edges = [];
adj = Array.from({ length: N }, () => []);
for (let i = 0; i < N; i++) {
for (let j = i + 1; j < N; j++) {
/* Probability 0.28 gives a moderately sparse graph */
if (Math.random() < 0.28) {
edges.push([i, j]);
adj[i].push(j);
adj[j].push(i);
}
}
}
/* Ensure no isolated nodes */
for (let i = 0; i < N; i++) {
if (adj[i].length === 0) {
const j = (i + 1) % N;
edges.push([i, j]);
adj[i].push(j);
adj[j].push(i);
}
}
}
let selected = null; /* Currently highlighted node index */
let showAll = false; /* Whether we are showing the global view */
function degree(i) { return adj[i].length; }
function neighborAvgDeg(i) {
/* Average degree of all neighbors of node i */
if (adj[i].length === 0) return 0;
const s = adj[i].reduce((acc, j) => acc + degree(j), 0);
return s / adj[i].length;
}
function globalStats() {
/* Compute mean degree and mean neighbor-average-degree across all nodes */
const meanDeg = nodes.reduce((a, _, i) => a + degree(i), 0) / N;
const meanNbAvg = nodes.reduce((a, _, i) => a + neighborAvgDeg(i), 0) / N;
return { meanDeg, meanNbAvg };
}
function nodeColor(i) {
/* Color encoding: blue=selected, orange=friend, grey=other */
if (!showAll) {
if (i === selected) return '#1d6fa5';
if (selected !== null && adj[selected].includes(i)) return '#e07b39';
return '#b0bec5';
}
/* In ’show all’ mode: orange=friends have more (paradox), green=node has more, grey=equal */
const d = degree(i), nb = neighborAvgDeg(i);
if (nb > d) return '#e07b39';
if (nb < d) return '#2e7d32';
return '#888';
}
function draw() {
ctx.clearRect(0, 0, W, H);
/* Draw all edges */
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1.5;
for (const [a, b] of edges) {
ctx.beginPath();
ctx.moveTo(nodes[a].x, nodes[a].y);
ctx.lineTo(nodes[b].x, nodes[b].y);
ctx.stroke();
}
/* Draw each node circle */
for (let i = 0; i < N; i++) {
const { x, y } = nodes[i];
ctx.beginPath();
ctx.arc(x, y, 11, 0, 2 * Math.PI);
ctx.fillStyle = nodeColor(i);
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
/* Label each node with its degree */
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(degree(i), x, y);
}
}
function showNodeStats(i) {
const d = degree(i);
const nb = neighborAvgDeg(i).toFixed(2);
document.getElementById('my-deg').textContent = d;
document.getElementById('nb-avg').textContent = nb;
const vEl = document.getElementById('verdict');
const vRow = document.getElementById('verdict-row');
const nbNum = parseFloat(nb);
if (nbNum > d) {
vEl.textContent = i18nFmt('Friends avg %nb% > your %d% — paradox!', { nb: nb, d: d });
vRow.className = 'stat-row verdict higher';
} else if (nbNum < d) {
vEl.textContent = i18nFmt('Friends avg %nb% < your %d% — you beat it!', { nb: nb, d: d });
vRow.className = 'stat-row verdict lower';
} else {
vEl.textContent = 'Equal — you’re in a regular graph.';
vRow.className = 'stat-row verdict equal';
}
document.getElementById('panel-hint').classList.add('hidden');
document.getElementById('global-stats').classList.add('hidden');
document.getElementById('stats').classList.remove('hidden');
}
function showGlobalStats() {
const { meanDeg, meanNbAvg } = globalStats();
document.getElementById('g-mean').textContent = meanDeg.toFixed(2);
document.getElementById('g-nb-mean').textContent = meanNbAvg.toFixed(2);
const gvEl = document.getElementById('global-verdict');
const gvRow = document.getElementById('global-verdict-row');
if (meanNbAvg > meanDeg) {
gvEl.textContent = 'Paradox holds across the whole graph.';
gvRow.className = 'stat-row verdict higher';
} else {
gvEl.textContent = 'Graph is regular — no gap.';
gvRow.className = 'stat-row verdict equal';
}
document.getElementById('panel-hint').classList.add('hidden');
document.getElementById('stats').classList.add('hidden');
document.getElementById('global-stats').classList.remove('hidden');
}
document.getElementById('btn-sample').addEventListener('click', () => {
showAll = false;
selected = Math.floor(Math.random() * N);
draw();
showNodeStats(selected);
});
document.getElementById('btn-all').addEventListener('click', () => {
selected = null;
showAll = true;
draw();
showGlobalStats();
});
document.getElementById('btn-reset').addEventListener('click', () => {
selected = null;
showAll = false;
buildGraph();
draw();
document.getElementById('panel-hint').classList.remove('hidden');
document.getElementById('stats').classList.add('hidden');
document.getElementById('global-stats').classList.add('hidden');
});
canvas.addEventListener('click', (e) => {
/* Find the closest node to the click point */
showAll = false;
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (W / rect.width);
const my = (e.clientY - rect.top) * (H / rect.height);
let best = -1, bestD = Infinity;
for (let i = 0; i < N; i++) {
const dx = nodes[i].x - mx, dy = nodes[i].y - my;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < bestD) { bestD = dist; best = i; }
}
if (bestD < 20) { selected = best; draw(); showNodeStats(selected); }
});
buildGraph();
draw();
<!-- Friendship Paradox demo: sample a node and compare its degree to its neighbors’ average degree. -->
<div class="layout">
<div class="canvas-wrap">
<canvas id="graph" width="300" height="280" title="Network graph — click a node to select it"></canvas>
</div>
<div class="panel" id="panel">
<p class="panel-hint" id="panel-hint">Click a node or press <b>Sample a node</b> to see the paradox.</p>
<div id="stats" class="stats hidden">
<div class="stat-row">
<span class="stat-label">Your degree</span>
<span class="stat-val" id="my-deg">—</span>
</div>
<div class="stat-row">
<span class="stat-label">Friends’ avg degree</span>
<span class="stat-val" id="nb-avg">—</span>
</div>
<div class="stat-row verdict" id="verdict-row">
<span id="verdict"></span>
</div>
</div>
<div id="global-stats" class="stats hidden">
<div class="stat-row">
<span class="stat-label">Mean degree (all)</span>
<span class="stat-val" id="g-mean">—</span>
</div>
<div class="stat-row">
<span class="stat-label">Mean neighbors’ avg</span>
<span class="stat-val" id="g-nb-mean">—</span>
</div>
<div class="stat-row verdict" id="global-verdict-row">
<span id="global-verdict"></span>
</div>
</div>
</div>
</div>
<div class="btns">
<button id="btn-sample" type="button">Sample a node</button>
<button id="btn-all" type="button">Show all</button>
<button id="btn-reset" type="button" class="ghost">New graph</button>
</div>
/* Styles for the friendship paradox demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.layout { display: flex; gap: .8rem; align-items: flex-start; flex-wrap: wrap; }
.canvas-wrap { flex: 0 0 auto; }
canvas { border: 1px solid #cdd9e3; border-radius: 10px; background: #f8fafc; display: block; cursor: pointer; }
.panel { flex: 1 1 160px; min-width: 140px; padding: .6rem .8rem; border: 1px solid #cdd9e3; border-radius: 10px; background: #f8fafc; }
.panel-hint { font-size: .88rem; color: #556; margin: 0; line-height: 1.4; }
.stats { display: flex; flex-direction: column; gap: .35rem; }
.stat-row { display: flex; justify-content: space-between; align-items: center; font-size: .88rem; }
.stat-label { color: #556; }
.stat-val { font-weight: 700; color: #1d3557; font-variant-numeric: tabular-nums; }
.verdict { justify-content: center; font-size: .85rem; font-weight: 600; margin-top: .2rem; }
.verdict.higher { color: #c92f3c; }
.verdict.lower { color: #0a7d33; }
.verdict.equal { color: #888; }
.hidden { display: none; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .6rem; }
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 i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Friendship paradox: the expected degree of a random edge-endpoint equals mean_deg + Var(deg)/mean_deg, which is >= mean_deg. */
const canvas = document.getElementById('graph');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
/* Generate a random graph (Erdos-Renyi style, then ensure connectivity) */
const N = 12;
let nodes, edges, adj;
function buildGraph() {
/* Place nodes on a circle */
nodes = [];
const cx = W / 2, cy = H / 2, r = Math.min(W, H) * 0.38;
for (let i = 0; i < N; i++) {
const a = (2 * Math.PI * i) / N - Math.PI / 2;
nodes.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
/* Generate edges randomly */
edges = [];
adj = Array.from({ length: N }, () => []);
for (let i = 0; i < N; i++) {
for (let j = i + 1; j < N; j++) {
/* Probability 0.28 gives a moderately sparse graph */
if (Math.random() < 0.28) {
edges.push([i, j]);
adj[i].push(j);
adj[j].push(i);
}
}
}
/* Ensure no isolated nodes */
for (let i = 0; i < N; i++) {
if (adj[i].length === 0) {
const j = (i + 1) % N;
edges.push([i, j]);
adj[i].push(j);
adj[j].push(i);
}
}
}
let selected = null; /* Currently highlighted node index */
let showAll = false; /* Whether we are showing the global view */
function degree(i) { return adj[i].length; }
function neighborAvgDeg(i) {
/* Average degree of all neighbors of node i */
if (adj[i].length === 0) return 0;
const s = adj[i].reduce((acc, j) => acc + degree(j), 0);
return s / adj[i].length;
}
function globalStats() {
/* Compute mean degree and mean neighbor-average-degree across all nodes */
const meanDeg = nodes.reduce((a, _, i) => a + degree(i), 0) / N;
const meanNbAvg = nodes.reduce((a, _, i) => a + neighborAvgDeg(i), 0) / N;
return { meanDeg, meanNbAvg };
}
function nodeColor(i) {
/* Color encoding: blue=selected, orange=friend, grey=other */
if (!showAll) {
if (i === selected) return '#1d6fa5';
if (selected !== null && adj[selected].includes(i)) return '#e07b39';
return '#b0bec5';
}
/* In ’show all’ mode: orange=friends have more (paradox), green=node has more, grey=equal */
const d = degree(i), nb = neighborAvgDeg(i);
if (nb > d) return '#e07b39';
if (nb < d) return '#2e7d32';
return '#888';
}
function draw() {
ctx.clearRect(0, 0, W, H);
/* Draw all edges */
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1.5;
for (const [a, b] of edges) {
ctx.beginPath();
ctx.moveTo(nodes[a].x, nodes[a].y);
ctx.lineTo(nodes[b].x, nodes[b].y);
ctx.stroke();
}
/* Draw each node circle */
for (let i = 0; i < N; i++) {
const { x, y } = nodes[i];
ctx.beginPath();
ctx.arc(x, y, 11, 0, 2 * Math.PI);
ctx.fillStyle = nodeColor(i);
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
/* Label each node with its degree */
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(degree(i), x, y);
}
}
function showNodeStats(i) {
const d = degree(i);
const nb = neighborAvgDeg(i).toFixed(2);
document.getElementById('my-deg').textContent = d;
document.getElementById('nb-avg').textContent = nb;
const vEl = document.getElementById('verdict');
const vRow = document.getElementById('verdict-row');
const nbNum = parseFloat(nb);
if (nbNum > d) {
vEl.textContent = i18nFmt('Friends avg %nb% > your %d% — paradox!', { nb: nb, d: d });
vRow.className = 'stat-row verdict higher';
} else if (nbNum < d) {
vEl.textContent = i18nFmt('Friends avg %nb% < your %d% — you beat it!', { nb: nb, d: d });
vRow.className = 'stat-row verdict lower';
} else {
vEl.textContent = 'Equal — you’re in a regular graph.';
vRow.className = 'stat-row verdict equal';
}
document.getElementById('panel-hint').classList.add('hidden');
document.getElementById('global-stats').classList.add('hidden');
document.getElementById('stats').classList.remove('hidden');
}
function showGlobalStats() {
const { meanDeg, meanNbAvg } = globalStats();
document.getElementById('g-mean').textContent = meanDeg.toFixed(2);
document.getElementById('g-nb-mean').textContent = meanNbAvg.toFixed(2);
const gvEl = document.getElementById('global-verdict');
const gvRow = document.getElementById('global-verdict-row');
if (meanNbAvg > meanDeg) {
gvEl.textContent = 'Paradox holds across the whole graph.';
gvRow.className = 'stat-row verdict higher';
} else {
gvEl.textContent = 'Graph is regular — no gap.';
gvRow.className = 'stat-row verdict equal';
}
document.getElementById('panel-hint').classList.add('hidden');
document.getElementById('stats').classList.add('hidden');
document.getElementById('global-stats').classList.remove('hidden');
}
document.getElementById('btn-sample').addEventListener('click', () => {
showAll = false;
selected = Math.floor(Math.random() * N);
draw();
showNodeStats(selected);
});
document.getElementById('btn-all').addEventListener('click', () => {
selected = null;
showAll = true;
draw();
showGlobalStats();
});
document.getElementById('btn-reset').addEventListener('click', () => {
selected = null;
showAll = false;
buildGraph();
draw();
document.getElementById('panel-hint').classList.remove('hidden');
document.getElementById('stats').classList.add('hidden');
document.getElementById('global-stats').classList.add('hidden');
});
canvas.addEventListener('click', (e) => {
/* Find the closest node to the click point */
showAll = false;
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (W / rect.width);
const my = (e.clientY - rect.top) * (H / rect.height);
let best = -1, bestD = Infinity;
for (let i = 0; i < N; i++) {
const dx = nodes[i].x - mx, dy = nodes[i].y - my;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < bestD) { bestD = dist; best = i; }
}
if (bestD < 20) { selected = best; draw(); showNodeStats(selected); }
});
buildGraph();
draw();
Run it several times. Almost every sampled node has friends with a higher average degree. Click Show all to compute the paradox for every node at once and confirm the aggregate claim: the mean of "neighbors' average degree" beats the mean degree of nodes.
Comments
Loading comments...