html`<style>
.${ojs_ns} div label {
background-color: #eef2f6;
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: #2780e3;
margin-bottom: 0;
}
</style>`Vocabulary size as a function of age. Curves show empirical quantiles of total vocabulary among children in the Wordbank database, computed in your browser from the individual administrations — so they update with any combination of filters.
san = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
split_options = new Map([
["None", null],
["Sex", "sex"],
["Birth order", "birth_order"],
["Caregiver education", "caregiver_education"],
["Ethnicity", "ethnicity"]
])
level_orders = ({
sex: ["Female", "Male", "Other"],
birth_order: ["First", "Second", "Third", "Fourth", "Fifth", "Sixth",
"Seventh", "Eighth"],
caregiver_education: ["None", "Primary", "Some Secondary", "Secondary",
"Some College", "College", "Some Graduate", "Graduate"],
ethnicity: ["Asian", "Black", "Hispanic", "White", "Other"]
})
level_sort = (split) => {
const order = split ? (level_orders[split] ?? []) : [];
return (a, b) => {
const ia = order.indexOf(a), ib = order.indexOf(b);
if (ia !== -1 || ib !== -1) return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
return a < b ? -1 : a > b ? 1 : 0;
};
}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>`;
}loess_points = (data, { x = "age", y = "prop", w = null, span = 0.75, grid = 60, clamp = null } = {}) => {
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);
// too few points to smooth meaningfully (a local-linear fit through 3–4
// points produces spurious kinks): connect the raw points instead
if (pts.length < 5) 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]: clamp ? Math.min(clamp[1], Math.max(clamp[0], yt)) : 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 })));
}Plot = import("https://esm.sh/@observablehq/plot@0.6.17")
d3 = require("d3@7")
insts = transpose(instruments)
norm_languages = [...new Set(insts.map((d) => d.language))].sort()
quantile_presets = new Map([
["Standard", [0.10, 0.25, 0.50, 0.75, 0.90]],
["Quartiles", [0.25, 0.50, 0.75]],
["Deciles", [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]],
["Median", [0.5]]
])viewof nlanguage = Inputs.select(norm_languages, {
label: "Language",
value: "English (American)"
})
viewof nform = Inputs.select(
insts.filter((d) => d.language === nlanguage).map((d) => d.form),
{ label: "Form" }
)
viewof nmeasure = Inputs.select(
insts.find((d) => d.language === nlanguage && d.form === nform)?.form_type === "WG"
? ["production", "comprehension"] : ["production"],
{ label: "Measure" }
)
viewof nsplit = Inputs.select(split_options, { label: "Split by" })
viewof nquantiles = Inputs.select(quantile_presets, { label: "Quantiles" })
viewof norming_only = Inputs.toggle({ label: "Norming data only", value: false })
Loading data — the first load can take a few seconds…
nslice = d3.csv(`slices/admins/${san(nlanguage + " " + nform)}.csv`, (d) => ({
age: +d.age,
vocab: +d[nmeasure],
is_norming: d.is_norming === "TRUE",
group: nsplit ? d[nsplit] : "All data"
}))
min_n = 10
norms_raw = nslice
.filter((d) => Number.isFinite(d.vocab) && d.group !== "" && (!norming_only || d.is_norming))
.map((d) => ({ ...d, age_j: d.age + (Math.random() - 0.5) * 0.8 }))
norms_quantiles = {
const rows = norms_raw;
const out = [];
for (const [group, byAge] of d3.groups(rows, (d) => d.group)) {
for (const [age, cell] of d3.groups(byAge, (d) => d.age)) {
if (cell.length < min_n) continue;
const vocabs = cell.map((d) => d.vocab).sort(d3.ascending);
for (const q of nquantiles) {
out.push({ group, age, n_children: cell.length,
quantile: `${Math.round(q * 100)}th`,
vocab: d3.quantileSorted(vocabs, q) });
}
}
}
const cmp = level_sort(nsplit);
return out.sort((a, b) => cmp(a.group, b.group) || d3.ascending(a.age, b.age));
}
norm_groups = [...new Set(norms_quantiles.map((d) => d.group))]
// sparse selections (few ages with >= min_n children) get points connected
// directly instead of a smoother; flag it so the jagged shape isn't read
// as an error
n_ages_shown = new Set(norms_quantiles.map((d) => d.age)).size
sparse_note = n_ages_shown < 5
? html`<div style="font-size:12px;color:#8a5a00;background:#fff7e0;border:1px solid #f0d890;border-radius:4px;padding:4px 8px;margin-bottom:6px">
Sparse data: only ${n_ages_shown} age${n_ages_shown === 1 ? "" : "s"} have at least ${min_n} children
(${norms_raw.length.toLocaleString()} administrations total), so quantile points are
connected directly rather than smoothed. Interpret with caution.</div>`
: html``{
clear_loading();
if (!norms_quantiles.length) {
return html`<em>Not enough data for this selection (cells need at least ${min_n} children per age).</em>`;
}
const single = norm_groups.length === 1;
const ylabel = `${nmeasure === "production" ? "Productive" : "Receptive"} vocabulary (words)`;
// a single quantile with a split consolidates onto one axis, one color per
// group — much easier to compare (e.g. median by sex)
if (!single && nquantiles.length === 1) {
const smooth = loess_series(norms_quantiles, "group",
{ y: "vocab", w: "n_children", clamp: [0, Infinity] });
return html`${sparse_note}${Plot.plot({
style: { fontFamily: "var(--sans-serif)", fontSize: "13px" },
width: 850,
height: 480,
inset: 8,
grid: true,
x: { label: "Age (months)", tickFormat: "d", line: true },
y: { label: `${ylabel}, ${Math.round(nquantiles[0] * 100)}th percentile`, line: true },
color: { legend: true, domain: norm_groups },
marks: [
Plot.dot(norms_raw, { x: "age_j", y: "vocab", r: 1.3, fill: "#bbb", fillOpacity: 0.35 }),
Plot.lineY(smooth, { x: "age", y: "vocab", stroke: "group", strokeWidth: 2.5 }),
Plot.dot(norms_quantiles, {
x: "age", y: "vocab", fill: "group", r: 3, fillOpacity: 0.6,
channels: { n_children: "n_children" }, tip: true
})
]
})}`;
}
const panels = norm_groups.map((g) => {
const sub = norms_quantiles.filter((d) => d.group === g);
const raw = norms_raw.filter((d) => d.group === g);
const smooth = loess_series(sub, "quantile", { y: "vocab", w: "n_children", clamp: [0, Infinity] });
const n = d3.sum([...new Set(sub.map((d) => `${d.age}`))].map((a) =>
sub.find((d) => `${d.age}` === a).n_children));
return html`<div style="text-align:center">
${single ? "" : html`<div style="font-weight:600">${g} <span style="color:#666;font-weight:400">(n=${n.toLocaleString()})</span></div>`}
${Plot.plot({
style: { fontFamily: "var(--sans-serif)", fontSize: single ? "13px" : "11px" },
width: single ? 850 : 410,
height: single ? 480 : 300,
inset: 8,
grid: true,
x: { label: "Age (months)", tickFormat: "d", line: true },
y: { label: single ? ylabel : null, line: true },
color: { legend: single, type: "ordinal", scheme: "viridis", label: "Quantile" },
marks: [
Plot.dot(raw, { x: "age_j", y: "vocab", r: single ? 1.3 : 1, fill: "#bbb", fillOpacity: 0.35 }),
Plot.lineY(smooth, { x: "age", y: "vocab", stroke: "quantile", strokeWidth: 2.5 }),
Plot.dot(sub, {
x: "age", y: "vocab", fill: "quantile", r: 2.5, fillOpacity: 0.6,
channels: { n_children: "n_children" }, tip: true
})
]
})}
</div>`;
});
return html`${sparse_note}<div style="display:flex;flex-wrap:wrap;gap:16px">${panels}</div>`;
}
Note
These are empirical quantiles of the archive’s convenience sample, smoothed over adjacent ages, with age-by-group cells under 10 children hidden — a useful descriptive picture, but not clinical norms. For norming applications see the official CDI norms.