Commit ·
13812dc
0
Parent(s):
Super-squash branch 'main' using huggingface_hub
Browse filesCo-authored-by: Luis Chaves Rodriguez <Luis Chaves Rodriguez@users.noreply.huggingface.co>
- .gitattributes +2 -0
- Dockerfile +51 -0
- README.md +90 -0
- backend/__init__.py +0 -0
- backend/data/etymdb.duckdb.zst +3 -0
- backend/database.py +357 -0
- backend/download_data.py +52 -0
- backend/download_language_codes.py +80 -0
- backend/enrich_definitions.py +228 -0
- backend/ingest.py +112 -0
- backend/main.py +108 -0
- backend/sql/enrichment/check_table_exists.sql +3 -0
- backend/sql/enrichment/create_definition_indexes.sql +2 -0
- backend/sql/enrichment/enrichment_stats.sql +3 -0
- backend/sql/enrichment/materialize_definitions.sql +36 -0
- backend/sql/enrichment/words_to_enrich.sql +5 -0
- backend/sql/enrichment/words_to_enrich_initial.sql +3 -0
- backend/sql/ingestion/01_drop_tables.sql +3 -0
- backend/sql/ingestion/02_create_words.sql +13 -0
- backend/sql/ingestion/03_create_links.sql +10 -0
- backend/sql/ingestion/04_create_sequences.sql +5 -0
- backend/sql/ingestion/05_create_indexes.sql +6 -0
- backend/sql/ingestion/06_create_macros.sql +11 -0
- backend/sql/ingestion/07_create_views.sql +23 -0
- backend/sql/ingestion/08_create_language_families.sql +8 -0
- backend/sql/ingestion/09_create_definitions_raw.sql +6 -0
- backend/sql/queries/find_start_word.sql +10 -0
- backend/sql/queries/get_language_families.sql +2 -0
- backend/sql/queries/get_language_info.sql +3 -0
- backend/sql/queries/search_words.sql +15 -0
- backend/sql/queries/traverse_etymology.sql +42 -0
- backend/sql_loader.py +19 -0
- cloudflare-worker/worker.js +7 -0
- cloudflare-worker/wrangler.toml +8 -0
- frontend/index.html +456 -0
- frontend/js/app.js +600 -0
- frontend/js/graph.js +375 -0
- frontend/js/search.js +93 -0
- frontend/js/tree.js +118 -0
- frontend/js/ui.js +226 -0
- frontend/js/utils.js +59 -0
- frontend/styles.css +1423 -0
- pyproject.toml +48 -0
- 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 ›</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;">🎲</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;">🎲</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, '&')
|
| 16 |
+
.replace(/</g, '<')
|
| 17 |
+
.replace(/>/g, '>')
|
| 18 |
+
.replace(/"/g, '"')
|
| 19 |
+
.replace(/'/g, ''');
|
| 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
|
|
|