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>`Explore how individual words are acquired over development. Pick a language and form, select words, and optionally split by a demographic variable — proportions are computed in your browser from the raw child-by-word responses.
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")
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()
insts = transpose(instruments)
languages = [...new Set(insts.map((d) => d.language))].sort()
sql = async (q) => (await (await conn).query(q)).toArray().map((r) => ({...r.toJSON()}))
quote_list = (xs) => xs.map((x) => `'${String(x).replace(/'/g, "''")}'`).join(", ")resp = {
const slug = san(language + " " + form);
const fname = `${slug}.parquet`;
if (!registered.has(fname)) {
const buf = new Uint8Array(
await fetch(`slices/responses/${fname}`).then((r) => {
if (!r.ok) throw new Error(`no data for ${language} ${form}`);
return r.arrayBuffer();
}));
await db.registerFileBuffer(fname, buf);
registered.add(fname);
}
return `read_parquet('${fname}')`;
}
form_type = insts.find((d) => d.language === language && d.form === form)?.form_type
measures = form_type === "WG" ? ["produces", "understands"] : ["produces"]
words = (await sql(`SELECT DISTINCT item_definition FROM ${resp} WHERE item_kind = 'word' ORDER BY 1`))
.map((d) => d.item_definition)
default_words = {
const defaults = words.filter((w) => ["dog", "mommy*", "ball"].includes(w));
return defaults.length ? defaults : words.slice(0, 3);
}
Loading data — the first load can take a few seconds…
min_n_traj = 5
trajectory_data = {
if (!selected.length) return [];
const grp = split ? `CAST(${split} AS VARCHAR)` : `'All data'`;
const where = [
`item_kind = 'word'`,
`item_definition IN (${quote_list(selected)})`,
split ? `${grp} IS NOT NULL AND ${grp} <> ''` : `TRUE`,
norming_only ? `is_norming` : `TRUE`
].join(" AND ");
const rows = await sql(`
SELECT item_definition, ${grp} AS grp, age,
AVG(CASE WHEN ${measure} THEN 1.0 ELSE 0.0 END) AS prop,
COUNT(DISTINCT data_id)::INT AS n_children
FROM ${resp}
WHERE ${where}
GROUP BY 1, 2, 3
HAVING COUNT(DISTINCT data_id) >= ${min_n_traj}
ORDER BY 1, 2, 3`);
return rows.map((d) => {
const p = Number(d.prop), n = Number(d.n_children), z = 1.96;
const denom = 1 + z * z / n;
const center = (p + z * z / (2 * n)) / denom;
const half = (z / denom) * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n));
return { ...d, age: Number(d.age), prop: p, n_children: n,
ci_l: Math.max(0, center - half), ci_u: Math.min(1, center + half) };
});
}
traj_groups = [...new Set(trajectory_data.map((d) => d.grp))].sort(level_sort(split)){
clear_loading();
if (!trajectory_data.length) {
return html`<em>No data for this selection (age-by-group cells need at least ${min_n_traj} children).</em>`;
}
const single = traj_groups.length === 1;
const panels = traj_groups.map((g) => {
const sub = trajectory_data.filter((d) => d.grp === g);
const smooth = loess_series(sub, "item_definition", { w: "n_children", clamp: [0, 1] });
return html`<div style="text-align:center">
${single ? "" : html`<div style="font-weight:600">${g}</div>`}
${Plot.plot({
style: { fontFamily: "var(--sans-serif)", fontSize: single ? "13px" : "11px" },
width: single ? 850 : 410,
height: single ? 450 : 300,
inset: 8,
grid: true,
x: { label: "Age (months)", tickFormat: "d", line: true },
y: { label: single ? `Proportion of children who ${measure === "produces" ? "produce" : "understand"}` : null, domain: [0, 1], line: true },
color: { legend: single, domain: selected },
marks: [
Plot.ruleX(sub, {
x: "age", y1: "ci_l", y2: "ci_u", stroke: "item_definition",
strokeOpacity: 0.4, strokeWidth: 1.5
}),
Plot.dot(sub, {
x: "age", y: "prop", stroke: "item_definition",
r: (d) => Math.sqrt(d.n_children) / 4, fillOpacity: 0.5,
channels: { n_children: "n_children" }, tip: true
}),
Plot.lineY(smooth, {
x: "age", y: "prop", stroke: "item_definition", strokeWidth: 2.5
})
]
})}
</div>`;
});
return html`<div style="display:flex;flex-wrap:wrap;gap:12px">
${single ? "" : html`<div style="width:100%">${Plot.legend({ color: { domain: selected } })}</div>`}
${panels}
</div>`;
}