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(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 (
setQ(e.target.value)} />
{filtered.map((o) => ( ))}
); } 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 (
onChange({ ...value, languages })} placeholder="Filter languages" /> onChange({ ...value, families })} placeholder="Filter families" />
{meta.macroareas.map((m) => ( ))}
onChange({ ...value, term_contains: e.target.value })} /> onChange({ ...value, phonemes_have: e.target.value.split(/[\s,]+/).filter(Boolean) })} placeholder="θ ʃ" /> onChange({ ...value, phonemes_lack: e.target.value.split(/[\s,]+/).filter(Boolean) })} /> {showPopularity && ( <>

Modern lects via wordfreq (Zipf); fallback ranked lists for extra languages. Historical forms usually have no score.

{ const raw = Number(e.target.value); onChange({ ...value, min_zipf: raw <= 0 ? null : raw / 10 }); }} />

Zipf ≈ 3 rare · 4 uncommon · 5 everyday · 6+ very common. 0 = no minimum.

)} {value.wals[0] && ( 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" /> )} {extra}
); } export default function App() { const [meta, setMeta] = useState(null); const [q, setQ] = useState(""); const [hits, setHits] = useState([]); const [searchOpen, setSearchOpen] = useState(false); const [activeHit, setActiveHit] = useState(0); const [term, setTerm] = useState(""); const [lang, setLang] = useState(""); const [view, setView] = useState("tree"); const [colorBy, setColorBy] = useState("relation"); const [relations, setRelations] = useState(DEFAULT_RELS); const [conf, setConf] = useState(0.7); const [depth, setDepth] = useState(3); const [leaf, setLeaf] = useState(emptyPredicate()); const [pathNode, setPathNode] = useState(emptyPredicate()); const [pathMatch, setPathMatch] = useState("any"); const [pathRelAny, setPathRelAny] = useState([]); const [pathRelNone, setPathRelNone] = useState([]); const [expand, setExpand] = useState([]); const [tree, setTree] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [detail, setDetail] = useState | null>(null); const [definition, setDefinition] = useState(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(null); const canvasRef = useRef(null); const viewRef = useRef(null); const mapRef = useRef(null); const mapObj = useRef(null); const searchRef = useRef(null); const cameraKeyRef = useRef(""); 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: "© OSM © CARTO", }).addTo(mapObj.current); } const map = mapObj.current; map.eachLayer((layer) => { if (layer instanceof L.CircleMarker) map.removeLayer(layer); }); const groups = new Map(); 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(`${g.words[0].lang_display || key}
${g.words.length} word(s)
${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 (
Reverse Etymology Words derived from a given etymon
{ 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 && (
{hits.map((h, i) => ( ))}
)}
{(["tree", "radial", "map", "table", "stats"] as ViewName[]).map((v) => ( ))}
{term && ( {term} {lang} )} {leaf.languages.map((l) => ( ))} {pathNode.languages.map((l) => ( ))} {query && ( )}
{mobilePanel !== "none" && (
{meta && ( <>
{filterTab === "graph" && (

Construction filters apply to every hop before leaf/path tests.

setDepth(Number(e.target.value))} /> setConf(Number(e.target.value) / 100)} />
{meta.relations.map((r) => ( ))}
)} {filterTab === "leaf" && (

Matching words in the filtered language stay visible even when they have further descendants.

)} {filterTab === "path" && (

Constraints on intermediate hops between the etymon and each leaf.

{meta.relations.map((r) => ( ))}
{meta.relations.map((r) => ( ))}
} />
)} )}
{!term && meta && (
{meta.examples.map((ex) => ( ))}
)} {(view === "tree" || view === "radial") && } {view === "map" &&
} {view === "table" && (
{words.map((n) => ( { setSelected(n); setMobilePanel("info"); }} > ))}
Term Language Family Relation Conf Depth
{n.term} {n.lang_display || n.lang} {n.family_name} {n.relation} {n.confidence?.toFixed(2)} {n.depth}
)} {view === "stats" && tree && (

Relations

Families

Depth

)}
{Object.entries(REL_COLORS).map(([k, c]) => ( {k.replace("_", " ")} ))}
{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" : ""}
); } function Bars({ data }: { data: Record }) { const max = Math.max(1, ...Object.values(data)); return (
{Object.entries(data).map(([k, v]) => (
{k}
{v}
))}
); }