<div class="controls">
<textarea id="input" rows="5" placeholder="Type or paste text here… (e.g. paste a paragraph from any book)">To be or not to be that is the question whether tis nobler in the mind to suffer the slings and arrows of outrageous fortune or to take arms against a sea of troubles and by opposing end them to die to sleep no more and by a sleep to say we end the heartache and the thousand natural shocks that flesh is heir to tis a consummation devoutly to be wished to die to sleep to sleep perchance to dream</textarea>
<button id="analyze" type="button">Analyze</button>
<button id="clear" type="button" class="ghost">Clear</button>
</div>
<div id="stats" class="stats"></div>
<canvas id="chart" width="480" height="300"></canvas>
<div id="table-wrap">
<table id="freq-table">
<thead><tr><th>Rank</th><th>Word</th><th>Count</th><th>Expected (1/rank × max)</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .7rem; }
textarea { width: 100%; border: 1px solid #cdd9e3; border-radius: 8px; padding: .5rem .7rem;
font: 14px system-ui, sans-serif; resize: vertical; color: #222; background: #f7fafc; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; align-self: flex-start; }
button.ghost { background: #fff; color: #1d3557; }
.stats { font-size: .88rem; color: #555; margin-bottom: .5rem; min-height: 1.2em; }
canvas { display: block; width: 100%; max-width: 480px; border: 1px solid #dde4eb;
border-radius: 8px; background: #f7fafc; margin-bottom: .7rem; }
#table-wrap { max-height: 160px; overflow-y: auto; border: 1px solid #dde4eb; border-radius: 8px; }
table { width: 100%; border-collapse: collapse; font-size: .82rem; }
thead th { background: #e8eef3; padding: .3rem .6rem; text-align: left; position: sticky; top: 0; }
tbody tr:nth-child(even) { background: #f7fafc; }
td { padding: .25rem .6rem; }
td.word { font-family: ui-monospace, monospace; color: #1d3557; }
td.count { color: #0a7d33; font-weight: 600; }
td.exp { color: #888; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Zipf's law demo: rank word frequencies from user text, plot log-log, fit slope.
// Core claim: in natural language the kth most common word appears ~C/k times,
// so log(count) ≈ log(C) - log(rank) → a straight line with slope -1 on a log-log plot.
const DEFAULT = document.getElementById('input').value;
function tokenize(text) {
return text.toLowerCase().match(/[a-z']+/g) || [];
}
function rankFrequency(text) {
const freq = {};
for (const w of tokenize(text)) {
if (w.length < 2) continue; // skip single-letter noise
freq[w] = (freq[w] || 0) + 1;
}
const sorted = Object.entries(freq).sort((a, b) => b[1] - a[1]);
return sorted; // [[word, count], ...]
}
// Ordinary least-squares slope on log-log data (ranks 1..N, counts c_k)
function fitSlope(pairs) {
if (pairs.length < 2) return null;
const xs = pairs.map((_, i) => Math.log(i + 1));
const ys = pairs.map(([, c]) => Math.log(c));
const n = xs.length;
const mx = xs.reduce((a, b) => a + b, 0) / n;
const my = ys.reduce((a, b) => a + b, 0) / n;
let num = 0, den = 0;
for (let i = 0; i < n; i++) {
num += (xs[i] - mx) * (ys[i] - my);
den += (xs[i] - mx) ** 2;
}
return den === 0 ? null : num / den;
}
function drawChart(pairs) {
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { left: 52, right: 20, top: 20, bottom: 42 };
ctx.clearRect(0, 0, W, H);
if (pairs.length === 0) return;
const maxRank = pairs.length;
const maxCount = pairs[0][1];
const logMaxR = Math.log10(maxRank);
const logMaxC = Math.log10(maxCount);
const logMinC = Math.log10(pairs[pairs.length - 1][1]);
const toX = (rank) => PAD.left + (Math.log10(rank) / logMaxR) * (W - PAD.left - PAD.right);
const toY = (count) => PAD.top + (1 - (Math.log10(count) - logMinC) / (logMaxC - logMinC)) * (H - PAD.top - PAD.bottom);
// Grid lines
ctx.strokeStyle = '#dde4eb';
ctx.lineWidth = 1;
for (let r = 1; r <= Math.ceil(logMaxR); r++) {
const x = toX(Math.pow(10, r));
if (x > PAD.left && x < W - PAD.right) {
ctx.beginPath(); ctx.moveTo(x, PAD.top); ctx.lineTo(x, H - PAD.bottom); ctx.stroke();
}
}
// Ideal Zipf line (slope = -1)
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(toX(1), toY(maxCount));
ctx.lineTo(toX(maxRank), toY(maxCount / maxRank));
ctx.stroke();
ctx.setLineDash([]);
// Data points
ctx.fillStyle = '#1d3557';
for (let i = 0; i < pairs.length; i++) {
const x = toX(i + 1);
const y = toY(pairs[i][1]);
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, 2 * Math.PI);
ctx.fill();
}
// Axes
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD.left, PAD.top);
ctx.lineTo(PAD.left, H - PAD.bottom);
ctx.lineTo(W - PAD.right, H - PAD.bottom);
ctx.stroke();
// Axis labels
ctx.fillStyle = '#555';
ctx.font = '11px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Rank (log scale)', PAD.left + (W - PAD.left - PAD.right) / 2, H - 6);
ctx.save();
ctx.translate(13, PAD.top + (H - PAD.top - PAD.bottom) / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('Count (log scale)', 0, 0);
ctx.restore();
// Legend
ctx.setLineDash([5, 4]);
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(W - PAD.right - 90, PAD.top + 8); ctx.lineTo(W - PAD.right - 70, PAD.top + 8); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#e63946';
ctx.textAlign = 'left';
ctx.fillText('Ideal Zipf (slope −1)', W - PAD.right - 68, PAD.top + 12);
ctx.fillStyle = '#1d3557';
ctx.beginPath(); ctx.arc(W - PAD.right - 80, PAD.top + 22, 3, 0, 2 * Math.PI); ctx.fill();
ctx.fillStyle = '#555';
ctx.fillText('Actual data', W - PAD.right - 68, PAD.top + 26);
}
function analyze() {
const text = document.getElementById('input').value.trim();
if (!text) { document.getElementById('stats').textContent = 'Please enter some text first.'; return; }
const pairs = rankFrequency(text);
if (pairs.length === 0) { document.getElementById('stats').textContent = 'No words found.'; return; }
const slope = fitSlope(pairs);
const totalTokens = pairs.reduce((s, [, c]) => s + c, 0);
document.getElementById('stats').textContent =
fmt('%totalTokens% tokens · %length% unique words · fitted slope: %v% (ideal: −1.000)', { totalTokens: totalTokens, length: pairs.length, v: slope !== null ? slope.toFixed(3) : 'n/a' });
drawChart(pairs);
// Table: top 20
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
const top = pairs.slice(0, 20);
const maxCount = top[0][1];
top.forEach(([word, count], i) => {
const exp = (maxCount / (i + 1)).toFixed(1);
const tr = document.createElement('tr');
tr.innerHTML = `<td>${i + 1}</td><td class="word">${word}</td><td class="count">${count}</td><td class="exp">${exp}</td>`;
tbody.appendChild(tr);
});
// Verify core Zipf claim: top-1 count ≥ top-2 count * 1.3 (rough check)
if (pairs.length >= 2) {
const ratio = pairs[0][1] / pairs[1][1];
console.log('Zipf rank-1/rank-2 ratio:', ratio.toFixed(3), '(expect ~2 for ideal Zipf, >1 always)');
}
}
document.getElementById('analyze').addEventListener('click', analyze);
document.getElementById('clear').addEventListener('click', () => {
document.getElementById('input').value = '';
document.getElementById('stats').textContent = '';
document.getElementById('tbody').innerHTML = '';
const canvas = document.getElementById('chart');
canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
});
// Auto-analyze the default text on load
analyze();
<div class="controls">
<textarea id="input" rows="5" placeholder="Type or paste text here… (e.g. paste a paragraph from any book)">To be or not to be that is the question whether tis nobler in the mind to suffer the slings and arrows of outrageous fortune or to take arms against a sea of troubles and by opposing end them to die to sleep no more and by a sleep to say we end the heartache and the thousand natural shocks that flesh is heir to tis a consummation devoutly to be wished to die to sleep to sleep perchance to dream</textarea>
<button id="analyze" type="button">Analyze</button>
<button id="clear" type="button" class="ghost">Clear</button>
</div>
<div id="stats" class="stats"></div>
<canvas id="chart" width="480" height="300"></canvas>
<div id="table-wrap">
<table id="freq-table">
<thead><tr><th>Rank</th><th>Word</th><th>Count</th><th>Expected (1/rank × max)</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .7rem; }
textarea { width: 100%; border: 1px solid #cdd9e3; border-radius: 8px; padding: .5rem .7rem;
font: 14px system-ui, sans-serif; resize: vertical; color: #222; background: #f7fafc; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; align-self: flex-start; }
button.ghost { background: #fff; color: #1d3557; }
.stats { font-size: .88rem; color: #555; margin-bottom: .5rem; min-height: 1.2em; }
canvas { display: block; width: 100%; max-width: 480px; border: 1px solid #dde4eb;
border-radius: 8px; background: #f7fafc; margin-bottom: .7rem; }
#table-wrap { max-height: 160px; overflow-y: auto; border: 1px solid #dde4eb; border-radius: 8px; }
table { width: 100%; border-collapse: collapse; font-size: .82rem; }
thead th { background: #e8eef3; padding: .3rem .6rem; text-align: left; position: sticky; top: 0; }
tbody tr:nth-child(even) { background: #f7fafc; }
td { padding: .25rem .6rem; }
td.word { font-family: ui-monospace, monospace; color: #1d3557; }
td.count { color: #0a7d33; font-weight: 600; }
td.exp { color: #888; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Zipf's law demo: rank word frequencies from user text, plot log-log, fit slope.
// Core claim: in natural language the kth most common word appears ~C/k times,
// so log(count) ≈ log(C) - log(rank) → a straight line with slope -1 on a log-log plot.
const DEFAULT = document.getElementById('input').value;
function tokenize(text) {
return text.toLowerCase().match(/[a-z']+/g) || [];
}
function rankFrequency(text) {
const freq = {};
for (const w of tokenize(text)) {
if (w.length < 2) continue; // skip single-letter noise
freq[w] = (freq[w] || 0) + 1;
}
const sorted = Object.entries(freq).sort((a, b) => b[1] - a[1]);
return sorted; // [[word, count], ...]
}
// Ordinary least-squares slope on log-log data (ranks 1..N, counts c_k)
function fitSlope(pairs) {
if (pairs.length < 2) return null;
const xs = pairs.map((_, i) => Math.log(i + 1));
const ys = pairs.map(([, c]) => Math.log(c));
const n = xs.length;
const mx = xs.reduce((a, b) => a + b, 0) / n;
const my = ys.reduce((a, b) => a + b, 0) / n;
let num = 0, den = 0;
for (let i = 0; i < n; i++) {
num += (xs[i] - mx) * (ys[i] - my);
den += (xs[i] - mx) ** 2;
}
return den === 0 ? null : num / den;
}
function drawChart(pairs) {
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { left: 52, right: 20, top: 20, bottom: 42 };
ctx.clearRect(0, 0, W, H);
if (pairs.length === 0) return;
const maxRank = pairs.length;
const maxCount = pairs[0][1];
const logMaxR = Math.log10(maxRank);
const logMaxC = Math.log10(maxCount);
const logMinC = Math.log10(pairs[pairs.length - 1][1]);
const toX = (rank) => PAD.left + (Math.log10(rank) / logMaxR) * (W - PAD.left - PAD.right);
const toY = (count) => PAD.top + (1 - (Math.log10(count) - logMinC) / (logMaxC - logMinC)) * (H - PAD.top - PAD.bottom);
// Grid lines
ctx.strokeStyle = '#dde4eb';
ctx.lineWidth = 1;
for (let r = 1; r <= Math.ceil(logMaxR); r++) {
const x = toX(Math.pow(10, r));
if (x > PAD.left && x < W - PAD.right) {
ctx.beginPath(); ctx.moveTo(x, PAD.top); ctx.lineTo(x, H - PAD.bottom); ctx.stroke();
}
}
// Ideal Zipf line (slope = -1)
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(toX(1), toY(maxCount));
ctx.lineTo(toX(maxRank), toY(maxCount / maxRank));
ctx.stroke();
ctx.setLineDash([]);
// Data points
ctx.fillStyle = '#1d3557';
for (let i = 0; i < pairs.length; i++) {
const x = toX(i + 1);
const y = toY(pairs[i][1]);
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, 2 * Math.PI);
ctx.fill();
}
// Axes
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD.left, PAD.top);
ctx.lineTo(PAD.left, H - PAD.bottom);
ctx.lineTo(W - PAD.right, H - PAD.bottom);
ctx.stroke();
// Axis labels
ctx.fillStyle = '#555';
ctx.font = '11px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Rank (log scale)', PAD.left + (W - PAD.left - PAD.right) / 2, H - 6);
ctx.save();
ctx.translate(13, PAD.top + (H - PAD.top - PAD.bottom) / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('Count (log scale)', 0, 0);
ctx.restore();
// Legend
ctx.setLineDash([5, 4]);
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(W - PAD.right - 90, PAD.top + 8); ctx.lineTo(W - PAD.right - 70, PAD.top + 8); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#e63946';
ctx.textAlign = 'left';
ctx.fillText('Ideal Zipf (slope −1)', W - PAD.right - 68, PAD.top + 12);
ctx.fillStyle = '#1d3557';
ctx.beginPath(); ctx.arc(W - PAD.right - 80, PAD.top + 22, 3, 0, 2 * Math.PI); ctx.fill();
ctx.fillStyle = '#555';
ctx.fillText('Actual data', W - PAD.right - 68, PAD.top + 26);
}
function analyze() {
const text = document.getElementById('input').value.trim();
if (!text) { document.getElementById('stats').textContent = 'Please enter some text first.'; return; }
const pairs = rankFrequency(text);
if (pairs.length === 0) { document.getElementById('stats').textContent = 'No words found.'; return; }
const slope = fitSlope(pairs);
const totalTokens = pairs.reduce((s, [, c]) => s + c, 0);
document.getElementById('stats').textContent =
fmt('%totalTokens% tokens · %length% unique words · fitted slope: %v% (ideal: −1.000)', { totalTokens: totalTokens, length: pairs.length, v: slope !== null ? slope.toFixed(3) : 'n/a' });
drawChart(pairs);
// Table: top 20
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
const top = pairs.slice(0, 20);
const maxCount = top[0][1];
top.forEach(([word, count], i) => {
const exp = (maxCount / (i + 1)).toFixed(1);
const tr = document.createElement('tr');
tr.innerHTML = `<td>${i + 1}</td><td class="word">${word}</td><td class="count">${count}</td><td class="exp">${exp}</td>`;
tbody.appendChild(tr);
});
// Verify core Zipf claim: top-1 count ≥ top-2 count * 1.3 (rough check)
if (pairs.length >= 2) {
const ratio = pairs[0][1] / pairs[1][1];
console.log('Zipf rank-1/rank-2 ratio:', ratio.toFixed(3), '(expect ~2 for ideal Zipf, >1 always)');
}
}
document.getElementById('analyze').addEventListener('click', analyze);
document.getElementById('clear').addEventListener('click', () => {
document.getElementById('input').value = '';
document.getElementById('stats').textContent = '';
document.getElementById('tbody').innerHTML = '';
const canvas = document.getElementById('chart');
canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
});
// Auto-analyze the default text on load
analyze();
Try short texts (a few sentences) and long ones (paste a whole paragraph). Notice how the straight line emerges more clearly the more text you add. The slope shown tells you how close reality is to the ideal −1. Natural language tends to land between −0.8 and −1.2.
Comments
Loading comments...