Mongoosetross's picture
Deploy Reverse Etymology Atlas
6efeb11 verified
Raw
History Blame Contribute Delete
33.2 kB
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import L from "leaflet";
import QRCode from "qrcode";
import { api } from "./api";
import { REL_COLORS } from "./colors";
import type { ColorBy, DefinitionPayload, GraphNode, Meta, NodePredicate, Quantifier, SuggestHit, TreeQuery, TreeResponse, ViewName } from "./types";
import { emptyPredicate } from "./types";
import { CanvasView } from "./viz";
const DEFAULT_RELS = [
"inherited",
"derived",
"borrowed",
"calque",
"clipping",
"back_formation",
"abbreviation",
"compound",
"blend",
"other",
"cognate",
];
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
function Multi({
options,
value,
onChange,
placeholder,
}: {
options: { key: string; label: string }[];
value: string[];
onChange: (v: string[]) => void;
placeholder: string;
}) {
const [q, setQ] = useState("");
const filtered = useMemo(() => {
const n = q.trim().toLowerCase();
return options.filter((o) => !n || o.label.toLowerCase().includes(n) || o.key.toLowerCase().includes(n)).slice(0, 80);
}, [options, q]);
return (
<div>
<input className="field-input" value={q} placeholder={placeholder} onChange={(e) => setQ(e.target.value)} />
<div className="checklist">
{filtered.map((o) => (
<label key={o.key}>
<input
type="checkbox"
checked={value.includes(o.key)}
onChange={() => onChange(value.includes(o.key) ? value.filter((x) => x !== o.key) : [...value, o.key])}
/>
{o.label}
</label>
))}
</div>
</div>
);
}
function PredicateEditor({
meta,
value,
onChange,
extra,
showPopularity = false,
}: {
meta: Meta;
value: NodePredicate;
onChange: (p: NodePredicate) => void;
extra?: ReactNode;
showPopularity?: boolean;
}) {
const langOpts = useMemo(
() => meta.languages.slice(0, 2500).map((l) => ({ key: l.key, label: `${l.display || l.key} (${l.nodes})` })),
[meta]
);
const famOpts = useMemo(() => meta.families.map((f) => ({ key: f.name, label: `${f.name} (${f.nodes})` })), [meta]);
return (
<div>
<label className="field">Languages</label>
<Multi options={langOpts} value={value.languages} onChange={(languages) => onChange({ ...value, languages })} placeholder="Filter languages" />
<label className="field">Families</label>
<Multi options={famOpts} value={value.families} onChange={(families) => onChange({ ...value, families })} placeholder="Filter families" />
<label className="field">Macroarea</label>
<div className="rel-grid">
{meta.macroareas.map((m) => (
<label key={m}>
<input
type="checkbox"
checked={value.macroareas.includes(m)}
onChange={() =>
onChange({
...value,
macroareas: value.macroareas.includes(m) ? value.macroareas.filter((x) => x !== m) : [...value.macroareas, m],
})
}
/>
{m}
</label>
))}
</div>
<label className="field">Term contains</label>
<input className="field-input" value={value.term_contains} onChange={(e) => onChange({ ...value, term_contains: e.target.value })} />
<label className="field">
<input type="checkbox" checked={value.require_coords} onChange={(e) => onChange({ ...value, require_coords: e.target.checked })} /> Mapped coordinates
</label>
<label className="field">Has phonemes (comma IPA)</label>
<input
className="field-input"
value={value.phonemes_have.join(" ")}
onChange={(e) => onChange({ ...value, phonemes_have: e.target.value.split(/[\s,]+/).filter(Boolean) })}
placeholder="θ ʃ"
/>
<label className="field">Lacks phonemes</label>
<input
className="field-input"
value={value.phonemes_lack.join(" ")}
onChange={(e) => onChange({ ...value, phonemes_lack: e.target.value.split(/[\s,]+/).filter(Boolean) })}
/>
<label className="field">Tone</label>
<select
value={value.require_tone === null ? "" : value.require_tone ? "yes" : "no"}
onChange={(e) => onChange({ ...value, require_tone: e.target.value === "" ? null : e.target.value === "yes" })}
>
<option value="">any</option>
<option value="yes">has tone</option>
<option value="no">no tone</option>
</select>
{showPopularity && (
<>
<label className="field">Popularity in language</label>
<p style={{ color: "var(--muted)", fontSize: 12, margin: "0 0 6px" }}>
Modern lects via wordfreq (Zipf); fallback ranked lists for extra languages. Historical forms usually have no score.
</p>
<label className="field">Top N most frequent</label>
<select
value={value.max_rank ?? ""}
onChange={(e) => onChange({ ...value, max_rank: e.target.value === "" ? null : Number(e.target.value) })}
>
<option value="">any</option>
<option value={100}>top 100</option>
<option value={1000}>top 1,000</option>
<option value={10000}>top 10,000</option>
<option value={100000}>top 100,000</option>
<option value={1000000}>top 1,000,000</option>
</select>
<label className="field">
Min Zipf {value.min_zipf != null ? value.min_zipf.toFixed(1) : "off"}
</label>
<input
type="range"
min={0}
max={70}
step={1}
value={value.min_zipf == null ? 0 : Math.round(value.min_zipf * 10)}
onChange={(e) => {
const raw = Number(e.target.value);
onChange({ ...value, min_zipf: raw <= 0 ? null : raw / 10 });
}}
/>
<p style={{ color: "var(--muted)", fontSize: 12, margin: "4px 0 0" }}>
Zipf ≈ 3 rare · 4 uncommon · 5 everyday · 6+ very common. 0 = no minimum.
</p>
</>
)}
<label className="field">WALS feature</label>
<select
value={value.wals[0]?.feature_id || ""}
onChange={(e) => {
const id = e.target.value;
onChange({ ...value, wals: id ? [{ feature_id: id, values: [] }] : [] });
}}
>
<option value="">none</option>
{meta.wals_features.map((f) => (
<option key={f.id} value={f.id}>
{f.id} {f.name}
</option>
))}
</select>
{value.wals[0] && (
<Multi
options={(meta.wals_features.find((f) => f.id === value.wals[0].feature_id)?.values || []).map((v) => ({
key: String(v.value),
label: v.label,
}))}
value={value.wals[0].values.map(String)}
onChange={(vals) => onChange({ ...value, wals: [{ ...value.wals[0], values: vals.map(Number) }] })}
placeholder="Allowed values"
/>
)}
<label className="field">
<input type="checkbox" checked={value.keep_unknown} onChange={(e) => onChange({ ...value, keep_unknown: e.target.checked })} /> Keep languages missing this metadata
</label>
{extra}
</div>
);
}
export default function App() {
const [meta, setMeta] = useState<Meta | null>(null);
const [q, setQ] = useState("");
const [hits, setHits] = useState<SuggestHit[]>([]);
const [searchOpen, setSearchOpen] = useState(false);
const [activeHit, setActiveHit] = useState(0);
const [term, setTerm] = useState("");
const [lang, setLang] = useState("");
const [view, setView] = useState<ViewName>("tree");
const [colorBy, setColorBy] = useState<ColorBy>("relation");
const [relations, setRelations] = useState<string[]>(DEFAULT_RELS);
const [conf, setConf] = useState(0.7);
const [depth, setDepth] = useState(3);
const [leaf, setLeaf] = useState<NodePredicate>(emptyPredicate());
const [pathNode, setPathNode] = useState<NodePredicate>(emptyPredicate());
const [pathMatch, setPathMatch] = useState<Quantifier>("any");
const [pathRelAny, setPathRelAny] = useState<string[]>([]);
const [pathRelNone, setPathRelNone] = useState<string[]>([]);
const [expand, setExpand] = useState<string[]>([]);
const [tree, setTree] = useState<TreeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<GraphNode | null>(null);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [definition, setDefinition] = useState<DefinitionPayload | null>(null);
const [definitionLoading, setDefinitionLoading] = useState(false);
const [filterTab, setFilterTab] = useState<"graph" | "leaf" | "path">("graph");
const [mobilePanel, setMobilePanel] = useState<"none" | "filters" | "info">("none");
const [qr, setQr] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewRef = useRef<CanvasView | null>(null);
const mapRef = useRef<HTMLDivElement>(null);
const mapObj = useRef<L.Map | null>(null);
const searchRef = useRef<HTMLDivElement>(null);
const cameraKeyRef = useRef<string>("");
const dq = useDebounced(q, 80);
useEffect(() => {
api.meta().then(setMeta).catch((e) => setError(String(e)));
}, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const t = params.get("q");
const l = params.get("lang");
if (t && l) {
setTerm(t);
setLang(l);
setQ(t);
}
if (params.get("view")) setView(params.get("view") as ViewName);
if (params.get("depth")) setDepth(Number(params.get("depth")));
}, []);
useEffect(() => {
if (!dq) {
setHits(searchOpen && meta ? meta.examples : []);
return;
}
let alive = true;
api.suggest(dq).then((r) => {
if (alive) {
setHits(r.hits);
setActiveHit(0);
}
});
return () => {
alive = false;
};
}, [dq, meta, searchOpen]);
useEffect(() => {
if (!searchOpen) return;
const onPointerDown = (e: PointerEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setSearchOpen(false);
}
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [searchOpen]);
const query: TreeQuery | null = useMemo(() => {
if (!term || !lang) return null;
return {
term,
lang,
edges: { relations, min_confidence: conf, max_depth: depth, max_visit: 50000 },
leaf,
path: {
node: pathNode,
quantifier: pathMatch,
exactly_k: 1,
relations_any: pathRelAny,
relations_none: pathRelNone,
apply_to_root: false,
},
expand,
cluster_threshold: 28,
max_payload: 2500,
color_by: colorBy,
};
}, [term, lang, relations, conf, depth, leaf, pathNode, pathMatch, pathRelAny, pathRelNone, expand, colorBy]);
useEffect(() => {
if (!query) return;
const ac = new AbortController();
setLoading(true);
setError(null);
api
.tree(query, ac.signal)
.then((r) => {
setTree(r);
setLoading(false);
const url = new URL(window.location.href);
url.searchParams.set("q", query.term);
url.searchParams.set("lang", query.lang);
url.searchParams.set("view", view);
url.searchParams.set("depth", String(depth));
history.replaceState(null, "", url);
})
.catch((e) => {
if (e.name !== "AbortError") {
setError(String(e));
setLoading(false);
}
});
return () => ac.abort();
}, [query, view, depth]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || (view !== "tree" && view !== "radial")) {
if (viewRef.current) {
viewRef.current.destroy();
viewRef.current = null;
}
return;
}
if (viewRef.current?.canvas !== canvas) {
viewRef.current?.destroy();
viewRef.current = new CanvasView(canvas);
}
const cv = viewRef.current;
cv.onSelect = (n) => {
if (n.kind === "cluster") return;
setSelected(n);
setMobilePanel("info");
};
cv.onExpand = (key) => setExpand((e) => (e.includes(key) ? e : [...e, key]));
if (tree?.nodes) {
const camKey = `${term}|${lang}|${view}`;
const resetCamera = cameraKeyRef.current !== camKey;
cameraKeyRef.current = camKey;
cv.setData(tree.nodes, view === "radial" ? "radial" : "tree", colorBy, resetCamera);
}
}, [tree, view, colorBy, term, lang]);
useEffect(() => {
viewRef.current?.setSelected(selected ? String(selected.id) : null);
}, [selected]);
useEffect(() => {
return () => {
viewRef.current?.destroy();
viewRef.current = null;
};
}, []);
useEffect(() => {
if (!selected || selected.kind !== "word") {
setDetail(null);
setDefinition(null);
setDefinitionLoading(false);
return;
}
let alive = true;
api
.node(selected.term, selected.lang)
.then((d) => {
if (alive) setDetail(d);
})
.catch(() => {
if (alive) setDetail(null);
});
setDefinitionLoading(true);
setDefinition(null);
api
.define(selected.term, selected.lang, selected.iso_639_3)
.then((d) => {
if (alive) setDefinition(d);
})
.catch(() => {
if (alive) setDefinition({ term: selected.term, senses: [], error: "fetch_failed" });
})
.finally(() => {
if (alive) setDefinitionLoading(false);
});
return () => {
alive = false;
};
}, [selected]);
useEffect(() => {
if (view !== "map" || !mapRef.current || !tree) return;
if (!mapObj.current) {
mapObj.current = L.map(mapRef.current, { zoomControl: true }).setView([30, 10], 2);
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
attribution: "&copy; OSM &copy; CARTO",
}).addTo(mapObj.current);
}
const map = mapObj.current;
map.eachLayer((layer) => {
if (layer instanceof L.CircleMarker) map.removeLayer(layer);
});
const groups = new Map<string, { lat: number; lon: number; words: GraphNode[] }>();
for (const n of tree.nodes) {
if (n.kind !== "word" || n.latitude == null || n.longitude == null) continue;
const key = n.lang;
const g = groups.get(key) || { lat: n.latitude, lon: n.longitude, words: [] };
g.words.push(n);
groups.set(key, g);
}
for (const [key, g] of groups) {
L.circleMarker([g.lat, g.lon], {
radius: Math.min(18, 4 + Math.sqrt(g.words.length) * 2),
color: REL_COLORS.inherited,
fillOpacity: 0.7,
})
.bindPopup(`<b>${g.words[0].lang_display || key}</b><br/>${g.words.length} word(s)<br/>${g.words.slice(0, 8).map((w) => w.term).join(", ")}`)
.addTo(map);
}
setTimeout(() => map.invalidateSize(), 50);
}, [view, tree]);
const pick = (hit: SuggestHit) => {
setTerm(hit.term);
setLang(hit.lang);
setQ(hit.term);
setHits([]);
setSearchOpen(false);
setExpand([]);
setSelected(null);
setMobilePanel("none");
};
const chipCount =
leaf.languages.length +
leaf.families.length +
(leaf.max_rank ? 1 : 0) +
(leaf.min_zipf != null ? 1 : 0) +
pathNode.languages.length +
pathNode.families.length +
pathRelAny.length +
(relations.length !== DEFAULT_RELS.length ? 1 : 0);
const words = tree?.nodes.filter((n) => n.kind === "word") || [];
const showQr = async () => {
setQr(await QRCode.toDataURL(window.location.href, { margin: 1, width: 360 }));
};
return (
<div className="app">
<header className="topbar">
<div className="brand">
<strong>Reverse Etymology</strong>
<span>Words derived from a given etymon</span>
</div>
<div className="search-wrap" ref={searchRef}>
<span className="search-icon"></span>
<input
value={q}
placeholder="Search a word — mater, *méh₂tēr, caput…"
onChange={(e) => {
setQ(e.target.value);
setSearchOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") setActiveHit((i) => Math.min(hits.length - 1, i + 1));
if (e.key === "ArrowUp") setActiveHit((i) => Math.max(0, i - 1));
if (e.key === "Enter" && hits[activeHit]) pick(hits[activeHit]);
if (e.key === "Escape") setSearchOpen(false);
}}
onFocus={() => {
setSearchOpen(true);
if (!q && meta) setHits(meta.examples);
}}
/>
{searchOpen && hits.length > 0 && (
<div className="suggest">
{hits.map((h, i) => (
<button key={`${h.term}-${h.lang}-${i}`} className={i === activeHit ? "active" : ""} type="button" onClick={() => pick(h)}>
<span className="term">{h.term}</span>
<span>{h.lang_display || h.lang}</span>
<span className="meta">{h.child_count} derived</span>
</button>
))}
</div>
)}
</div>
<button type="button" className="ghost filters-btn" onClick={() => setMobilePanel(mobilePanel === "filters" ? "none" : "filters")}>
Filters{chipCount ? ` ${chipCount}` : ""}
</button>
</header>
<div className="toolbar">
<div className="seg">
{(["tree", "radial", "map", "table", "stats"] as ViewName[]).map((v) => (
<button key={v} className={view === v ? "on" : ""} onClick={() => setView(v)}>
{v}
</button>
))}
</div>
<select value={colorBy} onChange={(e) => setColorBy(e.target.value as ColorBy)}>
<option value="relation">Color: relation</option>
<option value="family">Color: family</option>
<option value="confidence">Color: confidence</option>
<option value="depth">Color: depth</option>
<option value="macroarea">Color: area</option>
</select>
{term && (
<span className="chip">
<b>{term}</b> {lang}
</span>
)}
{leaf.languages.map((l) => (
<button key={l} className="chip" onClick={() => setLeaf({ ...leaf, languages: leaf.languages.filter((x) => x !== l) })}>
leaf:{l} ×
</button>
))}
{pathNode.languages.map((l) => (
<button key={l} className="chip" onClick={() => setPathNode({ ...pathNode, languages: pathNode.languages.filter((x) => x !== l) })}>
path:{l} ×
</button>
))}
<span style={{ flex: 1 }} />
<button className="ghost" onClick={showQr}>
Phone
</button>
{query && (
<button className="ghost" onClick={() => api.exportUrl(query)}>
CSV
</button>
)}
</div>
<div className="workspace">
{mobilePanel !== "none" && (
<button type="button" className="sheet-backdrop" aria-label="Close panel" onClick={() => setMobilePanel("none")} />
)}
<aside className={`panel ${mobilePanel === "filters" ? "open-sheet" : ""}`}>
<div className="sheet-grab">
<span />
<strong>Filters</strong>
<button type="button" className="ghost sheet-close" onClick={() => setMobilePanel("none")}>
Close
</button>
</div>
<div className="sheet-body">
{meta && (
<>
<div className="tabs">
<button type="button" className={filterTab === "graph" ? "on" : ""} onClick={() => setFilterTab("graph")}>
Graph
</button>
<button type="button" className={filterTab === "leaf" ? "on" : ""} onClick={() => setFilterTab("leaf")}>
Leaves
</button>
<button type="button" className={filterTab === "path" ? "on" : ""} onClick={() => setFilterTab("path")}>
Path
</button>
</div>
{filterTab === "graph" && (
<div>
<p style={{ color: "var(--muted)", fontSize: 13 }}>
Construction filters apply to every hop before leaf/path tests.
</p>
<label className="field">Max depth {depth}</label>
<input type="range" min={1} max={8} value={depth} onChange={(e) => setDepth(Number(e.target.value))} />
<label className="field">Min confidence {conf.toFixed(2)}</label>
<input type="range" min={70} max={100} value={conf * 100} onChange={(e) => setConf(Number(e.target.value) / 100)} />
<label className="field">Relations</label>
<div className="rel-grid">
{meta.relations.map((r) => (
<label key={r}>
<input
type="checkbox"
checked={relations.includes(r)}
onChange={() => setRelations(relations.includes(r) ? relations.filter((x) => x !== r) : [...relations, r])}
/>
<i className="dot" style={{ background: REL_COLORS[r] }} />
{r.replace("_", " ")}
</label>
))}
</div>
</div>
)}
{filterTab === "leaf" && (
<div>
<p style={{ color: "var(--muted)", fontSize: 13 }}>
Matching words in the filtered language stay visible even when they have further descendants.
</p>
<PredicateEditor meta={meta} value={leaf} onChange={setLeaf} showPopularity />
</div>
)}
{filterTab === "path" && (
<div>
<p style={{ color: "var(--muted)", fontSize: 13 }}>
Constraints on intermediate hops between the etymon and each leaf.
</p>
<label className="field">Quantifier</label>
<select value={pathMatch} onChange={(e) => setPathMatch(e.target.value as Quantifier)}>
<option value="any">any hop matches</option>
<option value="all">every hop matches</option>
<option value="none">no hop matches</option>
<option value="exactly">exactly one hop matches</option>
</select>
<PredicateEditor
meta={meta}
value={pathNode}
onChange={setPathNode}
extra={
<>
<label className="field">Path must include relation</label>
<div className="rel-grid">
{meta.relations.map((r) => (
<label key={r}>
<input
type="checkbox"
checked={pathRelAny.includes(r)}
onChange={() =>
setPathRelAny(pathRelAny.includes(r) ? pathRelAny.filter((x) => x !== r) : [...pathRelAny, r])
}
/>
{r}
</label>
))}
</div>
<label className="field">Path must exclude relation</label>
<div className="rel-grid">
{meta.relations.map((r) => (
<label key={`n${r}`}>
<input
type="checkbox"
checked={pathRelNone.includes(r)}
onChange={() =>
setPathRelNone(pathRelNone.includes(r) ? pathRelNone.filter((x) => x !== r) : [...pathRelNone, r])
}
/>
{r}
</label>
))}
</div>
</>
}
/>
</div>
)}
</>
)}
</div>
</aside>
<main className="stage">
{!term && meta && (
<div className="examples">
{meta.examples.map((ex) => (
<button key={`${ex.term}-${ex.lang}`} onClick={() => pick(ex)}>
<span className="t">{ex.term}</span>
<span>{ex.lang_display || ex.lang}</span>
<span className="b">{ex.blurb || `${ex.child_count} derived`}</span>
</button>
))}
</div>
)}
{(view === "tree" || view === "radial") && <canvas ref={canvasRef} className="viz-canvas" />}
{view === "map" && <div className="map" ref={mapRef} />}
{view === "table" && (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Term</th>
<th>Language</th>
<th>Family</th>
<th>Relation</th>
<th>Conf</th>
<th>Depth</th>
</tr>
</thead>
<tbody>
{words.map((n) => (
<tr
key={String(n.id)}
onClick={() => {
setSelected(n);
setMobilePanel("info");
}}
>
<td>{n.term}</td>
<td>{n.lang_display || n.lang}</td>
<td>{n.family_name}</td>
<td>{n.relation}</td>
<td>{n.confidence?.toFixed(2)}</td>
<td>{n.depth}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{view === "stats" && tree && (
<div className="stats">
<h3>Relations</h3>
<Bars data={tree.stats.relations || {}} />
<h3>Families</h3>
<Bars data={tree.stats.families || {}} />
<h3>Depth</h3>
<Bars data={tree.stats.depths || {}} />
</div>
)}
<div className="legend">
{Object.entries(REL_COLORS).map(([k, c]) => (
<span key={k}>
<i className="dot" style={{ background: c }} /> {k.replace("_", " ")}
</span>
))}
</div>
<div className="status-line">
{loading ? "querying…" : error ? error : tree ? `${tree.stats.kept_leaves} leaves · ${tree.stats.payload} shown · ${tree.stats.elapsed_ms} ms` : "Pick a word"}
{tree?.truncated ? " · truncated" : ""}
</div>
</main>
<aside className={`panel right inspector ${mobilePanel === "info" ? "open-sheet" : ""}`}>
<div className="sheet-grab">
<span />
<strong>Inspector</strong>
<button type="button" className="ghost sheet-close" onClick={() => setMobilePanel("none")}>
Close
</button>
</div>
<div className="sheet-body">
{selected ? (
<>
<h2>{selected.term}</h2>
<div>
{selected.lang_display || selected.lang}
{selected.family_name ? ` · ${selected.family_name}` : ""}
</div>
<div className="inspector-def">
<h3>Definition</h3>
{definitionLoading ? (
<p className="modal-muted">Looking up Wiktionary…</p>
) : definition?.senses?.length ? (
<>
{definition.fallback ? (
<p className="modal-muted">No exact language match — showing [{definition.matched_code}].</p>
) : null}
<div className="modal-senses">
{definition.senses.map((s) => (
<div key={s.pos} className="modal-sense">
<div className="modal-pos">{s.pos}</div>
<ol>
{s.glosses.map((g) => (
<li key={g}>{g}</li>
))}
</ol>
</div>
))}
</div>
</>
) : (
<p className="modal-muted">
No Wiktionary gloss found
{definition?.error ? ` (${definition.error})` : ""}.
</p>
)}
</div>
<dl className="kv">
<dt>Relation</dt>
<dd>{selected.relation || "root"}</dd>
<dt>Confidence</dt>
<dd>{selected.confidence ?? "—"}</dd>
<dt>Depth</dt>
<dd>{selected.depth}</dd>
<dt>Children</dt>
<dd>{selected.child_count}</dd>
<dt>Area</dt>
<dd>{selected.macroarea || "—"}</dd>
<dt>Glottocode</dt>
<dd>{selected.glottocode || "—"}</dd>
<dt>Zipf</dt>
<dd>{selected.zipf != null ? selected.zipf : "—"}</dd>
<dt>Freq rank</dt>
<dd>{selected.freq_rank != null ? selected.freq_rank.toLocaleString() : "—"}</dd>
</dl>
{selected.kind === "word" && (
<div className="row">
<button className="ghost primary" onClick={() => pick({ term: selected.term, lang: selected.lang, child_count: selected.child_count || 0 })}>
Descend from here
</button>
<button className="ghost" onClick={() => setLeaf({ ...leaf, languages: [selected.lang] })}>
Leaf = this language
</button>
</div>
)}
{detail && (
<>
<h3>Comes from</h3>
<div className="path-list">
{((detail.etymons as SuggestHit[]) || []).map((e, i) => (
<button key={i} onClick={() => pick({ term: e.term, lang: e.lang, child_count: 0 })}>
{e.term} · {e.lang} · {(e as unknown as { relation?: string }).relation}
</button>
))}
</div>
{!!(detail.wiktionary as string) && (
<p>
<a href={detail.wiktionary as string} target="_blank" rel="noreferrer" style={{ color: "var(--teal)" }}>
Wiktionary
</a>
</p>
)}
</>
)}
</>
) : (
<p style={{ color: "var(--muted)" }}>
Select a node to inspect etymons, typology, and phoneme inventory. On a phone, filters and this inspector become bottom sheets.
</p>
)}
{qr && (
<div className="qr">
<h3>Open this view on a phone</h3>
<img src={qr} alt="QR code of current URL" />
</div>
)}
{meta?.citation && (
<p style={{ color: "var(--muted)", fontSize: 12, marginTop: 24 }}>
Data: {meta.citation.author}, Etymology Atlas ({meta.citation.license}). {meta.nodes.toLocaleString()} words, {meta.edges.toLocaleString()} relations.
</p>
)}
</div>
</aside>
</div>
</div>
);
}
function Bars({ data }: { data: Record<string, number> }) {
const max = Math.max(1, ...Object.values(data));
return (
<div className="bars">
{Object.entries(data).map(([k, v]) => (
<div className="bar-row" key={k}>
<span>{k}</span>
<div className="bar">
<i style={{ width: `${(100 * v) / max}%` }} />
</div>
<span>{v}</span>
</div>
))}
</div>
);
}