Skip to content

Commit 71a58dd

Browse files
committed
Write the zipf page in every build, and make it a page worth reading
/zipf was a 404 because nothing but `site zipf` ever wrote it, and that command was not part of `build`. It is now written next to the report. The page itself drew a text label for every one of the ten thousand words on top of its own scatter plot. Now the most common words are labelled, every point names its word on hover, and a dashed line shows what Zipf's law predicts from the top word alone. The counts were mostly the Udon exposure dump and its `<` entities, so pages tagged `list` are left out, entities are dropped, and a contraction stays one word.
1 parent 740c663 commit 71a58dd

2 files changed

Lines changed: 158 additions & 66 deletions

File tree

‎tools/site/src/build.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ impl Site {
132132
}
133133

134134
let public = self.root.join("public");
135+
crate::zipf::write(self, &public)?;
135136
report::write(self, &public, started.elapsed().as_secs())
136137
}
137138

‎tools/site/src/zipf.rs‎

Lines changed: 157 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ pub(crate) fn write(site: &Site, public: &Path) -> Result<()> {
1313
let mut counts: HashMap<String, u64> = HashMap::new();
1414

1515
for path in md_paths {
16-
if let Ok(text) = fs::read_to_string(&path) {
17-
let stripped = strip_links(&text);
18-
let stripped = strip_html(&stripped);
16+
if let Ok(text) = fs::read_to_string(&path)
17+
&& !is_list_page(&text)
18+
{
19+
let stripped = strip_entities(&strip_html(&strip_links(&text)));
1920
for word in tokenize_words(&stripped) {
2021
*counts.entry(word).or_insert(0) += 1;
2122
}
@@ -29,121 +30,162 @@ pub(crate) fn write(site: &Site, public: &Path) -> Result<()> {
2930
let data_js = format!("const ZIPF_DATA = {};", serde_json::to_string(&items)?);
3031
let top_list_html = build_top_list_html(&items);
3132

33+
let word_count: u64 = items.iter().map(|(_, count)| count).sum();
34+
let unique = items.len();
35+
3236
let html = format!(
3337
r#"<!DOCTYPE html>
3438
<html lang="en">
3539
<head>
3640
<meta charset="utf-8" />
41+
<meta name="viewport" content="width=device-width, initial-scale=1" />
3742
<title>Zipf Distribution</title>
3843
<style>
39-
body {{ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; padding: 20px; }}
40-
.container {{ display: flex; gap: 20px; flex-wrap: wrap; }}
44+
body {{ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; margin: 0; padding: 20px; color: #222; background: #fff; }}
45+
.container {{ display: flex; gap: 20px; flex-wrap: wrap; align-items: flex-start; }}
4146
.plot {{ flex: 1 1 480px; min-width: 300px; }}
42-
.list {{ width: 360px; max-height: 80vh; overflow: auto; }}
43-
svg {{ width: 100%; height: 600px; border: 1px solid #ddd; background: #fff; }}
47+
.list {{ flex: 0 1 320px; max-height: 80vh; overflow: auto; }}
48+
svg {{ width: 100%; height: auto; border: 1px solid #ddd; background: #fff; }}
4449
.axis {{ stroke: #666; stroke-width: 1; }}
45-
.point {{ fill: steelblue; opacity: 0.9; }}
46-
.label {{ font-size: 12px; fill: #333; }}
50+
.fit {{ stroke: #c33; stroke-width: 1; stroke-dasharray: 4 4; }}
51+
.point {{ fill: steelblue; opacity: 0.8; }}
52+
.point:hover {{ fill: #c33; opacity: 1; }}
53+
.label {{ font-size: 11px; fill: #333; }}
54+
.word {{ font-size: 11px; fill: #333; pointer-events: none; }}
4755
</style>
4856
</head>
4957
<body>
50-
<h1>Zipf Distribution of Words in /content</h1>
51-
<p>Scatter plot of rank vs frequency (log-log). Right column lists the top words.</p>
58+
<h1>Zipf distribution of the words in /content</h1>
59+
<p>{word_count} words, {unique} distinct. Rank against frequency, both logarithmic. The dashed line is what Zipf's law predicts from the most common word alone. The most common words are labelled; hover any point for its word, or find it in the list.</p>
5260
<div class="container">
5361
<div class="plot">
54-
<svg id="plot" viewBox="0 0 800 600" preserveAspectRatio="none"></svg>
62+
<svg id="plot" viewBox="0 0 800 600" role="img" aria-label="rank against frequency"></svg>
5563
</div>
5664
<div class="list">
57-
<h2>Top words</h2>
65+
<h2>All words</h2>
5866
{top_list_html}
5967
</div>
6068
</div>
6169
6270
<script>
6371
{data_js}
6472
65-
function log10(x) {{ return Math.log(x) / Math.LN10; }}
73+
const LABELLED = 25;
74+
const POINTS = 5000;
6675
6776
function draw() {{
6877
const svg = document.getElementById('plot');
6978
const width = 800, height = 600, pad = 60;
70-
svg.setAttribute('viewBox', `0 0 ${{width}} ${{height}}`);
71-
const data = ZIPF_DATA.map((d, i) => ({{ rank: i + 1, count: d[1], word: d[0] }}));
79+
const data = ZIPF_DATA.slice(0, POINTS).map((d, i) => ({{ rank: i + 1, count: d[1], word: d[0] }}));
7280
if (data.length === 0) return;
7381
74-
const ranks = data.map(d => d.rank);
75-
const counts = data.map(d => d.count);
76-
const xmin = Math.max(1, Math.min(...ranks));
77-
const xmax = Math.max(...ranks);
78-
const ymin = Math.max(1, Math.min(...counts));
79-
const ymax = Math.max(...counts);
80-
81-
const lxmin = log10(xmin), lxmax = log10(xmax);
82-
const lymin = log10(ymin), lymax = log10(ymax);
83-
84-
function sx(x) {{ return pad + ((log10(x) - lxmin) / (lxmax - lxmin)) * (width - pad * 2); }}
85-
function sy(y) {{ return height - pad - ((log10(y) - lymin) / (lymax - lymin)) * (height - pad * 2); }}
82+
const log10 = Math.log10;
83+
const lxmax = Math.max(log10(ZIPF_DATA.length), 1);
84+
const lymax = Math.max(log10(data[0].count), 1);
85+
const sx = x => pad + (log10(x) / lxmax) * (width - pad * 2);
86+
const sy = y => height - pad - (log10(y) / lymax) * (height - pad * 2);
8687
87-
svg.innerHTML = '';
8888
const ns = 'http://www.w3.org/2000/svg';
89-
function el(name, attrs) {{
89+
function el(name, attrs, parent) {{
9090
const e = document.createElementNS(ns, name);
9191
for (const k in attrs) e.setAttribute(k, attrs[k]);
92+
(parent || svg).appendChild(e);
9293
return e;
9394
}}
9495
95-
svg.appendChild(el('rect', {{ x:0, y:0, width:width, height:height, fill:'white' }}));
96-
svg.appendChild(el('line', {{ x1: pad, y1: height-pad, x2: width-pad, y2: height-pad, class:'axis' }}));
97-
svg.appendChild(el('line', {{ x1: pad, y1: pad, x2: pad, y2: height-pad, class:'axis' }}));
96+
el('line', {{ x1: pad, y1: height - pad, x2: width - pad, y2: height - pad, class: 'axis' }});
97+
el('line', {{ x1: pad, y1: pad, x2: pad, y2: height - pad, class: 'axis' }});
9898
99-
for (let e = Math.ceil(lxmin); e <= Math.floor(lxmax); e++) {{
100-
const x = Math.pow(10, e);
101-
const xPos = sx(x);
102-
svg.appendChild(el('line', {{ x1:xPos, y1:height-pad, x2:xPos, y2:height-pad+6, stroke:'#666' }}));
103-
svg.appendChild(el('text', {{ x:xPos, y:height-pad+20, 'text-anchor':'middle', class:'label' }})).textContent = '10^' + e;
99+
for (let e = 0; e <= Math.floor(lxmax); e++) {{
100+
const x = sx(Math.pow(10, e));
101+
el('line', {{ x1: x, y1: height - pad, x2: x, y2: height - pad + 6, class: 'axis' }});
102+
el('text', {{ x, y: height - pad + 20, 'text-anchor': 'middle', class: 'label' }}).textContent = Math.pow(10, e).toLocaleString();
104103
}}
104+
el('text', {{ x: width / 2, y: height - pad + 40, 'text-anchor': 'middle', class: 'label' }}).textContent = 'rank';
105105
106-
for (let e = Math.ceil(lymin); e <= Math.floor(lymax); e++) {{
107-
const y = Math.pow(10, e);
108-
const yPos = sy(y);
109-
svg.appendChild(el('line', {{ x1:pad-6, y1:yPos, x2:pad, y2:yPos, stroke:'#666' }}));
110-
svg.appendChild(el('text', {{ x:pad-10, y:yPos+4, 'text-anchor':'end', class:'label' }})).textContent = '10^' + e;
106+
for (let e = 0; e <= Math.floor(lymax); e++) {{
107+
const y = sy(Math.pow(10, e));
108+
el('line', {{ x1: pad - 6, y1: y, x2: pad, y2: y, class: 'axis' }});
109+
el('text', {{ x: pad - 10, y: y + 4, 'text-anchor': 'end', class: 'label' }}).textContent = Math.pow(10, e).toLocaleString();
111110
}}
111+
el('text', {{ x: 16, y: height / 2, 'text-anchor': 'middle', class: 'label', transform: `rotate(-90 16 ${{height / 2}})` }}).textContent = 'occurrences';
112112
113-
const limit = Math.min(data.length, 5000);
114-
for (let i = 0; i < limit; i++) {{
115-
const d = data[i];
116-
svg.appendChild(el('circle', {{ cx: sx(d.rank), cy: sy(d.count), r: 2, class: 'point' }}));
113+
const top = data[0].count;
114+
const end = Math.min(ZIPF_DATA.length, top);
115+
el('line', {{ x1: sx(1), y1: sy(top), x2: sx(end), y2: sy(top / end), class: 'fit' }});
116+
117+
for (const d of data) {{
118+
const point = el('circle', {{ cx: sx(d.rank), cy: sy(d.count), r: 2, class: 'point' }});
119+
el('title', {{}}, point).textContent = `${{d.rank}}. ${{d.word}} (${{d.count}})`;
117120
}}
118121
119-
for (let i = 0; i < Math.min(2000000, data.length); i++) {{
120-
const d = data[i];
121-
const t = el('text', {{ x: sx(d.rank) + 6, y: sy(d.count) + 4, class:'label' }});
122-
t.textContent = `${{i+1}}: ${{d.word}} (${{d.count}})`;
123-
svg.appendChild(t);
122+
let lastY = -Infinity;
123+
for (const d of data.slice(0, LABELLED)) {{
124+
const y = sy(d.count);
125+
if (Math.abs(y - lastY) < 12) continue;
126+
lastY = y;
127+
el('text', {{ x: sx(d.rank) + 6, y: y + 4, class: 'word' }}).textContent = d.word;
124128
}}
125129
}}
126130
127-
window.addEventListener('load', draw);
128-
window.addEventListener('resize', draw);
131+
draw();
129132
</script>
130133
</body>
131134
</html>
132-
"#,
133-
data_js = data_js,
134-
top_list_html = top_list_html
135+
"#
135136
);
136137

137138
fs::write(public.join("zipf.html"), html)?;
138139
Ok(())
139140
}
140141

141-
const FILE_BLACKLIST: &[&str] = &["content/misc/posts/index.md"];
142+
/// Whether a page is an index rather than prose: anything tagged `list`, which
143+
/// covers the post listing, the gallery indices, and the 3 MB Udon exposure
144+
/// dump that would otherwise be half of every count on the page.
145+
fn is_list_page(text: &str) -> bool {
146+
let Some(body) = text.strip_prefix("---") else {
147+
return false;
148+
};
149+
let Some(end) = body.find("\n---") else {
150+
return false;
151+
};
152+
153+
body[..end].lines().any(|line| {
154+
let line = line.trim();
155+
line == "- list"
156+
|| line
157+
.strip_prefix("tags:")
158+
.is_some_and(|tags| tags.split(['[', ']', ',']).any(|tag| tag.trim() == "list"))
159+
})
160+
}
142161

143-
fn is_blacklisted(path: &Path) -> bool {
144-
FILE_BLACKLIST
145-
.iter()
146-
.any(|blocked| path.ends_with(Path::new(blocked)))
162+
/// Drops `&lt;`, `&#39;` and the rest: an entity renders as punctuation, and
163+
/// left in it counts as a word called `lt`.
164+
fn strip_entities(text: &str) -> String {
165+
let mut out = String::with_capacity(text.len());
166+
let mut rest = text;
167+
168+
while let Some(start) = rest.find('&') {
169+
out.push_str(&rest[..start]);
170+
let after = &rest[start + 1..];
171+
let entity = after.find(';').filter(|&end| {
172+
end <= 10
173+
&& after[..end]
174+
.chars()
175+
.all(|c| c.is_ascii_alphanumeric() || c == '#')
176+
});
177+
178+
match entity {
179+
Some(end) => rest = &after[end + 1..],
180+
None => {
181+
out.push('&');
182+
rest = after;
183+
}
184+
}
185+
}
186+
187+
out.push_str(rest);
188+
out
147189
}
148190

149191
fn collect_md_files(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
@@ -166,7 +208,7 @@ fn collect_md_files(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
166208
.and_then(OsStr::to_str)
167209
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
168210

169-
if is_md && !is_blacklisted(&path) {
211+
if is_md {
170212
out.push(path);
171213
}
172214
}
@@ -287,9 +329,16 @@ fn tokenize_words(text: &str) -> Vec<String> {
287329
let mut words = Vec::new();
288330
let mut buf = String::new();
289331

290-
for ch in text.chars() {
291-
if ch.is_alphabetic() {
292-
buf.push(ch.to_ascii_lowercase());
332+
let mut chars = text.chars().peekable();
333+
while let Some(ch) = chars.next() {
334+
let apostrophe = matches!(ch, '\'' | '’');
335+
let joins =
336+
apostrophe && !buf.is_empty() && chars.peek().is_some_and(|next| next.is_alphabetic());
337+
338+
if apostrophe && joins {
339+
buf.push('\'');
340+
} else if ch.is_alphabetic() {
341+
buf.extend(ch.to_lowercase());
293342
} else if !buf.is_empty() {
294343
words.push(std::mem::take(&mut buf));
295344
}
@@ -315,3 +364,45 @@ fn build_top_list_html(items: &[(String, u64)]) -> String {
315364
out.push_str("</ol>");
316365
out
317366
}
367+
368+
#[cfg(test)]
369+
mod tests {
370+
use super::*;
371+
372+
#[test]
373+
fn leaves_indices_out_of_the_count() {
374+
assert!(is_list_page(
375+
"---\ntitle: posts\ntags:\n - list\n---\n\nbody"
376+
));
377+
assert!(is_list_page("---\ntags: [unity, list]\n---\n"));
378+
assert!(!is_list_page(
379+
"---\ntitle: a list of things\n---\n\n- list\n"
380+
));
381+
assert!(!is_list_page("no frontmatter\n---\n - list\n"));
382+
}
383+
384+
#[test]
385+
fn an_entity_is_not_a_word() {
386+
assert_eq!(strip_entities("a &lt;b&gt; &amp; c&#39;s"), "a b cs");
387+
assert_eq!(strip_entities("tom & jerry"), "tom & jerry");
388+
assert_eq!(
389+
strip_entities("&notanentity because it is too long;"),
390+
"&notanentity because it is too long;"
391+
);
392+
}
393+
394+
#[test]
395+
fn counts_the_words_of_the_prose_and_nothing_else() {
396+
let text = "See [the docs](https://example.com/x) and <em>this</em> &mdash; https://a.b/c.";
397+
let words = tokenize_words(&strip_entities(&strip_html(&strip_links(text))));
398+
assert_eq!(words, ["see", "and", "this"]);
399+
}
400+
401+
#[test]
402+
fn keeps_a_contraction_together_and_drops_the_quotes_around_a_word() {
403+
assert_eq!(
404+
tokenize_words("Don't ‘stop’, it’s 'fine' École"),
405+
["don't", "stop", "it's", "fine", "école"]
406+
);
407+
}
408+
}

0 commit comments

Comments
 (0)