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 the structure of the early vocabulary as a semantic network. Words appear when the typical child acquires them (age of acquisition estimated from the Wordbank data) and connect to their nearest semantic neighbors (multilingual Gemini embeddings — so words from different languages live in the same semantic space, and selecting several languages shows translation equivalents clustering together).
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>`;
}viewof nw_langs = Inputs.select(net_languages, {
label: "Languages (1–4)",
multiple: 6,
value: ["English (American)"]
})
viewof nw_age = Inputs.range([8, 36], { label: "Age (months)", step: 1, value: 24 })
viewof nw_k = Inputs.range([1, 6], { label: "Neighbors per word", step: 1, value: 3 })
viewof nw_minsim = Inputs.range([0.5, 0.95], { label: "Min similarity", step: 0.05, value: 0.75 })
viewof nw_color = Inputs.select(
new Map([["Category", "category"], ["Language", "language"]]),
{ label: "Color by" }
)
Loading data — the first load can take a few seconds…
nw_selected = nw_langs.slice(0, 4)
nw_words = {
const all = [];
for (const lang of nw_selected) {
const d = await d3.json(`slices/networks/${san(lang)}.json`).catch(() => null);
if (!d) continue;
for (let i = 0; i < d.word.length; i++) {
all.push({
language: lang,
word: d.word[i],
category: d.category[i],
uni_lemma: d.uni_lemma[i],
aoa: Number(d.aoa[i]),
vec: Float32Array.from(d.vec[i])
});
}
}
return all;
}
// k-nearest-neighbor edges over the (normalized) embeddings, computed once
// per language selection; the age slider only filters
nw_edges = {
const n = nw_words.length;
if (!n) return [];
const k = 8; // store extra neighbors so the k slider works without recompute
const edges = [];
for (let i = 0; i < n; i++) {
const sims = [];
const vi = nw_words[i].vec;
for (let j = 0; j < n; j++) {
if (i === j) continue;
const vj = nw_words[j].vec;
let s = 0;
for (let d = 0; d < vi.length; d++) s += vi[d] * vj[d];
sims.push([j, s]);
}
sims.sort((a, b) => b[1] - a[1]);
for (let r = 0; r < Math.min(k, sims.length); r++) {
edges.push({ source: i, target: sims[r][0], sim: sims[r][1], rank: r + 1 });
}
}
return edges;
}{
clear_loading();
if (!nw_words.length) return html`<em>No network data for this selection.</em>`;
const visible = nw_words.map((d) => d.aoa <= nw_age);
const nodes = nw_words
.map((d, i) => ({ ...d, i }))
.filter((d) => visible[d.i]);
if (!nodes.length) {
return html`<em>No words acquired by ${nw_age} months in this selection — move the age slider right.</em>`;
}
const index = new Map(nodes.map((d, r) => [d.i, r]));
const links = nw_edges
.filter((e) => e.rank <= nw_k && e.sim >= nw_minsim &&
visible[e.source] && visible[e.target])
.map((e) => ({ source: index.get(e.source), target: index.get(e.target), sim: e.sim }));
const width = 900, height = 620;
const domain = [...new Set(nodes.map((d) => d[nw_color]))].sort();
const color = d3.scaleOrdinal(domain, d3.schemeTableau10.concat(d3.schemeSet3));
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width:100%; height:auto; border:1px solid #eee; border-radius:5px; cursor: grab;");
// pan/zoom: wheel or pinch to zoom, drag the background to pan
const container = svg.append("g");
const zoom = d3.zoom()
.scaleExtent([0.15, 10])
.on("zoom", (event) => container.attr("transform", event.transform));
svg.call(zoom).on("dblclick.zoom", null);
const fit = (duration = 400) => {
const xs = nodes.map((d) => d.x), ys = nodes.map((d) => d.y);
if (!xs.length || xs.some((v) => !Number.isFinite(v))) return;
const [x0, x1] = d3.extent(xs), [y0, y1] = d3.extent(ys);
const pad = 40;
const k = Math.min(8, 0.95 / Math.max((x1 - x0 + pad) / width, (y1 - y0 + pad) / height));
svg.transition().duration(duration).call(
zoom.transform,
d3.zoomIdentity.translate(width / 2, height / 2).scale(k)
.translate(-(x0 + x1) / 2, -(y0 + y1) / 2));
};
const link = container.append("g")
.selectAll("line").data(links).join("line")
.attr("stroke", "#bbb")
.attr("stroke-opacity", 0.5)
.attr("stroke-width", (d) => (d.sim - nw_minsim) * 8 + 0.5);
const node = container.append("g")
.selectAll("g").data(nodes).join("g");
node.append("circle")
.attr("r", 5)
.attr("fill", (d) => color(d[nw_color]))
.attr("stroke", "#fff")
.attr("stroke-width", 1);
node.append("title")
.text((d) => `${d.word} (${d.language})\ncategory: ${d.category}\nAoA: ${Math.round(d.aoa)} mo${d.uni_lemma ? `\nuni-lemma: ${d.uni_lemma}` : ""}`);
const label = node.append("text")
.attr("dx", 7)
.attr("dy", 3)
.attr("style", "font-family: var(--sans-serif); font-size: 9px; fill: #444; pointer-events: none;")
.text((d) => d.word);
let fitted = false;
const sim = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).distance(30).strength((d) => d.sim))
.force("charge", d3.forceManyBody().strength(-60))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collide", d3.forceCollide(10))
.on("tick", () => {
link.attr("x1", (d) => d.source.x).attr("y1", (d) => d.source.y)
.attr("x2", (d) => d.target.x).attr("y2", (d) => d.target.y);
node.attr("transform", (d) => `translate(${d.x},${d.y})`);
// fit once the layout has roughly settled
if (!fitted && sim.alpha() < 0.05) { fitted = true; fit(); }
});
node.call(d3.drag()
.on("start", (event, d) => { if (!event.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on("drag", (event, d) => { d.fx = event.x; d.fy = event.y; })
.on("end", (event, d) => { if (!event.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }));
invalidation.then(() => sim.stop());
const legend = html`<div style="display:flex;flex-wrap:wrap;gap:10px;font-size:12px;margin-bottom:6px">
${domain.slice(0, 24).map((c) => html`<span><span style="display:inline-block;width:10px;height:10px;border-radius:5px;background:${color(c)};margin-right:3px"></span>${c}</span>`)}
</div>`;
const resetBtn = html`<button class="btn btn-sm btn-outline-secondary" style="float:right">Fit to view</button>`;
resetBtn.onclick = () => fit();
const stats = html`<div style="color:#666;font-size:12px;margin-bottom:4px">
${resetBtn}${nodes.length} words acquired by ${nw_age} months · ${links.length} links
· scroll to zoom, drag background to pan</div>`;
return html`<div>${stats}${legend}${svg.node()}</div>`;
}
Note
Age of acquisition is the age at which at least 50% of children are reported to produce the word (glm fit per item, as in wordbankr::fit_aoa). Semantic similarity is the cosine between gemini-embedding-001 embeddings of the item definitions, which share one multilingual space — try selecting two languages and watch translation equivalents attract each other.