lucharo Luis Chaves Rodriguez commited on
Commit
13812dc
·
0 Parent(s):

Super-squash branch 'main' using huggingface_hub

Browse files

Co-authored-by: Luis Chaves Rodriguez <Luis Chaves Rodriguez@users.noreply.huggingface.co>

Files changed (44) hide show
  1. .gitattributes +2 -0
  2. Dockerfile +51 -0
  3. README.md +90 -0
  4. backend/__init__.py +0 -0
  5. backend/data/etymdb.duckdb.zst +3 -0
  6. backend/database.py +357 -0
  7. backend/download_data.py +52 -0
  8. backend/download_language_codes.py +80 -0
  9. backend/enrich_definitions.py +228 -0
  10. backend/ingest.py +112 -0
  11. backend/main.py +108 -0
  12. backend/sql/enrichment/check_table_exists.sql +3 -0
  13. backend/sql/enrichment/create_definition_indexes.sql +2 -0
  14. backend/sql/enrichment/enrichment_stats.sql +3 -0
  15. backend/sql/enrichment/materialize_definitions.sql +36 -0
  16. backend/sql/enrichment/words_to_enrich.sql +5 -0
  17. backend/sql/enrichment/words_to_enrich_initial.sql +3 -0
  18. backend/sql/ingestion/01_drop_tables.sql +3 -0
  19. backend/sql/ingestion/02_create_words.sql +13 -0
  20. backend/sql/ingestion/03_create_links.sql +10 -0
  21. backend/sql/ingestion/04_create_sequences.sql +5 -0
  22. backend/sql/ingestion/05_create_indexes.sql +6 -0
  23. backend/sql/ingestion/06_create_macros.sql +11 -0
  24. backend/sql/ingestion/07_create_views.sql +23 -0
  25. backend/sql/ingestion/08_create_language_families.sql +8 -0
  26. backend/sql/ingestion/09_create_definitions_raw.sql +6 -0
  27. backend/sql/queries/find_start_word.sql +10 -0
  28. backend/sql/queries/get_language_families.sql +2 -0
  29. backend/sql/queries/get_language_info.sql +3 -0
  30. backend/sql/queries/search_words.sql +15 -0
  31. backend/sql/queries/traverse_etymology.sql +42 -0
  32. backend/sql_loader.py +19 -0
  33. cloudflare-worker/worker.js +7 -0
  34. cloudflare-worker/wrangler.toml +8 -0
  35. frontend/index.html +456 -0
  36. frontend/js/app.js +600 -0
  37. frontend/js/graph.js +375 -0
  38. frontend/js/search.js +93 -0
  39. frontend/js/tree.js +118 -0
  40. frontend/js/ui.js +226 -0
  41. frontend/js/utils.js +59 -0
  42. frontend/styles.css +1423 -0
  43. pyproject.toml +48 -0
  44. uv.lock +0 -0
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *.duckdb filter=lfs diff=lfs merge=lfs -text
2
+ *.zst filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Etymology Graph Explorer
2
+ # Multi-stage build: uv for building, plain Python for runtime
3
+
4
+ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
5
+
6
+ WORKDIR /app
7
+
8
+ # Copy dependency files first for layer caching
9
+ COPY pyproject.toml uv.lock ./
10
+
11
+ # Install production dependencies only (no dev, no project yet)
12
+ RUN uv sync --locked --no-install-project --no-dev
13
+
14
+ # Copy application code
15
+ COPY backend/ backend/
16
+ COPY frontend/ frontend/
17
+
18
+ # Production stage - plain Python, no uv needed at runtime
19
+ FROM python:3.12-slim-bookworm
20
+
21
+ WORKDIR /app
22
+
23
+ # Install zstd for decompression (only needed if DB is compressed)
24
+ RUN apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/*
25
+
26
+ # Copy only what we need from builder
27
+ COPY --from=builder /app/.venv /app/.venv
28
+ COPY --from=builder /app/backend /app/backend
29
+ COPY --from=builder /app/frontend /app/frontend
30
+ COPY --from=builder /app/pyproject.toml /app/pyproject.toml
31
+
32
+ # Copy database (compressed or uncompressed) and decompress if needed
33
+ COPY backend/data/etymdb.duckdb* /app/backend/data/
34
+ RUN if [ -f /app/backend/data/etymdb.duckdb.zst ]; then \
35
+ echo "Decompressing database..." && \
36
+ zstd -d /app/backend/data/etymdb.duckdb.zst -o /app/backend/data/etymdb.duckdb && \
37
+ rm /app/backend/data/etymdb.duckdb.zst; \
38
+ fi
39
+
40
+ # Use the virtual environment
41
+ ENV PATH="/app/.venv/bin:$PATH"
42
+
43
+ # Port configuration (HF Spaces uses 7860)
44
+ ENV PORT=7860
45
+ EXPOSE ${PORT}
46
+
47
+ # Health check
48
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
49
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:${PORT}/health')" || exit 1
50
+
51
+ CMD uvicorn backend.main:app --host 0.0.0.0 --port ${PORT}
README.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Etymology Graph Explorer
3
+ emoji: 🌳
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
+
10
+ # Etymology Graph Explorer
11
+
12
+ A visual tool for exploring the origins and historical relationships between words. Search for any word and see its etymological journey through time, from modern usage back to ancient roots.
13
+
14
+ ## Quick Start
15
+
16
+ ### Using uv (recommended for development)
17
+
18
+ ```bash
19
+ # Install dependencies
20
+ uv sync
21
+
22
+ # Run the server
23
+ uv run uvicorn backend.main:app --reload
24
+
25
+ # Open http://localhost:8000 in your browser
26
+ ```
27
+
28
+ ### Using Docker
29
+
30
+ ```bash
31
+ # Build the image
32
+ docker build -t etymology .
33
+
34
+ # Run the container
35
+ docker run -p 7860:7860 etymology
36
+
37
+ # Open http://localhost:7860 in your browser
38
+ ```
39
+
40
+ ## Features
41
+
42
+ - **Search any word** to see its etymological tree
43
+ - **Random word** button for exploration and discovery
44
+ - **Interactive graph** - zoom, pan, and click on nodes
45
+ - **Color-coded languages** to visualize word evolution across language families
46
+ - **Mobile-friendly** responsive design
47
+
48
+ ## API Endpoints
49
+
50
+ | Endpoint | Description |
51
+ |----------|-------------|
52
+ | `GET /` | Web interface |
53
+ | `GET /graph/{word}` | Etymology graph for a word (JSON) |
54
+ | `GET /search?q=<query>&limit=<n>` | Search/autocomplete for words |
55
+ | `GET /random` | Random English word |
56
+ | `GET /version` | App version and database stats |
57
+ | `GET /health` | Health check |
58
+
59
+ ## Data Source
60
+
61
+ Etymology data comes from [EtymDB 2.1](https://github.com/clefourrier/EtymDB), an open etymological database derived from Wiktionary.
62
+
63
+ > Fourrier & Sagot (2020), "Methodological Aspects of Developing and Managing an Etymological Lexical Resource: Introducing EtymDB-2.0", Proceedings of the LREC Conference.
64
+
65
+ ## Tech Stack
66
+
67
+ - **Backend**: FastAPI + DuckDB
68
+ - **Frontend**: Vanilla JS + Cytoscape.js
69
+ - **Data**: EtymDB 2.1 (auto-downloaded on first run)
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ uv sync # Install dependencies
75
+ uv run prek install # Set up pre-commit hooks
76
+ uv run pytest backend/tests -q # Run tests
77
+ ```
78
+
79
+ Linting (ruff) runs automatically on commit via prek.
80
+
81
+ ## Deploy
82
+
83
+ ```bash
84
+ make hf-deploy # Deploy to HF Spaces (stages, pushes, squashes history)
85
+ make cf-deploy # Deploy Cloudflare Worker (custom domain proxy)
86
+ ```
87
+
88
+ ## License
89
+
90
+ GPL-3.0 - see [LICENSE](LICENSE)
backend/__init__.py ADDED
File without changes
backend/data/etymdb.duckdb.zst ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a9d682f0cba4f9f1a118007906c58d3b446c5b4e9aaf6eadbbde775a672c8d12
3
+ size 92564012
backend/database.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Database helpers for the FastAPI application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+
9
+ import duckdb
10
+
11
+ try:
12
+ from .sql_loader import load_sql
13
+ except ImportError: # pragma: no cover - fallback for direct execution
14
+ from sql_loader import load_sql
15
+
16
+ BASE_DIR = Path(__file__).resolve().parent
17
+ DEFAULT_DATA_DIR = BASE_DIR / "data"
18
+
19
+
20
+ def _resolve_path(env_var: str, default: Path) -> Path:
21
+ value = os.environ.get(env_var)
22
+ return Path(value) if value else default
23
+
24
+
25
+ def _data_dir() -> Path:
26
+ return _resolve_path("ETYM_DATA_DIR", DEFAULT_DATA_DIR)
27
+
28
+
29
+ @lru_cache(maxsize=1)
30
+ def database_path() -> Path:
31
+ """Return the configured DuckDB path, creating parent directories."""
32
+ path = _resolve_path("ETYM_DB_PATH", _data_dir() / "etymdb.duckdb")
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ return path
35
+
36
+
37
+ def _ensure_database() -> Path:
38
+ db_path = database_path()
39
+ if db_path.exists():
40
+ return db_path
41
+
42
+ try:
43
+ from . import ingest # type: ignore[attr-defined]
44
+ except ImportError: # pragma: no cover - fallback for direct execution
45
+ import ingest
46
+
47
+ try:
48
+ ingest.main()
49
+ except Exception as exc: # pragma: no cover - propagation with context
50
+ raise RuntimeError("Failed to ingest the EtymDB dataset") from exc
51
+
52
+ if not db_path.exists():
53
+ raise RuntimeError(f"Expected DuckDB database at {db_path} after ingestion")
54
+ return db_path
55
+
56
+
57
+ class _ConnectionManager:
58
+ """Lazily open DuckDB connections when required."""
59
+
60
+ def __init__(self) -> None:
61
+ self._conn: duckdb.DuckDBPyConnection | None = None
62
+
63
+ def __enter__(self) -> duckdb.DuckDBPyConnection:
64
+ db_path = _ensure_database()
65
+ self._conn = duckdb.connect(db_path.as_posix(), read_only=True)
66
+ return self._conn
67
+
68
+ def __exit__(self, exc_type, exc, tb) -> None:
69
+ if self._conn is not None:
70
+ self._conn.close()
71
+
72
+
73
+ def _normalize_depth(depth: int) -> int:
74
+ return max(depth, 0)
75
+
76
+
77
+ def _get_language_families(conn) -> dict[str, dict[str, str]]:
78
+ """Load language families into a lookup dict."""
79
+ rows = conn.execute(load_sql("queries/get_language_families.sql")).fetchall()
80
+ return {row[0]: {"name": row[1], "family": row[2], "branch": row[3]} for row in rows}
81
+
82
+
83
+ def _get_definitions_for_lexemes(conn, lexemes: list[str]) -> dict[str, str]:
84
+ """Fetch primary definitions for the specified lexemes.
85
+
86
+ Returns dict mapping lowercase lexeme -> definition string.
87
+ Uses the first definition (entry_idx=0, meaning_idx=0, def_idx=0).
88
+ """
89
+ if not lexemes:
90
+ return {}
91
+ try:
92
+ placeholders = ",".join(["?" for _ in lexemes])
93
+ rows = conn.execute(
94
+ f"""
95
+ SELECT lexeme, definition
96
+ FROM definitions
97
+ WHERE lexeme IN ({placeholders})
98
+ AND definition IS NOT NULL
99
+ AND entry_idx = 0 AND meaning_idx = 0 AND def_idx = 0
100
+ """,
101
+ [lex.lower() for lex in lexemes],
102
+ ).fetchall()
103
+ return {row[0]: row[1].strip('"') if row[1] else None for row in rows}
104
+ except Exception:
105
+ return {}
106
+
107
+
108
+ def _make_node_id(lexeme: str, lang: str) -> str:
109
+ """Create a unique node ID combining lexeme and language."""
110
+ return f"{lexeme}|{lang}"
111
+
112
+
113
+ def _build_node(
114
+ lexeme: str,
115
+ lang: str,
116
+ sense: str,
117
+ lang_families: dict,
118
+ enriched_defs: dict[str, str] | None = None,
119
+ ) -> dict:
120
+ """Build a rich node with all available metadata.
121
+
122
+ Args:
123
+ lexeme: The word
124
+ lang: Language code
125
+ sense: EtymDB sense/definition
126
+ lang_families: Language family lookup dict
127
+ enriched_defs: Optional dict of enriched definitions from Free Dictionary API
128
+ """
129
+ node = {
130
+ "id": _make_node_id(lexeme, lang), # Unique ID includes language
131
+ "lexeme": lexeme, # Display name
132
+ "lang": lang,
133
+ }
134
+
135
+ # Determine best definition to use
136
+ # Priority: enriched definition (for English) > EtymDB sense
137
+ definition = None
138
+ if enriched_defs and lang == "en" and lexeme.lower() in enriched_defs:
139
+ definition = enriched_defs[lexeme.lower()]
140
+ elif sense and sense.lower() != lexeme.lower():
141
+ definition = sense
142
+
143
+ if definition:
144
+ node["sense"] = definition
145
+
146
+ # Add language metadata if available
147
+ lang_info = lang_families.get(lang)
148
+ if lang_info:
149
+ node["lang_name"] = lang_info["name"]
150
+ node["family"] = lang_info["family"]
151
+ node["branch"] = lang_info["branch"]
152
+ else:
153
+ # Fallback: use lang code as name
154
+ node["lang_name"] = lang
155
+ return node
156
+
157
+
158
+ def get_db_stats() -> dict:
159
+ """Return row counts for key tables."""
160
+ with _ConnectionManager() as conn:
161
+ words = conn.execute("SELECT COUNT(*) FROM v_english_curated").fetchone()[0]
162
+ try:
163
+ definitions = conn.execute("SELECT COUNT(*) FROM definitions").fetchone()[0]
164
+ except Exception:
165
+ definitions = 0
166
+ return {"words": words, "definitions": definitions}
167
+
168
+
169
+ def fetch_etymology(word: str, depth: int = 5) -> dict | None:
170
+ """Return an etymology graph for *word* or ``None`` if absent."""
171
+ if not word:
172
+ return None
173
+
174
+ depth = _normalize_depth(depth)
175
+ with _ConnectionManager() as conn:
176
+ # Load language families (small table, 53 rows)
177
+ lang_families = _get_language_families(conn)
178
+
179
+ # Find starting word (prefer English, then most etymology links)
180
+ start = conn.execute(
181
+ load_sql("queries/find_start_word.sql"),
182
+ [word],
183
+ ).fetchone()
184
+ if not start:
185
+ return None
186
+
187
+ start_ix, start_lang, start_lexeme, start_sense = start
188
+
189
+ # Collect all node data first (without definitions)
190
+ raw_nodes: dict[int, tuple] = {start_ix: (start_lexeme, start_lang, start_sense)}
191
+ edges = []
192
+ seen_edges: set[tuple[str, str]] = set()
193
+
194
+ if depth > 0:
195
+ # Recursive traversal that handles both simple links and compound etymologies
196
+ # When target < 0, it's a sequence ID that resolves to multiple parent words
197
+ # Track is_compound to style compound edges differently in the UI
198
+ records = conn.execute(
199
+ load_sql("queries/traverse_etymology.sql"),
200
+ [start_ix, depth],
201
+ ).fetchall()
202
+
203
+ for row in records:
204
+ child_ix, child_lexeme, child_lang, child_sense = row[:4]
205
+ parent_ix, parent_lexeme, parent_lang, parent_sense = row[4:8]
206
+ is_compound = row[8]
207
+ link_type = row[9]
208
+
209
+ raw_nodes.setdefault(child_ix, (child_lexeme, child_lang, child_sense))
210
+ raw_nodes.setdefault(parent_ix, (parent_lexeme, parent_lang, parent_sense))
211
+
212
+ # Build edges with compound flag and link type for UI styling
213
+ child_id = _make_node_id(child_lexeme, child_lang)
214
+ parent_id = _make_node_id(parent_lexeme, parent_lang)
215
+ if child_id != parent_id:
216
+ edge_key = (child_id, parent_id)
217
+ if edge_key not in seen_edges:
218
+ seen_edges.add(edge_key)
219
+ edge = {"source": child_id, "target": parent_id}
220
+ if is_compound:
221
+ edge["compound"] = True
222
+ if link_type:
223
+ edge["type"] = link_type
224
+ edges.append(edge)
225
+
226
+ # Fetch definitions only for English lexemes in this graph
227
+ english_lexemes = [lex for lex, lang, _ in raw_nodes.values() if lang == "en"]
228
+ enriched_defs = _get_definitions_for_lexemes(conn, english_lexemes)
229
+
230
+ # Build final nodes with all metadata
231
+ nodes = {
232
+ ix: _build_node(lexeme, lang, sense, lang_families, enriched_defs)
233
+ for ix, (lexeme, lang, sense) in raw_nodes.items()
234
+ }
235
+
236
+ # Word exists but has no etymology links
237
+ if not edges:
238
+ return {
239
+ "nodes": list(nodes.values()),
240
+ "edges": [],
241
+ "no_etymology": True,
242
+ "lexeme": start_lexeme,
243
+ }
244
+
245
+ return {"nodes": list(nodes.values()), "edges": edges}
246
+
247
+
248
+ def fetch_random_word(include_compound: bool = True) -> dict[str, str | None]:
249
+ """Return a random curated English word (has etymology, no phrases/proper nouns).
250
+
251
+ Args:
252
+ include_compound: If True, include compound-only words (e.g., "acquaintanceship").
253
+ If False, only return words with "deep" etymology chains.
254
+ """
255
+ view = "v_english_curated" if include_compound else "v_english_deep"
256
+ # Guard against SQL injection (view name is interpolated)
257
+ assert view in ("v_english_curated", "v_english_deep"), f"Invalid view: {view}"
258
+ with _ConnectionManager() as conn:
259
+ row = conn.execute(f"SELECT lexeme FROM {view} ORDER BY random() LIMIT 1").fetchone()
260
+ return {"word": row[0] if row else None}
261
+
262
+
263
+ def fetch_language_info(lang_code: str) -> dict[str, str] | None:
264
+ """Return language family info for a language code."""
265
+ with _ConnectionManager() as conn:
266
+ row = conn.execute(
267
+ load_sql("queries/get_language_info.sql"),
268
+ [lang_code],
269
+ ).fetchone()
270
+ if row:
271
+ return {"name": row[0], "family": row[1], "branch": row[2]}
272
+ return None
273
+
274
+
275
+ def fetch_all_language_families() -> dict[str, dict[str, str]]:
276
+ """Return all language family mappings."""
277
+ with _ConnectionManager() as conn:
278
+ rows = conn.execute(load_sql("queries/get_language_families.sql")).fetchall()
279
+ return {row[0]: {"name": row[1], "family": row[2], "branch": row[3]} for row in rows}
280
+
281
+
282
+ def _is_useful_sense(sense: str | None, lexeme: str) -> bool:
283
+ """Check if a sense provides useful information beyond the lexeme itself.
284
+
285
+ NULL senses are filtered out - they're structural entries without
286
+ meaningful definitions. We prefer entries where sense differs from lexeme.
287
+ """
288
+ if sense is None:
289
+ return False
290
+ sense_lower = sense.lower().strip('"')
291
+ lexeme_lower = lexeme.lower()
292
+ # Not useful: NULL, empty string, or equals lexeme
293
+ return sense_lower != "" and sense_lower != lexeme_lower
294
+
295
+
296
+ def _format_sense_for_display(sense: str) -> str:
297
+ """Format a sense for display in the UI."""
298
+ return sense.strip('"')
299
+
300
+
301
+ def search_words(query: str, limit: int = 10) -> list[dict[str, str]]:
302
+ """Search for English words matching the query (fuzzy prefix search).
303
+
304
+ Returns words with etymology data. Shows EtymDB sense when it differs
305
+ from lexeme, otherwise falls back to Free Dictionary definition.
306
+ When multiple senses exist for a word, shows all of them.
307
+
308
+ For words with multiple Free Dictionary definitions, shows the primary
309
+ definition with a count indicator (e.g., "+3 more").
310
+
311
+ TODO: The subquery for def_count could be optimized by pre-computing
312
+ definition counts into a materialized column or separate table. This
313
+ would require a schema change to the definitions table.
314
+ """
315
+ if not query or len(query) < 2:
316
+ return []
317
+
318
+ with _ConnectionManager() as conn:
319
+ # Get primary definitions (entry=0, meaning=0, def=0) with total count
320
+ rows = conn.execute(
321
+ load_sql("queries/search_words.sql"),
322
+ [query, query],
323
+ ).fetchall()
324
+
325
+ # Group by lexeme to handle duplicates
326
+ seen_lexemes: dict[str, list[tuple]] = {}
327
+ for lexeme, sense, definition, pos, def_count in rows:
328
+ if lexeme not in seen_lexemes:
329
+ seen_lexemes[lexeme] = []
330
+ seen_lexemes[lexeme].append((sense, definition, pos, def_count or 0))
331
+
332
+ # Build results
333
+ results = []
334
+ for lexeme, entries in seen_lexemes.items():
335
+ useful_senses = [s for s, _, _, _ in entries if _is_useful_sense(s, lexeme)]
336
+
337
+ if useful_senses:
338
+ # Show all entries with useful senses
339
+ for sense in useful_senses:
340
+ display = _format_sense_for_display(sense)
341
+ results.append({"word": lexeme, "sense": display})
342
+ else:
343
+ # No useful senses - show Free Dictionary definition with count
344
+ _, definition, pos, def_count = entries[0]
345
+ display = definition.strip('"') if definition else None
346
+
347
+ # Add count indicator for polysemous words
348
+ if display and def_count > 1:
349
+ pos_str = f"({pos}) " if pos else ""
350
+ display = f"{pos_str}{display} (+{def_count - 1} more)"
351
+
352
+ results.append({"word": lexeme, "sense": display})
353
+
354
+ if len(results) >= limit:
355
+ break
356
+
357
+ return results[:limit]
backend/download_data.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utilities to download the EtymDB CSV files on demand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterable
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+
11
+ BASE_DIR = Path(__file__).resolve().parent
12
+ DEFAULT_DATA_DIR = BASE_DIR / "data"
13
+
14
+ DATA_DIR = Path(os.environ.get("ETYM_DATA_DIR", DEFAULT_DATA_DIR))
15
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
16
+
17
+ FILES: dict[str, str] = {
18
+ "etymdb_values.csv": "https://raw.githubusercontent.com/clefourrier/EtymDB/master/data/split_etymdb/etymdb_values.csv",
19
+ "etymdb_links_info.csv": "https://raw.githubusercontent.com/clefourrier/EtymDB/master/data/split_etymdb/etymdb_links_info.csv",
20
+ "etymdb_links_index.csv": "https://raw.githubusercontent.com/clefourrier/EtymDB/master/data/split_etymdb/etymdb_links_index.csv",
21
+ }
22
+
23
+ _CHUNK_SIZE = 1 << 20 # 1 MiB
24
+
25
+
26
+ def _iter_download(client: httpx.Client, url: str) -> Iterable[bytes]:
27
+ """Stream a download from *url* yielding binary chunks."""
28
+ with client.stream("GET", url) as response:
29
+ response.raise_for_status()
30
+ for chunk in response.iter_bytes(_CHUNK_SIZE):
31
+ if chunk:
32
+ yield chunk
33
+
34
+
35
+ def download() -> None:
36
+ """Download the EtymDB CSVs into :data:`DATA_DIR` if missing."""
37
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
38
+ with httpx.Client(follow_redirects=True, timeout=httpx.Timeout(60.0)) as client:
39
+ for name, url in FILES.items():
40
+ destination = DATA_DIR / name
41
+ if destination.exists():
42
+ continue
43
+ destination_tmp = destination.with_suffix(destination.suffix + ".tmp")
44
+ destination_tmp.parent.mkdir(parents=True, exist_ok=True)
45
+ with destination_tmp.open("wb") as fh:
46
+ for chunk in _iter_download(client, url):
47
+ fh.write(chunk)
48
+ destination_tmp.replace(destination)
49
+
50
+
51
+ if __name__ == "__main__": # pragma: no cover - manual utility
52
+ download()
backend/download_language_codes.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download language code mappings from ISO 639-3 and Wiktionary.
2
+
3
+ Sources:
4
+ - ISO 639-3: https://iso639-3.sil.org/code_tables/download_tables
5
+ - Wiktionary: etymology languages and families modules
6
+
7
+ Usage:
8
+ python -m backend.download_language_codes
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ import json
15
+ import re
16
+ from io import StringIO
17
+ from pathlib import Path
18
+ from urllib.request import Request, urlopen
19
+
20
+ DATA_DIR = Path(__file__).parent / "data"
21
+ OUTPUT_FILE = DATA_DIR / "language_codes.json"
22
+
23
+
24
+ def fetch(url: str) -> str:
25
+ """Fetch URL content."""
26
+ req = Request(url, headers={"User-Agent": "etymology-for-all/1.0"})
27
+ with urlopen(req, timeout=30) as r:
28
+ return r.read().decode("utf-8")
29
+
30
+
31
+ def main() -> None:
32
+ """Download and combine language codes into JSON."""
33
+ codes: dict[str, str] = {}
34
+
35
+ # 1. ISO 639-3 (official registry - ~8000 codes)
36
+ print("Fetching ISO 639-3...")
37
+ content = fetch("https://iso639-3.sil.org/sites/iso639-3/files/downloads/iso-639-3.tab")
38
+ for row in csv.DictReader(StringIO(content), delimiter="\t"):
39
+ codes[row["Id"]] = row["Ref_Name"]
40
+
41
+ # 2. ISO 639-1 (two-letter codes)
42
+ print("Fetching ISO 639-1...")
43
+ content = fetch(
44
+ "https://raw.githubusercontent.com/datasets/language-codes/master/data/language-codes.csv"
45
+ )
46
+ for row in csv.DictReader(StringIO(content)):
47
+ codes[row["alpha2"]] = row["English"]
48
+
49
+ # 3. Wiktionary main languages (code to name JSON)
50
+ print("Fetching Wiktionary languages JSON...")
51
+ content = fetch(
52
+ "https://en.wiktionary.org/w/index.php?title=Module:languages/code_to_canonical_name.json&action=raw"
53
+ )
54
+ codes.update(json.loads(content))
55
+
56
+ # 4. Wiktionary etymology languages (dialects, variants) - JSON
57
+ print("Fetching Wiktionary etymology codes...")
58
+ content = fetch(
59
+ "https://en.wiktionary.org/w/index.php?title=Module:etymology_languages/code_to_canonical_name.json&action=raw"
60
+ )
61
+ codes.update(json.loads(content))
62
+
63
+ # 5. Wiktionary families -> proto-languages
64
+ print("Fetching Wiktionary families...")
65
+ content = fetch("https://en.wiktionary.org/w/index.php?title=Module:families/data&action=raw")
66
+ for m in re.finditer(r'm\["([^"]+)"\]\s*=\s*\{\s*\n\s*"([^"]+)"', content):
67
+ codes[m.group(1)] = m.group(2)
68
+ codes[f"{m.group(1)}-pro"] = f"Proto-{m.group(2)}"
69
+
70
+ # Write output
71
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
72
+ data = [
73
+ {"code": c, "name": n, "family": None, "branch": None} for c, n in sorted(codes.items())
74
+ ]
75
+ OUTPUT_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
76
+ print(f"Wrote {len(data)} codes to {OUTPUT_FILE}")
77
+
78
+
79
+ if __name__ == "__main__":
80
+ main()
backend/enrich_definitions.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Enrich etymology database with definitions from Free Dictionary API.
2
+
3
+ Usage:
4
+ uv run python -m backend.enrich_definitions # Run full enrichment
5
+ uv run python -m backend.enrich_definitions --stats # Check progress
6
+ uv run python -m backend.enrich_definitions --test 50 # Test with 50 words
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import asyncio
13
+ import json
14
+ import sys
15
+ import time
16
+ from datetime import datetime
17
+
18
+ import duckdb
19
+
20
+ try:
21
+ import aiohttp
22
+ except ImportError:
23
+ print("aiohttp is required. Install with: uv add aiohttp")
24
+ sys.exit(1)
25
+
26
+ try:
27
+ from .database import database_path
28
+ from .sql_loader import load_sql
29
+ except ImportError:
30
+ from database import database_path
31
+ from sql_loader import load_sql
32
+
33
+ API_BASE = "https://api.dictionaryapi.dev/api/v2/entries/en"
34
+ DELAY_MS = 300 # Delay between requests
35
+ COMMIT_EVERY = 100 # Commit to DB every N words
36
+
37
+
38
+ async def fetch_definition(
39
+ session: aiohttp.ClientSession, word: str
40
+ ) -> tuple[str, str, str | None]:
41
+ """Fetch definition for a single word with retry logic."""
42
+ url = f"{API_BASE}/{word}"
43
+
44
+ for attempt in range(3):
45
+ try:
46
+ async with session.get(url) as resp:
47
+ if resp.status == 200:
48
+ data = await resp.json()
49
+ return (word, "success", json.dumps(data))
50
+ elif resp.status == 404:
51
+ return (word, "not_found", None)
52
+ elif resp.status == 429: # Rate limited
53
+ await asyncio.sleep(2**attempt)
54
+ continue
55
+ else:
56
+ if attempt < 2:
57
+ await asyncio.sleep(1)
58
+ continue
59
+ return (word, "error", None)
60
+ except Exception as e:
61
+ if attempt < 2:
62
+ await asyncio.sleep(1)
63
+ continue
64
+ print(f" Error fetching '{word}': {e}")
65
+ return (word, "error", None)
66
+
67
+ return (word, "error", None)
68
+
69
+
70
+ def get_words_to_enrich(conn: duckdb.DuckDBPyConnection) -> list[str]:
71
+ """Get curated English words that haven't been enriched yet."""
72
+ tables = conn.execute(
73
+ load_sql("enrichment/check_table_exists.sql"), ["definitions_raw"]
74
+ ).fetchall()
75
+
76
+ if not tables:
77
+ rows = conn.execute(load_sql("enrichment/words_to_enrich_initial.sql")).fetchall()
78
+ else:
79
+ rows = conn.execute(load_sql("enrichment/words_to_enrich.sql")).fetchall()
80
+
81
+ return [row[0] for row in rows]
82
+
83
+
84
+ def ensure_definitions_table(conn: duckdb.DuckDBPyConnection) -> None:
85
+ """Create definitions_raw table if it doesn't exist."""
86
+ conn.execute(load_sql("ingestion/09_create_definitions_raw.sql"))
87
+
88
+
89
+ def materialize_definitions(conn: duckdb.DuckDBPyConnection) -> None:
90
+ """Materialize parsed definitions into a proper table.
91
+
92
+ Extracts ALL definitions from the Free Dictionary API response,
93
+ not just the first one. Each row represents one definition with
94
+ indexes for entry, meaning, and definition within the JSON structure.
95
+
96
+ The raw JSON is kept in definitions_raw for debugging/reprocessing.
97
+ Lexeme is stored lowercase for fast equality joins.
98
+ """
99
+ print("Materializing definitions table...")
100
+ for stmt in load_sql("enrichment/materialize_definitions.sql").strip().split(";"):
101
+ if stmt.strip():
102
+ conn.execute(stmt)
103
+
104
+ for stmt in load_sql("enrichment/create_definition_indexes.sql").strip().split(";"):
105
+ if stmt.strip():
106
+ conn.execute(stmt)
107
+
108
+ count = conn.execute("SELECT COUNT(*) FROM definitions").fetchone()[0]
109
+ unique_words = conn.execute("SELECT COUNT(DISTINCT lexeme) FROM definitions").fetchone()[0]
110
+ print(f" Materialized {count:,} definitions for {unique_words:,} words")
111
+
112
+
113
+ def store_result(
114
+ conn: duckdb.DuckDBPyConnection, word: str, status: str, api_response: str | None
115
+ ) -> None:
116
+ """Store a single result in the database."""
117
+ conn.execute(
118
+ "INSERT OR REPLACE INTO definitions_raw (lexeme, api_response, fetched_at, status) VALUES (?, ?::JSON, ?, ?)",
119
+ [word, api_response, datetime.now().isoformat(), status],
120
+ )
121
+
122
+
123
+ async def enrich_definitions(max_words: int | None = None) -> None:
124
+ """Fetch definitions for all curated English words."""
125
+ db_path = database_path()
126
+ print(f"Database: {db_path}\n")
127
+
128
+ # Get words to enrich
129
+ with duckdb.connect(db_path.as_posix(), read_only=True) as conn:
130
+ words = get_words_to_enrich(conn)
131
+
132
+ if max_words:
133
+ words = words[:max_words]
134
+
135
+ total = len(words)
136
+ if total == 0:
137
+ print("All words already enriched!")
138
+ return
139
+
140
+ print(f"Words to enrich: {total:,}")
141
+ print(f"Estimated time: {(total * DELAY_MS) / 1000 / 60:.1f} minutes\n")
142
+
143
+ stats = {"success": 0, "not_found": 0, "error": 0}
144
+ start_time = time.time()
145
+
146
+ with duckdb.connect(db_path.as_posix()) as conn:
147
+ ensure_definitions_table(conn)
148
+
149
+ async with aiohttp.ClientSession() as session:
150
+ for i, word in enumerate(words):
151
+ result = await fetch_definition(session, word)
152
+ _, status, api_response = result
153
+ stats[status] += 1
154
+ store_result(conn, word, status, api_response)
155
+
156
+ # Progress every 50 words
157
+ if (i + 1) % 50 == 0 or i + 1 == total:
158
+ elapsed = time.time() - start_time
159
+ rate = (i + 1) / elapsed if elapsed > 0 else 0
160
+ remaining = (total - i - 1) / rate if rate > 0 else 0
161
+ print(
162
+ f"[{i + 1:,}/{total:,}] {100 * (i + 1) / total:.1f}% - {rate:.1f}/sec - ETA: {remaining / 60:.1f}m"
163
+ )
164
+
165
+ # Commit periodically
166
+ if (i + 1) % COMMIT_EVERY == 0:
167
+ conn.commit()
168
+
169
+ # Rate limiting
170
+ if i < total - 1:
171
+ await asyncio.sleep(DELAY_MS / 1000)
172
+
173
+ conn.commit()
174
+
175
+ elapsed = time.time() - start_time
176
+ print(f"\nDone! {total:,} words in {elapsed / 60:.1f}m ({total / elapsed:.1f}/sec)")
177
+ print(
178
+ f" Success: {stats['success']:,}, Not found: {stats['not_found']:,}, Errors: {stats['error']:,}"
179
+ )
180
+
181
+ # Materialize the definitions table for fast queries
182
+ with duckdb.connect(db_path.as_posix()) as conn:
183
+ materialize_definitions(conn)
184
+
185
+
186
+ def get_stats() -> None:
187
+ """Show current enrichment statistics."""
188
+ db_path = database_path()
189
+
190
+ with duckdb.connect(db_path.as_posix(), read_only=True) as conn:
191
+ tables = conn.execute(
192
+ load_sql("enrichment/check_table_exists.sql"), ["definitions_raw"]
193
+ ).fetchall()
194
+
195
+ if not tables:
196
+ print("No definitions_raw table yet. Run enrichment first.")
197
+ return
198
+
199
+ total_curated = conn.execute(
200
+ "SELECT COUNT(DISTINCT lexeme) FROM v_english_curated"
201
+ ).fetchone()[0]
202
+ stats = conn.execute(load_sql("enrichment/enrichment_stats.sql")).fetchall()
203
+
204
+ total_enriched = sum(s[1] for s in stats)
205
+ remaining = total_curated - total_enriched
206
+
207
+ print(f"Curated English words: {total_curated:,}")
208
+ print(f"Enriched: {total_enriched:,} ({100 * total_enriched / total_curated:.1f}%)")
209
+ print(f"Remaining: {remaining:,}\n")
210
+ print("By status:")
211
+ for status, count in stats:
212
+ print(f" {status}: {count:,}")
213
+
214
+
215
+ def main():
216
+ parser = argparse.ArgumentParser(description="Enrich etymology database with definitions")
217
+ parser.add_argument("--stats", action="store_true", help="Show enrichment statistics")
218
+ parser.add_argument("--test", type=int, metavar="N", help="Test mode: only process N words")
219
+ args = parser.parse_args()
220
+
221
+ if args.stats:
222
+ get_stats()
223
+ else:
224
+ asyncio.run(enrich_definitions(max_words=args.test))
225
+
226
+
227
+ if __name__ == "__main__":
228
+ main()
backend/ingest.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load EtymDB CSV files into a DuckDB database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import duckdb
10
+
11
+ try: # Local execution vs. package import
12
+ from .download_data import DATA_DIR, download
13
+ from .sql_loader import load_sql
14
+ except ImportError: # pragma: no cover - fallback for direct execution
15
+ from download_data import DATA_DIR, download
16
+ from sql_loader import load_sql
17
+
18
+ DEFAULT_DB_PATH = DATA_DIR / "etymdb.duckdb"
19
+ DEFAULT_VALUES = DATA_DIR / "etymdb_values.csv"
20
+ DEFAULT_LINKS = DATA_DIR / "etymdb_links_info.csv"
21
+ DEFAULT_LINKS_INDEX = DATA_DIR / "etymdb_links_index.csv"
22
+
23
+ DB_PATH = Path(os.environ.get("ETYM_DB_PATH", DEFAULT_DB_PATH))
24
+ VALUES_CSV = Path(os.environ.get("ETYM_VALUES_CSV", DEFAULT_VALUES))
25
+ LINKS_CSV = Path(os.environ.get("ETYM_LINKS_CSV", DEFAULT_LINKS))
26
+ LINKS_INDEX_CSV = Path(os.environ.get("ETYM_LINKS_INDEX_CSV", DEFAULT_LINKS_INDEX))
27
+
28
+
29
+ def _ensure_csvs() -> None:
30
+ required = [VALUES_CSV, LINKS_CSV, LINKS_INDEX_CSV]
31
+ if all(f.exists() for f in required):
32
+ return
33
+ download()
34
+ if not all(f.exists() for f in required): # pragma: no cover - defensive
35
+ raise FileNotFoundError("Failed to download required CSV files")
36
+
37
+
38
+ def main() -> None:
39
+ """Create or refresh the DuckDB database from the CSV files."""
40
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
41
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
42
+ _ensure_csvs()
43
+
44
+ with duckdb.connect(DB_PATH.as_posix()) as conn:
45
+ # Drop and recreate core tables
46
+ for stmt in load_sql("ingestion/01_drop_tables.sql").strip().split(";"):
47
+ if stmt.strip():
48
+ conn.execute(stmt)
49
+
50
+ conn.execute(load_sql("ingestion/02_create_words.sql"), [VALUES_CSV.as_posix()])
51
+ conn.execute(load_sql("ingestion/03_create_links.sql"), [LINKS_CSV.as_posix()])
52
+ conn.execute(load_sql("ingestion/04_create_sequences.sql"))
53
+
54
+ # Parse and insert sequences (handles variable-length rows)
55
+ with open(LINKS_INDEX_CSV, encoding="utf-8") as f:
56
+ for line in f:
57
+ parts = line.strip().split("\t")
58
+ if len(parts) < 2:
59
+ continue
60
+ seq_ix = int(parts[0])
61
+ for position, parent in enumerate(parts[1:]):
62
+ if parent:
63
+ conn.execute(
64
+ "INSERT INTO sequences VALUES (?, ?, ?)",
65
+ [seq_ix, position, int(parent)],
66
+ )
67
+
68
+ # Create indexes
69
+ for stmt in load_sql("ingestion/05_create_indexes.sql").strip().split(";"):
70
+ if stmt.strip():
71
+ conn.execute(stmt)
72
+
73
+ # Gold Layer: Macros, Views, and Reference Tables
74
+ for stmt in load_sql("ingestion/06_create_macros.sql").strip().split(";"):
75
+ if stmt.strip():
76
+ conn.execute(stmt)
77
+
78
+ for stmt in load_sql("ingestion/07_create_views.sql").strip().split(";"):
79
+ if stmt.strip():
80
+ conn.execute(stmt)
81
+
82
+ # Language families reference table
83
+ language_codes_path = DATA_DIR / "language_codes.json"
84
+ if not language_codes_path.exists():
85
+ raise FileNotFoundError(
86
+ f"Missing {language_codes_path}. "
87
+ "Run `python -m backend.download_language_codes` first."
88
+ )
89
+
90
+ for stmt in load_sql("ingestion/08_create_language_families.sql").strip().split(";"):
91
+ if stmt.strip():
92
+ conn.execute(stmt)
93
+
94
+ with open(language_codes_path, encoding="utf-8") as f:
95
+ language_data = json.load(f)
96
+ for entry in language_data:
97
+ conn.execute(
98
+ "INSERT INTO language_families VALUES (?, ?, ?, ?)",
99
+ [
100
+ entry["code"],
101
+ entry["name"],
102
+ entry.get("family"),
103
+ entry.get("branch"),
104
+ ],
105
+ )
106
+
107
+ # Definition enrichment tables
108
+ conn.execute(load_sql("ingestion/09_create_definitions_raw.sql"))
109
+
110
+
111
+ if __name__ == "__main__": # pragma: no cover - manual utility
112
+ main()
backend/main.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application exposing the etymology graph endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ from pathlib import Path
7
+
8
+ from fastapi import FastAPI, HTTPException, Request
9
+ from fastapi.responses import FileResponse, JSONResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+ from slowapi import Limiter, _rate_limit_exceeded_handler
12
+ from slowapi.errors import RateLimitExceeded
13
+ from slowapi.util import get_remote_address
14
+
15
+ try: # Support execution via `python backend/main.py`
16
+ from .database import (
17
+ fetch_etymology,
18
+ fetch_random_word,
19
+ get_db_stats,
20
+ search_words,
21
+ )
22
+ except ImportError: # pragma: no cover - fallback when run as a script
23
+ from database import fetch_etymology, fetch_random_word, get_db_stats, search_words
24
+
25
+ limiter = Limiter(key_func=get_remote_address)
26
+ app = FastAPI(title="Etymology Graph API")
27
+ app.state.limiter = limiter
28
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
29
+
30
+ # Resolve frontend directory relative to this file
31
+ FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
32
+
33
+
34
+ @app.get("/health")
35
+ def health_check():
36
+ """Health check endpoint for container orchestration."""
37
+ try:
38
+ stats = get_db_stats()
39
+ return {"status": "healthy", "db": stats}
40
+ except Exception as exc:
41
+ return JSONResponse(
42
+ status_code=503,
43
+ content={"status": "unhealthy", "reason": str(exc)},
44
+ )
45
+
46
+
47
+ def _get_version() -> str:
48
+ """Read version from package metadata, falling back to pyproject.toml."""
49
+ try:
50
+ return importlib.metadata.version("etymology-for-all")
51
+ except importlib.metadata.PackageNotFoundError:
52
+ # Fallback: parse pyproject.toml directly
53
+ toml_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
54
+ for line in toml_path.read_text().splitlines():
55
+ if line.strip().startswith("version"):
56
+ return line.split("=", 1)[1].strip().strip('"')
57
+ return "unknown"
58
+
59
+
60
+ @app.get("/version")
61
+ def version():
62
+ """Return the application version and database statistics."""
63
+ return {"version": _get_version(), "db_stats": get_db_stats()}
64
+
65
+
66
+ @app.get("/graph/{word}")
67
+ @limiter.limit("20/minute")
68
+ def get_graph(request: Request, word: str, depth: int = 5):
69
+ """Fetch etymology graph for a word."""
70
+ # Clamp depth to reasonable bounds
71
+ depth = max(1, min(depth, 10))
72
+ graph = fetch_etymology(word, depth=depth)
73
+ if graph is None:
74
+ raise HTTPException(status_code=404, detail="Word not found in the database")
75
+ return graph
76
+
77
+
78
+ @app.get("/random")
79
+ @limiter.limit("50/minute")
80
+ def get_random_word(request: Request, include_compound: bool = True):
81
+ """Return a random English word from the dataset.
82
+
83
+ Args:
84
+ include_compound: If True (default), include compound-only words.
85
+ If False, only return words with deep etymology chains.
86
+ """
87
+ return fetch_random_word(include_compound=include_compound)
88
+
89
+
90
+ @app.get("/search")
91
+ @limiter.limit("120/minute")
92
+ def search(request: Request, q: str = "", limit: int = 10):
93
+ """Search for words matching the query (autocomplete)."""
94
+ if len(q) < 2:
95
+ return {"results": []}
96
+ results = search_words(q, min(limit, 20)) # Cap at 20
97
+ return {"results": results}
98
+
99
+
100
+ # Serve frontend static files (must be after API routes)
101
+ if FRONTEND_DIR.exists():
102
+
103
+ @app.get("/")
104
+ def serve_index():
105
+ """Serve the main HTML page."""
106
+ return FileResponse(FRONTEND_DIR / "index.html")
107
+
108
+ app.mount("/", StaticFiles(directory=FRONTEND_DIR), name="frontend")
backend/sql/enrichment/check_table_exists.sql ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ SELECT table_name
2
+ FROM information_schema.tables
3
+ WHERE table_name = ?
backend/sql/enrichment/create_definition_indexes.sql ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ CREATE INDEX idx_definitions_lexeme ON definitions(lexeme);
2
+ CREATE INDEX idx_definitions_primary ON definitions(lexeme, entry_idx, meaning_idx, def_idx);
backend/sql/enrichment/enrichment_stats.sql ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ SELECT status, COUNT(*) as count
2
+ FROM definitions_raw
3
+ GROUP BY status
backend/sql/enrichment/materialize_definitions.sql ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ DROP TABLE IF EXISTS definitions;
2
+ DROP VIEW IF EXISTS v_definitions;
3
+
4
+ CREATE TABLE definitions AS
5
+ WITH entries AS (
6
+ SELECT
7
+ lower(lexeme) as lexeme,
8
+ unnest(from_json(api_response, '["json"]')) as entry,
9
+ generate_subscripts(from_json(api_response, '["json"]'), 1) - 1 as entry_idx
10
+ FROM definitions_raw
11
+ WHERE status = 'success'
12
+ ),
13
+ meanings AS (
14
+ SELECT
15
+ lexeme, entry_idx,
16
+ unnest(from_json(json_extract(entry, '$.meanings'), '["json"]')) as meaning,
17
+ generate_subscripts(from_json(json_extract(entry, '$.meanings'), '["json"]'), 1) - 1 as meaning_idx
18
+ FROM entries
19
+ ),
20
+ defs AS (
21
+ SELECT
22
+ lexeme, entry_idx, meaning_idx,
23
+ json_extract_string(meaning, '$.partOfSpeech') as part_of_speech,
24
+ unnest(from_json(json_extract(meaning, '$.definitions'), '["json"]')) as def,
25
+ generate_subscripts(from_json(json_extract(meaning, '$.definitions'), '["json"]'), 1) - 1 as def_idx
26
+ FROM meanings
27
+ )
28
+ SELECT
29
+ lexeme,
30
+ json_extract_string(def, '$.definition') as definition,
31
+ part_of_speech,
32
+ entry_idx,
33
+ meaning_idx,
34
+ def_idx
35
+ FROM defs
36
+ WHERE json_extract_string(def, '$.definition') IS NOT NULL
backend/sql/enrichment/words_to_enrich.sql ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ SELECT DISTINCT c.lexeme
2
+ FROM v_english_curated c
3
+ LEFT JOIN definitions_raw d ON c.lexeme = d.lexeme
4
+ WHERE d.lexeme IS NULL
5
+ ORDER BY c.lexeme
backend/sql/enrichment/words_to_enrich_initial.sql ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ SELECT DISTINCT lexeme
2
+ FROM v_english_curated
3
+ ORDER BY lexeme
backend/sql/ingestion/01_drop_tables.sql ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ DROP TABLE IF EXISTS words;
2
+ DROP TABLE IF EXISTS links;
3
+ DROP TABLE IF EXISTS sequences;
backend/sql/ingestion/02_create_words.sql ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE words AS
2
+ SELECT
3
+ word_ix::BIGINT AS word_ix,
4
+ lang,
5
+ lexeme,
6
+ sense
7
+ FROM read_csv_auto(?, delim='\t', header=false, columns={
8
+ 'word_ix': 'BIGINT',
9
+ 'lang': 'VARCHAR',
10
+ 'dummy': 'INTEGER',
11
+ 'lexeme': 'VARCHAR',
12
+ 'sense': 'VARCHAR'
13
+ })
backend/sql/ingestion/03_create_links.sql ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE links AS
2
+ SELECT
3
+ type,
4
+ source::BIGINT AS source,
5
+ target::BIGINT AS target
6
+ FROM read_csv_auto(?, delim='\t', header=false, columns={
7
+ 'type': 'VARCHAR',
8
+ 'source': 'BIGINT',
9
+ 'target': 'BIGINT'
10
+ })
backend/sql/ingestion/04_create_sequences.sql ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ CREATE TABLE sequences (
2
+ seq_ix BIGINT,
3
+ position INT,
4
+ parent_ix BIGINT
5
+ )
backend/sql/ingestion/05_create_indexes.sql ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ CREATE INDEX idx_words_word_ix ON words(word_ix);
2
+ CREATE INDEX idx_words_lexeme ON words(lexeme);
3
+ CREATE INDEX idx_links_source ON links(source);
4
+ CREATE INDEX idx_links_target ON links(target);
5
+ CREATE INDEX idx_sequences_seq_ix ON sequences(seq_ix);
6
+ CREATE INDEX idx_sequences_parent_ix ON sequences(parent_ix);
backend/sql/ingestion/06_create_macros.sql ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE OR REPLACE MACRO is_phrase(lexeme) AS
2
+ lexeme LIKE '% %';
3
+
4
+ CREATE OR REPLACE MACRO is_proper_noun(lexeme) AS
5
+ regexp_matches(lexeme, '^[A-Z][a-z]');
6
+
7
+ CREATE OR REPLACE MACRO is_clean_word(lexeme) AS
8
+ NOT is_phrase(lexeme) AND NOT is_proper_noun(lexeme);
9
+
10
+ CREATE OR REPLACE MACRO has_etymology(word_ix) AS
11
+ word_ix IN (SELECT DISTINCT source FROM links);
backend/sql/ingestion/07_create_views.sql ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Curated view: English words with etymology, no phrases/proper nouns
2
+ -- Filter out sense=NULL entries which are often garbage (e.g., suffix entries
3
+ -- like "-er" with corrupted links to unrelated words like "asteroid belt")
4
+ -- Paper notes 40% of EtymDB lacks glosses; our curated set is 99% with sense
5
+ CREATE OR REPLACE VIEW v_english_curated AS
6
+ SELECT DISTINCT w.*
7
+ FROM words w
8
+ JOIN links l ON w.word_ix = l.source
9
+ WHERE w.lang = 'en'
10
+ AND is_clean_word(w.lexeme)
11
+ AND w.sense IS NOT NULL;
12
+
13
+ -- View for words with "deep" etymology (at least one link to a real word)
14
+ -- Excludes compound-only words where all links point to sequences (negative IDs)
15
+ -- Also excludes sense=NULL entries (same rationale as v_english_curated)
16
+ CREATE OR REPLACE VIEW v_english_deep AS
17
+ SELECT DISTINCT w.*
18
+ FROM words w
19
+ JOIN links l ON w.word_ix = l.source
20
+ WHERE w.lang = 'en'
21
+ AND is_clean_word(w.lexeme)
22
+ AND w.sense IS NOT NULL
23
+ AND l.target > 0;
backend/sql/ingestion/08_create_language_families.sql ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ DROP TABLE IF EXISTS language_families;
2
+
3
+ CREATE TABLE language_families (
4
+ lang_code VARCHAR PRIMARY KEY,
5
+ lang_name VARCHAR,
6
+ family VARCHAR,
7
+ branch VARCHAR
8
+ )
backend/sql/ingestion/09_create_definitions_raw.sql ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ CREATE TABLE IF NOT EXISTS definitions_raw (
2
+ lexeme VARCHAR PRIMARY KEY,
3
+ api_response JSON,
4
+ fetched_at TIMESTAMP,
5
+ status VARCHAR
6
+ )
backend/sql/queries/find_start_word.sql ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ SELECT w.word_ix, w.lang, w.lexeme, w.sense
2
+ FROM words w
3
+ LEFT JOIN links l ON l.source = w.word_ix
4
+ WHERE lower(w.lexeme) = lower(?)
5
+ GROUP BY w.word_ix, w.lang, w.lexeme, w.sense
6
+ ORDER BY
7
+ CASE WHEN w.lang = 'en' THEN 0 ELSE 1 END,
8
+ COUNT(l.target) DESC,
9
+ w.word_ix
10
+ LIMIT 1
backend/sql/queries/get_language_families.sql ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ SELECT lang_code, lang_name, family, branch
2
+ FROM language_families
backend/sql/queries/get_language_info.sql ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ SELECT lang_name, family, branch
2
+ FROM language_families
3
+ WHERE lang_code = ?
backend/sql/queries/search_words.sql ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SELECT w.lexeme, w.sense, d.definition, d.part_of_speech, dc.def_count
2
+ FROM v_english_curated w
3
+ LEFT JOIN definitions d ON d.lexeme = lower(w.lexeme)
4
+ AND d.entry_idx = 0 AND d.meaning_idx = 0 AND d.def_idx = 0
5
+ LEFT JOIN (
6
+ SELECT lexeme, COUNT(*) as def_count
7
+ FROM definitions
8
+ GROUP BY lexeme
9
+ ) dc ON dc.lexeme = lower(w.lexeme)
10
+ WHERE lower(w.lexeme) LIKE lower(?) || '%'
11
+ ORDER BY
12
+ CASE WHEN lower(w.lexeme) = lower(?) THEN 0 ELSE 1 END,
13
+ length(w.lexeme),
14
+ w.lexeme,
15
+ w.word_ix
backend/sql/queries/traverse_etymology.sql ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ WITH RECURSIVE
2
+ -- Resolve negative targets through sequences table
3
+ resolved_links AS (
4
+ -- Simple links (positive target = direct word reference)
5
+ SELECT source, target AS parent_ix, FALSE AS is_compound, type
6
+ FROM links
7
+ WHERE target > 0
8
+ UNION ALL
9
+ -- Compound links (negative target = sequence, resolve to parents)
10
+ SELECT l.source, s.parent_ix, TRUE AS is_compound, l.type
11
+ FROM links l
12
+ JOIN sequences s ON s.seq_ix = l.target
13
+ WHERE l.target < 0
14
+ ),
15
+ traversal(child_ix, parent_ix, is_compound, type, lvl) AS (
16
+ SELECT source, parent_ix, is_compound, type, 1
17
+ FROM resolved_links
18
+ WHERE source = ?
19
+ UNION ALL
20
+ -- Only follow FROM parents that have valid sense (non-NULL for English)
21
+ -- This keeps sense=NULL entries as nodes but doesn't traverse their garbage links
22
+ SELECT rl.source, rl.parent_ix, rl.is_compound, rl.type, lvl + 1
23
+ FROM traversal t
24
+ JOIN resolved_links rl ON rl.source = t.parent_ix
25
+ JOIN words parent_word ON parent_word.word_ix = t.parent_ix
26
+ WHERE lvl < ?
27
+ AND (parent_word.lang != 'en' OR parent_word.sense IS NOT NULL)
28
+ )
29
+ SELECT
30
+ child.word_ix AS child_ix,
31
+ child.lexeme AS child_lexeme,
32
+ child.lang AS child_lang,
33
+ child.sense AS child_sense,
34
+ parent.word_ix AS parent_ix,
35
+ parent.lexeme AS parent_lexeme,
36
+ parent.lang AS parent_lang,
37
+ parent.sense AS parent_sense,
38
+ tr.is_compound,
39
+ tr.type
40
+ FROM traversal tr
41
+ JOIN words child ON child.word_ix = tr.child_ix
42
+ JOIN words parent ON parent.word_ix = tr.parent_ix
backend/sql_loader.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load SQL files from the backend/sql/ directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import cache
6
+ from pathlib import Path
7
+
8
+ SQL_DIR = Path(__file__).parent / "sql"
9
+
10
+
11
+ @cache
12
+ def load_sql(filename: str) -> str:
13
+ """Read and cache a SQL file from backend/sql/.
14
+
15
+ Args:
16
+ filename: Relative path within the sql/ directory,
17
+ e.g. "queries/find_start_word.sql"
18
+ """
19
+ return (SQL_DIR / filename).read_text()
cloudflare-worker/worker.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ export default {
2
+ async fetch(request) {
3
+ const url = new URL(request.url);
4
+ url.hostname = 'lucharo-etymology.hf.space';
5
+ return fetch(url, request);
6
+ }
7
+ }
cloudflare-worker/wrangler.toml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ name = "etymology-proxy"
2
+ main = "worker.js"
3
+ compatibility_date = "2025-12-21"
4
+
5
+ # Custom domain - Cloudflare handles DNS + SSL automatically
6
+ routes = [
7
+ { pattern = "etymology.luischav.es", custom_domain = true }
8
+ ]
frontend/index.html ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Etymology Graph Explorer</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="styles.css?v=60">
11
+ <script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
12
+ <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
13
+ <script src="https://unpkg.com/cytoscape-dagre@2.5.0/cytoscape-dagre.js"></script>
14
+ </head>
15
+ <body>
16
+ <main>
17
+ <header>
18
+ <h1>Etymology Explorer</h1>
19
+ <p class="subtitle">Trace the origins of words through time</p>
20
+ <div class="header-buttons">
21
+ <!-- Desktop: individual buttons -->
22
+ <a href="https://github.com/lucharo/etymology-for-all/issues/new/choose" target="_blank" rel="noopener" class="header-btn desktop-only" title="Report an issue">
23
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
24
+ <circle cx="12" cy="12" r="10"></circle>
25
+ <line x1="12" y1="8" x2="12" y2="12"></line>
26
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
27
+ </svg>
28
+ <span class="btn-label">Issue</span>
29
+ </a>
30
+ <button id="about-btn" class="header-btn desktop-only" title="About this project">
31
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
32
+ <circle cx="12" cy="12" r="10"></circle>
33
+ <path d="M12 16v-4"></path>
34
+ <path d="M12 8h.01"></path>
35
+ </svg>
36
+ <span class="btn-label">About</span>
37
+ </button>
38
+ <div class="settings-wrapper desktop-only">
39
+ <button id="settings-btn" class="header-btn" title="Settings" aria-label="Settings">
40
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
41
+ <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
42
+ <circle cx="12" cy="12" r="3"></circle>
43
+ </svg>
44
+ </button>
45
+ <div id="settings-popover" class="settings-popover hidden">
46
+ <label class="settings-option" title="Include compound words in random selection and graph display">
47
+ <input type="checkbox" id="include-compound" checked>
48
+ <span>Include compound words</span>
49
+ </label>
50
+ <label class="settings-option" title="Color edges by link type (inherited, borrowed, derived, cognate)">
51
+ <input type="checkbox" id="show-link-types">
52
+ <span>Show link types</span>
53
+ </label>
54
+ </div>
55
+ </div>
56
+ <!-- Mobile: single menu button -->
57
+ <div class="mobile-menu-wrapper mobile-only">
58
+ <button id="mobile-menu-btn" class="header-btn" title="Menu" aria-label="Menu">
59
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
60
+ <circle cx="5" cy="12" r="1"></circle>
61
+ <circle cx="12" cy="12" r="1"></circle>
62
+ <circle cx="19" cy="12" r="1"></circle>
63
+ </svg>
64
+ </button>
65
+ <div id="mobile-menu" class="mobile-menu hidden">
66
+ <button id="mobile-about-btn" class="mobile-menu-item mobile-about-link">About &rsaquo;</button>
67
+ <div class="mobile-menu-divider"></div>
68
+ <label class="mobile-menu-item settings-option" title="Include compound words">
69
+ <input type="checkbox" id="mobile-include-compound" checked>
70
+ <span>Include compound words</span>
71
+ </label>
72
+ <label class="mobile-menu-item settings-option" title="Color edges by link type">
73
+ <input type="checkbox" id="mobile-show-link-types">
74
+ <span>Show link types</span>
75
+ </label>
76
+ <div class="mobile-menu-divider"></div>
77
+ <a href="https://github.com/lucharo/etymology-for-all/issues/new/choose" target="_blank" rel="noopener" class="mobile-menu-item mobile-external-link">Report an issue <span class="external-icon">↗</span></a>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </header>
82
+
83
+ <div class="search-container">
84
+ <div class="search-wrapper">
85
+ <input
86
+ type="text"
87
+ id="word-input"
88
+ placeholder="Enter a word..."
89
+ autocomplete="off"
90
+ autofocus
91
+ >
92
+ <div id="suggestions" class="suggestions hidden"></div>
93
+ </div>
94
+ <button id="search-btn" title="Search">
95
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
96
+ <circle cx="11" cy="11" r="8"></circle>
97
+ <path d="m21 21-4.3-4.3"></path>
98
+ </svg>
99
+ </button>
100
+ <button id="random-btn" title="Random word" aria-label="Random word">
101
+ <span style="font-size: 1.3em; line-height: 1;">&#x1F3B2;</span>
102
+ </button>
103
+ </div>
104
+ <p class="search-hint">Click any word in the graph to see its definition</p>
105
+
106
+ <!-- Backdrop for expanded graph -->
107
+ <div id="graph-backdrop" class="graph-backdrop"></div>
108
+
109
+ <!-- Graph options -->
110
+ <div id="graph-options" class="graph-options hidden">
111
+ <div class="view-toggle">
112
+ <button id="view-graph" class="view-btn active" title="Graph view">
113
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
114
+ <circle cx="12" cy="12" r="3"></circle>
115
+ <circle cx="19" cy="5" r="2"></circle>
116
+ <circle cx="5" cy="19" r="2"></circle>
117
+ <circle cx="5" cy="5" r="2"></circle>
118
+ <line x1="12" y1="9" x2="12" y2="5"></line>
119
+ <line x1="6.5" y1="17.5" x2="9.5" y2="14.5"></line>
120
+ <line x1="17.5" y1="6.5" x2="14.5" y2="9.5"></line>
121
+ </svg>
122
+ Graph
123
+ </button>
124
+ <button id="view-tree" class="view-btn" title="Tree view">
125
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
126
+ <path d="M12 3v18"></path>
127
+ <path d="M5 8h14"></path>
128
+ <path d="M5 16h14"></path>
129
+ </svg>
130
+ Tree
131
+ </button>
132
+ </div>
133
+ <div class="depth-control">
134
+ <span class="depth-label">Depth:</span>
135
+ <button id="depth-minus" class="depth-btn" title="Decrease depth">−</button>
136
+ <span id="depth-value" class="depth-value">5</span>
137
+ <button id="depth-plus" class="depth-btn" title="Increase depth">+</button>
138
+ </div>
139
+ </div>
140
+
141
+ <div id="graph-container">
142
+ <!-- Expand/minimize toggle -->
143
+ <button id="expand-btn" class="expand-btn hidden" title="Expand graph (Esc to minimize)">
144
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="expand-icon">
145
+ <polyline points="15 3 21 3 21 9"></polyline>
146
+ <polyline points="9 21 3 21 3 15"></polyline>
147
+ <line x1="21" y1="3" x2="14" y2="10"></line>
148
+ <line x1="3" y1="21" x2="10" y2="14"></line>
149
+ </svg>
150
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="minimize-icon">
151
+ <polyline points="4 14 10 14 10 20"></polyline>
152
+ <polyline points="20 10 14 10 14 4"></polyline>
153
+ <line x1="14" y1="10" x2="21" y2="3"></line>
154
+ <line x1="3" y1="21" x2="10" y2="14"></line>
155
+ </svg>
156
+ </button>
157
+
158
+ <div id="loading" class="hidden">
159
+ <div class="spinner"></div>
160
+ <span>Tracing etymology...</span>
161
+ </div>
162
+ <div id="empty-state">
163
+ <p>Search for a word to see its etymological tree, or press <span style="font-size: 1.5em; vertical-align: middle; line-height: 1;">&#x1F3B2;</span> to get a random word</p>
164
+ </div>
165
+ <div id="error-state" class="hidden">
166
+ <p id="error-message"></p>
167
+ <div id="error-actions" class="error-actions"></div>
168
+ </div>
169
+ <div id="cy"></div>
170
+
171
+ <!-- Tree view (alternative to graph) -->
172
+ <div id="tree-view" class="tree-view hidden"></div>
173
+
174
+ <!-- Graph legend -->
175
+ <div class="graph-legend hidden" id="graph-legend">
176
+ <div class="edge-legend" id="edge-legend-simple">
177
+ <span class="legend-item">
178
+ <span class="legend-line regular"></span>
179
+ <span>Etymology</span>
180
+ </span>
181
+ <span class="legend-item">
182
+ <span class="legend-line compound"></span>
183
+ <span>Compound</span>
184
+ </span>
185
+ </div>
186
+ <div class="edge-legend hidden" id="edge-legend-detailed">
187
+ <span class="legend-item">
188
+ <span class="legend-line link-inh"></span>
189
+ <span>Inherited</span>
190
+ </span>
191
+ <span class="legend-item">
192
+ <span class="legend-line link-bor"></span>
193
+ <span>Borrowed</span>
194
+ </span>
195
+ <span class="legend-item">
196
+ <span class="legend-line link-der"></span>
197
+ <span>Derived</span>
198
+ </span>
199
+ <span class="legend-item">
200
+ <span class="legend-line link-cog"></span>
201
+ <span>Cognate</span>
202
+ </span>
203
+ <span class="legend-item">
204
+ <span class="legend-line link-cmpd"></span>
205
+ <span>Compound</span>
206
+ </span>
207
+ </div>
208
+ <span class="legend-divider">|</span>
209
+ <div id="direction-indicator">
210
+ <span class="direction-label direction-recent">Recent</span>
211
+ <span class="direction-arrow">→</span>
212
+ <span class="direction-label direction-ancient">Ancient</span>
213
+ </div>
214
+ </div>
215
+
216
+ <!-- Node detail panel -->
217
+ <div id="node-detail" class="hidden">
218
+ <button id="detail-close" title="Close" aria-label="Close">
219
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
220
+ <line x1="18" y1="6" x2="6" y2="18"></line>
221
+ <line x1="6" y1="6" x2="18" y2="18"></line>
222
+ </svg>
223
+ </button>
224
+ <div class="detail-header">
225
+ <span id="detail-lang" class="detail-lang"></span>
226
+ <span id="detail-word" class="detail-word"></span>
227
+ </div>
228
+ <div class="detail-row">
229
+ <span class="detail-label">
230
+ Family
231
+ <span class="info-btn" role="button" tabindex="0">i<span class="tooltip">Language family tree showing historical relationships</span></span>
232
+ </span>
233
+ <span id="detail-family" class="detail-value"></span>
234
+ </div>
235
+ <div class="detail-row">
236
+ <span class="detail-label">Meaning</span>
237
+ <span id="detail-sense" class="detail-value"></span>
238
+ </div>
239
+ </div>
240
+ </div>
241
+
242
+ <div id="word-info" class="hidden">
243
+ <span id="current-word"></span>
244
+ <span class="info-divider"></span>
245
+ <div id="lang-breakdown"></div>
246
+ <button id="stats-toggle" class="stats-toggle" title="Toggle graph statistics">
247
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
248
+ <line x1="18" y1="20" x2="18" y2="10"></line>
249
+ <line x1="12" y1="20" x2="12" y2="4"></line>
250
+ <line x1="6" y1="20" x2="6" y2="14"></line>
251
+ </svg>
252
+ Graph Stats
253
+ </button>
254
+ </div>
255
+
256
+ <!-- Stats panel expands below word-info, pushing footer down -->
257
+ <div id="stats-panel" class="stats-panel hidden">
258
+ <div class="stats-grid">
259
+ <div class="stat-item">
260
+ <span class="stat-value" id="stat-nodes">0</span>
261
+ <span class="stat-label">nodes</span>
262
+ </div>
263
+ <div class="stat-item">
264
+ <span class="stat-value" id="stat-edges">0</span>
265
+ <span class="stat-label">edges</span>
266
+ </div>
267
+ <div class="stat-item">
268
+ <span class="stat-value" id="stat-langs">0</span>
269
+ <span class="stat-label">languages</span>
270
+ </div>
271
+ <div class="stat-item">
272
+ <span class="stat-value" id="stat-depth">0</span>
273
+ <span class="stat-label">depth</span>
274
+ </div>
275
+ </div>
276
+ </div>
277
+
278
+ <!-- About Modal -->
279
+ <div id="about-modal" class="modal hidden">
280
+ <div class="modal-backdrop"></div>
281
+ <div class="modal-content">
282
+ <button id="about-close" class="modal-close" aria-label="Close">
283
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
284
+ <line x1="18" y1="6" x2="6" y2="18"></line>
285
+ <line x1="6" y1="6" x2="18" y2="18"></line>
286
+ </svg>
287
+ </button>
288
+
289
+ <div class="modal-tabs">
290
+ <button class="modal-tab active" data-tab="about">About</button>
291
+ <button class="modal-tab" data-tab="glossary">Glossary</button>
292
+ <button class="modal-tab" data-tab="how">How It Works</button>
293
+ </div>
294
+
295
+ <div id="tab-about" class="tab-content active">
296
+ <h2>Etymology for All</h2>
297
+ <p class="philosophy">
298
+ Etymology should be a <strong>public good</strong>.
299
+ </p>
300
+ <p>
301
+ Many apps gatekeep etymological knowledge behind paywalls or proprietary databases.
302
+ We believe the history of language belongs to everyone.
303
+ </p>
304
+ <p>
305
+ This project exists to make word origins accessible, explorable, and free.
306
+ Every word has a story—discover where yours came from.
307
+ </p>
308
+ <h3>Data Sources</h3>
309
+ <p>
310
+ <strong>Etymology:</strong> <a href="https://github.com/clefourrier/EtymDB" target="_blank" rel="noopener">EtymDB 2.1</a>,
311
+ an open etymological database derived from Wiktionary containing 1.9 million words across 2,500+ languages.
312
+ </p>
313
+ <p class="citation">
314
+ <a href="https://aclanthology.org/2020.lrec-1.392/" target="_blank" rel="noopener">Fourrier & Sagot (2020)</a>, "Methodological Aspects of Developing and Managing an Etymological Lexical Resource", LREC 2020
315
+ </p>
316
+ <p>
317
+ <strong>Definitions:</strong> <a href="https://dictionaryapi.dev/" target="_blank" rel="noopener">Free Dictionary API</a>,
318
+ a community-driven dictionary service also sourced from Wiktionary.
319
+ </p>
320
+ </div>
321
+
322
+ <div id="tab-glossary" class="tab-content">
323
+ <h2>Glossary</h2>
324
+ <h3>Etymology</h3>
325
+ <p>
326
+ The study of word origins and how their meanings have changed throughout history.
327
+ An etymology traces a word back through time to its earliest known form.
328
+ </p>
329
+ <h3>Ancestors</h3>
330
+ <p>
331
+ Words that a modern word <strong>inherited from</strong> or <strong>derived from</strong>.
332
+ These form the direct lineage of a word through time.
333
+ </p>
334
+ <p class="example">
335
+ Example: English "mother" ← Old English "mōdor" ← Proto-Germanic "*mōdēr" ← Proto-Indo-European "*méh₂tēr"
336
+ </p>
337
+ <h3>Cognates</h3>
338
+ <p>
339
+ Words in <strong>different languages</strong> that share a common ancestor.
340
+ They evolved separately but have the same root.
341
+ </p>
342
+ <p class="example">
343
+ Example: English "friend", German "Freund", Dutch "vriend", Gothic "frijōnds"
344
+ — all from Proto-Germanic "*frijōndz"
345
+ </p>
346
+ <h3>Compound Etymology</h3>
347
+ <p>
348
+ When a word is formed from <strong>multiple source words</strong> or morphemes combined together.
349
+ These are shown with blue edges in the graph.
350
+ </p>
351
+ <p class="example">
352
+ Example: "uplander" = "upland" + "-er" (the suffix meaning "one who")
353
+ </p>
354
+ <h3>Morpheme</h3>
355
+ <p>
356
+ The smallest meaningful unit of language. Words are built from morphemes,
357
+ including roots, prefixes, and suffixes.
358
+ </p>
359
+ <p class="example">
360
+ Example: "unhappiness" contains three morphemes: "un-" (not) + "happy" (root) + "-ness" (state of)
361
+ </p>
362
+ <h3>Graph Traversal</h3>
363
+ <p>
364
+ The graph follows etymology connections recursively up to 5 levels deep,
365
+ including both ancestors and cognates.
366
+ </p>
367
+ <p class="example">
368
+ Example: "friend" connects to 12 words directly. Following each of those
369
+ recursively for 5 levels yields 28 total nodes in the graph.
370
+ </p>
371
+ <h3>Language Family</h3>
372
+ <p>
373
+ A group of languages descended from a common ancestral language.
374
+ </p>
375
+ <p class="example">
376
+ Example: English, German, Dutch, and Swedish are all part of the <strong>Germanic</strong> branch
377
+ of the <strong>Indo-European</strong> family.
378
+ </p>
379
+ <h3>Proto-language</h3>
380
+ <p>
381
+ A reconstructed ancestral language that existed before writing.
382
+ Linguists use the prefix "Proto-" and asterisks (*) for reconstructed forms.
383
+ </p>
384
+ <p class="example">
385
+ Example: Proto-Indo-European (*méh₂tēr) is the reconstructed ancestor of words for "mother"
386
+ across many languages, from English to Hindi.
387
+ </p>
388
+ <h3>Language Codes</h3>
389
+ <p>
390
+ Languages are identified by standardized codes from <a href="https://en.wikipedia.org/wiki/ISO_639-3" target="_blank" rel="noopener">ISO 639</a>,
391
+ similar to how countries have two-letter codes (US, UK, DE).
392
+ These codes help linguists and researchers categorize the world's ~7,000 languages consistently.
393
+ </p>
394
+ <p class="example">
395
+ Examples: <code>en</code> = English, <code>la</code> = Latin, <code>grc</code> = Ancient Greek,
396
+ <code>ang</code> = Old English, <code>gem-pro</code> = Proto-Germanic, <code>ine-pro</code> = Proto-Indo-European
397
+ </p>
398
+ <h3>Why Random Words Are Obscure</h3>
399
+ <p>
400
+ Word usage follows <a href="https://en.wikipedia.org/wiki/Zipf%27s_law" target="_blank" rel="noopener">Zipf's Law</a>—a
401
+ small number of words make up most of what we read, while thousands of rare words form a "long tail."
402
+ Our random button samples uniformly, giving you equal chances of discovering hidden gems like "cystolithic" or "auxotrophy."
403
+ </p>
404
+ </div>
405
+
406
+ <div id="tab-how" class="tab-content">
407
+ <h2>How It Works</h2>
408
+ <h3>The Data Pipeline</h3>
409
+ <ol>
410
+ <li><strong>Source:</strong> EtymDB extracts etymology data from Wiktionary</li>
411
+ <li><strong>Curation:</strong> We filter for clean English words with valid etymology links (~40K words)</li>
412
+ <li><strong>Language metadata:</strong> Each word is tagged with its language family (e.g., "Germanic → Indo-European")</li>
413
+ <li><strong>Definitions:</strong> Enriched from Free Dictionary API (~21K definitions)</li>
414
+ </ol>
415
+ <h3>Reading the Graph</h3>
416
+ <ul>
417
+ <li><strong>Arrows</strong> point from modern words to their ancestors</li>
418
+ <li><strong>Click any word</strong> to see its language family and definition</li>
419
+ <li><strong>Language families</strong> show how languages are historically related</li>
420
+ </ul>
421
+ <h3>Definition Matching</h3>
422
+ <p>
423
+ EtymDB provides a <strong>sense</strong> field for each word entry (e.g., "bank" might have
424
+ senses like "financial institution" or "side of a river"). When the sense differs from
425
+ the word itself, we display it directly.
426
+ </p>
427
+ <p>
428
+ <strong>Key assumption:</strong> When a word's sense equals its lexeme (e.g., sense="bank"
429
+ for word "bank"), we fall back to the <strong>first definition</strong> from the Free Dictionary API.
430
+ We assume this primary definition corresponds to the word's main etymological meaning.
431
+ This may not always be accurate for words with multiple distinct origins.
432
+ </p>
433
+ <h3>Limitations</h3>
434
+ <p>
435
+ Not all words have definitions available. Some etymology connections may be incomplete
436
+ or reflect Wiktionary's editorial choices. Compound word breakdowns (e.g., "magn-animus")
437
+ are not yet supported. Definition matching between EtymDB senses and dictionary entries
438
+ is approximate—there is no shared identifier between the two data sources.
439
+ </p>
440
+ </div>
441
+ </div>
442
+ </div>
443
+ </main>
444
+
445
+ <footer>
446
+ <p>Etymology from <a href="https://github.com/clefourrier/EtymDB" target="_blank" rel="noopener">EtymDB 2.1</a>. Definitions from <a href="https://dictionaryapi.dev/" target="_blank" rel="noopener">Free Dictionary API</a>.</p>
447
+ <p>Built by <a href="https://github.com/lucharo" target="_blank" rel="noopener">@lucharo</a> · <a href="https://github.com/lucharo/etymology-for-all" target="_blank" rel="noopener">Source code</a></p>
448
+ <details id="version-details" class="version-details">
449
+ <summary id="version-summary">Version info</summary>
450
+ <div id="version-content" class="version-content">Loading...</div>
451
+ </details>
452
+ </footer>
453
+
454
+ <script type="module" src="js/app.js?v=6"></script>
455
+ </body>
456
+ </html>
frontend/js/app.js ADDED
@@ -0,0 +1,600 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Etymology Graph Explorer - Main Application
3
+ * Interactive visualization of word origins using Cytoscape.js
4
+ */
5
+
6
+ import { getLangName, checkNoEtymology } from './utils.js';
7
+ import {
8
+ initCytoscape,
9
+ getCy,
10
+ filterGraphByDepth,
11
+ filterCompoundEdges,
12
+ calculateMaxGraphDepth,
13
+ calculateGraphDepth,
14
+ renderGraphElements,
15
+ setShowLinkTypes,
16
+ } from './graph.js';
17
+ import {
18
+ fetchEtymology,
19
+ fetchRandomWord,
20
+ fetchSuggestions,
21
+ hideSuggestions,
22
+ navigateSuggestions,
23
+ getSelectedSuggestion,
24
+ } from './search.js';
25
+ import {
26
+ showNodeDetail,
27
+ hideNodeDetail,
28
+ showLoading,
29
+ showError,
30
+ showGraph,
31
+ updateStats,
32
+ updateInfoSummary,
33
+ updateDepthUI,
34
+ createExpandHandlers,
35
+ setupModal,
36
+ } from './ui.js';
37
+ import { buildTree, renderTreeHTML } from './tree.js';
38
+
39
+ // DOM elements
40
+ const elements = {
41
+ wordInput: document.getElementById('word-input'),
42
+ searchBtn: document.getElementById('search-btn'),
43
+ randomBtn: document.getElementById('random-btn'),
44
+ includeCompound: document.getElementById('include-compound'),
45
+ graphContainer: document.getElementById('graph-container'),
46
+ cyContainer: document.getElementById('cy'),
47
+ loadingEl: document.getElementById('loading'),
48
+ emptyState: document.getElementById('empty-state'),
49
+ errorState: document.getElementById('error-state'),
50
+ errorMessage: document.getElementById('error-message'),
51
+ wordInfo: document.getElementById('word-info'),
52
+ currentWord: document.getElementById('current-word'),
53
+ langBreakdown: document.getElementById('lang-breakdown'),
54
+ nodeDetail: document.getElementById('node-detail'),
55
+ detailWord: document.getElementById('detail-word'),
56
+ detailLang: document.getElementById('detail-lang'),
57
+ detailFamily: document.getElementById('detail-family'),
58
+ detailSense: document.getElementById('detail-sense'),
59
+ detailClose: document.getElementById('detail-close'),
60
+ suggestions: document.getElementById('suggestions'),
61
+ graphLegend: document.getElementById('graph-legend'),
62
+ directionIndicator: document.getElementById('direction-indicator'),
63
+ depthMinus: document.getElementById('depth-minus'),
64
+ depthPlus: document.getElementById('depth-plus'),
65
+ depthValue: document.getElementById('depth-value'),
66
+ graphOptions: document.getElementById('graph-options'),
67
+ expandBtn: document.getElementById('expand-btn'),
68
+ graphBackdrop: document.getElementById('graph-backdrop'),
69
+ statsToggle: document.getElementById('stats-toggle'),
70
+ statsPanel: document.getElementById('stats-panel'),
71
+ statNodes: document.getElementById('stat-nodes'),
72
+ statEdges: document.getElementById('stat-edges'),
73
+ statLangs: document.getElementById('stat-langs'),
74
+ statDepth: document.getElementById('stat-depth'),
75
+ viewGraphBtn: document.getElementById('view-graph'),
76
+ viewTreeBtn: document.getElementById('view-tree'),
77
+ treeView: document.getElementById('tree-view'),
78
+ };
79
+
80
+ // State
81
+ let fullGraphData = null;
82
+ let currentSearchedWord = null;
83
+ let currentDepth = 5;
84
+ let graphMaxDepth = 10;
85
+ const MIN_DEPTH = 1;
86
+ let searchTimeout = null;
87
+ let serverReady = false;
88
+ let graphAvailable = false;
89
+ let currentView = 'graph'; // 'graph' or 'tree'
90
+
91
+ // Server health check with retry (HF Spaces sleep after inactivity)
92
+ async function checkServerHealth(maxWaitMs = 120000) {
93
+ const startTime = Date.now();
94
+ const emptyState = document.getElementById('empty-state');
95
+ const originalText = emptyState?.querySelector('p')?.textContent;
96
+ let attempt = 0;
97
+
98
+ while (Date.now() - startTime < maxWaitMs) {
99
+ try {
100
+ const controller = new AbortController();
101
+ const timeout = setTimeout(() => controller.abort(), 5000);
102
+
103
+ const response = await fetch('/health', { signal: controller.signal });
104
+ clearTimeout(timeout);
105
+
106
+ if (response.ok) {
107
+ // Server is ready
108
+ if (emptyState?.querySelector('p')) {
109
+ emptyState.querySelector('p').textContent = originalText || 'Search for a word to see its etymological journey.';
110
+ }
111
+ serverReady = true;
112
+ return true;
113
+ }
114
+ } catch (e) {
115
+ // Server not ready yet
116
+ }
117
+
118
+ attempt++;
119
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
120
+ const remaining = Math.round((maxWaitMs - (Date.now() - startTime)) / 1000);
121
+
122
+ if (emptyState?.querySelector('p')) {
123
+ emptyState.querySelector('p').innerHTML =
124
+ `<span style="color: var(--accent);">Server waking up...</span><br>` +
125
+ `<small style="color: var(--text-muted);">Free tier sleeps after inactivity. Please wait (~${remaining}s remaining)</small>`;
126
+ }
127
+
128
+ // Wait before retry (1s, then 2s intervals)
129
+ await new Promise(r => setTimeout(r, attempt === 1 ? 1000 : 2000));
130
+ }
131
+
132
+ // Timeout reached
133
+ if (emptyState?.querySelector('p')) {
134
+ emptyState.querySelector('p').innerHTML =
135
+ `<span style="color: var(--error);">Server unavailable</span><br>` +
136
+ `<small>Please try refreshing the page.</small>`;
137
+ }
138
+ return false;
139
+ }
140
+
141
+ // Expand handlers
142
+ const { toggleExpandGraph, minimizeGraph, getIsExpanded } = createExpandHandlers(
143
+ elements.graphContainer,
144
+ elements.graphBackdrop
145
+ );
146
+
147
+ // View toggle
148
+ function setView(view) {
149
+ currentView = view;
150
+
151
+ // Update button states
152
+ if (elements.viewGraphBtn) {
153
+ elements.viewGraphBtn.classList.toggle('active', view === 'graph');
154
+ }
155
+ if (elements.viewTreeBtn) {
156
+ elements.viewTreeBtn.classList.toggle('active', view === 'tree');
157
+ }
158
+
159
+ // Toggle visibility
160
+ if (view === 'tree') {
161
+ elements.cyContainer.classList.add('hidden');
162
+ elements.treeView.classList.remove('hidden');
163
+ if (elements.graphLegend) {
164
+ elements.graphLegend.classList.add('hidden');
165
+ }
166
+ renderTreeView();
167
+ } else {
168
+ elements.treeView.classList.add('hidden');
169
+ elements.cyContainer.classList.remove('hidden');
170
+ // Re-render graph fully (may have been skipped while in tree view)
171
+ if (fullGraphData && currentSearchedWord) {
172
+ renderGraph(fullGraphData, currentSearchedWord, true);
173
+ }
174
+ }
175
+ }
176
+
177
+ // Render tree view
178
+ function renderTreeView() {
179
+ if (!fullGraphData || !currentSearchedWord) {
180
+ elements.treeView.innerHTML = '<div class="tree-empty">Search for a word to see its etymology tree</div>';
181
+ return;
182
+ }
183
+
184
+ // Apply filters: depth first, then compound
185
+ const includeCompound = elements.includeCompound?.checked ?? true;
186
+ let displayData = filterGraphByDepth(fullGraphData, currentDepth, currentSearchedWord);
187
+ displayData = filterCompoundEdges(displayData, includeCompound, currentSearchedWord);
188
+
189
+ const tree = buildTree(displayData.nodes, displayData.edges, currentSearchedWord, currentDepth);
190
+ const treeHTML = renderTreeHTML(tree);
191
+ elements.treeView.innerHTML = treeHTML;
192
+
193
+ // Add click handlers for tree nodes
194
+ elements.treeView.querySelectorAll('.tree-node').forEach(node => {
195
+ node.addEventListener('click', () => {
196
+ const data = {
197
+ word: node.dataset.lexeme,
198
+ lang: node.dataset.lang,
199
+ langName: node.dataset.langName,
200
+ sense: node.dataset.sense || null,
201
+ family: node.dataset.family || null,
202
+ branch: node.dataset.branch || null,
203
+ };
204
+ showNodeDetail(data, elements);
205
+ });
206
+ });
207
+ }
208
+
209
+ // Render graph with current depth
210
+ function renderGraph(data, searchedWord, filterByDepth = true) {
211
+ // Check for no-etymology response
212
+ const noEtym = checkNoEtymology(data);
213
+ if (noEtym) {
214
+ const word = noEtym.lexeme || searchedWord;
215
+ showError(
216
+ `'${word}' was found but has no etymology data in EtymDB`,
217
+ elements,
218
+ minimizeGraph,
219
+ { wiktionaryWord: word, searchedWord: word }
220
+ );
221
+ return;
222
+ }
223
+
224
+ if (!data.nodes || data.nodes.length === 0) {
225
+ showError('No etymology data available for this word', elements, minimizeGraph, { searchedWord });
226
+ return;
227
+ }
228
+
229
+ if (!filterByDepth || !fullGraphData || currentSearchedWord !== searchedWord) {
230
+ fullGraphData = data;
231
+ currentSearchedWord = searchedWord;
232
+ graphMaxDepth = calculateMaxGraphDepth(data.nodes, data.edges, searchedWord);
233
+ currentDepth = graphMaxDepth;
234
+ }
235
+
236
+ // Apply filters: depth first, then compound
237
+ const includeCompound = elements.includeCompound?.checked ?? true;
238
+ let displayData = filterByDepth
239
+ ? filterGraphByDepth(fullGraphData, currentDepth, searchedWord)
240
+ : data;
241
+ displayData = filterCompoundEdges(displayData, includeCompound, searchedWord);
242
+
243
+ if (elements.graphOptions) elements.graphOptions.classList.remove('hidden');
244
+ updateDepthUI(currentDepth, graphMaxDepth, elements);
245
+
246
+ hideNodeDetail(elements.nodeDetail);
247
+
248
+ elements.currentWord.textContent = searchedWord;
249
+ elements.wordInfo.classList.remove('hidden');
250
+ showGraph(elements);
251
+
252
+ if (currentView === 'tree') {
253
+ // In tree view, only render tree — skip expensive graph layout
254
+ renderTreeView();
255
+ } else {
256
+ // Render graph and legend
257
+ const { seenLangs, langCounts, langCodes } = renderGraphElements(displayData, elements.graphLegend, elements.directionIndicator);
258
+ updateInfoSummary(langCounts, langCodes, elements.langBreakdown);
259
+
260
+ // Re-fit after browser has reflowed layout (double-RAF ensures reflow is complete)
261
+ requestAnimationFrame(() => {
262
+ requestAnimationFrame(() => {
263
+ const cy = getCy();
264
+ if (cy) {
265
+ cy.resize();
266
+ cy.fit(undefined, 40);
267
+ }
268
+ });
269
+ });
270
+
271
+ setTimeout(() => {
272
+ const graphDepth = calculateGraphDepth(searchedWord);
273
+ updateStats(displayData.nodes.length, displayData.edges.length, langCounts.size, graphDepth, elements);
274
+ }, 50);
275
+ }
276
+ }
277
+
278
+ // Search handler
279
+ async function handleSearch() {
280
+ const word = elements.wordInput.value.trim();
281
+ if (!word) return;
282
+
283
+ if (!graphAvailable) {
284
+ showError('Graph engine is not available. Try refreshing the page.', elements, minimizeGraph);
285
+ return;
286
+ }
287
+
288
+ showLoading(elements);
289
+
290
+ try {
291
+ const data = await fetchEtymology(word);
292
+ renderGraph(data, word);
293
+ } catch (err) {
294
+ showError(err.message, elements, minimizeGraph, { searchedWord: word });
295
+ }
296
+ }
297
+
298
+ // Random word handler
299
+ async function handleRandom() {
300
+ if (!graphAvailable) {
301
+ showError('Graph engine is not available. Try refreshing the page.', elements, minimizeGraph);
302
+ return;
303
+ }
304
+
305
+ showLoading(elements);
306
+
307
+ try {
308
+ const includeCompound = elements.includeCompound?.checked ?? true;
309
+ const word = await fetchRandomWord(includeCompound);
310
+ if (!word) {
311
+ showError('Could not get a random word', elements, minimizeGraph);
312
+ return;
313
+ }
314
+ elements.wordInput.value = word;
315
+ const data = await fetchEtymology(word);
316
+ renderGraph(data, word);
317
+ } catch (err) {
318
+ showError(err.message, elements, minimizeGraph, { searchedWord: elements.wordInput.value.trim() });
319
+ }
320
+ }
321
+
322
+ // Depth change
323
+ function changeDepth(delta) {
324
+ const newDepth = currentDepth + delta;
325
+ if (newDepth < MIN_DEPTH || newDepth > graphMaxDepth) return;
326
+
327
+ currentDepth = newDepth;
328
+ updateDepthUI(currentDepth, graphMaxDepth, elements);
329
+
330
+ if (fullGraphData && currentSearchedWord) {
331
+ renderGraph(fullGraphData, currentSearchedWord, true);
332
+ }
333
+ }
334
+
335
+ // Suggestion selection
336
+ function selectSuggestion(word) {
337
+ elements.wordInput.value = word;
338
+ hideSuggestions(elements.suggestions);
339
+ handleSearch();
340
+ }
341
+
342
+ // Initialize on DOM ready
343
+ document.addEventListener('DOMContentLoaded', async () => {
344
+ // Check server health first (HF Spaces may be sleeping)
345
+ checkServerHealth(120000); // 2 minutes max wait, runs in background
346
+
347
+ // Initialize Cytoscape (wrapped so UI still works if CDN scripts failed to load)
348
+ try {
349
+ initCytoscape(
350
+ elements.cyContainer,
351
+ (data) => showNodeDetail(data, elements),
352
+ () => hideNodeDetail(elements.nodeDetail),
353
+ (node, container) => {
354
+ const sense = node.data('sense');
355
+ const langName = node.data('langName') || getLangName(node.data('lang'));
356
+ let tip = `${node.data('word')} (${langName})`;
357
+ if (sense) tip += `\n"${sense}"`;
358
+ container.title = tip;
359
+ container.style.cursor = 'pointer';
360
+ },
361
+ (container) => {
362
+ container.title = '';
363
+ container.style.cursor = 'default';
364
+ }
365
+ );
366
+ graphAvailable = true;
367
+ } catch (e) {
368
+ console.error('Failed to initialize graph engine:', e);
369
+ showError(
370
+ 'Graph engine failed to load. Try refreshing the page.',
371
+ elements,
372
+ minimizeGraph
373
+ );
374
+ }
375
+
376
+ // Detail panel close
377
+ if (elements.detailClose) {
378
+ elements.detailClose.addEventListener('click', () => hideNodeDetail(elements.nodeDetail));
379
+ }
380
+
381
+ // Search buttons
382
+ elements.searchBtn.addEventListener('click', handleSearch);
383
+ elements.randomBtn.addEventListener('click', handleRandom);
384
+
385
+ // Depth buttons
386
+ if (elements.depthMinus) {
387
+ elements.depthMinus.addEventListener('click', () => changeDepth(-1));
388
+ }
389
+ if (elements.depthPlus) {
390
+ elements.depthPlus.addEventListener('click', () => changeDepth(1));
391
+ }
392
+
393
+ // Compound filter checkbox - re-renders graph when toggled
394
+ if (elements.includeCompound) {
395
+ elements.includeCompound.addEventListener('change', () => {
396
+ const mobileCompound = document.getElementById('mobile-include-compound');
397
+ if (mobileCompound) mobileCompound.checked = elements.includeCompound.checked;
398
+ if (fullGraphData && currentSearchedWord) {
399
+ renderGraph(fullGraphData, currentSearchedWord, true);
400
+ }
401
+ });
402
+ }
403
+
404
+ // Stats toggle
405
+ if (elements.statsToggle && elements.statsPanel) {
406
+ elements.statsToggle.addEventListener('click', () => {
407
+ const isHidden = elements.statsPanel.classList.toggle('hidden');
408
+ elements.statsToggle.classList.toggle('active', !isHidden);
409
+ });
410
+ }
411
+
412
+ // View toggle buttons
413
+ if (elements.viewGraphBtn) {
414
+ elements.viewGraphBtn.addEventListener('click', () => setView('graph'));
415
+ }
416
+ if (elements.viewTreeBtn) {
417
+ elements.viewTreeBtn.addEventListener('click', () => setView('tree'));
418
+ }
419
+
420
+ // Expand button
421
+ if (elements.expandBtn) {
422
+ elements.expandBtn.addEventListener('click', toggleExpandGraph);
423
+ }
424
+ if (elements.graphBackdrop) {
425
+ elements.graphBackdrop.addEventListener('click', minimizeGraph);
426
+ }
427
+
428
+ // Autocomplete input
429
+ elements.wordInput.addEventListener('input', (e) => {
430
+ clearTimeout(searchTimeout);
431
+ searchTimeout = setTimeout(() => {
432
+ fetchSuggestions(e.target.value.trim(), elements.suggestions, selectSuggestion);
433
+ }, 150);
434
+ });
435
+
436
+ // Keyboard navigation
437
+ elements.wordInput.addEventListener('keydown', (e) => {
438
+ const isOpen = elements.suggestions && !elements.suggestions.classList.contains('hidden');
439
+
440
+ if (e.key === 'Enter') {
441
+ if (isOpen) {
442
+ const selected = getSelectedSuggestion(elements.suggestions);
443
+ if (selected) {
444
+ selectSuggestion(selected);
445
+ } else {
446
+ hideSuggestions(elements.suggestions);
447
+ handleSearch();
448
+ }
449
+ } else {
450
+ handleSearch();
451
+ }
452
+ e.preventDefault();
453
+ } else if (e.key === 'ArrowDown' && isOpen) {
454
+ navigateSuggestions(elements.suggestions, 1);
455
+ e.preventDefault();
456
+ } else if (e.key === 'ArrowUp' && isOpen) {
457
+ navigateSuggestions(elements.suggestions, -1);
458
+ e.preventDefault();
459
+ } else if (e.key === 'Escape') {
460
+ if (isOpen) {
461
+ hideSuggestions(elements.suggestions);
462
+ } else if (getIsExpanded()) {
463
+ minimizeGraph();
464
+ }
465
+ }
466
+ });
467
+
468
+ // Hide suggestions on outside click
469
+ document.addEventListener('click', (e) => {
470
+ if (!e.target.closest('.search-wrapper')) {
471
+ hideSuggestions(elements.suggestions);
472
+ }
473
+ });
474
+
475
+ // Settings popover toggle
476
+ const settingsBtn = document.getElementById('settings-btn');
477
+ const settingsPopover = document.getElementById('settings-popover');
478
+ if (settingsBtn && settingsPopover) {
479
+ settingsBtn.addEventListener('click', (e) => {
480
+ e.stopPropagation();
481
+ settingsPopover.classList.toggle('hidden');
482
+ });
483
+ document.addEventListener('click', (e) => {
484
+ if (!e.target.closest('.settings-wrapper')) {
485
+ settingsPopover.classList.add('hidden');
486
+ }
487
+ });
488
+ }
489
+
490
+ // Setup modal
491
+ const aboutBtn = document.getElementById('about-btn');
492
+ const aboutModal = document.getElementById('about-modal');
493
+ const aboutClose = document.getElementById('about-close');
494
+ const modalBackdrop = aboutModal?.querySelector('.modal-backdrop');
495
+ const modalTabs = aboutModal?.querySelectorAll('.modal-tab');
496
+
497
+ const { closeAboutModal } = setupModal(aboutBtn, aboutModal, aboutClose, modalBackdrop, modalTabs);
498
+
499
+ // Escape for modal
500
+ document.addEventListener('keydown', (e) => {
501
+ if (e.key === 'Escape' && aboutModal && !aboutModal.classList.contains('hidden')) {
502
+ closeAboutModal();
503
+ }
504
+ });
505
+
506
+ // Version info in footer
507
+ const versionDetails = document.getElementById('version-details');
508
+ const versionContent = document.getElementById('version-content');
509
+ const versionSummary = document.getElementById('version-summary');
510
+ if (versionDetails && versionContent) {
511
+ async function fetchVersion() {
512
+ try {
513
+ const response = await fetch('/version');
514
+ if (!response.ok) return;
515
+ const data = await response.json();
516
+ const version = data.version || 'unknown';
517
+ const stats = data.db_stats || {};
518
+ versionSummary.textContent = `v${version}`;
519
+ versionContent.innerHTML =
520
+ `<span>Version: ${version}</span>` +
521
+ (stats.words ? `<span>Words: ${stats.words.toLocaleString()}</span>` : '') +
522
+ (stats.definitions ? `<span>Definitions: ${stats.definitions.toLocaleString()}</span>` : '');
523
+ } catch (e) {
524
+ versionContent.textContent = 'Could not load version info';
525
+ }
526
+ }
527
+ // Fetch on toggle or on load
528
+ versionDetails.addEventListener('toggle', () => {
529
+ if (versionDetails.open) fetchVersion();
530
+ });
531
+ // Also fetch eagerly so the summary shows the version
532
+ fetchVersion();
533
+ }
534
+
535
+ // Link types toggle - shared handler for both desktop and mobile checkboxes
536
+ const showLinkTypesCheckbox = document.getElementById('show-link-types');
537
+ const mobileShowLinkTypes = document.getElementById('mobile-show-link-types');
538
+ const simpleLegend = document.getElementById('edge-legend-simple');
539
+ const detailedLegend = document.getElementById('edge-legend-detailed');
540
+
541
+ function handleLinkTypesToggle(enabled) {
542
+ setShowLinkTypes(enabled);
543
+ if (simpleLegend) simpleLegend.classList.toggle('hidden', enabled);
544
+ if (detailedLegend) detailedLegend.classList.toggle('hidden', !enabled);
545
+ // Re-render to apply/remove link type colors
546
+ if (fullGraphData && currentSearchedWord) {
547
+ renderGraph(fullGraphData, currentSearchedWord, true);
548
+ }
549
+ }
550
+
551
+ if (showLinkTypesCheckbox) {
552
+ showLinkTypesCheckbox.addEventListener('change', () => {
553
+ const enabled = showLinkTypesCheckbox.checked;
554
+ if (mobileShowLinkTypes) mobileShowLinkTypes.checked = enabled;
555
+ handleLinkTypesToggle(enabled);
556
+ });
557
+ }
558
+
559
+ // Mobile menu
560
+ const mobileMenuBtn = document.getElementById('mobile-menu-btn');
561
+ const mobileMenu = document.getElementById('mobile-menu');
562
+ const mobileAboutBtn = document.getElementById('mobile-about-btn');
563
+ const mobileIncludeCompound = document.getElementById('mobile-include-compound');
564
+
565
+ if (mobileMenuBtn && mobileMenu) {
566
+ mobileMenuBtn.addEventListener('click', (e) => {
567
+ e.stopPropagation();
568
+ mobileMenu.classList.toggle('hidden');
569
+ });
570
+ document.addEventListener('click', (e) => {
571
+ if (!e.target.closest('.mobile-menu-wrapper')) {
572
+ mobileMenu.classList.add('hidden');
573
+ }
574
+ });
575
+ }
576
+
577
+ if (mobileAboutBtn) {
578
+ mobileAboutBtn.addEventListener('click', () => {
579
+ mobileMenu?.classList.add('hidden');
580
+ if (aboutModal) aboutModal.classList.remove('hidden');
581
+ });
582
+ }
583
+
584
+ // Sync mobile compound checkbox with desktop
585
+ if (mobileIncludeCompound && elements.includeCompound) {
586
+ mobileIncludeCompound.addEventListener('change', () => {
587
+ elements.includeCompound.checked = mobileIncludeCompound.checked;
588
+ elements.includeCompound.dispatchEvent(new Event('change'));
589
+ });
590
+ }
591
+
592
+ // Sync mobile link types checkbox with desktop
593
+ if (mobileShowLinkTypes) {
594
+ mobileShowLinkTypes.addEventListener('change', () => {
595
+ const enabled = mobileShowLinkTypes.checked;
596
+ if (showLinkTypesCheckbox) showLinkTypesCheckbox.checked = enabled;
597
+ handleLinkTypesToggle(enabled);
598
+ });
599
+ }
600
+ });
frontend/js/graph.js ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Graph rendering and Cytoscape functionality
3
+ */
4
+
5
+ import { getLangName, buildNodeLabel } from './utils.js';
6
+
7
+ let cy = null;
8
+ let showLinkTypes = false;
9
+
10
+ export function getCy() {
11
+ return cy;
12
+ }
13
+
14
+ // Link type color mapping
15
+ const LINK_TYPE_COLORS = {
16
+ inh: '#059669', // green - inherited
17
+ bor: '#d97706', // amber - borrowed
18
+ der: '#7c3aed', // purple - derived
19
+ cog: '#0284c7', // blue - cognate
20
+ cmpd: '#0c4a6e', // dark blue - compound
21
+ };
22
+
23
+ function getLinkTypeColor(type) {
24
+ if (!type) return null;
25
+ // Handle compound types like "cmpd+bor", "der(s)", "der(p)"
26
+ if (type.startsWith('cmpd')) return LINK_TYPE_COLORS.cmpd;
27
+ if (type.startsWith('der')) return LINK_TYPE_COLORS.der;
28
+ return LINK_TYPE_COLORS[type] || null;
29
+ }
30
+
31
+ export function setShowLinkTypes(value) {
32
+ showLinkTypes = value;
33
+ if (!cy) return;
34
+ cy.edges().forEach(edge => {
35
+ const type = edge.data('linkType');
36
+ if (showLinkTypes && type) {
37
+ const color = getLinkTypeColor(type);
38
+ if (color) {
39
+ edge.style('line-color', color);
40
+ edge.style('target-arrow-color', color);
41
+ }
42
+ } else if (edge.data('compound')) {
43
+ edge.style('line-color', '#0c4a6e');
44
+ edge.style('target-arrow-color', '#0c4a6e');
45
+ } else {
46
+ edge.style('line-color', '#d6d3d1');
47
+ edge.style('target-arrow-color', '#d6d3d1');
48
+ }
49
+ });
50
+ }
51
+
52
+ export function getLayoutDirection() {
53
+ return window.innerWidth > window.innerHeight ? 'LR' : 'TB';
54
+ }
55
+
56
+ export function initCytoscape(container, onNodeTap, onBackgroundTap, onNodeHover, onNodeOut) {
57
+ cy = cytoscape({
58
+ container,
59
+ style: [
60
+ {
61
+ selector: 'node',
62
+ style: {
63
+ 'label': 'data(label)',
64
+ 'text-valign': 'center',
65
+ 'text-halign': 'center',
66
+ 'text-wrap': 'wrap',
67
+ 'text-max-width': '140px',
68
+ 'background-color': '#f8fafc',
69
+ 'color': '#1c1917',
70
+ 'font-size': '13px',
71
+ 'font-family': 'system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
72
+ 'width': 'label',
73
+ 'height': 'label',
74
+ 'padding': '12px',
75
+ 'shape': 'round-rectangle',
76
+ 'border-width': '1px',
77
+ 'border-color': '#cbd5e1',
78
+ },
79
+ },
80
+ {
81
+ selector: 'node:selected',
82
+ style: {
83
+ 'border-width': '2px',
84
+ 'border-color': '#0284c7',
85
+ 'background-color': '#f0f9ff',
86
+ },
87
+ },
88
+ {
89
+ selector: 'edge',
90
+ style: {
91
+ 'width': 2,
92
+ 'line-color': '#d6d3d1',
93
+ 'target-arrow-color': '#d6d3d1',
94
+ 'target-arrow-shape': 'triangle',
95
+ 'curve-style': 'bezier',
96
+ 'arrow-scale': 1.2,
97
+ },
98
+ },
99
+ {
100
+ selector: 'edge:selected',
101
+ style: {
102
+ 'line-color': '#78716c',
103
+ 'target-arrow-color': '#78716c',
104
+ },
105
+ },
106
+ {
107
+ selector: 'edge[compound]',
108
+ style: {
109
+ 'line-color': '#0c4a6e',
110
+ 'target-arrow-color': '#0c4a6e',
111
+ },
112
+ },
113
+ ],
114
+ layout: { name: 'preset' },
115
+ minZoom: 0.3,
116
+ maxZoom: 3,
117
+ wheelSensitivity: 0.3,
118
+ });
119
+
120
+ cy.on('tap', 'node', (e) => onNodeTap(e.target.data()));
121
+ cy.on('tap', (e) => {
122
+ if (e.target === cy) onBackgroundTap();
123
+ });
124
+ cy.on('mouseover', 'node', (e) => onNodeHover(e.target, container));
125
+ cy.on('mouseout', 'node', () => onNodeOut(container));
126
+
127
+ return cy;
128
+ }
129
+
130
+ // Compute depth of each node from the starting word using BFS
131
+ export function computeNodeDepths(nodes, edges, startWord) {
132
+ const nodeDepths = new Map();
133
+ const adjacency = new Map();
134
+
135
+ edges.forEach(edge => {
136
+ if (!adjacency.has(edge.source)) adjacency.set(edge.source, []);
137
+ adjacency.get(edge.source).push(edge.target);
138
+ });
139
+
140
+ const startNode = nodes.find(n =>
141
+ n.lexeme && n.lexeme.toLowerCase() === startWord.toLowerCase() && n.lang === 'en'
142
+ );
143
+
144
+ if (!startNode) {
145
+ nodes.forEach(n => nodeDepths.set(n.id, 0));
146
+ return nodeDepths;
147
+ }
148
+
149
+ const queue = [{ id: startNode.id, depth: 0 }];
150
+ nodeDepths.set(startNode.id, 0);
151
+
152
+ while (queue.length > 0) {
153
+ const { id, depth } = queue.shift();
154
+ const neighbors = adjacency.get(id) || [];
155
+
156
+ neighbors.forEach(neighborId => {
157
+ if (!nodeDepths.has(neighborId)) {
158
+ nodeDepths.set(neighborId, depth + 1);
159
+ queue.push({ id: neighborId, depth: depth + 1 });
160
+ }
161
+ });
162
+ }
163
+
164
+ // Handle disconnected nodes
165
+ nodes.forEach(n => {
166
+ if (!nodeDepths.has(n.id)) nodeDepths.set(n.id, 999);
167
+ });
168
+
169
+ return nodeDepths;
170
+ }
171
+
172
+ export function filterGraphByDepth(data, maxDepth, searchedWord) {
173
+ const nodeDepths = computeNodeDepths(data.nodes, data.edges, searchedWord);
174
+ const filteredNodes = data.nodes.filter(n => nodeDepths.get(n.id) <= maxDepth);
175
+ const filteredNodeIds = new Set(filteredNodes.map(n => n.id));
176
+ const filteredEdges = data.edges.filter(e =>
177
+ filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target)
178
+ );
179
+ return { nodes: filteredNodes, edges: filteredEdges };
180
+ }
181
+
182
+ export function filterCompoundEdges(data, includeCompound, searchedWord) {
183
+ if (includeCompound) {
184
+ return data; // No filtering needed
185
+ }
186
+
187
+ // Filter out compound edges
188
+ const nonCompoundEdges = data.edges.filter(e => !e.compound);
189
+
190
+ // Find the start node (searched word, English)
191
+ const startNode = data.nodes.find(n =>
192
+ n.lexeme && n.lexeme.toLowerCase() === searchedWord.toLowerCase() && n.lang === 'en'
193
+ );
194
+
195
+ if (!startNode) {
196
+ // Fallback: return only non-compound connected components
197
+ const connectedNodeIds = new Set();
198
+ nonCompoundEdges.forEach(e => {
199
+ connectedNodeIds.add(e.source);
200
+ connectedNodeIds.add(e.target);
201
+ });
202
+ const filteredNodes = data.nodes.filter(n => connectedNodeIds.has(n.id));
203
+ return { nodes: filteredNodes, edges: nonCompoundEdges };
204
+ }
205
+
206
+ // BFS to find all nodes reachable from start via non-compound edges
207
+ const reachableNodeIds = new Set([startNode.id]);
208
+ const adjacency = new Map();
209
+ nonCompoundEdges.forEach(e => {
210
+ if (!adjacency.has(e.source)) adjacency.set(e.source, []);
211
+ adjacency.get(e.source).push(e.target);
212
+ });
213
+
214
+ const queue = [startNode.id];
215
+ while (queue.length > 0) {
216
+ const nodeId = queue.shift();
217
+ const neighbors = adjacency.get(nodeId) || [];
218
+ neighbors.forEach(neighborId => {
219
+ if (!reachableNodeIds.has(neighborId)) {
220
+ reachableNodeIds.add(neighborId);
221
+ queue.push(neighborId);
222
+ }
223
+ });
224
+ }
225
+
226
+ // Keep only reachable nodes and edges between them
227
+ const filteredNodes = data.nodes.filter(n => reachableNodeIds.has(n.id));
228
+ const filteredEdges = nonCompoundEdges.filter(e =>
229
+ reachableNodeIds.has(e.source) && reachableNodeIds.has(e.target)
230
+ );
231
+
232
+ return { nodes: filteredNodes, edges: filteredEdges };
233
+ }
234
+
235
+ export function calculateMaxGraphDepth(nodes, edges, startWord) {
236
+ const nodeDepths = computeNodeDepths(nodes, edges, startWord);
237
+ let maxDepth = 0;
238
+ nodeDepths.forEach(depth => {
239
+ if (depth < 999 && depth > maxDepth) maxDepth = depth;
240
+ });
241
+ return maxDepth;
242
+ }
243
+
244
+ export function calculateGraphDepth(startWord) {
245
+ if (!cy || cy.nodes().length === 0) return 0;
246
+
247
+ const startNode = cy.nodes().filter(n => {
248
+ const word = n.data('word');
249
+ const lang = n.data('lang');
250
+ return word && word.toLowerCase() === startWord.toLowerCase() && lang === 'en';
251
+ }).first();
252
+
253
+ if (!startNode || startNode.length === 0) return 0;
254
+
255
+ const visited = new Set();
256
+ const queue = [{ node: startNode, depth: 0 }];
257
+ let maxDepth = 0;
258
+
259
+ while (queue.length > 0) {
260
+ const { node, depth } = queue.shift();
261
+ const nodeId = node.id();
262
+
263
+ if (visited.has(nodeId)) continue;
264
+ visited.add(nodeId);
265
+ maxDepth = Math.max(maxDepth, depth);
266
+
267
+ const outgoers = node.outgoers('node');
268
+ outgoers.forEach(neighbor => {
269
+ if (!visited.has(neighbor.id())) {
270
+ queue.push({ node: neighbor, depth: depth + 1 });
271
+ }
272
+ });
273
+ }
274
+
275
+ return maxDepth;
276
+ }
277
+
278
+ export function renderGraphElements(displayData, graphLegend, directionIndicator) {
279
+ const elements = [];
280
+ const seenLangs = new Map(); // lang code -> lang name
281
+ const langCounts = new Map(); // lang name -> count
282
+ const langCodes = new Map(); // lang name -> lang code (for display)
283
+
284
+ displayData.nodes.forEach((node) => {
285
+ const langName = node.lang_name || getLangName(node.lang);
286
+ const displayWord = node.lexeme || node.id;
287
+ seenLangs.set(node.lang, langName);
288
+ langCounts.set(langName, (langCounts.get(langName) || 0) + 1);
289
+ if (!langCodes.has(langName)) {
290
+ langCodes.set(langName, node.lang);
291
+ }
292
+
293
+ elements.push({
294
+ group: 'nodes',
295
+ data: {
296
+ id: node.id,
297
+ word: displayWord,
298
+ label: buildNodeLabel(node),
299
+ lang: node.lang,
300
+ langName: langName,
301
+ sense: node.sense || null,
302
+ hasSense: !!node.sense,
303
+ family: node.family || null,
304
+ branch: node.branch || null,
305
+ },
306
+ });
307
+ });
308
+
309
+ displayData.edges.forEach((edge) => {
310
+ if (edge.source === edge.target) return;
311
+ const edgeData = {
312
+ id: `${edge.source}-${edge.target}`,
313
+ source: edge.source,
314
+ target: edge.target,
315
+ };
316
+ if (edge.compound) {
317
+ edgeData.compound = true;
318
+ }
319
+ if (edge.type) {
320
+ edgeData.linkType = edge.type;
321
+ }
322
+ elements.push({
323
+ group: 'edges',
324
+ data: edgeData,
325
+ });
326
+ });
327
+
328
+ cy.elements().remove();
329
+ cy.add(elements);
330
+
331
+ const direction = getLayoutDirection();
332
+ cy.layout({
333
+ name: 'dagre',
334
+ rankDir: direction,
335
+ nodeSep: direction === 'LR' ? 40 : 30,
336
+ rankSep: direction === 'LR' ? 80 : 60,
337
+ padding: 30,
338
+ animate: false,
339
+ }).run();
340
+
341
+ cy.fit(undefined, 40);
342
+
343
+ // Apply link type colors if enabled
344
+ if (showLinkTypes) {
345
+ cy.edges().forEach(edge => {
346
+ const type = edge.data('linkType');
347
+ if (type) {
348
+ const color = getLinkTypeColor(type);
349
+ if (color) {
350
+ edge.style('line-color', color);
351
+ edge.style('target-arrow-color', color);
352
+ }
353
+ }
354
+ });
355
+ }
356
+
357
+ // Show the graph legend container
358
+ if (graphLegend) {
359
+ graphLegend.classList.remove('hidden');
360
+ }
361
+
362
+ // Update direction indicator arrow based on layout
363
+ if (directionIndicator) {
364
+ directionIndicator.classList.remove('vertical');
365
+ const arrow = directionIndicator.querySelector('.direction-arrow');
366
+ if (direction === 'TB') {
367
+ directionIndicator.classList.add('vertical');
368
+ if (arrow) arrow.textContent = '↓';
369
+ } else {
370
+ if (arrow) arrow.textContent = '→';
371
+ }
372
+ }
373
+
374
+ return { seenLangs, langCounts, langCodes };
375
+ }
frontend/js/search.js ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Search and autocomplete functionality
3
+ */
4
+
5
+ import { handleApiResponse, truncate, escapeHtml } from './utils.js';
6
+
7
+ const FETCH_DEPTH = 10;
8
+
9
+ export async function fetchEtymology(word) {
10
+ const response = await fetch(`/graph/${encodeURIComponent(word)}?depth=${FETCH_DEPTH}`);
11
+ await handleApiResponse(response, 'etymology lookup');
12
+ const data = await response.json();
13
+ return data;
14
+ }
15
+
16
+ export async function fetchRandomWord(includeCompound = true) {
17
+ const response = await fetch(`/random?include_compound=${includeCompound}`);
18
+ await handleApiResponse(response, 'random word');
19
+ const data = await response.json();
20
+ return data.word;
21
+ }
22
+
23
+ export async function fetchSuggestions(query, suggestionsEl, onSelect) {
24
+ if (query.length < 2) {
25
+ hideSuggestions(suggestionsEl);
26
+ return;
27
+ }
28
+
29
+ try {
30
+ const response = await fetch(`/search?q=${encodeURIComponent(query)}`);
31
+ if (response.status === 429) return;
32
+ if (!response.ok) return;
33
+ const data = await response.json();
34
+ showSuggestions(data.results, suggestionsEl, onSelect);
35
+ } catch (err) {
36
+ console.error('Search error:', err);
37
+ }
38
+ }
39
+
40
+ let selectedSuggestionIndex = -1;
41
+
42
+ export function showSuggestions(results, suggestionsEl, onSelect) {
43
+ if (!suggestionsEl || results.length === 0) {
44
+ hideSuggestions(suggestionsEl);
45
+ return;
46
+ }
47
+
48
+ selectedSuggestionIndex = -1;
49
+ suggestionsEl.innerHTML = results
50
+ .map(
51
+ (r, i) => `
52
+ <div class="suggestion-item" data-index="${i}" data-word="${escapeHtml(r.word)}">
53
+ <span class="suggestion-word">${escapeHtml(r.word)}</span>
54
+ ${r.sense ? `<div class="suggestion-sense">${escapeHtml(truncate(r.sense, 60))}</div>` : ''}
55
+ </div>
56
+ `
57
+ )
58
+ .join('');
59
+
60
+ suggestionsEl.classList.remove('hidden');
61
+
62
+ suggestionsEl.querySelectorAll('.suggestion-item').forEach((item) => {
63
+ item.addEventListener('click', () => onSelect(item.dataset.word));
64
+ });
65
+ }
66
+
67
+ export function hideSuggestions(suggestionsEl) {
68
+ if (suggestionsEl) {
69
+ suggestionsEl.classList.add('hidden');
70
+ suggestionsEl.innerHTML = '';
71
+ }
72
+ selectedSuggestionIndex = -1;
73
+ }
74
+
75
+ export function navigateSuggestions(suggestionsEl, direction) {
76
+ const items = suggestionsEl.querySelectorAll('.suggestion-item');
77
+ if (items.length === 0) return;
78
+
79
+ items.forEach((item) => item.classList.remove('selected'));
80
+
81
+ selectedSuggestionIndex += direction;
82
+ if (selectedSuggestionIndex < 0) selectedSuggestionIndex = items.length - 1;
83
+ if (selectedSuggestionIndex >= items.length) selectedSuggestionIndex = 0;
84
+
85
+ items[selectedSuggestionIndex].classList.add('selected');
86
+ items[selectedSuggestionIndex].scrollIntoView({ block: 'nearest' });
87
+ }
88
+
89
+ export function getSelectedSuggestion(suggestionsEl) {
90
+ if (selectedSuggestionIndex < 0) return null;
91
+ const items = suggestionsEl.querySelectorAll('.suggestion-item');
92
+ return items[selectedSuggestionIndex]?.dataset.word || null;
93
+ }
frontend/js/tree.js ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tree View Module
3
+ * Renders etymology data as a text-based tree using Unicode box-drawing characters
4
+ */
5
+
6
+ import { getLangName, escapeHtml } from './utils.js';
7
+
8
+ /**
9
+ * Build a tree structure from nodes and edges
10
+ * @param {Object[]} nodes - Array of node objects
11
+ * @param {Object[]} edges - Array of edge objects (source → target means child → parent)
12
+ * @param {string} startWord - The searched word to start from
13
+ * @param {number} maxDepth - Maximum depth to traverse
14
+ * @returns {Object|null} Tree structure or null if start node not found
15
+ */
16
+ export function buildTree(nodes, edges, startWord, maxDepth) {
17
+ if (!nodes || !edges || nodes.length === 0) return null;
18
+
19
+ // Create lookup maps
20
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
21
+
22
+ // Build child → parents adjacency (edges go child → parent)
23
+ const childToParents = new Map();
24
+ for (const edge of edges) {
25
+ if (!childToParents.has(edge.source)) {
26
+ childToParents.set(edge.source, []);
27
+ }
28
+ childToParents.get(edge.source).push(edge.target);
29
+ }
30
+
31
+ // Find the starting node (English version of searched word)
32
+ const startNodeId = `${startWord.toLowerCase()}|en`;
33
+ let startNode = nodeMap.get(startNodeId);
34
+
35
+ // Fallback: find any node with matching lexeme
36
+ if (!startNode) {
37
+ startNode = nodes.find(n =>
38
+ n.lexeme && n.lexeme.toLowerCase() === startWord.toLowerCase()
39
+ );
40
+ }
41
+
42
+ if (!startNode) return null;
43
+
44
+ // Recursive tree builder
45
+ function buildSubtree(nodeId, depth, visited) {
46
+ if (depth > maxDepth || visited.has(nodeId)) {
47
+ return null;
48
+ }
49
+
50
+ const node = nodeMap.get(nodeId);
51
+ if (!node) return null;
52
+
53
+ visited.add(nodeId);
54
+
55
+ const children = [];
56
+ const parentIds = childToParents.get(nodeId) || [];
57
+
58
+ for (const parentId of parentIds) {
59
+ const childTree = buildSubtree(parentId, depth + 1, new Set(visited));
60
+ if (childTree) {
61
+ children.push(childTree);
62
+ }
63
+ }
64
+
65
+ return {
66
+ id: nodeId,
67
+ lexeme: node.lexeme,
68
+ lang: node.lang,
69
+ langName: node.lang_name || getLangName(node.lang),
70
+ sense: node.sense,
71
+ family: node.family,
72
+ branch: node.branch,
73
+ children,
74
+ };
75
+ }
76
+
77
+ return buildSubtree(startNode.id, 0, new Set());
78
+ }
79
+
80
+ /**
81
+ * Render a tree structure as Unicode text
82
+ * @param {Object} tree - Tree structure from buildTree()
83
+ * @returns {string} HTML string of the rendered tree
84
+ */
85
+ export function renderTreeHTML(tree) {
86
+ if (!tree) return '<div class="tree-empty">No tree data available</div>';
87
+
88
+ const lines = [];
89
+
90
+ function renderNode(node, prefix, isLast, isRoot) {
91
+ // Build the connector prefix
92
+ const connector = isRoot ? '' : (isLast ? '└── ' : '├── ');
93
+ const langDisplay = node.langName || node.lang;
94
+
95
+ // Create clickable node HTML
96
+ const nodeId = `tree-node-${node.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
97
+ const senseAttr = node.sense ? ` data-sense="${escapeHtml(node.sense)}"` : '';
98
+ const familyAttr = node.family ? ` data-family="${escapeHtml(node.family)}"` : '';
99
+ const branchAttr = node.branch ? ` data-branch="${escapeHtml(node.branch)}"` : '';
100
+
101
+ const nodeHtml = `<span class="tree-node" id="${nodeId}" data-lexeme="${escapeHtml(node.lexeme)}" data-lang="${escapeHtml(node.lang)}" data-lang-name="${escapeHtml(langDisplay)}"${senseAttr}${familyAttr}${branchAttr}><span class="tree-word">${escapeHtml(node.lexeme)}</span> <span class="tree-lang">(${escapeHtml(langDisplay)})</span></span>`;
102
+
103
+ lines.push(`<div class="tree-line">${escapeHtml(prefix)}${connector}${nodeHtml}</div>`);
104
+
105
+ // Render children
106
+ const newPrefix = isRoot ? '' : (prefix + (isLast ? ' ' : '│ '));
107
+ node.children.forEach((child, i) => {
108
+ const childIsLast = i === node.children.length - 1;
109
+ renderNode(child, newPrefix, childIsLast, false);
110
+ });
111
+ }
112
+
113
+ renderNode(tree, '', true, true);
114
+
115
+ return `<div class="tree-content">${lines.join('')}</div>`;
116
+ }
117
+
118
+
frontend/js/ui.js ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * UI state management and controls
3
+ */
4
+
5
+ import { getLangName, truncate, escapeHtml } from './utils.js';
6
+ import { getCy } from './graph.js';
7
+
8
+ // Show node detail panel
9
+ export function showNodeDetail(data, elements) {
10
+ const { nodeDetail, detailWord, detailLang, detailFamily, detailSense } = elements;
11
+ if (!nodeDetail || !detailWord || !detailLang) return;
12
+
13
+ detailWord.textContent = data.word;
14
+ detailLang.textContent = data.langName || getLangName(data.lang);
15
+
16
+ if (detailFamily && detailFamily.parentElement) {
17
+ if (data.family) {
18
+ detailFamily.textContent = `${data.family}${data.branch ? ' → ' + data.branch : ''}`;
19
+ detailFamily.parentElement.classList.remove('hidden');
20
+ } else {
21
+ detailFamily.parentElement.classList.add('hidden');
22
+ }
23
+ }
24
+
25
+ if (detailSense && detailSense.parentElement) {
26
+ if (data.sense) {
27
+ detailSense.textContent = truncate(data.sense, 150);
28
+ detailSense.parentElement.classList.remove('hidden');
29
+ } else {
30
+ detailSense.parentElement.classList.add('hidden');
31
+ }
32
+ }
33
+
34
+ nodeDetail.classList.remove('hidden');
35
+ }
36
+
37
+ export function hideNodeDetail(nodeDetail) {
38
+ if (nodeDetail) nodeDetail.classList.add('hidden');
39
+ }
40
+
41
+ // State management
42
+ export function showLoading(elements) {
43
+ const { loadingEl, emptyState, errorState, wordInfo, graphOptions, statsPanel, statsToggle, graphLegend, expandBtn, treeView } = elements;
44
+ const cy = getCy();
45
+
46
+ loadingEl.classList.remove('hidden');
47
+ emptyState.classList.add('hidden');
48
+ errorState.classList.add('hidden');
49
+ wordInfo.classList.add('hidden');
50
+ if (graphOptions) graphOptions.classList.add('hidden');
51
+ if (statsPanel) statsPanel.classList.add('hidden');
52
+ if (statsToggle) statsToggle.classList.remove('active');
53
+ if (graphLegend) graphLegend.classList.add('hidden');
54
+ if (expandBtn) expandBtn.classList.add('hidden');
55
+ if (treeView) treeView.innerHTML = '';
56
+ if (cy) cy.elements().remove();
57
+ }
58
+
59
+ export function showError(message, elements, minimizeGraph, options = {}) {
60
+ const { loadingEl, emptyState, errorState, errorMessage, wordInfo, graphLegend, expandBtn } = elements;
61
+ const errorActions = document.getElementById('error-actions');
62
+
63
+ loadingEl.classList.add('hidden');
64
+ emptyState.classList.add('hidden');
65
+ errorState.classList.remove('hidden');
66
+ errorMessage.textContent = message;
67
+ wordInfo.classList.add('hidden');
68
+ if (graphLegend) graphLegend.classList.add('hidden');
69
+ if (expandBtn) expandBtn.classList.add('hidden');
70
+ minimizeGraph();
71
+
72
+ // Build action buttons
73
+ if (errorActions) {
74
+ errorActions.innerHTML = '';
75
+
76
+ // Wiktionary link (for no-etymology case)
77
+ if (options.wiktionaryWord) {
78
+ const wiktLink = document.createElement('a');
79
+ wiktLink.href = `https://en.wiktionary.org/wiki/${encodeURIComponent(options.wiktionaryWord)}`;
80
+ wiktLink.target = '_blank';
81
+ wiktLink.rel = 'noopener';
82
+ wiktLink.className = 'error-action-btn';
83
+ wiktLink.textContent = 'Look up on Wiktionary';
84
+ errorActions.appendChild(wiktLink);
85
+ }
86
+
87
+ // Report issue button
88
+ if (options.searchedWord) {
89
+ const reportLink = document.createElement('a');
90
+ const title = encodeURIComponent(`Issue with word: ${options.searchedWord}`);
91
+ const body = encodeURIComponent(
92
+ `**Word:** ${options.searchedWord}\n**Error:** ${message}\n\n**Additional context:**\n(Please describe what you expected to see)`
93
+ );
94
+ reportLink.href = `https://github.com/lucharo/etymology-for-all/issues/new?title=${title}&body=${body}`;
95
+ reportLink.target = '_blank';
96
+ reportLink.rel = 'noopener';
97
+ reportLink.className = 'error-action-btn error-action-report';
98
+ reportLink.textContent = 'Report issue with this word';
99
+ errorActions.appendChild(reportLink);
100
+ }
101
+ }
102
+ }
103
+
104
+ export function showGraph(elements) {
105
+ const { loadingEl, emptyState, errorState, expandBtn } = elements;
106
+
107
+ loadingEl.classList.add('hidden');
108
+ emptyState.classList.add('hidden');
109
+ errorState.classList.add('hidden');
110
+ if (expandBtn) expandBtn.classList.remove('hidden');
111
+ }
112
+
113
+ // Stats
114
+ export function updateStats(nodeCount, edgeCount, langCount, depth, elements) {
115
+ const { statNodes, statEdges, statLangs, statDepth } = elements;
116
+ if (statNodes) statNodes.textContent = nodeCount;
117
+ if (statEdges) statEdges.textContent = edgeCount;
118
+ if (statLangs) statLangs.textContent = langCount;
119
+ if (statDepth) statDepth.textContent = depth;
120
+ }
121
+
122
+ // Language breakdown
123
+ export function updateInfoSummary(langCounts, langCodes, langBreakdown) {
124
+ if (!langBreakdown) return;
125
+
126
+ const sorted = Array.from(langCounts.entries()).sort((a, b) => {
127
+ if (b[1] !== a[1]) return b[1] - a[1];
128
+ return a[0].localeCompare(b[0]);
129
+ });
130
+
131
+ langBreakdown.innerHTML = sorted
132
+ .map(([langName, count]) => {
133
+ const code = langCodes.get(langName) || '';
134
+ // Show code in tooltip on hover, keep UI clean
135
+ return `
136
+ <span class="lang-chip" title="${escapeHtml(code)}">
137
+ <span class="lang-chip-name">${escapeHtml(langName)}</span>
138
+ <span class="lang-chip-count">${escapeHtml(String(count))}</span>
139
+ </span>
140
+ `;
141
+ })
142
+ .join('');
143
+ }
144
+
145
+ // Depth UI
146
+ export function updateDepthUI(currentDepth, graphMaxDepth, elements) {
147
+ const { depthValue, depthMinus, depthPlus } = elements;
148
+ const MIN_DEPTH = 1;
149
+ if (depthValue) depthValue.textContent = currentDepth;
150
+ if (depthMinus) depthMinus.disabled = currentDepth <= MIN_DEPTH;
151
+ if (depthPlus) depthPlus.disabled = currentDepth >= graphMaxDepth;
152
+ }
153
+
154
+ // Expand/minimize
155
+ export function createExpandHandlers(graphContainer, graphBackdrop) {
156
+ let isExpanded = false;
157
+
158
+ function toggleExpandGraph() {
159
+ isExpanded = !isExpanded;
160
+ graphContainer.classList.toggle('expanded', isExpanded);
161
+ if (graphBackdrop) graphBackdrop.classList.toggle('visible', isExpanded);
162
+
163
+ setTimeout(() => {
164
+ const cyInstance = getCy();
165
+ if (cyInstance) {
166
+ cyInstance.resize();
167
+ cyInstance.animate({
168
+ fit: { eles: cyInstance.elements(), padding: 40 }
169
+ }, { duration: 200, easing: 'ease-out' });
170
+ }
171
+ }, 320);
172
+ }
173
+
174
+ function minimizeGraph() {
175
+ if (isExpanded) {
176
+ isExpanded = false;
177
+ graphContainer.classList.remove('expanded');
178
+ if (graphBackdrop) graphBackdrop.classList.remove('visible');
179
+
180
+ setTimeout(() => {
181
+ const cyInstance = getCy();
182
+ if (cyInstance) {
183
+ cyInstance.resize();
184
+ cyInstance.animate({
185
+ fit: { eles: cyInstance.elements(), padding: 40 }
186
+ }, { duration: 200, easing: 'ease-out' });
187
+ }
188
+ }, 320);
189
+ }
190
+ }
191
+
192
+ function getIsExpanded() {
193
+ return isExpanded;
194
+ }
195
+
196
+ return { toggleExpandGraph, minimizeGraph, getIsExpanded };
197
+ }
198
+
199
+ // Modal
200
+ export function setupModal(aboutBtn, aboutModal, aboutClose, modalBackdrop, modalTabs) {
201
+ function openAboutModal() {
202
+ if (aboutModal) aboutModal.classList.remove('hidden');
203
+ }
204
+
205
+ function closeAboutModal() {
206
+ if (aboutModal) aboutModal.classList.add('hidden');
207
+ }
208
+
209
+ function switchTab(tabName) {
210
+ modalTabs?.forEach((tab) => {
211
+ tab.classList.toggle('active', tab.dataset.tab === tabName);
212
+ });
213
+ document.querySelectorAll('.tab-content').forEach((content) => {
214
+ content.classList.toggle('active', content.id === `tab-${tabName}`);
215
+ });
216
+ }
217
+
218
+ if (aboutBtn) aboutBtn.addEventListener('click', openAboutModal);
219
+ if (aboutClose) aboutClose.addEventListener('click', closeAboutModal);
220
+ if (modalBackdrop) modalBackdrop.addEventListener('click', closeAboutModal);
221
+ modalTabs?.forEach((tab) => {
222
+ tab.addEventListener('click', () => switchTab(tab.dataset.tab));
223
+ });
224
+
225
+ return { openAboutModal, closeAboutModal };
226
+ }
frontend/js/utils.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Utility functions for Etymology Explorer
3
+ */
4
+
5
+ export function getLangName(lang) {
6
+ // API provides lang_name from 2400+ code database
7
+ // This is only called as fallback when API doesn't return lang_name
8
+ if (!lang) return 'Unknown';
9
+ return lang; // Return raw code if no name available
10
+ }
11
+
12
+ export function escapeHtml(str) {
13
+ if (!str) return '';
14
+ return str
15
+ .replace(/&/g, '&amp;')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;')
19
+ .replace(/'/g, '&#039;');
20
+ }
21
+
22
+ export function truncate(text, maxLength) {
23
+ if (!text || text.length <= maxLength) return text;
24
+ return text.slice(0, maxLength).trim() + '…';
25
+ }
26
+
27
+ export async function handleApiResponse(response, context = 'request') {
28
+ if (response.ok) return response;
29
+
30
+ if (response.status === 429) {
31
+ const retryAfter = response.headers.get('Retry-After') || '60';
32
+ throw new Error(`Too many requests. Please wait ${retryAfter} seconds and try again.`);
33
+ }
34
+ if (response.status === 404) {
35
+ throw new Error(`Word not found in the database`);
36
+ }
37
+ if (response.status >= 500) {
38
+ throw new Error(`Server is temporarily unavailable. Please try again in a moment.`);
39
+ }
40
+ throw new Error(`Failed to complete ${context}`);
41
+ }
42
+
43
+ /**
44
+ * Check if a graph API response indicates the word exists but has no etymology.
45
+ * Returns { noEtymology: true, lexeme } if so, null otherwise.
46
+ */
47
+ export function checkNoEtymology(data) {
48
+ if (data && data.no_etymology === true) {
49
+ return { noEtymology: true, lexeme: data.lexeme || '' };
50
+ }
51
+ return null;
52
+ }
53
+
54
+ export function buildNodeLabel(node) {
55
+ const langName = node.lang_name || getLangName(node.lang);
56
+ const displayWord = node.lexeme || node.id;
57
+ // Keep nodes clean: just word + language name
58
+ return displayWord + '\n(' + langName.toLowerCase() + ')';
59
+ }
frontend/styles.css ADDED
@@ -0,0 +1,1423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Reset and base styles */
2
+ *, *::before, *::after {
3
+ box-sizing: border-box;
4
+ margin: 0;
5
+ padding: 0;
6
+ }
7
+
8
+ :root {
9
+ /* Color palette - soft, muted tones */
10
+ --bg: #fafaf9;
11
+ --text: #1c1917;
12
+ --text-muted: #78716c;
13
+ --border: #e7e5e4;
14
+ --accent: #0c4a6e;
15
+ --accent-light: #e0f2fe;
16
+ --error: #dc2626;
17
+
18
+ /* Language family colors - earthy, distinct */
19
+ --lang-modern: #0284c7; /* Modern languages (en, fr, de, etc.) */
20
+ --lang-latin: #7c3aed; /* Latin and Romance */
21
+ --lang-greek: #059669; /* Greek */
22
+ --lang-germanic: #ea580c; /* Proto-Germanic */
23
+ --lang-pie: #dc2626; /* Proto-Indo-European */
24
+ --lang-semitic: #d97706; /* Semitic languages */
25
+ --lang-other: #64748b; /* Other/unknown */
26
+
27
+ /* Spacing */
28
+ --space-xs: 0.25rem;
29
+ --space-sm: 0.5rem;
30
+ --space-md: 1rem;
31
+ --space-lg: 2rem;
32
+ --space-xl: 4rem;
33
+
34
+ /* Typography */
35
+ --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
36
+ --font-serif: 'EB Garamond', Garamond, 'Times New Roman', serif;
37
+ --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
38
+ }
39
+
40
+ html {
41
+ font-size: 16px;
42
+ }
43
+
44
+ body {
45
+ font-family: var(--font-sans);
46
+ background: var(--bg);
47
+ color: var(--text);
48
+ line-height: 1.6;
49
+ min-height: 100vh;
50
+ display: flex;
51
+ flex-direction: column;
52
+ }
53
+
54
+ main {
55
+ flex: 1;
56
+ display: flex;
57
+ flex-direction: column;
58
+ padding: var(--space-sm) var(--space-lg) var(--space-lg);
59
+ max-width: 1200px;
60
+ margin: 0 auto;
61
+ width: 100%;
62
+ }
63
+
64
+ /* Header */
65
+ header {
66
+ text-align: center;
67
+ margin-bottom: var(--space-lg);
68
+ position: relative;
69
+ }
70
+
71
+ h1 {
72
+ font-size: 2rem;
73
+ font-weight: 600;
74
+ letter-spacing: -0.02em;
75
+ color: var(--text);
76
+ margin-top: 0;
77
+ }
78
+
79
+ .subtitle {
80
+ color: var(--text-muted);
81
+ font-size: 1rem;
82
+ margin-top: var(--space-xs);
83
+ }
84
+
85
+ /* Search */
86
+ .search-container {
87
+ display: flex;
88
+ gap: var(--space-sm);
89
+ max-width: 480px;
90
+ margin: 0 auto var(--space-xs);
91
+ width: 100%;
92
+ }
93
+
94
+ .search-hint {
95
+ text-align: center;
96
+ font-size: 0.8rem;
97
+ color: var(--text-muted);
98
+ margin-bottom: var(--space-xs);
99
+ }
100
+
101
+ /* Graph options */
102
+ .graph-options {
103
+ display: flex;
104
+ justify-content: center;
105
+ gap: var(--space-md);
106
+ margin-bottom: var(--space-sm);
107
+ max-width: 480px;
108
+ margin-left: auto;
109
+ margin-right: auto;
110
+ }
111
+
112
+ .depth-control {
113
+ display: flex;
114
+ align-items: center;
115
+ gap: var(--space-xs);
116
+ font-size: 0.8rem;
117
+ color: var(--text-muted);
118
+ }
119
+
120
+ .depth-label {
121
+ font-weight: 500;
122
+ margin-right: var(--space-xs);
123
+ }
124
+
125
+ .depth-btn {
126
+ width: 28px;
127
+ height: 28px;
128
+ padding: 0;
129
+ font-size: 1rem;
130
+ font-weight: 500;
131
+ line-height: 1;
132
+ border-radius: 6px;
133
+ display: flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ }
137
+
138
+ .depth-btn:disabled {
139
+ opacity: 0.4;
140
+ cursor: not-allowed;
141
+ }
142
+
143
+ .depth-value {
144
+ min-width: 1.5em;
145
+ text-align: center;
146
+ font-weight: 600;
147
+ font-size: 0.9rem;
148
+ color: var(--text);
149
+ }
150
+
151
+ /* Settings popover */
152
+ .settings-wrapper {
153
+ position: relative;
154
+ }
155
+
156
+ .settings-popover {
157
+ position: absolute;
158
+ top: calc(100% + 6px);
159
+ right: 0;
160
+ background: white;
161
+ border: 1px solid var(--border);
162
+ border-radius: 8px;
163
+ padding: var(--space-sm);
164
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
165
+ z-index: 20;
166
+ min-width: 200px;
167
+ display: flex;
168
+ flex-direction: column;
169
+ gap: var(--space-xs);
170
+ }
171
+
172
+ .settings-option {
173
+ display: flex;
174
+ align-items: center;
175
+ gap: var(--space-xs);
176
+ font-size: 0.8rem;
177
+ color: var(--text-muted);
178
+ cursor: pointer;
179
+ padding: 4px 0;
180
+ white-space: nowrap;
181
+ }
182
+
183
+ .settings-option input[type="checkbox"] {
184
+ width: 14px;
185
+ height: 14px;
186
+ cursor: pointer;
187
+ accent-color: var(--accent);
188
+ }
189
+
190
+ .settings-option:hover {
191
+ color: var(--text);
192
+ }
193
+
194
+ .search-wrapper {
195
+ flex: 1;
196
+ position: relative;
197
+ }
198
+
199
+ #word-input {
200
+ width: 100%;
201
+ padding: var(--space-sm) var(--space-md);
202
+ font-size: 1rem;
203
+ border: 1px solid var(--border);
204
+ border-radius: 8px;
205
+ background: white;
206
+ color: var(--text);
207
+ outline: none;
208
+ transition: border-color 0.2s, box-shadow 0.2s;
209
+ }
210
+
211
+ /* Autocomplete suggestions */
212
+ .suggestions {
213
+ position: absolute;
214
+ top: 100%;
215
+ left: 0;
216
+ right: 0;
217
+ background: white;
218
+ border: 1px solid var(--border);
219
+ border-top: none;
220
+ border-radius: 0 0 8px 8px;
221
+ max-height: 300px;
222
+ overflow-y: auto;
223
+ z-index: 100;
224
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
225
+ }
226
+
227
+ .suggestion-item {
228
+ padding: var(--space-sm) var(--space-md);
229
+ cursor: pointer;
230
+ border-bottom: 1px solid var(--border);
231
+ }
232
+
233
+ .suggestion-item:last-child {
234
+ border-bottom: none;
235
+ }
236
+
237
+ .suggestion-item:hover,
238
+ .suggestion-item.selected {
239
+ background: var(--accent-light);
240
+ }
241
+
242
+ .suggestion-word {
243
+ font-weight: 500;
244
+ color: var(--text);
245
+ }
246
+
247
+ .suggestion-sense {
248
+ font-size: 0.875rem;
249
+ color: var(--text-muted);
250
+ margin-top: 2px;
251
+ }
252
+
253
+ #word-input:focus {
254
+ border-color: var(--accent);
255
+ box-shadow: 0 0 0 3px var(--accent-light);
256
+ }
257
+
258
+ #word-input::placeholder {
259
+ color: var(--text-muted);
260
+ }
261
+
262
+ button {
263
+ padding: var(--space-sm) var(--space-md);
264
+ border: 1px solid var(--border);
265
+ border-radius: 8px;
266
+ background: white;
267
+ color: var(--text);
268
+ cursor: pointer;
269
+ display: flex;
270
+ align-items: center;
271
+ justify-content: center;
272
+ transition: background 0.2s, border-color 0.2s;
273
+ }
274
+
275
+ button:hover {
276
+ background: var(--bg);
277
+ border-color: var(--text-muted);
278
+ }
279
+
280
+ button:active {
281
+ transform: translateY(1px);
282
+ }
283
+
284
+ #search-btn {
285
+ background: var(--accent);
286
+ border-color: var(--accent);
287
+ color: white;
288
+ }
289
+
290
+ #search-btn:hover {
291
+ background: #0369a1;
292
+ border-color: #0369a1;
293
+ }
294
+
295
+ /* Graph container */
296
+ #graph-container {
297
+ flex: 1;
298
+ min-height: 400px;
299
+ background: white;
300
+ border: 1px solid var(--border);
301
+ border-radius: 12px;
302
+ position: relative;
303
+ overflow: hidden;
304
+ }
305
+
306
+ /* Expanded graph state */
307
+ #graph-container.expanded {
308
+ position: fixed;
309
+ top: var(--space-md);
310
+ left: var(--space-md);
311
+ right: var(--space-md);
312
+ bottom: var(--space-md);
313
+ z-index: 50;
314
+ min-height: auto;
315
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
316
+ transition: all 0.3s ease;
317
+ }
318
+
319
+ /* Expand/minimize button */
320
+ .expand-btn {
321
+ position: absolute;
322
+ top: var(--space-sm);
323
+ right: var(--space-sm);
324
+ z-index: 15;
325
+ padding: var(--space-xs);
326
+ background: white;
327
+ border: 1px solid var(--border);
328
+ border-radius: 6px;
329
+ cursor: pointer;
330
+ color: var(--text-muted);
331
+ display: flex;
332
+ align-items: center;
333
+ justify-content: center;
334
+ transition: all 0.2s;
335
+ }
336
+
337
+ .expand-btn:hover {
338
+ background: var(--bg);
339
+ color: var(--text);
340
+ border-color: var(--text-muted);
341
+ }
342
+
343
+ .expand-btn .minimize-icon {
344
+ display: none;
345
+ }
346
+
347
+ #graph-container.expanded .expand-btn .expand-icon {
348
+ display: none;
349
+ }
350
+
351
+ #graph-container.expanded .expand-btn .minimize-icon {
352
+ display: block;
353
+ }
354
+
355
+ /* Backdrop when expanded */
356
+ .graph-backdrop {
357
+ position: fixed;
358
+ inset: 0;
359
+ background: rgba(250, 250, 249, 0.9);
360
+ z-index: 40;
361
+ opacity: 0;
362
+ pointer-events: none;
363
+ transition: opacity 0.3s ease;
364
+ }
365
+
366
+ .graph-backdrop.visible {
367
+ opacity: 1;
368
+ pointer-events: auto;
369
+ }
370
+
371
+ #cy {
372
+ width: 100%;
373
+ height: 100%;
374
+ position: absolute;
375
+ top: 0;
376
+ left: 0;
377
+ }
378
+
379
+ /* Cytoscape HTML node labels */
380
+ .cy-node-html-label {
381
+ pointer-events: none;
382
+ }
383
+
384
+ .cy-node-label {
385
+ text-align: center;
386
+ padding: 8px 12px;
387
+ display: flex;
388
+ flex-direction: column;
389
+ align-items: center;
390
+ justify-content: center;
391
+ }
392
+
393
+ .node-lang {
394
+ font-family: system-ui, -apple-system, sans-serif;
395
+ font-size: 10px;
396
+ font-weight: 600;
397
+ color: #78716c;
398
+ text-transform: lowercase;
399
+ margin-bottom: 4px;
400
+ }
401
+
402
+ .node-word {
403
+ font-family: 'EB Garamond', Garamond, serif;
404
+ font-size: 18px;
405
+ font-weight: 600;
406
+ color: #1c1917;
407
+ line-height: 1.2;
408
+ }
409
+
410
+ .node-sense {
411
+ font-family: 'EB Garamond', Garamond, serif;
412
+ font-size: 11px;
413
+ font-style: italic;
414
+ color: #78716c;
415
+ margin-top: 4px;
416
+ line-height: 1.3;
417
+ max-width: 140px;
418
+ }
419
+
420
+ /* States */
421
+ #empty-state,
422
+ #error-state,
423
+ #loading {
424
+ position: absolute;
425
+ top: 50%;
426
+ left: 50%;
427
+ transform: translate(-50%, -50%);
428
+ text-align: center;
429
+ color: var(--text-muted);
430
+ }
431
+
432
+ #empty-state p,
433
+ #error-state p {
434
+ font-size: 1rem;
435
+ }
436
+
437
+ #error-state {
438
+ color: #dc2626;
439
+ }
440
+
441
+ /* Error action buttons */
442
+ .error-actions {
443
+ display: flex;
444
+ flex-wrap: wrap;
445
+ gap: var(--space-sm);
446
+ justify-content: center;
447
+ margin-top: var(--space-md);
448
+ }
449
+
450
+ .error-action-btn {
451
+ display: inline-flex;
452
+ align-items: center;
453
+ gap: var(--space-xs);
454
+ padding: var(--space-xs) var(--space-md);
455
+ font-size: 0.8rem;
456
+ border: 1px solid var(--border);
457
+ border-radius: 6px;
458
+ background: white;
459
+ color: var(--accent);
460
+ text-decoration: none;
461
+ cursor: pointer;
462
+ transition: background 0.2s, border-color 0.2s;
463
+ }
464
+
465
+ .error-action-btn:hover {
466
+ background: var(--accent-light);
467
+ border-color: var(--accent);
468
+ }
469
+
470
+ .error-action-report {
471
+ color: var(--text-muted);
472
+ }
473
+
474
+ .error-action-report:hover {
475
+ color: var(--text);
476
+ }
477
+
478
+ .hidden {
479
+ display: none !important;
480
+ }
481
+
482
+ /* Loading spinner */
483
+ #loading {
484
+ display: flex;
485
+ flex-direction: column;
486
+ align-items: center;
487
+ gap: var(--space-sm);
488
+ }
489
+
490
+ .spinner {
491
+ width: 32px;
492
+ height: 32px;
493
+ border: 3px solid var(--border);
494
+ border-top-color: var(--accent);
495
+ border-radius: 50%;
496
+ animation: spin 0.8s linear infinite;
497
+ }
498
+
499
+ @keyframes spin {
500
+ to { transform: rotate(360deg); }
501
+ }
502
+
503
+ /* Word info bar */
504
+ #word-info {
505
+ display: flex;
506
+ align-items: center;
507
+ flex-wrap: wrap;
508
+ gap: var(--space-sm) var(--space-md);
509
+ margin-top: var(--space-md);
510
+ padding: var(--space-sm) var(--space-md);
511
+ background: white;
512
+ border: 1px solid var(--border);
513
+ border-radius: 8px;
514
+ }
515
+
516
+ #current-word {
517
+ font-family: var(--font-serif);
518
+ font-size: 1.25rem;
519
+ font-weight: 600;
520
+ color: var(--text);
521
+ }
522
+
523
+ .info-divider {
524
+ width: 1px;
525
+ height: 1.25rem;
526
+ background: var(--border);
527
+ }
528
+
529
+ #lang-breakdown {
530
+ display: flex;
531
+ flex-wrap: wrap;
532
+ gap: var(--space-xs) var(--space-sm);
533
+ font-size: 0.8rem;
534
+ color: var(--text-muted);
535
+ }
536
+
537
+ .lang-chip {
538
+ display: inline-flex;
539
+ align-items: center;
540
+ gap: 4px;
541
+ padding: 2px 8px;
542
+ background: var(--bg);
543
+ border-radius: 12px;
544
+ }
545
+
546
+ .lang-chip-name {
547
+ color: var(--text);
548
+ }
549
+
550
+ .lang-chip-count {
551
+ color: var(--text-muted);
552
+ font-size: 0.75rem;
553
+ }
554
+
555
+ /* Stats toggle button */
556
+ .stats-toggle {
557
+ display: flex;
558
+ align-items: center;
559
+ gap: var(--space-xs);
560
+ margin-left: auto;
561
+ padding: var(--space-xs) var(--space-sm);
562
+ font-size: 0.75rem;
563
+ background: transparent;
564
+ border: 1px solid var(--border);
565
+ border-radius: 6px;
566
+ color: var(--text-muted);
567
+ cursor: pointer;
568
+ }
569
+
570
+ .stats-toggle:hover {
571
+ background: var(--bg);
572
+ color: var(--text);
573
+ }
574
+
575
+ .stats-toggle.active {
576
+ background: var(--accent-light);
577
+ border-color: var(--accent);
578
+ color: var(--accent);
579
+ }
580
+
581
+ /* Stats panel (below word-info) */
582
+ .stats-panel {
583
+ margin-top: var(--space-sm);
584
+ padding: var(--space-sm) var(--space-md);
585
+ background: white;
586
+ border: 1px solid var(--border);
587
+ border-radius: 8px;
588
+ }
589
+
590
+ .stats-grid {
591
+ display: flex;
592
+ justify-content: center;
593
+ gap: var(--space-lg);
594
+ }
595
+
596
+ .stat-item {
597
+ display: flex;
598
+ flex-direction: column;
599
+ align-items: center;
600
+ gap: 2px;
601
+ }
602
+
603
+ .stat-value {
604
+ font-size: 1.25rem;
605
+ font-weight: 600;
606
+ color: var(--text);
607
+ font-family: var(--font-mono);
608
+ }
609
+
610
+ .stat-label {
611
+ font-size: 0.7rem;
612
+ color: var(--text-muted);
613
+ text-transform: uppercase;
614
+ letter-spacing: 0.05em;
615
+ }
616
+
617
+
618
+ /* Node detail panel */
619
+ #node-detail {
620
+ position: absolute;
621
+ top: var(--space-md);
622
+ right: var(--space-md);
623
+ background: white;
624
+ border: 1px solid var(--border);
625
+ border-radius: 8px;
626
+ padding: var(--space-md);
627
+ min-width: 220px;
628
+ max-width: 300px;
629
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
630
+ z-index: 10;
631
+ font-family: var(--font-serif);
632
+ }
633
+
634
+ #detail-close {
635
+ position: absolute;
636
+ top: var(--space-xs);
637
+ right: var(--space-xs);
638
+ padding: var(--space-xs);
639
+ border: none;
640
+ background: transparent;
641
+ cursor: pointer;
642
+ color: var(--text-muted);
643
+ border-radius: 4px;
644
+ }
645
+
646
+ #detail-close:hover {
647
+ background: var(--bg);
648
+ color: var(--text);
649
+ }
650
+
651
+ .detail-header {
652
+ margin-bottom: var(--space-sm);
653
+ padding-right: var(--space-lg);
654
+ }
655
+
656
+ .detail-lang {
657
+ display: block;
658
+ font-size: 0.8rem;
659
+ color: var(--text-muted);
660
+ text-transform: uppercase;
661
+ letter-spacing: 0.05em;
662
+ margin-bottom: var(--space-xs);
663
+ }
664
+
665
+ .detail-word {
666
+ display: block;
667
+ font-size: 1.5rem;
668
+ font-weight: 600;
669
+ color: var(--text);
670
+ line-height: 1.2;
671
+ }
672
+
673
+ .detail-row {
674
+ display: flex;
675
+ flex-direction: column;
676
+ gap: 2px;
677
+ padding: var(--space-sm) 0;
678
+ border-top: 1px solid var(--border);
679
+ }
680
+
681
+ .detail-row .detail-label {
682
+ display: flex;
683
+ align-items: center;
684
+ gap: var(--space-xs);
685
+ font-family: var(--font-sans);
686
+ font-size: 0.7rem;
687
+ color: var(--text-muted);
688
+ text-transform: uppercase;
689
+ letter-spacing: 0.05em;
690
+ }
691
+
692
+ .detail-value {
693
+ font-size: 1rem;
694
+ color: var(--text);
695
+ }
696
+
697
+ .detail-row:last-child .detail-value {
698
+ font-style: italic;
699
+ }
700
+
701
+ /* Info tooltip button */
702
+ .info-btn {
703
+ display: inline-flex;
704
+ align-items: center;
705
+ justify-content: center;
706
+ width: 14px;
707
+ height: 14px;
708
+ border-radius: 50%;
709
+ background: var(--border);
710
+ color: var(--text-muted);
711
+ font-size: 0.6rem;
712
+ font-weight: 600;
713
+ font-family: var(--font-sans);
714
+ cursor: help;
715
+ border: none;
716
+ padding: 0;
717
+ position: relative;
718
+ }
719
+
720
+ .info-btn:hover {
721
+ background: var(--text-muted);
722
+ color: white;
723
+ }
724
+
725
+ .info-btn .tooltip {
726
+ display: none;
727
+ position: absolute;
728
+ bottom: 100%;
729
+ left: 50%;
730
+ transform: translateX(-50%);
731
+ background: var(--text);
732
+ color: white;
733
+ font-size: 0.75rem;
734
+ font-weight: 400;
735
+ text-transform: none;
736
+ letter-spacing: normal;
737
+ padding: var(--space-xs) var(--space-sm);
738
+ border-radius: 4px;
739
+ white-space: nowrap;
740
+ margin-bottom: 4px;
741
+ z-index: 20;
742
+ }
743
+
744
+ .info-btn:hover .tooltip {
745
+ display: block;
746
+ }
747
+
748
+ /* Graph legend */
749
+ .graph-legend {
750
+ position: absolute;
751
+ bottom: var(--space-md);
752
+ left: var(--space-md);
753
+ display: flex;
754
+ align-items: center;
755
+ gap: var(--space-sm);
756
+ padding: var(--space-xs) var(--space-sm);
757
+ background: rgba(255, 255, 255, 0.9);
758
+ border: 1px solid var(--border);
759
+ border-radius: 6px;
760
+ font-size: 0.7rem;
761
+ color: var(--text-muted);
762
+ z-index: 5;
763
+ backdrop-filter: blur(4px);
764
+ }
765
+
766
+ /* Direction indicator within legend */
767
+ #direction-indicator {
768
+ display: inline-flex;
769
+ align-items: center;
770
+ gap: var(--space-xs);
771
+ }
772
+
773
+ #direction-indicator.vertical {
774
+ flex-direction: column;
775
+ }
776
+
777
+ .direction-label {
778
+ font-family: var(--font-sans);
779
+ letter-spacing: 0.02em;
780
+ color: var(--text-muted);
781
+ }
782
+
783
+ .direction-arrow {
784
+ color: var(--text-muted);
785
+ font-size: 1rem;
786
+ line-height: 1;
787
+ }
788
+
789
+ /* Legend divider */
790
+ .legend-divider {
791
+ color: var(--border);
792
+ font-weight: 300;
793
+ }
794
+
795
+ /* Edge type legend */
796
+ .edge-legend {
797
+ display: flex;
798
+ align-items: center;
799
+ gap: var(--space-sm);
800
+ }
801
+
802
+ .legend-item {
803
+ display: flex;
804
+ align-items: center;
805
+ gap: 4px;
806
+ }
807
+
808
+ .legend-line {
809
+ display: inline-block;
810
+ width: 20px;
811
+ height: 2px;
812
+ vertical-align: middle;
813
+ }
814
+
815
+ .legend-line.regular {
816
+ background: #d6d3d1;
817
+ }
818
+
819
+ .legend-line.compound {
820
+ background: var(--accent);
821
+ }
822
+
823
+ .legend-line.link-inh {
824
+ background: #059669;
825
+ }
826
+
827
+ .legend-line.link-bor {
828
+ background: #d97706;
829
+ }
830
+
831
+ .legend-line.link-der {
832
+ background: #7c3aed;
833
+ }
834
+
835
+ .legend-line.link-cog {
836
+ background: #0284c7;
837
+ }
838
+
839
+ .legend-line.link-cmpd {
840
+ background: #0c4a6e;
841
+ }
842
+
843
+ /* Header buttons */
844
+ .header-buttons {
845
+ position: absolute;
846
+ top: 0.15em;
847
+ right: 0;
848
+ display: grid;
849
+ grid-template-columns: auto auto;
850
+ gap: var(--space-xs);
851
+ justify-items: end;
852
+ }
853
+
854
+ .header-buttons .settings-wrapper {
855
+ grid-column: 2;
856
+ }
857
+
858
+ .header-btn {
859
+ display: flex;
860
+ align-items: center;
861
+ justify-content: center;
862
+ gap: var(--space-xs);
863
+ padding: var(--space-xs) var(--space-sm);
864
+ font-size: 0.875rem;
865
+ background: transparent;
866
+ border: 1px solid var(--border);
867
+ border-radius: 6px;
868
+ color: var(--text-muted);
869
+ cursor: pointer;
870
+ text-decoration: none;
871
+ }
872
+
873
+ .header-btn:hover {
874
+ background: var(--bg);
875
+ color: var(--text);
876
+ }
877
+
878
+ /* Mobile menu (hidden on desktop by default) */
879
+ .mobile-only {
880
+ display: none;
881
+ }
882
+
883
+ .mobile-menu-wrapper {
884
+ position: relative;
885
+ }
886
+
887
+ #mobile-menu-btn {
888
+ min-width: 44px;
889
+ min-height: 44px;
890
+ }
891
+
892
+ .mobile-menu {
893
+ position: absolute;
894
+ top: calc(100% + 6px);
895
+ right: 0;
896
+ background: white;
897
+ border: 1px solid var(--border);
898
+ border-radius: 8px;
899
+ padding: var(--space-xs) 0;
900
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
901
+ z-index: 20;
902
+ min-width: 200px;
903
+ }
904
+
905
+ .mobile-menu-item {
906
+ display: flex;
907
+ align-items: center;
908
+ gap: var(--space-sm);
909
+ width: 100%;
910
+ padding: var(--space-sm) var(--space-md);
911
+ font-size: 0.875rem;
912
+ color: var(--text);
913
+ background: none;
914
+ border: none;
915
+ border-radius: 0;
916
+ cursor: pointer;
917
+ text-decoration: none;
918
+ text-align: left;
919
+ }
920
+
921
+ .mobile-menu-item:hover {
922
+ background: var(--bg);
923
+ }
924
+
925
+ .mobile-about-link {
926
+ color: var(--accent);
927
+ font-weight: 500;
928
+ justify-content: flex-start;
929
+ }
930
+
931
+ .mobile-external-link {
932
+ justify-content: space-between;
933
+ }
934
+
935
+ .mobile-external-link .external-icon {
936
+ color: var(--text-muted);
937
+ font-size: 0.75rem;
938
+ }
939
+
940
+ .mobile-menu-divider {
941
+ height: 1px;
942
+ background: var(--border);
943
+ margin: var(--space-xs) 0;
944
+ }
945
+
946
+ /* Modal */
947
+ .modal {
948
+ position: fixed;
949
+ inset: 0;
950
+ z-index: 100;
951
+ display: flex;
952
+ align-items: center;
953
+ justify-content: center;
954
+ padding: var(--space-md);
955
+ }
956
+
957
+ .modal-backdrop {
958
+ position: absolute;
959
+ inset: 0;
960
+ background: rgba(0, 0, 0, 0.5);
961
+ }
962
+
963
+ .modal-content {
964
+ position: relative;
965
+ background: white;
966
+ border-radius: 12px;
967
+ max-width: 600px;
968
+ max-height: 80vh;
969
+ overflow-y: auto;
970
+ padding: var(--space-lg);
971
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
972
+ }
973
+
974
+ .modal-close {
975
+ position: absolute;
976
+ top: var(--space-sm);
977
+ right: var(--space-sm);
978
+ padding: var(--space-xs);
979
+ background: transparent;
980
+ border: none;
981
+ color: var(--text-muted);
982
+ cursor: pointer;
983
+ border-radius: 4px;
984
+ }
985
+
986
+ .modal-close:hover {
987
+ background: var(--bg);
988
+ color: var(--text);
989
+ }
990
+
991
+ .modal-tabs {
992
+ display: flex;
993
+ gap: var(--space-xs);
994
+ margin-bottom: var(--space-md);
995
+ border-bottom: 1px solid var(--border);
996
+ padding-bottom: var(--space-sm);
997
+ }
998
+
999
+ .modal-tab {
1000
+ padding: var(--space-xs) var(--space-sm);
1001
+ background: transparent;
1002
+ border: none;
1003
+ color: var(--text-muted);
1004
+ cursor: pointer;
1005
+ font-size: 0.875rem;
1006
+ border-radius: 4px;
1007
+ }
1008
+
1009
+ .modal-tab:hover {
1010
+ background: var(--bg);
1011
+ }
1012
+
1013
+ .modal-tab.active {
1014
+ background: var(--accent-light);
1015
+ color: var(--accent);
1016
+ font-weight: 500;
1017
+ }
1018
+
1019
+ .tab-content {
1020
+ display: none;
1021
+ }
1022
+
1023
+ .tab-content.active {
1024
+ display: block;
1025
+ }
1026
+
1027
+ .tab-content h2 {
1028
+ font-family: var(--font-serif);
1029
+ font-size: 1.5rem;
1030
+ margin-bottom: var(--space-md);
1031
+ }
1032
+
1033
+ .tab-content h3 {
1034
+ font-size: 1rem;
1035
+ margin-top: var(--space-md);
1036
+ margin-bottom: var(--space-sm);
1037
+ color: var(--text);
1038
+ }
1039
+
1040
+ .tab-content p {
1041
+ margin-bottom: var(--space-sm);
1042
+ line-height: 1.7;
1043
+ color: var(--text);
1044
+ }
1045
+
1046
+ .tab-content .philosophy {
1047
+ font-family: var(--font-serif);
1048
+ font-size: 1.25rem;
1049
+ font-style: italic;
1050
+ margin-bottom: var(--space-md);
1051
+ }
1052
+
1053
+ .tab-content .citation {
1054
+ font-size: 0.875rem;
1055
+ color: var(--text-muted);
1056
+ font-style: italic;
1057
+ }
1058
+
1059
+ .tab-content .example {
1060
+ font-size: 0.875rem;
1061
+ color: var(--text-muted);
1062
+ background: var(--bg);
1063
+ padding: var(--space-sm) var(--space-md);
1064
+ border-radius: 6px;
1065
+ border-left: 3px solid var(--accent);
1066
+ }
1067
+
1068
+ .tab-content code {
1069
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
1070
+ background: var(--border);
1071
+ padding: 1px 5px;
1072
+ border-radius: 3px;
1073
+ font-size: 0.85em;
1074
+ }
1075
+
1076
+ .tab-content ol,
1077
+ .tab-content ul {
1078
+ margin-bottom: var(--space-sm);
1079
+ padding-left: var(--space-lg);
1080
+ line-height: 1.8;
1081
+ }
1082
+
1083
+ .tab-content li {
1084
+ margin-bottom: var(--space-xs);
1085
+ }
1086
+
1087
+ .tab-content a {
1088
+ color: var(--accent);
1089
+ }
1090
+
1091
+ /* View toggle buttons */
1092
+ .view-toggle {
1093
+ display: flex;
1094
+ align-items: center;
1095
+ gap: 0;
1096
+ }
1097
+
1098
+ .view-btn {
1099
+ padding: 2px var(--space-xs);
1100
+ font-size: 0.75rem;
1101
+ background: transparent;
1102
+ border: none;
1103
+ color: var(--text-muted);
1104
+ cursor: pointer;
1105
+ transition: color 0.15s ease;
1106
+ }
1107
+
1108
+ .view-btn:hover {
1109
+ color: var(--text);
1110
+ }
1111
+
1112
+ .view-btn.active {
1113
+ color: var(--text);
1114
+ font-weight: 600;
1115
+ }
1116
+
1117
+ .view-btn svg {
1118
+ display: none;
1119
+ }
1120
+
1121
+ .view-toggle .view-btn + .view-btn {
1122
+ border-left: 1px solid var(--border);
1123
+ }
1124
+
1125
+ /* Tree view container */
1126
+ .tree-view {
1127
+ width: 100%;
1128
+ height: 100%;
1129
+ position: absolute;
1130
+ top: 0;
1131
+ left: 0;
1132
+ overflow: auto;
1133
+ padding: var(--space-md);
1134
+ background: white;
1135
+ }
1136
+
1137
+ .tree-view.hidden {
1138
+ display: none;
1139
+ }
1140
+
1141
+ .tree-content {
1142
+ font-family: var(--font-mono);
1143
+ font-size: 0.875rem;
1144
+ line-height: 1.6;
1145
+ white-space: pre;
1146
+ }
1147
+
1148
+ .tree-line {
1149
+ display: block;
1150
+ }
1151
+
1152
+ .tree-node {
1153
+ cursor: pointer;
1154
+ padding: 2px 4px;
1155
+ border-radius: 4px;
1156
+ transition: background 0.15s ease;
1157
+ }
1158
+
1159
+ .tree-node:hover {
1160
+ background: var(--accent-light);
1161
+ }
1162
+
1163
+ .tree-word {
1164
+ font-family: var(--font-serif);
1165
+ font-weight: 600;
1166
+ color: var(--text);
1167
+ }
1168
+
1169
+ .tree-lang {
1170
+ color: var(--text-muted);
1171
+ font-family: var(--font-sans);
1172
+ font-size: 0.8em;
1173
+ }
1174
+
1175
+ .tree-empty {
1176
+ text-align: center;
1177
+ color: var(--text-muted);
1178
+ padding: var(--space-xl);
1179
+ }
1180
+
1181
+ /* Footer */
1182
+ footer {
1183
+ text-align: center;
1184
+ padding: var(--space-md);
1185
+ color: var(--text-muted);
1186
+ font-size: 0.875rem;
1187
+ }
1188
+
1189
+ footer a {
1190
+ color: var(--accent);
1191
+ text-decoration: none;
1192
+ }
1193
+
1194
+ footer a:hover {
1195
+ text-decoration: underline;
1196
+ }
1197
+
1198
+ /* Version details in footer */
1199
+ .version-details {
1200
+ margin-top: var(--space-sm);
1201
+ font-size: 0.75rem;
1202
+ color: var(--text-muted);
1203
+ }
1204
+
1205
+ .version-details summary {
1206
+ cursor: pointer;
1207
+ user-select: none;
1208
+ display: inline;
1209
+ }
1210
+
1211
+ .version-details summary:hover {
1212
+ color: var(--text);
1213
+ }
1214
+
1215
+ .version-content {
1216
+ display: flex;
1217
+ justify-content: center;
1218
+ gap: var(--space-md);
1219
+ margin-top: var(--space-xs);
1220
+ font-size: 0.75rem;
1221
+ color: var(--text-muted);
1222
+ }
1223
+
1224
+ /* Responsive */
1225
+ @media (max-width: 640px) {
1226
+ main {
1227
+ padding: var(--space-sm);
1228
+ }
1229
+
1230
+ header {
1231
+ margin-bottom: var(--space-md);
1232
+ }
1233
+
1234
+ h1 {
1235
+ font-size: 1.5rem;
1236
+ padding-right: 0;
1237
+ }
1238
+
1239
+ .subtitle {
1240
+ font-size: 0.875rem;
1241
+ }
1242
+
1243
+ .desktop-only {
1244
+ display: none !important;
1245
+ }
1246
+
1247
+ .mobile-only {
1248
+ display: block;
1249
+ }
1250
+
1251
+ .header-buttons {
1252
+ grid-template-columns: auto;
1253
+ }
1254
+
1255
+ /* Keep search on one row with compact buttons */
1256
+ .search-container {
1257
+ gap: var(--space-xs);
1258
+ margin-bottom: var(--space-xs);
1259
+ flex-wrap: nowrap;
1260
+ }
1261
+
1262
+ .search-hint {
1263
+ font-size: 0.7rem;
1264
+ margin-bottom: var(--space-xs);
1265
+ }
1266
+
1267
+ .graph-options {
1268
+ margin-bottom: var(--space-xs);
1269
+ flex-wrap: wrap;
1270
+ }
1271
+
1272
+ .view-toggle {
1273
+ order: -1;
1274
+ justify-content: center;
1275
+ margin-bottom: var(--space-xs);
1276
+ }
1277
+
1278
+ .depth-control {
1279
+ font-size: 0.75rem;
1280
+ }
1281
+
1282
+ .tree-content {
1283
+ font-size: 0.75rem;
1284
+ }
1285
+
1286
+ .depth-btn {
1287
+ width: 32px;
1288
+ height: 32px;
1289
+ }
1290
+
1291
+ #word-input {
1292
+ padding: var(--space-xs) var(--space-sm);
1293
+ font-size: 16px; /* Prevent iOS auto-zoom on focus */
1294
+ }
1295
+
1296
+ #search-btn,
1297
+ #random-btn {
1298
+ padding: var(--space-xs) var(--space-sm);
1299
+ min-width: 44px;
1300
+ }
1301
+
1302
+ #search-btn svg,
1303
+ #random-btn svg {
1304
+ width: 18px;
1305
+ height: 18px;
1306
+ }
1307
+
1308
+ /* Graph takes more space */
1309
+ #graph-container {
1310
+ min-height: 50vh;
1311
+ flex: 1;
1312
+ }
1313
+
1314
+ /* Legend vertical on mobile - top-left to avoid overlapping leaf nodes */
1315
+ .graph-legend {
1316
+ font-size: 0.6rem;
1317
+ padding: 4px 6px;
1318
+ top: var(--space-sm);
1319
+ bottom: auto;
1320
+ left: var(--space-sm);
1321
+ gap: 2px;
1322
+ flex-direction: column;
1323
+ align-items: flex-start;
1324
+ }
1325
+
1326
+ .edge-legend {
1327
+ gap: 2px;
1328
+ flex-direction: column;
1329
+ }
1330
+
1331
+ .legend-divider {
1332
+ display: none;
1333
+ }
1334
+
1335
+ .legend-line {
1336
+ width: 14px;
1337
+ }
1338
+
1339
+ /* Direction indicator vertical on mobile */
1340
+ #direction-indicator {
1341
+ flex-direction: column;
1342
+ align-items: flex-start;
1343
+ }
1344
+
1345
+ /* Detail panel full width at bottom on mobile */
1346
+ #node-detail {
1347
+ position: fixed;
1348
+ top: auto;
1349
+ bottom: 0;
1350
+ left: 0;
1351
+ right: 0;
1352
+ max-width: none;
1353
+ min-width: auto;
1354
+ border-radius: 12px 12px 0 0;
1355
+ padding: var(--space-sm) var(--space-md);
1356
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
1357
+ z-index: 55;
1358
+ }
1359
+
1360
+ .detail-word {
1361
+ font-size: 1.25rem;
1362
+ }
1363
+
1364
+ .detail-row {
1365
+ padding: var(--space-xs) 0;
1366
+ }
1367
+
1368
+ /* Info bar more compact */
1369
+ #word-info {
1370
+ padding: var(--space-xs) var(--space-sm);
1371
+ gap: var(--space-xs) var(--space-sm);
1372
+ margin-top: var(--space-sm);
1373
+ }
1374
+
1375
+ #current-word {
1376
+ font-size: 1rem;
1377
+ }
1378
+
1379
+ #lang-breakdown {
1380
+ font-size: 0.7rem;
1381
+ }
1382
+
1383
+ .lang-chip {
1384
+ padding: 1px 6px;
1385
+ }
1386
+
1387
+ /* Stats panel on mobile */
1388
+ .stats-grid {
1389
+ gap: var(--space-md);
1390
+ }
1391
+
1392
+ .stat-value {
1393
+ font-size: 1rem;
1394
+ }
1395
+
1396
+ .stat-label {
1397
+ font-size: 0.6rem;
1398
+ }
1399
+
1400
+ .stats-toggle {
1401
+ font-size: 0.7rem;
1402
+ padding: 2px 6px;
1403
+ }
1404
+
1405
+ /* Modal adjustments */
1406
+ .modal-content {
1407
+ padding: var(--space-md);
1408
+ max-height: 90vh;
1409
+ }
1410
+
1411
+ .tab-content h2 {
1412
+ font-size: 1.25rem;
1413
+ }
1414
+
1415
+ footer {
1416
+ padding: var(--space-sm);
1417
+ font-size: 0.75rem;
1418
+ }
1419
+
1420
+ .mobile-menu-wrapper.mobile-only {
1421
+ display: block;
1422
+ }
1423
+ }
pyproject.toml ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "etymology-for-all"
3
+ version = "0.11.0"
4
+ description = "Backend API for the Etymology Graph Explorer"
5
+ license = { file = "LICENSE" }
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "aiohttp>=3.13.2",
9
+ "altair>=6.0.0",
10
+ "duckdb>=1.4.0",
11
+ "fastapi>=0.116.2",
12
+ "httpx>=0.28.1",
13
+ "polars>=1.36.1",
14
+ "pyarrow>=22.0.0",
15
+ "slowapi>=0.1.9",
16
+ "uvicorn>=0.35.0",
17
+ ]
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "huggingface-hub>=1.2.3",
22
+ "locust>=2.42.6",
23
+ "marimo>=0.18.4",
24
+ "prek>=0.2.22",
25
+ "pytest>=8.4.2",
26
+ "ruff>=0.8.0",
27
+ ]
28
+
29
+ [tool.ruff]
30
+ target-version = "py310"
31
+ line-length = 100
32
+
33
+ [tool.ruff.lint]
34
+ select = [
35
+ "E", # pycodestyle errors
36
+ "W", # pycodestyle warnings
37
+ "F", # pyflakes
38
+ "I", # isort
39
+ "UP", # pyupgrade
40
+ "B", # flake8-bugbear
41
+ "SIM", # flake8-simplify
42
+ ]
43
+ ignore = [
44
+ "E501", # line too long (handled by formatter)
45
+ ]
46
+
47
+ [tool.ruff.format]
48
+ quote-style = "double"
uv.lock ADDED
The diff for this file is too large to render. See raw diff