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>`Compare the acquisition of word meanings across languages. Pick a “uni-lemma” (a cross-linguistic meaning, like dog) to see, for each language that has a mapped word, the proportion of children producing or understanding it at each age — optionally split by sex or consolidated onto a single plot.
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")
index = d3.json("slices/unilemmas/index.json")
lemma_opts = new Map(index.map((d) => [`${d.uni_lemma} (${d.n_languages} languages)`, d]))
sex_colors = new Map([["All", "#2780e3"], ["Female", "#c2571a"], ["Male", "#3b8462"]])
Loading data — the first load can take a few seconds…
{
clear_loading();
if (!lemma_filtered.length) return html`<em>No data for this selection.</em>`;
if (consolidate) {
// one axis, languages as colors; if split by sex, facet Female/Male
const smooth = xsplit
? ["Female", "Male"].flatMap((s) =>
loess_series(lemma_filtered.filter((d) => d.sex === s), "language", { w: "n", clamp: [0, 1] })
.map((p) => ({ ...p, sex: s })))
: loess_series(lemma_filtered, "language", { w: "n", clamp: [0, 1] });
return Plot.plot({
style: { fontFamily: "var(--sans-serif)", fontSize: "13px" },
width: 900,
height: 480,
inset: 8,
grid: true,
x: { label: "Age (months)", tickFormat: "d", line: true },
y: { label: `Proportion ${xmeasure === "produces" ? "producing" : "understanding"}`, domain: [0, 1], line: true },
fx: xsplit ? { label: null } : undefined,
color: { legend: true, domain: xlanguages.filter((l) => lemma_filtered.some((d) => d.language === l)) },
marks: [
Plot.lineY(smooth, {
x: "age", y: "prop", stroke: "language", strokeWidth: 2,
...(xsplit ? { fx: "sex" } : {})
}),
Plot.dot(lemma_filtered, {
x: "age", y: "prop", stroke: "language", r: 2, fillOpacity: 0.4,
channels: { word: "words", "uni-lemma": () => lemma.uni_lemma, n: "n" },
tip: true,
...(xsplit ? { fx: "sex" } : {})
})
]
});
}
// small multiples, one panel per language
const languages = lemma_languages.filter((l) =>
lemma_filtered.some((d) => d.language === l));
const panels = languages.map((lang) => {
const sub = lemma_filtered.filter((d) => d.language === lang);
const word = sub[0]?.words ?? "";
const smooth = loess_series(sub, "sex", { w: "n", clamp: [0, 1] });
return html`<div style="text-align:center">
<div style="font-weight:600">${lang}</div>
<div style="color:#666;font-size:12px">${word}</div>
${Plot.plot({
style: { fontFamily: "var(--sans-serif)", fontSize: "11px" },
width: 225,
height: 170,
inset: 4,
marginLeft: 32,
x: { label: null, tickFormat: "d", line: true },
y: { label: null, domain: [0, 1], line: true },
color: { domain: [...sex_colors.keys()], range: [...sex_colors.values()] },
marks: [
Plot.dot(sub, {
x: "age", y: "prop", stroke: "sex",
r: (d) => Math.sqrt(d.n) / 4, fillOpacity: 0.4,
channels: { word: "words", "uni-lemma": () => lemma.uni_lemma, n: "n" },
tip: true
}),
Plot.lineY(smooth, { x: "age", y: "prop", stroke: "sex", strokeWidth: 2 })
]
})}
</div>`;
});
const legend = xsplit
? html`<div style="width:100%;display:flex;gap:16px;justify-content:center;font-size:13px">
${["Female", "Male"].map((s) => html`<span><span style="display:inline-block;width:12px;height:12px;background:${sex_colors.get(s)};border-radius:2px;margin-right:4px"></span>${s}</span>`)}
</div>`
: "";
return html`<div style="display:flex;flex-wrap:wrap;gap:12px;justify-content:center">${legend}${panels}</div>`;
}