html`<style>
.${ojs_ns} div label {
background-color: #eaf3fc;
padding: 0.2rem 0.5rem;
border-radius: 0.4rem;
margin-bottom: 0.25rem;
width: auto;
}
.${ojs_ns} select,
.${ojs_ns} input[type="text"],
.${ojs_ns} input[type="file"] {
background-color: #fff;
border: 1px solid #d8dee4;
border-radius: 0.4rem;
padding: 0.2rem 0.4rem;
}
.${ojs_ns} input[type="checkbox"] {
accent-color: #3399f3;
margin-bottom: 0;
}
</style>`Frequency Counts
How often are particular words used in speech to and from children? Type comma-separated words, pick a collection, and compare usage frequency (parts per million tokens) across ages, speakers, corpora, and children. Counts are over all utterances attributed to a speaker, matched case-insensitively.
Plot = import("https://esm.sh/@observablehq/plot@0.6.17")
d3 = require("d3@7")
collections = FileAttachment("data/collections.json").json()
corpora_all = FileAttachment("data/corpora.json").json()
collection_names = collections.map((d) => d.name)
// slices are column-oriented JSON ({col: [values]}); expand to row objects
unpack = (cols) => {
const keys = Object.keys(cols);
const n = keys.length ? cols[keys[0]].length : 0;
return Array.from({ length: n }, (_, i) =>
Object.fromEntries(keys.map((k) => [k, cols[k][i]])));
}
collection_slug = (name) => collections.find((d) => d.name === name)?.slugloess_points = (data, { x = "age", y = "value", w = null, span = 0.75, grid = 60 } = {}) => {
const pts = data
.map((d) => ({ x: +d[x], y: +d[y], w: w ? +d[w] : 1 }))
.filter((d) => Number.isFinite(d.x) && Number.isFinite(d.y))
.sort((a, b) => a.x - b.x);
if (pts.length < 3) return pts.map((d) => ({ [x]: d.x, [y]: d.y }));
const k = Math.max(2, Math.ceil(span * pts.length));
const x0 = pts[0].x, x1 = pts[pts.length - 1].x;
const out = [];
for (let i = 0; i <= grid; i++) {
const xt = x0 + (x1 - x0) * (i / grid);
const dists = pts.map((p) => Math.abs(p.x - xt)).sort((a, b) => a - b);
const dmax = dists[Math.min(k, dists.length) - 1] || 1e-9;
let sw = 0, swx = 0, swy = 0, swxx = 0, swxy = 0;
for (const p of pts) {
const t = Math.abs(p.x - xt) / dmax;
if (t >= 1) continue;
const wt = Math.pow(1 - t * t * t, 3) * p.w;
sw += wt; swx += wt * p.x; swy += wt * p.y;
swxx += wt * p.x * p.x; swxy += wt * p.x * p.y;
}
if (sw <= 0) continue;
const denom = sw * swxx - swx * swx;
const yt = Math.abs(denom) < 1e-12
? swy / sw
: (swy * swxx - swx * swxy) / denom + ((sw * swxy - swx * swy) / denom) * xt;
out.push({ [x]: xt, [y]: yt });
}
return out;
}
// smooth each series (grouped by `series`) and tag points with the group
loess_series = (data, series, opts = {}) => {
const groups = new Map();
for (const d of data) {
const key = d[series];
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(d);
}
return [...groups.entries()].flatMap(([key, rows]) =>
loess_points(rows, opts).map((p) => ({ ...p, [series]: key })));
}download_csv = (rows, filename) => {
if (!rows || !rows.length) return html`<span></span>`;
const cols = Object.keys(rows[0]);
const esc = (v) => {
const s = String(v ?? "");
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const csv = [cols.join(","), ...rows.map((r) => cols.map((c) => esc(r[c])).join(","))].join("\n");
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
return html`<a class="btn btn-sm btn-outline-primary" href=${url} download=${filename}>Download CSV</a>`;
}function interval(range = [], options = {}) {
const [min = 0, max = 1] = range;
const {
step = .001,
label = null,
value = [min, max],
format = ([start, end]) => `${start} … ${end}`,
color,
width,
theme,
__ns__ = randomScope(),
} = options;
const css = `
#${__ns__} {
//font: 13px/1.2 var(--sans-serif);
display: flex;
align-items: baseline;
flex-wrap: wrap;
max-width: 100%;
width: auto;
flex-direction: column;
}
@media only screen and (min-width: 30em) {
#${__ns__} {
flex-wrap: nowrap;
width: 360px;
}
}
#${__ns__} .label {
//width: 120px;
//padding: 5px 0 4px 0;
//padding: 5px 0 0 0;
//margin-right: 6.5px;
flex-shrink: 0;
}
#${__ns__} .form {
display: flex;
width: 100%;
}
#${__ns__} .range {
flex-shrink: 1;
width: 100%;
}
#${__ns__} .range-slider {
width: 100%;
margin-bottom: .3em;
margin-top: .3em;
}
`;
const $range = rangeInput({min, max, value: [value[0], value[1]], step, color, width, theme});
const $output = ihtml`<output>`;
const $view = ihtml`<div id=${__ns__}>
${label == null ? '' : ihtml`<div class="label">${label}`}
<div class=form>
<div class=range>
${$range}<div class=range-output>${$output}</div>
</div>
</div>
${ihtml`<style>${css}`}
`;
const update = () => {
const content = format([$range.value[0], $range.value[1]]);
if(typeof content === 'string') $output.value = content;
else {
while($output.lastChild) $output.lastChild.remove();
$output.appendChild(content);
}
};
$range.oninput = update;
update();
return Object.defineProperty($view, 'value', {
get: () => $range.value,
set: ([a, b]) => {
$range.value = [a, b];
update();
},
});
}
function rangeInput(options = {}) {
const {
min = 0,
max = 100,
step = 'any',
value: defaultValue = [min, max],
color,
width,
theme = theme_Flat,
} = options;
const controls = {};
const scope = randomScope();
const clamp = (a, b, v) => v < a ? a : v > b ? b : v;
// Will be used to sanitize values while avoiding floating point issues.
const input = ihtml`<input type=range ${{min, max, step}}>`;
const dom = ihtml`<div class=${`${scope} range-slider`} style=${{
color,
width: cssLength(width),
}}>
${controls.track = ihtml`<div class="range-track">
${controls.zone = ihtml`<div class="range-track-zone">
${controls.range = ihtml`<div class="range-select" tabindex=0>
${controls.min = ihtml`<div class="thumb thumb-min" tabindex=0>`}
${controls.max = ihtml`<div class="thumb thumb-max" tabindex=0>`}
`}
`}
`}
${ihtml`<style>${theme.replace(/:scope\b/g, '.'+scope)}`}
</div>`;
let value = [], changed = false;
Object.defineProperty(dom, 'value', {
get: () => [...value],
set: ([a, b]) => {
value = sanitize(a, b);
updateRange();
},
});
const sanitize = (a, b) => {
a = isNaN(a) ? min : ((input.value = a), input.valueAsNumber);
b = isNaN(b) ? max : ((input.value = b), input.valueAsNumber);
return [Math.min(a, b), Math.max(a, b)];
}
const updateRange = () => {
const ratio = v => (v - min) / (max - min);
dom.style.setProperty('--range-min', `${ratio(value[0]) * 100}%`);
dom.style.setProperty('--range-max', `${ratio(value[1]) * 100}%`);
};
const dispatch = name => {
dom.dispatchEvent(new Event(name, {bubbles: true}));
};
const setValue = (vmin, vmax) => {
const [pmin, pmax] = value;
value = sanitize(vmin, vmax);
updateRange();
// Only dispatch if values have changed.
if(pmin === value[0] && pmax === value[1]) return;
dispatch('input');
changed = true;
};
setValue(...defaultValue);
// Mousemove handlers.
const handlers = new Map([
[controls.min, (dt, ov) => {
const v = clamp(min, ov[1], ov[0] + dt * (max - min));
setValue(v, ov[1]);
}],
[controls.max, (dt, ov) => {
const v = clamp(ov[0], max, ov[1] + dt * (max - min));
setValue(ov[0], v);
}],
[controls.range, (dt, ov) => {
const d = ov[1] - ov[0];
const v = clamp(min, max - d, ov[0] + dt * (max - min));
setValue(v, v + d);
}],
]);
// Returns client offset object.
const pointer = e => e.touches ? e.touches[0] : e;
// Note: Chrome defaults "passive" for touch events to true.
const on = (e, fn) => e.split(' ').map(e => document.addEventListener(e, fn, {passive: false}));
const off = (e, fn) => e.split(' ').map(e => document.removeEventListener(e, fn, {passive: false}));
let initialX, initialV, target, dragging = false;
function handleDrag(e) {
// Gracefully handle exit and reentry of the viewport.
if(!e.buttons && !e.touches) {
handleDragStop();
return;
}
dragging = true;
const w = controls.zone.getBoundingClientRect().width;
e.preventDefault();
handlers.get(target)((pointer(e).clientX - initialX) / w, initialV);
}
function handleDragStop(e) {
off('mousemove touchmove', handleDrag);
off('mouseup touchend', handleDragStop);
if(changed) dispatch('change');
}
invalidation.then(handleDragStop);
dom.ontouchstart = dom.onmousedown = e => {
dragging = false;
changed = false;
if(!handlers.has(e.target)) return;
on('mousemove touchmove', handleDrag);
on('mouseup touchend', handleDragStop);
e.preventDefault();
e.stopPropagation();
target = e.target;
initialX = pointer(e).clientX;
initialV = value.slice();
};
controls.track.onclick = e => {
if(dragging) return;
changed = false;
const r = controls.zone.getBoundingClientRect();
const t = clamp(0, 1, (pointer(e).clientX - r.left) / r.width);
const v = min + t * (max - min);
const [vmin, vmax] = value, d = vmax - vmin;
if(v < vmin) setValue(v, v + d);
else if(v > vmax) setValue(v - d, v);
if(changed) dispatch('change');
};
return dom;
}
function randomScope(prefix = 'scope-') {
return prefix + (performance.now() + Math.random()).toString(32).replace('.', '-');
}
cssLength = v => v == null ? null : typeof v === 'number' ? `${v}px` : `${v}`
// NOTE: the upstream notebook re-binds the page-wide `html` to htl.html.
// In Quarto's OJS runtime `htl` is not a builtin, so that line hangs the
// whole page silently; and Quarto's stdlib `html` is the old non-htl
// implementation, which drops the attribute interpolations this component
// relies on (${{min, max, step}}, class=${...}). We therefore import htl
// explicitly and use it here under the component-scoped name `ihtml`.
htl = import("https://esm.sh/htl@0.3.1")
ihtml = htl.html
theme_Flat = `
/* Options */
:scope {
color: #3b99fc;
width: 240px;
}
:scope {
position: relative;
display: inline-block;
--thumb-size: 15px;
--thumb-radius: calc(var(--thumb-size) / 2);
//padding: var(--thumb-radius) 0;
margin: 2px;
vertical-align: middle;
}
:scope .range-track {
box-sizing: border-box;
position: relative;
height: 7px;
background-color: hsl(0, 0%, 80%);
overflow: visible;
border-radius: 4px;
padding: 0 var(--thumb-radius);
}
:scope .range-track-zone {
box-sizing: border-box;
position: relative;
}
:scope .range-select {
box-sizing: border-box;
position: relative;
left: var(--range-min);
width: calc(var(--range-max) - var(--range-min));
cursor: ew-resize;
background: currentColor;
height: 7px;
border: inherit;
}
/* Expands the hotspot area. */
:scope .range-select:before {
content: "";
position: absolute;
width: 100%;
height: var(--thumb-size);
left: 0;
top: calc(2px - var(--thumb-radius));
}
:scope .range-select:focus,
:scope .thumb:focus {
outline: none;
}
:scope .thumb {
box-sizing: border-box;
position: absolute;
width: var(--thumb-size);
height: var(--thumb-size);
background: #fcfcfc;
top: -4px;
border-radius: 100%;
border: 1px solid hsl(0,0%,55%);
cursor: default;
margin: 0;
}
:scope .thumb:active {
box-shadow: inset 0 var(--thumb-size) #0002;
}
:scope .thumb-min {
left: calc(-1px - var(--thumb-radius));
}
:scope .thumb-max {
right: calc(-1px - var(--thumb-radius));
}
`duckdb = import("https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.29.0/+esm")
db = {
const bundle = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
const worker = await duckdb.createWorker(bundle.mainWorker);
const database = new duckdb.AsyncDuckDB(new duckdb.VoidLogger(), worker);
await database.instantiate(bundle.mainModule, bundle.pthreadWorker);
return database;
}
conn = db.connect()
registered = new Set()
sql = async (q) => (await (await conn).query(q)).toArray().map((r) => ({ ...r.toJSON() }))
quote_list = (xs) => xs.map((x) => `'${String(x).replace(/'/g, "''")}'`).join(", ")
// fetch and register a parquet file's bytes once (virtual name = its site
// path, so freq and bigram shards never collide); buffers cached across
// queries. Returns the name to use in read_parquet().
register_parquet = async (dir, fname) => {
const vname = `${dir}/${fname}`;
if (!registered.has(vname)) {
const buf = new Uint8Array(
await fetch(vname).then((r) => {
if (!r.ok) throw new Error(`failed to fetch ${vname}`);
return r.arrayBuffer();
}));
await (await db).registerFileBuffer(vname, buf);
registered.add(vname);
}
return vname;
}hash_check = FileAttachment("slices/freq/hash_check.json").json()
hash_ok = {
for (const v of hash_check.vectors) {
const lower = normGloss(v.gloss);
const shard = fnv1a(lower) % hash_check.n_shards;
if (lower !== v.gloss_lower || shard !== v.shard) {
throw new Error(
`gloss hash mismatch for "${v.gloss}": JS says (${lower}, shard ${shard}), ` +
`build says (${v.gloss_lower}, shard ${v.shard}) — ` +
`etl/write_freq_shards.py and frequency.qmd have drifted`);
}
}
return true;
}fq_child_options = {
const corpus_set = new Set(fq_corpora);
const names = [...new Set(
fq_stats
.filter((d) => corpus_set.has(d.corpus_name) && d.target_child_name != null)
.map((d) => d.target_child_name)
)].sort();
return ["All children", ...names];
}
fq_role_options = d3.groupSort(fq_stats, (v) => -v.length, (d) => d.speaker_role)viewof fq_children = Inputs.select(fq_child_options, {
label: "Target children",
multiple: 5,
value: ["All children"]
})
viewof fq_roles = Inputs.select(fq_role_options, {
label: "Speaker roles",
multiple: 5,
value: fq_role_options.includes("Target_Child")
? ["Target_Child"] : fq_role_options.slice(0, 1)
})
viewof fq_ages = interval([0, 18], { step: 0.5, value: [0, 18], label: "Ages (years)" })
viewof fq_binwidth = Inputs.range([0, 24], {
label: "Bin size (months, 0 = none)", step: 2, value: 2
})
Loading data — the first load can take a few seconds…
fq_words = [...new Set(
fq_words_raw.split(",").map((w) => normGloss(w.trim())).filter(Boolean)
)].slice(0, 8)
// lazy shard load + one DuckDB query per (words, collection); corpus /
// child / role / age filters below run reactively in JS on this result
fq_counts = {
if (!hash_ok || !fq_words.length) return [];
const shards = [...new Set(fq_words.map((w) => fnv1a(w) % hash_check.n_shards))];
const files = await Promise.all(shards.map(register_shard));
const rows = await sql(`
SELECT gloss_lower AS word, speaker_role, corpus_name,
target_child_id, target_child_name,
age::DOUBLE AS age, SUM(count)::INT AS n
FROM read_parquet([${files.map((f) => `'${f}'`).join(", ")}])
WHERE collection_name = '${fq_collection.replace(/'/g, "''")}'
AND gloss_lower IN (${quote_list(fq_words)})
GROUP BY 1, 2, 3, 4, 5, 6`);
return rows.map((d) => ({ ...d, age: Number(d.age), n: Number(d.n) }));
}SEP = "\u0001"
// age-bin cells with fewer total tokens than this are hidden: a handful of
// tokens in the denominator turns one word occurrence into a wild ppm spike
fq_min_tokens = 100
fq_binned = {
const corpus_set = new Set(fq_corpora);
const role_set = new Set(fq_roles);
const all_kids = fq_children.includes("All children");
const kid_set = new Set(fq_children);
const lo = fq_ages[0] * 12, hi = fq_ages[1] * 12;
const bw = fq_binwidth;
const keep = (d) =>
corpus_set.has(d.corpus_name) && role_set.has(d.speaker_role) &&
d.age != null && d.age >= lo && d.age <= hi &&
(all_kids || kid_set.has(d.target_child_name));
const bin_of = (age) => bw > 0 ? Math.floor(age / bw) * bw + bw / 2 : age;
const child_of = (d) => all_kids
? "All children" : (d.target_child_name ?? String(d.target_child_id));
// ppm denominator: total tokens per (child facet, role, age bin)
const denom = new Map();
for (const d of fq_stats) {
if (!keep(d) || !(d.num_tokens > 0)) continue;
const k = child_of(d) + SEP + d.speaker_role + SEP + bin_of(d.age);
denom.set(k, (denom.get(k) ?? 0) + d.num_tokens);
}
// numerator: word occurrences per (word, child facet, role, age bin)
const num = new Map();
for (const d of fq_counts) {
if (!keep(d)) continue;
const k = d.word + SEP + child_of(d) + SEP + d.speaker_role + SEP + bin_of(d.age);
num.set(k, (num.get(k) ?? 0) + d.n);
}
// one row per denominator cell per word: ages where a word does not occur
// contribute ppm = 0 rather than being dropped
const out = [];
for (const [k, tokens] of denom) {
if (tokens < fq_min_tokens) continue;
const [child, role, bin] = k.split(SEP);
for (const word of fq_words) {
const n = num.get(word + SEP + k) ?? 0;
out.push({
word, child, speaker_role: role,
age_months: +bin, age_years: +bin / 12,
n, tokens, ppm: 1e6 * n / tokens
});
}
}
return out.sort((a, b) =>
d3.ascending(a.word, b.word) || d3.ascending(a.child, b.child) ||
d3.ascending(a.speaker_role, b.speaker_role) ||
d3.ascending(a.age_months, b.age_months));
}
// words with at least one occurrence under the current filters get plotted;
// the rest are reported (a flat all-zero line is rarely what anyone wants)
fq_found_words = [...new Set(
fq_binned.filter((d) => d.n > 0).map((d) => d.word))]
fq_missing_words = fq_words.filter((w) => !fq_found_words.includes(w))
fq_plotted = fq_binned.filter((d) => fq_found_words.includes(d.word))
fq_role_dashes = new Map(
fq_roles.map((r, i) => [r, ["1,0", "6,4", "2,3", "8,3,2,3", "10,3"][i % 5]])){
clear_loading();
const note = fq_missing_words.length
? html`<div style="color:#8a6d3b;background:#fcf8e3;border:1px solid #faebcc;
border-radius:4px;padding:0.4rem 0.75rem;margin-bottom:0.5rem">
No occurrences of ${fq_missing_words.map((w) => `“${w}”`).join(", ")}
for the current selection.</div>`
: "";
if (!fq_plotted.length) {
return html`${note}<em>Nothing to plot — try other words, more corpora or
speaker roles, or a wider age range.</em>`;
}
const facets = [...new Set(fq_plotted.map((d) => d.child))].sort();
const faceted = !(facets.length === 1 && facets[0] === "All children");
const smooth = [];
for (const [key, rows] of d3.groups(fq_plotted,
(d) => d.word + SEP + d.speaker_role + SEP + d.child)) {
const [word, role, child] = key.split(SEP);
for (const p of loess_points(rows,
{ x: "age_years", y: "ppm", w: "tokens", span: 1 })) {
smooth.push({ ...p, word, speaker_role: role, child });
}
}
const role_legend = fq_roles.length > 1
? html`<div style="display:flex;gap:1rem;flex-wrap:wrap;font-size:12px;
color:#444;margin-bottom:0.25rem">
${[...fq_role_dashes].map(([r, d]) => html`<span style="display:inline-flex;
align-items:center;gap:4px"><svg width="28" height="10">
<line x1="0" y1="5" x2="28" y2="5" stroke="#444" stroke-width="2"
stroke-dasharray="${d}"></line></svg>${r}</span>`)}
</div>`
: "";
const plot = Plot.plot({
style: { fontFamily: "var(--sans-serif)", fontSize: "13px" },
width: 850,
height: faceted ? 420 : 500,
inset: 8,
marginLeft: 70,
grid: true,
x: { label: "Target child age (years)" },
y: { label: "Frequency (parts per million tokens)", line: true, tickFormat: "~s" },
fx: faceted ? { label: null } : undefined,
color: { legend: true, domain: fq_found_words },
marks: [
...fq_roles.map((role) => Plot.lineY(
smooth.filter((d) => d.speaker_role === role), {
x: "age_years", y: "ppm", stroke: "word", strokeWidth: 2.5,
strokeDasharray: fq_role_dashes.get(role),
...(faceted ? { fx: "child" } : {})
})),
Plot.dot(fq_plotted.filter((d) => d.n > 0), {
x: "age_years", y: "ppm", fill: "word", r: 3, fillOpacity: 0.4,
channels: { speaker_role: "speaker_role", n: "n", tokens: "tokens" },
tip: true,
...(faceted ? { fx: "child" } : {})
})
]
});
return html`${note}${role_legend}${plot}`;
}Points show word frequency per age bin (occurrences / total tokens for that speaker role, × 10⁶); curves are token-weighted LOESS smooths that include ages where a word did not occur (frequency 0). Bins with fewer than 100 tokens are hidden. When specific children are selected, panels show each child separately.
Note
Counts come from the childes-db 2026.1 type-frequency table (one row per word type per speaker per transcript); denominators are total tokens per speaker per transcript from get_speaker_statistics, aggregated over the same selection. Words are matched on the NFC-normalized, lowercased gloss. The first query initializes an in-browser database (~10 s); later queries fetch only the ~2 MB shard containing each word.