Start with a tiny seed network and add nodes one at a time. Each new node throws m edges to existing nodes — either choosing them proportional to their current degree (preferential attachment) or uniformly at random (random growth). Watch what happens.
<p class="hint">Each new node connects to <b>m = 2</b> existing nodes. Toggle the mode to compare <b>preferential attachment</b> (proportional to degree) versus <b>random growth</b> (uniform). Click <b>Step</b> to add one node, or <b>Run 20</b> to add a batch.</p>
<div class="controls">
<label class="toggle-label">
<span id="mode-label">Mode: <b>Preferential</b></span>
<button id="toggle-mode" type="button">Switch to Random</button>
</label>
<div class="btn-row">
<button id="step" type="button">Step (+1 node)</button>
<button id="run20" type="button">Run 20</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
</div>
<canvas id="net" width="420" height="220"></canvas>
<div class="stat-row">
<span id="info">Nodes: 4 | Edges: 4</span>
<span id="hub-info"></span>
</div>
<canvas id="hist" width="420" height="90"></canvas>
<p class="hist-label">Degree distribution (log–log scale)</p>
* { 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; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .5rem; }
.toggle-label { display: flex; align-items: center; gap: .7rem; font-size: .9rem; }
.btn-row { display: flex; gap: .4rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
#toggle-mode { font-size: 12px; padding: .28rem .65rem; background: #457b9d; border-color: #457b9d; }
canvas { display: block; border-radius: 8px; background: #f4f7fa;
border: 1px solid #dce3ea; max-width: 100%; }
#hist { margin-top: .4rem; background: #fff; }
.stat-row { display: flex; justify-content: space-between; font-size: .82rem;
color: #555; margin: .3rem 0 .2rem; flex-wrap: wrap; gap: .3rem; }
#hub-info { color: #c92f3c; font-weight: 600; }
.hist-label { font-size: .75rem; color: #888; margin: .2rem 0 0; text-align: center; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Barabási-Albert preferential attachment model
// Nodes stored as {id, x, y, deg}. Edges as [i, j].
// PA: new node connects proportional to degree.
// Random: new node connects uniformly.
const M = 2; // edges per new node
let nodes = [], edges = [], preferential = true;
const netC = document.getElementById('net');
const histC = document.getElementById('hist');
const nCtx = netC.getContext('2d');
const hCtx = histC.getContext('2d');
const W = netC.width, H = netC.height;
function init() {
nodes = []; edges = [];
// seed: 4-clique
for (let i = 0; i < 4; i++) {
const angle = (i / 4) * 2 * Math.PI;
nodes.push({ id: i, x: W/2 + 80*Math.cos(angle), y: H/2 + 60*Math.sin(angle), deg: 0 });
}
for (let i = 0; i < 4; i++) for (let j = i+1; j < 4; j++) {
edges.push([i, j]); nodes[i].deg++; nodes[j].deg++;
}
update();
}
function totalDeg() { return nodes.reduce((s,n) => s + n.deg, 0); }
function chooseTarget(exclude) {
const td = totalDeg();
if (preferential) {
let r = Math.random() * td;
for (const n of nodes) {
if (exclude.has(n.id)) continue;
r -= n.deg; if (r <= 0) return n.id;
}
// fallback
return nodes.find(n => !exclude.has(n.id)).id;
} else {
const pool = nodes.filter(n => !exclude.has(n.id));
return pool[Math.floor(Math.random() * pool.length)].id;
}
}
function addNode() {
const newId = nodes.length;
// layout: random position biased toward center
const angle = Math.random() * 2 * Math.PI;
const r = 30 + Math.random() * Math.min(W, H) * 0.35;
nodes.push({ id: newId, x: W/2 + r*Math.cos(angle), y: H/2 + r*Math.sin(angle), deg: 0 });
const chosen = new Set();
for (let i = 0; i < M && chosen.size < nodes.length - 1; i++) {
const t = chooseTarget(chosen);
chosen.add(t);
edges.push([newId, t]);
nodes[newId].deg++; nodes[t].deg++;
}
// gentle force-directed nudge to reduce overlap
relaxLayout(3);
update();
}
function relaxLayout(iters) {
for (let it = 0; it < iters; it++) {
for (let i = 0; i < nodes.length; i++) {
let fx = 0, fy = 0;
for (let j = 0; j < nodes.length; j++) {
if (i === j) continue;
const dx = nodes[i].x - nodes[j].x, dy = nodes[i].y - nodes[j].y;
const d2 = dx*dx + dy*dy + 1;
const rep = 600 / d2;
fx += rep * dx / Math.sqrt(d2);
fy += rep * dy / Math.sqrt(d2);
}
nodes[i].x = Math.max(10, Math.min(W-10, nodes[i].x + fx*0.1));
nodes[i].y = Math.max(10, Math.min(H-10, nodes[i].y + fy*0.1));
}
}
}
function nodeColor(deg) {
// blue for low degree, red for hubs
const maxDeg = Math.max(...nodes.map(n => n.deg), 1);
const t = deg / maxDeg;
const r = Math.round(30 + t * 200);
const g = Math.round(100 - t * 70);
const b = Math.round(200 - t * 170);
return `rgb(${r},${g},${b})`;
}
function drawNetwork() {
nCtx.clearRect(0, 0, W, H);
// edges
nCtx.strokeStyle = '#aabbcc';
nCtx.lineWidth = 0.8;
for (const [a, b] of edges) {
nCtx.beginPath();
nCtx.moveTo(nodes[a].x, nodes[a].y);
nCtx.lineTo(nodes[b].x, nodes[b].y);
nCtx.stroke();
}
// nodes
for (const n of nodes) {
const r = 3 + Math.sqrt(n.deg) * 1.2;
nCtx.beginPath();
nCtx.arc(n.x, n.y, r, 0, 2*Math.PI);
nCtx.fillStyle = nodeColor(n.deg);
nCtx.fill();
}
}
function drawHistogram() {
hCtx.clearRect(0, 0, histC.width, histC.height);
if (nodes.length < 5) return;
const maxDeg = Math.max(...nodes.map(n => n.deg));
// build degree counts
const counts = {};
for (const n of nodes) counts[n.deg] = (counts[n.deg]||0) + 1;
const Hw = histC.width, Hh = histC.height;
const pad = { l: 28, r: 8, t: 8, b: 20 };
const pw = Hw - pad.l - pad.r, ph = Hh - pad.t - pad.b;
// log-log axes
const minK = 1, maxK = Math.max(maxDeg, 2);
const logKMin = Math.log10(minK), logKMax = Math.log10(maxK);
const maxCnt = Math.max(...Object.values(counts));
const logCMin = 0, logCMax = Math.log10(maxCnt + 1);
const kToX = k => pad.l + (Math.log10(k) - logKMin) / (logKMax - logKMin + 0.01) * pw;
const cToY = c => pad.t + ph - (Math.log10(c + 0.1) - logCMin) / (logCMax - logCMin + 0.01) * ph;
// axes
hCtx.strokeStyle = '#999'; hCtx.lineWidth = 0.8;
hCtx.beginPath(); hCtx.moveTo(pad.l, pad.t); hCtx.lineTo(pad.l, pad.t+ph); hCtx.stroke();
hCtx.beginPath(); hCtx.moveTo(pad.l, pad.t+ph); hCtx.lineTo(pad.l+pw, pad.t+ph); hCtx.stroke();
// axis labels
hCtx.fillStyle = '#888'; hCtx.font = '9px system-ui'; hCtx.textAlign = 'center';
hCtx.fillText('degree k', pad.l + pw/2, Hh - 2);
hCtx.save(); hCtx.translate(10, pad.t + ph/2); hCtx.rotate(-Math.PI/2);
hCtx.fillText('count', 0, 0); hCtx.restore();
// dots
hCtx.fillStyle = preferential ? '#e63946' : '#457b9d';
for (const [k, c] of Object.entries(counts)) {
const x = kToX(+k), y = cToY(c);
hCtx.beginPath(); hCtx.arc(x, y, 3, 0, 2*Math.PI); hCtx.fill();
}
// power-law reference line: slope -3
hCtx.strokeStyle = '#bbb'; hCtx.lineWidth = 0.7;
hCtx.setLineDash([3, 3]);
hCtx.beginPath();
const x0 = pad.l, x1 = pad.l + pw;
const k0 = Math.pow(10, logKMin), k1 = Math.pow(10, logKMax);
// normalize so it passes through the first data point roughly
const firstK = +Object.keys(counts).sort((a,b)=>+a-+b)[0];
const firstC = counts[firstK];
const norm = firstC * Math.pow(firstK, 3);
hCtx.moveTo(kToX(k0), cToY(norm * Math.pow(k0, -3)));
hCtx.lineTo(kToX(k1), cToY(norm * Math.pow(k1, -3)));
hCtx.stroke();
hCtx.setLineDash([]);
// label
hCtx.fillStyle = '#bbb'; hCtx.font = '8px system-ui';
hCtx.textAlign = 'right';
hCtx.fillText('k⁻³', pad.l + pw - 2, pad.t + 14);
}
function update() {
drawNetwork();
drawHistogram();
const maxDeg = Math.max(...nodes.map(n => n.deg));
const hub = nodes.find(n => n.deg === maxDeg);
document.getElementById('info').textContent = fmt('Nodes: %length% | Edges: %length2%', { length: nodes.length, length2: edges.length });
document.getElementById('hub-info').textContent = fmt('Top hub: degree %maxDeg%', { maxDeg: maxDeg });
}
document.getElementById('step').addEventListener('click', addNode);
document.getElementById('run20').addEventListener('click', () => {
for (let i = 0; i < 20; i++) addNode();
});
document.getElementById('reset').addEventListener('click', init);
document.getElementById('toggle-mode').addEventListener('click', () => {
preferential = !preferential;
document.getElementById('mode-label').innerHTML =
fmt('Mode: <b>%v%</b>', { v: preferential ? 'Preferential' : 'Random' });
document.getElementById('toggle-mode').textContent =
preferential ? 'Switch to Random' : 'Switch to Preferential';
init();
});
init();
// Verify: after 50 steps with PA, top hub degree should be >> average
// This is structural — the demo self-verifies visually.
// Node-level check: total degree = 2 * edges always.
console.assert(
(() => { const td = nodes.reduce((s,n)=>s+n.deg,0); return td === 2*edges.length; })(),
'Degree sum invariant holds'
);
<p class="hint">Each new node connects to <b>m = 2</b> existing nodes. Toggle the mode to compare <b>preferential attachment</b> (proportional to degree) versus <b>random growth</b> (uniform). Click <b>Step</b> to add one node, or <b>Run 20</b> to add a batch.</p>
<div class="controls">
<label class="toggle-label">
<span id="mode-label">Mode: <b>Preferential</b></span>
<button id="toggle-mode" type="button">Switch to Random</button>
</label>
<div class="btn-row">
<button id="step" type="button">Step (+1 node)</button>
<button id="run20" type="button">Run 20</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
</div>
<canvas id="net" width="420" height="220"></canvas>
<div class="stat-row">
<span id="info">Nodes: 4 | Edges: 4</span>
<span id="hub-info"></span>
</div>
<canvas id="hist" width="420" height="90"></canvas>
<p class="hist-label">Degree distribution (log–log scale)</p>
* { 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; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .5rem; }
.toggle-label { display: flex; align-items: center; gap: .7rem; font-size: .9rem; }
.btn-row { display: flex; gap: .4rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
#toggle-mode { font-size: 12px; padding: .28rem .65rem; background: #457b9d; border-color: #457b9d; }
canvas { display: block; border-radius: 8px; background: #f4f7fa;
border: 1px solid #dce3ea; max-width: 100%; }
#hist { margin-top: .4rem; background: #fff; }
.stat-row { display: flex; justify-content: space-between; font-size: .82rem;
color: #555; margin: .3rem 0 .2rem; flex-wrap: wrap; gap: .3rem; }
#hub-info { color: #c92f3c; font-weight: 600; }
.hist-label { font-size: .75rem; color: #888; margin: .2rem 0 0; text-align: center; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Barabási-Albert preferential attachment model
// Nodes stored as {id, x, y, deg}. Edges as [i, j].
// PA: new node connects proportional to degree.
// Random: new node connects uniformly.
const M = 2; // edges per new node
let nodes = [], edges = [], preferential = true;
const netC = document.getElementById('net');
const histC = document.getElementById('hist');
const nCtx = netC.getContext('2d');
const hCtx = histC.getContext('2d');
const W = netC.width, H = netC.height;
function init() {
nodes = []; edges = [];
// seed: 4-clique
for (let i = 0; i < 4; i++) {
const angle = (i / 4) * 2 * Math.PI;
nodes.push({ id: i, x: W/2 + 80*Math.cos(angle), y: H/2 + 60*Math.sin(angle), deg: 0 });
}
for (let i = 0; i < 4; i++) for (let j = i+1; j < 4; j++) {
edges.push([i, j]); nodes[i].deg++; nodes[j].deg++;
}
update();
}
function totalDeg() { return nodes.reduce((s,n) => s + n.deg, 0); }
function chooseTarget(exclude) {
const td = totalDeg();
if (preferential) {
let r = Math.random() * td;
for (const n of nodes) {
if (exclude.has(n.id)) continue;
r -= n.deg; if (r <= 0) return n.id;
}
// fallback
return nodes.find(n => !exclude.has(n.id)).id;
} else {
const pool = nodes.filter(n => !exclude.has(n.id));
return pool[Math.floor(Math.random() * pool.length)].id;
}
}
function addNode() {
const newId = nodes.length;
// layout: random position biased toward center
const angle = Math.random() * 2 * Math.PI;
const r = 30 + Math.random() * Math.min(W, H) * 0.35;
nodes.push({ id: newId, x: W/2 + r*Math.cos(angle), y: H/2 + r*Math.sin(angle), deg: 0 });
const chosen = new Set();
for (let i = 0; i < M && chosen.size < nodes.length - 1; i++) {
const t = chooseTarget(chosen);
chosen.add(t);
edges.push([newId, t]);
nodes[newId].deg++; nodes[t].deg++;
}
// gentle force-directed nudge to reduce overlap
relaxLayout(3);
update();
}
function relaxLayout(iters) {
for (let it = 0; it < iters; it++) {
for (let i = 0; i < nodes.length; i++) {
let fx = 0, fy = 0;
for (let j = 0; j < nodes.length; j++) {
if (i === j) continue;
const dx = nodes[i].x - nodes[j].x, dy = nodes[i].y - nodes[j].y;
const d2 = dx*dx + dy*dy + 1;
const rep = 600 / d2;
fx += rep * dx / Math.sqrt(d2);
fy += rep * dy / Math.sqrt(d2);
}
nodes[i].x = Math.max(10, Math.min(W-10, nodes[i].x + fx*0.1));
nodes[i].y = Math.max(10, Math.min(H-10, nodes[i].y + fy*0.1));
}
}
}
function nodeColor(deg) {
// blue for low degree, red for hubs
const maxDeg = Math.max(...nodes.map(n => n.deg), 1);
const t = deg / maxDeg;
const r = Math.round(30 + t * 200);
const g = Math.round(100 - t * 70);
const b = Math.round(200 - t * 170);
return `rgb(${r},${g},${b})`;
}
function drawNetwork() {
nCtx.clearRect(0, 0, W, H);
// edges
nCtx.strokeStyle = '#aabbcc';
nCtx.lineWidth = 0.8;
for (const [a, b] of edges) {
nCtx.beginPath();
nCtx.moveTo(nodes[a].x, nodes[a].y);
nCtx.lineTo(nodes[b].x, nodes[b].y);
nCtx.stroke();
}
// nodes
for (const n of nodes) {
const r = 3 + Math.sqrt(n.deg) * 1.2;
nCtx.beginPath();
nCtx.arc(n.x, n.y, r, 0, 2*Math.PI);
nCtx.fillStyle = nodeColor(n.deg);
nCtx.fill();
}
}
function drawHistogram() {
hCtx.clearRect(0, 0, histC.width, histC.height);
if (nodes.length < 5) return;
const maxDeg = Math.max(...nodes.map(n => n.deg));
// build degree counts
const counts = {};
for (const n of nodes) counts[n.deg] = (counts[n.deg]||0) + 1;
const Hw = histC.width, Hh = histC.height;
const pad = { l: 28, r: 8, t: 8, b: 20 };
const pw = Hw - pad.l - pad.r, ph = Hh - pad.t - pad.b;
// log-log axes
const minK = 1, maxK = Math.max(maxDeg, 2);
const logKMin = Math.log10(minK), logKMax = Math.log10(maxK);
const maxCnt = Math.max(...Object.values(counts));
const logCMin = 0, logCMax = Math.log10(maxCnt + 1);
const kToX = k => pad.l + (Math.log10(k) - logKMin) / (logKMax - logKMin + 0.01) * pw;
const cToY = c => pad.t + ph - (Math.log10(c + 0.1) - logCMin) / (logCMax - logCMin + 0.01) * ph;
// axes
hCtx.strokeStyle = '#999'; hCtx.lineWidth = 0.8;
hCtx.beginPath(); hCtx.moveTo(pad.l, pad.t); hCtx.lineTo(pad.l, pad.t+ph); hCtx.stroke();
hCtx.beginPath(); hCtx.moveTo(pad.l, pad.t+ph); hCtx.lineTo(pad.l+pw, pad.t+ph); hCtx.stroke();
// axis labels
hCtx.fillStyle = '#888'; hCtx.font = '9px system-ui'; hCtx.textAlign = 'center';
hCtx.fillText('degree k', pad.l + pw/2, Hh - 2);
hCtx.save(); hCtx.translate(10, pad.t + ph/2); hCtx.rotate(-Math.PI/2);
hCtx.fillText('count', 0, 0); hCtx.restore();
// dots
hCtx.fillStyle = preferential ? '#e63946' : '#457b9d';
for (const [k, c] of Object.entries(counts)) {
const x = kToX(+k), y = cToY(c);
hCtx.beginPath(); hCtx.arc(x, y, 3, 0, 2*Math.PI); hCtx.fill();
}
// power-law reference line: slope -3
hCtx.strokeStyle = '#bbb'; hCtx.lineWidth = 0.7;
hCtx.setLineDash([3, 3]);
hCtx.beginPath();
const x0 = pad.l, x1 = pad.l + pw;
const k0 = Math.pow(10, logKMin), k1 = Math.pow(10, logKMax);
// normalize so it passes through the first data point roughly
const firstK = +Object.keys(counts).sort((a,b)=>+a-+b)[0];
const firstC = counts[firstK];
const norm = firstC * Math.pow(firstK, 3);
hCtx.moveTo(kToX(k0), cToY(norm * Math.pow(k0, -3)));
hCtx.lineTo(kToX(k1), cToY(norm * Math.pow(k1, -3)));
hCtx.stroke();
hCtx.setLineDash([]);
// label
hCtx.fillStyle = '#bbb'; hCtx.font = '8px system-ui';
hCtx.textAlign = 'right';
hCtx.fillText('k⁻³', pad.l + pw - 2, pad.t + 14);
}
function update() {
drawNetwork();
drawHistogram();
const maxDeg = Math.max(...nodes.map(n => n.deg));
const hub = nodes.find(n => n.deg === maxDeg);
document.getElementById('info').textContent = fmt('Nodes: %length% | Edges: %length2%', { length: nodes.length, length2: edges.length });
document.getElementById('hub-info').textContent = fmt('Top hub: degree %maxDeg%', { maxDeg: maxDeg });
}
document.getElementById('step').addEventListener('click', addNode);
document.getElementById('run20').addEventListener('click', () => {
for (let i = 0; i < 20; i++) addNode();
});
document.getElementById('reset').addEventListener('click', init);
document.getElementById('toggle-mode').addEventListener('click', () => {
preferential = !preferential;
document.getElementById('mode-label').innerHTML =
fmt('Mode: <b>%v%</b>', { v: preferential ? 'Preferential' : 'Random' });
document.getElementById('toggle-mode').textContent =
preferential ? 'Switch to Random' : 'Switch to Preferential';
init();
});
init();
// Verify: after 50 steps with PA, top hub degree should be >> average
// This is structural — the demo self-verifies visually.
// Node-level check: total degree = 2 * edges always.
console.assert(
(() => { const td = nodes.reduce((s,n)=>s+n.deg,0); return td === 2*edges.length; })(),
'Degree sum invariant holds'
);
With preferential attachment, a few nodes quickly pull away from the pack and become hubs. With random growth, degrees stay roughly equal. The degree histogram (bottom panel) tells the story: preferential attachment produces a heavy tail — many nodes with 1–2 connections, a handful with tens — while random growth stays narrow and symmetric.
Comments
Loading comments...