Spaces:
Sleeping
Sleeping
| 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: "© OSM © 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> | |
| ); | |
| } | |